iii.observe(source, target, options?). The old
trigger APIs keep working in 0.25, so most projects upgrade without code changes. The exceptions are
Rust builds that treat warnings as errors (-D warnings), which need #[allow(deprecated)] on the
calls they keep, and code that calls unregisterTriggerType, which now takes effect. Read the
behavior changes first, then move to the new APIs at your own pace.
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 once the engine confirmed the registration, instead of sending the call through the engine. Results, error shapes, and timeouts are the same. The differences:- No 16 MiB message limit on that path, because nothing is serialized. A test that expects
payload_too_largeon a call to a function in the same worker must force the remote path. - 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. 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 absent instead of sendingnull.- Loop guard. Every invocation carries an observe depth; activations deeper than
observers.max_observe_depth(8) are dropped. Workers on 0.24 SDKs do not forward the depth, so through them the engine estimates it from their activations still in flight. - 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 panicking handler now returns an
invocation_failederror to the caller instead of leaving it to time out; - an
invocation_idofNoneis omitted from frames instead of sent asnull.
- 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 iii.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. Register the target first, then observe. With
deliver: "input" the target receives the same payload its trigger handler received, so the handler
stays as it is.
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). Readobs.argsinstead ofconfig.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. - Only workers on a 0.25 SDK can stand in for a trigger type.
Providers not migrated yet
The http, cron, queue (durable:subscriber), state, stream, pubsub (subscribe), and database
workers live in the workers repository and still register trigger types in 0.25. Until they ship
source functions (such as http::on-request, cron::on-tick, or database::on-row-changed):
- keep using
registerTriggerto bind functions to them; 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;
- once a provider ships its function with
legacy_trigger_type, your existingregisterTriggercalls keep working through the engine, and you can move them toiii.observeat your own pace.
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) toiii.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
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.