Skip to content
A dark 3D operations bench where a failed billing event is detected and isolated before three recovery paths converge at a reconciliation gate
Recovery does not end when failed work runs again. Preserve the system of record, reconcile money and progress, and only then approve traffic restoration.

Part 1 covered duplicate ingestion and resumable close in a single PostgreSQL ledger. Part 2 rebuilt projections from an Event Store. Part 3 split billing into five services and used Outbox and Inbox records to survive partial failure.

The storage models differ, but operators must answer the same questions. How far did processing get? Which durable facts remain? Which reprocessing method is safe? What evidence proves the system is healthy again?

This article turns the failure paths exercised by those three examples into one operations runbook. It does not propose a universal monitoring product or recovery framework. It organizes recovery as detect → identify scope → classify → isolate and recover → reconcile → approve resume.

Define healthy billing with invariants, not throughput

Section titled “Define healthy billing with invariants, not throughput”

A normal HTTP success rate and low Kafka lag do not prove that billing is correct. Define health through business invariants.

BoundaryHealthy conditionFailure signal
IngestionOne producer event affects money onceSame ID with another digest, a receipt held too long
PricingEvidence exists for the price active at occurrence timeMissing or overlapping intervals, unapproved version
CloseEvery fact before the cursor is aggregated onceFailed batch, stale lease, stalled cursor
Event StoreStream versions and the hash chain are continuousVersion gap, hash mismatch, failed upcast
ProjectionA verified ACTIVE generation reaches the target positionFAILED generation, growing lag, amount mismatch
Service deliveryAn Outbox event passes an Inbox decision before one effectOld RETRY_WAIT, DEFERRED, or quarantine growth
Financial resultUsage, charge, invoice, and projection amounts trace to source eventsUnexplained amount, edited line, reconciliation difference

Build alerts around these invariants. Signals such as oldestRetryAge, projectionLag, quarantineCount, and reconciliationDifference are more useful to recovery decisions than a generic error-log count.

1. Record the signal and the affected scope together

Section titled “1. Record the signal and the affected scope together”

Reprocessing everything after the first alert also touches healthy tenants and periods. Pin the recovery unit first.

RecoveryScope = {
incidentId,
detectedAt,
design: LEDGER | EVENT_SOURCED | MICROSERVICES,
tenantIds,
billingPeriods,
producerEventIds,
aggregateKeys,
streamVersionRange,
projectionGeneration,
topicPartitionsAndOffsets,
observedHighWatermark
}

When the scope is uncertain, widen observation rather than mutation. Trace source event IDs into usage facts, charges, invoice lines, and projection rows. Locate the last verified cursor, stream version, or checkpoint. Those values define where recovery starts and the high watermark it must reach.

2. Classify the failure into three operational paths

Section titled “2. Classify the failure into three operational paths”

Retry count is not a sufficient classifier. The same exception can require a different response depending on which durable facts remain.

ClassExamplesDurable evidenceAllowed recovery
Transient failureDatabase connection, broker path, worker crashReceipt, Outbox, Inbox, checkpoint, leaseTake over the same work, redeliver the same envelope, resume at the checkpoint
Permanent contract errorUnsupported schema, missing field, same ID with another digestImmutable source event, reason, quarantine, redrive auditIsolate from healthy flow, fix the contract, redrive the same source
Consistency mismatchProjection difference, invalid snapshot, incorrect finalized amountEvent Store, ledger links, previous generation, reconciliation findingReplay, rebuild, append-only adjustment

Quarantining every transient failure creates unnecessary operator work. Retrying a permanent contract error forever blocks healthy events. Redelivering a consistency mismatch leaves already-created bad money in place. Persist the classification as an input to the recovery command.

A vertical recovery flow that detects and scopes a failure, branches by three failure classes, and converges at reconciliation and resume approval
Each failure class uses a different recovery mechanism, but every path must pass the same gate for position, quarantine, amounts, and current ownership.

Compare the three billing designs in the interactive companion

3. Isolate the failed scope while healthy work continues

Section titled “3. Isolate the failed scope while healthy work continues”

Isolation is not deletion. It separates failed work from healthy flow while retaining the evidence needed for diagnosis and reprocessing.

  • Keep a failed batch as FAILED with its last cursor so another owner can take it over.
  • Mark a corrupt or non-upcastable projection generation FAILED while the previous ACTIVE generation remains visible.
  • Record tenant, event ID, digest, schema version, and reason for a poison event in quarantine.
  • Keep an out-of-order event DEFERRED and create no business effect until the expected version arrives.
  • Persist a reconciliation difference as a finding and distinguish automated correction from operator approval.

An isolation record should point to the immutable source with an identifier and digest. Do not treat an editable copy of the payload as the source. This evidence proves that a later redrive used the same original after the contract was fixed.

4. Choose recovery from the durable evidence that remains

Section titled “4. Choose recovery from the durable evidence that remains”

Transient failure: take over existing work instead of creating new work

Section titled “Transient failure: take over existing work instead of creating new work”
val work = repository.claimExpiredOrPending(scope, ownerToken)
when (work.state) {
PENDING, RETRY_WAIT, CLAIMED_WITH_EXPIRED_LEASE -> {
executeFrom(work.checkpoint)
repository.complete(work.id, ownerToken)
}
COMPLETED -> return work.savedResult
else -> keepIsolated(work)
}

