Skip to main content
In Run Checkout Across Two Servers with PostgreSQL and Redis, both API instances executed orders synchronously from start to finish within a single HTTP request. In real e-commerce and B2B systems, workflows often cannot finish in a few hundred milliseconds:
  • Asynchronous payment gateways: 3D Secure verification, bank transfers, or payment redirects require waiting for an asynchronous webhook notification hours or days later.
  • Human-in-the-loop approvals: High-value orders, risk exceptions, or manager sign-offs require an operator to inspect and approve the transaction.
  • Cooling-off windows: Customers may have a 15-minute grace period to edit or cancel an order before the warehouse packs it (wait('15m')).
Holding open an HTTP connection or running an in-memory setTimeout is fatal in distributed environments: client connections time out, server processes crash or restart during deployments, and in-memory state is lost. This continuation extends the distributed checkout stack with durable workflows: an execution can suspend cleanly at waitForEvent or wait, persist its complete state and continuation point to PostgreSQL, release all process resources, survive server restarts, and resume safely on any API replica when an external webhook or human approval arrives.

Run the Ready-Made Example

You need a repository checkout, a running Docker daemon, and Docker Compose with up --wait. From the repository root, build and start the complete example without copying any tutorial snippets:
This uses the separate siglo-durable-checkout Compose project, image, database volume, Redis channel, and host ports. The previous tutorial and its examples/distributed-checkout stack can keep running unchanged. Submit an order to API A. Expect 202 Accepted with status: "waiting":
Open http://127.0.0.1:4100 to inspect the suspended run, then restart API A and approve the order on API B:
Expect 200 OK with result.status: "confirmed". Repeating approval returns 409 Conflict. Every order in this demo requires approval. Reused submission IDs are rejected for 24 hours, so use a new order ID when repeating the walkthrough. Run the automated smoke check, which restarts both API replicas between submission and resumption and also tests dashboard resumption:
Stop only this example with:
Data survives down; add --volumes only when you want to reset this example’s database. The previous tutorial’s volume is separate.

Example Files to Explore

The stock, payment, and shipping services are fake; no external provider credentials are needed. After trying the example, edit these files and rerun the build command above. The sections below explain how they fit together.

1. How Durable Distributed Execution Works

When a workflow runs with { durable: true } and reaches a wait node (waitForEvent or wait), the runtime:
  1. Suspends Execution: The step halts cleanly without blocking Node.js event loop threads.
  2. Persists Checkpoint: The engine writes an OCC versioned checkpoint containing the accumulated context and step history to siglo_checkpoints.
  3. Registers Wait Record: A durable wait descriptor is inserted into siglo_wait_records indexed by a business correlation key (e.g. order:distributed-1001).
  4. Returns Immediate Control: The workflow returns { status: "waiting", correlationKey, stackId }, enabling your API to respond immediately with 202 Accepted.
  5. Releases the Request: No HTTP connection or timer must stay open for the wait. The runtime may retain local state, but PostgreSQL holds the durable snapshot, so the API process can restart or deploy new code.
  6. Resumes Across Replicas: When an external webhook arrives at either API replica, calling resumeWorkflow acquires a PostgreSQL advisory lock, claims the wait record atomically, restores the state snapshot, and executes the remaining steps to completion.

2. Define the Durable Checkout Workflow

The runnable demo reserves stock and charges the customer, then pauses every order for manager sign-off before shipping. A real checkout could apply an amount or risk rule before choosing an approval workflow. checkout-durable.ts uses the existing demo service interfaces:
[!NOTE] waitForEvent accepts a string or a correlation key extractor (context) => string. Always correlate waits on immutable business identifiers (such as order:${order.orderId} or payment:${paymentIntentId}) rather than random request tokens to ensure idempotency.

3. Wire PostgreSQL Wait Records in Storage

In Distributed Checkout with PostgreSQL and Redis, our storage adapter only forwarded checkpoints and cache operations. For durable workflows, the storage adapter must expose wait record persistence and distributed locking. infrastructure.ts serializes checkpoint and wait writes and exposes flush(). The submission wrapper drains these writes before acknowledging 202; the resume path drains them while holding the workflow lock. Compose runs schema initialization once before starting the API replicas and monitor:

