Lesson 17 / 20
Claim Check Pattern
Moving a large payload outside the message: proof of delivery raising the message to 40,220 bytes, the claim check lowering message bytes by 141.6 times, the queue's backlog dropping from 3016.5 MB to 21.3 MB, the proof never shrinking on the storage side, and the orphaned payload and dangling claim check that splitting a single write into two brings.
Contents
The previous two lessons kept the workflow’s steps correct: duplication was suppressed, half-finished steps were balanced. The data the workflow carries was never in question. When the carrier reports a delivery, it attaches proof of delivery alongside it — the recipient’s signature or a photo of the delivery point. This addition moves K01’s 220-byte state event record to a different order of magnitude, and that byte count passes through the queue, gets copied to every consumer, and is carried again on every redelivery.
The claim check is a layout that writes the large payload to a separate store and lets the message carry only a reference to that payload. This lesson’s measures are four: the message’s size, the bytes crossing the queue, how long the payload lives in a separate store, and the check dangling — the payload deleted, the check still in hand.
Two Writes, No Shared Transaction Boundary
DD8 — proof of delivery is 40,000 bytes. Rationale: the proof is a compressed signature image, two orders of magnitude above K01’s 900-byte shipment record. Its sensitivity is already visible in the message byte ratio. DD9 — a claim check record is 64 bytes: the store name, the object key, and a digest.
The pattern’s hidden cost starts here. The attached layout has a single write: the message goes to the queue and the proof is inside it. The claim-check layout has two writes — the proof to the store first, then the message to the queue — and the two share no transaction boundary. This is exactly The Data Access Layer and Business Logic course’s distributed transaction problem. Two new failure modes are born: the orphaned payload (the proof was written, the message was not) and the dangling claim check (the message is in hand, the proof is deleted).
// claim-check/queue.mjs — the in-process model of the event stream carrying proof of delivery. // There is no real queue, store, or network: the queue is an array, the payload store a set, bytes are counted. export const EVENT = 220, PROOF = 40_000, CHECK = 64; // K01 V6; DD8 proof; DD9 check record export function generator(seed) { // 32-bit linear congruential generator, seed exposed let s = seed >>> 0; return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return s / 4294967296; }; } // layout: "attached" = the proof travels inside the message; "check" = the proof sits in a store, the message carries a check // deletion: "immediate" = the consumer deletes on processing; "lifetime" = deletion waits out a lifetime export function run({ n, layout, deletion, producerCrash, unackedCrash, seed }) { const rnd = generator(seed); const usesCheck = layout === "check"; const message = usesCheck ? EVENT + CHECK : EVENT + PROOF; const store = new Set(), queue = []; const s = { message, bytesMoved: 0, deliveries: 0, lostEvents: 0, orphanedPayload: 0, danglingCheck: 0, processed: 0, remainingInStore: 0 }; for (let i = 0; i < n; i += 1) { if (usesCheck) store.add(i); // the payload is written first const crashed = rnd() < producerCrash; // then the message is written; a crash can land in between if (crashed) { if (usesCheck) s.orphanedPayload += 1; else s.lostEvents += 1; continue; } queue.push({ no: i, attempt: 0 }); } while (queue.length > 0) { const m = queue.shift(); s.bytesMoved += message; s.deliveries += 1; // every delivery carries the message from scratch if (usesCheck && !store.has(m.no)) { s.danglingCheck += 1; continue; } s.processed += 1; if (usesCheck && deletion === "immediate") store.delete(m.no); if (rnd() < unackedCrash && m.attempt === 0) queue.push({ no: m.no, attempt: 1 }); } s.remainingInStore = usesCheck ? store.size : 0; return s; }
The failure scenario is named directly. DD10 — in the failure window, the probability that the producer crashes between its two writes is 0.005, the probability that the consumer crashes before acknowledgment is 0.01. Rationale: the first window is the short gap between two remote writes, the second is the gap between processing and acknowledgment, and the second is longer. Both are assumptions, not added to K01’s table.
// claim-check/measure.mjs — four layouts, without and with failure; message bytes and dangling counted import { run, EVENT, PROOF, CHECK } from "./queue.mjs"; const N = 100_000; const CONDITION = [["failure-free day", 0, 0], ["failure window (DD10)", 0.005, 0.01]]; const LAYOUT = [["attached", "-"], ["check", "immediate"], ["check", "lifetime"]]; console.log(`message bytes: attached ${EVENT + PROOF}, check ${EVENT + CHECK} ` + `(K01 V6 = ${EVENT} bytes event, DD8 = ${PROOF} bytes proof, DD9 = ${CHECK} bytes check)`); for (const [name, producerCrash, unackedCrash] of CONDITION) { console.log(`\n${name}: ${N} delivery events, producer crash ${producerCrash}, unacked crash ${unackedCrash}`); console.log(`${"layout".padEnd(18)}${"deliveries".padStart(11)}${"MB moved".padStart(11)}` + `${"lost events".padStart(13)}${"orphaned".padStart(11)}${"dangling".padStart(11)}` + `${"processed".padStart(11)}${"in store".padStart(10)}`); for (const [layout, deletion] of LAYOUT) { const r = run({ n: N, layout, deletion, producerCrash, unackedCrash, seed: 20260730 }); console.log(`${`${layout} ${deletion}`.padEnd(18)}${String(r.deliveries).padStart(11)}` + `${(r.bytesMoved / 1e6).toFixed(2).padStart(11)}${String(r.lostEvents).padStart(13)}` + `${String(r.orphanedPayload).padStart(11)}${String(r.danglingCheck).padStart(11)}` + `${String(r.processed).padStart(11)}${String(r.remainingInStore).padStart(10)}`); } }
message bytes: attached 40220, check 284 (K01 V6 = 220 bytes event, DD8 = 40000 bytes proof, DD9 = 64 bytes check) failure-free day: 100000 delivery events, producer crash 0, unacked crash 0 layout deliveries MB moved lost events orphaned dangling processed in store attached - 100000 4022.00 0 0 0 100000 0 check immediate 100000 28.40 0 0 0 100000 0 check lifetime 100000 28.40 0 0 0 100000 100000 failure window (DD10): 100000 delivery events, producer crash 0.005, unacked crash 0.01 layout deliveries MB moved lost events orphaned dangling processed in store attached - 100495 4041.91 492 0 0 100495 0 check immediate 100495 28.54 0 492 987 99508 492 check lifetime 100495 28.54 0 492 0 100495 100000
These numbers are in the measurement class: they come from a process-internal model and a generator seeded at 20260730.
The Deletion Policy Is a Correctness Decision
The top table gives the message bytes: the attached layout moves 4022.00 MB for a hundred thousand events, the claim-check layout 28.40 MB. The ratio is the message byte ratio, and with no failure there is no other difference.
The bottom table makes the real distinction, and the column that deserves attention is
processed. In the attached layout, the producer’s crash drops 492 events entirely — the
proof and the event were in a single write, and both were gone together. In the claim-check
layout, the same crash still loses the events, but it leaves 492 orphaned payloads in the store;
the loss is equal, and there is now garbage on top of it.
The two deletion policies behave differently under the same failure. Under immediate deletion, 987 redeliveries cannot find the proof and turn into dangling checks; the processed count sits at 99,508, meaning every event is processed exactly once. Under lifetime-based deletion, dangling checks are zero, but processed is 100,495: all 987 redeliveries find the proof and redo the work.
This is the pattern’s least-noticed consequence. The moment the payload is deleted turns, without anyone intending it, into an idempotency check: immediate deletion blocks duplicate processing but turns redelivery into an error; lifetime-based deletion runs the redelivery and hands the duplicated side effect off to the first lesson’s ledger. The correct setup keeps the two separate — the idempotency decision is made in the ledger, the deletion decision in the store; the payload’s lifetime is not used as a correctness mechanism.
Back to the Numbers
DD11 — proof lifetime is 7 days. Rationale: a delivery dispute arrives within the business day following the delivery day, and the proof is requested inside that window; proof is not kept without a dispute. Its sensitivity is given at 1 and 30 days.
// claim-check/cost.mjs — converting the measured message bytes into queue, storage, and dangling numbers at K01 scale const V3 = 400_000, V8 = 3, DAY_SEC = 86_400; // K01: daily shipments, peak factor const WRITE_MBIT = 0.17, GROWTH_MB = 976, STORAGE_GB = 712.48; // K01 computed values const BACKLOG = 525_000, EVENT_SHARE = 7; // M19/K03 message queues lesson: highest backlog const ATTACHED = 40_220, CHECKED = 284, PROOF = 40_000; // measure.mjs measurement; DD8 const peakDeliveries = (V3 / DAY_SEC) * V8; // every shipment produces one delivery event console.log(`peak delivery events ${peakDeliveries.toFixed(4)} events/s (K01: ${V3} shipments/day, peak factor ${V8})`); console.log(`\n${"layout".padEnd(12)}${"message bytes".padStart(14)}${"queue Mbit/s".padStart(15)}` + `${"of K01 write entry".padStart(21)}${"backlog MB".padStart(13)}`); for (const [name, b] of [["attached", ATTACHED], ["check", CHECKED]]) { const mbit = (peakDeliveries * b * 8) / 1e6, backlog = ((BACKLOG / EVENT_SHARE) * b) / 1e6; console.log(`${name.padEnd(12)}${String(b).padStart(14)}${mbit.toFixed(4).padStart(15)}` + `${`${(mbit / WRITE_MBIT).toFixed(2)}x`.padStart(21)}${backlog.toFixed(1).padStart(13)}`); } console.log(`queue byte ratio ${(ATTACHED / CHECKED).toFixed(1)}x; ` + `backlog ${(BACKLOG / EVENT_SHARE).toLocaleString("en-US")} messages with proof (M19/K03: ${BACKLOG.toLocaleString("en-US")} events / ${EVENT_SHARE})`); console.log(`\n${"DD11 lifetime".padStart(15)}${"proof in store".padStart(16)}${"store GB".padStart(11)}` + `${"of K01 storage".padStart(16)}${"of daily growth".padStart(17)}`); for (const days of [1, 7, 30]) { const count = V3 * days, gb = (count * PROOF) / 1e9; console.log(`${`${days} d`.padStart(15)}${count.toLocaleString("en-US").padStart(16)}` + `${gb.toFixed(2).padStart(11)}${`${((100 * gb) / STORAGE_GB).toFixed(2)}%`.padStart(16)}` + `${`${((gb * 1000) / GROWTH_MB).toFixed(2)}x`.padStart(17)}`); } const DANGLING = 987 / 100_000, ORPHANED = 492 / 100_000; // measure.mjs measurement console.log(`\nfailure window at K01 scale (${V3.toLocaleString("en-US")} delivery events/day):`); console.log(`dangling checks ${(V3 * DANGLING).toFixed(0)}/day, orphaned payloads ${(V3 * ORPHANED).toFixed(0)}/day`); console.log(`orphaned payload bytes ${((V3 * ORPHANED * PROOF) / 1e6).toFixed(1)} MB/day, ` + `without a lifetime, a year gives ${((V3 * ORPHANED * PROOF * 365) / 1e9).toFixed(2)} GB`); console.log(`in the attached layout, the same crash drops ${(V3 * ORPHANED).toFixed(0)} events entirely`);
peak delivery events 13.8889 events/s (K01: 400000 shipments/day, peak factor 3)
layout message bytes queue Mbit/s of K01 write entry backlog MB
attached 40220 4.4689 26.29x 3016.5
check 284 0.0316 0.19x 21.3
queue byte ratio 141.6x; backlog 75,000 messages with proof (M19/K03: 525,000 events / 7)
DD11 lifetime proof in store store GB of K01 storage of daily growth
1 d 400,000 16.00 2.25% 16.39x
7 d 2,800,000 112.00 15.72% 114.75x
30 d 12,000,000 480.00 67.37% 491.80x
failure window at K01 scale (400,000 delivery events/day):
dangling checks 3948/day, orphaned payloads 1968/day
orphaned payload bytes 78.7 MB/day, without a lifetime, a year gives 28.73 GB
in the attached layout, the same crash drops 1968 events entirely
The Numbers for Two Days
The failure-free day’s cost has three line items. Every delivery event now carries two writes and one extra read on the consumer side; the single write’s atomicity is gone. At DD11 = 7 days, the proof sits in the store at 112.00 GB, 15.72 percent of K01’s 712.48 GB storage line. And on the storage side, the pattern has no gain at all: the proof occupies the same 40,000 bytes whether it is attached or checked. The claim check is a transport decision, not a storage decision.
The failure day’s gain sits in the queue. In the attached layout, the peak delivery flow puts 4.4689 Mbit/s on the queue — 26.29 times K01’s 0.17 Mbit/s write entry. In the claim-check layout, it is 0.0316 Mbit/s, 0.19 times the write entry. The real difference shows up in the backlog: given that one in seven of the 525,000 events The Application Layer and Service Interaction course measured is a message carrying proof, the queue has to hold 3016.5 MB in the attached layout and 21.3 MB in the claim-check layout. In a design where the queue is a bounded buffer, this 141.6-fold difference is the difference in how many events the same bound can hold.
The gain’s counterpart is new failure modes. At K01 scale, the failure window produces 1968 orphaned payloads a day (78.7 MB) and, under the immediate deletion policy, 3948 dangling checks. Without a lifetime, orphaned payloads climb to 28.73 GB a year; DD11 is not a storage parameter, it is a garbage collection mechanism. Dangling checks, on the other hand, are not data loss — all 3948 are redeliveries of work already processed — but they show up as errors on the consumer side, and if they are not routed off the dead-letter path, they hide real errors.
Summary
- Proof of delivery raises the message from 220 bytes to 40,220 bytes; the claim check brings it down to 284 bytes, a ratio of 141.6x.
- Queue load: the peak delivery flow is 4.4689 Mbit/s in the attached layout (26.29 times K01’s write entry), 0.0316 Mbit/s in the claim-check layout (0.19 times).
- The difference grows in the backlog: a backlog of 75,000 messages with proof holds 21.3 MB instead of 3016.5 MB.
- The pattern has no gain on the storage side: at DD11 = 7 days the proof sits at 112.00 GB, 15.72 percent of K01’s storage; the claim check is a transport decision, not a storage decision.
- The pattern splits a single write into two and introduces two new failure modes: 1968 orphaned payloads a day (78.7 MB, 28.73 GB a year without a lifetime) and 3948 dangling checks.
- The deletion policy is silently a correctness decision: under immediate deletion, processed is 99,508 (exactly once); under lifetime-based deletion, it is 100,495 (987 duplicate processing runs); the latter depends on the idempotency ledger.
Next Step
The proof left the queue, but the whole path still runs through the service. The carrier uploads the proof to the service, the service writes it to the store; when a recipient wants to see the proof, the service reads it from the store and passes it to the client. The 40,000 bytes removed from the queue still pass through the service’s request path twice, and when the store slows down, they tie up the service’s own workers. The next lesson takes on removing this byte count from the service as well: the limited-time authorization granted for direct client access to the resource, the byte count that never passes through the service, the authorization window it opens, and what narrowing that scope costs.
To keep your progress and take notes, Log in
My notes
Log in to take notes.