worker.observe(source, target, options?). The old
trigger APIs keep working in 0.25, so most projects upgrade without code changes. The exceptions:
- Rust builds that treat warnings as errors (
-D warnings): put#[allow(deprecated)]on the calls you keep. - Code that calls
unregisterTriggerType: engines before 0.25 ignored this call. The 0.25 engine removes the trigger type. If your code expects the type to stay, remove the call. SeeunregisterTriggerTypenow takes effect.
The http, cron, queue, state, stream, pubsub, and database workers still publish trigger types in
0.25. Keep using
registerTrigger for them; see Providers not migrated
yet.What changes for existing workers
-
The old APIs keep working. 0.24 SDKs run against a 0.25 engine unchanged (except that an
unregisterTriggerTypenow takes effect), and their functions are observable. 0.25 SDKs run against a 0.24 engine too:observefails withUNSUPPORTED, observe hooks never run, and calls are not dispatched locally. -
registerTriggerandregisterTriggerTypeare deprecated in every SDK and will be removed in a future release. They keep working through the trigger type’s provider or, once the provider moved to a function, through the engine’s legacy shim. -
One runtime warning per process per API. The first call logs, through the SDK’s logger (each
SDK names its own API):
registerTriggerTypelogs the equivalent message pointing toregisterFunctionwith anobservedescriptor. The engine logs deprecated registrations atdebugonly. -
Language markers. TypeScript marks the APIs
@deprecated, Python emits aDeprecationWarningat the call site, Go marks themDeprecated:(reported by linters such as staticcheck), and Rust marks the methods#[deprecated(since = "0.25.0")].
Behavior changes
Local dispatch
A worker that calls a function it registered itself now runs the handler in-process after the engine confirms the registration. The call does not go through the engine. Results, error shapes, and timeouts are the same. The differences:- No message size limit on that path, because nothing is serialized. Through the engine, a
message can be at most 64 MiB (16 MiB for browsers and SDKs older than 0.25), and a larger one
fails with
payload_too_large. A local call never fails with this error. If a test expectspayload_too_largeon a call to a function in the same worker, send the call withdispatch: "remote". - No engine-side span or call metrics for the local call. The SDK still records its own
executespan, and reports the number of local invocations every 10 seconds (localinvocationstats), which the engine adds toiii.invocations.totalwithdispatch="local". - Value semantics. The handler receives a copy of the payload and the caller a copy of the result, as over the wire.
- Rust: a local call runs on the caller’s task, and a sync handler (
RegisterFunction::new) runs inline on it, where the call’s timeout cannot interrupt it. For a handler that can block, use an async handler (RegisterFunction::new_async) or setlocal_dispatch(false)on it. - Recursion: a local call nested more than 64 local calls deep goes through the engine. In the
browser while offline, local calls run up to 256 levels deep, and a deeper one fails at once with
LOCAL_DEPTH_EXCEEDED. - Python: a sync handler called locally runs on a thread of its own and honors the caller’s
timeout. Whatever a local handler raises,
BaseExceptions such asKeyboardInterruptandSystemExitincluded, reaches the caller as anInvocationErrorwith codeinvocation_failedand the original exception as its__cause__. - Disconnects: once confirmed, local dispatch keeps serving calls to the worker’s own functions while the connection is down.
middleware_function_id, or one that may not
invoke the function, because a local call would bypass those checks. Enqueue calls always go through
the engine. To keep the old behavior for a function, a call, or the whole engine:
unregisterTriggerType now takes effect
Every SDK has always sent unregistertriggertype, but engines before 0.25 ignored it. The 0.25
engine removes the trigger type when the sending worker is its provider (and ignores it otherwise).
Its triggers then move to another provider of the type, are taken by a function that declares it as
legacy_trigger_type, or wait until a provider registers again. If your worker calls
unregisterTriggerType and expected the type to stay, remove the call.
Smaller changes
- Legacy shim results. For a
registerTriggerthat the legacy shim turns into an observer, the engine sends oneTriggerRegistrationResult, also on success. The SDKs ignore the success result. RegisterFunctionframes omitrequest_formatandresponse_formatwhen they are absent. Before, they sentnull.- Loop guard. Every invocation carries an observe depth. When the depth of an event is more than
observers.max_observe_depth(8 by default), the engine drops it. Workers on 0.24 SDKs do not send the depth. For their calls, the engine uses the largest depth of the invocations still in flight on that worker. - Observe hooks that hang. An
onObserverAddedhook still running after 30 seconds fails the observer withOWNER_TIMEOUT, and the next hook of the same function may start while it runs. - Rust SDK:
- public protocol structs gained fields and
Messagegained variants, so struct literals of them and exhaustivematches onMessageneed updating (prefer the constructors, such asRegisterTriggerInput::newandTriggerRequest::new); - the connection state turns
Reconnectingas soon as the link drops; - a handler that panics now returns an
invocation_failederror to the caller. Before, the call timed out; - frames omit an
invocation_idofNone. Before, they sentnull.
- public protocol structs gained fields and
- Browser SDK:
triggernow sendsmetadata,traceparent, andbaggage, and handlers receive the metadata as their second argument;- the
unregistertriggertypeframe no longer includesdescription; - while the page is offline, functions it hosts run locally up to 256 nested calls, and a deeper
call fails with
LOCAL_DEPTH_EXCEEDED.
Transition overview
Move registerTrigger to worker.observe
Do this for triggers whose source is a function: any regular function, the
engine-hosted event sources, or a provider
that moved to an observable function. With deliver: "input" the target receives the same payload
its trigger handler received, so the handler stays as it is.
Register the target before you observe it. This is the best practice in most cases, but it is not
required. If the target is not registered yet, the observer stays pending (TARGET_UNAVAILABLE)
until the target registers, and events that occur in that time do not reach it.
Engine triggers
The engine’s own trigger types have source functions in 0.25:- Node / TypeScript
- Python
- Rust
- Go
Before (0.24)After (0.25)
Triggers with config and metadata
For a provider that moved to an owner-delivered function, the trigger’sconfig becomes the
observer’s args and its metadata the observer’s metadata. The example uses the timer
provider from the next section.
- Node / TypeScript
- Python
- Rust
- Go
Before (0.24)After (0.25)
condition_function_id that
let only gold customers through becomes filters: { "input.customer_tier": { "$eq": "gold" } },
evaluated without an extra function call; see the filter language. A
condition that needs code (a lookup, an auth check) has no filter form: keep that trigger on
registerTrigger, which works in 0.25, or move the check into the target function.
Browser workers use the same
observe call as Node. A browser handler knows its invocation only
until its first await; wrap later calls in worker.withInvocation(inv, fn) so they carry the
caller and the loop guard’s depth. While the page is offline, its own functions run locally up to
256 nested calls, then fail with LOCAL_DEPTH_EXCEEDED. See Caller and
causality.Move a trigger type to an observable function
A trigger type becomes a function with anobserve descriptor. Its owner keeps the same job: set
something up per observer, and hand each event to the observers it belongs to. The engine now stores
the bindings, validates their args, applies their filters and policy, and replays them when your
worker reconnects.
- Pick a function id for the event, by convention
<worker>::on-<event>. - Set
delivery: "owner"and describe the config your trigger type accepted asobserve.args_format. If the trigger type declared schemas (Python, Rust, Go), movetrigger_request_formattoobserve.args_formatandcall_request_formatto the function’srequest_format. - Move
registerTrigger(config)toonObserverAdded(obs)andunregisterTrigger(config)toonObserverRemoved(obs). Read the config fromobs.args. Before, it wasconfig.config. - Replace
trigger({ function_id: binding.function_id, payload })withobs.emit(event), or withobs.call(payload)when the result matters (an HTTP response, a queue acknowledgement). - Declare
legacy_trigger_typewith the old type id, and stop registering the trigger type, so old consumers keep working (below).
timer trigger type, which fires each binding every every_ms milliseconds.
- Node / TypeScript
- Python
- Rust
- Go
Before (0.24)After (0.25)
Keep old consumers working
legacy_trigger_type: "timer" lets workers that still call registerTrigger({ type: "timer" })
keep working with no change, as long as no worker registers the timer trigger type the old way:
- The engine turns such a trigger into an observer of your function, owned by the worker that
registered it. Its id is the trigger id, it has no args and no filters, its metadata is the
trigger’s, and it uses
deliver: "input", so the bound function receives the event you emit, exactly as it received the fired payload before. - Your
onObserverAddedhook seesobs.legacy = { trigger_type, config }. The engine does not check the config againstargs_format, so validate it as your oldTriggerHandlerdid. Accept or reject it like any observer; the old worker receives oneTriggerRegistrationResultwith the outcome. unregisterTriggerdisposes the observer.- A worker that registers the trigger type the old way always wins for new registrations. Remove the
registerTriggerTypecall in the same release that adds the function.
Providers not migrated yet
In their latest releases, the http, cron, queue (durable:subscriber), state, pubsub (subscribe),
and database workers still register trigger types. The stream worker, which ships with the engine,
does too. Some of these workers also ship a source function, starting with the release listed:
None of them declares
legacy_trigger_type, and none will: 0.26 removes registerTrigger, trigger
types, and the legacy shim. A 0.26 engine drops trigger registrations and logs a warning in its own
log; the worker gets no error, and the bound function never runs. On 0.25:
- your
registerTriggercalls to these workers keep working; the deprecation warning is informational, and Rust code needs#[allow(deprecated)]on those calls; - their documentation, including the config shapes at workers.iii.dev, stays valid;
- to move a binding to
worker.observe, upgrade the worker to a release that ships its source function, and observe that function with the args its documentation lists (they are not the trigger config: an http route takes{ method, path }, with a leading slash onpath). Remove theregisterTriggercall in the same deploy, so each route, schedule, or subscriber has one binding.
Migration checklist
- Upgrade the engine and the SDKs; nothing else is required to keep running
- Check functions that call themselves for local dispatch differences (large payloads, blocking Rust sync handlers, tests that expect engine spans)
- Remove
unregisterTriggerTypecalls that expected the type to stay registered - Add
#[allow(deprecated)]to Rust trigger calls you keep, if warnings are errors - Move triggers on engine events (
engine::functions-available,engine::workers-available,configuration,log,trace) toworker.observe - Move your own trigger types to observable functions with
legacy_trigger_type - Keep
registerTriggerfor the http, cron, queue, state, stream, pubsub, and database workers on 0.25, and move those bindings toworker.observebefore or with the 0.26 upgrade (see Providers not migrated yet)
Result
The project runs on 0.25 with its triggers unchanged. Functions you observe deliver to their targets through the engine’s observers, calls a worker makes to its own functions run in-process, and trigger types you moved keep serving the workers that still useregisterTrigger.
For the observer model, see Observers. For the provider side, see
Observable functions. For filter syntax, see the
filter language reference.