Claim proved: waiting costs one database row, not a thread, a timer or a container. 100 000 documents in flight consume zero compute while waiting.
Run it:
FLOWX_POSTGRES_CONNECTION="Host=localhost;Port=5432;Database=postgres;Username=postgres" \
dotnet run --project samples/pollingIt needs a database, and it needs the timer index on it. That is not incidental — see §4.
This page carried a warning box saying the sample had no code and the DSL it printed did not
exist. Both halves are now false: PollUntil and the ISO-8601 Backoff overloads are on
IFlowBuilder and Backoff, samples/polling is an application, and tests/Polling.Tests runs
it. Two things it printed were wrong rather than merely absent, and they are corrected below
rather than implemented as written — §3 and
§7.
A third-party OCR service accepts a document and returns a job id. Results take between 30 seconds and 4 hours. The usual implementations are all bad:
| Common approach | What goes wrong |
|---|---|
while (!done) await Task.Delay(5s) |
a thread and a container per document; lost on deploy |
| Hangfire recurring job scanning a table | polls everything, hot-loops, scales badly |
| Webhook only | breaks when the provider's webhook fails, with no fallback |
[Flow("document.process", Version = "1.0.0", Profile = ExecutionProfile.Durable, Owner = "documents")]
[HttpTrigger("POST", "/api/v1/documents", Idempotent = true)]
[FlowDeadline("PT6H")]
public sealed partial class ProcessDocumentFlow : Flow<ProcessDocument, DocumentResult>
{
protected override void Define(IFlowBuilder<ProcessDocument, DocumentResult> flow) => flow
.Step<UploadToOcr>().CompensateWith<CancelOcrJob>()
.PollUntil<CheckOcrStatus>(
until: ctx => ctx.Get<OcrStatus>().IsTerminal,
interval: Waits.OcrPolling, // Backoff.ExponentialJitter("PT5S", "PT5M")
timeout: Waits.OcrBudget) // TimeSpan.FromHours(4)
.OnTimeout(f => f
.Step<EscalateToManualReview>()
.Fail(DocumentErrors.NotReadInTime()))
.Step<ExtractFields>()
.Emit<DocumentProcessed>(ctx => new DocumentProcessed(
ctx.Input.DocumentId, ctx.Get<ExtractedFields>().Values.Count))
.Return(ctx => new DocumentResult(
ctx.Input.DocumentId, ctx.Get<ExtractedFields>().Values.Count));
}PollUntil is a durable timer loop: between attempts the instance is suspended — no lease
held, no memory, no thread anywhere in the cluster.
The schedule is a named property rather than a literal at the call site, for the reason
samples/workflow's Waits gives: a duration is a business decision and belongs where it can be
read without opening a flow. It is jittered, which is not decoration — a hundred thousand
documents accepted in the same minute and parked on the same undecorrelated schedule wake in the
same second, and the provider that could not keep up with the originals gets the retries as a
spike.
sequenceDiagram
autonumber
participant FE as Flow Engine
participant J as Journal
participant SW as Timer sweep
participant OCR as OCR provider
FE->>OCR: upload → jobId
FE->>J: commit step 0
loop poll with exponential backoff
FE->>OCR: GET status
FE->>J: commit attempt n, under scope n
FE->>J: state=Suspended, wake at T+interval
FE->>FE: release lease, return context to pool
Note over FE: 0 threads · 0 memory · 0 leases
SW->>FE: the row is due → any node leases the instance
FE->>FE: is the last answer terminal? if so, leave the loop
end
FE->>FE: extract fields, emit, complete
| In flight | Compute cost | Storage cost |
|---|---|---|
| 1 document | 0 while waiting | 1 instance row, plus 1 row per attempt |
| 100 000 documents | 0 while waiting | 100 000 instance rows (~40 MB), plus their attempts |
The storage column is two numbers rather than one, and the second is the honest correction to
what this table used to say. An attempt is a committed flow_step row — that is what makes the
attempt count, the remaining budget and the resume position survive the node that started them —
so a document polled fifty times costs one instance row and fifty step rows. Retention is what
bounds that, not the construct.
The compiled plan for the flow above:
StepNode.ForCapability(0, Descriptors.Step0, Descriptors.Step0Compensation), // ocr.upload
StepNode.ForPoll(1, Waits.OcrPolling, Waits.OcrBudget, satisfiedTarget: 5),
StepNode.ForCapability(2, Descriptors.Step2), // ocr.status
StepNode.ForCapability(3, Descriptors.Step3), // document.escalate
StepNode.ForFail(4),
StepNode.ForCapability(5, Descriptors.Step5), // document.extract
StepNode.ForEmit(6, "document.processed"),There is no backward target in it, and that is not an accident of this flow. Every target in
every compiled plan points strictly forward, which is the entire termination argument for a step
loop that has no iteration cap by design. A poll keeps it by re-entering the contiguous span
after its own node — index 2, once per attempt — exactly as a ForEach re-enters its body. What
replaces "a step runs at most once" is "the number of passes is bounded by a value fixed before
the first one", which is ForEach's own argument with a duration in place of an element count.
Three numbers are implied rather than stored. The body is at Index + 1, always, so the node
carries no target for it. The escalation begins at Index + 2, because the body is exactly one
step. satisfiedTarget is the one number that has to be written down: where control goes when
the predicate holds, one past the escalation block. That is StepNode.ForAwaitSignal's convention
read from the other side and for its reason — the escalation is the block that can be
contiguous, because the other path out of a wait is the rest of the flow.
A poll with no .OnTimeout carries no target at all, rather than one equal to the index after
the body. The engine reads the absence as "there is nowhere for this timeout to go" and ends the
flow with flow.poll_not_satisfied, unwinding the completed compensable steps behind it.
The body is one capability and cannot be a block. PollUntil returns IAwaitBuilder, which
has no CompensateWith — so a compensable step inside a loop that runs an unbounded number of
times, and the unbounded compensation stack that implies, is unrepresentable rather than
discouraged. "Check the status, and if it changed, record it" is two steps and goes after the
loop.
An attempt commits a row under its own scope, which is the attempt number:
select s.scope, s.step_id, s.capability_id, s.outcome
from flowx.flow_step s where s.instance_id = '019fbd…' order by s.sequence;
-- | 0 | ocr.upload | Success
-- 0 | 2 | ocr.status | Success
-- 1 | 2 | ocr.status | Success
-- 2 | 2 | ocr.status | SuccessStepScope is unchanged and is doing the job it was added for: (instance, step) is not unique
for a poll for exactly the reason it is not unique for a ForEach, and an append-only table
cannot overwrite a row.
The poll node itself commits nothing, and everything it needs on resume is read back off those rows:
| What it needs | Where it comes from |
|---|---|
| which attempt is next | the lowest scope with no successful row at step 2 |
| when that attempt is due | flow_instance.wake_at, written by the commit that wrote Suspended |
| when the budget runs out | attempt 0's nondeterminism → utcNow, plus the declared timeout |
| whether the loop is already over | the until predicate, over the restored state bag |
The third row is worth reading twice. The budget is measured from the instant the first attempt
read, not from the timestamp the store put on the row. NondeterminismCapture.UtcNow is
FlowContext.UtcNow captured for replay — the engine's own IClock, the same one that decides
whether the next attempt is due. flow_step.committed_at is PostgreSQL's now(): a second
clock, on a second machine. Bounding a loop by the difference between two clocks is the ambient
read FLOWX1007 forbids a capability, performed by the engine instead — and under a test clock
the difference is four hours rather than four milliseconds.
The fourth row is the one that is easy to leave out. A poll node commits no row, so an instance
resumed past a completed poll arrives at the node again — and without asking the predicate
first, every touch of a finished document would be one more billable call to the provider. What
stops it is the same question that ended the poll:
PollingTests.AResumePastAFinishedPollDoesNotPollAgain.
A failing attempt fails the flow rather than counting as "not yet". "The job is not finished"
is a successful call with an unfinished answer; "the status endpoint refused" is a failure, and
treating it as the former would poll a broken dependency for four hours and then report a timeout
for something that was never one. Tolerating a transient fault is a Retry policy on the
capability — a different declaration, in a different place.
This page originally printed:
.OnTimeout(f => f.Step<EscalateToManualReview>())That block escalates and then falls through to document.extract, which would read fields
out of a job that never produced any. Both paths out of a poll rejoin at the step after the
block, exactly as the two arms of a .When do; a block that should end the flow has to say so.
It is the same correction samples/workflow's offer.accept carries, for the same reason.
Ending it with a failure is also what closes the OCR job. ocr.cancel went on the compensation
stack four hours and fifty attempts earlier, on a node that has no memory of it by the time the
budget runs out — and the resumed flow rebuilds the stack from the journal's committed rows,
which is the same frontier scan that decides which attempts to skip.
PollingTests.ABudgetThatRunsOutEscalatesAndCancelsTheJob measures all three effects: the
instance is Failed, the document is in the review queue, and the job is closed.
document.process declares Durable, and a durable flow on a host that registered no journal
and no lease store is refused with flow.durability_not_configured before its first step.
That is samples/workflow's §4, unchanged.
What is new here is the second half. A host that registers a journal and no ITimerIndex polls
each document exactly once. The instance parks correctly, records the instant it is next due,
and nothing ever comes back for it — which is not a hang and not an error, it is a document that
waits for [FlowDeadline("PT6H")]. AddFlowXPostgres registers all four contracts, and
Program.cs also puts the flow in the FlowCatalog, because an instance a sweep finds is a row
carrying a flow id and a version and nothing else. Without that registration FlowTimerScan
counts the document NotRunnable — the honest answer to "this node was not deployed with that
flow", and indistinguishable from outside from a provider that never answers.
FLOWX1017 is what makes the profile non-negotiable. A poll parks between attempts and reads
which attempt it is on out of the journal that parked it; outside one it has neither anywhere to
record when the next call is due nor any way to count the ones already made. Change Profile to
Ephemeral and the build fails naming the construct.
$ curl -i -X POST :5199/api/v1/documents -H 'Idempotency-Key: doc-001' \
-H 'Authorization: Bearer intake-token' \
-d '{"documentId":"doc-1","contentType":"application/pdf","pages":12}'
HTTP/1.1 202 Accepted
{"instanceId":"019fbd86-b1be-7398-a9bb-b90a96c6774c","status":"suspended"}
202 with no awaiting array, and the absence is the interesting part. That array lists the
signals a caller could deliver to continue the instance, and a poll is not waiting for anybody —
it is waiting for a clock. There is nothing to address, so the generator publishes no signal
route for this flow, which is exactly the difference between this sample and
samples/workflow's.
select state, wake_at, wake_step_id, wake_scope from flowx.flow_instance where instance_id = '019fbd86-…';
-- Suspended | 2026-08-02 09:14:31.221+00 | 1 | 0 ← step 1 is the poll, scope 0 is attempt 0
select count(*) from flowx.flow_lease where instance_id = '019fbd86-…' and expires_at > now();
-- 0One row, no lease, and a scope that says which attempt the wait belongs to. A flow that polled twice would park at the second with the same step id and a different scope — which is why the wake carries both.
To watch it rather than read about it, wind the two demonstration knobs down:
FLOWX_POSTGRES_CONNECTION="…" \
FLOWX_SAMPLE_TIMER_SCAN="00:00:01" \
FLOWX_SAMPLE_OCR_PER_PAGE="00:00:02" \
dotnet run --project samples/pollingFLOWX_SAMPLE_OCR_PER_PAGE is a property of the in-memory OCR stand-in and reaches nothing the
flow declares. FLOWX_SAMPLE_TIMER_SCAN is a host setting, so it never reaches the plan or the
manifest either — the two durations the flow declares stay build-time constants, which is what
keeps the manifest's timeout field populated. samples/workflow's §2.0 is the account of what
happens when a declared wait is made overridable through the environment, and it is why neither
of these is one.
A wait is a lower bound and never an upper one. FlowTimerScan runs on
FlowXOptions.TimerScanInterval — ten seconds by default, jittered — so a five-second gap
elapses in five to fifteen. That is the same promise a scheduled trigger makes and the only one a
sweep can keep.
The test this page originally printed cannot be written, and the reason is the platform's rather than this sample's:
var host = FlowTestHost.For<ProcessDocumentFlow>() // no such overload
.WithVirtualTime() // no such method
.Substitute<CheckOcrStatus>(Sequence.Of(Pending, Pending, Pending, Completed))
.Build();FlowTestHost cannot run a Durable flow at all. Every RunAsync on it reaches the
FlowEngine.ExecuteAsync overload that passes durable: null, and the engine refuses a durable
plan with no instance rather than running it ephemerally — which is the correct refusal, and is
asserted rather than described by
WhyTheseTestsDoNotUseFlowTestHostTests.
PollUntil requires Durable (FLOWX1017), so a poll is exactly the shape that host refuses.
PeakLeasesHeld and Trace.PollAttempts do not exist either, and neither does Sequence.Of;
substitution is by capability id, and the clock seam is WithClock(IClock).
What replaces it is DocumentHarness — the same
stand-in tests/Workflow.Tests uses, with a timer sweep added. The engine, the plan, the pooled
context, the compensation stack, the deadline check, the lease, the fencing token, the journal
writes and FlowTimerScan are the production ones; the stores are the conformance suite's
reference implementations. The clock is virtual, and the flow is driven by moving it to the
instant the instance itself recorded rather than to one the test computed — the schedule is
jittered, so a test that restated the gap would be asserting against its own copy of Random.
[Fact]
public async Task WaitingCostsOneRowAndHoldsNoLease()
{
var harness = DocumentHarness.Create(TimeSpan.FromMinutes(30));
var started = await harness.StartAsync(ADocument(), ct);
started.IsSuspended.ShouldBeTrue();
harness.Instance.State.ShouldBe(FlowInstanceState.Suspended);
harness.Instance.Wake.ShouldNotBeNull();
harness.HoldsLease().ShouldBeFalse(); // ← the point of the sample
started.Trace.Count("ocr.status").ShouldBe(1); // ← one attempt per invocation
}The last assertion is the one that fails if the engine ever loops in-process between attempts instead of parking. Such an engine would still complete the document, still commit a row per attempt, and still pass every other test in the file — while holding a thread for four hours.
The whole class runs in milliseconds and moves the flow's clock through hours, which is the same fact the sample is claiming about a deployment.
This page also specified a webhook fast path:
.RaceUntil(
signal: r => r.AwaitSignal<OcrCompleted>(), // provider webhook, when it works
poll: p => p.PollUntil<CheckOcrStatus>(interval: Backoff.Exponential("PT30S","PT5M")),
timeout: TimeSpan.FromHours(4))"Whichever arrives first wins; the loser is cancelled."
As a fork over two suspending branches it is refused, and the reasons are pre-existing commitments rather than implementation difficulty. ADR-0058 is the record; the two that decide it:
- A fork's branches share one pooled context, so ADR-0015's limit §6.1 — an overlapping fork does not replay, because the capture taken at a branch's commit takes everything minted since the last commit including a sibling's — would apply to every instance of every flow that used it. A construct whose stated purpose is reliability cannot be the one that makes an instance unreplayable.
- "The loser is cancelled" describes threads. Both branches here are the same one row, and
the row carries one
wake_at. Two branches with two wake instants cannot both be recorded, and the engine resolves that by taking the earlier — so the poll's schedule would silently become the signal branch's too.
The idea behind it is right and the shape is wrong. A poll's park and a signal's park are the
same row; ending the wait early on a delivered signal is one call to
DurableExecution.TakeSignal from the poll's arrival path, and the honest spelling is a modifier
on the poll rather than a second construct — .PollUntil<T>(…).OrSignal<TSignal>(). That is one
wait with two ways to end it, one row, and no loser to cancel.
That modifier is now built, and it is on IPollBuilder:
.PollUntil<CheckOcrStatus>(
until: ctx => ctx.Get<OcrStatus>().IsTerminal,
interval: Waits.OcrPolling,
timeout: Waits.OcrBudget)
.OrSignal<OcrCompleted>()
.OnTimeout(f => f.Step<EscalateToManualReview>().Fail(DocumentErrors.NotReadInTime()))The instance still parks on one row carrying one wake_at; a delivery to
POST {route}/{instanceId}/signals/ocr.completed arrives at that row, and the arrival path asks
TakeSignal before it asks whether the next attempt is due. A signal arriving between two
attempts therefore ends the wait rather than parking it again — no attempt is made, the
escalation is not entered, and control continues where a satisfied predicate would have taken it.
That ending commits one row on the poll's own node, named after the signal, because the predicate
that stops a finished poll re-polling says not yet when a delivery is what ended it.
ADR-0066 is the record and
FLOWX1050 the one rule it needed;
FlowX.Postgres.Tests.PollSignalHostTests is the measurement, against a real database.
This flow does not declare one, because the OCR provider it stands in for publishes nothing.
- Change
ProfiletoEphemeral— the build fails withFLOWX1017, because a timer outside a journal has nowhere to record when it is due and a poll cannot count its own attempts. - Poll a capability that has not declared
Idempotent = true— the build fails withFLOWX1044. A poll repeats after every success, which is the stronger half of the argumentFLOWX1014makes about a retry. - Write
interval: Backoff.Exponential("PT10M", "PT30M")againsttimeout: TimeSpan.FromMinutes(5)—FLOWX1043, a warning, because that loop makes one call and then escalates and reads in a journal exactly like a provider that never answered. - Deploy mid-wait (restart every node) — every suspended document resumes, on whichever node sweeps next, with its attempt count and its remaining budget read back off its rows.
- Set
FLOWX_SAMPLE_OCR_PER_PAGEpast four hours — the budget runs out, the document goes for manual review, and the OCR job is cancelled by an unwind that spans the whole wait. - Drop the
.Failout of the.OnTimeoutblock and watchdocument.extractrun against a job that produced nothing. §3 is why it is there.