Chapter 10 · Atomicity Across Business Workflows: Counters, Reservations, Idempotency, and Event-Driven Consistency

Reservation / Inventory Patterns, Conditional Writes, Expiration, Compensation, and Oversell Prevention

Protect AtlasMart inventory with transactional reservations, explicit expiration, duplicate-safe compensation and correct TTL expectations.

Intermediate140–170 minutesReservations · expiration · compensationFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

01

Build an inventory reservation that prevents oversell inside a Firestore transaction while keeping payment outside that atomic boundary.

02

Model reservation expiration as explicit state plus an application sweeper rather than assuming TTL fires at an exact deadline.

03

Implement idempotent compensation that releases inventory exactly once and preserves evidence.

04

Explain conditional-write/precondition patterns and the difference between expiration eligibility, deletion and business availability.

Execution and safety note

Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.

Chapter 10 reproducibility baseline · reviewed 16 September 2026

AtlasMart continues the same environment used in Chapters 01–09: project ID demo-atlasmart-firestore, Standard edition / Native mode / (default) database for mandatory labs, Firestore emulator 127.0.0.1:8080, Authentication emulator 127.0.0.1:9099, Emulator UI 127.0.0.1:4000, Firebase CLI 15.30.0, Firebase JavaScript SDK 12.19.0, Firebase Admin Node SDK 14.4.0 with @google-cloud/firestore 9.1.0, and Node.js 22+. Mandatory work remains local/no-cost. Cloud Functions, Eventarc, managed TTL deletion, production IAM, billing, regional delivery latency and external payment systems are discussed accurately but are not falsely claimed to have run in the local emulator.

Evidence boundary

The local lab simulates duplicate and reordered events deterministically with ordinary Node code so the learner can prove idempotency, compensation and repair behavior without deploying cloud infrastructure. Firestore-triggered Cloud Functions and Eventarc Standard can deliver events at least once; Firestore event ordering is not guaranteed. Firestore TTL deletion is asynchronous and documents are typically removed within about 24 hours after expiration, so TTL is a retention mechanism—not an exact reservation scheduler. Any production p95/p99, event-delivery delay, TTL cleanup delay, trigger retry count or cost must be measured in the actual edition/region/billing configuration rather than inferred from emulator timing.

1. The AtlasMart problem: “held for 15 minutes” is two different requirements

When AtlasMart says an item is “held for 15 minutes,” it really means two things: the checkout must not oversell while the reservation is active, and an abandoned hold should eventually release inventory. The first requirement is a strict invariant and belongs inside a transaction. The second is a workflow/time policy and may be processed later. Treating Firestore TTL as the exact release timer confuses data retention with business scheduling.

Requirement Mechanism Guarantee
Do not reserve more than stock Transaction over item + reservation/order state Atomic Firestore invariant for the documents in the transaction
Mark when a reservation should expire expiresAt timestamp Business deadline stored durably
Release an expired hold promptly Explicit scheduled/sweeper workflow with idempotent transaction Application-controlled timing and evidence
Delete stale reservation records later Optional Firestore TTL policy Asynchronous retention cleanup; typically within ~24h, not exact scheduling
Prevent duplicate release Reservation terminal state + transaction/precondition Idempotent compensation

2. Create one reservation atomically

