fix(s3-datastore): implement absence-on-disk deletion in pushChanged (#797) #70
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/797-pushchanged-absence-deletion"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
markDirty(relPath)signals a path and it's absent locally,pushChangednow collects matching index entries and issues batched S3DeleteObjectcalls instead of silently ignoring the absencedirtyPathsOverflowedflag so that no-pathmarkDirty()(modification signal) does not delete remote-only entries from other writers (preserves swamp-club#30)!lazyPullActiveto avoid deleting un-hydrated contentformatBatchFailureextended with"delete"operation typepushChangedincludes deleted count (pushed + deleted)2026.06.24.1Closes swamp-club#797
Test plan
markDirty(relPath)+ delete local file +pushChangedasserts S3 DELETE issued and index entry removedpushChangedasserts S3 DELETE for absent indexed filesmarkDirty()does NOT delete remote-only entries (swamp-club#30 regression guard)lazyPullActive=true+ absent file must NOT trigger S3 DELETEdeno check,deno lint,deno fmt --checkcleandeno install --frozenpasses🤖 Generated with Claude Code
Adversarial Review
Critical / High
s3_cache_sync.ts:1306-1321— Scoped-walk catch block swallows non-ENOENT errors, treating permission errors and I/O failures as "file absent" and triggering remote deletion.The scoped-walk branch does
Deno.stat(absPath)inside atry, and thecatchblock unconditionally interprets any exception as "path is absent locally", which feeds entries intotoDelete. ButDeno.statcan throw for reasons other than file absence:PermissionDenied,Interrupted,FilesystemLoop, or even transient NFS/FUSE errors. On any of these, the code would queue the remote S3 objects for deletion despite the local files still existing.Breaking example: A cache directory on an NFS mount briefly becomes unresponsive.
Deno.statthrowsDeno.errors.Busy(or a generic OS error). The catch block treats every index entry matching that dirty path as absent and deletes them from S3, destroying data.Severity: HIGH — This is a data-loss path. A transient I/O error during
Deno.statwould cause permanent remote deletion.Suggested fix: Check the error type before entering the deletion branch. Only treat
Deno.errors.NotFoundas absence; re-throw (or skip the path without deleting) for all other error types:s3_cache_sync.ts:1427— Index writeback condition(pushed > 0 || this.indexMutated)does not account fordeleted > 0alone whenindexMutatedis false.Trace the case: scoped walk finds 0 files to push but >0 files to delete. The delete loop at lines 1385–1416 sets
this.indexMutated = truewhen deletion succeeds (line 1405). So the writeback conditionpushed > 0 || this.indexMutatedevaluates to0 > 0 || true→true, and the writeback fires. This is correct in the current code because the delete loop mutatesthis.indexMutated.However, if a future refactoring moves the
delete this.index.entries[batch[j]]call or drops thethis.indexMutated = trueassignment, the writeback would silently skip, leaving S3 objects deleted but the remote index still referencing them. The condition would read more clearly as(pushed > 0 || deleted > 0 || this.indexMutated). This is noted for robustness but does not block since the current code is correct.Severity: LOW — The current code works; this is a maintainability concern only.
Medium
s3_cache_sync.ts:1509—return pushed + deletedcounts delete-failures as neither pushed nor deleted, silently under-reporting.If some deletes succeed and then the delete-failure check at line 1414 throws, the function never reaches line 1509 — the error propagates. That's fine. But consider a subtle scenario: if
pushFilefor one file intoPushfails (caught byPromise.allSettled, added tofailures), then the push-failures check at line 1381 throws before the delete loop runs. Any files that should have been deleted are silently skipped with no error message mentioning them.Breaking example: A batch of 5 files to push and 3 to delete. File 2 fails with 403. The function throws at line 1382 with a push-failure message listing only the push files. The 3 deletions never execute and are not mentioned in the error. The caller has no signal that deletions were skipped.
Severity: MEDIUM — The error message is misleading about the scope of what failed, but the operation is already in a failure state. A second push would retry.
s3_cache_sync.ts:1310-1319— Scoped-walk deletion prefix matching could match unrelated entries with overlapping path prefixes.The prefix construction at line 1311-1312 appends "/" if not already present, then checks
rel.startsWith(prefix). Consider: dirty path isdata/model, prefix becomesdata/model/. An index entrydata/model-v2/file.yamldoes NOT start withdata/model/, so it's safe. But if dirty path isdata/mo, prefix becomesdata/mo/, anddata/model/file.yamlDOES match.In practice,
markDirtyis called with paths likedata/<kind>/<type>/<id>which are structured enough to avoid collisions. But the code has no structural validation — it relies on callers never passing a truncated prefix.Severity: MEDIUM — Unlikely with well-formed caller paths, but no guard prevents it.
s3_cache_sync_test.ts:4191-4200— Test "pushChanged scoped walk deletes S3 objects" removes both the file and its parent directory before callingpushChanged, but doesn't verify what happens if only the file is removed (directory still exists).When the directory
data/model/id/v1still exists but is empty,Deno.statsucceeds (it's a directory), the code enters thestat.isDirectorybranch,walkfinds no files, and no deletion occurs — the remote S3 object is never deleted even though the file is gone. This is a real gap: if a caller deletes a file but not the parent directory, the scoped walk won't detect the deletion.Severity: MEDIUM — Edge case that could leave orphaned S3 objects when only files (not directories) are deleted.
Low
s3_cache_sync.ts:1392-1397— Delete batches reusethis.pushConcurrencyrather than having a separate delete concurrency.S3 DeleteObject is much cheaper than PutObject. Using the push concurrency (default 25) for deletes is conservative but leaves performance on the table for large deletion sets. Not a bug, just a minor inefficiency.
Severity: LOW
s3_cache_sync.ts:434—dirtyPathsOverflowed?: booleanis optional inDatastoreSyncStateV2but required inmarkSynced's written object (line 674).The field is always written as
dirtyPathsOverflowed: falsebymarkSynced, so a read of a v2 sidecar without the field (written by the previous version) getsundefined, which!!undefinedat line 533 coerces tofalse. This is fine — the optional-to-required transition is handled correctly by the!!coercion. The only concern is that during a rolling upgrade, old writers produce sidecars without this field and new readers silently ignore it. This is safe behavior.Severity: LOW
Verdict
FAIL — Finding #1 (HIGH) is a data-loss risk. The scoped-walk catch block at lines 1306-1321 conflates all
Deno.staterrors with "file not found", meaning any non-ENOENT stat failure (permissions, I/O, NFS timeout) on a dirty path would trigger remote S3 deletion of existing data. This should be narrowed to only trigger deletion onDeno.errors.NotFound.Code Review
Blocking Issues
None.
Suggestions
formatBatchFailurepreposition logic can be simplified (s3_cache_sync.ts:399).op === "pull" ? "from" : op === "delete" ? "from" : "to"has a dead middle branch — both"pull"and"delete"map to"from". Simpler and equivalent:op === "push" ? "to" : "from". No behaviour change, just easier to read.Mock
deleteObjectomits thesignalparameter (s3_cache_sync_test.ts:106).The real
S3Client.deleteObjectsignature is(key: string, signal?: AbortSignal): Promise<void>. The mock only declares(key: string). This compiles fine becausesignalis optional, but making the signatures match improves fidelity for future tests that pass a signal.Redundant
Deno.mkdirin two new tests (s3_cache_sync_test.ts:4246,4268,4290,4311).Deno.makeTempDir()already creates the directory; callingawait Deno.mkdir(cachePath, { recursive: true })immediately after is a no-op. Removing it declutters the setup.Awkward
typeof+ cast assertions (s3_cache_sync_test.ts:4216,4256).assertEquals(typeof synced, "number"); assert((synced as number) >= 1)can be collapsed toassertGreater(synced as number, 0)(orassertGreaterOrEqual(..., 1)). These are pre-existing patterns elsewhere in the file, so not worth blocking on, but the new tests could set a cleaner precedent.Overall the implementation is well-structured. The
dirtyPathsOverflowedflag correctly distinguishes overflow-triggered bulk walks (where remote-only entries are safe to delete) from no-relPathmarkDirty()calls (where they are not). The lazy-pull guard is applied consistently in both the scoped and bulk walk paths. State is persisted and restored correctly across restarts. The four new tests cover the key cases: scoped-walk deletion, overflow-triggered bulk deletion, no-relPath non-deletion, and lazy-pull suppression.Adversarial Review
Critical / High
None found.
Medium
Partial delete failure leaves remote index referencing deleted objects —
s3_cache_sync.ts:1436-1438If some
deleteObjectcalls in the batch succeed but others fail (e.g., transient 5xx on the Nth key), the code throws at line 1437 before the index writeback at line 1449. Successfully-deleted objects are already gone from S3, but the remote index still references them (the in-memory index was updated at line 1426, but never written back). A subsequentpullChangedby another client would attempt togetObjecton the deleted keys and fail with 404.Breaking scenario: Batch of 5 deletes — keys 1-3 succeed, key 4 gets a persistent 5xx after retries. Keys 1-3 are gone from S3, but the remote index still lists all 5. Another client's
pullChangedtries to download key 1 and gets a 404 → batch failure → entire pull aborted.Mitigating factors: S3
DeleteObjectis highly reliable and the retry logic handles transient failures. A persistent partial failure requires a pathological scenario (e.g., per-key IAM deny, which is unusual). The existing push path has the same structural issue for uploads (partial push success + throw before writeback), so this is consistent with the pre-existing contract. Still, the consequence for deletes is worse — orphaned pushes are benign; dangling index references break pulls.Suggested fix: Consider writing back the index (with successfully-deleted entries removed) before throwing the delete-failure error, or at minimum document the partial-failure semantics so callers know to retry
pushChangedafter a delete batch error.Per-path deletion intent lost when
pushFileforces bulk invalidation —s3_cache_sync.ts:1345-1373Scenario: (a)
markDirty({ relPath: "data/x" })addsdata/xtodirtyPaths, (b)pushFile("data/y")callsmarkDirty()(no relPath), settingbulkInvalidated = truewithout settingdirtyPathsOverflowed, (c) user deletesdata/xlocally, (d)pushChanged()takes the bulk walk (becausebulkInvalidatedis true), but skips deletion detection (becausedirtyPathsOverflowedis false). The local deletion ofdata/xis never propagated to S3.Breaking scenario: A command lifecycle that both marks specific paths dirty for deletion AND calls
pushFilefor a different file. The dirty-path deletion intent from step (a) is silently discarded.Mitigating factors: This requires interleaving
markDirty({ relPath })withpushFilein the same service instance, which may not occur in current core usage. The remote object is preserved (not data loss), and the next explicit sync could catch it.Suggested fix: Either set
dirtyPathsOverflowed = truewhenbulkInvalidatedis set whiledirtyPathsis non-empty, or document this interaction as a known limitation.Low
Duplicate entries in
toDeletefrom overlapping dirty paths —s3_cache_sync.ts:1283-1344If
dirtyPathscontains both"data/model"and"data/model/id/v1", and the filedata/model/id/v1/rawwas deleted, both dirty-path iterations add it totoDelete. This produces twodeleteObjectcalls for the same key — wasteful but not harmful since S3 delete is idempotent. Thedeletedcounter is also inflated, which inflates the return value.Suggested fix: Use a
Set<string>fortoDeleteinstead of an array, or deduplicate before the delete loop.pushChangedreturn value semantic change —s3_cache_sync.ts:1531The return changed from
pushed(upload count) topushed + deleted. Any caller interpreting the return specifically as "number of files uploaded" would get a higher-than-expected number. The existing tests use>= 1or=== 0checks, so they pass regardless. The semantic shift is arguably correct ("sync actions performed"), but it's an observable contract change.formatBatchFailurepreposition for delete: "from" —s3_cache_sync.ts:399When
op === "delete", the ternaryop === "push" ? "to" : "from"produces"Failed to delete 3 file(s) from S3". Grammatically fine and natural, but if more operations are added later, the binary ternary would need revisiting. Not a bug today.Verdict
PASS — The core deletion logic is sound: scoped-walk absence detection, bulk-walk overflow detection, lazy-pull suppression, and the
dirtyPathsOverflowedflag all interact correctly for the primary use cases. The new tests cover the key scenarios well, including the regression guard for no-relPathmarkDirtyand the lazy-pull suppression path. The medium findings are edge cases in uncommon interaction patterns, not blocking issues.Code Review
This PR implements absence-on-disk deletion in pushChanged and persists dirtyPathsOverflowed across restarts so the bulk-delete path survives crashes. The logic is sound and the new tests cover the key scenarios.
Blocking Issues
None.
Suggestions
1. toDelete array can contain duplicates when overlapping dirty paths both turn up absent
When a caller marks both a parent and child directory dirty and neither exists on disk, both dirty-path iterations hit the NotFound catch branch and collect the same index entries. Duplicate keys in toDelete cause s3.deleteObject to be called twice for the same key.
AWS S3 is harmless here: DeleteObject always returns 204 even for already-gone keys. However, the README lists MinIO, DigitalOcean Spaces, and R2 as supported backends. Some S3-compatible stores return 404 when deleting a non-existent key, which would land in deleteFailures and throw a spurious batch error.
Fix: use a Set for toDelete rather than an array, or dedup before the delete loop.
2. (Pre-existing) sanitizeResources: false without inline explanation on DEF-2 integration tests
Tests at lines 1145, 1175, 1205, and 1383 set sanitizeResources: false with no accompanying comment explaining why (CLAUDE.md requires one). The explanation appears near line 2553, not adjacent to these tests. The new deletion tests do not repeat this gap, so this is worth a follow-up cleanup rather than a blocker.