Skip to content

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.

Pod ACanceljobid=3Pod Bexecutorread runningread runningwrite cancelledwrite successCancelled, but overwritten with success
Without a lock, both read running, and whoever writes last decides the result

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.

Finishing a job
UPDATE job
SET 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 action

Success 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.

Pod ACanceljobid=3Pod Bexecutorconditional UPDATE1 row → doneconditional UPDATE0 rows → skipOnly A applies; B skips
With the expected state in WHERE, only the first to arrive changes the row

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.

Completion flow
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 unchanged

Compared with a distributed lock

Before, this race was handled with a distributed lock.

Distributed lock

  1. Acquire the lock.
  2. Check the state.
  3. Write.
  4. 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.

Edit on GitHub
esc

Recent posts

↑↓ move↵ openOpen search page ›