duraton.events.send() is a network call, so “insert this row and start its run” cannot be a
single database transaction. That’s the one structural change when you move off an in-process durable
engine that shared your database. The failure you’re avoiding:
- Commit first, then send - and if the process dies in between, the row exists but its run never started.
- Send first, then commit - and if the commit fails, a run is now processing a row that doesn’t exist.
dedupeId: a repeat of
the same id within 24 hours (per project, per app) is dropped before any fan-out - no run, no
waiters woken, no log row - and the response is 202 { "deduped": true }. That is exactly the
at-least-once safety net a retry loop needs.
Pick the id from your own write, not a fresh random each attempt - the row’s primary key, or a natural
key like
order:A1:created. Same write, same dedupeId, so every retry of that write collapses to one
run.Pattern 1: the transactional outbox (recommended)
Write the domain row and an outbox row in one local transaction, then relay the outbox to Duraton out of band. The transaction is fully local, so it’s atomic; the relay turns “the row is committed” into “the run is guaranteed to start, at least once”. Step 1 - one local transaction writes both rows.dedupeId.
send() but before it marks the row sent, the next pass re-sends the same
dedupeId and Duraton drops it - the run starts exactly once. Run the relay on a short poll, or trigger
it right after commit and let the poller be the backstop.
Pattern 2: dedupeId-keyed retry (no outbox table)
If you don’t want a second table, commit the domain row first - the row is the source of truth - then send the event keyed to the row id, retrying on failure:GET /runs or your own bookkeeping) and re-sends them, again keyed by row id so the
re-send is safe. The outbox is that sweep, made durable. Use Pattern 2 when an occasional reconciliation
job is acceptable; use Pattern 1 when start-exactly-once must be automatic.
Which dedupe to use
Two mechanisms both surfacededuped: true; they solve different problems, and for transactional start
you want the event-level one:
They compose:
dedupeId guarantees your retrying sender starts the run once, and a workflow
idempotency key on the same field is a belt-and-suspenders backstop against a different producer
emitting the same logical event. See the flow-control reference for the
contrast in full.
Related
Events
What an event is and how it fans out to workflows.
Send an event
The POST /events request body, including dedupeId.
Flow control
Workflow idempotency, and how it differs from event dedupe.
Production and operations
Deploying and draining a runner fleet.