Skip to main content

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:

ConcernActionLookup
Invocationasynchronous dispatchsynchronous, mid-flow
Retryper-action retry confignone — the flow owns re-driving it
DLQsupportednone
Envelope on ctxyes (idempotency key, flow run id, trace)no — the request is {"params": …} only
Failureretried, then DLQleaves 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:

ConceptDirectionWhen
runtime_capabilitiesengine → serviceruntime
data_sourcesstudio → servicedesign-time
runtime_lookupsservice → engineruntime

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

DerivedGoPython
Wire namejson tag, else the field name snake_casedField(alias=...), else the field name
Skippedjson:"-", unexported fieldsField(exclude=True)
Typethe int | bool | string | double unionthe same union
Type overridemanifest:"type=..."Field(json_schema_extra={"manifest": {"type": "..."}})
Required!omitemptyhas no default
Required forcedmanifest:"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 ,omitempty Go field, because encoding/json drops 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

VariableDefaultBounds
TRIGGERS_LOOKUP_TIMEOUT_MS20020–1000
TRIGGERS_LOOKUP_ATTEMPT_BUDGET_MS600≥ 1
TRIGGERS_LOOKUP_BREAKER_TRIP5≥ 1
TRIGGERS_LOOKUP_BREAKER_COOLDOWN_MS10000≥ 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

MetricLabels
triggers_v2_lookup_duration_secondsservice, lookup
triggers_v2_lookup_results_totalservice, lookup, result
triggers_v2_lookup_breaker_stateservice, lookup