iii-stream and pubsub use case to primitives every SDK ships:
observable functions your workers host, explicit storage with queries, channels, and the durable
queue. The sections below also list why both are deprecated and what stays supported.
Why they are deprecated
iii-stream bundles three concerns behind one API: a generic key/value store, a change
notification, and a dedicated WebSocket transport. Every stream::set writes to storage even when
the caller only wanted to notify someone, stored items have no owner that can bound or clean them
up, and consumers depend on the stream envelope instead of a domain contract. Authorization is
handled by a separate auth_function that only covers the dedicated WebSocket.
The pubsub worker is a global topic bus. Its topics are free strings that no worker owns, and
publish delivers fire-and-forget with silent drops.
The replacements exist in Node, Browser, Python, Rust and Go: function registration with observer
hooks, observers, channels, the state and database workers, and the queue worker. They put
each concern with the worker that owns the data.
Do not replace
stream::set with state::set mechanically. That keeps the storage cost of
writing every event and loses the notification. Pick the recipe below that matches what the data
is for.Timeline and support window
What the warnings look like:
- Engine: a
warnlog with targetiii::deprecationthe first time a caller uses a deprecated function or connects to the dedicated Streams WebSocket, then at most once every 10 minutes per entry point and caller. Function calls are keyed by the calling worker; WebSocket connections are keyed by client IP. Startingiii-streamand its WebSocket listener warns once per process. The log names the entry point, the calling worker when known and this guide. It never contains payloads, stream, group or item ids, headers or query strings. Direct calls such ascall({ function_id: "stream::set" })from any SDK version are covered. Wheniii-streamis not running, astream::*call still fails withfunction_not_found(code unchanged), and the message now explains how to enableiii-streamand links this guide. - Node and Browser SDKs:
@deprecatedJSDoc oncreateStreamandIStream, so editors strike them through. No runtime change. - Python SDK: calling
create_streamor subclassingIStreamemits aFutureWarningpointing at your call site. Nothing is raised and behavior is unchanged. - Rust SDK:
#[deprecated]on the legacy stream helpers,IStream, andcreate_stream. - pubsub worker: the worker logs a warning once at startup, and on
publish, at most once every 10 minutes per caller. Traffic on topicstream.events, used by the engine’s owniii-streambridge, never warns.
What is deprecated and what is not
Deprecated withiii-stream:
- The
iii-streamengine worker. - Functions
stream::set,stream::get,stream::delete,stream::list,stream::list_groups,stream::list_all,stream::sendandstream::update. - Its event sources
stream::on-changed,stream::on-joinandstream::on-leave. - The dedicated Streams WebSocket listener (default
127.0.0.1:3112) and its clients. - The
auth_functionsetting ofiii-stream. - SDK helpers: Node and Browser
createStreamandIStream; Pythoncreate_streamandIStream; the Rust legacy stream provider and helpers; models that only exist for the legacy Streams contract.
pubsub (the registry worker pubsub, formerly iii-pubsub):
- Function
publish, which subscribers observe with a filter oninput.topic, and thepubsubconfiguration entry.
- Function registration, observer hooks, and
observein every SDK. - Channels, channel readers and writers, and
StreamChannelRef. StreamRequestandStreamResponseused for request/response streaming.UpdateOp,MergePath,UpdateOpError, and the RustStreamSetResult,StreamUpdateResultandStreamDeleteResulttypes. Thestateworker uses them.- The
auth_function_idofiii-worker-managerandrbac-proxy. That is connection authorization for RBAC listeners, unrelated to theiii-streamauth_function. See Access Control. - The
queueworker, includingiii::durable::publishandqueue::on-message. - Provider token streaming in LLM workers, which already uses channels.
Choose a replacement
An observer is a notification mechanism, not a database or durable event log. If history or replay
is required, define and retain the source of truth in the worker that owns the data. Do not build
another generic, unbounded event store. Prefer small notification payloads plus a query for large
datasets, or bounded record snapshots with a revision.
Find your current usage:
Recipe 1: Ephemeral notifications
Use this when consumers need to react to something that happened and nobody needs to read it back later: a lifecycle change, a status flip, a “work is done” signal. The worker that produces the event hosts a function whose handler returns the event, and calls it when the event happens. Consumers observe that function, with filters for the events they want. See Observable functions for the full API in every SDK. Producer:pending and becomes active when
the producer registers orders::on-event. See
Observer states.
Recipe 2: Stored records with live updates
Use this when consumers need the current data and also want to know when it changes: a dashboard, a list view, a sync job. Storage and notification are two separate things owned by the same domain worker.- Store records in storage the domain worker owns (
state,database, or your own store), with a revision number per record. - Expose read functions:
orders::getand a paginatedorders::list(cursor plus limit). - After the write is committed, call a small change notification function the domain worker hosts
(
orders::on-changedwith{ order_id, revision }), built as in Recipe 1. - Consumers observe first, then do an initial read, then apply notifications. They repeat the read to recover after a reconnect.
state::compare-and-set or your
database’s transaction so revisions stay monotonic.
Consumer:
Recipe 3: Continuous content for one operation
Use this for content produced by one running operation and read by one reader: progress output, tokens, frames, a file. A channel has an explicit lifetime and backpressure, and nothing is stored. The reader creates the channel and passes the writer ref to the function that produces the content:- One channel per operation and reader. For fan-out to many consumers, use Recipe 1 or 2 instead.
- End the writer when the operation finishes. If it fails, call
input.writer.stream.destroy()instead of ending it, then rethrow, so the reader gets an error, not a stream that looks complete. Pass no argument:destroy(err)emits an'error'event that crashes the worker when nothing listens for it. A producer whose writer socket never opened (it failed before its first write or, in Node, right after one, while the socket was still connecting) never reached the engine, so the reader is not told: its loop ends normally only when the engine drops the unused channel after the channel TTL (about 5 to 6 minutes), and only the rejecteddonereports the failure. When the reader leaves, the engine closes the writer. See Backpressure and lifecycle. - Bound the content: cap size or duration, and never queue an unbounded backlog for a slow reader.
- For bidirectional traffic, create two channels.
Provider and consumer obligations
iii-stream hid these responsibilities. With an observable function, iii stores the observers,
applies their filters and policy (max_in_flight, queue limits, observers.max_per_function), and
delivers in each observer’s namespace with its metadata. The rest belongs to your code.
The provider (the worker that hosts the function):
- Declares the event. Give the function a closed
request_formatandresponse_format, so a filter path that is not in the event fails when the observer is created (INVALID_FILTER_PATH). - Keeps the event to itself. Register the function with
private: true, so only the provider can make the event. - Validates args, when it takes any. Declare
args_format, and reject an observer for any other reason by throwingObserveRejectionfromonObserverAdded. Never let args widen what the consumer is allowed to see. - Emits after commit. For stored data, notify only after the mutation is durable. Never report success while dropping data or events.
- Handles duplicates and gaps. iii delivers each call at most once and counts what it drops in
iii.observer.dropped.total, and a producer may announce the same change twice. Make handlers idempotent. - Tracks revisions or cursors. Ignore notifications older than what it already applied.
- Recovers after reconnect. Re-run the initial read (Recipe 2) because notifications sent while it was away are not replayed.
- Cleans up. Dispose observers it no longer needs and close channels it opened.
Authorization and tenant isolation
A transport change alone does not reproduce the old policies. Moving a client from the dedicated Streams WebSocket to a Browser SDK observer ofstream::on-changed changes the connection, not the
dependency on iii-stream, and it keeps the deprecated sources.
For tenant isolation, give each tenant or browser session its own namespace through the RBAC
listener’s
namespaces grant, so a session observes only the functions granted to it, and run the
provider for each tenant in that tenant’s namespace. An observer’s args and filters come from the
consumer: never treat a tenant id taken from them as proof of the tenant.
The
auth_function_id of iii-worker-manager and rbac-proxy is not deprecated. Only the
auth_function of iii-stream and its stream::on-join / stream::on-leave sources are.A note on stream::send
stream::send already avoids the key/value write that stream::set does, but it still runs through
iii-stream and is deprecated with it. Changing stream::set to stream::send is not the final
migration, and it changes the event envelope consumers receive. Move straight to Recipe 1 or
Recipe 3.
Migrate from pubsub
Things to check while migrating:
- Payload.
pubsubandqueue::on-messagedeliver the publisheddataas-is. An observable function defines its own payload, and its observers receive theObservedpayload, with the call’s input as itsinputand its return value as itsoutputper theircapture, so update consumer handlers. - Namespaces.
pubsubignored namespaces: topics were global and subscriber functions resolved in thedefaultnamespace. Observers name the namespace of the function they observe and of their target, so a consumer in another namespace names it. - Silent drops. A
publishsent withCallAction.Void()when nopubsubworker is running is dropped without an error to the caller. The replacements make delivery explicit; do not rely on that behavior. - Order of removal. The
iii-streambridgeadapter usespubsubinternally. If you run it, keeppubsubuntiliii-streamis gone.
Existing stream data
During the support window, data stored byiii-stream stays readable through stream::get,
stream::list and stream::list_groups. To keep it, copy what you still need into storage your
domain worker owns while iii-stream is running, and map stream, group and item ids to your own
keys. Do not delete the iii-stream store files or Redis keys until your consumers run without
iii-stream.
A documented export path, the id mapping and a rollback plan are part of the removal phase and will
be published before storage access is removed in an upcoming release.
Worked examples in the tutorials
The Linkly tutorial and the real-time todo how-to use observable functions and thequeue worker:
- Ch. 5: Stream live clicks is a worked example of Recipe 2. The
click-streamerworker hostsclick-streamer::on-clickand calls it for each click after its row is committed, andlink::click_summarygives a late listener the current total. - Ch. 7: Bring in the browser observes that function from the
Browser SDK through
rbac-proxy, reads the total first, and reads it again after a reconnect. - Ch. 4: Make it durable fans
link.createdandlink.updatedout on durable topics withiii::durable::publishandqueue::on-message. - Build a real-time todo app outlines Recipe 2 for a browser
app: stored todos,
todos::getandtodos::list, and atodos::on-changedfunction each browser observes.
Checklist
- List every call to
stream::*andpublish, and every observer ofstream::on-changed,stream::on-join,stream::on-leaveandpublish, including calls whose failures your code ignores. - Pick a recipe per use case and name the worker that owns each piece of data.
- Move authorization to the RBAC listener and the provider’s
onObserverAddedvalidation. - Migrate producers and their consumers together. If you must publish to both paths for a while, define event identity, which consumer reads which path, and a deadline.
- Copy any stream data you still need into domain storage.
- Run your consumers with
iii-streamandpubsubabsent fromworker-compose.yamland confirm no deprecation warnings remain in the engine log.