Most of the time, claiming a seat is boring. A client asks the issuing service for a seat, the service checks that the pool is not full, records the claim, and answers yes. The interesting case is the one that happens at exactly 9:00 a.m. when a shift starts: two hundred machines wake up and ask for a seat in the same few milliseconds, and the pool has one seat left. Who gets it?
If you build the seat claim the obvious way — read the current count, check it is below the limit, write the incremented count — then under that burst, both of the machines racing for the last seat read "24 of 25 used," both decide there is room, and both write "25." You have sold 26 seats on a 25-seat license. The customer is now out of compliance through no fault of their own, and you only find out when the 26th user calls support. This is the last seat problem, and it is not a Keyright-specific bug — it is the classic lost-update race, applied to a license pool.
This article is about the mechanism that makes it impossible: optimistic concurrency. The seat claim is not a read followed by a write. It is a single conditional write — a compare-and-swap on a version token — that either wins cleanly or is rejected cleanly, with no in-between state where two clients both think they won.
The race, drawn
Picture two clients, A and B, both holding a valid license key, both reaching the issuing service at the same instant with one seat free.
Client A
Issuing service
Client B
used=24 v=57
read: 24/25, v=57
read: 24/25, v=57
compute 25
compute 25
write 25 if v==57
accepted → v=58
200 OK, seat granted
write 25 if v==57
v is 58 → reject
409 conflict → retry
Both clients read the same state: 24 seats used out of 25, carried together with a version token — here, v=57. That token is the whole trick. It is an opaque value the issuing service attaches to the current state of the seat pool, and it changes every time the pool changes. When a client sends its claim back, it includes the version it read, and the service accepts the write only if the version is still 57. Client A's write lands first, the count becomes 25, and the version advances to 58. Client B's write arrives a moment later carrying v=57 — but the pool is already at v=58, so the condition fails and the write is rejected before it can touch the count. B never got a seat; the count never went above 25.
What the client sees
The important design decision is that B's rejection is not the same thing as the pool being full. It is a concurrency conflict — a signal that B lost a race and should look again, because the state it decided on is stale. Keyright surfaces that as a distinct ConcurrencyConflictException (HTTP 409 with a conflict problem-type), separate from SeatsExhaustedException, and the SDK retries it for you.
var client = KeyrightClient.Initialize(new KeyrightOptions { Product = "acme-studio", PublicKeyBase64 = config["Keyright:PublicKey"], // verify-only, not a secret ServiceUrl = config["Keyright:ServiceUrl"], // your self-hosted instance }); try { // AcquireSeatAsync retries a concurrency conflict internally, // re-reading the version each time, with bounded jittered backoff. SeatLease lease = await client.AcquireSeatAsync(userKey); EnableLicensedFeatures(lease); } catch (SeatsExhaustedException) { // Definitive: the pool really is full after the race settled. ShowAllSeatsInUse(); } Note what you do not write: there is no lock, no transaction, no retry loop for the conflict case. The compare-and-swap lives server-side and the retry is inside AcquireSeatAsync. The only failure you handle yourself is the genuine one — the pool being full — which you surface to the user and fail closed on.
If you are driving the HTTP API directly rather than through the SDK, the conflict is visible on the wire, and the contract is simple: retry on 409 conflict, surface 409 seats-exhausted to the user.
POST /v1/seats/acquire HTTP/1.1 Host: license.internal.acme.example Content-Type: application/json { "product": "acme-studio", "licenseKey": "ACME-...-9F2C", "machine": "b14c…" } HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://keyright/problems/concurrency-conflict", "title": "Seat state changed during the claim", "status": 409, "retryable": true } The retryable hint is the one field your own retry policy should branch on. A concurrency-conflict is retryable; a seats-exhausted is not. Treating them the same way is the usual mistake — either you retry a full pool uselessly and hammer the service, or you give up on a conflict and spuriously tell a legitimate user that the license is full when a seat was free all along.
Why optimistic, not pessimistic
The obvious alternative is a pessimistic lock: take an exclusive lock on the seat pool, do the read-check-write inside it, release. That is correct, but it serialises every activation for a product through a single gate. The 9:00 a.m. burst that motivated this whole design would then queue two hundred activations one behind the next, and the last artist to start would wait for all the others. You would have traded a correctness bug for a latency bug.
Optimistic concurrency inverts the bet. It assumes collisions are rare — which, across a pool of seats, they are — and lets every claim proceed in parallel with no lock at all. The only cost is paid by the claims that actually collide on the same version, and that cost is a cheap re-read and retry. Under the burst, the vast majority of claims touch different seat counts (because the winners keep advancing the version) and sail through on the first try; only the handful contending for the exact same final seat ever retry. You get lock-free throughput for the common case and a serialised, oversell-proof write for the contended one.
The guarantee is worth stating plainly, because it is the reason the whole scheme is safe: no sequence of concurrent claims can push the count past the limit. Every increment is conditional on the version it was computed from, so two increments can never both apply to the same starting count. One wins and advances the version; the other is rejected and must start over from the new count — at which point it will correctly see the pool is full and get a SeatsExhaustedException.
Takeaways
- The seat claim is a compare-and-swap on a version token, not a read-then-write — so two clients racing for the last seat can never both win.
- A
ConcurrencyConflictException(409 conflict,retryable: true) means "re-read and try again," and the SDK handles it for you;SeatsExhaustedExceptionmeans "genuinely full," and you fail closed. - Branch your own retry policy on the
retryablehint — never retry a full pool, always retry a conflict. - Optimistic concurrency keeps activations parallel and only pays a cost when claims actually collide, so a shift-change burst does not serialise behind one lock.
Correct seat counting is not about counting carefully. It is about making the increment atomic, so there is no window in which two clients can both believe the last seat is theirs. Keyright does that with a version token and a conditional write, and leaves your client with nothing to do but tell a full pool from a lost race.
![Real Time Operating Systems — Ch.12: RTOS Case Studies [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9f8b1u4hxrwc1uq4gfe2.png)