Skip to content

Modal replay: at most two simulations across invocations - #166

Draft
MaxGhenis wants to merge 5 commits into
mainfrom
w6/modal-slots
Draft

MaxGhenis wants to merge 5 commits into
mainfrom
w6/modal-slots

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

WIP: W6 of the Autumn Budget 2026 CI plan, whose brief caps Modal at two concurrent simulations. Independent of the W6 stack (#160–#165); it branches from main.

Why

pipeline/modal_uk_event.py runs one replay worker per invocation (max_containers=1, workers=1). Separate invocations shared no limit, so a third concurrent invocation exceeded two simulations. The r4 audit flagged this as "separate Modal invocations currently have no global limit".

What

  • Before app.run(), an invocation takes one of two slots in the Modal Dict scorecard-replay-simulation-slots (SimulationSlots). Taking a slot uses Dict.put(key, value, skip_if_exists=True), an atomic server-side create-if-absent. I read its source in modal 1.3.2: DictUpdateRequest(if_not_exists=...) returns created.
  • If both slots are held, the invocation waits, polling every 30s for up to 6h.
  • A lease lasts 4h, longer than the 3h function timeout, so a live run never loses its slot.
  • An expired lease is reclaimed by whoever first creates its one-time reclaim token, so two reclaimers cannot both take it.
  • Release happens in a finally, including on interrupt. Only the holder can release its slot.
  • RECIPE.md is updated. Local simulations stay outside the lease and are counted by hand.

Invariants and tests (tests/test_modal_uk_event.py)

  • Two slots, then a third run waits and times out.
  • A non-holder's release does nothing.
  • An expired slot is reclaimed exactly once, and the dead holder cannot free the new lease.
  • The lease outlasts the timeout.
  • Hypothesis over random acquire/release/clock interleavings of four holders: never more than two live holders, and live holders equal the recorded ones.
  • An interrupted run releases its slot.

The 44 Modal-adapter and adoption tests pass.

axiom: n/a: replay infrastructure in scorecard.

🤖 Generated with Claude Code

Each Modal invocation runs one replay worker, and separate invocations
shared no limit, so a third concurrent run would exceed the two the shared
account allows. Before app.run(), an invocation now takes one of two slots
in the Modal Dict scorecard-replay-simulation-slots: put(skip_if_exists=True)
is an atomic create-if-absent on the server. A lease outlasts the function
timeout; an expired one is reclaimed by whoever first creates its one-time
reclaim token. The slot is released in a finally, interrupts included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-scorecard Ready Ready Preview Oct 11, 2026 11:57pm UTC

Request Review

Review of 14d6dab found three ways the two-slot lease could fail: a
reclaimer dying between creating its one-time token and taking the slot lost
that slot for good; a holder past its lease could pop a newer claim in
release, and a reclaimer's unconditional pop could delete a claim made after
it looked, each allowing a third simulation; and the property test asserted
something false once 24 ticks passed.

Every change of holder, and every renewal, now creates the slot's next
generation key with put(skip_if_exists=True), Modal's atomic
create-if-absent; nothing is popped, and a holder writes only its own key,
only to release or shorten it. A head key names the newest generation as a
hint (readers walk forward), and a random chain id separates keys written
after Modal expires idle entries.

main() renews the lease after the image build, just before the remote call,
and the remote function refuses to start unless its 3-hour timeout fits in
the lease, so a run ends before its slot can pass on even if the client
dies. A renewal after the lease lapsed is refused (exit 75). After ctrl-C,
Modal's client asks the server to stop the app's tasks
(modal/runner.py _disconnect), and the slot frees 10 minutes later instead
of at once.

Tests drive the protocol call by call: the review's three scenarios,
a holder dying after each possible step, a stale renewal after Modal
expired the chain, and a Hypothesis model check interleaving 2-4 holders'
Dict calls with clock jumps, crashes, interrupts and 7-day entry expiry
(no two runs overlap on a slot; both slots are free again once every lease
lapses). 20,000 generated schedules hold; each protection's deterministic
test fails when that protection is removed. Assumption, stated in the
docstring and RECIPE: no process stalls for days inside one protocol step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of dff81d0:

- Modal's runner re-raises a lost connection (modal.exception
  .ConnectionError) without sending its stop request (modal/runner.py,
  the ConnectionError branch of _run_app), yet main() freed the slot ten
  minutes later while the container could run for three hours. The grace
  now applies only when app.run() itself exits after a caught ctrl-C, where
  the runner awaited the stop request; any other exception after the run
  started leaves the claim until the lease lapses, which the remote guard
  puts after the run's end. A remote error now holds the slot until then
  too (conservative).
