Appearance
Transactional Workflow
This tutorial submits a workflow in the same Postgres transaction as your business data.
The Two-Writes Problem
A typical background job enqueue is two writes:
- Write your business data (INSERT into
orders). - Enqueue the job (
Enqueue(processOrderJob, ...)).
Those writes use separate transactions. A crash between them leaves one write committed. The crash loses the other write.
Why Enqueue-After-Commit Can Lose Work
go
// Scenario: crash between commit and enqueue
tx.Commit(ctx) // ← order row committed
// CRASH HERE // ← process dies
client.Enqueue(...) // ← never runsThe order row exists in the database. The enqueue did not create the processing job. The order is stuck. No one knows to process it.
Why Enqueue-Before-Commit Can Create Phantom Work
go
// Scenario: rollback after enqueue
client.Enqueue(...) // ← job enqueued
// ... something fails
tx.Rollback(ctx) // ← order row rolled backThe job exists. The job will start. The order row does not exist. The handler processes a non-existent order.
Solution: SubmitTx
SubmitTx inserts the workflow run in the same transaction as your business data:
go
package main
import (
"context"
"log"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/wafer-labs/cyclone"
"your-app/workflows"
)
func CreateOrder(ctx context.Context, pool *pgxpool.Pool, client *cyclone.Client, req OrderRequest) (int, cyclone.RunID, error) {
tx, err := pool.Begin(ctx)
if err != nil {
return 0, "", err
}
defer tx.Rollback(ctx)
// Business write:
var orderID int
if err := tx.QueryRow(ctx,
`INSERT INTO orders (user_id, total, status)
VALUES ($1, $2, 'pending')
RETURNING id`,
req.UserID, req.Total,
).Scan(&orderID); err != nil {
return 0, "", err
}
// Workflow submitted in the same transaction:
run, err := workflows.FulfillOrderWorkflow.SubmitTx(ctx, client, tx,
workflows.FulfillInput{OrderID: orderID},
)
if err != nil {
return 0, "", err
}
// Both commit, or neither commits.
if err := tx.Commit(ctx); err != nil {
return 0, "", err
}
return orderID, run.ID(), nil
}If tx.Commit fails, the database keeps neither the order row nor the workflow run. If you call tx.Rollback, the database keeps neither the order row nor the workflow run.
Commit Before You Read
The handle from SubmitTx exists before the commit. The run does not exist before the commit. Handle operations use the client pool. The operations return cyclone.ErrRunNotFound until the commit. Do not wait inside the transaction. Commit first. Then read:
go
if err := tx.Commit(ctx); err != nil {
return err
}
status, err := run.Wait(ctx)Cyclone sends no notification inside your transaction. Workers find the committed run within one poll interval. See Transactional Enqueueing to wake them at once.
Solution: EnqueueTx for Standalone Jobs
The same pattern with EnqueueTx:
go
tx, err := pool.Begin(ctx)
if err != nil { return err }
defer tx.Rollback(ctx)
// Create a payment record:
var paymentID int
if err := tx.QueryRow(ctx,
`INSERT INTO payments (user_id, amount) VALUES ($1, $2) RETURNING id`,
userID, amount,
).Scan(&paymentID); err != nil {
return err
}
// Enqueue charge processing in the same tx:
charge, err := ChargeStep.EnqueueTx(ctx, client, tx, ChargeInput{PaymentID: paymentID})
if err != nil { return err }
if err := tx.Commit(ctx); err != nil { return err }
// The run exists now. Result waits for the charge.
receipt, err := charge.Result(ctx)ctx.CompletionTx() Inside a Handler
Inside a handler, ctx.CompletionTx() is the completion transaction. This transaction commits the state change of the step. Use it to write business mutations atomically with the step's completion:
go
var ProcessOrder = cyclone.NewAction("process_order",
func(ctx cyclone.Context, in ProcessInput) error {
_, err := ctx.CompletionTx().Exec(ctx, `
UPDATE orders
SET status = 'processing', processed_at = now(), cyclone_run_id = $1
WHERE id = $2 AND status = 'pending'`,
ctx.RunID(), in.OrderID)
return err
},
)This UPDATE commits with the step's completion. If the worker crashes before commit, both the UPDATE and the step completion roll back. The WHERE status = 'pending' clause makes a retry a no-op after a successful write.
The Idempotency Key: (runID, node)
When you write through ctx.CompletionTx(), use (ctx.RunID(), ctx.Node()) as the deduplication key:
go
_, err := ctx.CompletionTx().Exec(ctx, `
INSERT INTO order_events (run_id, node, order_id, event_type, created_at)
VALUES ($1, $2, $3, 'processed', now())
ON CONFLICT (run_id, node) DO NOTHING`,
ctx.RunID(), ctx.Node(), in.OrderID)The (run_id, node) pair is stable across all retries and lease reclaims for the same step. If you include attempt, every retry looks like a new event.
Simulate a Crash with cyclonetest
go
func TestCrashBeforeCommit(t *testing.T) {
env := cyclonetest.New(t, cyclonetest.WithLeaseTTL(200*time.Millisecond))
var ProcessAction = cyclone.NewAction("process",
func(ctx cyclone.Context, in ProcessInput) error {
_, err := ctx.CompletionTx().Exec(ctx, `
INSERT INTO events (run_id, node, data)
VALUES ($1, $2, $3)
ON CONFLICT (run_id, node) DO NOTHING`,
ctx.RunID(), ctx.Node(), in.Data)
return err
},
)
env.Mux().Register(ProcessAction)
crashed := make(chan struct{})
env.KillAfter("process", func() { close(crashed) })
run, err := ProcessAction.Enqueue(context.Background(), env.Client(), ProcessInput{Data: "test"})
require.NoError(t, err)
<-crashed
status, err := run.Wait(context.Background())
require.NoError(t, err)
require.Equal(t, cyclone.RunCompleted, status.State)
// Verify the event was written exactly once (ON CONFLICT deduplicated the second run).
var count int
env.Pool().QueryRow(context.Background(),
`SELECT count(*) FROM events WHERE run_id = $1`, string(run.ID()),
).Scan(&count)
require.Equal(t, 1, count)
}Verify Atomicity
Either both the business write and the step completion commit, or neither does.
After a crash (KillAfter), the database does not have the business effect. After recovery, the database has the business effect exactly once:
- Submit the job.
- Arm
KillAfterto crash after thectx.CompletionTx()write but before commit. - Wait for the crash.
- Query the business table. The row must not be there (rolled back).
- Wait for recovery (
run.Wait). - Query the business table. The row must be there exactly once.
Next
- Transactional Enqueueing: full guide
- Exactly-Once Effects: ctx.CompletionTx() in depth
- Idempotency: the at-least-once contract