DEV Community

mohamed Tayel
mohamed Tayel

Posted on

Day 15 — Outbox Leasing: Preventing Competing Workers from Processing the Same Message

Day 14 made the payment and its outbox message commit atomically, and Day 13 made them durable. But a durable message with two workers is a shared message — both can read it and process it. Day 15 gives each message one temporary owner: a lease.

Outbox leasing — one owner per message

🙏 Credit where it's due — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, Fundamentals of Distributed Systems on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.

Where we are — after Days 13 and 14

  • Day 13 — Durable Reconciliation: the pending work survives a restart — a fresh instance reloads it from durable storage. The work survives a restart.
  • Day 14 — Transactional Outbox: the payment row and the outbox row commit or roll back together in one SQLite transaction. The business state and outbox row commit together.
  • Day 15 (this document): in a real deployment there is more than one application instance, each running an outbox worker. Two workers can read the same pending row. Only one worker owns a message at a time — and ownership expires if the worker dies.

🎯 The one idea to take away: Durability preserves the work. Atomicity creates the work safely. Leasing decides who owns the work now. The message is durable — now we need exclusive, temporary ownership. Durable does not mean exclusively owned; pending does not mean safe for every worker to process.


1. Why a durable, atomic outbox can still be processed twice

Day 14 answered "is the message durable and consistent?" with a firm yes. But that is a different question from "who is allowed to process it right now?"

  • ✅ Days 13–14 solved — durability + atomicity: the message survives restart and always commits with its payment. One row, no orphans.
  • ❌ Day 15 solves — exclusive ownership: with two app instances, both workers SELECT … WHERE Status='Pending' and both process the same rows.

A single-worker deployment hides this. Scale to two instances and the naive worker loop becomes a race: Worker A reads the pending rows, Worker B reads the same pending rows, and both start processing before either marks anything done.

⚠️ For payment reconciliation the work is a status read, so a duplicate does not double-charge. But it is duplicate provider load, wasted work, competing DB updates, and misleading metrics — and for other message types (send an email, ship an order) a duplicate is a real, visible side effect.


2. The BEFORE scenario — competing workers, and why the final state hides it

session-15-competing-workers-no-claim seeds the Day 14 durable outbox (6 payments + 6 pending messages), then starts two workers, each on its own SQLite connection (separate application instances). A Barrier(2) makes the race deterministic — neither worker processes until both have loaded the full pending snapshot:

  • Load: Worker-A loads all 6 pending rows. Worker-B loads all 6 pending rows. Neither has marked anything processed yet.
  • Process: both workers call CheckPaymentStatus for every message they loaded — 12 processing attempts, 12 provider status checks for 6 unique messages.
  • Final DB: all 6 rows end Processed, 4 Paid + 2 Failed, no duplicate rows. The database looks perfect.

That tidy final state is the trap. From the real run: unique messages 6, Worker-A loaded 6, Worker-B loaded 6, processing attempts 12, status checks 12, messages processed more than once 6. The doubling is only visible because the report keeps the ledgers separate.

⚠️ Verdict from the real report: "THE OUTBOX WAS DURABLE — BUT TWO WORKERS PROCESSED THE SAME WORK ❌". Final row state alone cannot tell you whether work happened once or twice.


3. The lease columns and message states

The AFTER scenario adds two columns and a third status. A message now moves through:

Pending  ──claim──▶  Processing  ──finalize──▶  Processed
                         │
                         └── lease expires ──▶ claimable again
Enter fullscreen mode Exit fullscreen mode
New column Meaning
LockedBy The worker id that currently owns the message (NULL when not leased).
LockedUntilUtc When the lease expires. After this time another worker may reclaim the message.

Status is now Pending | Processing | Processed. A message is claimable when it is Pending, or Processing with an expired lease.


4. The atomic claim — and claim vs processing

The claim must be atomic, or the race just moves from "process" to "claim". A worker claims inside one BEGIN IMMEDIATE transaction (SQLite takes the write lock up front, serializing claimers) with a conditional update:

