Repository navigation
feat: move the durable job substrate to PostgreSQL (#6809) - #7245
Conversation
…w) (#6809) Stores, queue, log store, result package store and five converted test classes written against the first ADR-0081 design. Not built as a whole and not run. The migration in this commit already follows the reviewed design, so the stores do not match it yet.
|
Clean code failed on Cause: the WIP commit (stores plus converted tests) triggers 69 blocking findings across 16 files:
Most findings are in Log line that proves it: Suggested fix (when Stage A is reworked to the reviewed design):
ci-doctor: 7161f48
|
…verted tests (#6809) One write path per job, database-stamped attempt state, claim ordered by visibility time per runtime profile, dispatch repair, lost-reply claim recovery, reconciler candidate queries, post-outage heartbeat grace and heartbeat write retry. Nothing is registered yet; the Redis stores remain in use.
|
Clean code failed again on Cause:
Log lines that prove it: Suggested fix:
ci-doctor: 23a5843
|
|
Clean code failed again on Cause:
Log lines that prove it: Suggested fix: the same as before, now also covering the three new test files.
ci-doctor: 8f4b9aa
|
…ovacuum (unbuilt) (#6809) Not built and not run when committed. Changes migration 147 and its ledger hash, and makes ExecutionJobRecord and ExecutionJobSpec derive from ExtensibleJsonRecord.
|
Clean code failed again on Cause: still
Log lines that prove it: Suggested fix: the same as for
ci-doctor: 971d66f
|
…d) (#6809) TryAddPostgresExecutionJobStore and TryAddPostgresJobQueueAndLogStore over the host's system NpgsqlDataSource. Nothing calls them yet; the Stage B swap starts from here.
…econciliation out of the Redis block (unbuilt) (#6809) The serving host, the single-job execution worker and the GDAL worker compose the PostgreSQL job store, queue, log store and result-package store over the system data source. The GDAL worker opens its own small pool from ConnectionStrings:DefaultConnection and no longer connects to Redis. The job reconciler, backstop sweep and queue-depth collector run whenever the job store is registered. Tests are not converted yet and the Redis job classes are still in the tree.
…e registered (unbuilt) (#6809) Classify reports Available for a registered store and queue whatever the Redis state. operations.proposals is gated on its own Redis-backed store.
…rness through the test kit (unbuilt, tests not converted) (#6809)
|
Clean code failed again on Cause:
These findings carry over unchanged:
Log lines that prove it: Suggested fix:
ci-doctor: d2b982f
|
|
Clean code failed again on Cause:
Log lines that prove it: Suggested fix:
ci-doctor: 2afd3cf
|
|
Clean code failed again on Cause: Log line that proves it: Suggested fix: the same as for
ci-doctor: 7f9408b
|
|
Clean code failed again on Cause: still Log line that proves it: Suggested fix: the same as in the
ci-doctor: 6ea9d18
|
|
Stopped overlapping #3606 / #7227 work and preserved WIP at feat/s-front-door-durable-proposals, head Coverage to reuse beyond the job-store scope here:
Latest focused remote run passed all 3 acceptance tests (Studio publication, existing PostgreSQL Studio HTTP journey, proposal approval restart). Broader affected tests have failures around durable proposal availability/idempotent read or audit; that run has not completed. Sol fixture delta passed, but the branch is not merge-ready: database-restore continuity of PostgreSQL approval authority still needs resolution and overall review/qualification are incomplete. Please port selectively rather than merge the WIP wholesale. |
geometry.buffer, surface.slope and the three routing rows: the evidence test bodies changed only in how the host and job tables are prepared (PostgreSQL job substrate, no Redis); no assertion changed. catalogSourceContentDigest follows GeoprocessingServiceCollectionExtensions.cs, whose job store registration moved to PostgreSQL.
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 04855f6967
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A serverless deployment hosts no in-process timer, so expired jobs, logs, result packages and leases accumulated. JobStoreRetention is a scheduled-tick kind whose handler drives the same JobStoreRetentionSweeper as the hosted loop.
|
Commit pushed:
|
|
Merged trunk dadaaa3; conflicts in
|
…greSQL lease rows (#7283) * feat: multi-node hosts start without Redis; version lock on lease rows (unbuilt, not run) (#6809) Removes the startup rule that a MultiNode deployment needs a Redis connection. MultiNode still refuses local file storage. The branch-versioning lock moves from Redis to a lease row in the system database, through a small ILeaseStore over the job store's lease statements. Jobs:RequireDurableStore=true is satisfied by the system database. Tests: MultiNodeWithoutRedisStartupTests (validation and a host that starts and answers liveness), LeaseVersionLockTests. Not compiled and not run on this branch; the same changes passed in the stacked branch. * test: fixture configuration overrides for the multi-node startup test (unbuilt) (#6809)
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fdab761829
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…otects (#6809) LeaseHolder stops renewing after a failed renewal and cancels its lease-lost token, but version maintenance never observed it, so it could keep running after another node took the expired lease. IVersionLockHandle now exposes LockLostToken; RunExclusiveAsync runs the work under it and reports a lost lock as VersionLockedException, which the job runner retries and the endpoint answers with 409.
|
@codex review |
|
Codex Review: Didn't find any major issues. Nice work! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Pull Request
Issue Link
Refs #6809 (this is packages P1 and P2 for the four job-side interfaces; the rest of the Redis removal is on the follow-on branch
feat/postgres-control-plane-state)Stage B and C verification (head
6e362c99eeor later)All runs through the build-slot shim, Release, PostgreSQL through Testcontainers. One command at a time.
dotnet build src/Honua.Server/Honua.Server.csproj /p:TreatWarningsAsErrors=true, same forsrc/Honua.Worker.GdalBuild succeeded. 0 Warning(s) 0 Error(s)Postgres*store classes,JobReconciliationServiceTests,JobExecutionServiceTests,ExecutionJobReconcilerTests,ExecutionWorkerProfileTests,DurableJobSubstrateRegistrationTests,ControlPlaneTriggerTests,ControlPlaneLambdaEventTargetsTests,GpDeploymentHandoffTests,CustomCodeArtifactIngestorTests,CapabilityManifestEndpointTests,GeoprocessingOutputStagingOptionsTestsFailed: 4, Passed: 367, Skipped: 2, Total: 373(two test-side assertions, fixed); rerun of the affected classesPassed: 16, Failed: 0Failed: 0, Passed: 58, Total: 58GPServerDurableRuntimeTests,GPServerDefaultServiceTests,RoutingAsyncWebToolTestsFailed: 2, Passed: 81, Total: 83; the two failures wereRoutingAsyncWebToolTestshosts that failed to initialize (the fixture's dispose hid the cause); rerun of those two theoriesPassed: 6, Failed: 0. Cause of the first-run failures not identified.OgcProcessesDurableRuntimeTests,OgcProcessesCiteEchoFixtureTestsFailed: 0, Passed: 23, Total: 23GdalWorkerJobSubstrateRegistrationTests,GdalWorkerLicensingTests,OgcProcessesExecutionProofTestsFailed: 1, Passed: 10, Total: 11. The failure,Execute_RasterSurfaceProcess_CompletesAndReturnsAnalyticalSlope, is the build host failing to pullghcr.io/osgeo/gdal:ubuntu-full-3.13.1; the job was claimed, failed ingdaldem, and retried on the PostgreSQL store. Not verified here.WorkersHadTimeToHeartbeatSinceLastStoreInterruptionforced trueRedisDurabilityInformationalTests(Core)Passed: 15PostgresDataSourceFactoryTestsPassed: 52Honua.Architecture.SourceTestsPassed: 117Honua.Architecture.Tests, filterDatabaseMigrationSafetyPassed: 45Honua.Architecture.Tests, allFailed: 2, Passed: 295, Total: 297; both failures are the GP operation matrix digests in the open itemsdotnet format --verify-no-changesonHonua.Jobs,Honua.Worker.Gdal,Honua.TestKitHow the no-Redis tests guarantee it:
NoRedisJobHost(one per protocol test project) builds the host withWithProductionJobSubstrate()andUseSetting("ConnectionStrings:redis", ""), registers nothing job-related itself, and asserts after start that theredisconnection string and the Aspire Redis key are empty, that noIConnectionMultiplexeris registered, thatIExecutionJobStoreisPostgresExecutionJobStore,IJobQueueisPostgresJobQueue, and a hostedJobExecutionServiceexists. The import journey isImportEndpointDurableRunnerWithoutRedisTests.UploadImport_ForcedBackgroundOnHostWithoutRedis_SucceedsOnDurableRunnerAndRowsAreReadable. The three per-node fallbacks are exercised inGPServerDurableRuntimeTests.NodeLocalCoordination.cs.GP operation matrix re-audit (
eng/gp-operation-matrix.v1.json)The matrix has no regeneration script: each evidence receipt pins the SHA-256 of the test method body (for a forwarding test, the expression plus the helper it calls), and
audit.catalogSourceContentDigestpins the catalog sources. The digests were recomputed with the test's own algorithm; the recomputation reproduces the 137 unchanged recorded digests and the pre-change digests of the five changed ones.geometry.bufferGeometryBufferJobExecutorIntegrationTests.BufferProcess_CompletesAndReturnsBufferedGeometryClearJobSubstrateAsync; the host is built withWithProductionJobSubstrate()geometry.bufferOgcProcessesExecutionProofTests.Execute_FirstSliceVectorProcess_CompletesAndReturnsBufferGeometry,...Execute_FirstSliceProcessWithValueOutputSelection_CompletesAndReturnsInlineBufferfixture.InitializeAsync()becameStartAsync(fixture)in the sharedProveBufferhelpersurface.slopeOgcProcessesExecutionProofTests.Execute_RasterSurfaceProcess_CompletesAndReturnsAnalyticalSlopefixture.InitializeAsync()becameStartAsync(fixture)routing.closest-facilities,routing.od-cost-matrix,routing.location-allocationRoutingAsyncWebToolTests.SubmitJob_StockFeatureSet_CompletesWithNamedResultsAndInputIdentityfixture.InitializeAsync()becameNoRedisJobHost.StartAsync(fixture)No assertion changed in any of them.
RoutingPgRoutingQueuedJobTests(the pgRouting evidence for the routing rows) is opt-in and its three evidence test bodies are unchanged.audit.catalogSourceContentDigestchanged becauseGeoprocessingServiceCollectionExtensions.csnow registers the PostgreSQL job store and result-package store; the executor registrations in that file are untouched.Checks that cannot pass while this is a draft
Review GateandPublish SHA-bound review attestationfail withpull request is draft(.github/workflows/review-gate.yml). After the draft flag is removed they also need an attesting reviewer's review of the head commit and no unresolved reviewer thread. Marking the PR ready is the founder's call.Open items
scripts/ci/pre-pr-check.sh(its dry run selects FULL mode, a solution-wide build and the whole suite, becauseHonua.Architecture.Testschanged); the fullHonua.Server.Testssuite;LayerPublishingIntegrationTests(remote publish),RoutingPgRoutingQueuedJobTests(opt-in),NativeWorkerPublicApiTestsandProductionWorkerContainerHandoffTests(need the worker image); the retention sweeper hosted service in a running host; the longer heartbeat and TOAST measurement.ControlPlaneLambdaEventTargets.EnsureDurableControlPlane,ControlPlaneEventHandler).WebAppFixturehave no job store unless they callWithProductionJobSubstrate(). A test that set a Redis connection string and relied on the job store that came with it now has none.Jobs:RequireDurableStore=truestill refuses to start without an attested Redis on the serving host (DurableJobSubstrateStartupGate); left for feat: PostgreSQL shared state and Redis removal (#6809) #7250.Summary
Moves the durable job system (execution jobs, the job queue, job logs, geoprocessing result packages) from Redis to PostgreSQL. Each PostgreSQL store replaces its Redis store outright; there is no selection setting and no dual-store period.
Landing. This PR stays a draft. It enters rc.2 only through the release session's batch process and on the founder's go. The branch is never half-swapped: Stage A adds code without wiring it in, Stage B is one coherent swap.
Stage status
docs/internal/contributor/adr/0081-postgres-job-substrate.md), reproduced belowWhat Stage A changes at run time. Nothing is registered, so the Redis stores are still the ones in use. Three consumers gained optional constructor arguments that default to today's behaviour (
IJobStoreClock,IReconcilableJobSource):JobReconciliationService,ExecutionJobReconcilerand its background service,AssignedExecutionJobQueue. Two behaviours are new even on Redis: a heartbeat write that fails is retried after 2 s instead of at the next interval, and the reconciliation sweep does not expire heartbeats for one heartbeat timeout after a sweep that failed or stalled.ExecutionJobRecordandExecutionJobSpecnow derive fromExtensibleJsonRecord, so unknown JSON members survive a rewrite.Verification (PostgreSQL 18 through Testcontainers, image
postgis/postgis:18-3.6)Command for every test line:
dotnet test tests/dotnet/Honua.Server.Tests/Honua.Server.Tests.csproj --configuration Release --filter <the Postgres* classes in Features/Infrastructure/ControlPlane>, through the build-slot shim. All results below are for commit8f4b9aad59. The head commit971d66ffba(outage grace on stalled sweeps, unknown record fields, TOAST autovacuum thresholds) has not been built or run: the build slots were held by other lanes.Total tests: 63, Passed: 63Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2Total tests: 3, Passed: 3Failed! - Failed: 1, Passed: 67, Skipped: 2, Total: 70(skipped: the two evidence tests)Honua.Architecture.Tests, filterDatabaseMigrationSafety)Passed! - Failed: 0, Passed: 45The one failure is
JobReconciliation_AfterAnOutageLongerThanTheHeartbeatTimeout_DoesNotExpireALiveWorkersJob, which passed in the first run. The sweep blocked through the outage inside the store's connect retries instead of failing, so the grace period never started. The fix is in971d66ffbaand is unverified. The check that this test fails with the grace period removed has not been done.Two-worker contention (
TryClaimAsync_EightWorkersRaceOneJob_ExactlyOneWinsWithOneAttempt,TryClaimAsync_WorkersDrainContestedQueue_EveryJobIsClaimedExactlyOnce), kill-mid-job recovery (WorkerLoss_NaturalHeartbeatLapse_...,StaleAttempt_IsFencedAcrossReclaimDuplicateDeliveryAndTerminalization) and the PostgreSQL restart during a running job (JobExecutionService_WhenPostgresRestartsMidJob_JobCompletesExactlyOnce: one execution, attempt 1) passed in every run.Heartbeat load (
PostgresJobStoreEvidenceTests,HONUA_JOB_STORE_EVIDENCE=1): 200 running jobs for 3 minutes, heartbeat every 30 s, with the 5 s candidate query and the 30 s sweep running, before the TOAST thresholds were added.Every heartbeat rewrites the out-of-line record, so TOAST grew about 0.4 MB per minute. Three minutes does not show whether it levels off; a longer run with the TOAST autovacuum thresholds is still to do. If it does not level off, heartbeats move to a statement that does not carry the record.
EXPLAIN (ANALYZE, BUFFERS) on 200,000 seeded rows, 11,400 queued
Converted tests: what was kept and what changed
Every behavioural assertion of the six Redis-bound test classes is kept in a
Postgres*class intests/dotnet/Honua.Server.Tests/Features/Infrastructure/ControlPlane/, with these changes:local; the Redis tests usedintegration-testorintegration, which the queue's backend constraint now rejects.TrySetAsyncnow age it with SQL, because a compare-and-set stamps database time. Tests that aged it throughSetAsyncorTryCreateAsyncare unchanged. One assertion changed for the same reason: "heartbeat equals the value the test wrote" became "heartbeat advanced and is close to database time".RedisJobQueueTests(which asserted Redis calls) became integration tests of the same behaviours: delayed and other-kind jobs do not block a ready job, the runtime-profile fence (six cases), a locked candidate is skipped, a job that became terminal is not claimed.test_signalstable.Open items in Stage A
971d66ffbais unbuilt and unrun (see above).Honua.Architecture.SourceTests,dotnet format --verify-no-changes,scripts/ci/pre-pr-check.sh, and the existing reconciler and worker unit tests against the consumer changes.Release note text
Where the implementation differs from the design review as written
Queuedjobs that have a cancellation request (it cancels them) or an earlier attempt (it resets their progress), so those stay in. Only first-attempt local jobs waiting in the queue are excluded.Queuedrows. Not changed separately: the rows in question no longer reach the reconciler.Queuedrepair. Every local kind is enqueued after 60 s rather than failed or opted in per kind.next_retry_atandqueue_visible_atboth remain, written from one expression; different readers compare them.Honua.Jobsthat the store implements, not default methods onIExecutionJobStore: NSubstitute returns a zero date and an empty list for an unconfigured default interface method, which would have silently disabled expiry in about eight existing test files.Follow-ups, not in this PR
PostgresOperateFixtureExecutionStore(a fixture-only second job store with its own tables) will never be selected once the real store is always registered.ADR-0081: PostgreSQL is the durable job substrate
Status
Proposed (2026-11-09). Supersedes the Redis-as-system-of-record statements in
ADR-0025 ("Redis and Durability") and
ADR-0031 ("Redis remains the coordination layer for
job state") for execution jobs, the job queue, job logs and geoprocessing result packages.
Workflow operations, proposals, operation instances and operation secrets stay on Redis until their
own change. Decision record: honua-server#6809 (founder decision and amendment, 2026-11-09).
Context
Execution jobs, their queue, their logs and geoprocessing result packages were Redis strings, sorted
sets, lists and Lua scripts. A deployment without Redis could not run geoprocessing, OGC API
Processes or background imports, and losing Redis lost every job. PostgreSQL is already required,
already holds the catalog, and already runs a
FOR UPDATE SKIP LOCKEDoutbox (migration 024).The four interfaces do not change shape:
IExecutionJobStore,IJobQueue(withIQueueClaimReconciler),IExecutionLogStore,IGeoprocessingResultPackageStore. The guarantees donot change: at-least-once execution with fencing by version compare-and-set, lease-owned updates, the
per-attempt job token and
CommittedEffectReferences.Decision
One table holds a job and its queue position. The queue is a query over that table. Each PostgreSQL
store replaces its Redis store outright; there is no selection setting and no dual-store period.
The stores are
PostgresExecutionJobStore,PostgresJobQueue,PostgresExecutionLogStore(
src/Honua.Jobs, raw Npgsql on anNpgsqlDataSource) andPostgresGeoprocessingResultPackageStore(
src/Honua.Geoprocessing). They live beside the consumers becauseHonua.Worker.GdalreferencesHonua.Jobsand must not referenceHonua.Server(ADR-0038), and the module policy forbids aHonua.PostgrestoHonua.Jobsreference in either direction.SQL below is written with the default system schema
honua; the migration uses$HonuaSchema$andthe stores quote the configured
Database:Schema. The migration only creates tables and indexes.1. Tables
recordisJSON, notJSONB: the stores never query inside it, andJSONBrejects the\^@escape that a caller-supplied job parameter can carry.
lookup_keysholds onedimension:VALUEentry per filterable value of the record:backend,queue,requested-by,correlation,trace,definition(definition id and geoprocessing planid),
resource(one per resource reference),environment,server,release,change-set,alert. The value is trimmed and upper-cased, which reproduces the Redis store's trimmed,case-insensitive match. These are the dimensions the Redis store indexed.
Autovacuum starts at 2 % dead rows (plus 200) instead of the default 20 %: the claim index is a small
partial index over a table that churns, and dead entries in it lengthen every claim. The TOAST table
gets the same thresholds because every write of a job replaces its out-of-line record.
2. Record of truth and projections
version,status,attempt_count,claimed_by,claimed_at,last_heartbeat_at,next_retry_at,completed_at,percent_complete,current_phase,updated_atExecutionJobRecordfields.recordand overlays these columns.recordholds the writer's copy of them, which a reader never sees.queue_priority,queue_visible_atExecutionJobRecord.expires_atrecordkind,runtime_profile,backend,priority,created_at,tenant_id,cancellation_requested,heartbeat_timeout,max_duration,lookup_keysrecord, used only inWHEREandORDER BY.record.There is one write path: every create and every compare-and-set is a single statement that writes
record, the attempt state and the projections together. The claim (3.2) and the queue statements(3.6) are the only writes that do not carry the record; they change only columns whose record of
truth is the column.
3. Statements
Every statement runs as its own transaction on a pooled connection.
now()is therefore thestatement start time on the database server.
3.1 Create if absent (
TryCreateAsync)One affected row means created.
SetAsync(used only by the operate fixture seeder) is the sameINSERTwithON CONFLICT (operation_id) DO UPDATEover every column except the queue columns.These two are the only statements that store the caller's timestamps as given.
3.2 Claim (
IJobQueue.TryClaimAsync)A worker's accepted runtime profiles are
{managed}when it declares none, asRuntimeProfiles.CanClaimdefines. A worker with one profile, which is every worker today, runs thestatement with
runtime_profile = @runtime_profile, so the scan is the ordered range ofidx_execution_jobs_claimablefor that profile and a native worker never reads managed rows. Aworker with several profiles runs it with
runtime_profile = ANY (@runtime_profiles).queue_priorityis 0 Critical, 1 High, 2 Normal, 3 Low. Within a band the order is the time the jobbecame visible, which is the Redis score: a requeued or retrying job goes to the back of its band, so
a job that keeps being handed back (partition-lease contention requeues with a 5 s delay and does
not consume an attempt) cannot hold the head of the band.
Lost reply. If the claim fails after it was sent, the queue remembers that for its worker id
(unique per process). The next
TryClaimAsyncfirst runsand returns that job if there is one. A worker runs one job at a time and moves a job out of the
Claimedphase before it claims again, so such a row can only be the claim whose reply was lost.3.3 Version compare-and-set (
TrySetAsync), heartbeat, progress, cancel requestwhere
<stamp>(column, @value)isCASE WHEN @value IS NULL THEN NULL WHEN column IS NOT DISTINCT FROM @value THEN column ELSE now() END:a timestamp the writer left as it read it is kept, one the writer changed becomes the database time,
and null clears it. No row affected is a version conflict and returns
false.@retry_delayis the writer'sNextRetryAt - UpdatedAt(zero whenNextRetryAtis null): thewriter states a delay on its own clock and the database turns it into a time on the database clock.
next_retry_atandqueue_visible_atcome from that one expression.next_retry_atis what therecord reports and what the provider reconciler waits on for a job no local worker runs;
queue_visible_atis the only time a claim compares.The heartbeat, progress, cancel-request and terminal writes are all this statement. A job that was
claimed from the local queue and is written back to
Queuedbecomes claimable again in thisstatement; there is no window in which it is
Queuedand absent from the queue.3.4 Lease-owned update (
TrySetIfLeaseOwnedAsync)Statement 3.3 with the lease row locked for the statement:
A concurrent takeover of the lease waits for this statement, and this statement re-checks owner and
expiry if the takeover committed first.
3.5 Leases (
TryAcquireLeaseAsync,RenewLeaseAsync,ReleaseLeaseAsync)As with the Redis lock, the holder cannot re-acquire a lease it still holds.
3.6 Enqueue, requeue with delay, remove, depth
A repeated enqueue keeps the original position, as the Redis
ZADD NXdid. Only a job whose backendis
localcan be in the queue; the table constraint enforces it. A job that a remote backend runs(AWS Batch, Azure Batch, Kubernetes Jobs, the local process pool) sits
Queuedwithqueue_visible_atNULLuntil that backend submits it.3.7 Dispatch repair (
IQueueClaimReconciler.ReconcileStaleClaimsAsync)Every producer creates the job and then enqueues it in a second call: geoprocessing, imports, tile
cache, layer publish, share export, provisioner build, topology rebuild, multidimensional scan, and
the console's manual retry. A process that dies between the two leaves a local job
Queuedand notin the queue.
JobReconciliationServicerepairs first-attempt geoprocessing jobs immediately(
NeedsLocalDispatchRepair); every other case is repaired by this statement, which the servicealready calls each sweep with a 60 s threshold:
Enqueueing is the step the producer was about to take and the job has not started, so the repair
enqueues for every local kind instead of failing the job. A cancellation-requested job is left for
the cancellation path.
3.8 Reconciler candidates
ListActiveAsyncreturned every non-terminal job to two loops. With a deep queue that is the wholebacklog every 5 s on every serving node. The two loops now read candidates instead:
The second excludes exactly the first-attempt local jobs waiting in the queue, for which the
reconciler only reads progress. It keeps local
Queuedjobs with a cancellation request or anearlier attempt, because the reconciler cancels the former and resets the progress of the latter.
The reconciler's per-job lease is unchanged; the rows that made it expensive no longer reach it.
Both services keep their own checks on each record; the queries only narrow what they look at. They
are
IReconcilableJobSource, implemented by the job store; a store without it (the in-memorydoubles) is read through
ListActiveAsyncas before.3.9 List and query with cursor
The store builds the text with only the predicates a query uses. The cursor is the opaque base64 of
<created_at in Unix microseconds>|<operation_id>of the last row returned.3.10 Log append with dedupe and trim, tail, page, retention
Append is one batch (one round trip, one implicit transaction):
sequenceis the absolute zero-based position, which is what the existing offset cursor encodes.@max_entriesis 2,000 and an entry is bounded to 4 KiB before it is sent, as today. An event idalready recorded for the job appends nothing, including after its entry was trimmed. The limit of
8,192 event identities per job is the named
CHECKconstraint.Tail (
@start = -1) and page (@startfrom the cursor) are one statement, so counts and entriescome from one snapshot:
Every append moves the stream's expiry to
Jobs:Store:LogRetentionfrom now, andSetRetentionAsync, which the execution and reconciliation services call when a job reaches aterminal state, sets it from that moment. The sweep (3.12) does not delete the log of a job that is
not terminal. A log therefore never expires while its job runs.
3.11 Result packages
3.12 Retention sweep
JobStoreRetentionSweepService(a hosted service on the serving host, modelled onAuditRetentionPruneService) runs everyJobs:Store:RetentionSweepIntervaland repeats eachstatement while it deletes a full batch:
and the first shape for
geoprocessing_result_packagesandleases. A log stream cascades to up to2,000 entries and 8,192 event identities, so
@log_stream_batch_sizeis 10: one statement deletes atmost about 100,000 small rows. The sweep runs on every serving node without leader election: the
deletes are bounded, idempotent and skip rows another node has locked.
Jobs:Store:JobRetentionJobs:Store:LogRetentionGeoprocessing:Executors:ResultRetentionJobs:Store:RetentionSweepIntervalJobs:Store:RetentionSweepBatchSizeA job that is not in a terminal state is never swept. Redis silently dropped a job left
Queuedforseven days; here it stays visible and, if local, is repaired by 3.7.
3.13 Database time
IJobStoreClock.GetUtcNowAsync, implemented by the job store, returnsSELECT now(). A consumergiven no clock uses the node clock, which is right for a store that stamps with the node clock (the
in-memory doubles).
4. Invariants and the tests that prove them
Tests are in
tests/dotnet/Honua.Server.Tests/Features/Infrastructure/ControlPlane/and run againstPostgreSQL through Testcontainers.
SKIP LOCKEDand re-checksstatus = 'Queued'on the locked row.PostgresJobClaimContentionTests.TryClaimAsync_EightWorkersRaceOneJob_ExactlyOneWinsWithOneAttempt,...TryClaimAsync_WorkersDrainContestedQueue_EveryJobIsClaimedExactlyOncePostgresStaleAttemptFencingIntegrationTests.StaleAttempt_IsFencedAcrossReclaimDuplicateDeliveryAndTerminalization,PostgresJobClaimContentionTests.WorkerLoss_NaturalHeartbeatLapse_ReExecutesOnceAndSuppressesTheStaleWorkersTerminal,PostgresExecutionSubstrateIntegrationTests.ExecutionJobStore_FinalizesOnlyWhileLeaseIsOwnedQueuedjob is always eventually claimable.Queuedrestores the queue position in the same statement; a localQueuedjob that is not in the queue is enqueued by the next sweep after 60 s (3.7); a job is never swept before it is terminal.PostgresJobQueueTests.TrySetAsync_ClaimedJobReturnedToQueued_IsClaimableWithoutRequeue,PostgresJobQueueTests.ReconcileStaleClaimsAsync_LocalJobCreatedButNeverEnqueued_IsEnqueued(one case per job kind),...ReconcileStaleClaimsAsync_ManualRetryThatNeverRequeued_IsEnqueued,...ReconcileStaleClaimsAsync_RemoteBackendJob_IsLeftForItsBackend,PostgresExecutionSubstrateIntegrationTests.JobReconciliation_PersistedLocalJobWithoutDelivery_ReplacementWorkerExecutesOnceCancellationRequestedAt; read on each heartbeat and by the reconciler.PostgresExecutionSubstrateIntegrationTests.JobReconciliationService_CancelsDurablyRequestedStaleJob,PostgresStaleAttemptFencingIntegrationTests.CancellationDuringBackoff_PreventsTheNextAutomaticAttemptqueue_visible_at = now() + delay; claim filters and orders on it.PostgresJobQueueTests.RequeueAsync_WithDelay_HidesTheJobUntilTheDelayElapses,...TryClaimAsync_RequeuedJob_GoesBehindJobsAlreadyWaitingInItsBand,PostgresStaleAttemptFencingIntegrationTests.AutomaticTransientFailure_UsesConfiguredBackoffAndStopsAtExactMaxAttemptslast_heartbeat_atstops advancing; the 30 s sweep requeues or fails the job once it is 90 s old.PostgresJobClaimContentionTests.WorkerLoss_NaturalHeartbeatLapse_ReExecutesOnceAndSuppressesTheStaleWorkersTerminal,PostgresStaleAttemptFencingIntegrationTests.StaleAttempt_IsFencedAcrossReclaimDuplicateDeliveryAndTerminalization(kills the worker process)(operation_id, event_id), checked in the append transaction.PostgresExecutionLogStoreTests.BoundedLog_TrimsWithAbsoluteCursorsAndDeduplicatesExternalEvents,...AppendOnceAsync_ConcurrentDeliveriesOfOneEvent_AppendExactlyOneEntryPostgresJobStoreClockTests.ClaimLeaseAndHeartbeatExpiry_UnderSkewedNodeClocks_FollowDatabaseTimePostgresJobExecutionResilienceTests.JobExecutionService_WhenPostgresRestartsMidJob_JobCompletesExactlyOncePostgresJobExecutionResilienceTests.JobReconciliation_AfterAnOutageLongerThanTheHeartbeatTimeout_DoesNotExpireALiveWorkersJobPostgresJobQueueTests.TryClaimAsync_AfterAClaimWhoseReplyWasLost_ReturnsThatJobWithOneAttempt5. Time
Every lease expiry, visibility delay, heartbeat timeout, execution timeout and retention comparison
uses the database clock.
now():claimed_at,last_heartbeat_at,next_retry_at,completed_at,updated_at,queue_visible_at,expires_atand every leaseexpires_at. A compare-and-setwrite replaces a timestamp the writer changed with the database time (3.3); it never stores the
writer's value. A duration between two of them cannot be negative.
now(): claim visibility, lease acquire and renew, the lease-owned update,the reconciler candidate queries, result-package expiry on read, the retention sweep.
IJobStoreClock: the places that today compare a stored timestamp againstthe node clock.
JobReconciliationService: heartbeat and timeout expiry againstDateTimeOffset.UtcNowExecutionJobReconciler:NextRetryAt > DateTimeOffset.UtcNowfor provider-run retriesNextRetryAtis database-stamped.AssignedExecutionJobQueue: heartbeat expiry of another worker's claim against itsTimeProviderRedisJobQueue: visibility and stale-claim age againstDateTimeOffset.UtcNownow().expires_atand SQLnow().CreatedAtandCancellationRequestedAtremain the writer's values: they are shown and sorted,never compared with a clock to make a decision. A worker's own execution timeout and drain deadline
are durations on its monotonic clock.
TryCreateAsyncandSetAsyncstore timestamps as given,which is how a job is first written and how tests age a record.
6. Write volume and reconciler load
Every write of a job rewrites its row including
record. A heartbeat is one read and one suchwrite: 200 running jobs at the 30 s interval is about 14 statements and 7 row versions per second.
A record over about 2 kB is stored out of line, so each of those writes also replaces its TOAST
chunks; autovacuum reclaims them. The measured row versions and heap, TOAST and index growth for that
load are in the pull request. If they show a problem, the fix is a heartbeat statement that does not
carry the record, which the column layout already allows.
Reconciler load per process, with R running or provisioning jobs and any number queued:
JobReconciliationService, every 30 sExecutionJobReconcilerBackgroundService, every 5 sWith 200 running jobs that last loop is about 120 to 160 statements per second per serving node,
which is what it costs on Redis today. A backlog of 10,000 queued local jobs adds nothing to any
loop.
7. Connections
The stores hold no connection between statements and use no session state of their own. They are
free of connection pinning behind RDS Proxy only when
Database:SessionSettings:Modeisrole-defaults: in the default mode the shared data source runsSETin its connectioninitializer, for every user of the pool.
A single-job execution worker (the server image started with
HONUA_OPERATION_ID) defaults to apool of at most 8 and at least 0 connections when
Limits:Connectionssets neither; the GDAL workeropens its own data source for the job store with at most 4 and at least 0
(
Jobs:Store:WorkerMaxPoolSize), fromConnectionStrings:DefaultConnection, which it now requiresin place of
ConnectionStrings:redis. The serving host is unchanged (200 and 20).8. Failure behaviour
A statement that fails with a transient error (connection refused or reset,
57P01,57P03, class08,53300) is retried with backoff 0.2 s, 0.5 s, 1 s, 2 s when repeating it is safe:release,
AppendOnceAsync, result-package writes.TryCreateAsyncand the compare-and-set writes. If an attempt failed afterthe statement was sent and the retry affects no row, the store reads the row; version
expected + 1holding exactly the record that was sent means the first attempt committed, and thecall returns
true.AppendAsyncwithout an event id. The error reaches thecaller.
Beyond that budget the existing loops carry on: the claim loop retries at its 5 s poll and the
reconciler at its next sweep. A heartbeat pump whose write failed retries every 2 s instead of
waiting for its next interval, so heartbeats resume as soon as the database is back. Nothing is held
in memory that the database does not have, so an outage loses no job.
After an outage every heartbeat in the table is older than the outage, and a sweep that compared
them with the current database time would expire healthy workers' jobs, failing those with
MaxAttempts = 1.JobReconciliationServicetherefore does not expire a heartbeat until it hasseen the store without interruption for that job's heartbeat timeout. A sweep that fails, or whose
reads take longer than one second (the store retries through a short outage and then answers),
starts the period again. Execution-timeout expiry is not delayed.
sees the database.
Capability manifest:
jobs.runneris available when the job store and queue are registered, whichis whenever the system database is configured. It no longer depends on Redis or on the
caching.redisentitlement.DurableJobSubstrateCausebecomesAvailable,JobStoreNotConfigured,JobQueueNotRegistered. The manifest does not flap with reachability. Readiness: the job store hasno dependency of its own; the existing database readiness check reports
DatabaseUnavailableandtakes the node out of rotation. The Redis durability attestation leaves the job path;
Jobs:RequireDurableStoreis accepted and always satisfied.operations.proposalskeeps its Redisrequirement and is gated on its own store instead of on the job-store classification.
9. Upgrade and rollouts
Jobs in progress are not carried over from earlier pre-release builds; start from a clean
deployment.
A rollout runs two releases side by side behind the load balancer (canary and primary), and the
workers of both claim from the same table. A job record written by release N must therefore be
readable and executable by release N-1 for the length of a rollout:
ExecutionJobRecordandExecutionJobSpec, never removed or renamedwithin one release step;
ExecutionJobRecordandExecutionJobSpeccarry unknown JSON members through deserialization and back(
ExtensibleJsonRecord), so a heartbeat from an N-1 worker does not strip what release N wrote.Test:
PostgresExecutionSubstrateIntegrationTests.TrySetAsync_RecordWithFieldsThisReleaseDoesNotKnow_KeepsThem.Unknown members of the other nested records (audit, policies) are not preserved;
execution_jobsis nullable or has a default, so an N-1 writer that does not setit still inserts.
The existing rollout step "drain in-flight jobs before the switch" still applies: the rule above
covers the overlap, not a job that needs behaviour only release N has.
What still uses Redis after this change, each owned by the follow-on branch
feat/postgres-control-plane-state: proposals, operation instances, operation secrets and workflowoperations; the data-protection key ring; sessions and the distributed cache (portal tokens, scoped
job tokens, replay protection, idempotency); leader election; catalog change notification across
nodes; the import, export, tile and version job managers; the orchestration run, definition and
watermark stores; and, on the job path, the cross-node submission lease
(
ExecutionAdmissionCoordinator), the per-principal submission rate limit(
ExecutionAdmissionEvaluator) and job progress (UniversalProgressStore), each of which fallsback to per-node state when Redis is absent.
10. Limits
Expected envelope: hundreds of concurrently running jobs, a backlog of the order of 100,000 queued or
retained rows, tens of submissions per second, one database.
What would be added if that is exceeded, each behind the same interfaces: a heartbeat statement that
does not carry the record; claiming several jobs per statement (
LIMIT n); an optional wake-upsignal so an idle worker does not wait for its 5 s poll.
Not reachable without a redesign: time partitioning of
execution_jobs(a partitioned table cannotkeep
operation_idunique across partitions, which create-if-absent relies on), and active-activeoperation across regions (every guarantee here rests on one writable database and its clock).
Known follow-ups: a point-in-time restore of the database restores job state with it, so a job that
had finished can come back as
QueuedorRunningand run again; there is no switch yet to holdclaiming while an operator reviews them. Job hosts (GDAL and single-job workers) use the system
database role; a role limited to the job tables is not implemented.
Deliberately not done:
LISTEN/NOTIFY, session advisory locks,SET, any second queue structure,any cross-statement transaction.
11. Fit for pipelines
The 2026.2 pipelines work (#7151) moves the orchestration stores to PostgreSQL on this substrate.
How a run uses jobs today: each step, and each fan-out item, becomes one geoprocessing job through
the normal submit path (
WorkflowOrchestrationEngine.SubmitStepAsync,GeoprocessingJobService.SubmitJobCoreAsync); the job id is derived from{runId}:{stepId}:{attempt}(JobOwnershipSecurity); the run learns a step finished by reading thatjob every 5 s (
WorkflowOrchestrationEngine.ObserveStepAsync); there is no terminal callback and noparent job.
Spec.Parameters["orchestration.runId"]and its step inorchestration.stepId. The run id is projected now as arun:<id>entry inlookup_keys, so alljobs of a run are one GIN lookup. No query field uses it yet; adding
RunIdtoExecutionJobQueryis one more required lookup key. A step is a filter within a run's jobs(at most 1,000 today,
WorkflowForEachSpec.HardIterationLimit).first-attempt local jobs in the queue, which neither candidate query (3.8) returns. They cost what
any job costs once claimed. The run's own polling is the load to change: one read per unfinished
step every 5 s is 1,000 statements per second for 5,000 steps, where one
run:<id>listing woulddo.
exactly-once across nodes:
TryCreateAsyncisON CONFLICT DO NOTHINGon the primary key andreturns
trueto one caller. A job can be created already delayed through the existing interface:TryCreateAsync, thenRequeueAsync(id, priority, visibleAfter), which puts a local job into thequeue with a future visibility whether or not it was in the queue. If the second call is lost, the
dispatch repair (3.7) enqueues the job without the delay after 60 s; a caller that cannot accept
that would need the delay on the record at creation, which is one optional field.
leasesis(lease_key, owner_id, expires_at)with acquire, renew and releasecompared against the database clock. Run leases (
IOperationStoreon the run store) and scheduleand trigger claims (set-if-absent with an expiry) fit it unchanged, under their own key prefixes.
expires_atis per row and every write takes a retention (ttl), so retention candiffer by kind or follow the owning run without a schema change. Run history itself belongs to the
run store, not to job rows.
run each statement on its own pooled connection, so today they are two transactions. Writing them
together later is additive: the compare-and-set becomes the first branch of a
WITH ... INSERT INTO <outbox>statement, or the store gains an overload that takes the caller's transaction.several jobs at once under one worker id: recovery of a claim whose reply was lost (3.2) relies on
a worker holding at most one job in the
Claimedphase.Alternatives considered
reconciler has to repair the cases where they did not; that is the Redis design's weakness
re-created. One row cannot disagree with itself.
survive a frozen Lambda (Lambda + RDS Proxy: session advisory locks and bound-source SET pin pooled connections; frozen sandboxes exhaust the proxy (POST /ogc/styles Lambda timeout, run 40961202761) #6760, bug: import and publish session advisory locks still pin RDS Proxy sessions on Lambda #6765); transaction-level locks would hold a transaction open for
the length of a job.
Redis as a requirement for running a job at all, which is what the decision removes.
atomic move between the broker and the job record.
recorduntouched (a second statement, a record hash andheap-only-update tuning). Rejected in design review: two write paths can drift, and the volume one
path writes is small. It stays available as the first step in section 10.
Consequences
runtime.
Redis.
Changes Made
147_CreateJobSubstrate.sqland its ledger hash.PostgresExecutionJobStore,PostgresJobQueue,PostgresExecutionLogStore(src/Honua.Jobs),PostgresGeoprocessingResultPackageStore(src/Honua.Geoprocessing); not registered.IJobStoreClockandIReconcilableJobSourceas optional dependencies of the reconcilers and the single-job queue; heartbeat write retry; post-outage heartbeat grace.ExecutionJobRecordandExecutionJobSpeckeep unknown JSON members.Testing
Gate Impact
Docs or Contract Impact
Release/Deploy Impact
Breaking Changes
ConnectionStrings:DefaultConnectionand no longer readsConnectionStrings:redis.Pre-PR Checklist
scripts/ci/pre-pr-check.shand all checks passedtype: description (#issue)OPENAPI_BREAKING_CHANGE_APPROVED— intentional Admin API break acknowledged for this PR