server-only Firestore initialization
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getFirestore, FieldValue, Timestamp } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();
reserve one item
async function createReservation({orderId,sku,qty,correlationId}) {  const itemRef=db.doc(`catalogItems/${sku}`);  const reservationRef=db.doc(`reservations/${orderId}`);  const orderRef=db.doc(`orders/${orderId}`);  return db.runTransaction(async tx=>{    const [item,reservation]=await Promise.all([tx.get(itemRef),tx.get(reservationRef)]);    if (reservation.exists) return reservation.data(); // idempotent duplicate command    if (!item.exists || item.get("stock") < qty) throw new Error("INSUFFICIENT_STOCK");    const expiresAt=Timestamp.fromMillis(Date.now()+15*60_000);    tx.update(itemRef,{stock:FieldValue.increment(-qty),updatedAt:FieldValue.serverTimestamp()});    tx.create(reservationRef,{orderId,sku,qty,state:"HELD",expiresAt,correlationId,schemaVersion:3,createdAt:FieldValue.serverTimestamp()});    tx.set(orderRef,{state:"RESERVED",sku,qty,correlationId,schemaVersion:3,updatedAt:FieldValue.serverTimestamp()},{merge:true});    return {orderId,sku,qty,state:"HELD",expiresAt,correlationId};  });}

The reservation document is durable authority. The product’s visible stock has already been decremented, so a second buyer cannot reserve the same unit if all writes obey this transaction path. Security Rules deny clients from directly changing server-owned inventory/reservation state.

3. Expire with an explicit sweeper, not TTL timing

idempotent expiration sweeper
async function releaseIfExpired(reservationId, nowMs=Date.now()) {  const reservationRef=db.doc(`reservations/${reservationId}`);  return db.runTransaction(async tx=>{    const r=await tx.get(reservationRef);    if (!r.exists) return {action:"missing"};    const data=r.data();    if (data.state!=="HELD") return {action:"noop",state:data.state};    if (data.expiresAt.toMillis()>nowMs) return {action:"not-expired"};    const itemRef=db.doc(`catalogItems/${data.sku}`);    const orderRef=db.doc(`orders/${data.orderId}`);    tx.update(itemRef,{stock:FieldValue.increment(data.qty),updatedAt:FieldValue.serverTimestamp()});    tx.update(reservationRef,{state:"RELEASED",releasedAt:FieldValue.serverTimestamp(),releaseReason:"EXPIRED"});    tx.set(orderRef,{state:"CANCELLED",cancelReason:"RESERVATION_EXPIRED",updatedAt:FieldValue.serverTimestamp()},{merge:true});    return {action:"released",qty:data.qty};  });}

Run the sweeper again and it must return noop; stock must not increase twice. That is idempotent compensation. In production, a scheduler, task queue or event system can drive the sweeper, but the state transition itself still needs duplicate-safe logic.

4. TTL is cleanup, not the clock that releases inventory

Firestore TTL makes a document eligible for asynchronous deletion based on a timestamp. Current documentation states that expired documents can remain visible until deletion and that deletion is typically within 24 hours. TTL deletions are not transactional with neighboring documents and do not necessarily occur in expiration order. Therefore, AtlasMart must release stock before (or independently of) TTL cleanup. An optional TTL policy may later remove RELEASED/COMPLETED workflow evidence according to retention policy, but only after the business/audit requirements allow it.

Failure mode

If stock is released only when TTL physically deletes the reservation, a 15-minute hold can remain unavailable for many hours. Worse, deleting the reservation erases the state needed to prove whether stock was already returned.

5. Conditional writes and stale workers

Imagine two sweepers both inspect the same expired reservation. A transaction prevents both from applying the release because only one can transition HELD → RELEASED; the retrying worker sees the new state and performs no increment. On trusted server code, update-time preconditions can also be useful for compare-and-set patterns, but when several documents must change together, a transaction keeps the state check and compensation in one atomic boundary.

server update-time precondition example
const snap=await db.doc("orders/ord-ch10-001").get();await snap.ref.update(  {note:"operator-reviewed"},  {lastUpdateTime:snap.updateTime});// A stale writer using an older updateTime fails instead of silently overwriting newer state.

6. Payment failure compensation

compensate an order once
async function compensateOrder(orderId, reason) {  const orderRef=db.doc(`orders/${orderId}`);  const reservationRef=db.doc(`reservations/${orderId}`);  return db.runTransaction(async tx=>{    const [o,r]=await Promise.all([tx.get(orderRef),tx.get(reservationRef)]);    if (!o.exists || !r.exists) return {action:"missing"};    if (["CANCELLED","COMPLETED"].includes(o.get("state"))) return {action:"noop",state:o.get("state")};    if (r.get("state")==="HELD") {      tx.update(db.doc(`catalogItems/${r.get("sku")}`),{stock:FieldValue.increment(r.get("qty"))});      tx.update(reservationRef,{state:"RELEASED",releaseReason:reason,releasedAt:FieldValue.serverTimestamp()});    }    tx.update(orderRef,{state:"CANCELLED",cancelReason:reason,updatedAt:FieldValue.serverTimestamp()});    return {action:"cancelled"};  });}

Compensation has its own invariants: never release the same hold twice, never cancel a completed order without an explicit administrative workflow, and preserve why the compensation occurred.

7. Reproducible AtlasMart lab

package.json
{  "name": "atlasmart-firestore-ch10",  "private": true,  "type": "module",  "engines": { "node": ">=22" },  "dependencies": {    "firebase-admin": "14.4.0"  },  "devDependencies": {    "firebase-tools": "15.30.0"  }}
firebase.json
{  "firestore": {    "rules": "firestore.rules",    "indexes": "firestore.indexes.json"  },  "emulators": {    "firestore": { "port": 8080 },    "auth": { "port": 9099 },    "ui": { "enabled": true, "port": 4000 }  }}
firestore.rules
rules_version = '2';service cloud.firestore {  match /databases/{database}/documents {    match /catalogItems/{productId} { allow read: if true; allow write: if false; }    match /profiles/{uid} { allow read, write: if request.auth != null && request.auth.uid == uid; }    match /orders/{orderId} {      allow read: if request.auth != null && resource.data.customerId == request.auth.uid;      allow write: if false;    }    // Workflow state, reservations, outbox/inbox, dedupe and repair evidence are server-owned.    match /workflowCommands/{id} { allow read, write: if false; }    match /reservations/{id} { allow read, write: if false; }    match /workflowEvents/{id} { allow read, write: if false; }    match /workflowOutbox/{id} { allow read, write: if false; }    match /workflowDeadLetters/{id} { allow read, write: if false; }    match /counters/{counterId}/{document=**} { allow read, write: if false; }    match /{document=**} { allow read, write: if false; }  }}
local setup
mkdir atlasmart-firestore-ch10 && cd atlasmart-firestore-ch10npm init -ynpm install firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0# Save firebase.json, firestore.rules and firestore.indexes.json from this lesson.printf '{"indexes":[],"fieldOverrides":[]}' > firestore.indexes.jsonnpx firebase-tools@15.30.0 emulators:start --project demo-atlasmart-firestore --only firestore,auth

Seed stock 1. Create one reservation with an expiresAt five seconds in the future for the lab, then call releaseIfExpired before and after a supplied deterministic nowMs. Call it twice after expiration. Assert the item returns to stock 1 exactly once, the reservation becomes RELEASED, and the order becomes CANCELLED. Do not wait for or claim managed TTL behavior locally.

deterministic time injection
const base=Date.now();await db.doc("catalogItems/P-EXP").set({sku:"P-EXP",stock:1,schemaVersion:3});const r=await createReservation({orderId:"ord-exp-001",sku:"P-EXP",qty:1,correlationId:"corr-exp-001"});const before=await releaseIfExpired("ord-exp-001", r.expiresAt.toMillis()-1);const after=await releaseIfExpired("ord-exp-001", r.expiresAt.toMillis()+1);const again=await releaseIfExpired("ord-exp-001", r.expiresAt.toMillis()+60_000);const stock=(await db.doc("catalogItems/P-EXP").get()).get("stock");console.log({before,after,again,stock});if (stock!==1 || after.action!=="released" || again.action!=="noop") process.exitCode=1;

Production judgment

Reservation correctness depends on the transaction, not on TTL, background timing or client cache. Expiration should be represented as durable state and processed by a retry-safe worker. TTL can later enforce storage retention, with its own cost and lag. If the business requires near-exact expiry timing, select an appropriate scheduler/task mechanism and still keep the release operation idempotent. Lesson 4 now introduces the delivery reality that makes this mandatory: duplicate events and retries.

Knowledge check

  1. Why can TTL not be the exact reservation timer?
  2. What prevents double stock release?
  3. Why retain a released reservation record?
  4. What does expiresAt represent?
  5. When is a precondition useful?
Review the answers

1. TTL deletion is asynchronous and typically occurs within about 24 hours; expired documents can remain queryable until deletion.

2. A transaction changes the reservation from HELD to RELEASED and increments stock only while the state is HELD.

3. It is evidence that compensation already occurred and supports auditing, deduplication and repair.

4. The business deadline/eligibility for expiration, not proof that cleanup or compensation has executed.

5. For trusted compare-and-set writes where a stale update must fail rather than overwrite newer state.

Summary and next step

Oversell prevention is transactional; expiration and compensation are workflow concerns. Durable state and idempotent release logic let AtlasMart tolerate late or duplicate scheduling. Next we make the event-delivery assumptions explicit.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.