BEGIN IMMEDIATE;
  SELECT Id FROM OutboxMessages
   WHERE Status='Pending' OR (Status='Processing' AND LockedUntilUtc < $now)
   ORDER BY CreatedAtUtc LIMIT 1;
  UPDATE OutboxMessages
     SET Status='Processing', LockedBy=$worker, LockedUntilUtc=$leaseUntil
   WHERE Id=$id
     AND (Status='Pending' OR (Status='Processing' AND LockedUntilUtc < $now));
COMMIT;   -- claimed only if this UPDATE affected exactly one row
Enter fullscreen mode Exit fullscreen mode

The two operations are deliberately different, and the implementation keeps two separate ledgers for them:

  • Claim = acquire temporary ownership of a row. Cheap, atomic, may fail (someone else won).
  • Processing = the actual work (CheckPaymentStatus, apply outcome). Happens only for messages you successfully claimed.

💡 Claim attempts ≠ work done. In the real AFTER run there were 5 successful claims in the concurrent phase plus a handful of empty claims (nothing claimable at that instant) — but exactly 6 unique messages were processed, once each.


5. Why leases expire — a lease is not a lock

A permanent lock would be a bug waiting to happen: if the owner crashes mid-work, the message is locked forever and the work is lost. A lease is ownership with an expiry.

  • 🔒 A lock says — permanent: "Nobody else may ever take this work." If the owner dies, the work is stuck forever.
  • 🎟️ A lease says — temporary: "I own this work until a specific time. If I disappear, another worker may recover it after expiry."

⚠️ Why the lease matters. A worker may claim a message and then crash. A permanent lock would lose the work forever. An expired lease lets another worker recover it — which is exactly what the recovery sub-scenario below proves.


6. Worker crash after claim, then reclaim by another worker

The AFTER scenario includes a small deterministic recovery demo on a scenario-controlled clock (so lease expiry needs no real waiting). Real run, verbatim from the report:

  • t = 09:00:00.000Z: Worker-A claims PAY-001 → CLAIMED (LockedBy=Worker-A, lease 500 ms).
  • crash: Worker-A crashes before processing — the row stays Status=Processing, LockedBy=Worker-A.
  • before expiry: Worker-B tries to claim PAY-001BLOCKED (owner still Worker-A, lease not expired).
  • t = 09:00:00.600Z: advance the clock past the lease → Worker-B RECLAIMS PAY-001 (LockedBy=Worker-B).
  • process: Worker-B processes PAY-001 exactly once → outbox=Processed. The work was never lost.

A lock would have left PAY-001 stuck under a dead owner. The lease let a healthy worker take over — safely, and only after the previous owner's claim demonstrably expired.


7. Ownership-verified finalization

Claiming is only half the guarantee — completing must also check ownership, so a worker never finalizes a message it no longer owns (e.g. one whose lease expired and was reclaimed). Payment finalization and message completion happen in one transaction, guarded by LockedBy:

BEGIN IMMEDIATE;
  UPDATE Payments SET PaymentStatus=, TransactionId= WHERE IdempotencyKey=$key;
  UPDATE OutboxMessages
     SET Status='Processed', ProcessedAtUtc=$now, LockedUntilUtc=NULL
   WHERE Id=$id AND Status='Processing' AND LockedBy=$worker;   -- ownership check
COMMIT;   -- only if that UPDATE affected exactly one row; otherwise ROLLBACK
Enter fullscreen mode Exit fullscreen mode

💡 Ownership violations = 0 in the real run. If the guarded update affects zero rows, the worker does not own the message any more and the transaction rolls back — it never writes a result it has no right to write.


8. The real implementation — proven by running it

Day 15 reuses the Day 14 SQLite infrastructure (OutboxSqliteCore) unchanged and adds a small leasing core plus two scenarios registered in Program.cs:

  • BEFORE: Session15CompetingWorkersNoClaimScenario → command session-15-competing-workers-no-claim
  • AFTER: Session15OutboxLeasingScenario → command session-15-outbox-leasing

No new packages — the same Microsoft.Data.Sqlite. Standard .NET only: a Barrier(2) to make the BEFORE race deterministic, two SqliteConnections (WAL + busy_timeout) for two application instances, BeginTransaction(deferred:false) for BEGIN IMMEDIATE atomic claims, and a small ScenarioClock so lease expiry is deterministic without real waiting.

Real BEFORE vs AFTER metrics

