Skip to content

Start a run or household job again when its Modal call never started - #1063

Draft
anth-volk wants to merge 20 commits into
mainfrom
v2plan/c1-19-restart-unstarted-runs
Draft

anth-volk wants to merge 20 commits into
mainfrom
v2plan/c1-19-restart-unstarted-runs

Conversation

@anth-volk

Copy link
Copy Markdown
Contributor

Fixes #1058

What this does

When the Simulation Entrypoint creates a household job, report run or standalone simulation run but stops before starting its Modal call, nothing records a call for it. Until now the stuck-run sweep failed such a run with run_not_started at 10 minutes. With this change, a restart check starts the run's call once more, and the sweep fails it only if that restart misses too.

Where it runs. It is a second scheduled function, restart_unstarted_runs, in the stuck-run sweep's Modal app (policyengine-simulation-run-sweep). It runs every two minutes as the Modal worker role, from the sweep's Modal secret policyengine-modal-worker-database. The restart code itself is the simulation service's (policyengine_simulation_entry.run_restarts, run_restart_calls, run_restart_check), on policyengine_simulation_persistence (run_restart_records, run_restart_store). The image adds both as local sources.

What one check does:

  1. Lists, oldest first and at most 20, the rows created 3 to 10 minutes ago that are still pending with no modal_call_id and whose updated_at still equals created_at:

    • household jobs;
    • report runs outside a budget window whose baseline is an economy simulation;
    • runs of economy simulations that no report run names (standalone runs).

    A report's own simulation runs are its coordinator's to start, so they are never listed.

  2. Reads back, on a thread, what the submission built the call from, each side's values read against the run's own catalog version with the submission's own reader. Then it rebuilds the call with the submission's own builders (report_run_calculation, simulation_run_calculation, prepare_household_job). It uses the manifest's worker for the .py version the row records, even when that worker is no longer the default.

    • The rebuilt payload equals the original. The context differs only in its request ID (run-restart-<uuid>).
    • A household job whose rebuilt job would record anything else is not restarted: for example, a changed default year or catalog version.
  3. Sets the row's updated_at (with clock_timestamp()), so the row is restarted at most once, even by two checks at once. Then it starts the call and records its ID with the submission's own conditional write.

  4. Counts the restart as done when the row then records the check's call. A row recording another call was claimed by that call first.

After three minutes a check marks no more rows and leaves the rest for the next check. A marked row then needs only its call's start and a few bounded statements, so the check ends within the function's five-minute timeout, and every row it marks gets its call recorded before the sweep's 15 minutes.

When a restart can't go ahead:

  • A row whose call can't be rebuilt is failed at once with run_not_started: …. That covers a worker that left the manifest, stored rows that changed, and a policy that is gone. A report run is failed together with its simulation runs that are still pending with no call, as its submission would fail it.
  • So is a row whose restarted call Modal refuses, or doesn't confirm within ten seconds.
  • A row whose rows or manifest can't be read right now is left unmarked, and the next check tries it again. So is a row whose rebuild hits an unexpected error, which is logged with its traceback (v2_run_restart_unexpected_error); the check goes on to the next row.
  • A late original call claims a row only while the row records no call or its own. Once the restart records its call, the late call's claim is refused. If the late call claims first, the restarted call's claim is refused instead.

The sweep now waits 15 minutes (DEFAULT_MINIMUM_AGE, was 10) before failing a pending run with no call. That is after the restart window, so a run whose restart started is judged by its call, and a second miss is still failed with run_not_started.

Telemetry (how often runs need a restart):

  • Each check is a v2_run_restart_check operation. The counts are set on it as restart_listed, restart_restarted, restart_failed, and so on.
  • Each row a check takes up is a v2_run_restart operation with run_kind, run_id and run_age_seconds. A deferred row is taken up again by the next check, so the runs whose call never started are the distinct run_id values.
  • Each outcome is set on that operation (restart_outcome, restart_error_type). It is also logged as a v2_run_restart_outcome event, at WARNING when the row was failed, couldn't be read or couldn't be recorded.
  • The function's return value is the same counts, which Modal's dashboard shows per run.