- A writer stalled across Modal's 7-day expiry could overwrite head-{slot}
  with its dead chain and hide a live holder. A protocol step (acquire or
  renew) that took over a day now gives its claim back and writes no head.
- The remote guard keeps a 10-minute margin inside the lease for clock
  differences and kill latency.
- The model check now caps stalls per protocol step, not per tick, and
  models a lost connection (the run continues to its own end, nothing is
  written). New tests: a week-stalled acquire cannot replace a live chain;
  a lost connection keeps the claim at its full lease; a finished run frees
  the slot at once; the stale-renewal scenario now asserts that the key
  it would recreate is absent. 20,000 generated schedules hold; the head
  test fails when the stale-step bail is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of 4a5a272:

- The step's clock was read after the head response, so a response left
  unread through a week asleep looked fresh. It is now read before the
  first call.
- A head write landing a week late, after its keys expired, could point
  readers at a dead chain and hide a live holder. A reader whose head names
  a claim that is gone now blocks the slot for one lease (any hidden
  holder's lease began before that write) instead of taking it.
- A claim whose head write fails is given back at once, on acquire and on
  renewal, instead of holding the slot for a lease with no run.
- A result that returned is downloaded even if leaving the app then fails.
- The docstring and RECIPE now state the guarantee against Modal's 7-day
  entry expiry, the two detected long-stall cases, and the one case not
  covered (a head write 7+ days late naming a generation that still exists
  while later ones expired), instead of an unconditional claim.

Tests: a head response read before a week asleep (with and without a
claim), a head written a week late, a failed head write on acquire and
renewal, a returned result surviving a failed app exit; the model's final
phase allows one blocked lease. Each new protection's test fails when the
protection is removed; 20,000 generated schedules hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of bac8974 reproduced three failures by running the code:

1. A head write delayed 6.5 days plus a reader stalled 0.95 days recreated
   expired generations and overlapped a live run, inside the guarantee as
   stated. Any pointer can be rolled back by a late write, so the pointer
   is gone. A slot now has one chain of claims per calendar day
   (claim-{slot}-{day}-{generation}); a caller creates a chain's next
   generation, with Modal's atomic create-if-absent, only when its newest
   one is released or lapsed. A lease that crosses midnight holds a claim
   in both days' chains, taken in order and given back together. A day's
   chain matters for at most a day plus a lease, far inside the 7 days
   after which Modal expires idle entries, so no claim that still matters
   can have expired and a late write lands in a chain that governs
   nothing. The guarantee no longer assumes anything about stalls. Head
   keys, chain ids, the blocker claim, the stale-step bail and renewal are
   removed.
2. A contender whose clock ran five hours fast took an occupied slot.
   Leases now run on Modal's clock: main() measures its offset with a
   small remote function, checks it again after waiting for a slot (drift
   over 30 s stops the run, exit 75), and a lapsed claim is taken only
   2 minutes past its expiry.
3. A Dict failure at release discarded a completed result. A failed
   release is now reported and the result is still downloaded; the claim
   lapses.

The slot is taken inside app.run(), after the image build, so a build no
longer consumes lease time.

Tests: the model check now has no stall cap and gives each holder its own
clock within the enforced drift, with jumps of any size (including 6.5 and
8 days) at any point, midnight crossings, crashes, interrupts, lost
connections and entry expiry; 20,000 generated schedules hold. New
deterministic tests cover the clock margin, a lease across midnight, a
refused second day, a step that outlasts its lease, the reviewer's late
write and five-hour-fast-clock scenarios, the offset measurement, a failed
release, and a clock that moved while waiting. Removing the takeover
margin, the newest-claim check, the second day's claim or the
time-left check each fails a deterministic test.

Verified on Modal: a real --preflight-only invocation ran the clock
function, took generation 0 in today's and tomorrow's chains (the lease
crossed UTC midnight), passed the remote guard and released both claims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 3e058b58 Deployed Oct 11, 2026 by vercel[bot]
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