Put the Concurrency Gate Before the Side Effect

Leader ●1 ●3 ●48
calendar_today ago • schedule4 min read
— Originally published at dev.to

Many concurrency bugs begin with code that looks entirely reasonable:

  1. Read the current row.
  2. Check that the operation is still allowed.
  3. Create something durable elsewhere.
  4. Save the original row as complete.

Run once, the workflow is correct. Run twice at almost the same time, and both callers can pass the check before either caller records completion. If step three is irreversible, the system can create duplicate work even though every individual condition was true when evaluated.

The core lesson is simple: when a state transition guards a side effect, make the database choose the winner before the side effect begins.

Why read-then-check is not ownership

An application-level check answers, “What did I observe?” It does not answer, “Do I exclusively own this transition?”

Imagine a parent record representing an open request. Accepting one option creates a downstream record and closes the parent. Two callers can follow this timeline:

  • Caller A reads the parent as open.
  • Caller B reads the same parent as open.
  • Caller A creates a downstream record.
  • Caller B creates another downstream record.
  • Both callers try to close the parent.

Optimistic concurrency on the final save may reject one update, but by then the rejected caller may already have created the duplicate side effect. Detecting a conflict after the irreversible action is too late.

Move the decision forward

A stronger shape puts a conditional write immediately before downstream creation. In generalized SQL, the intention is:

UPDATE WorkItems
SET ClaimToken = @token, ClaimedAt = @now
WHERE Id = @id
  AND Status = 'Open'
  AND ClaimToken IS NULL;

The affected-row count becomes the decision:

  • One affected row means this caller owns the transition.
  • Zero affected rows means another caller won, or the operation is no longer valid.

That losing result is not exceptional. It is a normal concurrency outcome and should stop before any downstream creation call.

This is stronger than reading again because the predicate and the update are evaluated atomically by the database. There is no application-level gap between “still open” and “now mine.”

The claim is a state, not a boolean trick

Once a claim exists, the domain has an in-progress state whether or not the schema calls it that. Treating it explicitly improves the design.

The workflow needs to answer:

  • Can a user retry while a claim is fresh?
  • Which failures can safely release the claim?
  • When is a claim considered abandoned?
  • Can another caller take over an abandoned claim?
  • What happens if the process fails after downstream creation?

A practical rule is to release ownership only when the system knows that no durable side effect exists. If downstream creation fails before producing a record, clearing the claim can make a retry safe. Once a durable result may exist, blindly clearing the claim risks creating another one.

A timeout is recovery policy, not exactly once

A bounded stale-claim timeout prevents an interrupted process from blocking the workflow forever. A new caller may take over only if the stored claim and timestamp still match what it read, so competing recovery attempts cannot both win.

That solves abandoned ownership before the side effect. It does not solve every crash window.

Suppose the downstream service successfully creates a record and the process dies before the parent records completion. After the timeout, replaying the operation could create a second record. The conditional claim narrowed the risk, but it did not create a distributed transaction.

For that boundary, choose an additional mechanism according to the cost of duplication:

  • Pass a stable idempotency key to the downstream operation.
  • Record an outbox message in the same transaction as the claim.
  • Make downstream creation upsert by the originating command identity.
  • Reconcile incomplete claims against downstream records before replay.

The honest claim is “one winner during concurrent admission,” not “exactly once under every failure.”

Test the overlap, not two sequential calls

A concurrency fix deserves a test that opens the original race window deliberately.

Hold the first caller inside downstream creation. While it is paused, submit the same command again and also submit a competing choice. Then release the first caller.

The useful assertions are concrete:

  • The downstream creation service was called once.
  • One winner completed.
  • Both competing calls received a clear losing result.
  • The parent stored one selected result.
  • Other candidates moved to their expected terminal state.

Add two recovery tests as well. First, make downstream creation fail before producing anything and prove that a retry can acquire the released claim. Second, seed a fresh claim and an expired claim, proving that the fresh claim blocks while the expired one can be reclaimed.

This test shape is more valuable than sending two tasks and hoping their timing overlaps. A controlled pause makes the race deterministic and keeps the assertion focused on the contract.

A compact design checklist

Before shipping a command that crosses a data boundary:

  1. Identify the first irreversible side effect.
  2. Put the ownership decision before it.
  3. Use one conditional database write, not a read-then-write promise.
  4. Treat zero affected rows as a normal loser.
  5. Release only when no durable effect exists.
  6. Define stale ownership and takeover semantics.
  7. Add idempotency or reconciliation for the post-side-effect crash window.
  8. Test with a deliberately paused winner and active losers.

The broader principle is not limited to any framework. Checks describe observations. Conditional writes grant ownership. When a workflow can create something you cannot casually undo, that distinction is part of correctness.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

Soft Delete Is a Model Contract, Not a Repository Habit

Ivan Rossouw - Oct 17

SEO-Friendly Web Design Checklist: Architecture Before Aesthetics

floxolab - Aug 30

Your Reader Cannot Be Stricter Than Your Schema

Ivan Rossouw - Sep 17

Why Most .NET Developers Misuse Records — and What It’s Costing Them

Yaseer Arafat - Jul 8, 2025
chevron_left
2k Points • 52 Badges
Cape Town, South Africa • ivanrossouw.iqtechsolutions.co.za
27Posts
11Comments
5Connections
Senior Software Engineer building modular .NET SaaS across web, mobile, and cloud | C# • ASP.NET Core • Blazor • .NET MAUI • EF Core

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!