PostgreSQL Wait Records Schema

The PostgresStorageAdapter automatically manages the siglo_wait_records table:

4. Implement the API and Webhook Endpoints

api-durable.ts provides submission and approval endpoints, plus health and checkpoint status routes:
  1. POST /api/checkouts: uses @siglo/express with { durable: true } and returns 202 Accepted when suspended or 200 OK when completed.
  2. POST /api/webhooks/orders/:orderId/approval: accepts approval webhooks and calls resumeWorkflow.
The submission adapter starts the workflow, unwraps its result, and automatically returns 202 for a waiting result or 200 on completion. No custom output callback is needed. The approval webhook calls resumeWorkflow directly because it continues an existing execution rather than starting a new one.

5. Walkthrough: Suspend on A, Resume on B

Let’s test durable suspension across server restarts and replicas.

Step 1: Submit an Order to API A

Send a checkout request to API A on port 3100:
API A returns 202 Accepted:
At this moment:
  • Inventory has been reserved.
  • Payment has been authorized.
  • The workflow has suspended at awaitApproval.
  • No HTTP request or in-process timer needs to remain open for this order; the durable state is in PostgreSQL.
The live monitor makes that pause visible: reserveStock and chargePayment are complete, the approval wait is amber, and arrangeShipping has not run. Selecting the wait opens its waiting state and UTC deadline in the right-hand details panel. These screenshots were captured from separate local API and monitor processes using this example’s real PostgreSQL and Redis services. Execution IDs, deadlines, and timings will differ in your run. Durable checkout live monitor showing the suspended approval wait in amber, completed reservation and payment steps, unexecuted shipping, and the wait state and UTC deadline in the right-hand panel

Step 2: Inspect PostgreSQL State

Verify the wait record directly in PostgreSQL:
Output:

Step 3: Simulate Process Crash & Restart

Restart API A to demonstrate complete crash survival:
Even if the originating process dies or rolls out a new deployment, the state snapshot is safe in PostgreSQL.

Step 4: Resume on API B

Deliver the approval webhook to API B on port 3101:
API B claims the wait record, restores the context snapshot, runs arrangeShipping, and returns 200 OK:
Check the wait records table again:
The wait record now has status: RESUMED with the claimant ID recorded. The checkpoint has status: completed. The same execution now displays COMPLETED in the shared monitor. In this capture, both API processes were restarted before API B received approval. The latency view retains the earlier reservation and payment steps, marks the wait complete, and shows the subsequent arrangeShipping execution under the original stack ID. Its step timings do not represent the time spent awaiting human approval. Shared durable checkout monitor showing the original execution completed after approval on API B, with reservation, payment, approval wait, and shipping all completed in the latency waterfall

Step 5: Test Duplicate Delivery Protection

Send the exact same approval webhook again to API B:
The server rejects the duplicate resumption with 409 Conflict:
[!IMPORTANT] Siglo uses PostgreSQL advisory locks and atomic conditional updates (WHERE status = 'WAITING') inside claimWaitRecord. If two identical webhooks arrive concurrently at API A and API B, exactly one replica acquires the claim; the other is rejected with DuplicateEventException, preventing duplicate shipping dispatches.

6. Live Monitoring and Interactive Resumption

As in Serve a REST Endpoint and Watch It Run Live, a separate dashboard observes checkout execution. This example uses port 4100 to avoid the previous tutorial’s monitor port. The monitor registers the checkout workflow and connects to the same PostgreSQL storage as both API replicas. Unlike the previous tutorial’s observation-only monitor, this local dashboard enables interactive resumption:
  1. Open http://127.0.0.1:4100 in your browser.
  2. Select the execution by its stackId.
  3. The DAG highlights reserveStock and chargePayment in green (COMPLETED).
  4. The run displays an amber SUSPENDED badge and the waiting event name order.approved.
  5. Click Resume and use the waiting run’s stack ID as the key.
  6. Enter the JSON event payload:
  7. Click Dispatch Signal. The dashboard dispatches POST /api/workflows/:stackId/resume, and the live DAG immediately transitions: arrangeShipping executes on screen and the workflow completes.

