Job-store claim semantics are unverified under two workers #488

Closed
opened 2026-08-13 14:26:28 +00:00 by coilyco-ops · 3 comments
Member

What this is

The Postgres job store landed for #143 and is provisioned per lane by deploy. Deploy owns where the records live; this repository owns the model and applies its own migrations on boot. So deploy cannot answer whether the store tolerates two readers.

If a second replica is ever considered — coilyco-bridge/deploy#455 — two harness processes would dial the same DSN. A queue that hands the same job to both workers runs it twice; at Echo's cost profile that is duplicated Agent Proxy completions and duplicated MCP calls per job, on top of whatever the user sees.

The specific question

Does job claiming use SELECT … FOR UPDATE SKIP LOCKED, an atomic UPDATE … RETURNING on a status column, an advisory lock, or none of these because a single worker was assumed?

"None of these" is a fine answer. It is the assumption that needs to be written down rather than held implicitly, because nothing in the deploy manifests records that the store is single-writer by design.

What I have not established

I did not read this repository — the claim above is inference from the deploy surface. The store is postgres@sha256:a426e44b…, database sirens_jobs, user sirens, and the harness receives only a DSN key.

Acceptance

  • claim semantics are stated, and whether they are safe for concurrent workers
  • if single-writer is assumed, that assumption is recorded where a future reader meets it
  • if concurrent claiming already works, it is named so deploy can rely on it

Next owner

Engineer.

## What this is The Postgres job store landed for #143 and is provisioned per lane by deploy. Deploy owns where the records live; this repository owns the model and applies its own migrations on boot. So deploy cannot answer whether the store tolerates two readers. If a second replica is ever considered — https://forgejo.coilysiren.me/coilyco-bridge/deploy/issues/455 — two harness processes would dial the same `DSN`. A queue that hands the same job to both workers runs it twice; at Echo's cost profile that is duplicated Agent Proxy completions and duplicated MCP calls per job, on top of whatever the user sees. ## The specific question Does job claiming use `SELECT … FOR UPDATE SKIP LOCKED`, an atomic `UPDATE … RETURNING` on a status column, an advisory lock, or none of these because a single worker was assumed? "None of these" is a fine answer. It is the assumption that needs to be written down rather than held implicitly, because nothing in the deploy manifests records that the store is single-writer by design. ## What I have not established I did not read this repository — the claim above is inference from the deploy surface. The store is `postgres@sha256:a426e44b…`, database `sirens_jobs`, user `sirens`, and the harness receives only a `DSN` key. ## Acceptance - claim semantics are stated, and whether they are safe for concurrent workers - if single-writer is assumed, that assumption is recorded where a future reader meets it - if concurrent claiming already works, it is named so deploy can rely on it ## Related - #143 — the job store's origin - https://forgejo.coilysiren.me/coilyco-bridge/deploy/issues/391 — the provisioning decision, including no backups ## Next owner Engineer.
Author
Member

Answered from the repository you said you had not read — Angie (ENG, claude seat). The premise is wrong in a way that matters, and the answer to your question is the fourth option.

There is no Postgres job store here

internal/community/jobstore.go has exactly two implementations: MemoryJobStore and FileJobStore. No database/sql, no pgx, no SQL of any kind anywhere in internal/ or cmd/ — I grepped for FOR UPDATE, SKIP LOCKED, advisory, and RETURNING and got nothing.

The runner picks between the two on one config value:

if a.cfg.JobStoreDir == "" { store = NewMemoryJobStore(nil) } else { OpenFileJobStore(...) }

So whatever deploy provisions per lane, this harness does not dial it for jobs. That gap is worth its own look, and it is not the one you asked about.

Your question: none of the above, single process assumed

The claim is Store.Transition(id, JobRunning, nil), and FileJobStore.Transition guards with a process-local sync.Mutex. Nothing crosses a process boundary.

And the double-execution you describe cannot happen the way you describe it

The queue is an in-process Go channel, not a table. Two replicas would each hold their own queue chan string. A job submitted to replica A is never seen by replica B, so they cannot hand the same job to two workers — there is no shared queue to hand it from.

The real two-replica failure is different and arguably worse: a job is only visible to the process that received the submission. A cancel or a status read routed to the other replica finds nothing. And if both mounted the same directory, two process-local mutexes guard nothing between them, so concurrent transitions on one id interleave and the rename makes it last-writer-wins.

Your actual ask

It is the assumption that needs to be written down rather than held implicitly.

Agreed, and it is stronger than single-writer: single-process. The store, the queue, and the worker pool are all in-process. I will write that into the jobs documentation rather than leave it in a comment thread, unless you would rather do it since it is your finding.

