Skip to content

v0.17.0

Latest

Choose a tag to compare

@pathosDev pathosDev released this 29 Aug 21:46
a5634ee

The dead letters, DevTools and the wire release — 828 commits since v0.16.0 and 291 changelog entries, by a wide margin the largest window this project has had (v0.16.0, the previous record, was 188). Three threads run through it. The wire — cluster frames stop being a bare JSON.stringify and become the same tagged JSON tree the persistence layer already wrote, so the framework stops contradicting itself across its own boundaries. Dead letters — an undeliverable message stops vanishing into an event stream nobody subscribed to and gets a bounded, inspectable, replayable queue. DevTools — the UI becomes something you would actually leave open, with four new panels and the ability to stop time.

Fifty-three entries carry a BREAKING marker, each with a migration note. One of them decides how you deploy this, and it is first below.

⚠️ Breaking changes (pre-1.0)

  • A rolling upgrade across this release is not safe in either direction — and this is the second consecutive release for which that is true (#450). Cluster frames are now the tagged JSON tree, applied to the whole frame rather than an envelope's body. The old format was a bare JSON.stringify, and the cost was that the framework disagreed with itself depending on which boundary a value crossed: a Map an actor could persist and recover verbatim arrived at a peer as {}, a Date arrived as a string whose .getTime() throws, a Uint8Array arrived as an index-keyed object, NaN and -0 arrived as null and 0, and a bigint threw straight out of TcpTransport.send — which is to say, out of your own ref.tell. One walker now serves HTTP bodies, journal rows and the wire alike, so there is no per-transport list of what a message may contain to keep in sync.

    Migration. Most legacy traffic decodes unchanged, because decodeJsonTree reads a tag only when it is an object's sole own key. The exception is a legacy body that already had that shape: __map__, __set__, __regexp__, __bigint__, __url__, __number__ and __error__ throw at any depth, and a decoder throw costs the whole connection along with every frame batched into the same chunk; __bytes__ and __date__ fail silently instead, decoding to a Uint8Array and an Invalid Date. The other direction is plainly lossy — an older node reads the tag wrapper as ordinary data.

    Upgrade the cluster in one step: stop every node, then start every node on the new version. v0.16.0 already broke gossip compatibility the same way (#112), so a hop from v0.15.x lands on two non-rollable legs and needs the same treatment once rather than twice. The upgrade documentation now carries a per-release compatibility table (#1304), and #823 — a protocol version handshake — is what will make a mixed-version window a supported state rather than a hazard.

  • Cluster.bootstrap rejects when readiness is missed (#943, #1086). A resolved bootstrap() now means a formed cluster. awaitReady widens to boolean | number | ClusterReadinessOptions, and its default budget covers the self-election grace on every stable-observation node — the old default under-covered N−1 of N nodes on a genuine cold start. On timeout the bootstrap runs the coordinated-shutdown pipeline and rejects with ClusterReadyTimeoutError instead of resolving for a node still joining and letting it serve traffic. Migration: awaitReady: false plus cluster.awaitReady().catch(…) restores the old fire-and-forget shape.

  • ActorCell handles a batch of user messages per dispatcher turn (#409) — worth 2.1×–3.6× on tell throughput. Configurable per actor through ActorOptions.withThroughput().

  • Three cluster-correctness defects that all had the same shape: two authorities where there should be one. A routine singleton scale-up no longer runs two instances (#949); KeepMajority now downs both sides of an exact 50/50 split rather than returning the empty set and leaving both halves live (#1170); and two nodes that disagree about numShards are no longer allowed to double-home entities silently, including via a persisted coordinator snapshot that used to route around the refusal (#633).

  • system.terminate() drains the actors under /user before stopping them (#663). ref.tell('x'); await system.terminate() now delivers x.

  • A bounded mailbox's capacity bounds the messages it may discard, not the messages it holds (#729) — and messages a bounded or priority mailbox discards can now become dead letters (#773) instead of vanishing.

  • BrokerActor.onReceive is sealed (#709). Subclasses implement the new abstract onCommand(command) instead.

  • Dead letters name the actor the message failed to reach, on every path (#433).

  • Projections gained a handler-failure recovery strategy and no longer retry a poison event forever (#650).

Forty more are in CHANGELOG.md, each with its own migration note.

🚀 New features

  • A bounded, optionally durable dead-letter queue with inspection and replay (#1000, #433). Undeliverable messages were published to an event stream that nothing subscribed to by default — which meant they produced no output at all, while two documentation pages claimed the system logged them. There is now a real queue, configured through ActorSystemOptions.withDeadLetters(…), with a metrics store option, and deadLetterQueue.replay(id, alternateRecipientPath) to replay a message to a recipient other than the one it was addressed to.

  • DevTools grew four panels and a pause button (#482, #553, #1349). The UI is Angular throughout, and gains a dead-letter panel, a live event-stream tail, a resolved-configuration panel showing every HOCON key and where its value came from, and a send-message action that is off by default. Time can be paused and resumed from the header. It also gained roughly 3,000 lines of tests, having previously had none (#487).

  • Cluster operability: wait until the cluster is actually formed (#943); ClusterOptions.advertisedHost for nodes behind NAT or a service mesh (#944); warm hand-over for singletons, so a scale-up transfers state instead of rebuilding it; and ClusterSharding.shardMap(typeName).

  • Persistence: PersistentActor can be fenced with a lease (#1166), so a stale instance cannot keep writing; PersistentActor and DurableStateActor gained an integrity() hook; InMemorySnapshotStore accepts a keepN retention bound; Postgres and MariaDB projections read the tags index instead of scanning.

  • The framework comparison is complete (#27, #1327). Nine arms across three runtimes — actor-ts, nact and XState on Bun; Akka and Pekko through both their Java and Scala APIs on the JVM; Akka.NET and Orleans on .NET — measured as a hundred interleaved rounds on one Linux machine, every row verified against work the system actually completed rather than work it was asked for. See RESULTS.md.

  • Observability: actor_mailbox_depth, actor_mailbox_wait_seconds and actor_dispatcher_queue_delay_seconds; /health and /ready aggregate framework-owned health checks; ActorSystem.runUntilTerminated() owns the whole of a service's shutdown.

  • An email bridge actor and HTML email templates (#1133).

🔒 Security

Seventy-one entries — the largest security section this project has shipped. Among them: a ProducerController now stamps a crypto-random per-incarnation token on every Delivery and the ConsumerController refuses one that does not echo it; a replicated event's author is bound to the node that sent it, so a member can no longer permanently suppress another replica's events (#706); object-storage bodies are bound to the storage key they live at, so a durable-state revision cannot silently go backwards; DistributedData gossip is bounded by a per-frame byte budget; a WebSocket route's transport frame cap is the cap you configured, in both directions (#373, #586); a decoded CRDT counter slot is bounded; and the object-storage backends and the master-key rotation sweep now share one key policy (#747).

🐛 Fixed

Ninety-one entries. The tooling half is worth calling out on its own, because it is what makes the rest checkable: typecheck:dev went from 320 errors to zero and became a gated CI job (#540) — it is the only gate that compiles the library from a caller's position, and it immediately turned up five exported declarations no caller could use. Alongside it: the coverage gate stopped being two implementations of one parse, with per-module floors a rollup of per-file percentages cannot express (#541, #1016); the bundled examples now run in CI (#559); the three quarantined multi-node suites run nightly with the skip flag off; and a repeat-run flake harness (bun run test:stress) landed with a Diagnosing flakes page to go with it.


Install: bun add actor-ts · npm install actor-ts

Published with npm provenance via Trusted Publishing (OIDC). Full detail in CHANGELOG.md; what is planned next is in ROADMAP.md.