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

Idempotency Keys, Exactly-Once Illusions, Cloud Functions / Eventarc Retries, and Deduplication

Design duplicate-safe event handlers using event/business idempotency keys, durable dedupe evidence and retry-aware Cloud Functions/Eventarc semantics.

Intermediate140–170 minutesIdempotency · retries · dedupeFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

01

Explain why Firestore-triggered Cloud Functions and Eventarc must be treated as at-least-once delivery systems rather than end-to-end exactly-once workflows.

02

Use stable event and business idempotency keys to deduplicate repeated processing while preserving evidence.

03

Design a handler whose database mutation and external side effect can be retried safely, including dead-letter/manual-repair escalation.

04

Simulate duplicates and reordering locally without pretending the emulator reproduces production Eventarc delivery timing.

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: the same payment-result event can arrive twice

A payment adapter emits PAYMENT_SUCCEEDED. The first delivery updates the order, but the acknowledgement is lost. Event infrastructure may deliver the event again. Current Firestore trigger documentation explicitly says events are delivered at least once and ordering is not guaranteed. Eventarc Standard also uses at-least-once delivery. Therefore the handler’s correctness test is not “did it run once?” but “does replay converge on the same durable result?”

Concept Incorrect assumption Reliable design
Delivery Exactly once At least once; duplicates possible
Ordering Arrival order equals business order Validate state/version; reject or park stale/impossible transitions
External API Retry repeats a safe effect automatically Use provider-supported idempotency key or durable dedupe/outbox protocol
Database update Handler can blindly set fields Check event/business idempotency and current workflow state
Persistent failure Delete event and alert Retain dead-letter/manual-repair evidence with correlation IDs

2. Two identifiers solve two different duplicate problems

An event ID identifies one delivery event. Eventarc guidance notes the CloudEvents source + id combination as the uniqueness basis for an event. A business idempotency key identifies one logical action such as checkout or payment capture. Keep both: the event ID lets you deduplicate redelivery; the business key prevents different event envelopes from accidentally repeating the same business side effect.

event envelope used in the local simulator
{  "source": "atlasmart://payment-simulator",  "id": "evt-pay-success-ord-ch10-001-v1",  "type": "PAYMENT_SUCCEEDED",  "orderId": "ord-ch10-001",  "paymentIdempotencyKey": "payment-ord-ch10-001",  "correlationId": "corr-ch10-001",  "occurredAt": "2026-09-16T18:30:00Z"}

3. Deduplicate in the same transaction as the state transition

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();
duplicate-safe payment-success handler
async function handlePaymentSucceeded(event) {  const eventKey=`${event.source}|${event.id}`;  const inboxRef=db.doc(`workflowEvents/${encodeURIComponent(eventKey)}`);  const orderRef=db.doc(`orders/${event.orderId}`);  return db.runTransaction(async tx=>{    const [seen,order]=await Promise.all([tx.get(inboxRef),tx.get(orderRef)]);    if (seen.exists) return {action:"duplicate",state:seen.get("resultState")};    if (!order.exists) throw new Error("ORDER_NOT_FOUND");    const state=order.get("state");    if (state==="COMPLETED") {      tx.create(inboxRef,{eventId:event.id,source:event.source,resultState:"COMPLETED",duplicateBusinessOutcome:true,processedAt:FieldValue.serverTimestamp()});      return {action:"already-completed"};    }    if (!["RESERVED","PAYMENT_PENDING"].includes(state)) {      throw new Error(`INVALID_TRANSITION_${state}_TO_COMPLETED`);    }    tx.update(orderRef,{state:"COMPLETED",paymentKey:event.paymentIdempotencyKey,lastEventId:event.id,updatedAt:FieldValue.serverTimestamp()});    tx.create(inboxRef,{eventId:event.id,source:event.source,resultState:"COMPLETED",correlationId:event.correlationId,processedAt:FieldValue.serverTimestamp()});    return {action:"completed"};  });}

Because the inbox marker and order transition commit together, a successful transaction cannot leave “order completed but event unrecorded.” A repeated delivery observes the marker and returns without repeating the state mutation.

4. External side effects need their own idempotency contract

Suppose payment success should send a receipt through an external email API. Writing emailSent=true before sending can lose the email if the process crashes; writing it after sending can duplicate email if the acknowledgement is lost. A practical workflow uses an outbox record plus a provider-supported idempotency key when available. The database tracks desired and observed state; the external provider remains a separate system.

