From b2aae8e733cd780fd08ae4c10ad94591ad78dd01 Mon Sep 17 00:00:00 2001 From: breis Date: Sat, 22 Aug 2026 19:11:08 -0400 Subject: [PATCH 1/2] fix(completion): treat a failed source cleanup as a failure, not a success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a delivery that was verified on every destination, a failure of the source completion action was logged and then ignored: the item was still persisted as `Completed`, `replicated` counted it, and a `FileArchived` event was emitted carrying a computed `archivePath` that pointed at a file which had never been written — while the source sat untouched in the watch directory. A missing `archiveDir` took the same path, silently degrading `onSuccess: archive` into "leave it where it is" and still reporting success. For an evidence pipeline that is a false record of custody. Completion is now proven before it is recorded. Two durable states carry it: `CleanupPending`, persisted before the filesystem is touched, and `CleanupFailed` when the action did not succeed. `Completed` is written only once the action is verified — the archive target exists at the source's byte count and, under `completion.verify: checksum`, re-hashes to the checksum the destinations verified against; or the deleted source is absent. A failed move, an unconfigured or unwritable `archiveDir`, a failed delete, and an archived copy that does not match are all cleanup failures: no `FileArchived`/`FileDeleted`, no `replicated` increment. `FileArchived.archivePath` now reports the path the file really landed at, which the `suffix` collision policy can rename. Cleanup retries run on their own bounded budget — the shared full-jitter backoff capped by `retry.maxAttempts`, else ten attempts — deliberately separate from the transfer's time-based `giveUpAfter`, whose clock starts at discovery and is usually spent by the time a slow transfer finishes. Permanent errors give up on the first attempt. Every reconciliation tick re-drives the `CleanupPending` rows and the due `CleanupFailed` rows; exhaustion parks the item with a `FileCleanupFailed` event and a `failed.items[]` entry in `get-status` (`state: "cleanup_failed"` plus `cleanupAttempts`), recoverable with `trigger`, which re-drives every cleanup failure regardless of its gate. Recovery re-evaluates a `CleanupPending` item against observed filesystem state before any new work: a source still present retries the action, a source already gone completes (archiving removes the source only after the target rename succeeds). Neither cleanup state is terminal, so a rescan that re-discovers the still-present source preserves the row instead of re-enqueueing an already-replicated file. Adds a `cleanup_attempts` column to `work_items`, applied to an existing database with a guarded `ALTER TABLE`, and a `SourceFs` seam in the worker so the failure paths are tested without a real cross-device mount. DESIGN.md gains register entry I, FR-CMP-7, the reworked §8.1 state machine and §13.2 completion sequence; the reference, explanation, and how-to docs describe the new behavior. --- AGENTS.md | 9 + DESIGN.md | 109 +- docs/explanation.md | 40 + docs/how-to-guides.md | 35 +- docs/reference/configuration.md | 16 +- docs/reference/data-types.md | 18 +- docs/reference/messaging-interface.md | 11 + src/control.rs | 67 ++ src/dest/local.rs | 1 + src/domain.rs | 34 +- src/events.rs | 53 + src/instance/mod.rs | 138 ++- src/instance/worker.rs | 1366 +++++++++++++++++++++++-- src/state.rs | 224 +++- tests/azure_azurite.rs | 1 + tests/gcs_fakegcs.rs | 1 + tests/http_inprocess.rs | 1 + tests/p1_engine.rs | 23 +- tests/s3_floci.rs | 1 + tests/s3_real.rs | 1 + tests/sftp_atmoz.rs | 1 + 21 files changed, 1993 insertions(+), 157 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b4fa659..2f22bdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,15 @@ first. the `instance` body field; the topic wins, and `src/control.rs`'s `address()` folds it into the body selector so every handler reads one selector. - Durable **write-ahead** state → crash-safe move+delete with checksum-verify-before-complete. +- **A source-completion failure is a failure, not a success** (DESIGN §20-I, register entry `I`): the + `Verified → CleanupPending → Completed` chain persists the cleanup intent *before* touching the source and + writes `Completed` only once the action is **proven** (archive target present + size/checksum match; delete + source absent). A failed move, a missing/unwritable `archiveDir`, a failed delete, or a mismatched archive + copy leaves the item `CleanupFailed` — no `FileArchived`/`FileDeleted`, no `replicated` increment. Cleanup + retries use the shared full-jitter backoff on their own attempt budget (`retry.maxAttempts`, else 10), + independent of the transfer's time-based `giveUpAfter`; exhaustion emits `FileCleanupFailed` and parks the + item until `trigger` re-drives it. Neither cleanup state is terminal, so a re-discovered source is never + re-enqueued. The `SourceFs` seam in `src/instance/worker.rs` is how the failure paths are tested. - **Long-outage tolerant** (hours–~2d): time-based `giveUpAfter` (default 7d, not attempt caps, shipped) and resume in-flight (shipped). The **disconnection circuit-breaker** (§13.4) is still **not implemented** — `Event::Disconnected`/`Reconnected` exist as enum variants in `src/events.rs` marked `Deferred`, and diff --git a/DESIGN.md b/DESIGN.md index 39a96d1..cb18b3b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -194,6 +194,7 @@ IDs follow the ecosystem `FR--` convention. RFC-2119 keywords. - **FR-CMP-4** — Completion MUST be **crash-safe** (no loss, no silent double-delivery; idempotent re-verify on restart; §14). - **FR-CMP-5** — `completion` is a **separate instance section**, not part of `egress` (§20-C, accepted). - **FR-CMP-6** — On retry-exhaustion, MUST support **quarantine to a Failed dir** (with an error sidecar) or **retain-in-place** (configurable; §13.3). +- **FR-CMP-7** — A file MUST be recorded as replicated **only when its completion action verifiably happened** (archive target present and matching, or source absent). A failed action is a `CleanupFailed` item, retried on its own bounded budget and surfaced to the operator — never a success (§13.2, §20-I). ### 4.5 Reliability / retry / resume / limits (REL) @@ -512,7 +513,11 @@ stateDiagram-v2 InProgress --> Failed: error (attempt++) Failed --> Ready: backoff elapsed, within giveUpAfter Failed --> Exhausted: giveUpAfter exceeded / maxAttempts hit - Verified --> Completed: completion action (delete or archive) + Verified --> CleanupPending: cleanup intent persisted (write-ahead) + CleanupPending --> Completed: completion action proven (target verified / source gone) + CleanupPending --> CleanupFailed: completion action failed (cleanupAttempt++) + CleanupFailed --> CleanupPending: cleanup backoff elapsed, within the cleanup attempt budget + CleanupFailed --> CleanupPending: trigger (operator re-drive, ignores the gate) Exhausted --> Quarantined: onExhausted=quarantine (move to failedDir) Exhausted --> Retained: onExhausted=retainInPlace Completed --> [*] @@ -520,8 +525,23 @@ stateDiagram-v2 Retained --> [*]: (retriable via trigger) ``` +| State | Meaning | Leaves it | +|---|---|---| +| `Ready` | Readiness passed, durably queued. | A claim (`Ready → InProgress`). | +| `InProgress` | Claimed; delivery to the destinations is underway. | Every destination verified, or an error. | +| `Verified` | Every configured destination delivered **and** integrity-verified. The source is untouched. | The cleanup intent write-ahead. | +| `CleanupPending` | The source completion action (`delete`/`archive`) is authorized but not yet proven done. Persisted **before** the filesystem is touched, so a crash here is re-evaluated against observed state on restart. | The action's proof, or its failure. | +| `CleanupFailed` | Delivered and verified everywhere, but the source was **not** released: the archive move failed, `archiveDir` is missing or unwritable, the delete failed, or the archived copy did not match. Not a success: no `FileArchived`/`FileDeleted`, and `replicated` does not move. | The cleanup backoff gate, or a `trigger`. Once the cleanup attempt budget is spent, only a `trigger`. | +| `Completed` | Terminal success — the source was **verifiably** deleted or archived. | — | +| `Failed` | A transfer attempt errored; awaiting backoff. | The gate elapsing, or the budget expiring. | +| `Exhausted` | `giveUpAfter`/`maxAttempts` spent on the transfer. | `onExhausted`. | +| `Quarantined` | Moved to `failedDir` with an `.error.json` sidecar. | — | +| `Retained` | Left in place, marked failed; retriable via `trigger`. | — | + Every transition is written to the durable store **before** the side effect it authorizes (write-ahead), so -restart re-derives the exact position (§14). +restart re-derives the exact position (§14). `CleanupPending` and `CleanupFailed` are **not** terminal, so a +rescan that re-discovers their still-present source preserves the row instead of re-enqueueing an +already-replicated file as new work. ### 8.2 Per-instance loop @@ -784,16 +804,41 @@ sequenceDiagram D-->>W: delivered with checksum W->>D: verify() D-->>W: ok - W->>S: persist Verified (write-ahead) + W->>S: persist Verified + W->>S: persist CleanupPending (write-ahead) W->>F: completion (delete or move to archive) + F-->>W: target verified / source gone W->>S: persist Completed - Note over W,S: crash between Verified and Completed is recovered idempotently on restart + Note over W,S: crash between CleanupPending and Completed is recovered idempotently on restart + Note over W,S: an unproven action persists CleanupFailed instead — never Completed ``` -**Crash recovery:** a crash between *persist Verified* and *persist Completed* is safe — on restart the item -is re-verified idempotently (the destination object already matches), then the completion action re-runs. The -file is **never re-uploaded and never lost**. Object stores use **stable, deterministic keys** (relpath + -prefix) so re-delivery overwrites identically (idempotent), avoiding duplicates (FR-REL-4). +**Crash recovery:** a crash between *persist CleanupPending* and *persist Completed* is safe — on restart the +item is re-verified idempotently (the destination object already matches), then the completion action is +re-evaluated against observed filesystem state: a source still present means the action is retried, a source +already gone means it landed and the item completes (`move_file` removes the source only after the target +rename succeeds). The file is **never re-uploaded and never lost**. Object stores use **stable, deterministic +keys** (relpath + prefix) so re-delivery overwrites identically (idempotent), avoiding duplicates (FR-REL-4). + +**Completion is proven, not assumed (§20-I).** `Completed` is persisted only once the action is verified: +for `archive`, the target exists at the source's byte count and — under `completion.verify: checksum` — +re-hashes to the checksum the destinations verified against; for `delete`, the source is absent. A failing +move, a missing or unwritable `archiveDir`, a failing delete, or an archived copy that does not match are all +**cleanup failures**: the item becomes `CleanupFailed`, no `FileArchived`/`FileDeleted` is emitted, and the +`replicated` statistic does not move. + +**Cleanup retries** run on their own budget, independent of the transfer's: the shared full-jitter backoff +(`retry.baseDelayMs` → `retry.maxDelayMs`) bounded by `retry.maxAttempts`, or 10 attempts when that is unset. +The transfer's time-based `giveUpAfter` is deliberately not reused — it starts at discovery and is usually +largely spent by the time a slow transfer finishes, which would leave exactly the files that struggled hardest +with no cleanup retries at all. A permanent error (a missing `archiveDir`, a permission denial, a mismatched +archive copy) gives up on the first attempt: no retry can change any of them. + +Every reconciliation tick re-drives the `CleanupPending` rows and the `CleanupFailed` rows whose gate has +elapsed. Once the budget is spent, the item stays `CleanupFailed` with a `FileCleanupFailed` event (§17.1), +a `failed.items[]` entry in `get-status` carrying `state: "cleanup_failed"` and `cleanupAttempts` (§16), and +one way back: fix the cause, then send `trigger`, which re-drives every `CleanupFailed` item regardless of +its gate. ### 13.3 Failure handling & the Failed folder (FR-CMP-6 — your question #9) @@ -1098,11 +1143,18 @@ dispatcher** (resource/verb → handler), structured to be liftable into core al "inProgress":[ { "path": "big.parquet", "size": 734003200, "bytesDone": 220200960, "percent": 30.0, "destination": "s3", "attempt": 1 } ], "replicated":{ "count": 4310, "bytes": 90230411223, "last": { "path": "...", "at": "..." } }, - "failed": { "count": 2, "items": [ { "path": "x.csv", "attempts": 24, "lastError": "...", - "quarantinedAt": "..." } ] } + "failed": { "count": 3, "items": [ { "path": "x.csv", "attempts": 24, "lastError": "...", + "quarantinedAt": "..." }, + { "path": "evidence.bin", "state": "cleanup_failed", + "attempts": 1, "cleanupAttempts": 10, "lastError": "..." } ] } } ``` +The `failed` bucket carries every item needing an operator, discriminated by `items[].state`: +`failed` (retrying), `exhausted`, `quarantined`, and `cleanup_failed` — the last meaning the file reached +every destination but its source was never released (§13.2, §20-I). A `cleanup_failed` row adds +`cleanupAttempts`, because its transfer `attempts` all succeeded. + **P3 reporting notes.** `schedule.mode` reports the **configured** mode verbatim (`immediate` / `cron` / `window`) — never a hardcoded literal — so an instance an operator set to a window is not misrepresented as `immediate` (cron/window *execution* is P4; the window-state sub-fields @@ -1125,9 +1177,14 @@ which derives the `evt/{severity}/{type}` channel from the body — the wire bod ### 17.1 Event types (FR-EVT-1) `FileDiscovered`, `FileReady`, `ReplicationStarted`, `ReplicationProgress`, `ReplicationCompleted`, -`ReplicationFailed`, `FileArchived`, `FileDeleted`, `FileQuarantined`, `RetriesExhausted`, -`ScheduleTriggered`, `WindowOpened`, `WindowClosed`, `ScheduleComplete`, `ScanComplete`, `Disconnected`, -`Reconnected`, **`InstanceActivated`**, **`InstanceDeactivated`**, `ComponentReady`. +`ReplicationFailed`, `FileArchived`, `FileDeleted`, `FileCleanupFailed`, `FileQuarantined`, +`RetriesExhausted`, `ScheduleTriggered`, `WindowOpened`, `WindowClosed`, `ScheduleComplete`, `ScanComplete`, +`Disconnected`, `Reconnected`, **`InstanceActivated`**, **`InstanceDeactivated`**, `ComponentReady`. + +`FileArchived` and `FileDeleted` are emitted only for a completion action that verifiably happened. +`FileCleanupFailed` (`{path, action, attempts}`, last error promoted to `message`, severity `critical`) is +the counterpart: the file replicated and verified on every destination, but its source was not released and +the cleanup attempt budget is spent (§13.2, §20-I). ### 17.2 Example — progress event @@ -1239,6 +1296,32 @@ rehash of the config keys. - **H. UNS shape — REVISED per review:** `{thing}/{component}/{class}/{resource…}` — dropped `edgecommons` root, `v1` (envelope carries version), and `site`/`enterprise` (unreliable tags → envelope); rooted on the IoT-Core-globally-unique `thing`; fits the 256-byte / 7-slash limits (§15). ✔ (confirm §15.2) +- **I. Source-completion failure is a failure, not a success — DECIDED:** a verified delivery whose source + completion action (archive move or delete) does not verifiably happen is recorded as `CleanupFailed`, never + `Completed`. Two durable states carry it — `CleanupPending` (the write-ahead marker persisted *before* the + filesystem is touched) and `CleanupFailed` (the action failed) — and `Completed` is written only once the + action is proven: the archive target exists at the source's byte count and re-hashes to the delivered + checksum under `completion.verify: checksum`, or the deleted source is absent. `FileArchived`/`FileDeleted` + fire only on a proven action, `FileArchived.archivePath` reports the path the file really landed at (which + the `suffix` collision policy can rename) rather than a computed one, and the `replicated` statistic counts + only `Completed`. Cleanup retries use the shared full-jitter backoff on their own attempt budget + (`retry.maxAttempts`, else 10), independent of the transfer's time-based `giveUpAfter`. Exhausted items stay + `CleanupFailed`, emit `FileCleanupFailed`, appear in `get-status` under `failed.items[]` with + `state: "cleanup_failed"` + `cleanupAttempts`, and are re-driven only by `trigger`. §8.1, §13.2, §16, §17.1. + + *Rationale.* The prior behavior logged the filesystem error, persisted `Completed`, counted the file as + replicated, and emitted `FileArchived` with a computed `archivePath` that pointed at a file which was never + written — while the source sat untouched in the watch directory. For an evidence pipeline that is a false + record of custody: the operator's dashboard, the event stream, and the statistics all assert a file was + archived when it was not. A missing `archiveDir` was the same failure by another route, silently degrading + `archive` into "leave it where it is" while still reporting success. The `Completed` marker's original job — + stopping a stale source from being re-discovered in a loop — is preserved by making the two cleanup states + non-terminal: `upsert_ready` only resets a *terminal* row, so a re-discovered `CleanupPending`/`CleanupFailed` + source keeps its row and is never re-enqueued as new work. An attempt-bounded cleanup budget was chosen over + reusing `giveUpAfter` because that clock starts at discovery: a file that spent six days retrying a transfer + would otherwise get no cleanup retry at all. The alternative of a separate top-level `cleanup` section in the + `get-status` document was rejected in favor of the existing `failed` bucket — the item genuinely needs an + operator, `failed.items[].state` already discriminates, and consumers need no new schema. **New decisions in this revision (flag if you disagree):** - Cron-first scheduling (`croner`), English as optional sugar; windows = open+close/duration crons (§12). diff --git a/docs/explanation.md b/docs/explanation.md index 9d5e1bb..295933f 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -58,6 +58,46 @@ side effect it authorizes (write-ahead), so a crash between "verified" and "sour idempotently on restart — never re-uploading, never losing the file. Object keys are stable/deterministic so re-delivery overwrites identically. +On restart, a file caught mid-completion is re-evaluated against what is actually on disk rather than +re-applied blindly: a source still present means the completion action runs again, and a source already +gone means it landed, so the file completes. Archiving removes the source only after the target is in +place, which is what makes "the source is gone" a safe signal. + +## Completion is proven, not assumed +A file counts as replicated only when its **source completion action verifiably happened**. After every +destination has delivered and verified, file-replicator archives or deletes the source and then proves it: +under `onSuccess: "archive"` the target must exist at the source's byte count and, with +`completion.verify: "checksum"`, re-hash to the checksum the destinations verified against; under +`onSuccess: "delete"` the source must be gone. + +When the action does not succeed — the archive move fails, `archiveDir` is missing or unwritable, the +delete fails, or the archived copy does not match — the file becomes **`cleanup_failed`**: + +- it is **not** counted in `replicated`, and no `file-archived` or `file-deleted` event is published; +- the source stays where it is, so nothing is lost; +- it appears in `get-status` under `failed.items[]` with `state: "cleanup_failed"` and a + `cleanupAttempts` count. + +The delivered copies are already safe on every destination — what is unresolved is the source. That +distinction matters for evidence and chain-of-custody pipelines, where "archived" appearing in a dashboard +for a file still sitting in the watch directory is worse than an explicit failure. + +Completion attempts retry on their own budget: the same exponential backoff as transfers +(`retry.baseDelayMs` → `retry.maxDelayMs`), bounded by `retry.maxAttempts` or, when that is unset, 10 +attempts. This is separate from the transfer's `retry.giveUpAfter` clock, which starts when the file is +discovered and is often nearly spent by the time a long transfer finishes. Errors that no retry can fix — a +missing `archiveDir`, a permission denial, an archived copy that does not match — stop after the first +attempt. + +Each reconciliation scan re-drives the files whose completion is still owed. Once a file's completion budget +is spent it stays `cleanup_failed` and publishes a `file-cleanup-failed` event (severity `critical`, with the +`action` that failed and the attempt count). To recover it: fix the cause, then send the `trigger` command, +which re-drives every `cleanup_failed` file regardless of its backoff. + +Because the source of such a file is still in the watch directory, every scan re-discovers it. It is not +re-queued as new work — the bytes already reached every destination, and only the source's release is +outstanding. + ## Scheduling vs windows `cron` releases ready work at each fire; a `window` (open→close cron, or open+duration) gates continuous flow to a time span, for bandwidth conservation. Work outside the window waits; a transfer crossing a diff --git a/docs/how-to-guides.md b/docs/how-to-guides.md index 6bb9fe2..00915ee 100644 --- a/docs/how-to-guides.md +++ b/docs/how-to-guides.md @@ -133,6 +133,37 @@ items also show up in `get-status` under `failed.items[]` with `state: "quaranti --- +## Recover files whose source could not be archived or deleted + +A file that reached every destination but whose source could not be released stops in `cleanup_failed`. It +is never reported as replicated, and the source stays in the watch directory. Find these files and clear +them: + +1. Call `get-status` and look for `failed.items[]` entries with `state: "cleanup_failed"`. Each carries + `lastError`, the failing `path`, and `cleanupAttempts`. + + ```jsonc + { "path": "batch-07/evidence.bin", "state": "cleanup_failed", + "attempts": 1, "cleanupAttempts": 10, + "lastError": "permanent: onSuccess=archive requires completion.archiveDir" } + ``` + +2. Fix what `lastError` names. The usual causes are a missing or misspelled `completion.archiveDir`, an + archive volume that is full, read-only, or unmounted, and directory permissions that block the component's + user. + +3. Send the `trigger` command to the instance. It re-drives every `cleanup_failed` file, including the ones + whose completion budget is spent. + +4. Confirm with `get-status`: the files move out of `failed` and into `replicated`, and a `file-archived` or + `file-deleted` event is published for each. + +Subscribe to `file-cleanup-failed` (severity `critical`) to be alerted the moment a file lands in this state +rather than discovering it at the next status poll. See +[explanation › Completion is proven, not assumed](explanation.md#completion-is-proven-not-assumed). + +--- + ## Survive a multi-day disconnection Two mechanisms tolerate an endpoint being down for hours to days — configure the time budget and @@ -194,8 +225,8 @@ RUNNING/STOPPED keepalive). Build a live view by combining two sources: 1. **Subscribe to the event stream** for a device (or the whole fleet): `ecv1/+/FileReplicator/+/evt/#`. Apply each `type` (`file-ready`, `replication-started`, `replication-progress`, `replication-completed`, `replication-failed`, `retries-exhausted`, - `file-archived`/`file-deleted`/`file-quarantined`, …) to your in-memory model. `replication-progress` - carries `percent`/`bytesDone` (throttled). + `file-archived`/`file-deleted`/`file-cleanup-failed`/`file-quarantined`, …) to your in-memory model. + `replication-progress` carries `percent`/`bytesDone` (throttled). 2. **Prime and re-sync with `get-status`** — call it on connect (and periodically) to get the exact `awaiting`/`inProgress`/`replicated`/`failed` document a late subscriber would otherwise have missed. Keep your own timestamped app-layer cache as the retain substitute. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c7ea6ff..87cfb1f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -96,16 +96,20 @@ cron, tz/DST-aware. | Key | Type | Default | Notes | |---|---|---|---| | `onSuccess` | enum | `archive` | `archive` (needs `archiveDir`) \| `delete`. | -| `archiveDir` | string | — | Required when `onSuccess=archive`. | +| `archiveDir` | string | — | Required when `onSuccess=archive`. Without it, no file can be archived: each one is recorded as a cleanup failure rather than a success. | | `onExhausted` | enum | `retainInPlace` | `retainInPlace` \| `quarantine` (needs `failedDir`). | | `failedDir` | string | — | Quarantine dir + `.error.json` sidecar. | | `onCollision` | enum | `suffix` | `suffix` \| `overwrite` \| `fail`. | -| `verify` | enum | `checksum` | `checksum` \| `size` \| `none`. | +| `verify` | enum | `checksum` | `checksum` \| `size` \| `none`. Also decides how the archived copy is proven: `checksum` re-hashes it against the delivered checksum, `size` and `none` check its byte count. | + +The completion action is proven before a file counts as replicated: under `archive` the target must exist +and match, and under `delete` the source must be gone. An action that fails leaves the file in +`cleanup_failed` — see [Explanation - Completion is proven, not assumed](../explanation.md#completion-is-proven-not-assumed). ### `retry` | Key | Type | Default | Notes | |---|---|---|---| -| `baseDelayMs` | int | 1000 | Backoff base. | -| `maxDelayMs` | int | 900000 | Backoff cap (15 min). | -| `giveUpAfter` | string | `7d` | Time budget; governs long-outage tolerance. | -| `maxAttempts` | int | — | Optional hard cap (default: none — time-governed). | +| `baseDelayMs` | int | 1000 | Backoff base. Also the base for source-completion retries. | +| `maxDelayMs` | int | 900000 | Backoff cap (15 min). Also caps source-completion retries. | +| `giveUpAfter` | string | `7d` | Time budget for transfers; governs long-outage tolerance. Source-completion retries are bounded by attempts instead. | +| `maxAttempts` | int | — | Optional hard cap on transfer attempts (default: none — time-governed). It also caps source-completion attempts, which use 10 when it is unset. | diff --git a/docs/reference/data-types.md b/docs/reference/data-types.md index d75db36..d59f122 100644 --- a/docs/reference/data-types.md +++ b/docs/reference/data-types.md @@ -71,11 +71,18 @@ The reply is always wrapped by the command contract: `{ "ok": true, "result": There is no `link` (destination-connectivity) field — file-replicator has no destination circuit-breaker > (see [explanation › Resilience](../explanation.md#resilience-across-long-outages)). Do not depend on it. @@ -125,7 +132,8 @@ object carries the event-specific data; its fields, across all event types, have | `size` | int | `file-ready`, `replication-*` | File size in bytes. | | `destination` | string | `replication-*` | Backend label of the target (`local`/`s3`/…). | | `attempt` | int | `replication-started`/`-progress`/`-failed` | Current attempt, 1-based. | -| `attempts` | int | `retries-exhausted`, `file-quarantined` | Total attempts made. | +| `attempts` | int | `retries-exhausted`, `file-quarantined`, `file-cleanup-failed` | Total attempts made — transfer attempts, except on `file-cleanup-failed`, where it counts source completion attempts. | +| `action` | string | `file-cleanup-failed` | The source completion action that failed: `archive` or `delete`. | | `bytesDone` | int | `replication-progress` | Bytes transferred so far. | | `percent` | float | `replication-progress` | Percent complete, 0.0–100.0. | | `bytes` | int | `replication-completed` | Bytes transferred (total). | @@ -145,7 +153,7 @@ object carries the event-specific data; its fields, across all event types, have | `link` | string | `disconnected` — not emitted | Destination link label (there is no destination circuit-breaker). | **`message` vs `context`.** For the events with a natural error string — `replication-failed`, -`retries-exhausted`, `file-quarantined`, `permission-denied` — the error is promoted to the top-level +`retries-exhausted`, `file-quarantined`, `file-cleanup-failed`, `permission-denied` — the error is promoted to the top-level `message` field and **removed from `context`** (never duplicated in the decoded body). So a consumer reads the human error from `message`, and the machine fields (`path`, `attempt`, `willRetry`, `role`, …) from `context`. diff --git a/docs/reference/messaging-interface.md b/docs/reference/messaging-interface.md index c2afdb4..3163ca4 100644 --- a/docs/reference/messaging-interface.md +++ b/docs/reference/messaging-interface.md @@ -79,6 +79,7 @@ severity + type — the topic and the body can never disagree. Body: | `retries-exhausted` | critical | — | `path`, `destination`, `attempts` (`message` carries the last error) | | `file-archived` | info | — | `path`, `archivePath`? | | `file-deleted` | info | — | `path` | +| `file-cleanup-failed` | critical | — | `path`, `action` (`archive`\|`delete`), `attempts` (`message` carries the last error) | | `file-quarantined` | critical | — | `path`, `attempts`, `quarantinePath`? (`message` carries the last error) | | `scan-complete` | info | — | `discovered`, `awaiting` | | `instance-activated` / `instance-deactivated` | info | — | `source` | @@ -89,6 +90,16 @@ severity + type — the topic and the body can never disagree. Body: | `disconnected` | critical | `raise_alarm` | `link` — not emitted (there is no destination circuit-breaker) | | `permission-denied` | critical | — | `path`, `role` (`ingress`\|`egress`\|`archive`\|`failed`) (`message` carries the error) | +`file-archived` / `file-deleted` — published only for a source completion action that verifiably happened: +the archive target exists and matches the source, or the deleted source is gone. `archivePath` is the path +the file actually landed at, which the `suffix` collision policy can rename. + +`file-cleanup-failed` — the file replicated and verified on every destination, but its source could not be +released and the completion retry budget is spent. The file is **not** counted in `replicated`, its source +stays in the watch directory, and it appears in `get-status` under `failed.items[]` with +`state: "cleanup_failed"`. Fix what `message` reports, then send `trigger` to re-drive it. See +**Completion is proven, not assumed** in `explanation.md`. + `permission-denied` — a directory/target the instance depends on is unreadable/unwritable, at startup or at runtime. ALWAYS emitted, but **deduplicated** so it is not repeated on every rescan or every file: for `ingress`/`archive`/`failed` the dedup key (carried in `context.path`) is the directory, and for `egress` diff --git a/src/control.rs b/src/control.rs index 8f0276e..4aa29e5 100644 --- a/src/control.rs +++ b/src/control.rs @@ -574,6 +574,14 @@ pub(crate) fn instance_state_snapshot( .list_by_state(id, ItemState::Quarantined) .unwrap_or_default(), ); + // `CleanupFailed` belongs here too (DESIGN §20-I): the bytes reached every destination, but the + // source was never released, so the file still needs an operator. It is discriminated by + // `items[].state` and carries `cleanupAttempts`, and it is deliberately absent from `replicated`. + failed.extend( + store + .list_by_state(id, ItemState::CleanupFailed) + .unwrap_or_default(), + ); instance_status_json( &StatusInputs { id, @@ -629,6 +637,11 @@ fn instance_status_json(inp: &StatusInputs, now: i64) -> Value { if w.state == ItemState::Quarantined { m["quarantinedAt"] = json!(rfc3339_ms(w.updated_at)); } + // A cleanup failure's `attempts` are transfer attempts (all of which succeeded), so the + // count that explains the row is the separate source-completion one (DESIGN §20-I). + if w.state == ItemState::CleanupFailed { + m["cleanupAttempts"] = json!(w.cleanup_attempts); + } m }) .collect(); @@ -860,6 +873,7 @@ mod tests { size, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, @@ -948,6 +962,59 @@ mod tests { assert_eq!(v["failed"]["items"][1]["quarantinedAt"], json!("2026-07-01T09:15:22Z")); } + #[test] + fn a_cleanup_failed_item_is_reported_as_needing_attention() { + // DESIGN §20-I: the bytes reached every destination but the source was never released, so the + // file still needs an operator. `state` discriminates it from a transfer failure, and the + // count that explains it is `cleanupAttempts` (its transfer `attempts` all succeeded). + let mut c = work_item("evidence.bin", ItemState::CleanupFailed, 12); + c.attempts = 1; + c.cleanup_attempts = 10; + c.last_error = Some("permanent: onSuccess=archive requires completion.archiveDir".into()); + let failed = vec![c]; + let inp = StatusInputs { + id: "i1", + active: true, + configured_enabled: true, + schedule_mode: "immediate", + dest_label: "local", + stats: Stats { + replicated: 0, + failed: 0, + bytes: 0, + }, + awaiting: &[], + in_progress: &[], + failed: &failed, + }; + let v = instance_status_json(&inp, 100_000); + assert_eq!(v["failed"]["count"], json!(1)); + assert_eq!(v["failed"]["items"][0]["state"], json!("cleanup_failed")); + assert_eq!(v["failed"]["items"][0]["cleanupAttempts"], json!(10)); + assert_eq!( + v["replicated"]["count"], + json!(0), + "a cleanup failure is never counted as replicated" + ); + assert!(v["failed"]["items"][0].get("quarantinedAt").is_none()); + } + + #[test] + fn cleanup_failed_rows_reach_the_status_document_from_the_store() { + // Proves the wiring, not just the serializer: `instance_state_snapshot` must actually query + // the CleanupFailed state alongside failed/exhausted/quarantined. + let store = mem_store(); + store.upsert_ready("i1", "evidence.bin", 12, 0, 1).unwrap(); + store + .set_state("i1", "evidence.bin", ItemState::CleanupFailed, 2) + .unwrap(); + let v = + instance_state_snapshot(store.as_ref(), "i1", true, true, "immediate", "local", 100); + assert_eq!(v["failed"]["count"], json!(1)); + assert_eq!(v["failed"]["items"][0]["path"], json!("evidence.bin")); + assert_eq!(v["failed"]["items"][0]["state"], json!("cleanup_failed")); + } + // ---- addressing (core 0.5.0 scoped commands, D-SC-4) ---------------------------------------- #[test] diff --git a/src/dest/local.rs b/src/dest/local.rs index 5ce81f8..1d7ddaf 100644 --- a/src/dest/local.rs +++ b/src/dest/local.rs @@ -297,6 +297,7 @@ mod tests { size, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, diff --git a/src/domain.rs b/src/domain.rs index a4796c6..c9113f1 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -21,7 +21,15 @@ pub enum ItemState { InProgress, /// Delivered + integrity-verified; the WRITE-AHEAD point *before* the source side effect (§13.2). Verified, - /// Terminal success — the source has been deleted or archived. + /// Every destination is verified and the source completion action (`delete`/`archive`) is + /// authorized but not yet proven done — the WRITE-AHEAD point *before* the filesystem side effect + /// (DESIGN §13.2/§20-I). Crash recovery re-evaluates it against observed filesystem state. + CleanupPending, + /// Every destination is verified but the source completion action failed. Retried on its own + /// bounded backoff clock; once that budget is spent the item stays here — operator-visible in + /// `get-status`, re-drivable with `trigger`. Never counted as replicated (DESIGN §20-I). + CleanupFailed, + /// Terminal success — the source has been **verifiably** deleted or archived. Completed, /// Last attempt errored; awaiting backoff (attempts recorded, `next_attempt_at` set). Failed, @@ -40,6 +48,8 @@ impl ItemState { ItemState::Ready => "ready", ItemState::InProgress => "in_progress", ItemState::Verified => "verified", + ItemState::CleanupPending => "cleanup_pending", + ItemState::CleanupFailed => "cleanup_failed", ItemState::Completed => "completed", ItemState::Failed => "failed", ItemState::Exhausted => "exhausted", @@ -59,6 +69,8 @@ impl ItemState { "ready" => ItemState::Ready, "in_progress" => ItemState::InProgress, "verified" => ItemState::Verified, + "cleanup_pending" => ItemState::CleanupPending, + "cleanup_failed" => ItemState::CleanupFailed, "completed" => ItemState::Completed, "failed" => ItemState::Failed, "exhausted" => ItemState::Exhausted, @@ -69,6 +81,11 @@ impl ItemState { } /// True for the terminal states no worker will re-claim: `Completed`, `Quarantined`, `Retained`. + /// + /// [`CleanupPending`](Self::CleanupPending) and [`CleanupFailed`](Self::CleanupFailed) are + /// deliberately **not** terminal: the source file is still on disk, so re-discovery must preserve + /// the row (see [`StateStore::upsert_ready`](crate::state::StateStore::upsert_ready), which only + /// resets a *terminal* row) rather than re-enqueue an already-replicated file as new work. pub fn is_terminal(&self) -> bool { matches!( self, @@ -92,8 +109,17 @@ pub struct WorkItem { pub size: u64, /// Unix ms — oldest-ready-first ordering. pub discovered_at: i64, + /// Transfer attempts (the aggregate rollup across destinations). Cleanup attempts are counted + /// separately in [`cleanup_attempts`](Self::cleanup_attempts). pub attempts: u32, - /// Unix ms — backoff gate for re-claim (P1 addition to the §14.2 schema). + /// Source-completion (`delete`/`archive`) attempts made since the item was first verified — the + /// cleanup retry clock, kept independent of the transfer's `attempts`/`giveUpAfter` budget so a + /// file that spent its whole transfer budget still gets a full set of cleanup retries + /// (DESIGN §20-I). + pub cleanup_attempts: u32, + /// Unix ms — backoff gate for re-claim (P1 addition to the §14.2 schema). While the item is + /// `CleanupPending`/`CleanupFailed` the transfer is already done, so this same column carries the + /// **cleanup** retry gate. pub next_attempt_at: i64, pub last_error: Option, pub bytes_done: u64, @@ -224,6 +250,8 @@ mod tests { ItemState::Ready, ItemState::InProgress, ItemState::Verified, + ItemState::CleanupPending, + ItemState::CleanupFailed, ItemState::Completed, ItemState::Failed, ItemState::Exhausted, @@ -262,6 +290,8 @@ mod tests { ItemState::Ready, ItemState::InProgress, ItemState::Verified, + ItemState::CleanupPending, + ItemState::CleanupFailed, ItemState::Failed, ItemState::Exhausted, ] { diff --git a/src/events.rs b/src/events.rs index ef414f0..83da5d7 100644 --- a/src/events.rs +++ b/src/events.rs @@ -117,6 +117,16 @@ pub enum Event { }, /// The source file was deleted on success. FileDeleted { path: String }, + /// Every destination is verified but the source completion action (`archive`/`delete`) failed and + /// its retry budget is spent (DESIGN §20-I). The item stays `CleanupFailed` — never `Completed`, + /// never counted as replicated — until an operator fixes the cause and re-drives it with + /// `trigger`. `action` is `"archive"` or `"delete"`. + FileCleanupFailed { + path: String, + action: String, + attempts: u32, + last_error: String, + }, /// The source file was quarantined after exhaustion. `quarantinePath` omitted when unknown. FileQuarantined { path: String, @@ -178,6 +188,7 @@ impl Event { Event::RetriesExhausted { .. } => "RetriesExhausted", Event::FileArchived { .. } => "FileArchived", Event::FileDeleted { .. } => "FileDeleted", + Event::FileCleanupFailed { .. } => "FileCleanupFailed", Event::FileQuarantined { .. } => "FileQuarantined", Event::ScanComplete { .. } => "ScanComplete", Event::InstanceActivated { .. } => "InstanceActivated", @@ -267,6 +278,14 @@ impl Event { m } Event::FileDeleted { path } => obj(json!({ "path": path })), + Event::FileCleanupFailed { + path, + action, + attempts, + last_error, + } => obj(json!({ + "path": path, "action": action, "attempts": attempts, "lastError": last_error + })), Event::FileQuarantined { path, attempts, @@ -321,6 +340,7 @@ impl Event { Event::ReplicationFailed { error, .. } => Some(error.clone()), Event::RetriesExhausted { last_error, .. } => Some(last_error.clone()), Event::FileQuarantined { last_error, .. } => Some(last_error.clone()), + Event::FileCleanupFailed { last_error, .. } => Some(last_error.clone()), Event::PermissionDenied { error, .. } => Some(error.clone()), _ => None, }; @@ -328,6 +348,7 @@ impl Event { Event::ReplicationFailed { .. } => Severity::Warning, Event::RetriesExhausted { .. } | Event::FileQuarantined { .. } + | Event::FileCleanupFailed { .. } | Event::Disconnected { .. } | Event::Reconnected { .. } | Event::PermissionDenied { .. } => Severity::Critical, @@ -344,6 +365,7 @@ impl Event { Event::RetriesExhausted { .. } => "retries-exhausted", Event::FileArchived { .. } => "file-archived", Event::FileDeleted { .. } => "file-deleted", + Event::FileCleanupFailed { .. } => "file-cleanup-failed", Event::FileQuarantined { .. } => "file-quarantined", Event::ScanComplete { .. } => "scan-complete", Event::InstanceActivated { .. } => "instance-activated", @@ -667,6 +689,37 @@ mod tests { assert!(plan.context.get("lastError").is_none(), "promoted to message, not duplicated"); } + #[test] + fn file_cleanup_failed_plan_is_critical_and_promotes_the_last_error() { + // DESIGN §20-I: the file reached every destination, but the source completion action did not + // succeed, so this is a critical operator-facing event — never a `file-archived`/`file-deleted`. + let ev = Event::FileCleanupFailed { + path: "a/b.csv".into(), + action: "archive".into(), + attempts: 4, + last_error: "permanent: onSuccess=archive requires completion.archiveDir".into(), + }; + let plan = ev.plan(); + assert_eq!(plan.severity, Severity::Critical); + assert_eq!(plan.event_type, "file-cleanup-failed"); + assert_eq!( + plan.message, + Some("permanent: onSuccess=archive requires completion.archiveDir".to_string()) + ); + assert_eq!(plan.context["path"], json!("a/b.csv")); + assert_eq!(plan.context["action"], json!("archive")); + assert_eq!(plan.context["attempts"], json!(4)); + assert!( + plan.context.get("lastError").is_none(), + "promoted to message, not duplicated" + ); + assert!( + plan.alarm.is_none(), + "a terminal per-file failure has no clear counterpart" + ); + assert_eq!(ev.name(), "FileCleanupFailed"); + } + #[test] fn optional_paths_omitted_when_absent_present_when_set() { let arch_none = Event::FileArchived { diff --git a/src/instance/mod.rs b/src/instance/mod.rs index 520d314..c489f57 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -154,6 +154,19 @@ struct ScheduleTransition { window_closed: bool, } +/// The operator overrides a control-plane `trigger` layers on top of an ordinary reconciliation tick +/// (FR-CTL-3 / DESIGN §20-I). The periodic tick uses [`TickForce::default`] — no overrides. +#[derive(Debug, Clone, Copy, Default)] +struct TickForce { + /// Bypass the cron/window schedule gate and drain all ready work now (`trigger`'s + /// `ignoreWindow`). + ignore_window: bool, + /// Re-drive every `CleanupFailed` item, including the ones whose cleanup retry budget is spent — + /// the operator's way back for a file that is replicated everywhere but whose source could not be + /// archived or deleted. + redrive_cleanup: bool, +} + struct QueueMetricSnapshot { ready: usize, values: MetricValues, @@ -457,16 +470,17 @@ impl Instance { /// the schedule (DESIGN §12). A deactivated instance does nothing. `now` is the single Unix-ms /// clock read for this tick. pub async fn tick(&self, now: i64) { - self.run_tick(now, false).await; + self.run_tick(now, TickForce::default()).await; } - /// The tick body. `force = true` is a control-plane `trigger { ignoreWindow: true }` (FR-CTL-3): - /// it still discovers/enqueues and promotes retries as usual, but then **bypasses the schedule - /// gate** and drains ALL ready work now regardless of cron/window state — an explicit operator - /// override. A forced tick does not disturb the cron watermark or window open-state and emits no - /// gate-transition events (the control plane emits its own `ScheduleTriggered`), so it never - /// perturbs the automatic schedule. - async fn run_tick(&self, now: i64, force: bool) { + /// The tick body. [`TickForce`] carries the operator overrides a control-plane `trigger` adds on + /// top of the ordinary reconciliation pass (FR-CTL-3): `ignore_window` still discovers/enqueues and + /// promotes retries as usual but then **bypasses the schedule gate**, draining ALL ready work now + /// regardless of cron/window state, and `redrive_cleanup` re-drives even the `CleanupFailed` items + /// whose cleanup budget is spent (DESIGN §20-I). A forced tick does not disturb the cron watermark + /// or window open-state and emits no gate-transition events (the control plane emits its own + /// `ScheduleTriggered`), so it never perturbs the automatic schedule. + async fn run_tick(&self, now: i64, force: TickForce) { if !self.is_active() { self.emit_schedule_metrics(0, false, false, false, false, 0) .await; @@ -573,13 +587,24 @@ impl Instance { tracing::error!(instance = %self.id, error = %e, "retry promotion task failed") } } + // Cleanup manager (DESIGN §20-I): re-drive every item that is replicated and verified on all + // destinations but whose source completion action has not succeeded — a `CleanupPending` row an + // abort left mid-action, plus the `CleanupFailed` rows whose cleanup backoff gate has elapsed + // (all of them under an operator `trigger`). This runs BEFORE the schedule gate and regardless + // of it: the transfer is already paid for, and a source left un-archived is not something a + // closed replication window should hold hostage. Serialized with the rest of the tick by + // `tick_lock`, so it never races the batch below over the same item. + if let Err(e) = self.worker.drive_cleanup(now, force.redrive_cleanup).await { + tracing::error!(instance = %self.id, error = %e, "cleanup re-drive failed"); + } + let queue_snapshot = self.emit_queue_metrics(now).await; // Scheduling gate (DESIGN §12): decide whether/how ready work may be claimed this tick. // Discovery/enqueue above always ran regardless — only the CLAIM is gated, so newly-ready work // simply accumulates in the durable `Ready` backlog while the gate is closed. A forced trigger // (FR-CTL-3 `ignoreWindow`) bypasses the gate entirely and drains everything now. - let (admission, transition) = if force { + let (admission, transition) = if force.ignore_window { ( Admission::All { drain: true }, ScheduleTransition::default(), @@ -1039,6 +1064,17 @@ impl Instance { ); } } + // Aborted inside the write-ahead cleanup window: the completion action is still + // owed, so drive it here rather than leaving the row non-terminal until the next + // tick's cleanup pass (DESIGN §20-I). + ItemState::CleanupPending => { + if let Err(e) = self.worker.run_cleanup(&row, now).await { + tracing::error!( + instance = %self.id, relpath = %rp, error = %e, + "completing cleanup-pending item after window-close pause failed" + ); + } + } _ => {} }, Ok(None) => {} @@ -1194,7 +1230,19 @@ impl InstanceControl for Instance { // `ignore_window` the tick bypasses the schedule gate so a cron/window instance replicates // now regardless of its schedule (FR-CTL-3); otherwise it is exactly the periodic-rescan tick // (gate-respecting). A deactivated instance's tick is a no-op. - self.run_tick(now, ignore_window).await; + // + // Every `trigger` — with or without `ignoreWindow` — also re-drives the `CleanupFailed` items + // whose cleanup budget is spent (DESIGN §20-I). That is the operator's way back for a file that + // is replicated and verified everywhere but could not be archived or deleted: fix the cause, + // then `trigger`. + self.run_tick( + now, + TickForce { + ignore_window, + redrive_cleanup: true, + }, + ) + .await; } fn apply_activation( @@ -1319,6 +1367,76 @@ mod tests { assert_eq!(store.stats("i1").unwrap().replicated, 2); } + #[tokio::test] + async fn tick_drives_a_cleanup_pending_item_to_completion() { + // DESIGN §20-I: the reconciliation tick owns the cleanup pass, so an item left mid-action by a + // crash or a window-close abort is finished on the next tick rather than sitting non-terminal. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + std::fs::write(src.path().join("a.txt"), b"hello").unwrap(); + let store: Arc = Arc::new(SqliteStore::open_in_memory().unwrap()); + let inst = build( + instance_cfg("i1", src.path(), dst.path(), true), + store.clone(), + ); + // A prior run delivered + verified and wrote the write-ahead cleanup marker, then died. + store.upsert_ready("i1", "a.txt", 5, 0, 1).unwrap(); + store + .set_state("i1", "a.txt", ItemState::CleanupPending, 1) + .unwrap(); + + inst.tick(100).await; + + assert_eq!( + store.get("i1", "a.txt").unwrap().unwrap().state, + ItemState::Completed + ); + assert!(!src.path().join("a.txt").exists(), "source released"); + assert_eq!(store.stats("i1").unwrap().replicated, 1); + } + + #[tokio::test] + async fn a_parked_cleanup_failure_waits_for_a_trigger_not_for_the_next_tick() { + // A cleanup failure whose budget is spent carries a gate no clock reaches, so the ordinary + // tick leaves it alone; `trigger` re-drives it (DESIGN §20-I) — the operator's way back once + // the underlying cause is fixed. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + std::fs::write(src.path().join("a.txt"), b"hello").unwrap(); + let store: Arc = Arc::new(SqliteStore::open_in_memory().unwrap()); + let inst = build( + instance_cfg("i1", src.path(), dst.path(), true), + store.clone(), + ); + store.upsert_ready("i1", "a.txt", 5, 0, 1).unwrap(); + store + .record_cleanup_attempt( + "i1", + "a.txt", + "disk full", + ItemState::CleanupFailed, + i64::MAX, + 1, + ) + .unwrap(); + + inst.tick(100).await; + assert_eq!( + store.get("i1", "a.txt").unwrap().unwrap().state, + ItemState::CleanupFailed, + "the ordinary tick respects the parked gate" + ); + assert!(src.path().join("a.txt").exists()); + + inst.trigger_scan(200, false).await; + assert_eq!( + store.get("i1", "a.txt").unwrap().unwrap().state, + ItemState::Completed, + "trigger re-drives a parked cleanup failure" + ); + assert!(!src.path().join("a.txt").exists()); + } + #[tokio::test] async fn tick_promotes_failed_items_past_their_backoff_gate() { let src = tempfile::tempdir().unwrap(); diff --git a/src/instance/worker.rs b/src/instance/worker.rs index c2c06c5..6d3c6a2 100644 --- a/src/instance/worker.rs +++ b/src/instance/worker.rs @@ -9,7 +9,9 @@ //! └─ error → record per-dest attempt + backoff → retry | dest Exhausted //! //! once EVERY destination is DestPhase::Verified: -//! persist ItemState::Verified (write-ahead) → completion (delete|archive) → persist Completed +//! persist ItemState::Verified → persist CleanupPending (write-ahead) +//! → completion (delete|archive) → PROVE it landed → persist Completed +//! └─ error → record cleanup attempt + backoff → CleanupFailed → retry | give up //! //! if ANY destination permanently exhausts its retry budget: //! the item can never complete (even though other destinations already succeeded) → @@ -18,11 +20,21 @@ //! //! The **write-ahead** ordering is load-bearing (DESIGN §13.2/§20-B): each destination's //! `DestPhase::Verified` is persisted *before* the aggregate item is promoted to `ItemState::Verified`, -//! which itself is persisted *before* the source side effect, and `Completed` *after* — so a crash at -//! any point is recovered idempotently by [`recover`](Worker::recover): an already-`Verified` -//! destination is never re-delivered (the destination object already matches — stable key → idempotent -//! overwrite), and the completion action (delete/archive) fires **exactly once**, only after the last -//! destination verifies. +//! which itself is persisted *before* `ItemState::CleanupPending`, which is persisted *before* the +//! source side effect — so a crash at any point is recovered idempotently by +//! [`recover`](Worker::recover): an already-`Verified` destination is never re-delivered (the +//! destination object already matches — stable key → idempotent overwrite), and the completion action +//! (delete/archive) fires **exactly once**, only after the last destination verifies. +//! +//! ## Completion is proven, not assumed (DESIGN §20-I) +//! `Completed` means the source was **verifiably** released: the archive target exists at the expected +//! size (and re-hashes to the delivered checksum under `completion.verify = checksum`), or the deleted +//! source is really gone. A failed archive move, an unwritable or unconfigured `archiveDir`, or a +//! failed delete is a **cleanup failure**, not a success: the item stays `CleanupFailed`, `FileArchived` +//! /`FileDeleted` are not emitted, and the `replicated` statistic does not move. Cleanup retries run on +//! their own bounded budget ([`Worker::decide_cleanup`]), independent of the transfer's time-based +//! `giveUpAfter`; when it is spent the item stays `CleanupFailed` with a `FileCleanupFailed` event, +//! visible in `get-status` and re-drivable with the `trigger` command. //! //! Failures are classified by [`ReplError`](crate::error::ReplError): *permanent* errors fail fast to //! that destination's `Exhausted`; *transient*/*integrity* errors back off with **full-jitter @@ -48,6 +60,7 @@ use crate::domain::{ }; use crate::error::{ReplError, Result}; use crate::events::{Event, Events, ProgressThrottle}; +use crate::integrity::{hash_reader, verify_checksum, verify_size, Algorithm}; use crate::metrics::{MetricValues, ReplicatorMetrics}; use crate::permission::{PermissionLog, Role}; use crate::ratelimit::Bandwidth; @@ -64,6 +77,18 @@ const DEFAULT_BASE_DELAY_MS: u64 = 1_000; const DEFAULT_MAX_DELAY_MS: u64 = 900_000; const DEFAULT_GIVE_UP_AFTER_MS: i64 = 7 * 24 * 60 * 60 * 1_000; +/// Cleanup attempt budget when `retry.maxAttempts` is not configured (DESIGN §20-I). The source +/// completion action is a local filesystem operation, so it is bounded by **attempts** rather than by +/// the transfer's time-based `giveUpAfter` — that clock starts at discovery and is often largely spent +/// by the time the transfer finishes, which would leave a slow file with no cleanup retries at all. +/// Ten attempts on the shared full-jitter backoff (capped at `retry.maxDelayMs`, default 15 min) spans +/// roughly an hour of transient unavailability before an operator is asked to intervene. +const DEFAULT_CLEANUP_MAX_ATTEMPTS: u32 = 10; + +/// The `next_attempt_at` written for a `CleanupFailed` item whose cleanup budget is spent: no +/// reconciliation scan will ever re-drive it, only an explicit operator `trigger` (DESIGN §20-I). +const CLEANUP_NO_RETRY: i64 = i64::MAX; + /// The resolved retry/backoff policy for an instance (instance `retry` ▸ `global.defaults.retry` ▸ /// built-in defaults, field-by-field). #[derive(Debug, Clone, Copy)] @@ -259,8 +284,8 @@ pub struct Worker { /// Optional metric emitter. metrics: Option>, /// UNS event emitter for the per-file lifecycle events (`ReplicationStarted`/`…Progress`/ - /// `…Completed`/`…Failed`/`FileDeleted`/`FileArchived`/`RetriesExhausted`/`FileQuarantined`, - /// DESIGN §17). A no-op [`Events::disabled`] by default, so a worker built without messaging (or + /// `…Completed`/`…Failed`/`FileDeleted`/`FileArchived`/`FileCleanupFailed`/`RetriesExhausted`/ + /// `FileQuarantined`, DESIGN §17). A no-op [`Events::disabled`] by default, so a worker built without messaging (or /// in a unit test) runs the exact P1/P2 pipeline with zero event overhead. events: Events, /// Feature A (`src/permission.rs`) dedup-log for egress permission errors — ALWAYS logged once @@ -273,6 +298,10 @@ pub struct Worker { /// (ingress evicts recovered paths every tick; egress is inherently bounded by destination count), /// so they are deliberately NOT shared. A fresh, unshared [`PermissionLog`] by default. perm_log: Arc, + /// The source-side filesystem the completion action runs against. Always the real filesystem in + /// production ([`SourceFs::real`]); tests swap in a faulting handle to exercise the cleanup-failure + /// paths (DESIGN §20-I). + source_fs: SourceFs, } impl Worker { @@ -335,9 +364,19 @@ impl Worker { metrics, events: Events::disabled(), perm_log: Arc::new(PermissionLog::new()), + source_fs: SourceFs::real(), } } + /// Swap in a faulting source filesystem (tests only — see [`SourceFs`]). + #[cfg(test)] + fn with_source_faults(mut self, faults: SourceFaults) -> Self { + self.source_fs = SourceFs { + faults: Some(Arc::new(faults)), + }; + self + } + /// Attach the UNS event emitter (the P3 control-plane wiring path, [`crate::app`]). Consumes and /// returns `self` so it composes in [`crate::instance::Instance::build_with_dest`] before the /// worker is shared behind an `Arc`. With the default [`Events::disabled`] every emit is a no-op. @@ -379,8 +418,9 @@ impl Worker { join_rel(&self.ingress_root, &item.relpath) } - /// Process one claimed (`InProgress`) item to a terminal (or `Failed` retry) state, emitting - /// metrics. A durable-store fault is logged and the item is left as-is for the next recovery pass. + /// Process one claimed (`InProgress`) item to a terminal state, a `Failed` transfer retry, or a + /// `CleanupFailed` completion retry (DESIGN §20-I), emitting metrics. A durable-store fault is + /// logged and the item is left as-is for the next recovery pass. pub async fn process_item(&self, item: &WorkItem, now: i64) -> ItemState { // The store returns `relpath` only; rebuild the live absolute source path for delivery. let mut item = item.clone(); @@ -651,42 +691,103 @@ impl Worker { .clear_resume(&self.instance, &item.relpath, label); } - /// Run the success completion action (`delete` | `archive`) on the source, then persist - /// `Completed`. Idempotent: a missing source (already completed before a crash) is fine. Filesystem - /// errors are logged but still advance to `Completed` — the delivery succeeded and the durable - /// `Completed` marker stops the file being re-discovered, so a stale source can never loop. + /// Drive an item whose every destination is `Verified` through the source completion action + /// (`delete` | `archive`) to `Completed` — or, if that action fails, to `CleanupFailed` + /// (DESIGN §20-I). /// - /// The source-side filesystem work runs on the blocking pool: a cross-device archive falls back to - /// a whole-file copy, which must not block a shared async worker thread (DESIGN §6.3). + /// Thin wrapper over [`run_cleanup`](Self::run_cleanup), kept as the name the aggregate-`Verified` + /// call sites use ([`run_pipeline`](Self::run_pipeline), [`recover_verified`](Self::recover_verified), + /// and the window-close pause path). async fn complete_verified(&self, item: &WorkItem, now: i64) -> Result { - let src = self.abs_source(item); - let on_success = self.completion.on_success; - let archive_dir = self.completion.archive_dir.clone(); - let collision = self.completion.on_collision; - let relpath = item.relpath.clone(); - let instance = self.instance.clone(); - if let Err(e) = tokio::task::spawn_blocking(move || { - apply_success_action( - on_success, - &src, - archive_dir.as_deref(), - &relpath, - collision, - &instance, - ) - }) - .await - { - tracing::warn!( - instance = %self.instance, relpath = %item.relpath, error = %e, - "completion task join failed; marking complete anyway (delivery already succeeded)" - ); + self.run_cleanup(item, now).await + } + + /// Apply the source completion action and persist the outcome (DESIGN §13.2/§20-I). + /// + /// **Write-ahead:** `CleanupPending` is persisted *before* the filesystem is touched, so a crash + /// mid-action is found by [`recover`](Self::recover) and re-evaluated against observed state. + /// `Completed` is persisted only once [`apply_success_action`] has **proven** the action landed — + /// the archive target exists at the source's size (re-hashed to the delivered checksum under + /// `completion.verify = checksum`), or the deleted source is really gone. Anything else is a + /// cleanup failure: the item moves to `CleanupFailed`, no `FileArchived`/`FileDeleted` is emitted, + /// and the `replicated` statistic does not move. For an evidence pipeline a source left behind, or + /// an archive copy that never landed, is not a success and must never be recorded as one. + /// + /// The filesystem work runs on the blocking pool: a cross-device archive falls back to a whole-file + /// copy plus a re-hash, which must not block a shared async worker thread (DESIGN §6.3). + /// + /// Callers own the metric emission ([`emit`](Self::emit)) for the returned state. + pub(crate) async fn run_cleanup(&self, item: &WorkItem, now: i64) -> Result { + self.store.set_state( + &self.instance, + &item.relpath, + ItemState::CleanupPending, + now, + )?; + + let ctx = CleanupCtx { + on_success: self.completion.on_success, + src: self.abs_source(item), + archive_dir: self.completion.archive_dir.clone(), + relpath: item.relpath.clone(), + collision: self.completion.on_collision, + verify: self.completion.verify, + expected_size: item.size, + expected_checksum: self.delivered_checksum(&item.relpath), + }; + let action = ctx.action(); + let fs = self.source_fs.clone(); + let outcome = + match tokio::task::spawn_blocking(move || apply_success_action(&ctx, &fs)).await { + Ok(r) => r, + Err(join_err) => Err(ReplError::Transient(format!( + "completion task join failed: {join_err}" + ))), + }; + match outcome { + Ok(done) => self.finish_completed(item, done, now).await, + Err(e) => self.record_cleanup_failure(item, action, e, now).await, + } + } + + /// The checksum every destination verified this item's bytes against, read back from any + /// destination's `Verified` write-ahead checkpoint (they all hashed the same source bytes, and the + /// [`Checksum`] variant carries its own algorithm). The checkpoints are cleared only AFTER + /// `Completed`, so they are still present for every cleanup attempt, including one that runs after + /// a restart. [`Checksum::None`] when nothing was hashed on delivery. + fn delivered_checksum(&self, relpath: &str) -> Checksum { + for slot in &self.dests { + let Ok(Some(resume)) = self.store.load_resume(&self.instance, relpath, &slot.label) + else { + continue; + }; + let delivered: Option = resume + .token + .get("verified") + .cloned() + .and_then(|v| serde_json::from_value(v).ok()); + if let Some(d) = delivered { + if d.checksum != Checksum::None { + return d.checksum; + } + } } + Checksum::None + } + + /// Persist `Completed` after the source action was proven done, then emit the source-side success + /// event and count the file as replicated. + async fn finish_completed( + &self, + item: &WorkItem, + done: CleanupDone, + now: i64, + ) -> Result { self.store .set_state(&self.instance, &item.relpath, ItemState::Completed, now)?; tracing::info!( instance = %self.instance, relpath = %item.relpath, - action = ?self.completion.on_success, "item completed; source side-effect applied" + action = ?self.completion.on_success, "item completed; source side-effect verified" ); // Clear every destination's resume checkpoint + per-destination completion bookkeeping only // AFTER Completed is durable, so a crash in the aggregate Verified→Completed gap still finds @@ -702,27 +803,24 @@ impl Worker { }, )?; - // Lifecycle events (DESIGN §17.1): the source side effect that ran. `ReplicationCompleted` was - // already emitted per destination (by `run_one_dest`, or by `recover_verified` re-verifying on - // recovery) — this is the aggregate/source-side event, fired exactly once. - match self.completion.on_success { - OnSuccess::Delete => { + // Lifecycle events (DESIGN §17.1): the source side effect that actually ran. + // `ReplicationCompleted` was already emitted per destination (by `run_one_dest`, or by + // `recover_verified` re-verifying on recovery) — this is the aggregate/source-side event, fired + // exactly once, and only for an action that verifiably happened. `archivePath` is the path the + // file really landed at (which the `suffix` collision policy can rename), not a computed guess. + match done { + CleanupDone::Deleted => { self.events .emit(Event::FileDeleted { path: item.relpath.clone(), }) .await; } - OnSuccess::Archive => { - let archive_path = self - .completion - .archive_dir - .as_ref() - .map(|d| join_rel(d, &item.relpath).display().to_string()); + CleanupDone::Archived(target) => { self.events .emit(Event::FileArchived { path: item.relpath.clone(), - archive_path, + archive_path: target.map(|p| p.display().to_string()), }) .await; } @@ -730,6 +828,123 @@ impl Worker { Ok(ItemState::Completed) } + /// Record a failed source completion action (DESIGN §20-I): `cleanup_attempts += 1`, the error, and + /// the move to `CleanupFailed` with the item's next cleanup gate. While the cleanup budget lasts the + /// gate is a full-jitter backoff and the next reconciliation scan re-drives the item; once the + /// budget is spent the gate is [`CLEANUP_NO_RETRY`] (only an operator `trigger` re-drives it) and a + /// `FileCleanupFailed` event is emitted. + async fn record_cleanup_failure( + &self, + item: &WorkItem, + action: &str, + error: ReplError, + now: i64, + ) -> Result { + let attempts = item.cleanup_attempts.saturating_add(1); + let err_str = error.to_string(); + match self.decide_cleanup(error.is_permanent(), item.cleanup_attempts, now) { + RetryDecision::Retry { next_attempt_at } => { + self.store.record_cleanup_attempt( + &self.instance, + &item.relpath, + &err_str, + ItemState::CleanupFailed, + next_attempt_at, + now, + )?; + tracing::warn!( + instance = %self.instance, relpath = %item.relpath, action, + attempts, retry_at = next_attempt_at, error = %err_str, + "source completion action failed; scheduled for retry (item NOT completed)" + ); + } + RetryDecision::GiveUp => { + self.store.record_cleanup_attempt( + &self.instance, + &item.relpath, + &err_str, + ItemState::CleanupFailed, + CLEANUP_NO_RETRY, + now, + )?; + tracing::error!( + instance = %self.instance, relpath = %item.relpath, action, attempts, + error = %err_str, + "source completion action failed permanently; item parked in CleanupFailed \ + (delivered and verified on every destination, source NOT released)" + ); + self.events + .emit(Event::FileCleanupFailed { + path: item.relpath.clone(), + action: action.to_string(), + attempts, + last_error: err_str, + }) + .await; + } + } + Ok(ItemState::CleanupFailed) + } + + /// Decide whether a failed cleanup attempt is retried (DESIGN §20-I). Uses the instance's + /// [`RetryPolicy`] backoff — the same full-jitter exponential curve, capped at `retry.maxDelayMs` — + /// but its OWN attempt budget: `retry.maxAttempts` when configured, otherwise + /// [`DEFAULT_CLEANUP_MAX_ATTEMPTS`]. + /// + /// Deliberately NOT the transfer's time-based `giveUpAfter`: that clock starts at discovery and is + /// usually largely spent by the time a slow or long-retried transfer finishes, which would leave + /// exactly the files that struggled hardest with no cleanup retries at all. A permanent error — a + /// missing `archiveDir`, a permission denial, an archived copy that does not match — gives up + /// immediately, because retrying cannot change any of them and the operator needs it surfaced. + fn decide_cleanup(&self, permanent: bool, attempts_so_far: u32, now: i64) -> RetryDecision { + if permanent { + return RetryDecision::GiveUp; + } + let attempts = attempts_so_far.saturating_add(1); + let cap = self + .retry + .max_attempts + .unwrap_or(DEFAULT_CLEANUP_MAX_ATTEMPTS); + if attempts >= cap { + return RetryDecision::GiveUp; + } + let delay = { + let mut r = self.rng.lock().expect("rng mutex"); + self.retry.backoff_ms(attempts, &mut *r) + } as i64; + RetryDecision::Retry { + next_attempt_at: now.saturating_add(delay), + } + } + + /// Re-drive the items whose delivery is done but whose source completion action is not + /// (DESIGN §20-I) — every `CleanupPending` row (a crash or an abort left the action mid-flight) + /// plus the `CleanupFailed` rows whose cleanup backoff gate has elapsed. + /// + /// Called on every reconciliation tick, so a transient cleanup failure heals on its own. With + /// `redrive_all` (the operator `trigger` command) the gate is ignored, which is what rescues an + /// item whose cleanup budget is spent once the underlying cause — a full disk, a missing + /// `archiveDir`, a read-only mount — has been fixed. + pub async fn drive_cleanup(&self, now: i64, redrive_all: bool) -> Result<()> { + let mut due = self + .store + .list_by_state(&self.instance, ItemState::CleanupPending)?; + for it in self + .store + .list_by_state(&self.instance, ItemState::CleanupFailed)? + { + if redrive_all || it.next_attempt_at <= now { + due.push(it); + } + } + for mut it in due { + it.abs_source = self.abs_source(&it); + let state = self.run_cleanup(&it, now).await?; + self.emit(state, it.size).await; + } + Ok(()) + } + /// Terminal handling for an `Exhausted` item: `quarantine` (move to `failedDir` + an /// `.error.json` sidecar) or `retainInPlace` (leave it, mark `Retained`). Idempotent for recovery. /// The quarantine move + sidecar write run on the blocking pool (cross-device moves copy). @@ -770,8 +985,9 @@ impl Worker { destination: self.dest_labels_joined(), bytes_done: item.bytes_done, }; + let fs = self.source_fs.clone(); if let Err(e) = tokio::task::spawn_blocking(move || { - apply_quarantine_action(failed_dir.as_deref(), &src, collision, &ctx) + apply_quarantine_action(failed_dir.as_deref(), &src, collision, &ctx, &fs) }) .await { @@ -810,14 +1026,30 @@ impl Worker { } } - /// Crash recovery (DESIGN §13.2), idempotent. `Verified` items are **re-verified against the - /// destination before** the source side effect (see [`recover_verified`](Self::recover_verified)); - /// `InProgress` items return to `Ready` for idempotent re-delivery; `Exhausted` items (a crash - /// before their terminal action) re-run `onExhausted`. + /// Crash recovery (DESIGN §13.2), idempotent, run before any new work. `Verified` items are + /// **re-verified against the destination before** the source side effect (see + /// [`recover_verified`](Self::recover_verified)); `CleanupPending` items — a crash caught between + /// the write-ahead cleanup marker and the proof that the action landed — are re-evaluated against + /// observed filesystem state by [`run_cleanup`](Self::run_cleanup) (DESIGN §20-I): a source still + /// present means the action is retried, a source already gone (deleted, or archived — `move_file` + /// removes it only after the target rename succeeds) means the item completes. `InProgress` items + /// return to `Ready` for idempotent re-delivery; `Exhausted` items (a crash before their terminal + /// action) re-run `onExhausted`. + /// + /// `CleanupFailed` items are NOT re-driven here: they carry their own backoff gate and are picked + /// up by [`drive_cleanup`](Self::drive_cleanup) on the reconciliation tick that follows startup. pub async fn recover(&self, now: i64) -> Result<()> { for it in self.store.recover_incomplete(&self.instance)? { match it.state { ItemState::Verified => self.recover_verified(&it, now).await?, + ItemState::CleanupPending => { + tracing::info!( + instance = %self.instance, relpath = %it.relpath, + "recovering CleanupPending → re-evaluating the source completion action" + ); + let state = self.run_cleanup(&it, now).await?; + self.emit(state, it.size).await; + } ItemState::InProgress => { // Before re-readying, re-verify any destination a PRIOR session already marked // `DestPhase::Verified` (fan-out narrows the §13.2 crash window — see @@ -883,8 +1115,10 @@ impl Worker { if all_ok { tracing::info!(instance = %self.instance, relpath = %it.relpath, "recovering Verified → every destination re-verified, completing"); - let _ = self.complete_verified(it, now).await?; - self.emit(ItemState::Completed, it.size).await; + // The completion action can still fail (an unwritable archive, a vanished archiveDir), so + // report the state it actually reached — `Completed` or `CleanupFailed` (DESIGN §20-I). + let state = self.complete_verified(it, now).await?; + self.emit(state, it.size).await; } else { self.store .set_state(&self.instance, &it.relpath, ItemState::Ready, now)?; @@ -1547,6 +1781,118 @@ fn percent_int(done: u64, size: u64) -> i32 { (((done as f64 / size as f64) * 100.0).round() as i32).clamp(0, 100) } +/// The source-side filesystem operations the completion action performs, behind one cloneable handle +/// so tests can inject faults (a failing rename, an uncreatable archive directory, a failing delete, a +/// cross-filesystem copy) deterministically — no real cross-device mount and no platform-specific +/// permission tricks. In production every call delegates straight to `std::fs`; the fault table exists +/// only in test builds, mirroring [`Events`]'s test recorder, so the shipping path carries nothing but +/// the plain `std::fs` call. +#[derive(Clone, Default)] +pub(crate) struct SourceFs { + #[cfg(test)] + faults: Option>, +} + +impl SourceFs { + /// The real filesystem (the only constructor used outside tests). + pub(crate) fn real() -> Self { + Self::default() + } + + /// The injected error for `op`, if this handle is faulting it on this call. + #[cfg(test)] + fn fault(&self, op: FsOp) -> Option { + self.faults.as_ref().and_then(|f| f.take(op)) + } + #[cfg(not(test))] + #[inline] + fn fault(&self, _op: FsOp) -> Option { + None + } + + fn remove_file(&self, path: &Path) -> std::io::Result<()> { + match self.fault(FsOp::RemoveFile) { + Some(e) => Err(e), + None => std::fs::remove_file(path), + } + } + + fn create_dir_all(&self, path: &Path) -> std::io::Result<()> { + match self.fault(FsOp::CreateDirAll) { + Some(e) => Err(e), + None => std::fs::create_dir_all(path), + } + } + + fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> { + match self.fault(FsOp::Rename) { + Some(e) => Err(e), + None => std::fs::rename(from, to), + } + } + + fn copy(&self, from: &Path, to: &Path) -> std::io::Result { + match self.fault(FsOp::Copy) { + Some(e) => Err(e), + None => std::fs::copy(from, to), + } + } +} + +/// The [`SourceFs`] operations a test can fault. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FsOp { + RemoveFile, + CreateDirAll, + Rename, + Copy, +} + +/// Test-only fault table for [`SourceFs`]: each faulted operation returns `kind` instead of touching +/// the filesystem. `heal_after` bounds how many faulted calls are served before the table stops +/// faulting, which is what lets a test prove "cleanup retries, then succeeds". +#[cfg(test)] +#[derive(Debug, Default)] +pub(crate) struct SourceFaults { + ops: Vec<(FsOp, std::io::ErrorKind)>, + heal_after: Option, + calls: std::sync::atomic::AtomicU32, +} + +#[cfg(test)] +impl SourceFaults { + /// Fault `op` with `kind` on every call. + pub(crate) fn always(op: FsOp, kind: std::io::ErrorKind) -> Self { + SourceFaults { + ops: vec![(op, kind)], + heal_after: None, + calls: std::sync::atomic::AtomicU32::new(0), + } + } + + /// Also fault `op` with `kind` (chained onto an existing table). + pub(crate) fn and(mut self, op: FsOp, kind: std::io::ErrorKind) -> Self { + self.ops.push((op, kind)); + self + } + + /// Stop faulting after `n` faulted calls have been served. + pub(crate) fn heal_after(mut self, n: u32) -> Self { + self.heal_after = Some(n); + self + } + + fn take(&self, op: FsOp) -> Option { + let kind = self.ops.iter().find(|(o, _)| *o == op).map(|(_, k)| *k)?; + if let Some(limit) = self.heal_after { + if self.calls.fetch_add(1, Ordering::SeqCst) >= limit { + return None; + } + } + Some(std::io::Error::new(kind, format!("injected {op:?} fault"))) + } +} + /// Join a forward-slash `relpath` onto `root`, dropping empty/`.`/`..` segments so the result always /// stays under `root` (mirrors the destination key derivation). fn join_rel(root: &Path, relpath: &str) -> PathBuf { @@ -1560,45 +1906,49 @@ fn join_rel(root: &Path, relpath: &str) -> PathBuf { p } -/// Move `src` → `dst`, creating parents and honoring the collision policy. Prefers an atomic rename; -/// falls back to a **crash-atomic** copy across filesystems. +/// Move `src` → `dst`, creating parents and honoring the collision policy, returning the **resolved** +/// target the file actually landed at (which differs from `dst` under the `suffix` policy). Prefers an +/// atomic rename; falls back to a **crash-atomic** copy across filesystems. /// /// The cross-device fallback copies into a sibling temp file in the destination directory, then /// atomically renames it onto the resolved target and removes the source. Copying via a temp keeps /// the move crash-atomic: a crash *during* the (long) copy leaves only an orphan temp, never a /// partially-written `target` that a recovery pass would mistake for a real file and duplicate under /// the default `suffix` collision policy (`name` + `name.1.ext`). (DESIGN §13.2.) -fn move_file(src: &Path, dst: &Path, collision: Collision) -> Result<()> { +/// +/// Every filesystem call goes through [`SourceFs`] so the failure branches are testable (DESIGN §20-I); +/// the caller reports the returned path and verifies the file landed there. +fn move_file(src: &Path, dst: &Path, collision: Collision, fs: &SourceFs) -> Result { if let Some(parent) = dst.parent() { - std::fs::create_dir_all(parent).map_err(ReplError::classify_io)?; + fs.create_dir_all(parent).map_err(ReplError::classify_io)?; } - let target = resolve_collision(dst, collision)?; + let target = resolve_collision(dst, collision, fs)?; // Fast path: same-filesystem rename is already atomic. - if std::fs::rename(src, &target).is_ok() { - return Ok(()); + if fs.rename(src, &target).is_ok() { + return Ok(target); } // Cross-device (or Windows target-exists) fallback: copy → temp, atomic rename, remove source. let tmp = move_temp_path(&target); - if let Err(e) = std::fs::copy(src, &tmp) { + if let Err(e) = fs.copy(src, &tmp) { let _ = std::fs::remove_file(&tmp); return Err(ReplError::classify_io(e)); } - if std::fs::rename(&tmp, &target).is_err() { + if fs.rename(&tmp, &target).is_err() { // Windows rename won't overwrite; `resolve_collision` should have freed the target, but be // defensive: remove an existing target then retry, cleaning the temp on any failure. if target.exists() { - if let Err(e) = std::fs::remove_file(&target) { + if let Err(e) = fs.remove_file(&target) { let _ = std::fs::remove_file(&tmp); return Err(ReplError::classify_io(e)); } } - if let Err(e) = std::fs::rename(&tmp, &target) { + if let Err(e) = fs.rename(&tmp, &target) { let _ = std::fs::remove_file(&tmp); return Err(ReplError::classify_io(e)); } } - std::fs::remove_file(src).map_err(ReplError::classify_io)?; - Ok(()) + fs.remove_file(src).map_err(ReplError::classify_io)?; + Ok(target) } /// A collision-resistant temp path in the destination directory for the crash-atomic move fallback: @@ -1616,42 +1966,137 @@ fn move_temp_path(target: &Path) -> PathBuf { dir.join(format!(".{name}.{tag:032x}.movetmp")) } -/// The success completion side effect (`delete` | `archive`) on the source file, run on the blocking -/// pool by [`Worker::complete_verified`]. Idempotent: a missing source (already completed before a -/// crash) is fine. Errors are logged, not fatal — delivery already succeeded and the durable -/// `Completed` marker stops re-discovery, so a stale source can never loop. -fn apply_success_action( +/// Everything the source-side completion action needs, carried into the blocking pool by +/// [`Worker::run_cleanup`]. +struct CleanupCtx { on_success: OnSuccess, - src: &Path, - archive_dir: Option<&Path>, - relpath: &str, + /// Absolute source path. + src: PathBuf, + archive_dir: Option, + relpath: String, collision: Collision, - instance: &str, -) { - match on_success { + /// `completion.verify` — decides whether the archived copy is re-hashed or only size-checked. + verify: Verify, + /// The source's byte count, as every destination verified it. + expected_size: u64, + /// The checksum the destinations verified against, recovered from a `Verified` write-ahead + /// checkpoint. [`Checksum::None`] when nothing was hashed (`completion.verify` is `size`/`none`, + /// or the checkpoint is gone), which falls the archive proof back to the size check. + expected_checksum: Checksum, +} + +impl CleanupCtx { + /// The wire token for the action this context performs (`FileCleanupFailed.action`). + fn action(&self) -> &'static str { + match self.on_success { + OnSuccess::Delete => "delete", + OnSuccess::Archive => "archive", + } + } +} + +/// What the completion action verifiably did — the input to the success event. +enum CleanupDone { + /// The source is gone. + Deleted, + /// The source landed in the archive at this path. `None` only when a prior, crashed attempt had + /// already moved it under a collision-resolved name this pass cannot re-derive. + Archived(Option), +} + +/// The success completion side effect (`delete` | `archive`) on the source file, run on the blocking +/// pool by [`Worker::run_cleanup`], returning **only** once the action is proven to have happened +/// (DESIGN §20-I). +/// +/// Idempotent for recovery: a source a prior attempt already released is re-checked against observed +/// filesystem state rather than re-applied — an absent source under `delete` is done, and an absent +/// source under `archive` means the move's final rename already succeeded (`move_file` removes the +/// source only afterwards), so the archived copy is re-verified where it can still be identified. +/// +/// Every failure is returned, never swallowed: an unwritable or unconfigured `archiveDir`, a failing +/// rename or copy, a failing delete, and a target that does not match the source are all cleanup +/// failures that keep the item out of `Completed`. +fn apply_success_action(ctx: &CleanupCtx, fs: &SourceFs) -> Result { + match ctx.on_success { OnSuccess::Delete => { - if let Err(e) = std::fs::remove_file(src) { - if e.kind() != std::io::ErrorKind::NotFound { - tracing::warn!(path = %src.display(), error = %e, "delete source failed"); - } + match fs.remove_file(&ctx.src) { + Ok(()) => {} + // Already gone: a prior attempt (or one interrupted by a crash) succeeded. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(ReplError::classify_io(e)), } + if ctx.src.exists() { + return Err(ReplError::Transient(format!( + "delete returned success but the source is still present: {}", + ctx.src.display() + ))); + } + Ok(CleanupDone::Deleted) } - OnSuccess::Archive => match archive_dir { - Some(dir) if src.exists() => { - let dst = join_rel(dir, relpath); - if let Err(e) = move_file(src, &dst, collision) { - tracing::warn!( - src = %src.display(), dst = %dst.display(), error = %e, - "archive move failed; leaving source in place" - ); + OnSuccess::Archive => { + // A missing `archiveDir` cannot archive anything. Permanent: no number of retries makes a + // configuration appear, and the operator needs it surfaced now rather than in an hour. + let Some(dir) = ctx.archive_dir.as_deref() else { + return Err(ReplError::Permanent(format!( + "onSuccess=archive requires completion.archiveDir; cannot archive {}", + ctx.relpath + ))); + }; + let dst = join_rel(dir, &ctx.relpath); + if !ctx.src.exists() { + // The source was already released by a prior attempt. If the un-suffixed target is + // there, prove it; otherwise the file landed under a collision-resolved name, and with + // the source gone and every destination verified the item is complete. + if dst.exists() { + verify_archived(&dst, ctx)?; + return Ok(CleanupDone::Archived(Some(dst))); } + tracing::warn!( + relpath = %ctx.relpath, dst = %dst.display(), + "source already archived under a collision-resolved name; completing without a path" + ); + return Ok(CleanupDone::Archived(None)); } - Some(_) => {} // source already gone (idempotent recovery) - None => tracing::warn!( - instance = %instance, relpath = %relpath, - "onSuccess=archive but no archiveDir configured; leaving source in place" - ), - }, + let target = move_file(&ctx.src, &dst, ctx.collision, fs)?; + verify_archived(&target, ctx)?; + Ok(CleanupDone::Archived(Some(target))) + } + } +} + +/// Prove an archived file is really there and really the file (DESIGN §20-I): it must exist at the +/// source's byte count and, under `completion.verify = checksum`, re-hash to the checksum every +/// destination verified against. +/// +/// A failure to *read* the target (an unmounted archive volume, a transient I/O error) is classified +/// as usual and retried. A genuine size/checksum **mismatch** is returned as +/// [`ReplError::Permanent`]: the move already released the source, so no retry can change the outcome +/// and the operator needs the item parked in `CleanupFailed` with the reason immediately. +fn verify_archived(target: &Path, ctx: &CleanupCtx) -> Result<()> { + let len = std::fs::metadata(target) + .map_err(ReplError::classify_io)? + .len(); + verify_size(ctx.expected_size, len).map_err(permanent_mismatch)?; + if ctx.verify != Verify::Checksum { + return Ok(()); + } + let algo = match ctx.expected_checksum { + Checksum::Crc32c(_) => Algorithm::Crc32c, + Checksum::Sha256(_) => Algorithm::Sha256, + // Nothing was hashed on delivery, so the size check above is the whole proof available. + Checksum::None => return Ok(()), + }; + let mut f = std::fs::File::open(target).map_err(ReplError::classify_io)?; + let (_, actual) = hash_reader(&mut f, algo).map_err(ReplError::classify_io)?; + verify_checksum(&ctx.expected_checksum, &actual).map_err(permanent_mismatch) +} + +/// Re-tag an integrity mismatch on an already-moved archive copy as permanent (see +/// [`verify_archived`]); any other error keeps its classification. +fn permanent_mismatch(e: ReplError) -> ReplError { + match e { + ReplError::Integrity(m) => ReplError::Permanent(format!("archived copy mismatch: {m}")), + other => other, } } @@ -1675,12 +2120,13 @@ fn apply_quarantine_action( src: &Path, collision: Collision, ctx: &QuarantineCtx, + fs: &SourceFs, ) { match failed_dir { Some(dir) => { let dst = join_rel(dir, &ctx.relpath); if src.exists() { - if let Err(e) = move_file(src, &dst, collision) { + if let Err(e) = move_file(src, &dst, collision, fs) { tracing::warn!( src = %src.display(), dst = %dst.display(), error = %e, "quarantine move failed" @@ -1724,13 +2170,13 @@ fn write_error_sidecar(dst: &Path, ctx: &QuarantineCtx) { /// Resolve the effective target path for a collision policy: `overwrite` removes the existing file, /// `suffix` finds a free `name.N.ext`, `fail` errors permanently. -fn resolve_collision(dst: &Path, collision: Collision) -> Result { +fn resolve_collision(dst: &Path, collision: Collision, fs: &SourceFs) -> Result { if !dst.exists() { return Ok(dst.to_path_buf()); } match collision { Collision::Overwrite => { - std::fs::remove_file(dst).map_err(ReplError::classify_io)?; + fs.remove_file(dst).map_err(ReplError::classify_io)?; Ok(dst.to_path_buf()) } Collision::Fail => Err(ReplError::Permanent(format!( @@ -1872,6 +2318,7 @@ mod tests { size: 1, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, @@ -1921,7 +2368,7 @@ mod tests { let src = dir.path().join("src.txt"); std::fs::write(&src, b"hi").unwrap(); let dst = dir.path().join("sub/dir/out.txt"); - move_file(&src, &dst, Collision::Fail).unwrap(); + move_file(&src, &dst, Collision::Fail, &SourceFs::real()).unwrap(); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).unwrap(), b"hi"); } @@ -1938,18 +2385,18 @@ mod tests { // Fail → error, source untouched. let s1 = mk("s1.txt", b"a"); - assert!(move_file(&s1, &dst, Collision::Fail).is_err()); + assert!(move_file(&s1, &dst, Collision::Fail, &SourceFs::real()).is_err()); assert!(s1.exists()); // Suffix → writes dst.1.txt, original preserved. let s2 = mk("s2.txt", b"b"); - move_file(&s2, &dst, Collision::Suffix).unwrap(); + move_file(&s2, &dst, Collision::Suffix, &SourceFs::real()).unwrap(); assert_eq!(std::fs::read(dir.path().join("dst.1.txt")).unwrap(), b"b"); assert_eq!(std::fs::read(&dst).unwrap(), b"existing"); // Overwrite → replaces dst. let s3 = mk("s3.txt", b"c"); - move_file(&s3, &dst, Collision::Overwrite).unwrap(); + move_file(&s3, &dst, Collision::Overwrite, &SourceFs::real()).unwrap(); assert_eq!(std::fs::read(&dst).unwrap(), b"c"); } @@ -2383,6 +2830,7 @@ mod tests { size: 18, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, @@ -2458,6 +2906,7 @@ mod tests { size: 16, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, @@ -3409,6 +3858,7 @@ mod tests { size: 7, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, @@ -3631,6 +4081,7 @@ mod tests { size: 7, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, @@ -3764,6 +4215,7 @@ mod tests { size: 7, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, @@ -3888,4 +4340,704 @@ mod tests { "completes exactly once" ); } + + // ---- source-completion failures are NOT success (DESIGN §20-I) ------------------------------- + // + // Every test below shares one invariant: a file that replicated and verified on every destination + // but whose source completion action did NOT verifiably happen must never reach `Completed`, never + // emit `FileArchived`/`FileDeleted`, and never move the `replicated` statistic. For an evidence + // pipeline that combination is a false record of custody. + + /// A worker with an archive completion pointing at `archive_root`, and an injected source-side + /// filesystem fault table. + fn archiving_worker( + store: Arc, + src_root: &Path, + dst_root: &Path, + archive_root: Option<&Path>, + retry: RetryPolicy, + faults: SourceFaults, + ) -> Worker { + let mut comp = completion(OnSuccess::Archive); + comp.archive_dir = archive_root.map(|p| p.to_path_buf()); + local_worker(store, src_root, dst_root, comp, retry).with_source_faults(faults) + } + + /// Claim the single ready item and run it through the whole pipeline. + async fn process_only_item( + worker: &Worker, + store: &Arc, + now: i64, + ) -> ItemState { + let item = store.claim_ready(INST, 10, now).unwrap().pop().unwrap(); + worker.process_item(&item, now).await + } + + #[tokio::test] + async fn archive_move_failure_leaves_the_item_cleanup_failed_not_completed() { + // The defect this fixes: the transfer succeeded, the archive move did not, and the item was + // still recorded as `Completed` + `FileArchived` with a computed path that pointed at nothing. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let archive = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + // Both the rename fast path and the cross-device copy fallback fail: the move cannot happen. + let worker = archiving_worker( + store.clone(), + src.path(), + dst.path(), + Some(archive.path()), + RetryPolicy::default(), + SourceFaults::always(FsOp::Rename, std::io::ErrorKind::Other) + .and(FsOp::Copy, std::io::ErrorKind::Other), + ) + .with_events(events); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + + let row = store.get(INST, "r.csv").unwrap().unwrap(); + assert_eq!(row.state, ItemState::CleanupFailed, "never Completed"); + assert_eq!(row.cleanup_attempts, 1); + assert!(row.last_error.is_some()); + assert!( + src.path().join("r.csv").exists(), + "the source is still there — that is exactly why this is not a success" + ); + assert!( + !archive.path().join("r.csv").exists(), + "nothing was archived" + ); + assert!( + fake.events_named("FileArchived").is_empty(), + "no FileArchived for a move that never happened" + ); + assert_eq!( + store.stats(INST).unwrap().replicated, + 0, + "a cleanup failure is not a replicated file" + ); + // The delivery itself did succeed and is still reported per destination. + assert_eq!(fake.events_named("ReplicationCompleted").len(), 1); + assert_eq!( + std::fs::read(dst.path().join("r.csv")).unwrap(), + b"evidence" + ); + } + + #[tokio::test] + async fn missing_archive_dir_is_a_cleanup_failure_not_a_silent_retain() { + // `onSuccess: archive` with no `archiveDir` used to log a warning, leave the source in place, + // and still mark the item Completed. It is a permanent cleanup failure: no retry can conjure + // configuration, so the item is parked and the operator is told once. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + let worker = archiving_worker( + store.clone(), + src.path(), + dst.path(), + None, // no archiveDir configured + RetryPolicy::default(), + SourceFaults::default(), + ) + .with_events(events); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + + let row = store.get(INST, "r.csv").unwrap().unwrap(); + assert_eq!(row.state, ItemState::CleanupFailed); + assert_eq!( + row.next_attempt_at, CLEANUP_NO_RETRY, + "permanent → parked for the operator, not retried on a timer" + ); + assert!(src.path().join("r.csv").exists(), "source retained"); + assert!(fake.events_named("FileArchived").is_empty()); + assert_eq!(store.stats(INST).unwrap().replicated, 0); + + let failures = fake.events_named("FileCleanupFailed"); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].body["path"], serde_json::json!("r.csv")); + assert_eq!(failures[0].body["action"], serde_json::json!("archive")); + assert_eq!(failures[0].body["attempts"], serde_json::json!(1)); + assert!(failures[0].body["lastError"] + .as_str() + .unwrap() + .contains("archiveDir")); + } + + #[tokio::test] + async fn uncreatable_archive_dir_is_a_cleanup_failure() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let archive = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + let worker = archiving_worker( + store.clone(), + src.path(), + dst.path(), + Some(archive.path()), + RetryPolicy::default(), + SourceFaults::always(FsOp::CreateDirAll, std::io::ErrorKind::PermissionDenied), + ) + .with_events(events); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + assert!(src.path().join("r.csv").exists()); + assert!(fake.events_named("FileArchived").is_empty()); + assert_eq!(store.stats(INST).unwrap().replicated, 0); + // A permission denial is permanent for the retry engine, so the operator hears about it now. + assert_eq!(fake.events_named("FileCleanupFailed").len(), 1); + } + + #[tokio::test] + async fn delete_failure_leaves_the_item_cleanup_failed_not_completed() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + let worker = local_worker( + store.clone(), + src.path(), + dst.path(), + completion(OnSuccess::Delete), + RetryPolicy::default(), + ) + .with_source_faults(SourceFaults::always( + FsOp::RemoveFile, + std::io::ErrorKind::Other, + )) + .with_events(events); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().state, + ItemState::CleanupFailed + ); + assert!( + src.path().join("r.csv").exists(), + "the source is still there" + ); + assert!(fake.events_named("FileDeleted").is_empty()); + assert_eq!(store.stats(INST).unwrap().replicated, 0); + } + + #[tokio::test] + async fn cross_filesystem_archive_copies_then_verifies_and_completes() { + // The rename fast path fails (as it does across a device boundary); the copy → temp → rename + // fallback runs, the archived copy is re-hashed against the delivered checksum, and only then + // does the item complete. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let archive = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + let worker = archiving_worker( + store.clone(), + src.path(), + dst.path(), + Some(archive.path()), + RetryPolicy::default(), + // Only the FIRST rename (the same-filesystem fast path) fails; the fallback's temp→target + // rename then succeeds. + SourceFaults::always(FsOp::Rename, std::io::ErrorKind::Other).heal_after(1), + ) + .with_events(events); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::Completed + ); + assert!(!src.path().join("r.csv").exists(), "source released"); + assert_eq!( + std::fs::read(archive.path().join("r.csv")).unwrap(), + b"evidence" + ); + assert_eq!(store.stats(INST).unwrap().replicated, 1); + let archived = fake.events_named("FileArchived"); + assert_eq!(archived.len(), 1); + assert_eq!( + archived[0].body["archivePath"].as_str().unwrap(), + archive.path().join("r.csv").display().to_string(), + "the path reported is the one the file really landed at" + ); + // No orphan temp left behind by the fallback. + let leftovers: Vec<_> = std::fs::read_dir(archive.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains("movetmp")) + .collect(); + assert!(leftovers.is_empty(), "temp file cleaned up"); + } + + #[tokio::test] + async fn a_cleanup_failure_is_retried_and_then_succeeds() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + let worker = local_worker( + store.clone(), + src.path(), + dst.path(), + completion(OnSuccess::Delete), + RetryPolicy::default(), + ) + .with_source_faults( + SourceFaults::always(FsOp::RemoveFile, std::io::ErrorKind::Other).heal_after(1), + ) + .with_events(events); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + assert_eq!(store.stats(INST).unwrap().replicated, 0); + + // Past the backoff gate, the reconciliation pass re-drives it and it completes. + worker.drive_cleanup(10_000_000, false).await.unwrap(); + let row = store.get(INST, "r.csv").unwrap().unwrap(); + assert_eq!(row.state, ItemState::Completed); + assert!(!src.path().join("r.csv").exists()); + assert_eq!(store.stats(INST).unwrap().replicated, 1); + assert_eq!( + fake.events_named("FileDeleted").len(), + 1, + "the success event fires exactly once, on the attempt that worked" + ); + assert!(fake.events_named("FileCleanupFailed").is_empty()); + } + + #[tokio::test] + async fn a_cleanup_failure_that_exhausts_its_budget_parks_and_reports() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + let retry = RetryPolicy { + max_attempts: Some(3), + ..RetryPolicy::default() + }; + let worker = local_worker( + store.clone(), + src.path(), + dst.path(), + completion(OnSuccess::Delete), + retry, + ) + .with_source_faults(SourceFaults::always( + FsOp::RemoveFile, + std::io::ErrorKind::Other, + )) + .with_events(events); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + worker.drive_cleanup(10_000_000, false).await.unwrap(); // attempt 2 → still retrying + assert!(fake.events_named("FileCleanupFailed").is_empty()); + worker.drive_cleanup(20_000_000, false).await.unwrap(); // attempt 3 → budget spent + + let row = store.get(INST, "r.csv").unwrap().unwrap(); + assert_eq!(row.state, ItemState::CleanupFailed); + assert_eq!(row.cleanup_attempts, 3); + assert_eq!(row.next_attempt_at, CLEANUP_NO_RETRY); + assert_eq!(row.attempts, 0, "the transfer budget was never touched"); + assert!(src.path().join("r.csv").exists()); + assert_eq!(store.stats(INST).unwrap().replicated, 0); + + let failures = fake.events_named("FileCleanupFailed"); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].body["action"], serde_json::json!("delete")); + assert_eq!(failures[0].body["attempts"], serde_json::json!(3)); + + // A parked item is no longer picked up by the ordinary reconciliation pass. + worker.drive_cleanup(i64::MAX - 1, false).await.unwrap(); + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().cleanup_attempts, + 3, + "no further automatic attempts once the budget is spent" + ); + } + + #[tokio::test] + async fn trigger_redrives_a_parked_cleanup_failure() { + // The operator path back: fix the cause, then `trigger` (which re-drives regardless of the + // gate). Modelled here by the fault table healing before the forced re-drive. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let retry = RetryPolicy { + max_attempts: Some(2), + ..RetryPolicy::default() + }; + let worker = local_worker( + store.clone(), + src.path(), + dst.path(), + completion(OnSuccess::Delete), + retry, + ) + .with_source_faults( + SourceFaults::always(FsOp::RemoveFile, std::io::ErrorKind::Other).heal_after(2), + ); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + worker.drive_cleanup(10_000_000, false).await.unwrap(); // attempt 2 → budget spent, parked + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().next_attempt_at, + CLEANUP_NO_RETRY + ); + + worker.drive_cleanup(20_000_000, true).await.unwrap(); + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().state, + ItemState::Completed + ); + assert!(!src.path().join("r.csv").exists()); + assert_eq!(store.stats(INST).unwrap().replicated, 1); + } + + /// Drive a file all the way to `CleanupPending` with a failing source filesystem, then hand the + /// durable state to a fresh, fault-free worker — the shape of a crash inside the write-ahead + /// cleanup window. + async fn crash_into_cleanup_pending( + store: &Arc, + src: &Path, + dst: &Path, + archive: Option<&Path>, + on_success: OnSuccess, + ) { + let mut comp = completion(on_success); + comp.archive_dir = archive.map(|p| p.to_path_buf()); + let crashing = local_worker(store.clone(), src, dst, comp, RetryPolicy::default()) + .with_source_faults( + SourceFaults::always(FsOp::Rename, std::io::ErrorKind::Other) + .and(FsOp::Copy, std::io::ErrorKind::Other) + .and(FsOp::RemoveFile, std::io::ErrorKind::Other), + ); + process_only_item(&crashing, store, 100).await; + // The prior run died between the write-ahead marker and the proof, leaving the row pending. + store + .set_state(INST, "r.csv", ItemState::CleanupPending, 100) + .unwrap(); + } + + #[tokio::test] + async fn recovery_retries_a_cleanup_pending_item_whose_source_is_still_present() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let archive = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + crash_into_cleanup_pending( + &store, + src.path(), + dst.path(), + Some(archive.path()), + OnSuccess::Archive, + ) + .await; + assert!(src.path().join("r.csv").exists(), "source still on disk"); + + let (fake, events) = recording_events(); + let recovered = archiving_worker( + store.clone(), + src.path(), + dst.path(), + Some(archive.path()), + RetryPolicy::default(), + SourceFaults::default(), + ) + .with_events(events); + recovered.recover(200).await.unwrap(); + + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().state, + ItemState::Completed + ); + assert_eq!( + std::fs::read(archive.path().join("r.csv")).unwrap(), + b"evidence" + ); + assert!(!src.path().join("r.csv").exists()); + assert_eq!(fake.events_named("FileArchived").len(), 1); + assert_eq!(store.stats(INST).unwrap().replicated, 1); + } + + #[tokio::test] + async fn recovery_completes_a_cleanup_pending_item_already_archived_before_the_crash() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let archive = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + crash_into_cleanup_pending( + &store, + src.path(), + dst.path(), + Some(archive.path()), + OnSuccess::Archive, + ) + .await; + // The prior run's move DID land; the crash fell between the rename and the `Completed` write. + std::fs::rename(src.path().join("r.csv"), archive.path().join("r.csv")).unwrap(); + + let (fake, events) = recording_events(); + let recovered = archiving_worker( + store.clone(), + src.path(), + dst.path(), + Some(archive.path()), + RetryPolicy::default(), + SourceFaults::default(), + ) + .with_events(events); + recovered.recover(200).await.unwrap(); + + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().state, + ItemState::Completed, + "source absent + target present → complete" + ); + assert_eq!( + std::fs::read(archive.path().join("r.csv")).unwrap(), + b"evidence", + "the archived copy is untouched, not moved twice" + ); + assert_eq!(fake.events_named("FileArchived").len(), 1); + assert_eq!(store.stats(INST).unwrap().replicated, 1); + } + + #[tokio::test] + async fn recovery_completes_a_cleanup_pending_item_whose_source_was_already_deleted() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + crash_into_cleanup_pending(&store, src.path(), dst.path(), None, OnSuccess::Delete).await; + // The prior run's delete DID land; the crash fell before the `Completed` write. + std::fs::remove_file(src.path().join("r.csv")).unwrap(); + + let (fake, events) = recording_events(); + let recovered = local_worker( + store.clone(), + src.path(), + dst.path(), + completion(OnSuccess::Delete), + RetryPolicy::default(), + ) + .with_events(events); + recovered.recover(200).await.unwrap(); + + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().state, + ItemState::Completed, + "source absent after a delete intent → complete" + ); + assert_eq!(fake.events_named("FileDeleted").len(), 1); + assert_eq!(store.stats(INST).unwrap().replicated, 1); + } + + #[tokio::test] + async fn a_cleanup_failed_source_is_never_re_enqueued_by_a_rescan() { + // The source of a cleanup-failed item stays on disk, so every rescan re-discovers it. It must + // not become new work: the bytes are already delivered and verified everywhere. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let worker = local_worker( + store.clone(), + src.path(), + dst.path(), + completion(OnSuccess::Delete), + RetryPolicy::default(), + ) + .with_source_faults(SourceFaults::always( + FsOp::RemoveFile, + std::io::ErrorKind::Other, + )); + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + + // A rescan re-discovers the still-present source and re-upserts it. + store.upsert_ready(INST, "r.csv", 8, 0, 300).unwrap(); + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().state, + ItemState::CleanupFailed + ); + assert!( + store.claim_ready(INST, 10, 300).unwrap().is_empty(), + "not claimable as new work" + ); + } + + #[tokio::test] + async fn the_archive_path_reported_is_the_collision_resolved_one() { + // Under the default `suffix` policy the file lands at `r.1.csv`; the event must say so rather + // than report the computed `r.csv` that belongs to somebody else's file. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let archive = tempfile::tempdir().unwrap(); + std::fs::write(archive.path().join("r.csv"), b"an older run").unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + let worker = archiving_worker( + store.clone(), + src.path(), + dst.path(), + Some(archive.path()), + RetryPolicy::default(), + SourceFaults::default(), + ) + .with_events(events); + + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::Completed + ); + let archived = fake.events_named("FileArchived"); + assert_eq!(archived.len(), 1); + assert_eq!( + archived[0].body["archivePath"].as_str().unwrap(), + archive.path().join("r.1.csv").display().to_string() + ); + assert_eq!( + std::fs::read(archive.path().join("r.1.csv")).unwrap(), + b"evidence" + ); + assert_eq!( + std::fs::read(archive.path().join("r.csv")).unwrap(), + b"an older run", + "the pre-existing file is untouched" + ); + } + + #[test] + fn verify_archived_rejects_a_target_that_does_not_match_the_source() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("archived.csv"); + std::fs::write(&target, b"evidence").unwrap(); + let (_, checksum) = + hash_reader(&mut std::io::Cursor::new(b"evidence"), Algorithm::Crc32c).unwrap(); + + let ok = CleanupCtx { + on_success: OnSuccess::Archive, + src: dir.path().join("r.csv"), + archive_dir: Some(dir.path().to_path_buf()), + relpath: "r.csv".into(), + collision: Collision::Suffix, + verify: Verify::Checksum, + expected_size: 8, + expected_checksum: checksum.clone(), + }; + verify_archived(&target, &ok).expect("a matching copy verifies"); + + // Wrong size → permanent (the source is already gone; retrying cannot change the bytes). + let wrong_size = CleanupCtx { + expected_size: 99, + ..CleanupCtx { + expected_checksum: checksum.clone(), + ..clone_ctx(&ok) + } + }; + let err = verify_archived(&target, &wrong_size).unwrap_err(); + assert!(err.is_permanent(), "got {err:?}"); + + // Right size, wrong content → the checksum catches it. + std::fs::write(&target, b"tampered").unwrap(); + let err = verify_archived(&target, &ok).unwrap_err(); + assert!(err.is_permanent(), "got {err:?}"); + assert!(err.to_string().contains("mismatch")); + + // A target that is not there at all is an I/O failure, not a mismatch. + let err = verify_archived(&dir.path().join("nope.csv"), &ok).unwrap_err(); + assert!(err.to_string().contains("io:"), "got {err:?}"); + + // Under `verify: size` a content change is not inspected at all. + let size_only = CleanupCtx { + verify: Verify::Size, + ..clone_ctx(&ok) + }; + verify_archived(&target, &size_only).expect("size policy checks only the byte count"); + } + + /// `CleanupCtx` is deliberately not `Clone` in the shipping code (it is built once per attempt); + /// this rebuilds one for the struct-update syntax above. + #[cfg(test)] + fn clone_ctx(c: &CleanupCtx) -> CleanupCtx { + CleanupCtx { + on_success: c.on_success, + src: c.src.clone(), + archive_dir: c.archive_dir.clone(), + relpath: c.relpath.clone(), + collision: c.collision, + verify: c.verify, + expected_size: c.expected_size, + expected_checksum: c.expected_checksum.clone(), + } + } + + #[test] + fn decide_cleanup_uses_its_own_attempt_budget_not_the_transfer_time_budget() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + // A transfer budget that is already fully spent — the cleanup decision must not read it. + let retry = RetryPolicy { + give_up_after_ms: Some(1), + max_attempts: None, + ..RetryPolicy::default() + }; + let worker = local_worker( + store(), + src.path(), + dst.path(), + completion(OnSuccess::Delete), + retry, + ); + assert!( + matches!( + worker.decide_cleanup(false, 0, 10_000_000_000), + RetryDecision::Retry { .. } + ), + "an expired transfer budget must not deny the first cleanup retry" + ); + // The default attempt cap still bounds it. + assert_eq!( + worker.decide_cleanup(false, DEFAULT_CLEANUP_MAX_ATTEMPTS - 1, 100), + RetryDecision::GiveUp + ); + // And a permanent error never retries. + assert_eq!(worker.decide_cleanup(true, 0, 100), RetryDecision::GiveUp); + } } diff --git a/src/state.rs b/src/state.rs index 3b92b7b..57a3549 100644 --- a/src/state.rs +++ b/src/state.rs @@ -8,8 +8,11 @@ //! inline and pushes bulk scans through `spawn_blocking`. One DB is shared across instances, keyed by //! `instance`. Every state transition is persisted *before* the side effect it authorizes (§13.2): //! `Ready → InProgress` ([`claim_ready`](StateStore::claim_ready)) precedes the transfer, `Verified` -//! ([`set_state`](StateStore::set_state)) precedes the source delete/archive, so a crash at any point -//! is recovered idempotently via [`recover_incomplete`](StateStore::recover_incomplete). +//! precedes the aggregate completion decision, and `CleanupPending` +//! ([`set_state`](StateStore::set_state)) precedes the source delete/archive — `Completed` is written +//! only once that action is proven done (DESIGN §20-I). A crash at any point is recovered +//! idempotently via [`recover_incomplete`](StateStore::recover_incomplete), which returns the +//! `InProgress`, `Verified`, and `CleanupPending` rows a prior run left behind. use std::path::Path; use std::sync::Mutex; @@ -54,6 +57,19 @@ pub trait StateStore: Send + Sync { now: i64, ) -> Result<()>; + /// Cleanup failure path (DESIGN §20-I): `cleanup_attempts += 1`, record `err`, set the cleanup + /// backoff gate in `next_attempt_at`, and move to `next_state` (`CleanupFailed`). The transfer's + /// own `attempts` are left untouched — the two retry budgets are independent. + fn record_cleanup_attempt( + &self, + instance: &str, + relpath: &str, + err: &str, + next_state: ItemState, + next_attempt_at: i64, + now: i64, + ) -> Result<()>; + /// Persist streamed-byte progress for an in-flight item. fn set_bytes_done(&self, instance: &str, relpath: &str, bytes: u64, now: i64) -> Result<()>; @@ -173,7 +189,9 @@ pub struct Stats { /// The DDL for the state DB. One DB per component data dir; instances share every table, keyed by /// `instance`. `next_attempt_at` is the P1 addition to the §14.2 schema (the backoff re-claim gate, -/// see [`WorkItem::next_attempt_at`]). `resume.blob` is a JSON-serialized [`ResumeState`] so the S3 +/// see [`WorkItem::next_attempt_at`]); `cleanup_attempts` is the source-completion retry counter +/// (DESIGN §20-I), kept separate from the transfer's `attempts` so the two budgets are independent — +/// it is also applied to an already-existing DB via [`ADDED_ITEM_COLUMNS`]. `resume.blob` is a JSON-serialized [`ResumeState`] so the S3 /// backend (P2) reuses the column for `{uploadId, completedParts}` with no migration. `dest_state` is /// the P6 addition (DESIGN §20-B): independent per-destination completion tracking for /// multi-destination fan-out, keyed the same way as `resume` — `(instance, relpath, dest)`. The DB is @@ -189,6 +207,7 @@ CREATE TABLE IF NOT EXISTS work_items( mtime_ms INTEGER NOT NULL DEFAULT 0, discovered_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, + cleanup_attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL DEFAULT 0, last_error TEXT, bytes_done INTEGER NOT NULL DEFAULT 0, @@ -219,7 +238,12 @@ CREATE TABLE IF NOT EXISTS stats( /// The column list for every `work_items` full-row read, in [`row_to_item`] order. const ITEM_COLS: &str = "instance, relpath, state, size, discovered_at, attempts, \ - next_attempt_at, last_error, bytes_done, updated_at"; + next_attempt_at, last_error, bytes_done, updated_at, cleanup_attempts"; + +/// Columns added to `work_items` after the original release, applied to an already-existing DB with a +/// guarded `ALTER TABLE` (SQLite has no `ADD COLUMN IF NOT EXISTS`, and `CREATE TABLE IF NOT EXISTS` +/// only covers a *fresh* file — see [`SqliteStore::init`]). +const ADDED_ITEM_COLUMNS: &[&str] = &["cleanup_attempts INTEGER NOT NULL DEFAULT 0"]; /// Crash-safe durable state backed by SQLite in WAL mode (DESIGN §14). /// @@ -254,6 +278,15 @@ impl SqliteStore { conn.pragma_update(None, "foreign_keys", "ON")?; conn.busy_timeout(std::time::Duration::from_secs(5))?; conn.execute_batch(SCHEMA)?; + // `CREATE TABLE IF NOT EXISTS` covers a fresh DB and a whole new table, but not a column added + // to an existing table. Add each late column with a guarded `ALTER TABLE`: SQLite reports an + // already-present column as a plain error, which is the "nothing to do" case here. + for col in ADDED_ITEM_COLUMNS { + let name = col.split_whitespace().next().unwrap_or(col); + if !column_exists(&conn, "work_items", name)? { + conn.execute(&format!("ALTER TABLE work_items ADD COLUMN {col}"), [])?; + } + } Ok(Self { conn: Mutex::new(conn), }) @@ -267,6 +300,20 @@ impl SqliteStore { } } +/// Whether `table` already has a column named `column` (drives the guarded `ALTER TABLE` migration in +/// [`SqliteStore::init`]). +fn column_exists(conn: &Connection, table: &str, column: &str) -> Result { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; + let mut rows = stmt.query([])?; + while let Some(r) = rows.next()? { + let name: String = r.get(1)?; + if name == column { + return Ok(true); + } + } + Ok(false) +} + /// Map a full `work_items` row (in [`ITEM_COLS`] order) to a [`WorkItem`]. `abs_source` is derived /// by the engine from `ingress.path` and is not a DB column, so it is reconstructed from `relpath` /// alone here (the caller re-joins the ingress root when it needs a live path). @@ -293,6 +340,7 @@ fn row_to_item(r: &Row<'_>) -> rusqlite::Result { last_error: r.get(7)?, bytes_done: r.get::<_, i64>(8)? as u64, updated_at: r.get(9)?, + cleanup_attempts: r.get::<_, i64>(10)? as u32, }) } @@ -375,6 +423,10 @@ impl StateStore for SqliteStore { WHEN work_items.state IN ('completed','quarantined','retained') AND (work_items.size <> excluded.size OR work_items.mtime_ms <> excluded.mtime_ms) THEN 0 ELSE work_items.bytes_done END, + cleanup_attempts = CASE + WHEN work_items.state IN ('completed','quarantined','retained') + AND (work_items.size <> excluded.size OR work_items.mtime_ms <> excluded.mtime_ms) + THEN 0 ELSE work_items.cleanup_attempts END, discovered_at = CASE WHEN work_items.state IN ('completed','quarantined','retained') AND (work_items.size <> excluded.size OR work_items.mtime_ms <> excluded.mtime_ms) @@ -467,6 +519,32 @@ impl StateStore for SqliteStore { Ok(()) } + fn record_cleanup_attempt( + &self, + instance: &str, + relpath: &str, + err: &str, + next_state: ItemState, + next_attempt_at: i64, + now: i64, + ) -> Result<()> { + self.lock().execute( + "UPDATE work_items + SET cleanup_attempts = cleanup_attempts + 1, last_error = ?3, state = ?4, + next_attempt_at = ?5, updated_at = ?6 + WHERE instance = ?1 AND relpath = ?2", + params![ + instance, + relpath, + err, + next_state.as_str(), + next_attempt_at, + now + ], + )?; + Ok(()) + } + fn set_bytes_done(&self, instance: &str, relpath: &str, bytes: u64, now: i64) -> Result<()> { self.lock().execute( "UPDATE work_items SET bytes_done = ?3, updated_at = ?4 @@ -494,7 +572,7 @@ impl StateStore for SqliteStore { let guard = self.lock(); let mut stmt = guard.prepare(&format!( "SELECT {ITEM_COLS} FROM work_items - WHERE instance = ?1 AND state IN ('in_progress', 'verified') + WHERE instance = ?1 AND state IN ('in_progress', 'verified', 'cleanup_pending') ORDER BY discovered_at ASC" ))?; let rows = stmt.query_map(params![instance], row_to_item)?; @@ -921,20 +999,150 @@ mod tests { } #[test] - fn recover_incomplete_returns_in_progress_and_verified_only() { + fn recover_incomplete_returns_the_non_terminal_in_flight_states_only() { let (s, _d) = temp_store(); - for (rel, disc) in [("a", 1), ("b", 2), ("c", 3), ("d", 4)] { + for (rel, disc) in [("a", 1), ("b", 2), ("c", 3), ("d", 4), ("e", 5), ("f", 6)] { s.upsert_ready(INST, rel, 1, 0, disc).unwrap(); } s.set_state(INST, "a", ItemState::InProgress, 10).unwrap(); s.set_state(INST, "b", ItemState::Verified, 10).unwrap(); s.set_state(INST, "c", ItemState::Completed, 10).unwrap(); + // A crash inside the write-ahead cleanup window must be recovered too (DESIGN §20-I). + s.set_state(INST, "e", ItemState::CleanupPending, 10) + .unwrap(); + // A cleanup FAILURE carries its own backoff gate and is re-driven by the cleanup pass on the + // next tick, not by crash recovery — so it must NOT appear here. + s.set_state(INST, "f", ItemState::CleanupFailed, 10) + .unwrap(); // "d" stays Ready. let rec = s.recover_incomplete(INST).unwrap(); assert_eq!( rec.iter().map(|i| i.relpath.as_str()).collect::>(), - vec!["a", "b"] + vec!["a", "b", "e"] + ); + } + + #[test] + fn record_cleanup_attempt_counts_separately_from_transfer_attempts() { + // DESIGN §20-I: the two retry budgets are independent, so a cleanup attempt must never disturb + // the transfer's `attempts` (which `get-status` reports and the transfer backoff reads). + let (s, _d) = temp_store(); + s.upsert_ready(INST, "f", 10, 0, 1).unwrap(); + s.record_attempt(INST, "f", "transfer boom", ItemState::Failed, 100, 5) + .unwrap(); + s.record_attempt(INST, "f", "transfer boom", ItemState::Failed, 200, 6) + .unwrap(); + + s.record_cleanup_attempt(INST, "f", "archive boom", ItemState::CleanupFailed, 900, 10) + .unwrap(); + let it = s.get(INST, "f").unwrap().unwrap(); + assert_eq!(it.state, ItemState::CleanupFailed); + assert_eq!(it.attempts, 2, "transfer attempts untouched"); + assert_eq!(it.cleanup_attempts, 1); + assert_eq!( + it.next_attempt_at, 900, + "the cleanup gate reuses next_attempt_at" ); + assert_eq!(it.last_error.as_deref(), Some("archive boom")); + assert_eq!(it.updated_at, 10); + + s.record_cleanup_attempt( + INST, + "f", + "archive boom 2", + ItemState::CleanupFailed, + 950, + 11, + ) + .unwrap(); + let it = s.get(INST, "f").unwrap().unwrap(); + assert_eq!(it.cleanup_attempts, 2); + assert_eq!(it.attempts, 2); + } + + #[test] + fn rediscovery_never_reenqueues_a_cleanup_state() { + // DESIGN §20-I / FR-REL-1: the source of a `CleanupPending`/`CleanupFailed` item is still on + // disk, so every rescan re-discovers it. Neither state is terminal, so `upsert_ready` must + // preserve the row rather than resurrect an already-replicated file as new work. + let (s, _d) = temp_store(); + for (rel, state) in [ + ("pending", ItemState::CleanupPending), + ("failed", ItemState::CleanupFailed), + ] { + s.upsert_ready(INST, rel, 10, 111, 1).unwrap(); + s.set_state(INST, rel, state, 2).unwrap(); + // Re-discovered unchanged, and re-discovered with a different signature: neither resets. + s.upsert_ready(INST, rel, 10, 111, 3).unwrap(); + assert_eq!(s.get(INST, rel).unwrap().unwrap().state, state); + s.upsert_ready(INST, rel, 999, 222, 4).unwrap(); + assert_eq!( + s.get(INST, rel).unwrap().unwrap().state, + state, + "{rel}: a cleanup-state row is never re-enqueued as new work" + ); + } + assert!( + s.claim_ready(INST, 10, 100).unwrap().is_empty(), + "and neither is claimable" + ); + } + + #[test] + fn cleanup_attempts_reset_when_a_changed_file_reuses_a_completed_relpath() { + // The rotating-filename producer pattern: a genuinely new file at a completed relpath starts + // with a fresh cleanup budget, not the prior file's spent one. + let (s, _d) = temp_store(); + s.upsert_ready(INST, "report.csv", 10, 111, 1).unwrap(); + s.record_cleanup_attempt(INST, "report.csv", "boom", ItemState::CleanupFailed, 500, 2) + .unwrap(); + s.set_state(INST, "report.csv", ItemState::Completed, 3) + .unwrap(); + s.upsert_ready(INST, "report.csv", 10, 222, 10).unwrap(); + let it = s.get(INST, "report.csv").unwrap().unwrap(); + assert_eq!(it.state, ItemState::Ready); + assert_eq!( + it.cleanup_attempts, 0, + "fresh cleanup budget for a new file" + ); + } + + #[test] + fn a_db_written_before_the_cleanup_column_is_migrated_on_open() { + // `CREATE TABLE IF NOT EXISTS` cannot add a column to an existing table, so opening a DB from + // an earlier build must apply the guarded ALTER (see `ADDED_ITEM_COLUMNS`) rather than fail + // every subsequent `SELECT`. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + { + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE work_items( + instance TEXT NOT NULL, + relpath TEXT NOT NULL, + state TEXT NOT NULL, + size INTEGER NOT NULL, + mtime_ms INTEGER NOT NULL DEFAULT 0, + discovered_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + bytes_done INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY(instance, relpath)); + INSERT INTO work_items(instance, relpath, state, size, discovered_at, updated_at) + VALUES ('inst-a', 'old.csv', 'ready', 7, 1, 1);", + ) + .unwrap(); + } + let s = SqliteStore::open(&path).unwrap(); + let it = s.get(INST, "old.csv").unwrap().unwrap(); + assert_eq!(it.size, 7); + assert_eq!(it.cleanup_attempts, 0, "back-filled at the column default"); + // The migration is idempotent across reopens. + drop(s); + let s = SqliteStore::open(&path).unwrap(); + assert_eq!(s.claim_ready(INST, 10, 100).unwrap().len(), 1); } #[test] diff --git a/tests/azure_azurite.rs b/tests/azure_azurite.rs index 2b3eab5..841890a 100644 --- a/tests/azure_azurite.rs +++ b/tests/azure_azurite.rs @@ -201,6 +201,7 @@ fn work_item(src_root: &Path, rel: &str) -> WorkItem { size, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, diff --git a/tests/gcs_fakegcs.rs b/tests/gcs_fakegcs.rs index a7797bc..1305745 100644 --- a/tests/gcs_fakegcs.rs +++ b/tests/gcs_fakegcs.rs @@ -221,6 +221,7 @@ fn work_item(src_root: &Path, rel: &str) -> WorkItem { size, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, diff --git a/tests/http_inprocess.rs b/tests/http_inprocess.rs index 87e89e2..7dbf47f 100644 --- a/tests/http_inprocess.rs +++ b/tests/http_inprocess.rs @@ -360,6 +360,7 @@ fn work_item(src_root: &std::path::Path, rel: &str) -> WorkItem { size, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, diff --git a/tests/p1_engine.rs b/tests/p1_engine.rs index 4df1a8a..d415f76 100644 --- a/tests/p1_engine.rs +++ b/tests/p1_engine.rs @@ -246,6 +246,18 @@ impl StateStore for FailCompletedOnceStore { self.inner .record_attempt(instance, relpath, err, next_state, next_attempt_at, now) } + fn record_cleanup_attempt( + &self, + instance: &str, + relpath: &str, + err: &str, + next_state: ItemState, + next_attempt_at: i64, + now: i64, + ) -> ReplResult<()> { + self.inner + .record_cleanup_attempt(instance, relpath, err, next_state, next_attempt_at, now) + } fn set_bytes_done(&self, instance: &str, relpath: &str, bytes: u64, now: i64) -> ReplResult<()> { self.inner.set_bytes_done(instance, relpath, bytes, now) } @@ -787,18 +799,21 @@ async fn crash_after_source_delete_before_completed_recovers_exactly_once() { )); // Phase A — one tick: deliver + verify succeed, the source is deleted, but persisting Completed - // fails (the injected crash). The item is left Verified: source gone, dest written, not counted. + // fails (the injected crash). The item is left CleanupPending: source gone, dest written, not + // counted. `CleanupPending` is the write-ahead marker written immediately before the source side + // effect (DESIGN §20-I), so it is exactly what a crash in this window leaves behind. inst.tick(1_000).await; assert_eq!(std::fs::read(dst.path().join("once.dat")).unwrap(), PAYLOAD, "delivered"); assert!(!src.path().join("once.dat").exists(), "source side effect ran before Completed persist"); assert_eq!( store.get("once", "once.dat").unwrap().unwrap().state, - ItemState::Verified, - "Verified persisted before the source side effect; Completed not yet written" + ItemState::CleanupPending, + "the cleanup intent is persisted before the source side effect; Completed not yet written" ); assert_eq!(store.stats("once").unwrap().replicated, 0, "not counted before Completed"); - // Phase B — restart: recovery re-verifies the destination (still present) and completes the item. + // Phase B — restart: recovery re-evaluates the cleanup against observed state (the source is + // already gone, so the delete landed) and completes the item. let cancel = CancellationToken::new(); let handle = { let inst = inst.clone(); diff --git a/tests/s3_floci.rs b/tests/s3_floci.rs index 2dedf97..a36ab25 100644 --- a/tests/s3_floci.rs +++ b/tests/s3_floci.rs @@ -215,6 +215,7 @@ fn work_item(src_root: &Path, rel: &str) -> WorkItem { size, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, diff --git a/tests/s3_real.rs b/tests/s3_real.rs index e30cc98..f64f73e 100644 --- a/tests/s3_real.rs +++ b/tests/s3_real.rs @@ -83,6 +83,7 @@ fn work_item(root: &Path, rel: &str) -> WorkItem { size, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, diff --git a/tests/sftp_atmoz.rs b/tests/sftp_atmoz.rs index ffbf34b..563c745 100644 --- a/tests/sftp_atmoz.rs +++ b/tests/sftp_atmoz.rs @@ -198,6 +198,7 @@ fn work_item(src_root: &Path, rel: &str) -> WorkItem { size, discovered_at: 0, attempts: 0, + cleanup_attempts: 0, next_attempt_at: 0, last_error: None, bytes_done: 0, From 8ffeb20eb2f187591367f8d452fa02b3a62e4b73 Mon Sep 17 00:00:00 2001 From: breis Date: Sat, 22 Aug 2026 19:23:58 -0400 Subject: [PATCH 2/2] fix(completion): retry a locked source file instead of parking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A source completion action that fails because something else still holds the file open was classified permanent and parked the item in `CleanupFailed` after a single attempt. On Windows that is the ordinary case — a producer finishing its write, an antivirus scanner, an indexer, a backup agent — and it clears on its own within seconds, so the file demanded operator action for a condition that heals itself. `ReplError::classify_cleanup_io` now classifies a locked file as transient on the cleanup path: `PermissionDenied` and `ResourceBusy` by `io::ErrorKind`, plus the platform's raw code — Windows `ERROR_SHARING_VIOLATION` (32), Unix `EBUSY` (16) — matched per platform because 32 is `EPIPE` on Unix. Both are checked because which of the two a lock surfaces as depends on the OS and the toolchain version. Everything else keeps `classify_io`'s rules, so `NotFound` still fails fast. The transfer path is unchanged: there a `PermissionDenied` is a credential or ACL an operator must fix, and failing fast to `Exhausted` is right. The shared `move_file` helper takes the classifier as a parameter so each caller keeps its own policy — the completion action passes `classify_cleanup_io`, quarantine passes `classify_io`. Adds a fault-injection test proving a locked source retries on the cleanup backoff (transient error recorded, no `FileCleanupFailed`, no `FileDeleted`, `replicated` unmoved) and then completes once the lock clears. The uncreatable-archive-dir test now asserts the retry before the give-up. Classifier unit tests cover both error kinds, the platform raw code, and that `NotFound`/`TimedOut` are unaffected. DESIGN §13.2 and register entry I record the divergence and why; the explanation page documents it alongside the cleanup retry budget. --- DESIGN.md | 21 ++++- docs/explanation.md | 11 ++- src/error.rs | 82 +++++++++++++++++++ src/instance/worker.rs | 177 +++++++++++++++++++++++++++++++++++------ 4 files changed, 260 insertions(+), 31 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cb18b3b..2a15fbb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -831,8 +831,15 @@ move, a missing or unwritable `archiveDir`, a failing delete, or an archived cop (`retry.baseDelayMs` → `retry.maxDelayMs`) bounded by `retry.maxAttempts`, or 10 attempts when that is unset. The transfer's time-based `giveUpAfter` is deliberately not reused — it starts at discovery and is usually largely spent by the time a slow transfer finishes, which would leave exactly the files that struggled hardest -with no cleanup retries at all. A permanent error (a missing `archiveDir`, a permission denial, a mismatched -archive copy) gives up on the first attempt: no retry can change any of them. +with no cleanup retries at all. A permanent error (an unconfigured `archiveDir`, a mismatched archive copy) +gives up on the first attempt: no retry can change either. + +**A locked source file is transient on this path.** A `PermissionDenied`/`ResourceBusy` I/O error — Windows +`ERROR_SHARING_VIOLATION` (32), Unix `EBUSY` (16) — usually means a producer, antivirus scanner, indexer, or +backup agent still holds the source open, which clears on its own within seconds. Cleanup retries it under +the backoff rather than parking the file after one attempt. The **transfer** path keeps the opposite rule +(`PermissionDenied` is a credential/ACL problem an operator must fix, so it fails fast to `Exhausted`); the +two classifications are `ReplError::classify_cleanup_io` and `ReplError::classify_io`. Every reconciliation tick re-drives the `CleanupPending` rows and the `CleanupFailed` rows whose gate has elapsed. Once the budget is spent, the item stays `CleanupFailed` with a `FileCleanupFailed` event (§17.1), @@ -1305,7 +1312,9 @@ rehash of the config keys. fire only on a proven action, `FileArchived.archivePath` reports the path the file really landed at (which the `suffix` collision policy can rename) rather than a computed one, and the `replicated` statistic counts only `Completed`. Cleanup retries use the shared full-jitter backoff on their own attempt budget - (`retry.maxAttempts`, else 10), independent of the transfer's time-based `giveUpAfter`. Exhausted items stay + (`retry.maxAttempts`, else 10), independent of the transfer's time-based `giveUpAfter`, and a **locked** + source (`PermissionDenied`/`ResourceBusy`, Windows `ERROR_SHARING_VIOLATION`, Unix `EBUSY`) is transient on + this path even though it stays permanent on the transfer path. Exhausted items stay `CleanupFailed`, emit `FileCleanupFailed`, appear in `get-status` under `failed.items[]` with `state: "cleanup_failed"` + `cleanupAttempts`, and are re-driven only by `trigger`. §8.1, §13.2, §16, §17.1. @@ -1319,7 +1328,11 @@ rehash of the config keys. non-terminal: `upsert_ready` only resets a *terminal* row, so a re-discovered `CleanupPending`/`CleanupFailed` source keeps its row and is never re-enqueued as new work. An attempt-bounded cleanup budget was chosen over reusing `giveUpAfter` because that clock starts at discovery: a file that spent six days retrying a transfer - would otherwise get no cleanup retry at all. The alternative of a separate top-level `cleanup` section in the + would otherwise get no cleanup retry at all. The cleanup path's locked-file rule diverges from the transfer + path deliberately: on egress a `PermissionDenied` is a credential or ACL an operator must fix, but on the + source it is normally a producer, antivirus scanner, indexer, or backup agent still holding the file — the + common case on Windows, and one that clears itself in seconds. Failing fast there would demand operator + action for a self-healing condition. The alternative of a separate top-level `cleanup` section in the `get-status` document was rejected in favor of the existing `failed` bucket — the item genuinely needs an operator, `failed.items[].state` already discriminates, and consumers need no new schema. diff --git a/docs/explanation.md b/docs/explanation.md index 295933f..ae9913e 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -85,9 +85,14 @@ for a file still sitting in the watch directory is worse than an explicit failur Completion attempts retry on their own budget: the same exponential backoff as transfers (`retry.baseDelayMs` → `retry.maxDelayMs`), bounded by `retry.maxAttempts` or, when that is unset, 10 attempts. This is separate from the transfer's `retry.giveUpAfter` clock, which starts when the file is -discovered and is often nearly spent by the time a long transfer finishes. Errors that no retry can fix — a -missing `archiveDir`, a permission denial, an archived copy that does not match — stop after the first -attempt. +discovered and is often nearly spent by the time a long transfer finishes. Errors that no retry can fix — an +unconfigured `archiveDir`, an archived copy that does not match — stop after the first attempt. + +A **locked source file** is retried, not parked. When a producer, antivirus scanner, indexer, or backup agent +still holds the file open, the delete or move fails with a sharing violation or a permission error that +clears on its own within seconds — the ordinary case on Windows. Completion treats these as transient and +retries them on the backoff above. Transfers keep the opposite rule: there a permission error means a +credential or ACL you have to fix, so it fails fast rather than retrying for days. Each reconciliation scan re-drives the files whose completion is still owed. Once a file's completion budget is spent it stays `cleanup_failed` and publishes a `file-cleanup-failed` event (severity `critical`, with the diff --git a/src/error.rs b/src/error.rs index f47dcd0..569bc69 100644 --- a/src/error.rs +++ b/src/error.rs @@ -101,6 +101,49 @@ impl ReplError { } } + /// Map a raw [`io::Error`] raised by the **source completion action** (`delete`/`archive`, + /// DESIGN §20-I) to a classified [`ReplError`]. Identical to [`classify_io`](Self::classify_io) + /// except that a **locked file** is [`Transient`](Self::Transient) rather than permanent. + /// + /// On the transfer path a `PermissionDenied` means a credential or ACL an operator has to fix, so + /// failing fast is right. On the cleanup path it usually means something else still has the source + /// open — a producer finishing its write, an antivirus scanner, an indexer, a backup agent — which + /// is the normal case on Windows and clears on its own within seconds. Parking such a file in + /// `CleanupFailed` after a single attempt would demand operator action for a condition that heals + /// itself, so these are retried under the cleanup backoff instead. + /// + /// Detection covers both the portable [`io::ErrorKind`] and the platform's raw code, because which + /// of the two a lock surfaces as depends on the OS and the toolchain version (see + /// [`is_locked_file`](Self::is_locked_file)). + pub fn classify_cleanup_io(e: io::Error) -> Self { + if Self::is_locked_file(&e) { + return ReplError::Transient(format!("io: {e} ({:?})", e.kind())); + } + Self::classify_io(e) + } + + /// Whether `e` reports a file another process is holding open (see + /// [`classify_cleanup_io`](Self::classify_cleanup_io)): `PermissionDenied` or `ResourceBusy` by + /// [`io::ErrorKind`], or the platform's raw code — Windows `ERROR_SHARING_VIOLATION` (32), Unix + /// `EBUSY` (16). The raw codes are matched per platform because the same number means something + /// unrelated on the other (32 is `EPIPE` on Unix). + fn is_locked_file(e: &io::Error) -> bool { + if matches!( + e.kind(), + io::ErrorKind::PermissionDenied | io::ErrorKind::ResourceBusy + ) { + return true; + } + #[cfg(windows)] + const LOCKED_RAW: &[i32] = &[32]; // ERROR_SHARING_VIOLATION + #[cfg(unix)] + const LOCKED_RAW: &[i32] = &[16]; // EBUSY + #[cfg(not(any(windows, unix)))] + const LOCKED_RAW: &[i32] = &[]; + e.raw_os_error() + .is_some_and(|code| LOCKED_RAW.contains(&code)) + } + fn io_kind_is_permanent(kind: io::ErrorKind) -> bool { matches!( kind, @@ -175,6 +218,45 @@ mod tests { assert!(!ReplError::Transient("later".into()).is_permission_denied()); } + #[test] + fn classify_cleanup_io_treats_a_locked_file_as_transient() { + // On the CLEANUP path a held-open source (a producer, antivirus scanner, indexer, backup + // agent) must be retried under the cleanup backoff, not parked after one attempt — unlike the + // transfer path, where a permission denial is a credential/ACL problem that fails fast. + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::ResourceBusy] { + let e = ReplError::classify_cleanup_io(io::Error::new(kind, "locked")); + assert!(e.is_transient(), "{kind:?} → {e:?}"); + assert!(!e.is_permanent(), "{kind:?} → {e:?}"); + } + // The transfer path is deliberately unchanged. + let transfer = + ReplError::classify_io(io::Error::new(io::ErrorKind::PermissionDenied, "denied")); + assert!(matches!(transfer, ReplError::PermissionDenied(_))); + assert!(transfer.is_permanent()); + } + + #[test] + fn classify_cleanup_io_matches_the_platform_sharing_violation_code() { + // Windows ERROR_SHARING_VIOLATION (32) / Unix EBUSY (16) — matched by raw code as well as by + // `ErrorKind`, because which of the two a lock surfaces as depends on the OS and toolchain. + #[cfg(windows)] + let raw = 32; + #[cfg(not(windows))] + let raw = 16; + let e = ReplError::classify_cleanup_io(io::Error::from_raw_os_error(raw)); + assert!(e.is_transient(), "raw {raw} → {e:?}"); + assert!(!e.is_permanent()); + } + + #[test] + fn classify_cleanup_io_leaves_every_other_kind_alone() { + // Not-found still fails fast (nothing to retry), and an unrelated error stays transient. + let gone = ReplError::classify_cleanup_io(io::Error::new(io::ErrorKind::NotFound, "gone")); + assert!(gone.is_permanent(), "got {gone:?}"); + let slow = ReplError::classify_cleanup_io(io::Error::new(io::ErrorKind::TimedOut, "slow")); + assert!(slow.is_transient(), "got {slow:?}"); + } + #[test] fn classify_io_timeout_is_transient() { let e = ReplError::classify_io(io::Error::new(io::ErrorKind::TimedOut, "slow")); diff --git a/src/instance/worker.rs b/src/instance/worker.rs index 6d3c6a2..b906a14 100644 --- a/src/instance/worker.rs +++ b/src/instance/worker.rs @@ -893,9 +893,11 @@ impl Worker { /// /// Deliberately NOT the transfer's time-based `giveUpAfter`: that clock starts at discovery and is /// usually largely spent by the time a slow or long-retried transfer finishes, which would leave - /// exactly the files that struggled hardest with no cleanup retries at all. A permanent error — a - /// missing `archiveDir`, a permission denial, an archived copy that does not match — gives up - /// immediately, because retrying cannot change any of them and the operator needs it surfaced. + /// exactly the files that struggled hardest with no cleanup retries at all. A permanent error — an + /// unconfigured `archiveDir`, an archived copy that does not match — gives up immediately, because + /// retrying cannot change either and the operator needs it surfaced. A **locked** source (another + /// process holding it open) is deliberately transient here and retried, unlike on the transfer path: + /// see [`ReplError::classify_cleanup_io`]. fn decide_cleanup(&self, permanent: bool, attempts_so_far: u32, now: i64) -> RetryDecision { if permanent { return RetryDecision::GiveUp; @@ -1839,6 +1841,12 @@ impl SourceFs { } } +/// How a filesystem `io::Error` is turned into a [`ReplError`]. Passed explicitly so the shared +/// [`move_file`] helper keeps each caller's own policy: the source completion action classifies a +/// locked file as transient ([`ReplError::classify_cleanup_io`]), while quarantine keeps the ordinary +/// [`ReplError::classify_io`] rules (DESIGN §20-I). +type IoClassifier = fn(std::io::Error) -> ReplError; + /// The [`SourceFs`] operations a test can fault. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FsOp { @@ -1918,11 +1926,17 @@ fn join_rel(root: &Path, relpath: &str) -> PathBuf { /// /// Every filesystem call goes through [`SourceFs`] so the failure branches are testable (DESIGN §20-I); /// the caller reports the returned path and verifies the file landed there. -fn move_file(src: &Path, dst: &Path, collision: Collision, fs: &SourceFs) -> Result { +fn move_file( + src: &Path, + dst: &Path, + collision: Collision, + fs: &SourceFs, + classify: IoClassifier, +) -> Result { if let Some(parent) = dst.parent() { - fs.create_dir_all(parent).map_err(ReplError::classify_io)?; + fs.create_dir_all(parent).map_err(classify)?; } - let target = resolve_collision(dst, collision, fs)?; + let target = resolve_collision(dst, collision, fs, classify)?; // Fast path: same-filesystem rename is already atomic. if fs.rename(src, &target).is_ok() { return Ok(target); @@ -1931,7 +1945,7 @@ fn move_file(src: &Path, dst: &Path, collision: Collision, fs: &SourceFs) -> Res let tmp = move_temp_path(&target); if let Err(e) = fs.copy(src, &tmp) { let _ = std::fs::remove_file(&tmp); - return Err(ReplError::classify_io(e)); + return Err(classify(e)); } if fs.rename(&tmp, &target).is_err() { // Windows rename won't overwrite; `resolve_collision` should have freed the target, but be @@ -1939,15 +1953,15 @@ fn move_file(src: &Path, dst: &Path, collision: Collision, fs: &SourceFs) -> Res if target.exists() { if let Err(e) = fs.remove_file(&target) { let _ = std::fs::remove_file(&tmp); - return Err(ReplError::classify_io(e)); + return Err(classify(e)); } } if let Err(e) = fs.rename(&tmp, &target) { let _ = std::fs::remove_file(&tmp); - return Err(ReplError::classify_io(e)); + return Err(classify(e)); } } - fs.remove_file(src).map_err(ReplError::classify_io)?; + fs.remove_file(src).map_err(classify)?; Ok(target) } @@ -2023,7 +2037,7 @@ fn apply_success_action(ctx: &CleanupCtx, fs: &SourceFs) -> Result Ok(()) => {} // Already gone: a prior attempt (or one interrupted by a crash) succeeded. Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(ReplError::classify_io(e)), + Err(e) => return Err(ReplError::classify_cleanup_io(e)), } if ctx.src.exists() { return Err(ReplError::Transient(format!( @@ -2057,7 +2071,13 @@ fn apply_success_action(ctx: &CleanupCtx, fs: &SourceFs) -> Result ); return Ok(CleanupDone::Archived(None)); } - let target = move_file(&ctx.src, &dst, ctx.collision, fs)?; + let target = move_file( + &ctx.src, + &dst, + ctx.collision, + fs, + ReplError::classify_cleanup_io, + )?; verify_archived(&target, ctx)?; Ok(CleanupDone::Archived(Some(target))) } @@ -2074,7 +2094,7 @@ fn apply_success_action(ctx: &CleanupCtx, fs: &SourceFs) -> Result /// and the operator needs the item parked in `CleanupFailed` with the reason immediately. fn verify_archived(target: &Path, ctx: &CleanupCtx) -> Result<()> { let len = std::fs::metadata(target) - .map_err(ReplError::classify_io)? + .map_err(ReplError::classify_cleanup_io)? .len(); verify_size(ctx.expected_size, len).map_err(permanent_mismatch)?; if ctx.verify != Verify::Checksum { @@ -2086,8 +2106,8 @@ fn verify_archived(target: &Path, ctx: &CleanupCtx) -> Result<()> { // Nothing was hashed on delivery, so the size check above is the whole proof available. Checksum::None => return Ok(()), }; - let mut f = std::fs::File::open(target).map_err(ReplError::classify_io)?; - let (_, actual) = hash_reader(&mut f, algo).map_err(ReplError::classify_io)?; + let mut f = std::fs::File::open(target).map_err(ReplError::classify_cleanup_io)?; + let (_, actual) = hash_reader(&mut f, algo).map_err(ReplError::classify_cleanup_io)?; verify_checksum(&ctx.expected_checksum, &actual).map_err(permanent_mismatch) } @@ -2126,7 +2146,7 @@ fn apply_quarantine_action( Some(dir) => { let dst = join_rel(dir, &ctx.relpath); if src.exists() { - if let Err(e) = move_file(src, &dst, collision, fs) { + if let Err(e) = move_file(src, &dst, collision, fs, ReplError::classify_io) { tracing::warn!( src = %src.display(), dst = %dst.display(), error = %e, "quarantine move failed" @@ -2170,13 +2190,18 @@ fn write_error_sidecar(dst: &Path, ctx: &QuarantineCtx) { /// Resolve the effective target path for a collision policy: `overwrite` removes the existing file, /// `suffix` finds a free `name.N.ext`, `fail` errors permanently. -fn resolve_collision(dst: &Path, collision: Collision, fs: &SourceFs) -> Result { +fn resolve_collision( + dst: &Path, + collision: Collision, + fs: &SourceFs, + classify: IoClassifier, +) -> Result { if !dst.exists() { return Ok(dst.to_path_buf()); } match collision { Collision::Overwrite => { - fs.remove_file(dst).map_err(ReplError::classify_io)?; + fs.remove_file(dst).map_err(classify)?; Ok(dst.to_path_buf()) } Collision::Fail => Err(ReplError::Permanent(format!( @@ -2368,7 +2393,14 @@ mod tests { let src = dir.path().join("src.txt"); std::fs::write(&src, b"hi").unwrap(); let dst = dir.path().join("sub/dir/out.txt"); - move_file(&src, &dst, Collision::Fail, &SourceFs::real()).unwrap(); + move_file( + &src, + &dst, + Collision::Fail, + &SourceFs::real(), + ReplError::classify_io, + ) + .unwrap(); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).unwrap(), b"hi"); } @@ -2385,18 +2417,39 @@ mod tests { // Fail → error, source untouched. let s1 = mk("s1.txt", b"a"); - assert!(move_file(&s1, &dst, Collision::Fail, &SourceFs::real()).is_err()); + assert!(move_file( + &s1, + &dst, + Collision::Fail, + &SourceFs::real(), + ReplError::classify_io + ) + .is_err()); assert!(s1.exists()); // Suffix → writes dst.1.txt, original preserved. let s2 = mk("s2.txt", b"b"); - move_file(&s2, &dst, Collision::Suffix, &SourceFs::real()).unwrap(); + move_file( + &s2, + &dst, + Collision::Suffix, + &SourceFs::real(), + ReplError::classify_io, + ) + .unwrap(); assert_eq!(std::fs::read(dir.path().join("dst.1.txt")).unwrap(), b"b"); assert_eq!(std::fs::read(&dst).unwrap(), b"existing"); // Overwrite → replaces dst. let s3 = mk("s3.txt", b"c"); - move_file(&s3, &dst, Collision::Overwrite, &SourceFs::real()).unwrap(); + move_file( + &s3, + &dst, + Collision::Overwrite, + &SourceFs::real(), + ReplError::classify_io, + ) + .unwrap(); assert_eq!(std::fs::read(&dst).unwrap(), b"c"); } @@ -4483,12 +4536,17 @@ mod tests { let store = store(); enqueue_file(&store, src.path(), "r.csv", b"evidence"); let (fake, events) = recording_events(); + // Two cleanup attempts, so the retry and the give-up are both observable. + let retry = RetryPolicy { + max_attempts: Some(2), + ..RetryPolicy::default() + }; let worker = archiving_worker( store.clone(), src.path(), dst.path(), Some(archive.path()), - RetryPolicy::default(), + retry, SourceFaults::always(FsOp::CreateDirAll, std::io::ErrorKind::PermissionDenied), ) .with_events(events); @@ -4500,8 +4558,79 @@ mod tests { assert!(src.path().join("r.csv").exists()); assert!(fake.events_named("FileArchived").is_empty()); assert_eq!(store.stats(INST).unwrap().replicated, 0); - // A permission denial is permanent for the retry engine, so the operator hears about it now. + // On the cleanup path a permission denial is TRANSIENT (the archive volume may be briefly + // unavailable, or something may hold the target), so the first attempt schedules a retry rather + // than parking the file. + assert!(fake.events_named("FileCleanupFailed").is_empty()); + assert!( + store.get(INST, "r.csv").unwrap().unwrap().next_attempt_at > 100, + "a retry gate, not the parked sentinel" + ); + + // Once the cleanup budget is spent it parks and reports, still without ever completing. + worker.drive_cleanup(10_000_000, false).await.unwrap(); + let row = store.get(INST, "r.csv").unwrap().unwrap(); + assert_eq!(row.state, ItemState::CleanupFailed); + assert_eq!(row.next_attempt_at, CLEANUP_NO_RETRY); assert_eq!(fake.events_named("FileCleanupFailed").len(), 1); + assert_eq!(store.stats(INST).unwrap().replicated, 0); + } + + #[tokio::test] + async fn a_locked_source_file_retries_and_completes_once_the_lock_clears() { + // The ordinary Windows case: a producer, antivirus scanner, indexer, or backup agent still + // holds the source open, so the delete fails with a sharing/permission error. On the CLEANUP + // path that is transient — the file must be retried under the cleanup backoff, not parked after + // a single attempt the way an egress permission denial is (DESIGN §20-I). + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + let store = store(); + enqueue_file(&store, src.path(), "r.csv", b"evidence"); + let (fake, events) = recording_events(); + let worker = local_worker( + store.clone(), + src.path(), + dst.path(), + completion(OnSuccess::Delete), + RetryPolicy::default(), + ) + .with_source_faults( + SourceFaults::always(FsOp::RemoveFile, std::io::ErrorKind::PermissionDenied) + .heal_after(1), + ) + .with_events(events); + + // Attempt 1: the file is locked → retry scheduled, nothing completed, nothing announced. + assert_eq!( + process_only_item(&worker, &store, 100).await, + ItemState::CleanupFailed + ); + let row = store.get(INST, "r.csv").unwrap().unwrap(); + assert_eq!(row.cleanup_attempts, 1); + assert!( + row.next_attempt_at > 100 && row.next_attempt_at != CLEANUP_NO_RETRY, + "a locked source is retried on the backoff, not parked: {row:?}" + ); + assert!( + row.last_error.as_deref().unwrap().starts_with("transient:"), + "classified transient on the cleanup path, got {:?}", + row.last_error + ); + assert!(fake.events_named("FileCleanupFailed").is_empty()); + assert!(fake.events_named("FileDeleted").is_empty()); + assert_eq!(store.stats(INST).unwrap().replicated, 0); + assert!(src.path().join("r.csv").exists()); + + // Attempt 2, after the lock clears: the source is released and the file completes. + worker.drive_cleanup(10_000_000, false).await.unwrap(); + assert_eq!( + store.get(INST, "r.csv").unwrap().unwrap().state, + ItemState::Completed + ); + assert!(!src.path().join("r.csv").exists()); + assert_eq!(store.stats(INST).unwrap().replicated, 1); + assert_eq!(fake.events_named("FileDeleted").len(), 1); + assert!(fake.events_named("FileCleanupFailed").is_empty()); } #[tokio::test]