Steps
Transactional step functions for atomic operations
What Steps Do
Steps group multiple operations into an atomic unit. Either all operations in a step succeed and are committed together, or the step fails and all changes are rolled back. On replay, completed steps return their stored result instantly.
const result = await step("write-files", async () => {
await writeFile("hello.txt", "Hello from step!");
await writeFile("world.txt", "World from step!");
return { hello: "Hello from step!", world: "World from step!" };
});How Steps Work
Outside a step, each operation is individually committed to the journal. Inside a step, operations are buffered in a pending transaction. This means the journal goes from having individually committed entries to having a batch committed atomically.
Step Lifecycle (First Run):
- Snapshot VFS -- The current state of the Virtual File System is saved in memory. This is the rollback point if the step fails.
- Record
op_step_begin-- Committed to journal immediately (marks step start) - Execute step body -- All operations inside the step are buffered (journal entries + VFS changes)
- On success: Record
op_step_completewith the return value, then commit ALL buffered entries atomically - On failure: Discard all buffered entries, restore VFS from the pre-step snapshot, re-throw the error (or retry if retries configured)
Journal Structure
op_step_begin {"step": "write-files"} -- committed immediately
op_write_file null -- buffered
op_write_file null -- buffered
op_read_file "Hello from step!" -- buffered
op_read_file "World from step!" -- buffered
op_step_complete {"step": "write-files", "value": {...}} -- committed with all aboveStep Replay
On replay, when a completed step is encountered:
op_step_beginis consumed from journal, step name is verified- Tide finds the matching
op_step_completeby scanning forward - The step body re-executes, with each internal operation replaying from the journal
op_step_completeis consumed,in_stepis cleared
Retries
Steps support automatic retries on failure:
const result = await step("call-api", async () => {
const data = await someOperation();
await writeFile("result.json", JSON.stringify(data));
return data;
}, { retries: 3 });When retries is set:
- If the step body throws, the step is rolled back (VFS restored, buffered entries discarded)
- The step re-enters from
op_step_beginand the body executes again - This continues up to
retriestimes - If all retries are exhausted, the error is re-thrown
- Only the successful attempt survives in the journal
The StepOptions interface:
interface StepOptions {
retries?: number; // Maximum retry attempts on failure
}Crash Recovery
If the process crashes inside a step:
- The
op_step_beginwas committed, but the buffered operations were NOT committed - On restart, replay processes all entries before the step normally
- When
op_step_beginis reached during replay, Tide scans forward forop_step_complete - If no
op_step_completeis found: the step never completed (crash recovery)- The journal is truncated to before the
op_step_begin - Replay mode ends, execution goes live
- The step re-executes from scratch
- The journal is truncated to before the
- This is safe because the failed step's buffered entries were never persisted
Nested Steps
Nested steps are not supported. Calling step() inside another step() throws:
Nested steps are not supportedVFS and Steps
The VFS snapshot taken at step start captures the full in-memory filesystem state. On rollback:
- All VFS changes made during the step are discarded
- The VFS is replaced with a fresh MemoryFS hydrated from the snapshot
- The workflow continues with the filesystem in its pre-step state
Step Return Values
The step's return value is serialized with JSON.parse(JSON.stringify(result)) to ensure it's JSON-serializable. The serialized value is stored in the op_step_complete journal entry. On replay, this stored value is what gets returned.
Example: Chained Steps
// Step 1: Write files
const result = await step("write-files", async () => {
await writeFile("hello.txt", "Hello from step!");
await writeFile("world.txt", "World from step!");
const h = await readFile("hello.txt");
const w = await readFile("world.txt");
return { hello: h, world: w };
});
console.log("Step result:", JSON.stringify(result));
// Files persist after the step
const hello = await readFile("hello.txt");
// Step 2: Build on previous state
const count = await step("count-files", async () => {
const h = await readFile("hello.txt");
const w = await readFile("world.txt");
return { fileCount: 2, combined: h + " " + w };
});
// Step 3: Sleep inside a step
await step("timed-step", async () => {
await sleep(500);
return "done";
});Related
- Durable execution — how journaling and replay work.
- Virtual file system — how VFS snapshots work with steps.
- Workflow API — the
step()signature. - Architecture — internal transaction implementation.