Skip to content

Add the simulation job store and background result collection through Cloud Tasks - #848

Closed
anth-volk wants to merge 2 commits into
apiv2/1a-persistence-foundationfrom
apiv2/1a-job-store
Closed

anth-volk wants to merge 2 commits into
apiv2/1a-persistence-foundationfrom
apiv2/1a-job-store

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #845

What this changes

Step 1 of the API v2 migration, item 1A-4 (result collection option B: Cloud Tasks, no Postgres leases). Second of three stacked PRs: #847, then #848 (this one), then #849. It needs #847.

Tables (revision 0002). simulation.jobs has one row per calculation: the submitter, input_sha256 (a SHA-256 of the job kind, the policyengine.py bundle version and the request as canonical JSON), the bundle version, the Modal application, function and call ID, the status, created_at/updated_at/dispatched_at/last_checked_at/completed_at, the number of collection checks, the persisted observability_id and any error. simulation.job_results holds the validated result, its schema version and SHA-256 for each succeeded job that is not a report (#849 adds report_results).

Conditional writes, no leases (JobStore in the persistence library; rule sim.persistence.conditional-job-writes):

  • Submission is INSERT ... ON CONFLICT DO NOTHING on a unique index over input_sha256 that excludes failed jobs. Identical requests share one job; a failed job never blocks a new attempt.
  • Only the instance whose UPDATE ... SET status = 'dispatching' WHERE status = 'pending' returns a row spawns the Modal call.
  • A result is written in the same transaction as UPDATE ... SET status = 'succeeded' WHERE status = 'running', and a failure with ... WHERE status = 'running'. When two collectors race, PostgreSQL makes the second wait for the first's row lock, re-check the condition after the first commits, and update nothing.

Tests for concurrent collectors (tests/test_job_store_concurrency.py, PostgreSQL). Five tests force the interleaving: writer A's transaction holds the row lock, and the test waits until pg_stat_activity shows writer B blocked on it before A commits or rolls back. They cover result vs result, result vs failure (both orders), rollback of the first writer, and dispatch vs dispatch. Four more race eight threads through the public methods per job: results, mixed results and failures, dispatch claims and identical submissions. Removing the status condition from the result write makes four of them fail.

Simulation Entrypoint (simulation_jobs.py, google_oidc.py):

  • SimulationJobService.submit records the job, claims dispatch, spawns the Modal call with the captured observability context, records the call ID and schedules the first collection task. A refused spawn fails the job; a refused task is left to the sweep.
  • POST /internal/simulation-jobs/{job_id}/collect, called by Cloud Tasks: checks the Modal call with FunctionCall.get(timeout=0). If it is running, it counts the check and enqueues the next task after 2, 4, 8, 16, 32 and then 60 seconds. A finished result is validated against the job kind's Pydantic model and written conditionally. A worker failure is stored with an error code and only the exception type. Transient Modal, Cloud Tasks or database failures return 503 so that Cloud Tasks retries.
  • POST /internal/simulation-jobs/sweep, called by Cloud Scheduler: enqueues a task for running jobs nobody has checked for five minutes and fails jobs stuck before their Modal call was recorded for ten minutes (dispatch_abandoned). It returns 503 if any task was refused, so the failed run shows in Cloud Scheduler.
  • Both internal routes accept only a Google-signed OIDC token whose audience is the stable service URL and whose email is the invoker service account (rule sim.persistence.internal-routes-google-oidc).
  • GET /simulation-jobs/{job_reference} (permission simulation:read) reads a job by UUID or Modal fc- call ID from the database only, returning 202 with Retry-After while unfinished and the stored result once succeeded (rule sim.persistence.status-reads-never-advance-work).
  • None of these routes is in the OpenAPI document; the route table stays frozen.

Not included. No job kind is registered (REGISTERED_JOB_KINDS is empty) and no route submits jobs; Step 2 adds both. Nothing in production uses the new tables or routes. The old-gateway pass-through routes and the Stage 12 comparison are unchanged.

Deploy. The deploy step now requires and passes RECONCILIATION_QUEUE and RECONCILIATION_INVOKER_SERVICE_ACCOUNT (GitHub environment variables), RECONCILIATION_TARGET_URL (the stable service URL, which the deploy job already reads from Cloud Run before deploying) and SIMULATION_MODAL_ENVIRONMENT.

Before merging: operator steps (1A-3) in both staging and production

None of these has been performed. The deploy fails without step 5, and collection cannot work without the others.

  1. Create the Cloud Tasks queue in the Simulation Entrypoint project, with a retry policy for 503 responses.
  2. Create the invoker service account. Grant the Simulation Entrypoint runtime service accounts (sim-entry-beta-runtime, sim-entry-prod-runtime) roles/cloudtasks.enqueuer on the queue and roles/iam.serviceAccountUser on the invoker (to attach its OIDC token to tasks). Grant the invoker roles/run.invoker on the service.
  3. Create the Cloud Scheduler job that sends POST <stable service URL>/internal/simulation-jobs/sweep every few minutes with an OIDC token of the invoker service account and audience equal to the stable service URL (the value of steps.previous.outputs.stable_url in the deploy job).
  4. Confirm the Cloud Run ingress setting accepts requests from Cloud Tasks and Cloud Scheduler on the stable URL (it is deployed with --allow-unauthenticated; the routes authenticate the token themselves).
  5. Set the RECONCILIATION_QUEUE (projects/<project>/locations/<location>/queues/<queue>) and RECONCILIATION_INVOKER_SERVICE_ACCOUNT variables in the beta and prod GitHub environments.

After deployment, the plan's staging load check of Cloud Tasks and the conditional writes is still to be done before acceptance.

Environment variables

Name Read by Provider How it reaches the reader Status
RECONCILIATION_QUEUE Simulation Entrypoint (config.py, CloudTasksCollectionQueue) GitHub environment variable RECONCILIATION_QUEUE (beta, prod) step env: RECONCILIATION_QUEUE_VALUE → validated by the deploy script → Cloud Run environment file Wiring checked; the variable and queue do not exist yet: unverified
RECONCILIATION_INVOKER_SERVICE_ACCOUNT Simulation Entrypoint (task OIDC tokens and GoogleOidcVerifier) GitHub environment variable (beta, prod) step env: RECONCILIATION_INVOKER_SERVICE_ACCOUNT_VALUE → deploy script → Cloud Run environment file Wiring checked; the account and IAM do not exist yet: unverified
RECONCILIATION_TARGET_URL Simulation Entrypoint (task URL and token audience) Cloud Run: the service's status.url, read by cloud-run-simulation-entry-stable-state.sh steps.previous.outputs.stable_url → step env: ENTRYPOINT_STABLE_URL → deploy script → Cloud Run environment file Verified in the repository; no operator configuration
SIMULATION_MODAL_ENVIRONMENT Simulation Entrypoint (ModalCallDispatcher) Reusable workflow input modal_environment step env: MODAL_ENVIRONMENT → deploy script → Cloud Run environment file Verified in the repository
Google credentials for Cloud Tasks GoogleAccessTokens (google.auth.default) The Cloud Run runtime service account Cloud Run metadata server Enqueue permission is step 2: unverified
MODAL_TOKEN_ID, MODAL_TOKEN_SECRET (existing) Modal client in the collection route Secret Manager Mounted by the deploy script only when DEPLOY_STAGE12_V2 is true, which both deploy workflows set Existing wiring; unchanged

Checks run

  • libs/policyengine-simulation-persistence: against a local PostgreSQL 17 with SIMULATION_REQUIRE_DATABASE_TESTS=1, 46 passed.
  • projects/policyengine-simulation-entry: uv run pytest tests/, 654 passed.
  • projects/policyengine-simulation-executor: uv run pytest tests/, 1292 passed and 22 skipped.
  • ruff check ., ruff format --check, the rules guard, the quality guards (with shellcheck).
  • Ran the built entrypoint image with the job store configured against a local database migrated to 0002: a job written as simulation_runtime read back by UUID and by fc- call ID, the internal routes refused requests without a valid Google token, and /ready reported 0002.
  • Not run: anything that needs Modal, Cloud Tasks, Cloud Scheduler or Google's token-signing keys; those adapters are tested with fakes (httpx.MockTransport, patched Modal handles, locally signed RS256 tokens).

Deviations from the plan

  • RECONCILIATION_TARGET_URL and SIMULATION_MODAL_ENVIRONMENT are new runtime variables not listed in the plan's configuration table. Both come from values the deploy job already has, so they add no operator configuration.
  • One invoker service account serves both Cloud Tasks and the Cloud Scheduler job.
  • There is no submission route yet, because no job kind exists until Step 2; submission is implemented and tested in SimulationJobService.

Notes for review

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #850 October 19, 2026 22:12
@anth-volk anth-volk changed the title Add the simulation job store and background result collection Add the simulation job store and background result collection through Cloud Tasks Oct 19, 2026
anth-volk and others added 2 commits October 20, 2026 17:11
Add simulation.jobs and simulation.job_results (revision 0002) and the
JobStore that owns them. One jobs row records each calculation: submitter,
an input hash covering the job kind, policyengine.py bundle version and
request, the Modal application, function and call ID, status, timings,
collection checks and any error.

Every transition is a conditional update on the expected status, and no
lease is taken: submission inserts with ON CONFLICT DO NOTHING on a unique
index over the input hash that excludes failed jobs; only the instance that
moves a job from pending to dispatching spawns its Modal call; a result or
failure is written only while the job is running, so a second collector
changes nothing. PostgreSQL tests force the interleaving (the second writer
blocks on the first's row lock, then finds nothing to change) and race
eight threads per job.

In the Simulation Entrypoint, SimulationJobService submits jobs, collects
results from Cloud Tasks requests (checking the Modal call without waiting,
validating the result against the job kind's model, and re-enqueuing with
exponential backoff while it runs), and sweeps from Cloud Scheduler
requests (re-enqueuing running jobs not checked for five minutes and
failing dispatches stalled for ten). The internal collection and sweep
routes accept only Google OIDC tokens of the invoker service account. A
status route reads a job by UUID or Modal fc- call ID from the database
only. No job kind is registered yet, no route submits jobs, and none of the
routes is in the OpenAPI document.

The deploy step passes RECONCILIATION_QUEUE and
RECONCILIATION_INVOKER_SERVICE_ACCOUNT from GitHub environment variables,
the stable service URL as RECONCILIATION_TARGET_URL, and the Modal
environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
migrations/env.py compared the database against simulation_metadata without
importing the module that defines simulation.jobs and simulation.job_results,
so in a standalone Alembic process the metadata held no tables. "alembic
check" then reported both tables as removed, and "alembic revision
--autogenerate" would have written a revision that drops them. The CI
migration-cycle test passed only because other test modules imported the
job store during collection.

The environment now imports the job store module. A new test runs
"alembic check" in a separate process after upgrading to the head; it failed
with the removed-table differences before this change. The test is added to
rule sim.persistence.migration-cycle, whose scope now includes the
environment, and the README's revision steps say that new table modules must
be imported there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Add the simulation job store and background result collection without leases

1 participant