feat(gcs-datastore): implement absence-on-disk deletion in pushChanged (swamp-club#798) #71
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/gcs-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
Implements markDirty contract rule #2 for the GCS datastore: when core calls
markDirty(relPath)before removing a file,pushChangednow detects the absent dirty path and issues GCSdeleteObjectcalls for matching index entries.bulkInvalidated)lazyPullActive+isLazySkippableto avoid deleting un-hydrated contentpushChangedwithoutmarkDirtyare unaffected (no dirty signal = no deletion)Test plan
markDirty(relPath), remove file,pushChangedissuesdeleteObjectand removes index entrymarkDirty()without relPath, remove file,pushChangeddetects orphan and deleteslazyPullActive=true, absent un-hydrated files are NOT deletedmarkDirty, no files are deleteddeno check,deno lint,deno fmt --check,deno install --frozenall cleanCloses swamp-club#798
Adversarial Review
Medium
toDeletefrom overlapping scoped dirty pathsdatastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1186-1199this.index.entriesand appends matching keys totoDelete. If two dirty paths overlap (e.g.data/model/alphaanddata/model/alpha/v1), the same index key can be appended totoDeletemultiple times.markDirty({relPath: "data/model/alpha"})thenmarkDirty({relPath: "data/model/alpha/v1"}), then delete the directory.pushChangedbuildstoDelete = ["data/model/alpha/v1/raw", "data/model/alpha/v1/raw"]. The delete loop fires twodeleteObjectcalls for the same key (the second is a GCS no-op since 404 is handled), incrementsdeletedto 2 instead of 1, and the return value is inflated.delete this.index.entries[key]on the second pass is a no-op, anddeleteObjecthandles 404 gracefully.toDeletebefore the delete loop, or use aSet<string>and convert to array at the end:Low
Return value semantics change
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1351pushChangedpreviously returnedpushed(upload count); it now returnspushed + deleted. TheDatastoreSyncServiceinterface says "returns the count synced" which is ambiguous. Callers that log this as "N files pushed" will now report a count that includes deletions.Partial delete failure leaves stale index entries for already-deleted objects
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1293-1296pullChangedfrom another client would try to download the missing objects, get 404s, and fail with a batch error.pushChanged. This is the same pattern that existed for partial push failures before this PR, so not a regression.Verdict
PASS — The deletion implementation is well-designed with appropriate guards: reader-side safety (no
markDirty= no deletes), lazy hydration protection (un-hydrated raw files excluded from bulk orphan scan), properisInternalCacheFilefiltering, retry-with-backoff on delete calls, and comprehensive test coverage for all paths. The medium finding (duplicatetoDeleteentries from overlapping dirty paths) is low-impact and self-correcting. The overall approach is sound and consistent with the existing push/pull patterns.Code Review
Blocking Issues
None.
Suggestions
Scoped walk catch block catches all
Deno.staterrors, not justNotFound(gcs_cache_sync.ts, the catch block in the scoped walk added by this PR): IfDeno.stat(absPath)fails for a reason other thanNotFound— for example a transientPermissionDeniedwhile the cache directory is temporarily inaccessible — the code currently queues all matching index entries for remote deletion. This could delete GCS objects that actually exist locally but are momentarily unreadable. The fix is straightforward: checkerr instanceof Deno.errors.NotFoundbefore populatingtoDelete:The previous code silently swallowed all errors here (prior comment: "Path may have been deleted between markDirty and push"), so this is not a regression, but now that the catch block does real work the distinction matters.
deleteObjecton GCS returns 404 for already-deleted objects; concurrent callers will fail (gcs_cache_sync.tsdelete loop, ~line 1275): GCS DELETE returns HTTP 404 when the object does not exist, which the GCS client surfaces asNotFoundError.isRetryableErrortreatsNotFoundErroras terminal and non-retryable, so if two concurrentpushChangedcalls race to delete the same set of dirty-then-absent paths, the second caller will collectNotFoundErrorentries indeleteFailuresand throw a batch-delete error rather than succeeding silently. Silently ignoringNotFoundErrorinside the delete loop (or inside the retryWithBackoff wrapper) would make deletion idempotent:No test exercises the delete-batch-failure message path: The
formatBatchFailurefunction now handles"delete"as an operation type, and the mock'sdeleteFailuresmap supports injecting per-key failures. A test that injectsdeleteFailuresfor one or more keys and verifieserr.messagecontains"Failed to delete"would complete the coverage added in DEF-2 for the push case.