Skip to content

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

Merged
anth-volk merged 26 commits into
mainfrom
v2plan/b3-4-stuck-run-sweep
Nov 10, 2026
Merged

anth-volk merged 26 commits into
mainfrom
v2plan/b3-4-stuck-run-sweep

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 3, 2026 •

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 nothing will finish them: the run is pending or running but the Modal call recorded for it has ended, for example because its container ran out of memory before it could record the outcome, or the run is still pending with no Modal call recorded ten minutes after it was created, because the simulation service stopped before starting its call. It runs every ten minutes, never fails a run whose call is still running or queued, or whose state Modal cannot report, and two sweeps at once fail each run once. The deployment workflow deploys it with the executor.

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. The simulation service also records, on a pending report run, the Modal call it starts.

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 pending or running for at least ten minutes, counted from their first claim (started_at) or, while pending, from their creation (created_at). A pending run with no call recorded is failed without asking Modal: run_not_started: The run stayed pending with no Modal call recorded for it. Usually the simulation service stopped before starting its call; if it started one but failed to record it, that call's claim is refused once the run has failed. A running run with no call recorded is left to the 24-hour limit. For each recorded call, on a pending or a running run, 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, queued, 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 pending or running report run names it as its baseline or reform (report_runs.baseline_simulation_run_id or reform_simulation_run_id). That report run's coordinator starts its children's calls once Modal runs it, and records their outcomes itself; after a pre-emption it cancels them and starts them again under new calls, and a sweep while it is queued or in that gap would otherwise fail a run that is about to start. Report runs are swept first, so a dead coordinator's simulation runs are reached in the same sweep.

A run still unfinished 24 hours after its first claim, or after its creation if it was never claimed, 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 = <the listed status> AND modal_call_id = <the listed call> (or IS NULL), setting only the status, error, completion time and updated_at, so a run that was claimed, finished, moved to a new call, or had its call recorded by the simulation service 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. Because it is unversioned and its schedule starts as soon as it deploys, it deploys only from main and only after the release has passed its checks: in simulation-deploy.reusable.yml's publish_stage12_v2_default job (Stage 12's own job, which runs after the integration and authenticated tests and the Cloud Run promotion, in a main deployment and in a Stage 12-only one), after the moved v2 default's reader check, with uv run --frozen modal deploy --env="${{ inputs.modal_environment }}" src/modal/run_sweep_app.py. If that deploy fails, the job fails and its last step writes the previous default back. A deployment dispatched from another branch, which may be behind main, leaves the environment's sweep as it is. Modal refuses the deployment while the Modal secret is missing; the v2 workers' deploy earlier in the run requires it too.

Not covered: household jobs, until the simulation service records their Modal calls. A run marked running with no recorded call is failed only by the 24-hour limit.

Tests

