> ## Documentation Index
> Fetch the complete documentation index at: https://docs.duraton.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

<Note>
  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.
</Note>

## 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.**

```ts theme={null}
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`.**

```ts theme={null}
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.

<Warning>
  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.
</Warning>

## 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:

```ts theme={null}
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`](/reference/api/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 `dedupeId`                                                        | Workflow [`idempotency`](/core/flow-control#idempotency)             |
| ------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Keyed on     | An id you send on the event                                             | A path into the event payload (e.g. `orderId`)                       |
| Window       | 24h, per project + app                                                  | Configurable `periodMs` (default 24h)                                |
| Drops        | The **whole event**, before any fan-out                                 | One duplicate **run** of that workflow                               |
| Side effects | No run, no waiters woken, no log row                                    | Event is still recorded and still wakes `waitForEvent` waiters       |
| Use for      | Making a retried `POST /events` safe - **the transactional-start case** | At-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](/core/flow-control#idempotency) for the
contrast in full.

## Related

<CardGroup cols={2}>
  <Card title="Events" href="/core/workflows">
    What an event is and how it fans out to workflows.
  </Card>

  <Card title="Send an event" href="/reference/api/events">
    The POST /events request body, including dedupeId.
  </Card>

  <Card title="Flow control" href="/core/flow-control">
    Workflow idempotency, and how it differs from event dedupe.
  </Card>

  <Card title="Production and operations" href="/core/production">
    Deploying and draining a runner fleet.
  </Card>
</CardGroup>
