Skip to content

v0.13.0

Choose a tag to compare

@pathosDev pathosDev released this 05 Aug 03:50
· 1369 commits to main since this release
80fa80c

The names and lifecycle release. Two threads that turned out to be the same thread: what an actor is built from, and when it goes away. Props is gone — spawning takes the actor class, and per-actor configuration became ActorOptions, an ordinary options family like every other one in the framework. Sharded entities now passivate by default, empty shards stop with them, and the generated names the framework hands out are no longer guessable. Underneath, a cluster-transport bug that could partition two healthy nodes permanently, and the discovery that the documented mTLS recipe was never actually authenticating anyone. Pre-1.0 — this minor carries breaking changes; see below.

🚀 New features

  • ActorOptions (#547) — withSupervisorStrategy, withDispatcher, withMailboxCapacity, withMailbox, withInternal, withEntity, withDisplayName, plus an ActorOptionsValidator that rejects a non-positive mailboxCapacity at the spawn call rather than from inside the mailbox constructor. Accepted as a builder or as a plain object, like every other options family.
  • Actor.displayName() — a readable name for an actor in logs and DevTools (#891). A path is an address, not a name: under sharding the log source grows to ~120 characters of machine identifier, and the business identity it stands for had to be repeated by hand in every message the entity logged. Override displayName() and the actor says it once — the name joins the line as its own segment (... - User(test-user-590) - recovery complete) and labels the row in the DevTools actor tree, which is worth the most for Behaviors actors whose class column reads TypedActor on every row. Also settable at the spawn site with ActorOptions.withDisplayName(...) and at runtime with context.setDisplayName(...). Defaults to the path, so existing log output is unchanged, and it stays a label: metrics, tracing, dead letters and every cluster-wire identifier keep using the path.
  • Empty shards passivate (#892). A shard actor used to outlive its entities indefinitely — since entity ids spread over the hash space, a long-running node accumulated one idle, empty shard actor per numShards. A shard that has stood empty for shardPassivationIdleMs is now stopped too. The region keeps ownership, so the shard stays routable and the next message re-creates it with no coordinator round trip.
  • shardPassivationIdleMs / withShardPassivationIdleMs() / actor-ts.sharding.shard-passivation-idle (#892). Unset, it follows passivationIdleMs; 0 keeps empty shards resident while entities still passivate.
  • ShardInfo.resident (#901) — ClusterSharding.shards() now reports whether each shard actor was materialised when its region answered. entityCount: 0 cannot say that on its own: a running-but-empty shard and one that passivated report the same count.

⚠️ Breaking changes (pre-1.0)

  • Props is gone from the public API (#547). Spawning takes the actor class or a factory directly:

    // before
    system.spawn(Props.create(() => new Greeter()), 'greeter');
    system.spawn(Props.create(() => new Worker(db)).withMailboxCapacity(500), 'w');
    
    // after
    system.spawn(Greeter, 'greeter');                      // zero-arg class
    const workerOptions = ActorOptions.create<WorkerMessage>().withMailboxCapacity(500);
    system.spawn(() => new Worker(db), 'w', workerOptions);

    Props bundled two unrelated things — what to construct and how to run it — and 75 % of its ~970 call sites used only the first. Migration: drop Props.create( and its closing ); move each .withX(…) into a third ActorOptions argument; asInternal() becomes withInternal(). Renamed carriers: entityPropsentityActor, singleton propsactor, singletonPropssingletonActor, childPropschild, routeePropsroutee, behaviorForactorFor; BackoffSupervisor.props.factory, ClusterRouter.props.factory, typedPropstypedActor. Two behavioural notes: ActorOptions mutates in place where Props was copy-on-write (settings are snapshotted at spawn), and a class whose constructor takes arguments is now rejected at the spawn call instead of being constructed with undefined dependencies.

  • Idle entities passivate by default, after 5 minutes (#892). passivation-idle shipped as 0ms, so nothing ever passivated until an operator went looking for the key, and entity sets only grew. Migration: an entity that keeps state in memory and does not rebuild it in preStart now loses that state after five minutes idle — persistent entities recover, plain ones do not. Set passivation-idle = 0ms to restore the old behaviour. ShardedDaemonProcess opts out on its own.

  • The cluster TLS listener requests a client certificate (#565). See Security — a cluster already passing ca starts demanding peer certificates, and mutual TLS on Deno is now refused rather than silently skipped.

  • Anonymous actors are named $anonymous-<n>-<random>, not $1 / $2 (#895). The old per-parent counter was both opaque and guessable — /user/$1 is the first anonymous actor of every run, and a path is an address. Migration: code that hard-codes an anonymous path or parses $<n> out of a name must spawn with a name of its own.

  • Unnamed reliable-delivery controllers are consumer-<n>-<random> / producer-<n>-<random> (#897). The fallback came from a module-global counter, so /system/delivery/consumer-1 was the first one of every run, and two ActorSystems in one process drew from the same sequence.

  • Actor names starting with $ are reserved for the framework (#900). spawn and spawnTyped now reject them — until now anyone could claim the prefix spawnAnonymous generates. A $ anywhere other than the first character is unaffected.

🔒 Security

  • The cluster TLS listener never requested a client certificate (#565, severity: critical). requestCert hard-defaulted to false in both listener adapters, and requestClientCert was never set to true anywhere in the repo. On a server rejectUnauthorized does nothing unless requestCert is on — so the mTLS recipe the Cluster security page documents produced server-authenticated TLS only. Since the hello handshake carries no credential of its own, that left the peer certificate — the cluster's only admission control — unrequested: anything that could reach the remoting port completed the handshake presenting nothing and then claimed whatever node identity it liked. It is also the mitigation several other findings lean on, so until now those notes promised more than the transport delivered. requestClientCert now defaults to ca !== undefined, and two incoherent configurations fail closed at bind time: requestClientCert: true with no ca, and mutual TLS on Deno, where Deno.listenTls cannot request a client certificate and the dialer sends none.
  • Quorum correlation ids in DistributedData are no longer guessable (#896). nextPendingId() returned p<Date.now()>-<counter>. That value travels on the wire and the peer echoes it back on its acknowledgment, so a guessable id is one whose acknowledgment can be forged — satisfying a quorum write or read no peer actually confirmed. Now sixteen random hex characters.
  • A ClusterClient's own wire identity no longer comes from Math.random() (#910). The synthetic port a client names itself by goes into the NodeAddress it announces and keys the cluster's byPeer map, so it is an address — and Math.random() is not a CSPRNG. The comment above it claimed hrtime-derived randomness, which the code never did. Now drawn with crypto.getRandomValues across the whole ephemeral range; the old 15 000-slot window also made accidental collisions likely at a few dozen clients per process, which was a correctness problem on its own.
  • Filesystem object-storage temp paths no longer come from Math.random() (#898). The atomic-write temp file was named with the clock and Math.random(), so a local process sharing the directory could predict the path and pre-create it or plant a symlink there.

🐛 Fixed

  • Two nodes that dial each other at the same moment no longer stay split forever (#697). openOutbound registers a connection in byPeer before the handshake, and the hello-hijack guard compared identity alone — so in a crossing dial each node held an un-acked outbound under the other's key and rejected the other's perfectly legitimate hello. Neither dial then received its hello-ack, and onClose released the slot only if the handshake had completed: no re-dial, and every frame for that peer accumulating silently in the handshake buffer. The pair was partitioned for the lifetime of the process. Cleanup is now keyed on the dialled address, a 5 s handshake deadline reclaims a dial that connects but never acks, the buffer is capped, and a crossing dial is settled by address order so exactly one survives. An established peer connection is still never displaced.
  • rememberEntities no longer forgets every entity when a shard rebalances (#632). The departing region announced an EntityStopped for every entity of the shard, which emptied the coordinator's registry — so when the shard was reallocated there was nothing left to ship to the new owner. A rebalance is the ordinary path, so this was rememberEntities failing at the one thing it exists for; it survived because the only coverage was a cold restart, which reloads from the journal and never exercises a live handoff.
  • Filesystem object storage stopped recognising its own temp files (#909). The Math.random() removal above changed the temp-file name without updating the pattern list() uses to skip them, so a crashed writer's partial body was reported as an ordinary object. The test had staged the old shape as a literal, which still matched the stale pattern — so it passed while the behaviour it guards was gone.
  • Messages buffered during a handoff are no longer stranded (#893). completeHandOff cleared the region's cached shard home without ever replaying the buffer, and the coordinator announces a new placement only to the new owner — so on a shard that went quiet after the rebalance, the trigger never came.
  • A shard ref for a remote shard no longer drops messages while that shard is passivated (#901). Remote shard traffic now goes to the owning region, which materialises the shard before forwarding.
  • Remembered entities return after an unexpected shard death (#894). Ownership stayed put, which is what lets the next message re-create the shard — but that also meant nothing ever re-shipped the remembered registry.
  • preRestart never stopped children, whatever its documentation said (#899). No behaviour changed; the documentation now matches, and the consequence it was hiding is spelled out — an actor that spawns a named child in preStart fails its first restart.
  • The sharding-failover churn test no longer measures the scheduler (#902). It bounded a sample count by a wall clock, and instrumentation showed the sample count was ~100 % Windows timer granularity.

🛠 Tooling & CI

  • The six example frontends are built in CI (#903). No workflow path filter covered examples/**, so a PR touching only those directories produced zero checks — and ts-pattern was missing from all six lockfiles without anything noticing.
  • Six advisories cleared in the Angular example frontends (#904), bundles rebuilt for Next 16.3.0 (#905), and Dependabot no longer re-proposes @types/node majors against the deliberate engines floor (#906).
  • The docs API-drift guard covers the Props removal (#907). It runs in CI and its own header says to add a pattern whenever an API is renamed or removed — the largest removal the project has made had added none.
  • publish.yml has a concurrency group, closing the TOCTOU window between the release: published trigger and the workflow_dispatch fallback, and multi-runtime.yml now runs on main so the tagged release merge gets a Node/Deno signal.