Repository navigation
Conversation
anth-volk
added this pull request to stack #850
October 19, 2026 22:12
anth-volk
force-pushed
the
apiv2/1a-job-store
branch
from
October 20, 2026 01:13
9fe2a17 to
3beeb5b
Compare
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>
anth-volk
force-pushed
the
apiv2/1a-job-store
branch
from
October 20, 2026 13:18
3beeb5b to
09f2254
Compare
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 #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.jobshas 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 persistedobservability_idand any error.simulation.job_resultsholds the validated result, its schema version and SHA-256 for each succeeded job that is not a report (#849 addsreport_results).Conditional writes, no leases (
JobStorein the persistence library; rulesim.persistence.conditional-job-writes):INSERT ... ON CONFLICT DO NOTHINGon a unique index overinput_sha256that excludes failed jobs. Identical requests share one job; a failed job never blocks a new attempt.UPDATE ... SET status = 'dispatching' WHERE status = 'pending'returns a row spawns the Modal call.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 untilpg_stat_activityshows 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.submitrecords 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 withFunctionCall.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.sim.persistence.internal-routes-google-oidc).GET /simulation-jobs/{job_reference}(permissionsimulation:read) reads a job by UUID or Modalfc-call ID from the database only, returning 202 withRetry-Afterwhile unfinished and the stored result once succeeded (rulesim.persistence.status-reads-never-advance-work).Not included. No job kind is registered (
REGISTERED_JOB_KINDSis 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_QUEUEandRECONCILIATION_INVOKER_SERVICE_ACCOUNT(GitHub environment variables),RECONCILIATION_TARGET_URL(the stable service URL, which the deploy job already reads from Cloud Run before deploying) andSIMULATION_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.
sim-entry-beta-runtime,sim-entry-prod-runtime)roles/cloudtasks.enqueueron the queue androles/iam.serviceAccountUseron the invoker (to attach its OIDC token to tasks). Grant the invokerroles/run.invokeron the service.POST <stable service URL>/internal/simulation-jobs/sweepevery few minutes with an OIDC token of the invoker service account and audience equal to the stable service URL (the value ofsteps.previous.outputs.stable_urlin the deploy job).--allow-unauthenticated; the routes authenticate the token themselves).RECONCILIATION_QUEUE(projects/<project>/locations/<location>/queues/<queue>) andRECONCILIATION_INVOKER_SERVICE_ACCOUNTvariables in thebetaandprodGitHub 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
RECONCILIATION_QUEUEconfig.py,CloudTasksCollectionQueue)RECONCILIATION_QUEUE(beta,prod)env:RECONCILIATION_QUEUE_VALUE→ validated by the deploy script → Cloud Run environment fileRECONCILIATION_INVOKER_SERVICE_ACCOUNTGoogleOidcVerifier)beta,prod)env:RECONCILIATION_INVOKER_SERVICE_ACCOUNT_VALUE→ deploy script → Cloud Run environment fileRECONCILIATION_TARGET_URLstatus.url, read bycloud-run-simulation-entry-stable-state.shsteps.previous.outputs.stable_url→ stepenv:ENTRYPOINT_STABLE_URL→ deploy script → Cloud Run environment fileSIMULATION_MODAL_ENVIRONMENTModalCallDispatcher)modal_environmentenv:MODAL_ENVIRONMENT→ deploy script → Cloud Run environment fileGoogleAccessTokens(google.auth.default)MODAL_TOKEN_ID,MODAL_TOKEN_SECRET(existing)DEPLOY_STAGE12_V2istrue, which both deploy workflows setChecks run
libs/policyengine-simulation-persistence: against a local PostgreSQL 17 withSIMULATION_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).0002: a job written assimulation_runtimeread back by UUID and byfc-call ID, the internal routes refused requests without a valid Google token, and/readyreported0002.httpx.MockTransport, patched Modal handles, locally signed RS256 tokens).Deviations from the plan
RECONCILIATION_TARGET_URLandSIMULATION_MODAL_ENVIRONMENTare 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.SimulationJobService.Notes for review
rules/need approval from a core developer other than the author.🤖 Generated with Claude Code