outbox record for external side effect
{  "eventId": "email-receipt-ord-ch10-001",  "type": "SEND_RECEIPT",  "businessKey": "receipt-ord-ch10-001",  "orderId": "ord-ch10-001",  "state": "READY",  "attempts": 0,  "nextAttemptAt": null,  "correlationId": "corr-ch10-001"}

If the provider supports an idempotency key, use businessKey. If it does not, exactly-once external effect may be impossible; design duplicate tolerance or operator reconciliation explicitly.

5. Wrong approach: suppress retries and call it exactly once

Disabling retries does not transform delivery into exactly once; it merely increases loss risk after transient failures. Conversely, retrying forever can create cost and noisy poison messages. Eventarc Standard supports retry configuration and dead-letter patterns; Firestore trigger behavior has its own runtime configuration. The application should classify failures, bound retry policy, retain dead-letter evidence and provide manual replay.

Exactly-once illusion

Exactly-once end-to-end processing across Firestore, Eventarc/Functions and an arbitrary external API cannot be assumed. Build at-least-once delivery + idempotent state transitions + provider idempotency/deduplication + repair evidence instead.

6. Deterministic duplicate and reorder simulation

local event runner
const events=[ {source:"atlasmart://payment-simulator",id:"evt-pay-success-001",type:"PAYMENT_SUCCEEDED",orderId:"ord-ch10-001",paymentIdempotencyKey:"payment-ord-ch10-001",correlationId:"corr-ch10-001"}, {source:"atlasmart://payment-simulator",id:"evt-pay-success-001",type:"PAYMENT_SUCCEEDED",orderId:"ord-ch10-001",paymentIdempotencyKey:"payment-ord-ch10-001",correlationId:"corr-ch10-001"}, // duplicate];for (const event of events) {  try { console.log(event.id, await handlePaymentSucceeded(event)); }  catch (error) { console.error(event.id, error.message); }}const order=(await db.doc("orders/ord-ch10-001").get()).data();const inbox=await db.collection("workflowEvents").get();console.log({orderState:order.state,inboxCount:inbox.size});

The expected state is one completed order and one inbox record for the repeated source/id pair. The simulator proves handler logic, not Eventarc latency, retention, retry backoff or production trigger behavior.

7. Firestore trigger specifics that alter deployment

Firestore event triggers are tied to one database. Ordering is not guaranteed. 2nd-generation functions are the appropriate path when named-database support or current Eventarc integration is required; validate exact trigger support for the chosen edition/mode. For MongoDB compatibility, use its documented Eventarc/change-stream surfaces instead of assuming Native Firestore trigger code is identical.

8. 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 an order in PAYMENT_PENDING. Deliver the same success event twice; verify one logical transition. Then deliver a failure event with a new ID after completion and assert the handler rejects or parks the impossible transition rather than undoing the completed order. Persist rejected events to workflowDeadLetters with the reason and correlation ID.

park an impossible event
async function parkEvent(event, reason) {  await db.doc(`workflowDeadLetters/${encodeURIComponent(event.source+"|"+event.id)}`).set({    ...event, reason, state:"NEEDS_REVIEW", parkedAt:FieldValue.serverTimestamp(), schemaVersion:3  },{merge:true});}

Production judgment

Design for replay by default. Stable event IDs, business idempotency keys, conditional state transitions and retained failure evidence make retries routine instead of dangerous. Track duplicate rate, handler failures, age of READY/PROCESSING outbox records and dead-letter backlog. Never interpret “no duplicate observed in testing” as a guarantee. Lesson 5 assembles these pieces into a saga-like workflow with explicit recovery and operator repair.

Knowledge check

  1. What delivery guarantee should a Firestore trigger handler assume?
  2. Why keep both event ID and business idempotency key?
  3. What makes the inbox pattern safe?
  4. Does disabling retries create exactly-once processing?
  5. What should happen to impossible or repeatedly failing events?
Review the answers

1. At least once; duplicates are possible and ordering is not guaranteed.

2. They address different duplicate scopes: one delivery envelope versus one logical business action.

3. The inbox marker and business state transition commit atomically so replay can detect already-applied events.

4. No. It can increase loss risk and does not provide atomicity with external systems.

5. Retain them with reason/correlation evidence in a dead-letter/manual-review path for replay or repair.

Summary and next step

Retries and duplicates are normal distributed-system behavior. Idempotency turns them from a correctness hazard into an expected control path. Next, AtlasMart formalizes the whole order flow as a saga-like state machine.

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.