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 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 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 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_large on 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 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. 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 absent instead of sending null.
  • 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 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 panicking handler now returns an invocation_failed error to the caller instead of leaving it to time out;
    • an invocation_id of None is omitted from frames instead of sent as 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 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:
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 obs.args instead of 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, emit from the function’s own handler, as in Observable functions.

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.
  • Only workers on a 0.25 SDK can stand in for a trigger type.
See Accept old triggers for the lookup rules.

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 registerTrigger to 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 existing registerTrigger calls keep working through the engine, and you can move them to iii.observe at your own pace.
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 iii.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

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.