feat(datastores): two-phase sync for S3 and GCS (swamp-club#829) #84
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/two-phase-sync-s3-gcs"
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 the extension side of swamp-club/swamp#1720 — two-phase sync
(
preparePush/commitPush) for both@swamp/s3-datastoreand@swamp/gcs-datastore.The current single-phase push holds the global lock for the entire sync
(file upload + index write), serializing all concurrent writers. Two-phase
sync narrows the critical section:
preparePush()— uploads files outside the global lockcommitPush(manifest)— merges index entries under the global lock (fast)Concurrent writers to different models now overlap on the expensive file
I/O and serialize only on the fast index merge.
Changes
interfaces.ts(both S3 and GCS): AddtwoPhaseSynccapability,PushManifestopaque type,preparePush/commitPushmethods toDatastoreSyncServices3_cache_sync.ts: ImplementpreparePush(walk + upload withoutindex write) and
commitPush(fresh index pull, merge, write back).Advertise
twoPhaseSync: truegcs_cache_sync.ts: Same implementation adapted for GCS client APIs3_lock.ts: Fix pre-existingTimeouttype error (number→ReturnType<typeof setInterval>)2026.06.30.2, update descriptionsWhy this is correct
preparePushnever touches the remote index — it uploads files andreturns an opaque manifest. If it fails, no index corruption occurs.
commitPushalways reads the fresh remote index — another writer mayhave committed between phases, so the merge is against current state, not
a stale snapshot.
pushChangedis unchanged — zero regression for older core versionsthat don't know about two-phase sync. They continue using the single-phase
path.
twoPhaseSync: truebutolder cores ignore the capability entirely.
Verification
two-phase tests total)
swamp extension source add, model created and run, core dispatchedthrough the two-phase path:
Closes swamp-club#829
Code Review
Blocking Issues
None.
Suggestions
Dead code in
gcs_cache_sync.ts:1512(new in this PR)Inside
preparePush's bulk-walk deletion block:The outer guard requires
!this.lazyPullActive, so the innerthis.lazyPullActive &&is alwaysfalseand thecontinueis unreachable. The S3 equivalent (s3_cache_sync.ts) correctly omits this line. The same dead code exists in the pre-existingpushChangedat line 1291 (out of scope for this PR), but sincepreparePushis new code, it's a clean-copy opportunity to drop it.s3_lock.tschange touches adjacent code (CLAUDE.md: "Only touch what's necessary")The type change from
private heartbeatId: number | undefinedtoReturnType<typeof setInterval> | undefinedis unrelated to two-phase sync. In Deno,setIntervalreturnsnumber, soReturnType<typeof setInterval>resolves tonumber— the change is semantically a no-op. It's a benign quality improvement but it modifies code outside the scope of this PR.commitPushignoresoptions?.namespacein both implementationspreparePushcallsthis.bindNamespace(options?.namespace), butcommitPushonly extractssignalfrom options and ignores thenamespacefield. For the current intended usage (same service instance for both phases) this is harmless since the binding frompreparePushpersists on the instance. It's worth a comment noting thatcommitPushassumes the namespace is already bound, or a guard that callsbindNamespacedefensively.Round-trip test only checks index entry keys, not values
The
preparePush + commitPush round-trip matches pushChanged behaviortest asserts that both approaches produce the same set of entry keys but doesn't verify that the SHA-256, size, orlastModifiedvalues match. A content-level assertion would give stronger confidence in parity.Adversarial Review
Medium
Stale manifest can overwrite concurrent index updates (both S3 and GCS
preparePush/commitPush)preparePushuploads objects to the remote and captures their sha256 in the manifest.commitPushthen re-pulls the current index (forceRemote: true) and merges the manifest entries on top. If another process ranpushChanged(or its ownpreparePush+commitPush) between phases and uploaded different content for the same key, the merge produces an inconsistent state:Concrete scenario: Process A calls
preparePush(), uploadsdata/model-x/file.yamlwith sha256=aaa…. Process B runspushChanged()with the lock, uploadsdata/model-x/file.yamlwith sha256=bbb…(overwrites A's object in storage), writes index withsha256: bbb…. Process A then callscommitPush()with the lock, pulls the fresh index (contains B's sha256), merges A's manifest on top (overwrites withsha256: aaa…), writes index. Result: Index claims sha256=aaa…, but the stored object has B's content (sha256=bbb…).Impact: SHA-256 mismatch between the index and remote storage. Downstream consumers that verify content integrity will see corruption.
Mitigation: This depends on the caller's locking protocol. If the global lock is held for both phases (prepare + commit), this race can't occur. But the stated design intent is "narrow the global-lock critical section," implying
preparePushruns outside the lock. The interface contract (interfaces.ts) does not document the expected locking discipline.Suggested fix: Either (a) document that the caller MUST hold the lock across both phases (which reduces the benefit but prevents the race), or (b) have
commitPushre-verify that each uploaded key's sha256 still matches what's in remote storage before merging, or (c) use conditional writes (S3:If-None-Match, GCS:ifGenerationMatch) during thepreparePushupload so a concurrent overwrite causespreparePushto fail rather than silently losing.Files:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1415-1614(preparePush)datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1617-1681(commitPush)datastore/s3/extensions/datastores/_lib/s3_cache_sync.ts:1574-1776(preparePush)datastore/s3/extensions/datastores/_lib/s3_cache_sync.ts:1779-1843(commitPush)commitPushdoes not write back zombie-scrubbed index (both S3 and GCS)When
preparePushreturns an empty manifest (fast-path, or nothing dirty),commitPushenters the early-return branch (data.pushed === 0 && data.deleted === 0). This branch callspullIndex({ forceRemote: true }), which may scrub zombie entries viascrubIndex()and setthis.indexMutated = true. ButcommitPushnever checksindexMutated— it returns0without writing the cleaned index back to the remote.In contrast,
pushChangedexplicitly checksthis.indexMutatedin its writeback condition (pushed > 0 || deleted > 0 || this.indexMutated).Concrete scenario: Remote index has a zombie internal-cache-file entry (e.g.,
.datastore-index.jsonreferencing itself).preparePushfinds nothing to push.commitPushpulls the index,scrubIndex()removes the zombie, setsindexMutated = true, butcommitPushreturns 0 without writing back. The zombie persists in the remote index until a future push with actual file changes.Impact: Zombie entries persist longer than they would with
pushChanged. Low practical impact since zombies are inert metadata, but it's a behavioral divergence between the two code paths that could surprise callers or mask a scrubIndex regression.Suggested fix: In
commitPush, checkthis.indexMutatedin the early-return branch and write back if set, mirroringpushChanged's behavior.Files:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1624-1641datastore/s3/extensions/datastores/_lib/s3_cache_sync.ts:1786-1803Low
GCS
preparePush: deadisLazySkippableguard in full-walk branchAt
gcs_cache_sync.ts:1512, inside theelse(full-walk) branch:The outer
ifrequires!this.lazyPullActive, so the innerthis.lazyPullActivecheck is always false. This is dead code —isLazySkippableis never reached. The S3preparePushcorrectly omits this guard. Note: this is a pre-existing issue copied frompushChanged(line 1291), not a new bug.File:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1512No runtime validation of manifest in
commitPushcommitPushcasts the opaquePushManifesttoInternalPushManifestviaas unknown as. If a corrupted or foreign manifest is passed (e.g., from serialization across process boundaries, or from a different extension version), the code would silently merge garbage into the index. The branded type provides compile-time safety but no runtime guard.This is unlikely in practice since core treats the manifest as opaque passthrough within a single process, but worth noting for future resilience if the manifest ever crosses a serialization boundary.
Files:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1621datastore/s3/extensions/datastores/_lib/s3_cache_sync.ts:1783Verdict
PASS — The implementation is structurally sound: it correctly splits the upload and index-write phases, re-fetches the remote index in
commitPushto merge against current state, handles abort signals consistently, and mirrors the existingpushChangederror-handling patterns. The concurrent-overwrite race (Medium #1) is a design-level concern that depends on the caller's locking protocol rather than a code bug, and the zombie-scrub gap (Medium #2) is a minor behavioral divergence with negligible practical impact. The s3_lock.ts type fix and interface additions are clean. Tests cover the core contract (prepare uploads without indexing, commit merges and writes index, round-trip parity with pushChanged, fast-path no-op).