Metric (real run) BEFORE (no claim) AFTER (leasing)
Payments / pending outbox seeded 6 / 6 6 / 6
Worker-A loaded / Worker-B loaded 6 / 6 (both, all rows) claimed per-message, not bulk-loaded
Processing attempts 12 6
Unique messages processed 6 6
Messages processed more than once 6 0
Provider status-check calls 12 6
Ownership violations n/a (no ownership) 0
Worker ownership split (A / B) both did all 6 3 / 3 (varies by who wins each claim)
Resolved Paid / Failed / pending 4 / 2 / 0 4 / 2 / 0
Outbox Processed / Pending / Processing 6 / 0 / 0 6 / 0 / 0
Duplicate payment / outbox rows 0 / 0 0 / 0
Payment creation calls in workers 0 0
Fake successes 0 0

The headline is the halving: 12 → 6 processing attempts and status checks for the same 6 messages. Same durable outcome, half the provider load, zero duplicate work. The A/B split is whatever the atomic claims happen to produce (3/3 in this run; 2/4 in another) — the invariant is 6 unique, once each.

Lease-expiry recovery proof (AFTER)

From the real report: Worker-A claimed PAY-001 then crashedWorker-B blocked before expiry (true) → advance clock → Worker-B reclaimed after expiry (true)reclaimed message processed once. A lock would have lost PAY-001; the lease recovered it.

Safety checks from the real AFTER report — all passing

  • each message processed exactly once → 6 unique, 0 duplicates
  • zero ownership violations → 0
  • no duplicate payment/outbox rows → max 1 per key
  • one gateway creation attempt per key → 6 / 6
  • tx ids only from provider, Paid only → 4 Paid w/ id, 0 Failed w/ id

💡 Verdicts from the real reports. BEFORE: "THE OUTBOX WAS DURABLE — BUT TWO WORKERS PROCESSED THE SAME WORK ❌". AFTER: "OUTBOX LEASING — ONE OWNER PER MESSAGE, AND OWNERSHIP EXPIRES ✅". Both exit with code 0 only when every check passes.

Run it yourself

dotnet run --project tools/Wassal.SessionTests -- session-15-competing-workers-no-claim
dotnet run --project tools/Wassal.SessionTests -- session-15-outbox-leasing
Enter fullscreen mode Exit fullscreen mode

Each run prints the schema, the worker/claim timeline, the lease-expiry demo, the per-message ownership table, the ledgers, the checks, and the verdict, then saves a timestamped report under:

Reports/Session-15/Session-15-Competing-Workers-No-Claim-RunReport-<timestamp>.txt
Reports/Session-15/Session-15-Outbox-Leasing-RunReport-<timestamp>.txt
Enter fullscreen mode Exit fullscreen mode

The SQLite file is created in a per-scenario temp folder — e.g. %LOCALAPPDATA%\Temp\wassal-day15-leasing-<random>\wassal-outbox.db — and cleaned up at the end; its path is printed in the report.


9. Final takeaway

  • A durable, atomic outbox is still a shared outbox — two workers can read and process the same pending row.
  • Final row state cannot prove single processing; you must count claim and processing attempts separately.
  • An atomic claim (BEGIN IMMEDIATE + conditional update) gives exactly one worker temporary ownership of a message.
  • Finalization must verify ownership (LockedBy = me) so a worker never completes a message it no longer owns.
  • Ownership is a lease, not a lock: it expires, so a crashed owner's work can be reclaimed instead of lost forever.
  • All Day 14 guarantees still hold: one payment per key, one outbox row per payment, no orphans, no duplicate charges, provider-only transaction ids.

The three days, side by side:

Day 13:  The work survives a restart.
Day 14:  The business state and outbox row commit together.
Day 15:  Only one worker owns a message at a time — and ownership expires if the worker dies.
Enter fullscreen mode Exit fullscreen mode

💡 Durability preserves the work. Atomicity creates it safely. Leasing decides who owns it now. Retry backoff, dead-letter handling, and a hosted worker are later lessons — Day 15's invariant is exactly one temporary owner per message.


Part of the **Fundamentals of Distributed Systems* series — building Wassal, a distributed food-delivery lab, one concept at a time.*

Top comments (0)