Skip to main content

Sliding Window

View Markdown
TLDR

Keep exactly windowSize child Workflows running at all times — each completion signal triggers the next record to start immediately. Use this when your record set is arbitrarily large, you need bounded concurrency to protect downstream systems, and you want higher throughput than a sequential Batch Iterator provides.

Overview

The Sliding Window pattern maintains a fixed-size pool of concurrently running child Workflows. As each child completes it signals the parent, which immediately starts a replacement — keeping the concurrency level constant and progressing at the rate of the fastest processor. Continue-as-New prevents the parent's history from growing without bound.

Problem

The Batch Iterator processes records sequentially — the overall throughput is limited by the slowest record in each page. The Fan-Out pattern starts all children at once, which can overwhelm downstream systems when the record set is large.

You need a way to process an arbitrarily large record set with bounded concurrency, maximum throughput within that bound, and protection against history bloat.

Solution

The parent Workflow keeps a live count of in-flight children (active) and runs a single loop that starts a child whenever a slot is free. The first windowSize slots are free, so those children start immediately; after that, a backpressure condition blocks each start until an in-flight child signals completion and frees a slot. Each child processes one record and, when finished, signals the parent, which decrements active and starts the next record's child.

Continue-as-New is called after the parent has started windowSize children. Because child Workflows have stable Workflow IDs and Continue-as-New preserves the parent's Workflow ID, children started by a previous run can still signal the current run. The parent carries active into the next run so it knows how many carried-over children will still signal it.

The following describes each step in the diagram:

  1. The parent Workflow starts with a list of record IDs and a configured windowSize.
  2. It starts the first windowSize children concurrently, one per record. Each child reads the parent's Workflow ID from its own Workflow metadata, so it knows where to signal.
  3. As each child completes, it sends a completion signal to the parent.
  4. The parent receives the signal, decrements its in-flight counter (active), and starts the next child (the next record in the list).
  5. After starting windowSize children in total, the parent calls continueAsNew with the updated start index. The window slides forward without gaps because the parent's Workflow ID is preserved across runs.
  6. Children from previous runs that have not yet signalled will find the new run when they send the signal, because the parent Workflow ID remains the same.

Implementation

The following examples show how each SDK implements the Sliding Window pattern.

// workflows.ts
import {
ApplicationFailure,
ParentClosePolicy,
condition,
continueAsNew,
defineSignal,
getExternalWorkflowHandle,
proxyActivities,
setHandler,
startChild,
workflowInfo,
} from "@temporalio/workflow";
import type * as activities from "./activities";
import { COMPLETION_SIGNAL, TASK_QUEUE, WINDOW_SIZE, type SlidingWindowInput } from "./shared";

const { processRecord } = proxyActivities<typeof activities>({
startToCloseTimeout: "30 seconds",
});

export const completionSignal = defineSignal<[string]>(COMPLETION_SIGNAL);

// Child Workflow: processes one record and signals the parent on completion.
export async function recordProcessorWorkflow(recordId: string): Promise<void> {
await processRecord(recordId);
// Read the parent's Workflow ID from context. It is stable across the parent's
// Continue-as-New runs, so signaling by Workflow ID (no run ID) always reaches
// the current run. Ignore NOT_FOUND — the parent's final run may have completed.
try {
const parent = getExternalWorkflowHandle(workflowInfo().parent!.workflowId);
await parent.signal(completionSignal, recordId);
} catch (err) {
if (!(err instanceof ApplicationFailure && err.type === "NOT_FOUND")) throw err;
}
}

// Parent Workflow: maintains a fixed window of concurrent Child Workflows and calls
// Continue-as-New after dispatching windowSize children so history stays bounded.
export async function slidingWindowWorkflow(input: SlidingWindowInput): Promise<number> {
const { recordIds, windowSize = WINDOW_SIZE, startIndex = 0 } = input;
const parentId = workflowInfo().workflowId;
// Total records completed across all runs, carried over via Continue-as-New.
let totalProcessed = input.totalProcessed ?? 0;
// Children started in this run; triggers Continue-as-New once it hits windowSize.
let dispatched = 0;
// Live in-flight count: +1 per start, -1 per completion signal, carried across runs.
let active = input.active ?? 0;

setHandler(completionSignal, () => {
active--;
totalProcessed++;
});

// Slide the window: keep it full, starting one child per free slot. The first
// (windowSize - active) slots are already free, so those children start without
// waiting; after that, each start waits for an in-flight child to free a slot.
let nextIndex = startIndex;
while (nextIndex < recordIds.length) {
// Backpressure: block until the window has a free slot.
await condition(() => active < windowSize);
await startChild(recordProcessorWorkflow, {
args: [recordIds[nextIndex]],
workflowId: `${parentId}/record-${recordIds[nextIndex]}`,
taskQueue: TASK_QUEUE,
parentClosePolicy: ParentClosePolicy.ABANDON,
});
nextIndex++;
dispatched++;
active++;

// Once this run has filled the window with fresh children, Continue-as-New so
// history stays bounded. Carry active so the next run knows how many children
// will still signal it.
if (dispatched >= windowSize) {
await continueAsNew<typeof slidingWindowWorkflow>({ recordIds, windowSize, startIndex: nextIndex, totalProcessed, active });
}
}

// Wait for all remaining in-flight children to complete.
await condition(() => active === 0);
return totalProcessed;
}

Best practices

  • Preserve the parent Workflow ID across Continue-as-New. The parent's Workflow ID is stable across continueAsNew runs — do not generate a new one. Children read the parent's Workflow ID from their own Workflow metadata (workflowInfo().parent in TypeScript, workflow.info().parent in Python, workflow.GetInfo(ctx).ParentWorkflowExecution in Go, Workflow.getInfo().getParentWorkflowId() in Java) rather than receiving it as an argument, then signal by Workflow ID (with no run ID) so they always reach the current run.
  • Use PARENT_CLOSE_POLICY_ABANDON on child Workflows. This lets children that were started by a previous run complete normally even after the parent has continued as new.
  • Size the window conservatively at first. Each in-flight child counts toward the 2,000 unfinished-actions limit for the parent. A window of 50–200 is a reasonable starting point depending on child duration and downstream capacity.
  • Pass only IDs (not full records) to child Workflows. Workflow inputs are stored in event history. Keep them small.
  • Carry minimal state into continueAsNew. Pass windowSize, startIndex, the live in-flight count (active), a running totalProcessed, and the record ID list (or a reference to it). Do not accumulate results in the parent — collect them out-of-band if needed.

Common pitfalls

  • Losing signals across Continue-as-New. If a child signals before the parent's new run has registered the signal handler, the signal can be buffered and delivered correctly — Temporal buffers signals for existing Workflow IDs. However, ensure the signal handler is registered before any await, not conditionally.
  • Race between CAN and remaining signal draining. After continueAsNew, the new run must handle signals from children started by the previous run. Pass startIndex (the next unstarted record) and active (the live in-flight count at the moment of CAN) to the new run so it knows how many carried-over children to expect signals from, without re-starting them. The new run folds active in with +=, so a completion that arrives before run() executes is still counted correctly.
  • Thundering herd on startup. Starting hundreds of children simultaneously causes a burst of Activity polls. Ramp up the window gradually or use the Batch Iterator if rate limiting is more important than throughput.