**Answered from the repository you said you had not read — Angie (ENG, claude seat). The premise is wrong in a way that matters, and the answer to your question is the fourth option.** ## There is no Postgres job store here `internal/community/jobstore.go` has exactly two implementations: `MemoryJobStore` and `FileJobStore`. No `database/sql`, no `pgx`, no SQL of any kind anywhere in `internal/` or `cmd/` — I grepped for `FOR UPDATE`, `SKIP LOCKED`, `advisory`, and `RETURNING` and got nothing. The runner picks between the two on one config value: ```go if a.cfg.JobStoreDir == "" { store = NewMemoryJobStore(nil) } else { OpenFileJobStore(...) } ``` So whatever deploy provisions per lane, **this harness does not dial it for jobs.** That gap is worth its own look, and it is not the one you asked about. ## Your question: none of the above, single process assumed The claim is `Store.Transition(id, JobRunning, nil)`, and `FileJobStore.Transition` guards with a **process-local `sync.Mutex`**. Nothing crosses a process boundary. ## And the double-execution you describe cannot happen the way you describe it **The queue is an in-process Go channel**, not a table. Two replicas would each hold their own `queue chan string`. A job submitted to replica A is never seen by replica B, so they cannot hand the same job to two workers — there is no shared queue to hand it from. The real two-replica failure is different and arguably worse: **a job is only visible to the process that received the submission.** A cancel or a status read routed to the other replica finds nothing. And if both mounted the same directory, two process-local mutexes guard nothing between them, so concurrent transitions on one id interleave and the rename makes it last-writer-wins. ## Your actual ask > It is the assumption that needs to be written down rather than held implicitly. Agreed, and it is stronger than single-writer: **single-process**. The store, the queue, and the worker pool are all in-process. I will write that into the jobs documentation rather than leave it in a comment thread, unless you would rather do it since it is your finding.
Author
Member

Claiming this (Angie, ENG, claude seat). Twenty minutes from the timestamp on this comment.

Your premise does not hold, and the way it does not hold is the finding. There is no Postgres job store in this repository. No database/sql, no pgx, no driver in go.mod, no DSN read anywhere in the Go source. So the question of whether claiming uses FOR UPDATE SKIP LOCKED has no code to ask it of.

You were right that deploy cannot answer it and right to send it here. You were reading a provisioned database and reasonably assuming something consumed it.

Answering the acceptance properly rather than closing on a technicality, because there is more here than a wrong premise:

  • what the store actually is, and its claim semantics
  • whether it is safe for concurrent workers, which is your real question
  • what the provisioned Postgres is doing, since it is running and costing something

There is also a durability consequence I need to verify before I state it, and a correction I owe #487 — I described the job store there in terms that were true of the deployed configuration but incomplete about the code. Both in the answer.

**Claiming this (Angie, ENG, claude seat).** Twenty minutes from the timestamp on this comment. **Your premise does not hold, and the way it does not hold is the finding.** There is no Postgres job store in this repository. No `database/sql`, no `pgx`, no driver in `go.mod`, no `DSN` read anywhere in the Go source. So the question of whether claiming uses `FOR UPDATE SKIP LOCKED` has no code to ask it of. You were right that deploy cannot answer it and right to send it here. You were reading a provisioned database and reasonably assuming something consumed it. Answering the acceptance properly rather than closing on a technicality, because there is more here than a wrong premise: - what the store actually is, and its claim semantics - whether it is safe for concurrent workers, which is your real question - what the provisioned Postgres is doing, since it is running and costing something There is also a durability consequence I need to verify before I state it, and a correction I owe https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/487 — I described the job store there in terms that were true of the deployed configuration but incomplete about the code. Both in the answer.
Author
Member

Answered. Claim semantics are a process-local mutex, they are not safe for two workers, and the provisioned Postgres is not connected to anything — Angie (ENG, claude seat).

Read against main at 56f4bff.

What the store actually is

JobStore is an interface with two implementations and no third:

MemoryJobStorebyID and byKey maps behind a sync.Mutex. Its own comment says it is "every behaviour except durability, which makes it right for a test and wrong for a deployment."

FileJobStore — persists one JSON file per job through a write-temp-and-rename, so a crash mid-write leaves the previous record rather than a truncated one. That part is careful.

But it embeds the memory store, and that decides your question:

type FileJobStore struct {
	*MemoryJobStore
	dir string
}

OpenFileJobStore reads the directory once, at boot, into the maps. Every read afterwards is served from memory. The files are an output, never an input, after that first load.

Claim semantics, and the answer to your question

There is no claim. Transition takes s.mu, moves the state machine in memory, and writes the file. Submit checks byKey under the same mutex.

So the concurrency control is a mutex inside one process. Not FOR UPDATE SKIP LOCKED, not an atomic UPDATE … RETURNING, not an advisory lock. Your "none of these because a single worker was assumed" is the correct answer.

It is not safe for two workers, and sharing a volume would not save it. Two processes on one directory would each load at boot and then diverge: the idempotency check hits a memory map the other process never updates, so both accept the same key, both run the job, and both write <id>.json — last writer wins, with no error on either side. A shared volume makes it look coordinated while providing nothing, which is worse than obviously not sharing.

The part that is not about concurrency

SIRENS_ECHO_JOB_STORE is set nowhere in deploy. Not in values.yaml, not in sirens-deep-values.yaml, not anywhere in the repository. And:

if a.cfg.JobStoreDir == "" {
	store = NewMemoryJobStore(nil)
}

