An automation is useful when repeating its input does not repeat an unintended action. Network retries, redelivered events and impatient clicks can all reach the same workflow. Start with a draft-only task and make its identity, result and failure behavior explicit before connecting a live action.
Choose the action you want to happen once
This teaching task prepares one internal review draft for each version of a request. It does not send email, charge a card or update another service. Required fields are request_id, positive integer version, title and owner. An optional delivery_id is transport metadata, not business identity. The expected output is a draft ID plus a recorded operation key.
Our example request is R-42, version 3, title Review renewal options, owner ops. Its operation key is demo:R-42:v3:prepare-brief. A second delivery of the same request version should return the existing draft. Version 4 is intentionally a new action in this policy; if you want one draft per request regardless of version, choose a different key and update rule.
A delivery ID is not the business identity
Two delivery IDs can describe the same business event. Keying only on delivery ID would accept both as new work. Conversely, keying only on request ID would suppress a legitimate later version. Write the policy first: one action per workspace, request, version and action type. Do not derive identity from a mutable title.
| Input change | Expected behavior | Total drafts |
|---|---|---|
| First R-42, version 3 delivery | Prepare draft-1. | 1 |
| Same data, new delivery ID | Return draft-1 as duplicate; no new draft. | 1 |
| Same version, different title | Block conflicting payload for review. | 1 |
| R-42, version 4 | Prepare draft-2 under a new key. | 2 |
A runnable, limited example
Save this as retry-demo.mjs and run node retry-demo.mjs using Node.js 22 or newer. The Map is an in-memory teaching ledger. It deliberately demonstrates the decision rules without claiming durability, concurrency safety or a production “exactly once” guarantee.
const ledger = new Map();
function prepareDraft(event) {
if (!event || typeof event.request_id !== 'string' ||
!event.request_id.trim() || !Number.isSafeInteger(event.version) ||
event.version < 1 || typeof event.title !== 'string' ||
!event.title.trim() || event.owner !== 'ops') {
return { status: 'blocked', reason: 'invalid_input' };
}
const key = 'demo:' + encodeURIComponent(event.request_id) +
':v' + event.version + ':prepare-brief';
const fingerprint = JSON.stringify([
event.request_id, event.version, event.title.trim(), event.owner
]);
const existing = ledger.get(key);
if (existing) {
if (existing.fingerprint !== fingerprint) {
return { status: 'blocked', reason: 'payload_conflict', key };
}
return { status: 'duplicate', key, draft_id: existing.draft_id,
draft: { ...existing.draft } };
}
const draft_id = 'draft-' + (ledger.size + 1);
const draft = { title: event.title.trim(), owner: event.owner,
state: 'awaiting_review' };
ledger.set(key, { fingerprint, draft_id, draft });
return { status: 'prepared', key, draft_id, draft: { ...draft } };
}
const input = {
request_id: 'R-42', version: 3,
title: 'Review renewal options', owner: 'ops', delivery_id: 'delivery-1'
};
console.log(prepareDraft(input));
console.log(prepareDraft({ ...input, delivery_id: 'delivery-2' }));
console.log(prepareDraft({ ...input, title: 'Changed without a new version' }));
console.log(prepareDraft({ ...input, version: 4 }));
console.log('Draft count:', ledger.size);
The expected statuses are prepared, duplicate, blocked and prepared; the expected draft count is 2. The fingerprint excludes delivery ID because transport metadata changes on a retry. It includes the fields that affect the draft, so reusing a version with different content becomes a visible conflict.
Move the invariant into durable storage
A production worker can restart, and two workers can process the same key at once. An in-memory “check then write” is insufficient. Store the operation key under a database uniqueness constraint and atomically reserve it with the draft or pending work item. PostgreSQL’s constraint documentation explains the database mechanism; the same requirement applies when using another store.
For an external action, an outbox can record intended work in the same transaction as the local state change. A delivery worker then attempts it and records the outcome. The outbox does not itself prevent a remote service from acting twice after an ambiguous timeout. Use the destination’s documented idempotency mechanism where available and keep its key and retention rules in your design.
If the remote action may have succeeded but its acknowledgement was lost, mark the outcome unknown. Reconcile against the destination or require a person to review it. Blindly retrying a send or charge is not a safe substitute for knowing the result. Stripe’s idempotency documentation is an example of provider-specific semantics, not a promise that every integration supports them.
Write the failure plan before the schedule
Operation: one review draft per request version
Identity: workspace + request_id + version + action
Allowed input: approved owner, required fields, positive integer version
Duplicate: return the recorded result; create nothing
Same key, changed payload: block and review
Unknown external result: reconcile; do not blindly repeat
Limits: maximum batch size, retry budget and processing deadline
Recovery: operator can inspect, pause and resume recorded operations
Retention: document how long keys and minimal audit data are kept
Add a maximum batch size and a retry budget so a malformed feed cannot produce unbounded work. Log the key, state transition, attempt count and error category. Avoid putting private source text or credentials into logs. A failed record should be visible to an owner, rather than disappearing behind a successful batch summary.
Acceptance checklist and remaining limits
- Deliver the same input twice and confirm only one draft exists.
- Change delivery ID without changing business data; the result is reused.
- Reuse the key with changed content; the operation stops for review.
- Restart the production worker; its durable ledger still prevents duplicates.
- Race two deliveries and simulate failure before and after destination acknowledgement.
- Pause the workflow and verify that pending operations remain inspectable.
The restart, concurrent-delivery and pause checks need your actual storage and integration; the small script cannot establish them. Define whether later versions may overtake earlier ones, how old keys expire and what happens after expiry. Keep human approval for consequential external actions until those questions have clear answers and your own checks support the behavior.
References & further reading
These sources explain the methods used here. They do not endorse Wicked.today or the fictional examples.
- PostgreSQL — Constraints, including unique constraints ↗
- AWS Prescriptive Guidance — Transactional outbox pattern ↗
- Stripe API — Idempotent requests ↗
A correction should identify the sentence, calculation or example in question. Contact the publisher at [email protected].