Smaller changes:

  • ManifestEconomyWorkers and ManifestHouseholdWorkers gain worker_for_version.
  • The entry package's __init__ loads create_app on first use. Before, importing any of its modules imported the FastAPI app, whose PyJWT and cryptography the sweep's image doesn't install.
  • The executor's dev group gains the entry and persistence packages. The deploy job's uv sync installs them for modal deploy, which adds them to the sweep image as local sources. The executor's own Docker image is built without that group, so it doesn't need them. The relock adds only those two.

What waits for other pull requests

Once each merges, main is merged into this branch and the restart covers what it adds.

Deployment

The deploy step Deploy the stuck-run sweep and the restart check in publish_stage12_v2_default deploys the app, as before. It runs only from main, after every check. Each variable the changed code or workflow reads or declares:

Variable Provider Wiring Confirmed
MODAL_TOKEN_ID, MODAL_TOKEN_SECRET GitHub Actions secrets The step's env: (unchanged), for modal deploy. Yes, unchanged
SIMULATION_V2_MODAL_ENVIRONMENT The reusable workflow's modal_environment input (staging or main, from simulation-deploy.yml) The step's env:. run_sweep_app.py bakes it into the image's environment at deploy time. The restart function reads it, then maps it to the deployment label (staging or production) that a call's context and artifact prefix name. Yes. The input is used by the same step's command.
OBSERVABILITY_*, OTEL_EXPORTER_OTLP_ENDPOINT, POLICYENGINE_OTEL_GOOGLE_AUDIENCE GitHub Actions repository variables (vars.OBSERVABILITY_*) The step's env:, the same eight entries as the v2 workers' deploy step. modal_image_environment() bakes them into the image. Yes. The variables exist at repository level.
MODAL_WORKER_DATABASE_URL Modal secret policyengine-modal-worker-database Attached to both scheduled functions, with required_keys. Yes. The sweep already requires it in staging and main.
MODAL_IDENTITY_TOKEN, MODAL_TASK_ID, MODAL_REGION Modal, at run time Read by the observability runtime. Provided by Modal

Telemetry export needs an operator step (not done). The modal-api-v1 workload identity provider (project policyengine-observability) admits only these app names:

  • policyengine-simulation-gateway;
  • names matching policyengine-simulation-py…;
  • names matching policyengine-simulation-v2-py….

So the sweep's app can't get Google credentials. Until its condition also admits policyengine-simulation-run-sweep, the restart check's records reach Modal's own logs but not Cloud Logging or the collector.

Tests

All run by node ID; CI runs the rest.

  • Persistence: the 18 tests of test_run_restart_records.py, against a PostgreSQL 17 database built by the Public API master's v2 chain (429d348ae), with its privileges reconciled. They run as the Modal worker role.

  • Entry:

    • the 26 tests (27 cases) of test_run_restarts.py;
    • the 7 tests (12 cases) of test_run_restart_check.py;
    • the 3 new tests each in test_economy_workers.py and test_household_workers.py.
  • Executor:

    • the changed and new tests of test_stage12_run_sweep_app.py;
    • test_stage12_run_sweep.py::test_by_default_unstarted_runs_wait_until_the_restart_check_has_passed;
    • test_executor_image_dependencies.py, which checks the Docker image's build holds every main dependency from a local path;
    • test_run_restart_image.py, which imports the restart check with only the sweep image's packages, and requires every module it loads to come from the standard library, a local source or a distribution the image installs.

    A dry run of the Docker image's uv sync --frozen --no-dev, in a copy holding only its build directories, also succeeds.

  • Observability: test_stages.py::test_v2_run_restart_plan_measures_each_check_and_each_restart.

Review

Five read-only review rounds, each by a reviewer following the review-code skill:

  • Round 1 found three bugs, each fixed with a failing test first:
    • the executor's Docker image no longer built, because the entry package was a main dependency outside the image's build;
    • a report run whose rows couldn't be read was failed without its simulation runs;
    • a restart whose own call claimed the row before the record was counted as another call's.
  • Round 2 found no bugs. Its quality findings were fixed:
    • the restart window is narrowed to 3–10 minutes, for a margin before the sweep;
    • an unexpected rebuild error defers only its own row;
    • the telemetry docs are corrected;
    • an image import test is added.
  • Round 3 found no bugs. Its quality findings were fixed: the image test checks every module file the import loads, and unexpected errors are logged with their traceback.
  • Round 4, on round 3's fixes, found one bug, fixed test-first: the unexpected-error log's run kind and ID didn't reach the observability runtime's handler.
  • Round 5, on round 4's fixes, found no bugs. Its findings were wording only.

