Skip to main content
← Back to list
01Issue
BugIn ProgressSwamp CLIPublic
Assigneesstack72

Relationships

#3485 S3/GCS: a run refused for losing its model lock keeps its write dirty when the refresh can't reach the datastore, so a later sync pushes it

Opened by stack72 · 10/14/2026

Background (read this first)

swamp is reworking its datastore layer (tracking: swamp-club#2865). Before Phase 5 (serve leader, commit feed and model leases), swamp-uat is pinning today's v2 behaviour:

  • concurrent runs (swamp-uat#488);
  • an unreachable remote (swamp-uat#489);
  • multi-instance serve (swamp-uat#491).

swamp-uat#489 found this bug. It came with the swamp-club#3373 fix.

How v2 works:

  • on S3 and GCS, repositories write into the datastore cache dir and mark files dirty, and the extension's sync service (@swamp/s3-datastore, @swamp/gcs-datastore) pushes them;
  • per-model locks (acquireModelLocks, src/cli/repo_context.ts) serialise runs of one model across clones. Each is a DistributedLock (src/domain/datastore/distributed_lock.ts), backed on buckets by the extensions' S3Lock and GcsLock, and kept alive by a heartbeat with a 30 s TTL;
  • since #3373, before each push assertModelLocksHeld (around line 3130 of repo_context.ts) reads each lock with inspect(). If one no longer names this run's holder, the run refuses to push, pulls the lost models so the cache holds the datastore's copy, and throws ModelLockLostError (model_lock_lost, exit 1).

The bug

A run that exits 1 saying "Nothing was pushed" can still have its write pushed later.

  1. Clone A starts a mutating method run, which takes the model's lock.
  2. The datastore becomes unreachable while the method body runs.
  3. The run decides its lock is lost and refuses to push:

    This run lost its lock on @test/side-effect/ (now released) before pushing: the lock was force-released or taken over by another process, which may have written the same data version. Nothing was pushed, and this host's copy of the model's data was refreshed from the datastore, which may replace what this run wrote. Run the method or workflow again.

  4. The refresh pull fails, because the datastore is still unreachable. It is best effort, so the failure is only logged at debug. The run's data version stays in A's cache, still marked dirty.
  5. The datastore comes back. A's next swamp datastore sync pushes that version, and clone B pulls it. Both clones now hold data from a run that reported failure.

The same gap exists after a real takeover (a breakglass with lock release --force) whenever the refresh pull fails for any reason. A later push then uploads the refused version over the other run's, which is the lost write #3373 was meant to stop.

Cause

The refusal relies on the refresh pull to overwrite the refused versions in the cache. When that pull can't run, nothing removes them from the cache or from the dirty set, and the next push (a later datastore sync, a workflow's end-of-run push, serve's sync) uploads them.

A contributing cause is in the GCS extension, and will be filed separately as a swamp-club Lab issue. GcsLock.inspect() returns null for any read failure, a timeout included (readLockWithGeneration catches everything), so an unreachable bucket looks like a released lock. S3Lock returns null only when the lock object is absent, and throws otherwise. On S3, the same outage therefore fails the push as an ordinary push error: the write stays dirty and is pushed later, which is v2's normal offline behaviour.

This issue is the core side. It has to hold whatever any lock backend returns, since third-party registry locks may also report errors as "absent".

What to do

Make "Nothing was pushed" true without depending on the refresh pull. When a run refuses its push because a lock was lost:

  • remove the run's own refused data versions from the local cache and from the dirty set before throwing ModelLockLostError;
  • then try the refresh pull, as today, best effort. A later sync pulls whatever the datastore holds, so the cache converges either way.
  • The run knows what it wrote: the push is given the data versions the work under the locks wrote (ModelLockResult push / flush(written)). Remove those, never versions this run didn't write.
  • Keep the error code and exit code. Adjust the message only if it has to match what now happens: the run's write was discarded, and the model's data is refreshed from the datastore when it can be reached.
  • Filesystem and v3 don't change. v2 S3 and GCS pushes that keep their lock don't change.

If tracing shows the refused versions can't be told apart from other dirty files in the cache (for example a version directory shared with an earlier run), stop and say so on this issue before widening the fix.

Tests

  • On the in-memory remote (packages/testing), with failure injection:
    • lock lost while the remote is unreachable: the run's lock read returns "absent" and the refresh pull fails. The run exits with model_lock_lost, its version is gone from the cache and the dirty set, and a later sync pushes nothing of it;
    • breakglass with a failed refresh: another holder's version survives a later sync from the old holder;
    • unchanged: a holder whose lock is still its own pushes as today, and a refusal whose refresh pull succeeds behaves as today.
  • Check the tests fail without the fix: confirm each fails on main.

Acceptance

  • A run that exits with model_lock_lost leaves its data on no clone, ever, whether or not the datastore is reachable when it exits.
  • swamp-uat#489's scenario "swamp model method run cut off from its gcs datastore mid-run ends with its data on every clone or on none, never a partial result or a hang [gcs]" passes once un-ignored, with no other swamp-uat change.

Evidence:

  • swamp 20261013.200158.0-sha.35be0b9d;
  • swamp-uat CI run 38473633290, job "Datastore: gcs (3/4)": AssertionError: a failed run must leave no data; actual ["15517086-…"], expected [];
  • reproduced locally on macOS against fake-gcs-server.

Blocked by: nothing. Runs in parallel with Phase 4 (swamp-club#3482).

02Bog Flow
✓OPEN✓TRIAGED◉IN PROGRESS○SHIPPED+ 1 MOREASSIGNED+ 5 MOREREVIEW+ 3 MOREVERIFICATION_STARTED

In Progress

10/14/2026, 11:48:29 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/14/2026, 11:28:32 PM
Editable. Press Enter to edit.

stack72 commented 10/14/2026, 11:36:09 PM

Triage finding before planning, as the issue asks.

Reproduced on main with a test on the in-memory remote, with and without twoPhaseSync:

  • Lock read as absent while the remote is offline: the run exits model_lock_lost, and a later push uploads its version.
  • Breakglass, B pushes the same version number, A's refresh pull fails: A's later push overwrites B's version.

The refused versions can be told apart. Deleting them is not enough. I tried removing the refused version (UnifiedDataRepository.delete) before throwing:

  • Offline case: fixed.
  • Breakglass case: a later push-first sync deletes B's version from the remote. That covers a workflow's end-of-run push and datastore sync --push. The removed paths stay in the extension's dirty set, and core has no way to unmark them. The extension's push reads the fresh remote index and treats a marked, locally absent path as a delete (markDirty contract rule 2). A pull-first sync (bare datastore sync) is safe.

Plan going forward (core only, no extension change): when a run refuses its push, put each file of its refused versions back to the datastore's copy, along with each data name's latest marker. Read each with fetchContent, which S3, GCS and the in-memory remote implement:

  • the remote has the file: write its bytes into the cache, so a later push sees no change;
  • the remote lacks it: remove it locally;
  • the read fails: remove it locally.

Then try the refresh pull as today. Error code and exit code are unchanged.

Remaining gap: a breakglass, plus the datastore becoming unreachable between the lock read and the per-file reads, plus a later push-first sync. Phase 5 leases close it.

Sign in to post a ripple.