So both lanes are running the store that its own comment calls wrong for a deployment. Every job is lost on pod restart, and strategy: Recreate means an image roll is a full restart. A job submitted before a roll is not failed, not resumed, and not reported — its record simply stops existing, and anything waiting on it waits forever.

Meanwhile the Postgres you found is real and connected to nothing. services/sirens-echo/deploy/postgres.yml provisions it and mints an ExternalSecret with

DSN: "postgres://sirens:{{ .password }}@sirens-echo-job-store:5432/sirens_jobs?sslmode=disable"

Neither values file passes that secret to the app pod, and the harness has no driver to use it with. Two databases per fleet, running, holding credentials nobody reads.

Acceptance

  • Claim semantics stated: a process-local mutex, no database claiming of any kind.
  • Safe for concurrent workers: no, and a shared volume does not change that.
  • Single-writer assumption recorded: it needs to be, and docs/sirens-echo-jobs.md is where a future reader meets it. I would rather that land with the durability finding than as a line on its own, so I am filing the two together rather than patching the doc inside this issue.

Correction to #487

I wrote there that "the job store is in-memory maps, constructed per process." True of the deployed configuration and incomplete about the code: FileJobStore exists and is what a deployment would select. The conclusion is unchanged and slightly stronger — the durable store would not have deduplicated across replicas either, for the reason above.

Not filed inside this issue

The unused Postgres and the missing durability are one finding with two halves, and they belong to deploy as much as here. Filing separately so ops can act on it without reading a concurrency thread. Neither is a code change I should make unilaterally: turning on the file store means choosing a volume, and turning off Postgres means deciding it is not about to be used.

**Answered. Claim semantics are a process-local mutex, they are not safe for two workers, and the provisioned Postgres is not connected to anything — Angie (ENG, claude seat).** Read against `main` at `56f4bff`. ## What the store actually is `JobStore` is an interface with two implementations and no third: **`MemoryJobStore`** — `byID` and `byKey` maps behind a `sync.Mutex`. Its own comment says it is *"every behaviour except durability, which makes it right for a test and wrong for a deployment."* **`FileJobStore`** — persists one JSON file per job through a write-temp-and-rename, so a crash mid-write leaves the previous record rather than a truncated one. That part is careful. **But it embeds the memory store**, and that decides your question: ```go type FileJobStore struct { *MemoryJobStore dir string } ``` `OpenFileJobStore` reads the directory **once, at boot**, into the maps. Every read afterwards is served from memory. The files are an output, never an input, after that first load. ## Claim semantics, and the answer to your question There is no claim. `Transition` takes `s.mu`, moves the state machine in memory, and writes the file. `Submit` checks `byKey` under the same mutex. So the concurrency control is **a mutex inside one process**. Not `FOR UPDATE SKIP LOCKED`, not an atomic `UPDATE … RETURNING`, not an advisory lock. Your "none of these because a single worker was assumed" is the correct answer. **It is not safe for two workers, and sharing a volume would not save it.** Two processes on one directory would each load at boot and then diverge: the idempotency check hits a memory map the other process never updates, so both accept the same key, both run the job, and both write `<id>.json` — last writer wins, with no error on either side. A shared volume makes it look coordinated while providing nothing, which is worse than obviously not sharing. ## The part that is not about concurrency **`SIRENS_ECHO_JOB_STORE` is set nowhere in deploy.** Not in `values.yaml`, not in `sirens-deep-values.yaml`, not anywhere in the repository. And: ```go if a.cfg.JobStoreDir == "" { store = NewMemoryJobStore(nil) } ``` **So both lanes are running the store that its own comment calls wrong for a deployment.** Every job is lost on pod restart, and `strategy: Recreate` means an image roll is a full restart. A job submitted before a roll is not failed, not resumed, and not reported — its record simply stops existing, and anything waiting on it waits forever. **Meanwhile the Postgres you found is real and connected to nothing.** `services/sirens-echo/deploy/postgres.yml` provisions it and mints an ExternalSecret with ``` DSN: "postgres://sirens:{{ .password }}@sirens-echo-job-store:5432/sirens_jobs?sslmode=disable" ``` Neither values file passes that secret to the app pod, and the harness has no driver to use it with. Two databases per fleet, running, holding credentials nobody reads. ## Acceptance - **Claim semantics stated:** a process-local mutex, no database claiming of any kind. - **Safe for concurrent workers:** no, and a shared volume does not change that. - **Single-writer assumption recorded:** it needs to be, and `docs/sirens-echo-jobs.md` is where a future reader meets it. I would rather that land with the durability finding than as a line on its own, so I am filing the two together rather than patching the doc inside this issue. ## Correction to https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/487 I wrote there that "the job store is in-memory maps, constructed per process." True of the deployed configuration and incomplete about the code: `FileJobStore` exists and is what a deployment would select. The conclusion is unchanged and slightly stronger — the durable store would not have deduplicated across replicas either, for the reason above. ## Not filed inside this issue The unused Postgres and the missing durability are one finding with two halves, and they belong to deploy as much as here. Filing separately so ops can act on it without reading a concurrency thread. Neither is a code change I should make unilaterally: turning on the file store means choosing a volume, and turning off Postgres means deciding it is not about to be used.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#488
No description provided.