Concurrency Control Across Multiple Pods: The Single‑SQL CAS Pattern
Running a service as several pod replicas raises throughput, but it also means two pods can touch the same data at the same time. For me the trouble was the moment a job was written as done. I stopped the race with a single SQL statement, no distributed lock needed, and it turned out to be the CAS (Compare-And-Swap) pattern.
The problem: last writer wins
A pod handling a cancel request (not the leader) and the executor running the job (the leader pod) tried to write the same job row as Done at the same time.
With no lock, both pods read state=running, and both writes succeed. The result follows whoever wrote last, so cancelled flips to success or the other way around. To the user, it’s a bug where a job they clearly cancelled shows up as successful.
The fix: put the expected state in the UPDATE
The fix was simple. Add the state you expect as a condition in the UPDATE’s WHERE, so only the first writer succeeds.
UPDATE jobSET state = ?, result = ?, finished_at = ?WHERE id = 3 AND run_seq = 3 -- the generation I knew AND state = 'running' -- still in progress AND action = 'start' -- same actionSuccess is judged by RowsAffected. If it’s 1, I was the first writer, so finish as usual. If it’s 0, someone else finished first, so skip.
It compares against the expected state (Compare) and changes the row only on a match (Swap), so it’s CAS. The database applies the UPDATE atomically with a row-level lock, so the application doesn’t need a lock of its own.
Why compare run_seq too
If you only check state = 'running', you can’t tell apart a restart or rerun that brings the same state back. run_seq (the generation number) confirms the row is still the run I knew. It guards against the ABA problem, where a value looks the same but actually belongs to a different generation.
The pattern was already in the codebase
Another workflow in the same codebase was already using this pattern to save intermediate checkpoints (SaveStatusIfCurrent). The new completion path (FinishIfCurrent) reuses the same WHERE and, since it only handles completion, keeps the SET simpler.
finish(Done, Cancelled) ├─ condition: {JobID=3, ExpectRunSeq=3, ExpectAction=start} ├─ FinishIfCurrent(cond, Done, Cancelled, now, logs) │ └─ UPDATE ... WHERE run_seq=3 AND state=running AND action=start ├─ matched=true → finished normally └─ matched=false → warn "superseded by another actor", DB unchangedCompared with a distributed lock
Before, this race was handled with a distributed lock.
Distributed lock
- Acquire the lock.
- Check the state.
- Write.
- Release the lock.
Each step goes to etcd and back, and any of them can fail in between.
✓CAS conditional UPDATE
- One SQL statement gives the same guarantee, with no etcd round trip.
- Idempotent: calling it several times applies it once.
- Normal, failed, and cancelled completion go through one method.
When to use CAS, when to use a lock
Concurrency between pods often comes down to one line of WHERE, without any heavy infrastructure.