Owner and fencing tokens prevent a late former worker from completing the current owner’s work. A broker retry sends the existing Outbox envelope instead of creating another event. A lost HTTP response reuses the stored result for the same idempotency key and fingerprint.

Permanent contract error: fix the contract, then redrive the immutable source

Section titled “Permanent contract error: fix the contract, then redrive the immutable source”
1. Inspect the quarantine reason and original digest.
2. Fix the decoder, upcaster, or producer contract and pass regression tests.
3. Read the same original envelope from the external retained source.
4. Record a redrive audit with operator, reason, and incident ID.
5. Deliver it again so Inbox links the old quarantine to the new decision.
6. Confirm that the error does not recur and healthy events keep advancing.

Editing a quarantined payload before processing breaks the link between source and recovery result. Issuing an arbitrary new event ID also bypasses the original deduplication and audit trail.

Consistency mismatch: distinguish replay, rebuild, and adjustment

Section titled “Consistency mismatch: distinguish replay, rebuild, and adjustment”
MechanismUse whenChangesMust preserve
ReplayThe Event Store is valid and only aggregate state must be recomputedIn-memory state or a new snapshotSource event order and hashes
Projection rebuildA read model is corrupt or its handler changedA new projection generationPrevious ACTIVE generation, checkpoints, failed generation
Append-only adjustmentA finalized charge or invoice amount must be correctedA new adjustment and correction lineOriginal amount, source links, correction reason

Stop replay if an Event Store hash is invalid. First restore the history or establish a trusted backup boundary. When the Event Store is valid but a projection differs, do not edit source events. Build a new generation, verify it, and switch ACTIVE atomically.

5. Reconciliation is the approval condition, not cleanup after recovery

Section titled “5. Reconciliation is the approval condition, not cleanup after recovery”

A successful recovery command only says the command ran. Resume approval requires evidence that independent storage boundaries explain the same business facts.

val evidence = RecoveryEvidence(
sourceHighWatermark = source.highWatermark(scope),
processedCursor = consumers.minimumCursor(scope),
unresolvedQuarantine = quarantine.countOpen(scope),
ledgerAmount = ledger.sum(scope),
projectionAmount = projection.sum(scope),
invoiceAmount = invoices.sum(scope),
currentFencingToken = leases.currentToken(scope),
observedLag = consumers.maxLag(scope),
)
require(evidence.processedCursor >= evidence.sourceHighWatermark)
require(evidence.unresolvedQuarantine == 0L)
require(evidence.ledgerAmount == evidence.projectionAmount)
require(evidence.ledgerAmount == evidence.invoiceAmount)
require(evidence.currentFencingToken == recoveryCommand.fencingToken)
require(evidence.observedLag <= recoverySlo.maxLag)

Reconcile by source event ID and price version before comparing period totals. Equal totals can hide two offsetting errors. First prove the usage fact → charge → invoice line → projection chain, and then aggregate the verified records.

Passing the recovery gate does not require opening all traffic immediately.

  1. Resume the affected tenant or partition at a limited rate.
  2. Confirm that new FAILED, DEFERRED, quarantine, and reconciliation findings do not increase.
  3. Confirm that cursors advance and lag converges within its normal range.
  4. Reconcile once more and attach before-and-after evidence to the incident.
  5. Expand the scope gradually and record final resume time.

This may look slower than restarting a service. In billing, however, preventing silent propagation of incorrect money is more important than a fast but unverified resume.

Forbidden actionWhy it looks attractiveLong-term risk
Delete Outbox or quarantine rowsBacklog and error counts fallDelivery loss and destroyed evidence
Advance a checkpoint or cursor manuallyLag approaches zero immediatelyUnprocessed events are skipped permanently
Force failed work to COMPLETEDThe worker moves forwardStatus no longer matches the business result
Edit an existing charge or invoice lineThe current total appears correctOriginal decision and correction cannot be explained
Skip snapshot validationReplay starts fasterCorrupt or stale state becomes the baseline
Republish a poison event with a new IDInbox deduplication no longer blocks itSource-to-redrive audit linkage is lost

Operations tooling should not bypass these rules. Separate inspection from mutation, and require incident ID, operator, reason, target scope, and fencing token for every recovery command.

Recovery cost remains in every architecture

Section titled “Recovery cost remains in every architecture”
DesignSimpler recovery boundaryAdditional operational boundary
Single PostgreSQL ledgerOne transaction and an explicit cursorLong-running batches, receipt takeover, period reconciliation
Event SourcingRebuild state and projections from immutable eventsSnapshot validation, upcasters, generation switch, replay cost
MicroservicesIndependent deployment and fault isolationOutbox, Inbox, lag, schema contracts, quarantine, cross-service reconciliation

The ability to reprocess does not by itself make a system recoverable. The input must be immutable, repeated effects must be contained, progress must be restartable, and results must be independently reconcilable.

The shared rule across all four parts is simple: do not rewrite source facts, resume from durable processing evidence, and declare completion only through independent reconciliation. The implementation changes, but this rule lets an operator recover from evidence instead of guessing.

Comments

Leave a note or reaction with your GitHub account.