Repository navigation
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_startedat 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 secretpolicyengine-modal-worker-database. The restart code itself is the simulation service's (policyengine_simulation_entry.run_restarts,run_restart_calls,run_restart_check), onpolicyengine_simulation_persistence(run_restart_records,run_restart_store). The image adds both as local sources.What one check does:
Lists, oldest first and at most 20, the rows created 3 to 10 minutes ago that are still
pendingwith nomodal_call_idand whoseupdated_atstill equalscreated_at:A report's own simulation runs are its coordinator's to start, so they are never listed.
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.run-restart-<uuid>).Sets the row's
updated_at(withclock_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.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:
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.v2_run_restart_unexpected_error); the check goes on to the next row.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 withrun_not_started.Telemetry (how often runs need a restart):
v2_run_restart_checkoperation. The counts are set on it asrestart_listed,restart_restarted,restart_failed, and so on.v2_run_restartoperation withrun_kind,run_idandrun_age_seconds. A deferred row is taken up again by the next check, so the runs whose call never started are the distinctrun_idvalues.restart_outcome,restart_error_type). It is also logged as av2_run_restart_outcomeevent, at WARNING when the row was failed, couldn't be read or couldn't be recorded.Smaller changes:
ManifestEconomyWorkersandManifestHouseholdWorkersgainworker_for_version.__init__loadscreate_appon first use. Before, importing any of its modules imported the FastAPI app, whose PyJWT and cryptography the sweep's image doesn't install.devgroup gains the entry and persistence packages. The deploy job'suv syncinstalls them formodal 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
mainis merged in next. Report runs with abudget_window_run_idare not restarted: a year still waiting in its window is pending with no call by design. Restarting a year whose start an earlier year's coordinator lost waits until Calculate budget windows on the detailed-save path, five years at a time #1054 merges. Calculate budget windows on the detailed-save path, five years at a time #1054 also changesrun_sweep.py; this branch changes onlyDEFAULT_MINIMUM_AGEthere. Calculate budget windows on the detailed-save path, five years at a time #1054's idle-window settling uses the same minimum age, so it moves to 15 minutes too.POST /reportsandPOST /simulationsdo. That is being confirmed with that session.Once each merges,
mainis merged into this branch and the restart covers what it adds.Deployment
The deploy step
Deploy the stuck-run sweep and the restart checkinpublish_stage12_v2_defaultdeploys the app, as before. It runs only frommain, after every check. Each variable the changed code or workflow reads or declares:MODAL_TOKEN_ID,MODAL_TOKEN_SECRETenv:(unchanged), formodal deploy.SIMULATION_V2_MODAL_ENVIRONMENTmodal_environmentinput (stagingormain, fromsimulation-deploy.yml)env:.run_sweep_app.pybakes it into the image's environment at deploy time. The restart function reads it, then maps it to the deployment label (stagingorproduction) that a call's context and artifact prefix name.OBSERVABILITY_*,OTEL_EXPORTER_OTLP_ENDPOINT,POLICYENGINE_OTEL_GOOGLE_AUDIENCEvars.OBSERVABILITY_*)env:, the same eight entries as the v2 workers' deploy step.modal_image_environment()bakes them into the image.MODAL_WORKER_DATABASE_URLpolicyengine-modal-worker-databaserequired_keys.stagingandmain.MODAL_IDENTITY_TOKEN,MODAL_TASK_ID,MODAL_REGIONTelemetry export needs an operator step (not done). The
modal-api-v1workload identity provider (projectpolicyengine-observability) admits only these app names:policyengine-simulation-gateway;policyengine-simulation-py…;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:
test_run_restarts.py;test_run_restart_check.py;test_economy_workers.pyandtest_household_workers.py.Executor:
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:
🤖 Generated with Claude Code