Skip to content

Fail report runs and simulation runs left running after their Modal call ended - #994

Draft
anth-volk wants to merge 9 commits into
v2plan/b3-3-workers-write-runsfrom
v2plan/b3-4-stuck-run-sweep
Draft

anth-volk wants to merge 9 commits into
v2plan/b3-3-workers-write-runsfrom
v2plan/b3-4-stuck-run-sweep

Conversation

@anth-volk

Copy link
Copy Markdown
Contributor

Fixes #993

Same-Repository Draft PR Check

  • This PR is a draft.
  • This PR is from a branch in PolicyEngine/policyengine-sim-api, not a personal fork.

Summary

A scheduled Modal function now marks the Public API's report runs and simulation runs failed when they are still running but the Modal call recorded for them has ended, for example because its container ran out of memory before it could record the outcome. It runs every ten minutes, never fails a run whose call is still running or whose state Modal cannot report, and two sweeps at once fail each run once.

This builds on #992, which records each run's Modal call ID when the coordinator or a worker claims the run; review only the commits after it.

How it tells that a call has ended

Every ten minutes the sweep lists the report runs, and then the simulation runs, that have been running under a recorded Modal call for at least ten minutes (counted from started_at, or updated_at when there is none). For each recorded call it asks Modal once for the call graph the call is part of (modal.FunctionCall.from_id(call_id).get_call_graph()) and reads the status of the inputs whose call ID is the recorded one, wherever they are in the graph: a worker spawned inside a coordinator's input is nested under it.

  • any PENDING input: the call is still running, or waiting to run again; Modal restarts a pre-empted call under the same call ID and reports it pending meanwhile;
  • every input SUCCESS, FAILURE, TERMINATED, TIMEOUT or INIT_FAILURE: the call has ended, and the run is marked failed with modal_call_ended: The Modal call recorded for this run ended without recording the run's outcome.;
  • no input with that call ID, or any error asking (an unknown call, Modal unreachable): unknown, and the run is left as it is.

A simulation run is listed only while no report run that combines it is running. That report run's coordinator records its children's outcomes itself; after a pre-emption it cancels them and starts them again under new calls, and a sweep in that gap would otherwise fail a run that is about to start again. Report runs are swept first, so a dead coordinator's simulation runs are reached in the same sweep.

