Skip to content

v0.14.0

Choose a tag to compare

@pathosDev pathosDev released this 11 Aug 15:30
· 1079 commits to main since this release
b08bafd

The caps, codecs and lifecycle release, and by some distance the largest window the project has had: 266 commits, 106 issues, eighteen breaking changes. Three threads run through it. Caps — the cluster-wide registries, the member map, the metric label space and the quorum queue are all bounded now, and a gossiped record is held to those bounds rather than believed. Codecs — CBOR reaches rich-type parity with the JSON tree, which took four encoding changes to get right, and Avro and Protobuf ship beside it. Lifecycle — a restart stops the actor's children, a resumed actor brings its subtree back with it, and both of those had been quietly impossible before. Underneath: every severity: high finding from both audit passes is closed, including a cluster hello that trusted whatever identity a peer claimed. Pre-1.0 — this minor carries breaking changes, and one of them rules out a rolling upgrade; see below.

🚀 New features

  • Scatter/gather router (#153) — Router.scatterGatherFirstCompleted(size, routee, options?) asks every routee at once and answers with the first reply: Akka's ScatterGatherFirstCompletedPool, the hedged-request pattern for tail latency. The fan-out is not awaited in the handler, so concurrent scatters overlap instead of serialising the router's mailbox; every failure rejects with an AggregateError carrying one error per routee, and the message distinguishes "nobody replied in time" from "everyone failed". Stopping or restarting the router fails its open scatters immediately rather than running out the clock. withTimeoutMs defaults to 4 500 ms — deliberately under ask's 5 000, because the router can only name the failing routees after its own deadline has passed (#1088).
  • TCP listener actor (#158) — TcpServerActor binds a port and serves every connection it accepts, closing the one half of the raw-TCP API that was genuinely missing. Framing is applied per connection with its own re-assembly buffer, TLS and mTLS go through the same cross-runtime layer the cluster transport uses, and maxConnections refuses at the door. Connections are addressed by an opaque connectionId rather than getting an actor each, because an actor's restart semantics cannot resurrect a peer's TCP connection.
  • CborSerializer carries the same rich types as the JSON tree (#1036) — Map, Set, BidirectionalMap, RegExp, URL, Error, the typed arrays, DataView and ArrayBuffer all round-trip, most through their registered IANA tags. Until now a store configured with a CBOR serializer silently degraded them to {}.
  • Avro and Protobuf serializers (#73) — plus serializerCodec(serializer), which adapts any byte-native serializer to the codec seam, so one implementation serves both.
  • Live and cursor-paginated persistence-id queries (#156) — allPersistenceIds() streams entities as they appear; currentPersistenceIdsPaginated(cursor, limit) walks them in pages. Paging is pushed into the backend wherever a sorted key over ids exists (ORDER BY … LIMIT on SQLite and the SQL dialects, a clustering range on Cassandra); MongoDB and DynamoDB have no such index and fall back to an in-process page.
  • BidirectionalMap<K, V> and BidirectionalMultiMap<L, R> (#1035, #1037) — a Map that answers in both directions, and its many-to-many sibling. Both round-trip through every store as tagged JSON-tree values, so they can be held in an actor's state directly: no snapshot adapter, no serializer registration, nothing at the boundary. The receptionist, the pub-sub mediator and the broker base now index their subscribers through the multi-map instead of each maintaining the same relation by hand.
  • DistributedPubSub anycast (#155) — Publish takes a third argument, delivery, and 'one-subscriber' hands the message to exactly one subscriber cluster-wide: the work-queue shape a broadcast bus cannot do without the workers coordinating among themselves. Selection rotates a per-topic cursor over local subscribers and the remote nodes claiming the topic, so ten tasks over three workers land 4/3/3 rather than "probably roughly even".
  • JetStream Key-Value and Object-Store actors (#74), gRPC client-streaming as its own call mode (#5), and gRPC health checking (#4) — GrpcServerActor can now host grpc.health.v1.Health from the HealthCheckRegistry.
  • Cluster subscriptions get a CurrentClusterState snapshot replay and a ReachabilityChanged event (#161), and stable-observation bootstrap (#148) decides whether, and when, a node may elect itself rather than racing its peers into two rival clusters.
  • Death watch with a custom termination message (#159) — watchWith(ref, message) delivers a message you choose instead of Terminated, so a watcher that already speaks one protocol does not need a second arm for the framework's.
  • Typed Behaviors.intercept / monitor / logMessages (#152), Router.smallestMailbox (#154) and smallest-mailbox for the cluster router (#69).
  • LogContext.runFresh(fn) and runEach(entries, fn) (#129) — the two seams where deferred work either belongs to nobody or belongs to somebody different per item. Without them a batch drained in one turn logged every item under whichever tenant happened to start the turn.
  • randomUuid(), randomString, randomHex, randomId, safeStringify, lazyImportModule on the public surface (#1034, #1109) — all six already existed and the framework runs on them; nothing but the barrel kept them from consumers.
  • acquireLock(cache, key, ttlMs) (#141) — mutual exclusion over any Cache, and the setIfAbsent atomicity contract that makes it sound is now written down and proven across all three backends.

⚠️ Breaking changes (pre-1.0)

  • The cluster wire protocol's discriminator is kind (#494). The framework had three spellings for one concept: t on the cluster wire and the internal coordinator/singleton events, $t on the sharding protocol, kind everywhere else. A rolling upgrade is not possible — a v0.13.0 node and a v0.14.0 node cannot talk. Stop the cluster, upgrade every node, start it again.

  • CborSerializer encodes several values differently (#1036). All of these previously produced something wrong rather than something different, so the migration is usually "delete the workaround": Map, Set, RegExp, Error and the typed arrays no longer encode as {}; undefined is CBOR simple value 23 rather than being dropped; -0 is written as a float instead of collapsing to 0; wrapper objects are unwrapped, and Promise / WeakMap / WeakSet are refused rather than silently encoded as nothing.

  • A restart stops the actor's children (#634). Children used to be inherited by the new incarnation, which made an ordinary pattern impossible: postRestart re-runs preStart, so an actor that spawned a named child there hit Child name … is not unique on its first restart and never recovered. Migration: an actor whose children should outlive a restart overrides Actor.stopChildrenOnRestart() to return false and adopts the survivor in preStartthis.child = this.context.child('name').toNullable() ?? this.context.spawn(Child, 'name'). An instance field cannot carry it across: preStart runs on a fresh instance, so this.child ??= … is always unset and re-spawns into the name the surviving child still holds.

  • A sharded entity's child name escapes its id injectively (#568). entityName() folded every character outside [A-Za-z0-9_-] to _, which is many-to-one — a.b@x.com and a-b@x.com collided, and the collision killed the whole Shard actor along with every unrelated entity in it, including other tenants'. Ordinary ids are unchanged; anything outside [A-Za-z0-9_-.@:+] is now escaped as ~ plus four hex digits.

  • persistenceId is validated before it becomes a storage key (#133). Empty ids, ids over 255 characters, / and \ separators, whole-id . / .. and control characters are rejected in preStart — the rules actor names have had all along, mirrored onto the one identifier in the persistence layer that had no validator.

  • Recovery over a journal compacted without a covering snapshot now fails instead of inventing a state (#122). It used to fold the surviving tail onto initialState() and hand that to onCommand as the current state. Migration: compact only past a snapshot — deleteHistory(seq) keeps the snapshot at seq for exactly this reason — or take one before compacting.

  • redirect() rejects off-origin targets (#125). Forwarding a ?next= parameter into it was a textbook open redirect. Same-origin targets only; use the new redirectExternal(url, status?) where the off-origin hop is deliberate.

  • The HKDF info parameter is required for client-side encryption (#108). info is HKDF's context binding, so the framework-wide constant it defaulted to meant any two deployments holding the same master key derived byte-for-byte the same subkey — a staging environment restored from a production dump could read production snapshots.

  • The two cluster-wide subscriber registries are bounded and watched (#137, #139), and DistributedPubSub's Subscribe takes an optional replyTo (#139). A refused Subscribe is now answered rather than discarded; existing calls compile unchanged and keep following the sender.

  • ClusterOptions.firstSightMaxVersionSkewMs is now maxVersionSkewMs (#114) — same default, same unit, no HOCON key to change. The old name stopped being true once the cap applied to every merge rather than to a first sighting.

  • Smaller ones, each with a note in the CHANGELOG: Publish's third constructor slot is delivery, not the no-op sendOneMessageToEachGroupdo not rewrite true to 'one-subscriber', the old flag broadcast and the new value does not; PersistenceQuery requires the two new query methods; GrpcInbound gains a stream-started arm; ClusterClient.ask() rejects with a generic message; the ORSet wire shape drops counters.

🔒 Security

  • A cluster hello identity is bound to the TLS peer certificate (#912) — the handshake carries no credential of its own, so until now a peer that completed it could claim whatever node identity it liked. Everything the gossip-authority rules rest on sits on top of this.
  • Gossip claims need authority, not just a high version number (#562, #564, #572, #573) and wire frames are validated before anything reads them (#563, #571, #587, #705) — FrameDecoder used to end in JSON.parse(json) as WireMessage. A peer could previously speak for a node it had no relationship with, purely by naming it with a larger version.
  • Every gossiped record is held to the merge-path caps, not just the first (#114, #138), and a first-sight record is held to a tight version-skew cap. Refusals are counted through cluster_gossip_records_refused_total{reason}, with reason closed to three values so the series count cannot follow an attacker's record count.
  • parseCidr accepts canonical IPv4 only (#145, #312) — Number() understood 1e1, 010 and 0x0a as octets, so a non-canonical spelling walked through IpAllowlist in front of /cluster/down and /metrics. Prefix lengths are decimal-only, so a trailing-slash typo no longer reads as /0.
  • A CBOR map key can no longer pick the decoded object's prototype (#567) and a CBOR body can no longer stall the event loop (#567, #618). The encoder likewise refuses cycles and runaway depth instead of overflowing the stack.
  • CRDT payloads are validated before they are merged (#699, #720, #722), DistributedData credits the connection rather than the payload (#719, #723, #725), and a __proto__ store key gossips and persists like any other (#767). ORSet tags are minted from entropy instead of a counter (#722).
  • X-Content-Type-Options: nosniff on every response (#127) — from the backend, not a middleware, so the framework's own 404, its body-parse 413 and every error short-circuit are covered too.
  • Prometheus cardinality is capped per metric family (#131) — a label value derived from user-controlled input used to mint one time series per distinct value, with nothing bounding it, until the Prometheus server OOMed ingesting them.
  • DistributedData bounds its pending quorum requests (#140), cluster membership is capped (#138), ambiguous master-key rings are rejected (#111), AES-GCM IVs are generated inside the encrypt call (#110), a TLS cluster listener refuses to bind in plaintext when only half the credential is present, and the DevTools WebSocket enforces the same-origin default it documented (#566).
  • A ClusterClient no longer learns why an ask failed inside the cluster — the unknown-path reply also stopped leaking the node's own selfAddress, which behind a load balancer is not the address the client dialled.

🐛 Fixed

  • Anycast stopped leaving the node in the topology it exists for (#155, #1091). The two 'one-subscriber' delivery paths shared one rotation cursor but rotated over different candidate lists, and rotate writes the cursor back modulo the count it was handed — so every inbound frame left the cursor below the local subscriber count and the next publish this node originated was guaranteed to pick a local subscriber again. In a symmetric work queue, the topology the feature exists for, nothing ever crossed: measured at eight bodies delivered locally and zero frames sent.
  • A fully compacted journal no longer blocks every later persist (#628), and deleteHistory(toSeq) keeps the snapshot it compacts past (#629).
  • A terminated ActorSystem no longer keeps the process alive (#641, #763), a fired one-shot timer reports itself finished (#642), CoordinatedShutdown.removeProcessHooks() removes only its own (#644), and a stopping actor releases its event-stream subscriptions (#645) — unsubscribe had one caller in the entire framework, so the subscriber list only ever grew.
  • A PersistentFSM state timeout could fire after a transition had already superseded it (#143).
  • A Deno node can join an mTLS cluster (#576) — Deno.connectTls accepts a client key/cert pair and the adapter never passed them, so a Deno node could not answer a listener that correctly demanded a certificate.
  • BrokerActor now actually prunes a subscriber that stops (#1111).
  • HOCON include refuses itself with an explanation instead of a confusing parse error, and the configuration reference no longer documents it as working.
  • SQLite persistence sets an explicit busy_timeout on every connection, with DEFAULT_SQLITE_BUSY_TIMEOUT_MS and buildSqliteDatabase exported so the documented "share one handle" route is usable from outside the package.
  • TcpServerActor refuses at the cap by aborting rather than half-closing (#1096) — end() sends a FIN and nothing more, so a peer that never answers kept the socket and its descriptor alive, uncounted by the cap it had just breached. The refused peer now sees a connection error, which is what capacity refusal looks like at the TCP level.
  • The DevTools handshake reported the wrong framework version (#657) — hand-maintained and stale since 0.11.0, in the one field you trust when triaging. A test now asserts it against package.json, and it caught this release.

🛠 Tooling & CI

  • The fn and cb short forms are spelled out across the API (#1112, #1113) — 136 and 152 occurrences. cb was the interesting one: it carried three unrelated meanings in the same tree (a callback, a CircuitBreaker, and a counter named B), so the sweep had to read every site rather than rename mechanically.
  • UUIDs in src/ are minted through randomUuid() (#1110), which puts the choice of primitive back in the one module that owns where identifiers come from, and removes the last node:crypto import that had a Web Crypto equivalent.
  • Sharding resolves entities and regions by index instead of scanning, and the receptionist, mediator and broker base index their subscribers through BidirectionalMultiMap rather than each keeping the same relation by hand.
  • Documentation corrected where it named parameters that no longer existed, plus three stale signatures the naming sweeps surfaced. Both language versions throughout.
  • A cross-runtime smoke case for the TCP refusal path (#1096) — the adapters return hand-written object literals, so a wrong native method name is not a type error; the case calls destroy() on the socket the adapter actually produced, on Bun, Node and Deno.