Lookup node (v1)
A lookup is a synchronous, stateless request/response the engine issues mid-flow. It lets a flow branch on data the source event does not carry — for example, whether a player is currently online — without the author wiring a service-in round trip.
Stateless means no state is carried between executions — params in, result
out, nothing remembered. It does not mean side-effect-free: a lookup handler may
write. spark_bonus/create_prize is registered as both an action and a lookup
for that reason, and its sibling spark_bonus/prize_state is the read-only
counterpart — one service, one writing lookup and one reading lookup, on the
same budget. What a writing handler gives up is the action's machinery:
| Concern | Action | Lookup |
|---|---|---|
| Invocation | asynchronous dispatch | synchronous, mid-flow |
| Retry | per-action retry config | none — the flow owns re-driving it |
| DLQ | supported | none |
| Envelope on ctx | yes (idempotency key, flow run id, trace) | no — the request is {"params": …} only |
| Failure | retried, then DLQ | leaves by the failed port |
A handler that needs an idempotency key must therefore take one as a declared
param. A handler that writes also has no natural absence to report, so it
returns found: true for every completed write and an error for everything
else — the engine merges no fields when found is false.
The budget is the hard part of writing from a lookup, not the contract. A
lookup sits on the ingest path: the engine gives up after
TRIGGERS_LOOKUP_TIMEOUT_MS (200ms by default, and it cannot be raised above
1000ms), and the service bounds its own handler at one second, hardcoded. That
is a fine budget for a read and a tight one for a write. Before registering an
existing action as a lookup, cost its slowest real path — an outbound call per
item, a retry loop, a provider Retry-After, a sequence of database writes —
against 200ms, and remember that a local stack with mocks will not show you the
number that matters. A handler that overruns does not merely fail: five
consecutive timeouts trip the engine's per-(service, lookup) breaker, which is
process-wide, so one slow handler stalls that lookup for every flow and every
tenant until the cooldown elapses.
A lookup is not a runtime_capability (engine → service) and not a
data_source (studio → service, design-time). Those three words are taken and
mean different things:
| Concept | Direction | When |
|---|---|---|
runtime_capabilities | engine → service | runtime |
data_sources | studio → service | design-time |
runtime_lookups | service → engine | runtime |
Declaring a lookup
A service advertises its lookups in its manifest under runtime_lookups. Each
declares a name, its params, and its result fields — both lists of
InputField ({name, type, required}), flat scalars from the
int | bool | string | double union.
An author writes neither list. Both SDKs derive them from the handler's params type and its result type, so the declaration and the type the handler actually receives cannot drift apart:
type presenceParams struct {
PlayerID string `json:"player_id"`
TenantID string `json:"tenant_id"`
}
type presenceResult struct {
IsActive bool `json:"is_active"` // no omitempty: false is a real answer
}
triggers.Lookup(svc, "presence",
func(ctx context.Context, p presenceParams) (bool, presenceResult, error) {
// found=false means "no such record". Return an error for "I could not
// answer" — the two are different facts.
return true, presenceResult{IsActive: online}, nil
})
class PresenceParams(BaseModel):
player_id: str
tenant_id: str
class PresenceResult(BaseModel):
is_active: bool
@svc.lookup("presence")
async def presence(params: PresenceParams) -> tuple[bool, PresenceResult]:
return True, PresenceResult(is_active=online)
Define both Python models at module scope. Postponed annotations are resolved against the handler's module globals, so a locally scoped model cannot be resolved and the registration is refused.
Declaring a lookup — or changing its params or result fields — changes the
service's manifest hash, so it is a re-registration for that service.
supports_nodes is not part of that: every SDK-built service advertises
lookup because any service can call one. runtime_lookups is what marks a
service as answering.
What is derived
| Derived | Go | Python |
|---|---|---|
| Wire name | json tag, else the field name snake_cased | Field(alias=...), else the field name |
| Skipped | json:"-", unexported fields | Field(exclude=True) |
| Type | the int | bool | string | double union | the same union |
| Type override | manifest:"type=..." | Field(json_schema_extra={"manifest": {"type": "..."}}) |
| Required | !omitempty | has no default |
| Required forced | manifest:"required" | Field(json_schema_extra={"manifest": {"required": True}}) |
Derivation is single-level by construction: a nested struct, a slice, a map or a
time.Time is a loud registration error rather than a silently flattened or
dropped leaf. A params type may declare no fields — a lookup can take none — but
a result type must declare at least one, since a lookup that returns nothing has
no reason to be called.
Every param is required
Both SDKs reject a lookup whose params derive required: false. An optional
param decodes to a zero value that means both "absent" and "legitimately zero",
and a lookup answered from a missing id is a wrong answer that looks
authoritative. Python also rejects a nullable param (T | None): an explicit
null would reach the handler there, while Go refuses it for every declared
param.
required is omitted from the wire when false, so an absent key and
required: false mean the same thing: optional. Only result fields can
carry that.
An optional result field has one spelling per language
Go is a pointer plus ,omitempty; Python is X | None = None.
LastSite *string `json:"last_site,omitempty"`
Everything else is refused at registration:
- A non-pointer
,omitemptyGo field, becauseencoding/jsondrops a present zero value ("",0,false) exactly as it drops an unset one, silently losing a legitimate answer. - A Python field that is nullable with no default, or that carries any default
other than
None— neither has a Go spelling.
An unset optional is absent from the reply, never null: ,omitempty on
the Go side, exclude_none on the Python side.
What registration refuses
These are not the author's responsibility to remember. Each is a startup error — quiet at the call site, loud when the service builds its manifest, the same channel every other registration problem uses.
Both SDKs refuse anything that would let the value on the wire disagree with the
type declared beside it: a type override whose target does not match what the
value marshals as. int and double are interchangeable because both are JSON
numbers; a bool declared string is not, and neither is a value that does not
marshal as a flat scalar at all.
Go additionally refuses the json ,string option, which marshals a number or a
bool as a JSON string while the derived type still says int or bool.
Python additionally refuses three shapes Pydantic allows and Go cannot spell:
- a derived wire name that is not snake_case — Go snake_cases every untagged field, so the two SDKs would advertise different keys;
- a split alias on a params field, where a serialization-only or validation-only
alias declares one name and validates another; use
Field(alias=...)so one name governs both directions; extra="forbid"on a params model, which would silently reverse the rule that unknown request keys are ignored so a flow released against a manifest predating a param keeps working.
A flow node binds each declared param the same way a mapping node binds an
output: a dragged field or a static CEL expression. Release validation
checks every binding against the declaration:
lookup_param_undeclared— the flow binds a param the lookup does not declare, e.g.lookup "presence" does not declare a param named "tenant_id".lookup_param_missing— a declared param has no binding. Every param is required, so every one must be bound.
Both block release.
Result fields on the record
A resolved lookup merges only its declared result fields into the record,
each prefixed with the node's lookup_ref:
presence_is_active
lookup_ref is the author's per-node reference, so the author controls
collisions; a clash with an existing field is rejected at release with
lookup_result_field_collision.
There is no _resolved flag and no _found flag on the body. found
exists on the wire and as a metric label only — the failed port replaced both.
Ports
A lookup emits exactly one of two ports:
ok— the read resolved. Result fields are merged.failed— the read did not resolve: no such record, a timeout, no responder, or an open breaker.
An edge with no source_port is the default port, which for a lookup means ok
— the same convention every other node kind follows. Leaving failed unwired is
legal and means halt here; there is no configuration for it.
Because the two ports are mutually exclusive, a node on the failed branch may
not reference a result field — the flow will not release
(lookup_failed_branch_references_result). Studio's field picker enforces the
same rule at authoring time: a node on the failed branch is never offered the
lookup's result fields as something to bind, so the release check is a
backstop, not the first line of defense.
Failure behaviour
A lookup failure is a routing decision, not a retry signal. The engine never classifies one as transient: doing so would return the consumer, rebuild the client and rejoin the group, and since the reconciler runs exactly one goroutine per topic, a single degraded lookup service would stall ingest for every flow on that topic — including flows that never touch it.
Degraded runs are recorded on the node event as degraded, with status
staying ok so run-status rollups are not corrupted:
lookup_timeout · lookup_no_responder · lookup_breaker_open ·
lookup_budget_exhausted
Transport
Core NATS request-reply on:
triggers.v2.service.<service>.lookup.<name>
Responders subscribe with the queue group
triggers-sdk-lookup-<service>-<name>, byte-identical across the Go and Python
SDKs so a service running both load-balances instead of double-answering.
The reply is a strict one-of. found is always emitted — a reply that omits
it is rejected rather than read as an authoritative absence.
{ "found": true, "fields": { "is_active": true } }
No retry. A rolling-restart gap lasts seconds, so a re-request inside the same attempt budget lands in the same gap and only doubles the latency.
One deliberate asymmetry between the SDKs: the Python decoder parses every JSON
number as a string to keep large ids intact, so a number sent for a string
param is indistinguishable from a string and validates, where Go fails loud on
the same request. Exact parity would mean weakening the precision guard, so the
leniency is documented instead of removed.
Limits
| Variable | Default | Bounds |
|---|---|---|
TRIGGERS_LOOKUP_TIMEOUT_MS | 200 | 20–1000 |
TRIGGERS_LOOKUP_ATTEMPT_BUDGET_MS | 600 | ≥ 1 |
TRIGGERS_LOOKUP_BREAKER_TRIP | 5 | ≥ 1 |
TRIGGERS_LOOKUP_BREAKER_COOLDOWN_MS | 10000 | ≥ 1 |
If consumer lag appears, do not raise the timeout — a lookup sits on the ingest path, and raising it trades a visible problem for a hidden one.
Service-side, one handler call is bounded at one second on both SDKs,
hardcoded: answering after the caller has given up is wasted work, and a knob on
one side only would itself be a divergence. Python's asyncio.wait_for actively
cancels the handler task; Go's deadline rides on ctx, so a Go handler that
ignores ctx.Done() keeps its goroutine busy past it.
The breaker is per (service, lookup) and per process, counting consecutive
failures and resetting on success. With N engine replicas the responder is
probed up to N times per cooldown.
Pinning
A resolved result is pinned for the execution and replayed on a re-walk, so a retry does not re-resolve and possibly answer differently. A degraded result is never pinned — freezing one would turn a transient blip into a permanently wrong answer.
Placement
A lookup may appear after a source, service-in, action, condition, mapping, rate-limit, or durable timer, and may be followed by anything a condition may be followed by. It may not be terminal: a flow that ends on a lookup discards the read it just paid for.
A lookup is transparent to durable-timer segment resolution, with one exception:
inside a retry segment its failed port must be wired, because a halt there
never feeds the timer back.
Metrics
| Metric | Labels |
|---|---|
triggers_v2_lookup_duration_seconds | service, lookup |
triggers_v2_lookup_results_total | service, lookup, result |
triggers_v2_lookup_breaker_state | service, lookup |