🤖 Generated with Claude Code

anth-volk and others added 20 commits November 13, 2026 01:17
List the pending runs and household jobs with no Modal call recorded,
mark one restart of each, and read a report run's or standalone
simulation run's stored inputs back, as the Modal worker role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The restart check lists the household jobs, report runs and standalone
simulation runs still pending with no Modal call that nothing has
changed since their insert, marks each one before restarting it so it
is restarted at most once, and reads back the stored rows its call is
built from, against the run's own catalog version.

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

The restart rebuilds each run's call from its stored rows and starts
the same call its submission started, on the worker of the run's own
.py version; a run that can't be rebuilt, or whose restart Modal
refuses, is failed with run_not_started, and each listed run is one
telemetry scope with its outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The restart service lists the rows created 3 to 12 minutes ago that are
still pending with no call, rebuilds each call from its stored rows on
the worker of the row's own .py version, marks the row, starts the
call and records its ID. A row that can't be rebuilt, or whose restart
Modal refuses, is failed with run_not_started; one whose rows or
manifest can't be read now is left for the next check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The restart service keeps the check's loop and its writes; the call
builder, its stores' protocols and the errors that defer or fail a
restart move to run_restart_calls.

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

The restart check runs every two minutes in the sweep's Modal app on
the Modal worker's database secret, with the Modal environment and the
v2 workers' telemetry settings baked into its image; each check and
each restart is an operation named by a registered stage, with the
restart's outcome logged as an event. The sweep now waits 15 minutes
before failing a pending run with no call, after the restart window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's Modal app gains a scheduled function that runs the
simulation service's restart check as the Modal worker role, with the
Modal environment and the v2 workers' telemetry settings baked into its
image at deployment. Each check and each restarted run is an operation
named by a registered stage, and each restart's outcome is logged as an
event. The sweep now fails a pending run with no call at 15 minutes,
after the restart window; the entry package loads its web app lazily so
the restart code imports in the sweep's image.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The executor's image builds only from the directories its Dockerfile
copies; an unreadable report run is failed with its simulation runs; a
restart is counted by whose call holds the row afterwards; a check
marks no row once its time limit has passed; and a call is rebuilt off
the event loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The executor's Docker image builds again: the Simulation Entrypoint and
its persistence library move to the executor's dev group, which the
deploy job installs for modal deploy and the image does not. A report
run whose rows can't be rebuilt is failed with its simulation runs. A
restart is counted by whose call the row records afterwards, so a
restarted call that claims its run before the record counts as
restarted. A check marks no row after three minutes, rebuilds each call
on a thread so Modal's blocking reads don't hold its event loop, and a
restart reads its values with the submission's own reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restart is recorded with a minute to spare before the sweep judges
its run, an unexpected rebuild error defers only its own run, and the
restart check imports with only the sweep image's packages (this last
test passed when first run: it guards what already holds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The restart window is now 3 to 10 minutes after creation, so a row
marked at the end of a check's time limit has its call recorded minutes
before the sweep judges it; an unexpected error rebuilding one row's
call defers that row and the check goes on; and the telemetry docs say
how to count runs from restart attempts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unexpected rebuild error is logged with its traceback, the image
test also requires every module the restart check loads to come from
the standard library, a local source or an image distribution, and the
persistence window test uses the 3-to-10-minute window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unexpected error rebuilding a row's call is logged with its
traceback, the descriptions of a deferred row name that case, and the
telemetry docs count the runs that needed a restart by distinct run ID.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unexpected-error log passes its run kind and ID as
policyengine_attributes, which the observability runtime's log handler
writes; the image test checks only the modules the import adds; and the
descriptions of a deferred row and of the restart counts name every
case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wording fixes from review round 5; no behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1054's budget windows. The sweep's 15-minute age now also
times its settling of idle windows, and the migration document and the
sweep app's docstring say so.

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.

Start a run or household job again when its Modal call never started

1 participant