Skip to main content
0.25 makes every function observable. A trigger type becomes a function that knows who observes it, and a trigger becomes an observer created with 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. See unregisterTriggerType 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 unregisterTriggerType now takes effect), and their functions are observable. 0.25 SDKs run against a 0.24 engine too: observe fails with UNSUPPORTED, observe hooks never run, and calls are not dispatched locally.
  • registerTrigger and registerTriggerType are 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):
    registerTriggerType logs the equivalent message pointing to registerFunction with an observe descriptor. The engine logs deprecated registrations at debug only.
  • Language markers. TypeScript marks the APIs @deprecated, Python emits a DeprecationWarning at the call site, Go marks them Deprecated: (reported by linters such as staticcheck), and Rust marks the methods #[deprecated(since = "0.25.0")].
Rust crates built with #![deny(warnings)] or -D warnings fail to compile on calls to register_trigger, register_trigger_type, or unregister_trigger_type. Put #[allow(deprecated)] on the calls you keep for now (for example bindings to the http or cron worker), or migrate them.

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 expects payload_too_large on a call to a function in the same worker, send the call with dispatch: "remote".
  • No engine-side span or call metrics for the local call. The SDK still records its own execute span, and reports the number of local invocations every 10 seconds (localinvocationstats), which the engine adds to iii.invocations.total with dispatch="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 set local_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 as KeyboardInterrupt and SystemExit included, reaches the caller as an InvocationError with code invocation_failed and 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.
Local dispatch is off for a session with an RBAC 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 registerTrigger that the legacy shim turns into an observer, the engine sends one TriggerRegistrationResult, also on success. The SDKs ignore the success result.
  • RegisterFunction frames omit request_format and response_format when they are absent. Before, they sent null.
  • 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 onObserverAdded hook still running after 30 seconds fails the observer with OWNER_TIMEOUT, and the next hook of the same function may start while it runs.
  • Rust SDK:
    • public protocol structs gained fields and Message gained variants, so struct literals of them and exhaustive matches on Message need updating (prefer the constructors, such as RegisterTriggerInput::new and TriggerRequest::new);
    • the connection state turns Reconnecting as soon as the link drops;
    • a handler that panics now returns an invocation_failed error to the caller. Before, the call timed out;
    • frames omit an invocation_id of None. Before, they sent null.
  • Browser SDK:
    • trigger now sends metadata, traceparent, and baggage, and handlers receive the metadata as their second argument;
    • the unregistertriggertype frame no longer includes description;
    • 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:
Before (0.24)
After (0.25)

Triggers with config and metadata

For a provider that moved to an owner-delivered function, the trigger’s config becomes the observer’s args and its metadata the observer’s metadata. The example uses the timer provider from the next section.
Before (0.24)
After (0.25)
Filters cover conditions you can state over the event. For example, a 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 an observe 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.
  1. Pick a function id for the event, by convention <worker>::on-<event>.
  2. Set delivery: "owner" and describe the config your trigger type accepted as observe.args_format. If the trigger type declared schemas (Python, Rust, Go), move trigger_request_format to observe.args_format and call_request_format to the function’s request_format.
  3. Move registerTrigger(config) to onObserverAdded(obs) and unregisterTrigger(config) to onObserverRemoved(obs). Read the config from obs.args. Before, it was config.config.
  4. Replace trigger({ function_id: binding.function_id, payload }) with obs.emit(event), or with obs.call(payload) when the result matters (an HTTP response, a queue acknowledgement).
  5. Declare legacy_trigger_type with the old type id, and stop registering the trigger type, so old consumers keep working (below).
The example moves a timer trigger type, which fires each binding every every_ms milliseconds.
Before (0.24)
After (0.25)
The “after” versions keep one timer per observer, like the original. To share one timer per interval between all its observers, index them by interval and emit from the function’s own handler, as the file watcher example does per path.

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 onObserverAdded hook sees obs.legacy = { trigger_type, config }. The engine does not check the config against args_format, so validate it as your old TriggerHandler did. Accept or reject it like any observer; the old worker receives one TriggerRegistrationResult with the outcome.
  • unregisterTrigger disposes the observer.
  • A worker that registers the trigger type the old way always wins for new registrations. Remove the registerTriggerType call in the same release that adds the function.
See Accept old triggers for the lookup rules.

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 registerTrigger calls 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 on path). Remove the registerTrigger call in the same deploy, so each route, schedule, or subscriber has one binding.
Move every binding before you upgrade the engine to 0.26, or move them all in that upgrade. The 0.26 engine, these workers’ 0.26 releases, and the workers that bind to them upgrade together; see Upgrading from 0.25.x. What you can observe in 0.25: any regular function, the engine-hosted event sources, and providers that already moved to observable functions.

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 unregisterTriggerType calls 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) to worker.observe
  • Move your own trigger types to observable functions with legacy_trigger_type
  • Keep registerTrigger for the http, cron, queue, state, stream, pubsub, and database workers on 0.25, and move those bindings to worker.observe before 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 use registerTrigger.
For the observer model, see Observers. For the provider side, see Observable functions. For filter syntax, see the filter language reference.