Transactional run start

Start a run atomically with an app-side database write - the outbox recipe and the dedupeId-keyed retry pattern.

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.

Neither ordering is safe on its own. The fix is to make the send retriable and idempotent, so "send until it lands" is safe to repeat. Duraton gives you that with an event 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.

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.

await db.transaction(async (tx) => {
  await tx.insert(orders).values(order);
  await tx.insert(outbox).values({
    id: order.id,               // used as the dedupeId - stable across retries
    event: "order.created",
    app: "shop",
    payload: { orderId: order.id },
    sentAt: null,
  });
});

Step 2 - a relay drains unsent outbox rows and sends each with its id as the dedupeId.

const pending = await db.select().from(outbox).where(isNull(outbox.sentAt));

for (const row of pending) {
  await duraton.events.send({
    name: row.event,
    app: row.app,
    data: row.payload,
    dedupeId: row.id,          // a retry of this row is dropped, not double-run
  });
  await db.update(outbox).set({ sentAt: new Date() }).where(eq(outbox.id, row.id));
}

If the relay crashes after 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.

The dedupe window is 24 hours. Keep the relay's retry horizon well inside it (a healthy relay drains in seconds). A row that first landed but whose acknowledgement was lost, then re-sent more than 24h later, would start a second run - so a backlog that ages past a day needs reconciliation, not a blind re-send.

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:

const order = await db.insert(orders).values(newOrder).returning();

await sendWithRetry(() =>
  duraton.events.send({
    name: "order.created",
    app: "shop",
    data: { orderId: order.id },
    dedupeId: order.id,        // safe to retry: a repeat is dropped
  }),
);

The trade-off vs the outbox: if the process dies after the commit but before the send ever succeeds, nothing retries automatically - you need a sweep that finds rows with no corresponding run (query 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 surface deduped: true; they solve different problems, and for transactional start you want the event-level one:

Event dedupeIdWorkflow idempotency
Keyed onAn id you send on the eventA path into the event payload (e.g. orderId)
Window24h, per project + appConfigurable periodMs (default 24h)
DropsThe whole event, before any fan-outOne duplicate run of that workflow
Side effectsNo run, no waiters woken, no log rowEvent is still recorded and still wakes waitForEvent waiters
Use forMaking a retried POST /events safe - the transactional-start caseAt-most-one-run-per-key admission, independent of who sent the event

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.

On this page