On 2026-11-09 #992's new head, which merged sim's main, was merged in (160ce40f, no conflicts), then #992's later head (585a71e8) and, after #992 merged, main (eb231f1c, no change to any file), each without conflicts. 9944590a fixes test_the_scheduled_function_sweeps_with_the_report_run_store, which failed before and after the merge because it expected the scheduled function's result without failed_without_call; it now sets that count and expects it back. After that, this pull request's 19 changed executor tests and 14 changed store tests passed by node ID, the store tests against a local database as the Modal worker role, and Ruff, the quality guards, the rules document, uv lock --check and Pyright for the Stage 12 persistence library passed. A review round on the merge found no bugs in it, but found that the deploy step, then in deploy_executor, ran before the release's checks and only in main deployments; the user chose on 2026-11-10 to move it after every check. a47c810d requires the step in publish_stage12_v2_default (seen failing), and 03cd559a moves it there and updates the changelog fragment; the job's uv sync --frozen is enough to import the sweep app, and actionlint is clean. A second review round found that a dispatch from another branch still replaced staging's sweep: cb955a3d requires the step's condition github.ref == 'refs/heads/main' (seen failing), and ca51c9d7 adds it and updates the app's docstring, the changelog fragment and the executor README's Stage 12 default section. A third review round found no bugs; its one text finding, the app docstring's account of when the sweep deploys, is fixed in 6d1b16fe. It noted that re-running an older main run after a newer one would redeploy the older sweep.

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, with the worker's UPDATE limited to its columns, including two sweeps started together in threads, pending runs listed from their creation, a pending run whose call was recorded or claimed after the listing, a run with no recorded call, and simulation runs named, as a baseline or a reform, by pending, running, finished or several report runs, by a report run with no reform run, or by none; 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, and the deployment step by reading the workflow.

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. Checked on 2026-11-09: the secret exists in both Modal environments (names only), and its URL logs in as policyengine_v2_modal_worker. Not verified until the first deployment after merging: modal deploy accepts its required key for this app.
  • The Modal client in the container authenticates as the workspace the app is deployed in; no other variable is read.
  • MODAL_TOKEN_ID, MODAL_TOKEN_SECRET: read by the new deployment step. Provider: GitHub Actions repository secrets (both exist; the beta and prod environments don't override them). Wiring: the beta and prod jobs of simulation-deploy.yml and of stage12-deploy.yml call the reusable workflow with secrets: inherit, and the step's env: reads secrets.MODAL_TOKEN_ID and secrets.MODAL_TOKEN_SECRET, as the executor's deploy step does.

What this waits for before merging

  1. Done: Record report runs and simulation runs from the v2 Modal coordinator and workers #992 merged on 2026-11-09, and this pull request's base is now main. Everything else Record report runs and simulation runs from the v2 Modal coordinator and workers #992 waited for was done the same day: 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 in both v2 databases, and the Modal secret policyengine-modal-worker-database in the staging and main Modal environments. Without the secret, the deployment's executor job would fail.
  2. An approving review.

After the first deployment, check that modal app list shows the app in each environment and that its first scheduled run returned a summary (checked, failed, running, unknown, left, failed_for_age, failed_without_call) without errors in modal app logs policyengine-simulation-run-sweep.

🤖 Generated with Claude Code

anth-volk and others added 17 commits November 6, 2026 21:59
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 names, 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 finished report runs naming 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>
The simulation service now records the Modal call it starts on a run while
the run is still pending, and a run whose call was never started stays
pending. The store therefore lists pending runs as well as running ones,
with their status: a running run counts from its first claim and a pending
one from its creation, so the 24-hour limit also covers pending runs.

A pending report run now keeps the simulation runs it combines out of the
listing, as a running one does: its coordinator, queued in Modal, will
start their calls. Failing a listed run requires its listed status as well
as its listed call, so a run that was claimed, or that the simulation
service recorded a call on, after the listing is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pending run with no Modal call recorded ten minutes after its creation
is failed with `run_not_started`, without asking Modal: the simulation
service stopped between inserting the run and starting its call, so
nothing will run it. A pending run with a recorded call is judged by its
call, exactly as a running one: Modal may still be queueing it, or it may
have ended without claiming the run. A running run with no call recorded
is still left to the 24-hour limit, which now counts a never-claimed run
from its creation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `deploy_executor` job of the reusable deployment workflow now runs
`modal deploy` on `src/modal/run_sweep_app.py` after the versioned
executor, with only the Modal tokens in the step's environment, as the
executor's own deploy step has them. The sweep reads its database URL at
run time from the Modal secret `policyengine-modal-worker-database`, so
this step fails until an operator has created that secret in the Modal
environment; the secret must exist before this merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's module docstring and `run_not_started` message gave one cause
for a pending run with no call; it also follows a call the simulation
service started but failed to record, whose claim is then refused. The
scheduled function and the store protocol described their old scope.

The sweep tests' stand-in store now tells the maximum-age listing by the
exact cutoff the sweep asks for, instead of by a fixed hour. New cases:
a pending run is failed for age like a running one, and a dead
coordinator's never-started simulation runs are listed once its report
run has failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report run names its baseline and reform simulation runs in its own
columns, so the sweep's docstrings now say a pending or running report
run names the simulation runs it keeps out, not that it combines them.
The store tests also check that a report run keeps its reform run, not
only its baseline, out of the sweep until it finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's store tests now also check that a simulation run a pending or
running report run names only as its reform is not failed, and that a
report run with no reform run keeps only its baseline out of the sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'t see

- A running simulation run that no report run names, as one the
  simulation service starts on its own would be, is listed and failed by
  the sweep. The rebuilt history lost that case when the member rows it
  deleted went away.
- `fail_stuck_run` says a report run naming the simulation run counts once
  its insert has committed before the update starts: PostgreSQL makes
  neither statement wait for the other.
- The tests' extra simulation run copies only the columns that say what it
  is, and a second report run that is failed gets its completion time,
  as the table's check requires.

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

The test of a simulation run no report run names now fails the run the
sweep listed, so it also checks the listing's status and call. The
persistence package's README and the run tables' module docstring name
the stuck-run sweep among the Modal worker role's readers and writers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the v2plan/b3-4-stuck-run-sweep branch from 5cf46f9 to c07edcc Compare November 6, 2026 18:18
anth-volk and others added 8 commits November 9, 2026 23:54
Brings #992's merge of sim's main (e9a00b5) into this stacked branch. No
conflicts. Both sides changed simulation-deploy.reusable.yml: this branch's
"Deploy the stuck-run sweep" step stays in deploy_executor, after the
versioned executor's deploy, with the same Modal token and environment, and
main's changes to the release jobs leave that job's steps as they were. The
rules document renders unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scheduled function returns every field of the sweep's summary, and
bc2bb43 added failed_without_call to it, but this test still expected the
summary without it, so it failed before and after merging #992's new head.
It now sets the count and expects it back, as for the other counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck (failing)

The sweep's app is unversioned and its schedule starts when it deploys, so
deploying it in deploy_executor, before the release's checks, left a failed
release's sweep running, and a Stage 12-only deploy skipped it. The test
requires its only deploy step in publish_stage12_v2_default, after the reader
check and before the step that puts the default back. Fails while the step is
in deploy_executor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's deploy step moves from deploy_executor, where it ran before the
release's checks and only in main deployments, to publish_stage12_v2_default:
after the integration and authenticated tests and the Cloud Run promotion, in
both main and Stage 12-only deployments, after the reader check of the moved
default. A failed sweep deploy fails that job, whose last step then writes
the previous default back. Its environment is unchanged (the Modal tokens),
and the job's `uv sync --frozen` is enough to import the sweep app. The
changelog fragment says where it deploys. The user chose this placement on
2026-11-10, replacing decision 5's deploy_executor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding: a manual dispatch from another branch runs
publish_stage12_v2_default in staging (its promote_entrypoint is false), and
the move step leaves the default alone when the branch's version is not
newer, but the sweep step deployed the branch's sweep anyway. The test now
requires the step's condition `github.ref == 'refs/heads/main'`, and its
step helper skips jobs without steps. Fails while the step has no condition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's deploy step runs only when github.ref is main, so a dispatch
from another branch, which may be behind main, no longer replaces staging's
sweep; with no status function, the condition still requires every earlier
step to pass. Review findings: the step comment and the changelog no longer
say the sweep's deploy is what fails without the Modal secret (the v2
workers' deploy needs it first), the app's docstring says where it deploys,
and the executor README's Stage 12 default section describes the sweep's
deploy and that a failed one puts the default back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding, text only: the app's docstring said the sweep deploys after
the Stage 12 v2 default moves, but it deploys after the job's move step,
which moves the default only when the release is newer, and its reader
check, whether or not the default changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #992's merge of main at 9ad6733 (#1030's Stage 12 v2 manifest
changes) into this stacked branch. No conflicts; publish_stage12_v2_default
keeps the sweep's deploy step after the reader check, and the rules document
renders unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from v2plan/b3-3-workers-write-runs to main November 9, 2026 21:02
main's one new commit is #992's merge, whose changes this branch already
holds, so the tree is unchanged. The push starts this pull request's full CI
now that its base is main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review November 10, 2026 13:27
@anth-volk
anth-volk merged commit ffff7c8 into main Nov 10, 2026
30 checks passed
@anth-volk
anth-volk deleted the v2plan/b3-4-stuck-run-sweep branch November 10, 2026 13:35
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.

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

1 participant