Approve Directly from the Dashboard

Use a fresh submitted order to try this route after the webhook walkthrough. The Resume dialog pre-fills the selected stack ID and order.approved signal. Replace the default JSON with the approval payload above before dispatching it. Live monitor Resume Suspended Flow dialog with a waiting stack ID, the order.approved event signal, an approved true JSON payload, and the Dispatch Signal button After dispatch, open Live Events, filter to step:complete, and expand arrangeShipping. The captured completion event contains status: "confirmed", the tracking ID, and approvedBy: "[email protected]". The run is COMPLETED, and the original webhook-resumed checkout remains visible alongside it. Durable checkout live monitor showing a completed dashboard-resumed execution and its expanded arrangeShipping completion event with confirmed status, tracking ID, and dashboard operator approval

7. Rejections, Timeouts, and Saga Rollbacks

What happens if the reviewer rejects the order, or the 24-hour approval window expires?

Scenario A: Order Rejected by Reviewer

First submit another order with orderId: "order-5002" using the checkout endpoint. Then send an approval payload with approved: false:
  1. The workflow resumes at arrangeShipping.
  2. arrangeShipping sees order.approved !== true and explicitly refunds and releases the persisted provider IDs before throwing ApprovalRejectedError.
  3. This cleanup runs in reverse business-operation order:
    • refund(paymentId) is called.
    • release(reservationId) is called.
  4. The fake services have refunded the payment and released stock. The API returns 422 for rejection. Because a fresh process does not reconstruct earlier compensation handlers, the example performs this post-resume cleanup explicitly. Production provider operations must be idempotent for recovery attempts.
The monitor distinguishes delivery of the reviewer’s decision from business approval: the wait completes because a signal arrived, but arrangeShipping fails because that signal rejected the order. The run turns red while earlier completed operations remain visible. Refund and stock release are explicit service calls inside the continuation, not separate nodes in this view. Durable checkout monitor showing a reviewer-rejected execution marked failed, a completed approval wait, and a failed arrangeShipping row beside the earlier successful executions

Scenario B: SLA Expiration

If no approval webhook is received before timeout: '24h' elapses:
  1. Any late resume attempt triggers ExpiredWaitException.
  2. The runnable example returns 410 Gone for a late approval.
  3. The demo does not run an expiration sweeper. Production systems need a scheduled reconciliation job to expire abandoned orders and refund or release their persisted provider IDs; declaring a timeout alone does not perform that cleanup.

8. Workflow Versioning and In-Flight Migrations

Long-running workflows that wait days or weeks for external signals often outlive software deployments. What happens if your team refactors the checkout workflow while 200 orders are suspended in PostgreSQL? Siglo provides declarative version migrations via .version() and migrations:

9. Deterministic Testing with @siglo/testing

Durable workflows should be tested deterministically without waiting 24 hours or starting real database containers. Use @siglo/testing with createWorkflowHarness to test approval without waiting for the timeout:

10. Operational Best Practices

When operating durable workflows in production:
  1. Correlation Key Uniqueness & Scoping: Prefix correlation keys with domain namespaces (e.g., checkout:v1:${orderId}). Never correlate on mutable user attributes.
  2. PostgreSQL Connection Sizing: Each resumeWorkflow invocation briefly uses a PostgreSQL connection for advisory locking and OCC verification. Maintain a dedicated pool for durable state distinct from transactional business logic.
  3. Sweeping Expired Waits: waitForEvent({ timeout }) sets expires_at in the database. Schedule a periodic job (SELECT * FROM siglo_wait_records WHERE status = 'WAITING' AND expires_at < NOW()) via @siglo/scheduler or cron to reconcile abandoned orders.
  4. Interactive Dashboard Security: Remote resumption via the DevTools UI (allowRemoteResume) is intended for local staging and debug environments. Restrict access using auth.adminKey and network policies in production.

Summary & Next Steps