A run still running 24 hours after its first claim is failed whatever Modal reports, with or without a recorded call (run_exceeded_maximum_age). No attempt of these functions can run nearly that long (the coordinator's own time limit is 90 minutes), however often Modal pre-empts and restarts it; the limit stops a run staying running for good when Modal keeps reporting a call it won't retry as pending, no longer describes a call, or no call was recorded.

The failure is one conditional UPDATE … WHERE id = … AND status = 'running' AND modal_call_id = <the listed call>, so a run that finished, or that moved to a new call, since it was listed is left alone, and a second sweep running at the same time changes nothing the first changed.

Where it runs

A new Modal application, policyengine-simulation-run-sweep (src/modal/run_sweep_app.py), with one function, sweep_stuck_runs, on the cron schedule */10 * * * *, at most one container at a time, with the Modal worker role's database secret (policyengine-modal-worker-database) and the locked package group the v2 images install, without the bundle's data. It is not versioned with the .py bundle, so one copy runs per Modal environment rather than one per deployed v2 application. The deploy workflow doesn't deploy it yet; an operator deploys it (below).

Not covered: household jobs, which nothing records from Modal yet. A run marked running with no recorded call is failed only by the 24-hour limit.

Tests

The store's listing and conditional failure run against a disposable Postgres migrated with the Public API's v2 chain, as the Modal worker role, including two sweeps started together in threads, a run past the 24-hour limit with no recorded call, and simulation runs combined by pending, several or no report runs; they skip when REPORT_RUN_TEST_DATABASE_URL and REPORT_RUN_TEST_SEED_DATABASE_URL are unset, as in CI. The sweep's decisions and the call-graph reading use stand-ins for Modal and the store. The Modal application's declaration is checked with the repository's fake Modal module.

Environment variables

  • MODAL_WORKER_DATABASE_URL: read by sweep_stuck_runs. Provider: the Modal secret policyengine-modal-worker-database, created by an operator in the staging and main Modal environments (the step Record report runs and simulation runs from the v2 Modal coordinator and workers #992 waits for). Wiring: modal.Secret.from_name(..., required_keys=["MODAL_WORKER_DATABASE_URL"]) attached through secrets= to sweep_stuck_runs. Not verified: the secret does not exist yet.
  • The Modal client in the container authenticates as the workspace the app is deployed in; no other variable is read.

What this waits for before merging

  1. Record report runs and simulation runs from the v2 Modal coordinator and workers #992 merged (this PR is stacked on it), with everything it waits for: Record each run of a v2 simulation and report, and key results by run policyengine-api#4162 deployed, the Modal worker role and its grants, and the Modal secret.
  2. An approving review.

After merging, an operator deploys the app once per Modal environment, from projects/policyengine-simulation-executor:

uv run --frozen modal deploy --env=staging src/modal/run_sweep_app.py
uv run --frozen modal deploy --env=main src/modal/run_sweep_app.py

and checks that modal app list shows it and that its first scheduled run returned a summary (checked, failed, running, unknown, left, failed_for_age) without errors in modal app logs policyengine-simulation-run-sweep.

🤖 Generated with Claude Code

anth-volk and others added 9 commits November 4, 2026 01:52
The store can now list the report runs and simulation runs that are
running under a recorded Modal call and started before a given time,
and mark one failed only while it is still running under the call it
was listed with. A run that finished, or that a restarted coordinator
moved to a new call since it was listed, is left as it is, so two
sweeps at once fail each run once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep lists the report runs and simulation runs that have been
running under a recorded Modal call for at least ten minutes, asks
Modal for each call's call graph once, and marks a run failed only when
every input of its call has finished: succeeded, failed, terminated,
timed out or failed to start. A call with a pending input is running;
Modal reports a pre-empted call pending until it runs again under the
same call ID. A call Modal cannot describe, or any error asking, leaves
the run as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The new Modal application policyengine-simulation-run-sweep runs the
sweep on a cron schedule every ten minutes, one container at a time,
with the Modal worker role's database secret and the locked packages of
the v2 images, without the bundle's data. It is not versioned with the
.py bundle, so one copy runs per Modal environment; an operator deploys
it. A sweep that overlaps another is still safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's app module imports src.modal at module level, and Modal
imports that module again in the container, so the image now adds the
src.modal sources as the project's other Modal apps do. Without them
every scheduled run failed with No module named 'src'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal returns the whole call graph a call belongs to, and nests a call
spawned inside another call's input under that input, so the sweep now
reads only the inputs whose call ID is the recorded one, wherever they
are, and treats a call missing from its graph as unknown. Before, a
worker's simulation run was judged by its coordinator's status.

A simulation run is now listed only while no report run that combines it
is running: that report run's coordinator records its children's
outcomes, and after a pre-emption it cancels them and starts them again
under new calls, so a sweep in that gap could fail a run that would have
finished. The sweep handles report runs first, so a dead coordinator's
children are reached in the same sweep. Listing has no row limit, so
runs that stay unknown can't hide newer ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal reports a call it won't retry after an internal failure as
pending, stops describing old calls, and a run can be marked running
with no call recorded; none of these could be decided, so such a run,
and the simulation runs it combines, stayed running for good. The sweep
now also fails every run still running 24 hours after its first claim,
whatever Modal reports and with or without a recorded call, a time no
attempt of the v2 functions can approach. A run listed without a call is
failed only while it still has none. The summary now also counts runs
left because they finished or moved to another call, and runs failed for
their age, and the listing of simulation runs is tested with pending,
several and no report runs combining them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep now fails runs past the maximum age before it asks Modal about
any call, so lookups that stall can't use up the scheduled function's
time before the check that exists for undecidable runs. The update that
fails a simulation run repeats the condition that no running report run
combines it, so a report run that starts combining it after the listing
keeps it. The failing write is renamed fail_stuck_run, the age message
no longer names a fixed age, and the changelog fragment and docstrings
say that a run past the maximum age is failed whatever Modal reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep checked simulation runs' age only after every report run's
Modal lookup, so lookups that stalled could still keep simulation runs
past the maximum age from being failed. It now fails report runs, then
simulation runs, past the maximum age before any lookup, then asks about
each kind's calls, report runs first. A refused failure in the age check
counts as left, and the docstrings say a write is refused also when a
running report run started combining the simulation run.

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

This branch has not been deployed

No deployments
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