diff --git a/docs/plan/derived-blobs.md b/docs/plan/derived-blobs.md
index f96b9bdf..e35f67ce 100644
--- a/docs/plan/derived-blobs.md
+++ b/docs/plan/derived-blobs.md
@@ -956,8 +956,8 @@ Findings each job reports today, and where the new tables land:
| 6 | `storage.blobs.ref_count` | recompute | `refcount_mismatch` | ✓ chunk level only |
| 7 | `chunk_manifests.ref_count` | recompute | `refcount_mismatch` (manifests_consistency) | ✓ manifest level |
| 8 | manifest orphan reaping | GC predicate | registry `NOT EXISTS` union, no `ref_count` | ✓ |
-| 9 | derived/attached → Blob | dangling | — | ✗ new check needed |
-| 10 | `content_derived_blobs.source_hash` → Blob | orphan mapping | — | ✗ new check needed |
+| 9 | derived/attached → Blob | dangling | `derived_dangling_blob`, `attached_dangling_blob` (satellites_consistency) | ✓ |
+| 10 | `content_derived_blobs.source_hash` → Blob | orphan mapping | `derived_orphan_mapping` (satellites_consistency) | ✓ |
| 11 | chunk at `ref_count = 0` past grace, still present | GC lag | — | ✗ a stalled GC is silent |
| 12 | `blob_extracted_text`, `faces.faces` orphans | dependents | search worker self-janitors; `faces` unverified | ~ verify |
| 13 | `file_attached_blobs.file_id` → `files` | dangling | FK `ON DELETE CASCADE` | ✓ DB-enforced |
diff --git a/docs/plan/jobs-handling-recoverable-error.md b/docs/plan/jobs-handling-recoverable-error.md
index 3337c0d4..16b0902b 100644
--- a/docs/plan/jobs-handling-recoverable-error.md
+++ b/docs/plan/jobs-handling-recoverable-error.md
@@ -1,7 +1,60 @@
# Recoverable errors in jobs — retry, then pause
-**Status: not started.** Design settled 2026-08-31, from a live
-diagnosis (see [Motivating incident](#motivating-incident)).
+**Status: implemented 2026-09-08**, except the online-migration
+follow-up in [Where this should end up](#where-this-should-end-up),
+which remains deliberately out of scope. Design settled 2026-08-31,
+from a live diagnosis (see
+[Motivating incident](#motivating-incident)).
+
+Steps 1–4 are in, and §Testing is enforced by
+`tests/api/backend_migration_blackhole.hurl`. Validated by hand against
+a real S3 endpoint in both directions, with the network blackholed
+mid-run: source-probe failure, target-upload failure and target-init
+failure all reach `Paused` with the cause recorded and the cursor
+positioned so nothing is skipped.
+
+Two things the design did not anticipate, both found during
+implementation and worth reading before touching this area:
+
+* **Classification was not enough on its own.** A backend that *fails*
+ was the case this plan modelled. A backend that never *answers* has
+ no error to classify, so no amount of retry policy sees it. The S3
+ client was built from a bare `config::Builder::new()` and carried no
+ `TimeoutConfig` at all, which also meant `SdkError::TimeoutError` —
+ an arm the classifier already handled — was unreachable in
+ production. Fixed with `TimeoutBlobBackend` (innermost, below retry)
+ plus the SDK's own connect/read timeouts and stalled-stream
+ protection. See that module's docs for why the bound lives in the
+ chain rather than being configured per SDK.
+
+* **`NotFound` was returned for every read failure.** Nine sites across
+ S3, Azure and local. `blob_exists` was among them, and it is the
+ migration's FIRST probe of the source — so a transient outage read as
+ "blob absent", took the permanent branch, advanced the cursor past
+ the row and could reach `finish_completed` with the pointer flipped
+ to a target missing everything the outage covered. A migration
+ reporting success having silently dropped whatever was unreachable.
+ That path is why the retry-then-pause policy alone would not have
+ been sufficient.
+
+Reporting bugs surfaced by the same testing, all fixed: a paused run
+logged `outcome="ok"`, a resumed run inherited the previous attempt's
+`error_message` through to `Completed`, and four of five migration
+counters reset per segment while `scanned_count` alone was cumulative.
+
+Known remaining, none of them blocking:
+
+* The online-migration shape below (the gate is still held for the
+ whole copy, so a paused migration freezes writes until an operator
+ resumes or cancels).
+* Per-backend SDK retry tuning — see [Scope](#scope-azure-and-s3-both).
+ Two retry layers currently stack multiplicatively, so the configured
+ retry count is not the effective one and detection takes ~2min rather
+ than ~30s.
+* `scanned_count` over-reported once (2522 against 2022 rows) on a run
+ with several pause/resume cycles. Not reproduced in five runs since;
+ now observable rather than inferable, because counters are persisted
+ per batch.
A job that hits a failing backend today has two possible endings, and
neither is right for an outage: it fails the run (throwing away a
@@ -90,7 +143,7 @@ implementation possible, and it is step 1.
---
-## Step 1 — errors say whether they are retryable
+## Step 1 — errors say whether they are retryable — **DONE**
Today both backends wrap SDK errors into
`DomainError::internal_error("Azure", format!("…{e}"))`, so the status
@@ -120,7 +173,7 @@ own backoff, so a second layer above it multiplies. Check what the S3
backend inherits before adding anything, and consider making the
engine's cap the *outer* bound with SDK retries reduced or disabled.
-## Step 2 — an outcome the engine can act on
+## Step 2 — an outcome the engine can act on — **DONE**
`RunOutcome` grows a variant meaning "the environment failed, this is
worth trying again later":
@@ -143,7 +196,7 @@ differs is `error_message`, which must let the panel — and an operator —
tell "I paused this" from "the provider went down". Without that
distinction a paused run is an unexplained one.
-## Step 3 — the engine implements the policy
+## Step 3 — the engine implements the policy — **DONE**
In `run_or_resume`:
@@ -159,7 +212,7 @@ Handlers then return the retryable outcome and get the policy for free.
---
-## Step 4 — `migration_readonly`, the sharp edge
+## Step 4 — `migration_readonly`, the sharp edge — **DONE** (conservative option; gate still held while paused, confirmed as intended)
`backend_migration` holds a gate that refuses writes **application-wide**
until cutover. What happens to it on pause is a correctness question,
@@ -238,7 +291,36 @@ SDK retry tuning stays a separate, optional refinement — and for Azure
specifically it should wait for the official SDK, since `azure_core`
0.21 is archived and queued for replacement.
-## Testing
+## Testing — **DONE**
+
+Implemented as `tests/api/backend_migration_blackhole.hurl`, though not
+the way this section anticipated. Two departures worth recording:
+
+**The fixture is an unreachable address, not Azurite.** Azurite's
+deterministic CRC64 500 is a *failure*, and failures were never the
+hard case — they surface, get classified and retry. The case that hung
+is a peer that never answers, so the entry points at `192.0.2.1`
+(TEST-NET-1, RFC 5737, guaranteed unrouted): a SYN goes unanswered,
+with no RST and no ICMP. Note the existing `s3_stub` entry
+(`127.0.0.1:9999`) is NOT usable for this — nothing listens, so the
+connection is refused instantly, and a test built on it would pass with
+no timeout configured anywhere.
+
+**The bound is a polling budget, not a request duration.**
+`backend_migration` is a detached job: the trigger returns 202 in
+milliseconds regardless of how long the backend hangs, so timing the
+trigger proves nothing. The file polls `/runs` with `retry: 60,
+retry-interval: 2000` — 120s, three orders of magnitude below the
+unbounded socket's ~15min. That budget is the assertion; every other
+assert in the file would eventually pass even unbounded.
+
+It targets the **target-init** failure deliberately, because that path
+pauses BEFORE `migration_readonly` is engaged and so cannot leave the
+shared suite's server read-only. It also cancels its own run: a Paused
+row left behind would be resumed by the next `backend_migration`
+trigger in the suite.
+
+The original notes follow.
The `backend_consistency_azure.hurl` scenario and its Azurite service
are already wired (`tests/common/docker-compose.test.yml`,
diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts
index 200112b7..4758bf7c 100644
--- a/frontend/src/lib/api/types.ts
+++ b/frontend/src/lib/api/types.ts
@@ -597,9 +597,33 @@ export interface FolderAncestorsResponse {
* discriminant is the `outcome` field, not the object key.
*/
export type JobOutcome =
- | { outcome: 'ok'; count: number; extra?: unknown }
+ | { outcome: 'ok'; count: number; extra?: JobOutcomeExtra }
| { outcome: 'err'; message: string };
+/**
+ * The parts of a job outcome's free-form `extra` the panel reads.
+ *
+ * Deliberately narrow — most keys are per-job counters nothing generic
+ * should switch on. These three describe the RUN's shape rather than
+ * its work, and the panel has to render them:
+ *
+ * A run that stopped because the backend was unreachable reports
+ * `outcome: 'ok'` — it did not fail, it paused and can be resumed. Read
+ * alone that renders as a green "ok" pill, which is exactly wrong: a
+ * paused `backend_migration` still holds `migration_readonly` and is
+ * refusing writes application-wide. `retryable` is what lets the row
+ * say so.
+ */
+export interface JobOutcomeExtra {
+ /** The run stopped at its cursor and can be resumed. */
+ paused?: boolean;
+ /** It stopped because the ENVIRONMENT failed, not because an
+ * operator asked — `reason` says what. */
+ retryable?: boolean;
+ reason?: string;
+ [key: string]: unknown;
+}
+
/**
* `JobSummary` — one row per registered job in `GET /api/admin/jobs`.
* Cadence + last-run bookkeeping. `interval_ms` / `next_run_at` are
@@ -694,6 +718,14 @@ export interface JobSummary {
* for this job. Distinct from `running` — a paused run is
* resumable via the same trigger endpoint. */
paused_run?: PausedRunBrief;
+ /** Status of this job's most recent run row (recoverable jobs only).
+ *
+ * **Prefer this over `last_outcome` wherever they could disagree.**
+ * `last_outcome` is the backend's in-memory record of the last
+ * dispatch, so anything that changes a run row without running the
+ * handler leaves it stale — cancelling a Paused run is a direct SQL
+ * flip, and the panel went on rendering the pause it replaced. */
+ last_run_status?: RunStatus;
/** Present iff `OXICLOUD_STARTUP_JOBS` names this job — the flags it
* is dispatched with at every boot. Worth showing: a job configured
* with `repair: true` deletes on every restart, and the row would
diff --git a/frontend/src/lib/components/AdminJobsPanel.svelte b/frontend/src/lib/components/AdminJobsPanel.svelte
index 7b0a0770..d30f1367 100644
--- a/frontend/src/lib/components/AdminJobsPanel.svelte
+++ b/frontend/src/lib/components/AdminJobsPanel.svelte
@@ -516,8 +516,58 @@
}
}
+ // A run that stopped because the backend was unreachable, rather than
+ // because an operator asked it to stop.
+ //
+ // It reports `outcome: 'ok'` on the wire — correctly, since it did
+ // not fail and a Resume continues it — but rendering that as a plain
+ // green "ok" hides the thing worth acting on. A paused
+ // `backend_migration` is still holding `migration_readonly` and
+ // refusing writes across the whole app; the row has to say so.
+ /// Lifecycle label for the State column. Lower-cased to match the
+ /// existing `running` pill rather than shouting the DB's PascalCase.
+ function runStatusLabel(status: RunStatus): string {
+ switch (status) {
+ case 'Running':
+ return t('admin.jobs.state_running', 'running');
+ case 'Paused':
+ return t('admin.jobs.state_paused', 'paused');
+ case 'CancelRequested':
+ return t('admin.jobs.state_cancelling', 'cancelling');
+ case 'Cancelled':
+ return t('admin.jobs.state_cancelled', 'cancelled');
+ case 'Completed':
+ return t('admin.jobs.state_completed', 'completed');
+ case 'Failed':
+ return t('admin.jobs.state_failed', 'failed');
+ }
+ }
+
+ function backendFailureReason(job: JobSummary): string | undefined {
+ if (job.last_outcome?.outcome !== 'ok') return undefined;
+ const reason = job.last_outcome.extra?.reason;
+ return typeof reason === 'string' ? reason : undefined;
+ }
+
+ /// How the last dispatch turned out. NOT where the run is in its
+ /// lifecycle — that is the State column, driven by
+ /// `last_run_status`. A paused run legitimately has no outcome yet,
+ /// and saying so is the honest answer.
function outcomeLabel(job: JobSummary): string {
- if (!job.last_outcome) return t('admin.jobs.never', 'never');
+ if (!job.last_outcome) {
+ // "never" means never ran. A job with a run row DID run — the
+ // outcome simply is not in memory, because `last_outcome` is
+ // populated per dispatch and a restart empties it. Saying
+ // "never" there is a lie the run history immediately
+ // contradicts: Ed saw it on a job whose last run was 8h ago.
+ //
+ // "—" is the honest answer: no outcome recorded. The State
+ // column still shows what the run did, and the drawer has
+ // the history.
+ return job.last_run_status
+ ? t('admin.jobs.outcome_unknown', '—')
+ : t('admin.jobs.never', 'never');
+ }
if (job.last_outcome.outcome === 'ok') {
// `ok` on the wire = dispatch completed. If any actionable
// findings surfaced, we flip to "issues" (amber). If only
@@ -939,11 +989,26 @@
{/if}
+
{#if isRunning(job)}
{t('admin.jobs.state_running', 'running')}
+ {:else if job.last_run_status}
+
+
+ {runStatusLabel(job.last_run_status)}
+
{:else}
—
{/if}
diff --git a/src/common/config.rs b/src/common/config.rs
index c240fba1..f006fb99 100644
--- a/src/common/config.rs
+++ b/src/common/config.rs
@@ -2,6 +2,8 @@ use std::env;
use std::path::PathBuf;
use std::time::Duration;
+use crate::infrastructure::services::timeout_blob_backend::TimeoutPolicy;
+
/// Cache configuration
#[derive(Debug, Clone)]
pub struct CacheConfig {
@@ -268,6 +270,9 @@ pub struct StorageConfig {
pub encryption: EncryptionConfig,
/// Retry policy for remote backends.
pub retry: RetryConfig,
+ /// Wall-clock bounds on backend calls, so a stalled endpoint
+ /// surfaces as a transient error instead of hanging indefinitely.
+ pub timeout: TimeoutPolicy,
}
/// Which blob storage backend to use.
@@ -1295,6 +1300,7 @@ impl Default for StorageConfig {
cache: BlobCacheConfig::default(),
encryption: EncryptionConfig::default(),
retry: RetryConfig::default(),
+ timeout: TimeoutPolicy::default(),
}
}
}
@@ -3722,6 +3728,29 @@ impl AppConfig {
config.storage.retry.backoff_multiplier = n;
}
+ // Backend call timeouts. `0` means "unbounded" for that class,
+ // which is the default for writes — see `TimeoutPolicy`.
+ for (var, slot) in [
+ (
+ "OXICLOUD_STORAGE_TIMEOUT_METADATA_MS",
+ &mut config.storage.timeout.metadata,
+ ),
+ (
+ "OXICLOUD_STORAGE_TIMEOUT_OPEN_MS",
+ &mut config.storage.timeout.open,
+ ),
+ (
+ "OXICLOUD_STORAGE_TIMEOUT_WRITE_MS",
+ &mut config.storage.timeout.write,
+ ),
+ ] {
+ if let Ok(v) = env::var(var)
+ && let Ok(n) = v.parse::()
+ {
+ *slot = (n > 0).then(|| std::time::Duration::from_millis(n));
+ }
+ }
+
// OIDC configuration
if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") {
config.oidc.enabled = v.parse::().unwrap_or(false);
diff --git a/src/common/di.rs b/src/common/di.rs
index fe88a3fa..d65377de 100644
--- a/src/common/di.rs
+++ b/src/common/di.rs
@@ -329,7 +329,8 @@ impl AppServiceFactory {
// just before the struct init.
let active_backend_name = Arc::new(std::sync::RwLock::new(active_backend_name));
- // Stack decorators: retry → encryption → cache (inner-to-outer).
+ // Stack decorators: timeout → retry → encryption → cache
+ // (inner-to-outer).
//
// Encryption is applied INSIDE build_entry_backend (per-entry
// key), so it's already on the base returned above when the
@@ -343,6 +344,32 @@ impl AppServiceFactory {
// gates the "remote-only" decorators the same as before.
let mut blob_backend: Arc = base_backend;
+ // Timeout decorator — INNERMOST, and applied to every backend
+ // kind including Local.
+ //
+ // It has to sit below retry: a call that never returns produces
+ // no error, so retry has nothing to react to and the job never
+ // pauses. Converting the hang into a transient error first is
+ // what gives every layer above it something to act on.
+ //
+ // Unconditional by design. Retry is gated on "not Local" because
+ // the kernel already retries local I/O, but a bound that never
+ // fires is free, and keeping the chain uniform avoids a class of
+ // backend-specific surprise.
+ {
+ use crate::infrastructure::services::timeout_blob_backend::TimeoutBlobBackend;
+ let policy = self.config.storage.timeout.clone();
+ if policy.is_enabled() {
+ blob_backend = Arc::new(TimeoutBlobBackend::new(blob_backend, policy.clone()));
+ tracing::info!(
+ metadata_ms = policy.metadata.map(|d| d.as_millis() as u64),
+ open_ms = policy.open.map(|d| d.as_millis() as u64),
+ write_ms = policy.write.map(|d| d.as_millis() as u64),
+ "Blob storage timeout decorator enabled"
+ );
+ }
+ }
+
// Retry decorator (for remote backends)
if self.config.storage.retry.enabled && active_backend_kind != StorageBackendType::Local {
use crate::infrastructure::services::retry_blob_backend::{
diff --git a/src/domain/errors.rs b/src/domain/errors.rs
index 0d6f2f44..540f1a71 100644
--- a/src/domain/errors.rs
+++ b/src/domain/errors.rs
@@ -23,6 +23,30 @@ pub enum ErrorKind {
AccessDenied,
/// Timeout expired
Timeout,
+ /// A dependency failed in a way that may clear on its own — an HTTP
+ /// 5xx or 429 from object storage, a connection reset, a DNS
+ /// failure.
+ ///
+ /// Distinct from [`ErrorKind::InternalError`] because the engine has
+ /// to tell "the provider is down" from "this data is wrong": the
+ /// first is worth retrying and then pausing so an operator can
+ /// resume, the second is terminal. Flattening both into
+ /// `InternalError` is what forced `RetryBlobBackend` to classify by
+ /// string-matching `Display` output — fragile in exactly the way
+ /// that turns an SDK's cosmetic reformat into a silent behaviour
+ /// change.
+ ///
+ /// **Set it deliberately, at the point where the status code is
+ /// still visible** — the port wrapping the SDK error. By the time an
+ /// error reaches the engine, the code survives only inside a
+ /// formatted string.
+ ///
+ /// Not a promise that a retry succeeds. A deterministic 500 (Azurite
+ /// answering the CRC64 ranged GET) is a permanent fault wearing a
+ /// retryable status code, which no status-based taxonomy can get
+ /// right — the bounded attempt cap is the safety net for exactly
+ /// that. See `docs/plan/jobs-handling-recoverable-error.md`.
+ TransientBackend,
/// Internal system error
InternalError,
/// Functionality not implemented
@@ -58,6 +82,10 @@ impl ErrorKind {
ErrorKind::InvalidInput => "Invalid Input",
ErrorKind::AccessDenied => "Access Denied",
ErrorKind::Timeout => "Timeout",
+ // Wire value — the SPA switches on `error_type`, so this
+ // string is a contract. Additive here; nothing keys off it
+ // yet.
+ ErrorKind::TransientBackend => "Transient Backend",
ErrorKind::InternalError => "Internal Error",
ErrorKind::NotImplemented => "Not Implemented",
ErrorKind::UnsupportedOperation => "Unsupported Operation",
@@ -148,6 +176,36 @@ impl DomainError {
}
}
+ /// A dependency failed in a way that may clear on its own. See
+ /// [`ErrorKind::TransientBackend`] for what qualifies and why the
+ /// classification belongs at the port rather than downstream.
+ pub fn transient_backend>(entity_type: &'static str, message: S) -> Self {
+ Self {
+ kind: ErrorKind::TransientBackend,
+ entity_type,
+ entity_id: None,
+ message: message.into(),
+ source: None,
+ }
+ }
+
+ /// Whether retrying this operation could plausibly succeed.
+ ///
+ /// The single place that answers the question, so a retry decorator
+ /// and the job engine cannot disagree about the same error — they
+ /// did while the answer was `Display` string-matching in one of
+ /// them and nothing in the other.
+ ///
+ /// `Timeout` is included because it is transient by construction;
+ /// everything else must say so explicitly via
+ /// [`ErrorKind::TransientBackend`]. Defaulting to "not retryable" is
+ /// the safe direction: a missed retry surfaces as a visible failure,
+ /// whereas retrying a permanent fault burns attempts and, in the
+ /// job engine, holds `migration_readonly` while it does.
+ pub fn is_transient(&self) -> bool {
+ matches!(self.kind, ErrorKind::Timeout | ErrorKind::TransientBackend)
+ }
+
/// Creates an internal error
pub fn internal_error>(entity_type: &'static str, message: S) -> Self {
Self {
@@ -317,3 +375,41 @@ impl From for DomainError {
}
}
}
+
+#[cfg(test)]
+mod transient_tests {
+ use super::*;
+
+ /// The retry decorator and the job engine both branch on this, so
+ /// the set has to be deliberate rather than incidental.
+ #[test]
+ fn only_timeout_and_transient_backend_are_retryable() {
+ assert!(DomainError::transient_backend("S3", "503").is_transient());
+ assert!(DomainError::timeout("S3", "read timed out").is_transient());
+
+ // Everything else defaults to permanent. Retrying a genuine
+ // fault burns attempts and, in the job engine, holds
+ // `migration_readonly` while it does — so the default has to be
+ // "no".
+ for e in [
+ DomainError::internal_error("S3", "decode failed"),
+ DomainError::new(ErrorKind::NotFound, "Blob", "missing"),
+ DomainError::new(ErrorKind::AccessDenied, "S3", "bad credentials"),
+ DomainError::new(ErrorKind::InvalidInput, "S3", "malformed key"),
+ DomainError::new(ErrorKind::UnsupportedOperation, "S3", "no enumeration"),
+ ] {
+ assert!(
+ !e.is_transient(),
+ "{:?} must not be retryable by default",
+ e.kind
+ );
+ }
+ }
+
+ /// `error_type` is a wire contract the SPA switches on, so this
+ /// string is not free to churn.
+ #[test]
+ fn transient_backend_has_a_stable_wire_name() {
+ assert_eq!(ErrorKind::TransientBackend.as_str(), "Transient Backend");
+ }
+}
diff --git a/src/infrastructure/scheduler/engine.rs b/src/infrastructure/scheduler/engine.rs
index 58dc0b5c..88e18cf4 100644
--- a/src/infrastructure/scheduler/engine.rs
+++ b/src/infrastructure/scheduler/engine.rs
@@ -275,6 +275,30 @@ fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option, elapse
// structured log renderer to project the `elapsed_ms` field.
let elapsed = format_elapsed(elapsed_ms);
match outcome {
+ // A paused run is carried as `Ok` — the handler did its job and
+ // stopped cleanly at a checkpoint — but logging it as `ok` says
+ // the opposite of what an operator needs to know: the migration
+ // is blocked and will not progress until the backend returns.
+ // Same distinction the admin panel draws between a run's STATE
+ // and its OUTCOME; this line only ever showed the outcome.
+ JobOutcome::Ok { count, extra }
+ if extra.get("paused") == Some(&serde_json::Value::Bool(true)) =>
+ {
+ tracing::warn!(
+ target: "oxicloud::scheduler",
+ event = "job.run",
+ job = %name,
+ outcome = "paused",
+ retryable = extra.get("retryable") == Some(&serde_json::Value::Bool(true)),
+ count = *count,
+ elapsed_ms = elapsed_ms,
+ extra = %extra,
+ "job {} PAUSED after {} — count={} (resume when the cause clears)",
+ name,
+ elapsed,
+ count,
+ );
+ }
JobOutcome::Ok { count, extra } => {
tracing::info!(
target: "oxicloud::scheduler",
diff --git a/src/infrastructure/scheduler/mod.rs b/src/infrastructure/scheduler/mod.rs
index a78b9310..f1077050 100644
--- a/src/infrastructure/scheduler/mod.rs
+++ b/src/infrastructure/scheduler/mod.rs
@@ -33,9 +33,9 @@ pub use engine::SchedulerEngine;
pub use handler::JobHandler;
pub use pg_job_store::{PgJobStore, PgJobStoreProvider};
pub use recoverable::{
- Finding, JobStore, JobStoreProvider, OpenedRun, ProgressKind, RecoverableAdapter,
- RecoverableJobHandler, RunOutcome, RunProgress, RunStatus, RunSummary, derive_progress,
- record_or_log, run_or_resume,
+ CANCEL_INTENT_PARAM, CANCEL_INTENT_TERMINATE, Finding, JobStore, JobStoreProvider, OpenedRun,
+ ProgressKind, RecoverableAdapter, RecoverableJobHandler, RunOutcome, RunProgress, RunStatus,
+ RunSummary, derive_progress, record_or_log, run_or_resume,
};
pub use registry::{
JobEntry, JobRegistry, JobSummary, PausedRunBrief, RegisterError, StartupTrigger,
diff --git a/src/infrastructure/scheduler/pg_job_store.rs b/src/infrastructure/scheduler/pg_job_store.rs
index 2f6ee98c..8e06e306 100644
--- a/src/infrastructure/scheduler/pg_job_store.rs
+++ b/src/infrastructure/scheduler/pg_job_store.rs
@@ -229,19 +229,22 @@ impl JobStore for PgJobStore {
Ok(row.and_then(|(v,)| v))
}
- async fn scanned_count(&self) -> Result {
- // `(stats->>'scanned_count')::BIGINT` — text cast rather than
- // `->` numeric extraction because the stored value has been
- // written via `((...)::text)::jsonb` in `checkpoint`, which
- // may present as either a JSON number or a JSON string
- // depending on prior versions. `::BIGINT` handles both.
+ // `(stats ->> $2)::BIGINT` — text extraction then cast, rather
+ // than `->` numeric extraction, because the stored value has been
+ // written via `((...)::text)::jsonb` in `checkpoint` and may
+ // present as either a JSON number or a JSON string depending on
+ // prior versions. `::BIGINT` handles both.
+ //
+ // `scanned_count()` is the trait's default wrapper around this.
+ async fn stat_u64(&self, key: &str) -> Result {
let row: Option<(Option,)> = sqlx::query_as(
- "SELECT (stats ->> 'scanned_count')::BIGINT FROM jobs.recoverable_runs WHERE id = $1",
+ "SELECT (stats ->> $2)::BIGINT FROM jobs.recoverable_runs WHERE id = $1",
)
.bind(self.run_id)
+ .bind(key)
.fetch_optional(self.pool.as_ref())
.await
- .map_err(|e| map_sqlx_err("scanned_count", e))?;
+ .map_err(|e| map_sqlx_err("stat_u64", e))?;
Ok(row.and_then(|(v,)| v).unwrap_or(0).max(0) as u64)
}
@@ -327,6 +330,56 @@ impl JobStore for PgJobStore {
Ok(())
}
+ async fn mark_paused_retryable(
+ &self,
+ cursor: Option>,
+ reason: &str,
+ ) -> Result<(), DomainError> {
+ // `status = 'Paused'`, so resume is the same operation an
+ // operator pause produces — the only difference is that
+ // `error_message` is populated, which is what lets the panel say
+ // WHY it stopped. `completed_at` stays NULL: the run is not
+ // over.
+ //
+ // One statement per cursor shape, matching `mark_paused`: a
+ // COALESCE would overwrite a real cursor with NULL when the
+ // handler had not advanced since the last checkpoint.
+ if let Some(c) = cursor {
+ sqlx::query(
+ r#"
+ UPDATE jobs.recoverable_runs
+ SET status = 'Paused',
+ cursor = $2,
+ error_message = $3,
+ last_progress_at = NOW()
+ WHERE id = $1
+ "#,
+ )
+ .bind(self.run_id)
+ .bind(&c[..])
+ .bind(reason)
+ .execute(self.pool.as_ref())
+ .await
+ .map_err(|e| map_sqlx_err("mark_paused_retryable", e))?;
+ } else {
+ sqlx::query(
+ r#"
+ UPDATE jobs.recoverable_runs
+ SET status = 'Paused',
+ error_message = $2,
+ last_progress_at = NOW()
+ WHERE id = $1
+ "#,
+ )
+ .bind(self.run_id)
+ .bind(reason)
+ .execute(self.pool.as_ref())
+ .await
+ .map_err(|e| map_sqlx_err("mark_paused_retryable", e))?;
+ }
+ Ok(())
+ }
+
async fn mark_failed(&self, message: &str) -> Result<(), DomainError> {
sqlx::query(
r#"
@@ -824,10 +877,18 @@ impl PgJobStoreProvider {
// In practice this is a rare edge case that
// ONLY hits if two admin triggers land in
// the same microsecond.
+ //
+ // `error_message` is cleared here: it records why
+ // the LAST attempt stopped, so carrying it past a
+ // resume leaves a Completed run still displaying a
+ // transient error it recovered from — a failure
+ // that did not happen. Same stale-state shape as
+ // the read-only banner outliving its migration.
let row: Option<(DateTime, Option>)> = sqlx::query_as(
r#"
UPDATE jobs.recoverable_runs
SET status = 'Running',
+ error_message = NULL,
last_progress_at = NOW()
WHERE id = $1
RETURNING started_at, cursor
diff --git a/src/infrastructure/scheduler/recoverable.rs b/src/infrastructure/scheduler/recoverable.rs
index 98db6dea..ff3728f8 100644
--- a/src/infrastructure/scheduler/recoverable.rs
+++ b/src/infrastructure/scheduler/recoverable.rs
@@ -157,6 +157,32 @@ pub enum RunOutcome {
Paused {
cursor: Vec,
},
+ /// The ENVIRONMENT failed after a bounded number of attempts, and
+ /// this is worth trying again later.
+ ///
+ /// Lands as `Paused` in the row, so resume works unchanged. What
+ /// differs is `error_message`: an operator has to be able to tell "I
+ /// paused this" from "the provider went down", and a paused run with
+ /// no explanation is an unexplained one.
+ ///
+ /// Distinct from both neighbours, and the distinction is the point:
+ ///
+ /// | outcome | meaning | resumes? |
+ /// |---|---|---|
+ /// | `Failed` | the data or the request is wrong | no — terminal |
+ /// | `Paused` | an operator asked it to stop | yes |
+ /// | `PausedRetryable` | the environment failed | yes, and says why |
+ ///
+ /// Reached only after the handler has already retried — see
+ /// `retry_transient` — because a single transient error is not news.
+ /// The cap exists because no status-based taxonomy can tell a
+ /// deterministic 5xx from a passing one (Azurite answers 500 to a
+ /// CRC64 ranged GET, every time), so the policy is deliberately
+ /// "retry as if transient, then hand the decision to a human".
+ PausedRetryable {
+ cursor: Vec,
+ reason: String,
+ },
Failed {
message: String,
},
@@ -197,6 +223,56 @@ impl RunOutcome {
),
}
}
+
+ /// Turn a failed operation into the right outcome:
+ /// [`RunOutcome::PausedRetryable`] when the error is transient,
+ /// [`RunOutcome::Failed`] otherwise.
+ ///
+ /// **This is where step 1's classification pays off.** Handlers
+ /// should route every backend error through here rather than
+ /// reaching for `Failed` directly, so "the provider is down" stops a
+ /// long scan at its cursor instead of discarding it.
+ ///
+ /// `cursor` is the resume position — normally the same value the
+ /// handler last checkpointed. Pass `None` only when nothing has been
+ /// settled yet; the run then resumes from the beginning.
+ ///
+ /// # Why the engine does not add its own retry loop
+ ///
+ /// The plan sketched bounded backoff *here*. Measuring first showed
+ /// two layers already exist below: the AWS SDK retries internally,
+ /// and `RetryBlobBackend` wraps every remote backend with its own
+ /// exponential backoff (defaults: 3 retries, 100 ms, ×2, 10 s cap —
+ /// all env-tunable). A third layer would multiply, not add: one
+ /// logical operation could span SDK × decorator × engine attempts,
+ /// turning a brief outage into minutes of held `migration_readonly`.
+ ///
+ /// The plan anticipated exactly this — "do not double-retry … the
+ /// AWS SDK already retries internally, so a second layer above it
+ /// multiplies" — so the retrying stays where it already is, at the
+ /// operation, and the engine supplies the part that was genuinely
+ /// missing: converting an exhausted-retry failure into a resumable
+ /// pause with a reason instead of a terminal `Failed`.
+ ///
+ /// Retrying at this level would also mean re-running a scan, not an
+ /// operation. Tuning attempts belongs in
+ /// `OXICLOUD_STORAGE_RETRY_*`, where it applies per request.
+ pub fn from_domain_error(
+ cursor: Option<&[u8]>,
+ context: &str,
+ err: &crate::domain::errors::DomainError,
+ ) -> Self {
+ if err.is_transient() {
+ RunOutcome::PausedRetryable {
+ cursor: cursor.map(<[u8]>::to_vec).unwrap_or_default(),
+ reason: format!("{context}: {err}"),
+ }
+ } else {
+ RunOutcome::Failed {
+ message: format!("{context}: {err}"),
+ }
+ }
+ }
}
/// Write `JobRunArgs` to `params` on a Fresh run, or read them back on a
@@ -484,7 +560,44 @@ pub trait JobStore: Send + Sync {
/// Returns `0` if the key is absent (fresh row) or not a
/// number. Callers on a Fresh run can safely skip this — the
/// answer is trivially 0 and the write path starts fresh.
- async fn scanned_count(&self) -> Result;
+ async fn scanned_count(&self) -> Result {
+ self.stat_u64("scanned_count").await
+ }
+
+ /// Read any numeric key out of the run's `stats` JSONB.
+ ///
+ /// The generalisation of [`Self::scanned_count`], which is now
+ /// one caller of it. Handlers use this on a Resume path to
+ /// restore their own cumulative counters — see
+ /// [`Self::checkpoint_counters`].
+ ///
+ /// Returns `0` when the key is absent or not a number, so a
+ /// fresh run and a run that never wrote the key are the same
+ /// answer.
+ async fn stat_u64(&self, key: &str) -> Result;
+
+ /// Handler-callable. Merge the handler's OWN cumulative counters
+ /// into `stats` mid-run.
+ ///
+ /// Distinct from [`Self::merge_stats`], which stays engine-only and
+ /// runs once at `Completed`. That timing is the problem this
+ /// solves: counters written only at the end are lost by a pause,
+ /// so every resumed segment restarts them at zero and the final
+ /// row reports the LAST segment rather than the run. `backend_
+ /// migration` showed this as `copied: 0` on a migration that had
+ /// copied plenty, next to a `scanned_count` that was cumulative
+ /// because `checkpoint` had been persisting it all along.
+ ///
+ /// Pass ABSOLUTE values, not deltas — the merge is
+ /// `stats = stats || $1`, so each write displaces the last. Keys
+ /// are the handler's own; do not write engine-owned
+ /// `scanned_count` / `finding_count` through here.
+ async fn checkpoint_counters(
+ &self,
+ counters: &serde_json::Map,
+ ) -> Result<(), DomainError> {
+ self.merge_stats(counters).await
+ }
/// Persist one finding to `jobs.run_findings` and bump
/// `stats.finding_count` on the parent run. Consistency handlers
@@ -554,6 +667,27 @@ pub trait JobStore: Send + Sync {
/// returned. Handler code MUST NOT call this.
async fn mark_paused(&self, cursor: Option>) -> Result<(), DomainError>;
+ /// Engine-only. Called by [`run_or_resume`] on
+ /// [`RunOutcome::PausedRetryable`]. Handler code MUST NOT call this.
+ ///
+ /// Writes `status = Paused` — so resume is the same operation — plus
+ /// `error_message = reason`. The reason is the whole point: without
+ /// it the panel cannot distinguish an operator pause from a provider
+ /// outage, and a paused migration holding `migration_readonly` looks
+ /// like someone forgot about it.
+ ///
+ /// Separate method rather than an extra argument on
+ /// [`Self::mark_paused`] because the two carry different meaning and
+ /// only one of them writes `error_message`. A `reason: Option<&str>`
+ /// parameter would let a caller write a Paused row with an
+ /// error message and no error, which is the state this exists to
+ /// distinguish from.
+ async fn mark_paused_retryable(
+ &self,
+ cursor: Option>,
+ reason: &str,
+ ) -> Result<(), DomainError>;
+
/// Engine-only. Called by [`run_or_resume`] on
/// [`RunOutcome::Failed`]. Handler code MUST NOT call this.
async fn mark_failed(&self, message: &str) -> Result<(), DomainError>;
@@ -1018,6 +1152,47 @@ pub async fn run_or_resume(
)
}
}
+ RunOutcome::PausedRetryable { cursor, reason } => {
+ let cursor_hex = hex::encode(&cursor);
+ log_terminal_write_err(
+ "mark_paused_retryable",
+ run_id,
+ store.mark_paused_retryable(Some(cursor), &reason).await,
+ );
+ // Audited, not merely logged. Writes are refused app-wide
+ // while `backend_migration` holds `migration_readonly`, so a
+ // run that stopped on a provider outage is an operational
+ // event someone has to act on — and "why is the app
+ // read-only" must be answerable afterwards.
+ tracing::info!(
+ target: "audit",
+ event = "job.paused_retryable",
+ reason = "backend_unavailable",
+ job = %job.name(),
+ run_id = %run_id,
+ cursor_hex = %cursor_hex,
+ detail = %reason,
+ "👮🏻♂️ `{}` paused after exhausting retries: {reason}",
+ job.name(),
+ );
+ // `ok`, not `err`: the run did not fail, it stopped and can
+ // be resumed. Reporting it as an error would put a red job
+ // in the panel that a Resume click fixes, which reads as a
+ // bug rather than as a decision waiting to be made.
+ JobOutcome::ok_with(
+ stats.finding_count,
+ serde_json::json!({
+ "paused": true,
+ "retryable": true,
+ "reason": reason,
+ "run_id": run_id.to_string(),
+ "cursor_hex": cursor_hex,
+ "finding_count": stats.finding_count,
+ "scanned_count": stats.scanned_count,
+ "severity_counts": stats.by_severity,
+ }),
+ )
+ }
RunOutcome::Failed { message } => {
log_terminal_write_err("mark_failed", run_id, store.mark_failed(&message).await);
JobOutcome::err(format!("{message} (run_id={run_id})"))
@@ -1315,8 +1490,14 @@ mod tests {
async fn get_string_param(&self, key: &str) -> Result