Skip to content

v0.15.0

Choose a tag to compare

@pathosDev pathosDev released this 12 Aug 09:04
· 1017 commits to main since this release
1fa62c7

The mailboxes and channels release, and the shortest window the project has had: 58 commits and twelve issues in the day since v0.14.0. It is a minor rather than the patch it was planned as, because pre-1.0 puts breaking changes in a minor and this window carries one — the default mailbox. Two threads run through it. Mailboxes — the default is unbounded again, bounding became a deliberate act that names its own loss, and the telemetry that watches what replaced it exists for the first time. ChannelsEventStream finally accepts the kind-discriminated message style the rest of the framework argues for, and stops letting one bad subscription take the bus down with it. Underneath, constants got a placement rule, and the rule found a dead export, five duplicated values and a second path-traversal denylist.

⚠️ Breaking changes (pre-1.0)

  • The default mailbox is unbounded again (#1148). Every actor spawned without an explicit mailboxCapacity now gets the plain, unbounded Mailbox. Since #310 it got a BoundedMailbox with capacity = 10_000 and overflow = 'drop-head', which silently discarded the oldest queued message on overflow — no dead letter, no exception, only a counter.

    #310's trade was worst-case message loss for a guaranteed memory ceiling. The ceiling turned out not to exist: the system-message queue was never bounded (#794), so the framework was paying the loss without collecting the guarantee. And the loss was never confined to the telemetry-shaped workloads drop-head suits, because a mailbox cannot tell a stale sample from a control message — the tracker has one entry per victim: a death-watch Terminated evicted and the watcher blinded (#729), ReliableDelivery sends discarded with their confirm never settling (#732), DistributedData updateAsync promises stranded unsettled (#1078), and three WebSocket-hub defects where the evicted envelope was a spawn command, a close() or a disconnect signal (#717, #985, #986). None of those are reachable under the new default.

    Migration. Nothing to do if you want the unbounded shape — it is the default. To keep a bound, say so at the spawn site:

    // before — the bound was implicit, and so was the drop policy
    system.spawn(Worker, 'worker');
    
    // after — bounding is the deliberate act, and it names its own loss
    const workerOptions = ActorOptions.create<WorkerMessage>()
      .withMailboxCapacity(10_000)
      .withMailboxOverflow('drop-head');
    system.spawn(Worker, 'worker', workerOptions);

    Unbounded does not mean unobserved. ActorCell warns when a mailbox crosses 10 000 queued messages and again at every doubling, and metrics gained the actor_mailbox_size gauge that the tuning docs had been documenting for a gauge that did not exist. actor_mailbox_dropped_total still exists and now only counts drops someone asked for.

🚀 New features

  • EventStream channels can be kind-discriminated types, not just classes (#1143). subscribe/unsubscribe took a class constructor and matched with instanceof, which locked the one API the project offers for loosely coupled fan-out to exactly the message style the project argues against — and since publish(event: object) has always accepted a plain object, you could publish something nobody was able to subscribe to. A channel is now named three ways: by a class, exactly as before; by an EventKey, a new export mirroring ServiceKey/ShardKey; or by the bare kind string, which costs the type unless you supply the type argument.

    export type UserLoggedInEvent = { readonly kind: 'user-logged-in'; readonly userId: string };
    export const UserLoggedInEvent = EventKey.of<UserLoggedInEvent>('user-logged-in');
    
    eventStream.subscribe(self, UserLoggedInEvent, (event) => event.userId !== 'system');
    eventStream.publish({ kind: 'user-logged-in', userId: 'user-42' });

    A key and its string are the same channel — subscribing both ways dedups, and either form unsubscribes the other. A class and a kind are two channels even when the class's instances carry that kind. Prefix and wildcard families ('billing.*') are deliberately not included; they are purely additive later.

  • A collision predicate on every random-id helper (#1141). randomString, randomHex, randomId and randomUuid take an optional exists callback and draw again while it answers true, so the loop every caller wrote by hand collapses into randomUuid((id) => state.users.has(id)). The polarity is the design: the callback is that while condition, which keeps the ! off Map- and Set-backed call sites. Bounded at 1 000 draws, then throws an Error naming the helper and the count. ExistsPredicate is exported from the root barrel.

  • Every mailbox reports its drops, not just the one the framework built (#1149). actor_mailbox_dropped_total was fed by an onDrop the cell passed into the BoundedMailbox it constructed, so a mailbox supplied through withMailbox was invisible to it. The cell now registers its observer after choosing the mailbox, on anything implementing the new DropReportingMailbox contract — a structural probe rather than an instanceof check. Registration is additive: a BoundedMailboxOptions.onDrop of your own keeps firing alongside the stock counter. New exports: DropReportingMailbox and MailboxDropReason.

  • Mailbox and Envelope are exported from the package root (#661) — the escape hatch the docs described was previously impossible to import. mailboxOverflow / withMailboxOverflow is a real ActorOptions field too (default drop-head; setting it without a capacity is rejected rather than silently ignored).

  • Three of the framework's own identifier draws go through the exists predicate (#1146). The framework mints twelve identifiers; only three have a registry in scope and a failure worth preventing. ActorCell's anonymous child names now draw against this._children, ORSet.add against the element's live tags and its tombstones, and ClusterClient.ask's id against the pending map. The other nine are recorded on the issue with the reason rather than left to be re-derived.

🐛 Fixed

  • One faulty EventStream subscription no longer breaks the bus for everyone else (#1010). publish guarded the subscription's predicate but not its channel: the instanceof test sat one line above the try and subscriber.tell ran unguarded below it, so of the three things that can throw per subscription exactly one was covered. A single bad entry raised a TypeError into whoever called publish, and because the throw escaped the loop, every subscription registered after it silently stopped receiving anything — in an order decided by subscription order, which no caller controls. Since publish runs on every actor start, every actor stop and every dead-lettered tell, that turned ref.tell(…) — an API that does not throw by contract — into one that did.

    subscribe now rejects a channel that is not a usable instanceof right-hand side, throwing on the line that wrote the subscription. publish runs the match test, the predicate and subscriber.tell under a guard and carries on to the next subscriber. Behaviour change: a subscriber.tell that throws is now logged and swallowed rather than propagated out of publish.

  • A dead constant and its live duplicate (#1142). DEFAULT_SNAPSHOT_CACHE_TTL_MS had zero importers while the consumer its own docblock named declared the same five minutes locally as DEFAULT_TTL_MS.

  • reEncryptionSweep rebuilt the ATS1 magic prefix (#1142) instead of importing the ATS1_MAGIC that BodyCodec already exports — a second copy of a format definition.

  • The heartbeat interval existed twice (#1142). defaultFailureDetectorOptions and defaultPhiAccrualOptions each carried heartbeatIntervalMs: 500, so swapping detectors could silently change how often a node talks to its peers — and only the first was pinned to reference.conf by a test.

  • Two independently-introduced redraw caps (#1142) and unnamed literals mirroring reference.conf (#1142) — the dispatcher throughput was a bare 16 in three places, and ShardCoordinator resolved its rebalance interval and hand-off timeout against ?? 2_000 and ?? 10_000. All now named and verified against the HOCON leaves they mirror.

🔒 Security

  • One path-traversal denylist instead of two (#1142). ActorPath and PersistenceIdValidator each declared new Set(['.', '..']) under a different name. Both guard against the same attack — a persistence id becomes a filesystem or object-storage key where .. climbs out of the configured prefix (#133), a path segment reaches actor-selection resolution — and neither imported the other, so extending one would have left the other accepting what it now rejects. Shared as PATH_TRAVERSAL_SEGMENTS, typed ReadonlySet. No behaviour change: both sites reject exactly what they did before.

🧹 Internal

  • Constants have a placement rule, and follow it (#1142). src/ held ~300 module-level SCREAMING_SNAKE constants across 130 files with no documented rule for where any of them belonged. A constant now has exactly four possible homes, checked in order, written down in AGENTS.md. Eight Constants.ts modules hold 42 values; ~20 misplaced options defaults moved next to the option they back. Every public name is unchanged — the barrels re-export from the new location, so no import breaks.