You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Akka apps frequently reach for Streams for three specific patterns:
Backpressure-aware queue from non-actor code (Source.queue) — HTTP request handlers push events into a bounded queue; downstream actor consumes at its own pace.
Fan-in from many producers (MergeHub) — every WebSocket connection produces events; a single downstream pipeline consumes them merged.
Fan-out to many consumers (BroadcastHub) — single upstream event stream; N late-attached consumers each get the live tail.
actor-ts has actor-mailbox-based backpressure but no front-door API for non-actor callers to participate in it. The Streams subset closes that gap without shipping the full Streams library (which would be a 6-month project alone — sources, sinks, flows, async-boundaries, materializers, RestartSource, Throttle, Conflate, etc.).
The non-goal is "Akka Streams API compatibility". The goal is "the 5% of the API surface that 80% of apps actually use", expressed naturally in TypeScript.
Reference: what Akka does
// Source.queue + backpressureval (queue, completion) =Source
.queue[Event](bufferSize =100, overflowStrategy =OverflowStrategy.backpressure)
.toMat(Sink.foreach(processEvent))(Keep.both)
.run()
queue.offer(event).onComplete {
caseSuccess(QueueOfferResult.Enqueued) => ...
caseSuccess(QueueOfferResult.Dropped) => ...
}
// MergeHub: many producers → one sinkvalsink:Sink[Event, NotUsed] =MergeHub.source[Event](perProducerBufferSize =16)
.to(Sink.foreach(processEvent))
.run()
// Each producer:Source.single(event).runWith(sink)
// BroadcastHub: one source → many consumersvalsource:Source[Event, NotUsed] =Source
.actorRef[Event](bufferSize =256, overflowStrategy =OverflowStrategy.dropHead)
.toMat(BroadcastHub.sink[Event](bufferSize =256))(Keep.right)
.run()
// Each late consumer:
source.runForeach(processForOneConsumer)
Design sketch — actor-ts equivalents
SourceQueue<T> — push from non-actor code with backpressure semantics:
exportinterfaceSourceQueueOptions{/** Bounded buffer; 0 = unbuffered (offer blocks until consumer ready). Default: 64. */readonlybufferSize?: number;/** What happens when bufferSize is reached. Default: 'backpressure'. */readonlyoverflowStrategy?: 'backpressure'|'drop-head'|'drop-tail'|'drop-new'|'fail';}exportinterfaceSourceQueue<T>{/** * Push one element. Resolves with: * - 'enqueued' if the buffer accepted (or if backpressure waited then accepted). * - 'dropped' for drop-* strategies when buffer was full. * Rejects for 'fail' strategy + full buffer. */offer(value: T): Promise<'enqueued'|'dropped'>;/** Signal completion to the consumer. Subsequent offer() rejects. */complete(): Promise<void>;/** Signal failure to the consumer. Subsequent offer() rejects. */fail(error: Error): Promise<void>;/** Future that resolves when the consumer side terminates. */readonlywatchCompletion: Promise<void>;}exportfunctionsourceQueue<T>(consumer: (value: T)=>Promise<void>|void,options?: SourceQueueOptions,): SourceQueue<T>;
Implementation: under the hood, a small wrapper actor with a bounded mailbox. offer is actor.ask({ kind: 'offer', value }); the actor's handler awaits consumer(value) and replies. Backpressure is exactly the mailbox-backpressure we already have.
MergeHub<T> — fan-in from many producers:
exportinterfaceMergeHubOptions{/** Per-producer buffer; protects against one slow producer blocking others. Default: 16. */readonlyperProducerBufferSize?: number;}exportinterfaceMergeHubSink<T>{/** Attach a producer. Returns its push-handle. */attach(): SourceQueue<T>;/** Stop accepting new producers; in-flight ones drain. */complete(): Promise<void>;}exportfunctionmergeHub<T>(consumer: (value: T)=>Promise<void>|void,options?: MergeHubOptions,): MergeHubSink<T>;
Implementation: a coordinator actor that holds the consumer; each attach() spawns a child actor with its own bounded queue that drains into the coordinator. Per-producer backpressure isolated.
BroadcastHub<T> — fan-out to many late consumers:
exportinterfaceBroadcastHubOptions{/** Per-consumer buffer; slow consumers don't block fast ones. Default: 256. */readonlyperConsumerBufferSize?: number;/** What happens for a consumer that's too slow. Default: 'drop-head'. */readonlyslowConsumerStrategy?: 'drop-head'|'detach';}exportinterfaceBroadcastHubSource<T>{/** Attach a consumer. Returns an unsubscribe handle. */attach(consumer: (value: T)=>Promise<void>|void): ()=>void;/** Push from upstream. */push(value: T): Promise<void>;/** Signal completion. */complete(): Promise<void>;}exportfunctionbroadcastHub<T>(options?: BroadcastHubOptions): BroadcastHubSource<T>;
Implementation: coordinator actor; each attach() adds a child with its own per-consumer buffer. Upstream push fans out to every child's mailbox concurrently.
Integration with existing actor-ts subsystems
Mailbox: piggy-backs on the existing bounded-mailbox + backpressure semantics. No new transport, no new scheduler.
Supervision: hubs are normal actors; user can system.actorOf(...) and apply standard supervision strategies.
Cluster: a SourceQueue whose consumer is a RemoteActorRef works automatically — the backpressure is whatever the remote mailbox provides (today: unbounded; with [Feature] Dispatcher-saturation + mailbox-depth histograms #196 dispatcher-saturation tracking, becomes meaningful).
Explicitly NOT in this issue (track separately if demand emerges):
Full Streams DSL: Source.map, Source.filter, Source.via(Flow), Source.runWith, materialization. We're not building Akka Streams — we're shipping the 3 hub patterns.
Async boundaries / materializer: actor-ts already has async via actors. No need for a separate execution model.
RestartSource / RestartFlow: use BackoffSupervisor on the hub actor instead.
offer return type: Akka returns QueueOfferResult (a sealed trait). TypeScript discriminated union vs. throwing the failure? Current sketch uses a string literal; could promote to { status: 'enqueued' } | { status: 'dropped'; reason: 'buffer-full' } if richer info needed.
Materialization: Akka separates "graph definition" from "running graph" via .run(). We start running on construction — simpler, matches actor lifecycle. Is anyone relying on graph-without-running for re-use?
MergeHub.attach() lifecycle: how does a producer signal "I'm done"? Probably queue.complete() on the returned SourceQueue. The coordinator stops counting that producer for completion-tracking but other producers continue.
BroadcastHub slow-consumer: 'detach' strategy needs a callback to notify the consumer they were detached. New onDetach?: (reason) => void option on attach()?
These are part of the design phase, not pre-decided.
Test plan
sourceQueue happy path — push 100 events; consumer receives all 100 in order.
Size / Priority
Rationale
Akka apps frequently reach for Streams for three specific patterns:
Source.queue) — HTTP request handlers push events into a bounded queue; downstream actor consumes at its own pace.MergeHub) — every WebSocket connection produces events; a single downstream pipeline consumes them merged.BroadcastHub) — single upstream event stream; N late-attached consumers each get the live tail.actor-ts has actor-mailbox-based backpressure but no front-door API for non-actor callers to participate in it. The Streams subset closes that gap without shipping the full Streams library (which would be a 6-month project alone — sources, sinks, flows, async-boundaries, materializers, RestartSource, Throttle, Conflate, etc.).
The non-goal is "Akka Streams API compatibility". The goal is "the 5% of the API surface that 80% of apps actually use", expressed naturally in TypeScript.
Reference: what Akka does
Design sketch — actor-ts equivalents
SourceQueue<T>— push from non-actor code with backpressure semantics:Implementation: under the hood, a small wrapper actor with a bounded mailbox.
offerisactor.ask({ kind: 'offer', value }); the actor's handler awaitsconsumer(value)and replies. Backpressure is exactly the mailbox-backpressure we already have.MergeHub<T>— fan-in from many producers:Implementation: a coordinator actor that holds the consumer; each
attach()spawns a child actor with its own bounded queue that drains into the coordinator. Per-producer backpressure isolated.BroadcastHub<T>— fan-out to many late consumers:Implementation: coordinator actor; each
attach()adds a child with its own per-consumer buffer. Upstreampushfans out to every child's mailbox concurrently.Integration with existing actor-ts subsystems
system.actorOf(...)and apply standard supervision strategies.SourceQueuewhose consumer is aRemoteActorRefworks automatically — the backpressure is whatever the remote mailbox provides (today: unbounded; with [Feature] Dispatcher-saturation + mailbox-depth histograms #196 dispatcher-saturation tracking, becomes meaningful).streams_queue_depth{name}Gauge,streams_offer_dropped_total{name,reason}Counter,streams_consumer_latency_secondsHistogram. ReuseMetricsRegistry.Out of scope / non-goals
Explicitly NOT in this issue (track separately if demand emerges):
Source.map,Source.filter,Source.via(Flow),Source.runWith, materialization. We're not building Akka Streams — we're shipping the 3 hub patterns.Publisher/Subscriber/Subscription): track as separate item [Feature] Reactive-Streams interop (Akka-Streams subset interop) #189 (B6.6) — wraps the hubs in the reactive-streams-spec API.Migration story (from Akka)
Cheat-sheet doc page mapping Akka idioms → actor-ts equivalents. ~3 pages.
Open design questions
offerreturn type: Akka returnsQueueOfferResult(a sealed trait). TypeScript discriminated union vs. throwing the failure? Current sketch uses a string literal; could promote to{ status: 'enqueued' } | { status: 'dropped'; reason: 'buffer-full' }if richer info needed..run(). We start running on construction — simpler, matches actor lifecycle. Is anyone relying on graph-without-running for re-use?MergeHub.attach()lifecycle: how does a producer signal "I'm done"? Probablyqueue.complete()on the returnedSourceQueue. The coordinator stops counting that producer for completion-tracking but other producers continue.BroadcastHubslow-consumer: 'detach' strategy needs a callback to notify the consumer they were detached. NewonDetach?: (reason) => voidoption onattach()?These are part of the design phase, not pre-decided.
Test plan
sourceQueuehappy path — push 100 events; consumer receives all 100 in order.sourceQueuebackpressure — bufferSize=10, push 100; verifyofferblocks once buffer fills.sourceQueuedrop- strategies* — each overflow strategy verified independently.mergeHubfan-in — 10 producers × 100 events each = 1000 events; consumer receives all 1000 (any interleaving).mergeHubslow-producer isolation — one producer pauses; others continue without blocking.broadcastHubfan-out — 1 producer × 100 events; 5 late-attached consumers all receive all 100.broadcastHubslow-consumer — one consumer ignores; verify per-consumer buffer fills + drop-head fires.complete()signals all consumers;fail(err)propagates to all.Acceptance criteria
src/streams/module:sourceQueue,mergeHub,broadcastHubexported.Pre-implementation checklist
Because this is XL, the implementation should not start without a dedicated planning slot: