Durable Timer
Durable Timer is the Triggers platform component for workflow delays that must survive restarts and deployments. A flow sends an event into a Timer, the dedicated Timer service stores it, and Notify Triggers continues the released flow when the due time is reached.
It is a generic platform capability. It does not know what a prize, notification, payment, or campaign means.
Why it exists
A normal application timer ties the delay to one process. A deployment, crash, or ownership change can lose the wait or run it twice without a durable record. Moving the wait into every business service would also duplicate scheduling, recovery, and observability logic.
Durable Timer provides one execution contract for all SDK-enabled services:
| Capability | Behavior |
|---|---|
| Durable wait | Waiting state is stored in NATS JetStream, not in process memory. |
| Event pass-through | The exact event received on Start is saved and emitted on Time reached. |
| Restart recovery | Native schedules are reconciled after restart; overdue work becomes eligible immediately. |
| Flow-native retry | A Condition may route to Retry and rearm the same Timer lineage. |
| Multiple Timers | A released flow may use Timer nodes on different branches or in sequence. |
| Business isolation | Actions keep their ordinary request and result contracts. The SDK owns Timer control metadata. |
| Version safety | Every occurrence resumes the released runtime version that created it, not the current Studio draft. |
Use Durable Timer for workflow delays that require durable recovery. Keep short, action-local retries inside the action's normal retry policy.
What a flow author sees
A Timer has two inputs and one output:
Startcreates a new durable occurrence from the current event.Retryrearms that same occurrence after a downstream Condition chooses its retry branch.Time reachedcontinues the flow with the event saved atStart.
One-shot wait
Retry until a condition is met
The Check Prize action is not changed for this flow. It receives its normal request and returns its normal result. The SDK evaluates the Condition and, if the expression matches, publishes a Retry command that references the active Timer. If the expression does not match, the elapsed path completes. In v1 a Condition has one explicit output, so one Condition cannot both retry and send another normal branch onward. Explicit true/false branches are future work.
On the next due delivery, the flow again receives the event originally saved at Start. The Check Prize result controls the branch; it does not replace the saved event.
How data schemas move through a flow
Schemas follow the data edges of the released flow. They are not always the schema of the original source event.
| Node | Data schema available after the node |
|---|---|
| Source | The source event schema. |
| Durable Timer | The schema of its Start input. Timer saves and later resumes the same event. |
| Service | The schema it received. A service handoff does not by itself transform the event. |
| Action | The action's manifest result_event.fields. This is the action result, not the request sent to the action. |
| Mapping | The fields declared by that mapping's projection. |
| Condition | No new data schema. It reads the schema from its direct upstream node. |
Retry is a control edge, not a data edge. It therefore does not participate in schema lineage. A Timer retry schedules the immutable event captured at its Start input; it does not schedule the result of the action that led to the Condition.
When a flow is released, the runtime writes the resolved direct-upstream fields into config.input_fields on every Condition, Mapping, and Action. The Go and Python SDKs use that immutable configuration for local validation and evaluation. Only releases made before this contract may fall back to absent input fields for compatibility.
Who owns each part
| Owner | Responsibility |
|---|---|
| Studio | Timer configuration, graph ports, authoring feedback, and layout. |
| Notify Triggers | Flow authority, release validation, runtime compilation, initial routing, resume processing, and downstream continuation. |
| Triggers SDK | Private Timer lineage, command construction, service-side Condition evaluation, and removal of control metadata before invoking actions. |
| Business action | Domain behavior and idempotent external side effects. |
| Durable Timer service | Occurrence state, immutable start snapshot, retry count, wake scheduling, reconciliation, terminal archive, and resume publication. |
| NATS JetStream | Durable command, state, snapshot, schedule, wake, terminal, and resume storage. |
The component that is currently executing the graph publishes the Timer command. Notify publishes it when the Timer or terminal feedback is reached in a coordinator-owned source/control path. An SDK publishes it when the same point is reached after a business-service handoff. Business services use their existing shared NATS connection, and Notify does not relay SDK-originated commands.
Release and execution model
The Studio graph is an authoring model. During release, Notify validates the Timer contract and compiles a deterministic internal resume boundary behind each Time reached edge.
Authors do not create or configure that internal boundary. An occurrence is identified by its released flow version, root execution, Timer node, stable edge lineage, and retry generation. This identity lets Notify and Durable Timer reject stale or unrelated control messages.
Authoring contract
Studio and release validation enforce the same rules:
| Rule | Contract |
|---|---|
| Delay | 1,000 to 2,592,000,000 milliseconds, inclusive |
| Maximum retries | 0 to 100 |
Start input | Exactly one incoming edge |
Retry input | Zero or one incoming edge |
Time reached output | Exactly one outgoing edge |
| Retry source | A Condition reached from this Timer's elapsed path |
| Stable identity | Timer control edges require unique, stable edge IDs |
| Enablement | The top-level node disabled field controls whether the Timer executes |
Ordinary graph cycles remain invalid. The supported cycle is a downstream Condition returning to the Retry input of the same Timer.
Every elapsed branch must have one unambiguous terminal owner. A branch either hands work to a service or output, completes the Timer, retries it, or fails it. Release validation rejects ambiguous feedback paths.
Runtime lifecycle
No process sleeps for the configured delay. NATS 2.14 native scheduling drives the wake. Schedules operate at whole-second precision, so a delay is rounded up to the next whole-second boundary. An early wake is rejected.
Here, Owner is Notify while execution remains in a coordinator-owned path and the service SDK after a service handoff.
Delivery and recovery guarantees
- The start snapshot and waiting state are durable before
Startis acknowledged. - Startup and periodic reconciliation recreate the deterministic wake for each waiting occurrence.
- An overdue occurrence is eligible immediately after recovery.
- Compare-and-set updates, fences, deterministic IDs, and deduplication reject stale or duplicate control work.
- Notify records resume progress in a MongoDB outbox before downstream publication.
- Terminal state is archived before active state is removed.
- Shutdown withdraws readiness and drains work; unfinished work is returned for redelivery.
The platform guarantee is at least once, not exactly once. NATS and MongoDB cannot commit atomically with each other or with a business system. Any action that can create an external side effect must therefore be idempotent.
Operational scope
Durable Timer is default-off and enabled centrally for the environment. Any valid released flow may contain multiple Timer nodes and may have multiple released versions. Business services do not carry a Timer flow list.
The supported behavior includes:
- one-shot waits;
- retry followed by completion;
- retry-limit exhaustion;
- sequential Timers;
- restart with overdue recovery;
- independent released flows using the same Durable Timer service.
Before production use, validate multi-replica availability, capacity, restore behavior, and the target environment's deployment configuration.
Coordination between different flows that operate on the same business entity is outside the current contract. Per-flow fairness and protection from noisy-neighbor load are backlog work.
Trust and data boundaries
V1 uses the platform's shared plain NATS 2.14 bus. It adds no Timer-specific TLS, credentials, ACL, certificate, or signing-key configuration. Access to the internal NATS network is the trust boundary.
Private Timer context is structurally validated and stripped before business handlers and public Kafka, HTTP, or output-topic boundaries. This protects business contracts from Timer internals, but it is not publisher authentication.