Skip to content

Retry a throttled lock claim when restarting a server - #42

Merged
chrismccord merged 2 commits into
mainfrom
cm-retry-lock-claim
Oct 13, 2026
Merged

chrismccord merged 2 commits into
mainfrom
cm-retry-lock-claim

Conversation

@chrismccord

Copy link
Copy Markdown
Member

Summary

A restarted server claims its existing key in try_lock_object_via_update/2 with an If-Match PUT. That PUT had no retries. The first-ever claim (ObjectStore.try_claim/3) and regular syncs (@max_sync_retries) both retry. So a single transient object-storage failure failed the start.

Object storage throttles rapid writes to one key, and a restart claims the key right after the previous owner's final writes. Sprites staging hit exactly that: a sprite start issued right after a stop returned internal_error, and the retained logs show:

01:12:31.117 DurableServer orgs/23430/clusters/default/sprite/dkr-src shutting down gracefully via user stop (:permanent)
01:12:31.439 [error] Failed to put object with etag: [key: "durable/orgs/23430/.../dkr-src", response: %Req.Response{status: 503, ...
             <Code>SlowDown</Code><Message>Please reduce your request rate.</Message> ...]
01:12:31.442 [error] Failed to initialize DurableServer: %Req.Response{status: 503, ...}
01:12:31.892 won the lock for "orgs/23430/clusters/default/sprite/dkr-src"   <- the next caller, 450 ms later

The lock claim now passes max_retries: 2, the same budget as try_claim. The object store retries 408/429/500/502/503/504 and transport timeouts and closes.

Safety. A retry whose earlier attempt actually landed gets 412. Backends.ObjectStore.put_object/4 already resolves a 412 on an :etag put through resolve_ambiguous_conditional_put/5. That reads the object back and adopts it only if it holds exactly this attempt's encoded state from the same boot owner. The claim's state carries this attempt's pid and heartbeat, so a different winner is still {:error, :conflict} → "raced the lock".

Testing

New DurableServerTest cases run against LocalStack with a Req adapter in front of it:

  • "a restart retries a throttled lock claim": the restart's lock claim gets one 503 SlowDown. Without this change the test fails with the same two log lines as staging. With it, the restart wins the lock.
  • "a retried lock claim whose first attempt landed is this boot's claim": the claim reaches storage, but its response is replaced with 503. The retry gets 412 and resolves as this boot's claim.

mix test: 454 passed, 37 excluded. mix compile --warnings-as-errors is clean.

A restart of an existing server claims its key with an If-Match PUT.
Unlike the first-ever claim (ObjectStore.try_claim/3, two transient
retries), that PUT had no retries, so one transient failure failed the
start. Object storage throttles rapid writes to one key: Tigris answers
503 SlowDown, and a restart claims the key right after the previous
owner's final writes. On Sprites staging a `sprite start` issued right
after a stop failed with internal_error this way:

  Failed to put object with etag: ... status: 503 ... <Code>SlowDown</Code>
  Failed to initialize DurableServer: %Req.Response{status: 503, ...}

The claim now retries transient failures twice, like try_claim. A retry
whose earlier attempt landed gets 412, which the object store backend
already resolves as this boot's own write (same-boot owner check).
@chrismccord
chrismccord merged commit ee904e4 into main Oct 13, 2026
2 checks passed
@chrismccord
chrismccord deleted the cm-retry-lock-claim branch October 13, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant