Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/batch-pair-chunk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Commit the batched fan-out's pre-claimed inline `[step_created, step_started]` pairs in their own leading chunk, ahead of the plain step/wait creates, so the write that gates the inline bodies stays small and commits faster. The pairs now fold only with two or more inline steps (a lone inline step keeps the lazy start), and a lone plain create beside them takes the single write.
2 changes: 1 addition & 1 deletion docs/content/docs/v5/changelog/batched-event-writes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The contract:

**Per-chunk continuation.** Each chunk's follow-on work starts the moment **that chunk** commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation: trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged.

**Pre-claimed inline pairs.** When the fold engages and has company for them (at least two inline steps, or one plus other batchable events), the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and any trailing chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step with nothing else to batch keeps the optimistic lazy-start path, whose claim overlaps the body.
**Pre-claimed inline pairs.** When the fold engages with at least two inline steps, the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain `step_created` and `wait_created` chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 32-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step keeps the optimistic lazy-start path (one row, whose claim overlaps the body) even when eager creates batch beside it: the pairs share no round trip with those creates, so only two or more inline steps make a pair chunk worth the trade. A plain partition of exactly one `step_created` or `wait_created` beside the pairs is written through the ordinary single path rather than a one-row batch, and its queue message still waits for that write.

Per-event `409`s are tolerated the same way the single path tolerates `EntityConflictError` (a concurrent delivery already created the entity); any other per-event failure fails the suspension write the way a single-path rejection would. A batch carrying a `step_started` (that is, any batch with inline pairs) is **not** retried in-process on a transport blip: a pair's `409` cannot be told apart from the caller's own earlier attempt having committed it, so recovery goes through queue redelivery instead, where the step's ownership stamp routes it back to the same invocation.

Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/runtime/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,13 +398,14 @@ describe('getInlineOwnershipLeaseSeconds', () => {
describe('pre-claimed inline pairs fit one batch chunk', () => {
it('keeps two rows per inline step inside MAX_BATCH_FANOUT_EVENTS', () => {
// The suspension fold folds each lazy-inline step into an adjacent
// [step_created, step_started] pair, and the inline slice sorts to the
// front of the batch — so every pair lands in the FIRST chunk exactly
// while two rows per inline step fit inside one chunk.
// [step_created, step_started] pair and commits the pairs in chunk(s)
// of their own, ahead of the plain creates — so every pair lands in ONE
// leading chunk exactly while two rows per inline step fit inside one
// chunk, and the inline bodies gate on a single small commit.
//
// Past that, pairs spill into a trailing chunk. `handleSuspension` gates
// its return on every pair-carrying chunk so that degrades safely, but a
// spilled pair costs the caller its claim/body overlap for no reason.
// Past that, pairs spill into a second pair chunk. `handleSuspension`
// gates its return on every pair-carrying chunk so that degrades
// safely, but the bodies then wait for two commits instead of one.
// Raising MAX_MAX_INLINE_STEPS therefore has to raise the chunk cap with
// it (and re-check the server's per-batch transaction budget).
expect(2 * MAX_MAX_INLINE_STEPS).toBeLessThanOrEqual(
Expand Down
Loading
Loading