Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ These are easy to get subtly wrong. Read the linked docs before editing related
- **Deprecated event versions throw on emit:** the `_v<digits>` naming convention is load-bearing. Adding `Foo_v2` to `.emits({...})` auto-deprecates `Foo`; any static `.emit("Foo")` targeting the legacy version throws at `act().build()`. Reducers (`.patch({Foo: ...})`) stay silent — replay of historical events never warns. Dynamic emits do **not** warn at runtime — the one-line startup advisory enumerates every deprecated event in scope, and `app.registry.deprecated_events(state_name)` exposes the set for callers that want to layer their own policy. The orchestrator and `event-sourcing.ts` stay deprecation-unaware by design. See [event-schema-evolution.md § The versioning convention is the deprecation signal](docs/docs/architecture/event-schema-evolution.md).
- **Tests:** prefer `fixture(builder)` from `@rotorsoft/act/test` for the common case (per-test isolation, parallel-safe, auto-cleanup) and `sandbox(builder)` for multi-Act or `beforeAll`-shared setups. Legacy `store().seed()` in `beforeEach` + `dispose()()` in `afterAll` still works for tests that exercise the singleton port mechanism itself. In tests, prefer the explicit `await app.correlate(); await app.drain();` pair over `settle()` so cycle counts are deterministic — and note the pair is mandatory, not stylistic: `claim` follows the work mark and only `correlate` raises one, so a bare `drain()` after a commit finds nothing (#1488).
- **Reaction backoff is a persisted per-stream schedule.** `ReactionOptions.backoff` paces retries by persisting `deferred_at = now + delay` on the stream via a due-marked `ack` (#1262) — the same store mechanism as an explicit `defer`, except the due-ack carries the climbing `retry` so the budget keeps accruing toward `blockOnError`, whereas a plain defer passes `retry: -1` (a defer is not a failure). Because the schedule lives in the store, **every** competing worker honors the window (the stream is excluded from `claim` until `deferred_at`), no worker re-claims and phantom-bumps `retry` mid-window, and `retry` advances once per real attempt — so a stream blocks after exactly `maxRetries` attempts regardless of worker count. The effective backoff is the configured delay, honored precisely and decoupled from `leaseMillis` (the lease is released on the due-ack, not held through the window). See [error-handling.md § Backoff](docs/docs/concepts/error-handling.md).
- **Lanes give intra-process responsiveness, not just deployment shapes.** `.withLane({...})` spawns one `DrainController` per declared lane plus the implicit `"default"`. `Act._drainAll` runs every controller's `drain()` in parallel via `Promise.all`, so a slow handler holding the slow lane's lease doesn't block the fast lane's claim — even in a single process with no `ACT_ONLY_LANES`. Per-lane `LaneConfig.leaseMillis`/`streamLimit`/`cycleMs` override caller-passed `DrainOptions` (the whole point of `withLane({leaseMillis: 30_000})` is to give the lane its own budget — a caller-level override would erase it). Lane assignments must agree across every reaction targeting the same `target` **regardless of source** (#1325) — a stream drains on one lane and `subscribe` keys lane per-target, so disagreement throws at `classifyRegistry`, because lanes have no `max()` merge analogous to priority. That guard only sees **static** resolvers — a `.to(fn)` lane is a function until an event arrives — so correlate applies the same rules at resolution time, logging rather than throwing (a throw there pins the checkpoint for the whole app, #1420): a disagreement keeps the first-discovered lane and reports it (#1567), and an undeclared lane is rerouted to `"default"` and reported (#1564), because no controller claims it. Re-laning is restart-driven: `subscribe()` UPSERTs each stream's lane on every call; online re-laning while workers hold leases is not supported. See [concepts/configuration.md § Lanes](docs/docs/concepts/configuration.md#lanes) and [guides/production-checklist.md § Sizing lanes](docs/docs/guides/production-checklist.md).
- **Lanes give intra-process responsiveness, not just deployment shapes.** `.withLane({...})` spawns one `DrainController` per declared lane plus the implicit `"default"`. `Act._drainAll` runs every controller's `drain()` in parallel via `Promise.all`, so a slow handler holding the slow lane's lease doesn't block the fast lane's claim — even in a single process with no `ACT_ONLY_LANES`. Per-lane `LaneConfig.leaseMillis`/`streamLimit`/`cycleMs` override caller-passed `DrainOptions` (the whole point of `withLane({leaseMillis: 30_000})` is to give the lane its own budget — a caller-level override would erase it). Lane assignments must agree across every reaction targeting the same `target` **regardless of source** (#1325) — a stream drains on one lane and `subscribe` keys lane per-target, so disagreement throws at `classifyRegistry`, because lanes have no `max()` merge analogous to priority. That guard only sees **static** resolvers — a `.to(fn)` lane is a function until an event arrives — so correlate applies the same rules at resolution time, logging rather than throwing (a throw there pins the checkpoint for the whole app, #1420): a disagreement keeps the first-discovered lane and reports it (#1567), and an undeclared lane is rerouted to `"default"` and reported (#1564), because no controller claims it. Re-laning is restart-driven, and the lane rides the priority max in the store (#1599): `subscribe()` writes a stream's lane only when the incoming priority is **at or above** the stored one, so a restart re-lanes at equal priority while a caller that has forgotten what a stream carries — an evicted LRU record, a fresh process — cannot take a lane it lost. Online re-laning while workers hold leases is not supported. See [concepts/configuration.md § Lanes](docs/docs/concepts/configuration.md#lanes) and [guides/production-checklist.md § Sizing lanes](docs/docs/guides/production-checklist.md).

## Code Organization (pointers, not duplication)

Expand Down
19 changes: 19 additions & 0 deletions book/1599-forgetting-is-not-a-fact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Forgetting is not a fact

A reaction whose target is computed at runtime can produce an unbounded number of targets. The documented shape mints one per aggregate, so an application with a million customers has a million subscription rows, and the process that discovers them cannot remember them all. It keeps a bounded map of what it last subscribed each target at, and when the map is full the oldest entry goes. The map's own comment called itself a memory bound rather than a correctness mechanism, and pointed at the reason: an evicted entry costs at most a redundant write, because the store merges priority by keeping the maximum, so re-sending a stale priority changes nothing.

That was true of priority and false of the lane sitting next to it. The lane was written unconditionally on every subscribe. So the sentence should have read: forgetting a target is free as long as the only thing you forgot was a value the store knows how to merge.

The failure is quiet and it starves work. Two reactions resolve to the same target, one at high priority asking for the fast lane and one at low priority asking for slow. While the record is in the map, the low-priority resolution sees that it does not outrank what is already there, carries the recorded values forward, and the target stays fast. Evict the record and that same resolution reads as the first one ever seen, so it wins by default and writes its own lane. The stream moves to slow. A worker deployed to serve only the fast lane stops claiming it, and the reactions that stream carries stop running. Nothing errors. The priority column, meanwhile, is still correct, which makes the row look healthy to anyone reading it.

The first thing worth saying about the investigation is that eviction turned out to be the smaller half. A missing record reads as never-seen, and eviction is only one way a record goes missing. A restart is another, and it misses everything: a fresh process starts with an empty map while every row persists in the database. So the same re-laning happens on the first low-priority resolution after any deploy, with no memory pressure involved at all, on an application whose bound is set generously enough that eviction never occurs. Any fix scoped to eviction would have closed the narrower half of the hole and left the one that fires on every deploy.

That reframing decided between the candidates. Reading the row back before deciding whether a target is new is correct and covers both halves, but the store's stream filter has no way to ask for a named set of streams — the portable filter grammar is anchors, dots and literals, with no alternation — so a read-back costs one round trip per unknown target rather than one per scan. Under the very access pattern that makes the map overflow, every scan is full of unknown targets. Detecting and reporting the drift instead is cheaper, and it is what the sibling tickets in this family chose for lane disagreements, but it is not implementable here: the thing that would have to notice is the record, and the record is what went missing. There is nothing to compare against, so there is nothing to report.

What is left is the rule the codebase was already applying next door. Priority survives being forgotten because the store merges it. Give the lane the same treatment and the lane survives too: write it when the incoming priority is at or above the stored priority, leave it alone below. The highest priority registered for a stream owns its lane, durably, and the bounded map goes back to being what it always claimed to be — an optimization that saves a write, holding no invariant of its own.

Equal priority has to write the lane, and that is not a compromise. Moving a stream between lanes is done by editing the declaration and restarting, and a restart re-subscribes at the same declared priority. A rule that only wrote the lane on a strict increase would freeze every lane assignment at whatever it was first given and turn an ordinary config edit into a data migration. Writing at equal priority preserves that, and it leaves one narrow corner: a stream whose priority was raised out of band by an operator override keeps its lane until a subscribe reaches that priority. The alternative was a flag on the write to mark it authoritative, which is more surface than the corner is worth.

The change is a port contract change, so it lands in all three adapters and in the compatibility kit that third-party adapters run. That is the honest cost of putting the invariant in durable state, and it is also the mechanism that catches anyone who implemented the old rule: the new cases fail against an unchanged adapter, which is exactly the signal an executable contract exists to send. Both cases were written first and confirmed red on all three implementations, and the orchestrator-level regression pins the part an adapter test cannot see — that under lane sharding, the reaction on the mis-laned stream simply never runs.

The general lesson is about where an invariant is allowed to live. A component with a bounded memory cannot be the keeper of a rule, because the bound will be reached and the rule will quietly stop applying. It can cache the answer; it cannot own it. Whenever a design says "we remember this so we don't do the wrong thing," the question to ask is what happens on the pass where it does not remember, and whether the answer is a redundant write or a silent behavior change.
1 change: 1 addition & 0 deletions docs/docs/architecture/behavior-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ backed by unit/integration specs under `libs/act/test/`.
|---|---|---|
| `subscribe` is idempotent on repeat | `Store.subscribe` doc | `store-tck.ts` → "subscribes new streams and is idempotent on repeat" |
| `subscribe` keeps the **maximum** priority when a stream is re-subscribed with a different priority — across the whole number line, not just above zero. Because `subscribe` is restart-driven, a `prioritize()` downgrade below the declared priority is restored on the next boot rather than being sticky forever | `Store.subscribe` `priority` doc-comment; `Store.prioritize` doc | `store-tck.ts` → "keeps the maximum priority when a stream is re-subscribed" **(gap filled — #1029)**, "keeps the maximum for negative and zero priorities too", "restores the declared priority on the next subscribe after a prioritize() downgrade" **(#1445 — SQLite gated its merge on `priority > 0`, so a negative priority was unraisable; the existing cases all used positive values, where the gate is a no-op)** |
| A stream's **lane** rides the priority max in `subscribe`: written when the incoming priority is at or above the stored one, left alone below it. Restart-driven re-laning still works (a restart re-subscribes at the same declared priority), and a caller that has forgotten what a stream carries — an evicted LRU record, a fresh process — cannot re-lane it from underneath the highest-priority reaction | `configuration.md` § Lanes; `SubscribeInput.lane` doc | `store-tck.ts` → describe("lanes"): "keeps the lane when a lower-priority subscribe re-registers", "keeps the lane when a mark-only subscribe re-registers", "re-lanes when the subscribe outranks the stored priority", "re-lanes at equal priority, which is what keeps re-laning restart-driven"; `correlate-lane.spec.ts` → describe("dynamic targets survive a forgotten record (#1599)") **(#1599)** |
| A **dynamic-resolver** target's priority/lane obeys the runtime `max()` invariant **across correlate scans** — a later scan resolving a higher priority re-subscribes it (raising the store's priority + carrying the winning lane); a lower-or-equal resolution dedups without downgrading. The orchestrator's `_dynamic_subscriptions` map records the last-subscribed priority per target instead of a plain presence set, so the guarantee isn't frozen at first discovery | `priority-lanes.md` § "the same `max()` invariant holds at runtime" | `correlate-lane.spec.ts` → describe("cross-scan priority upgrade (#1363)"): "a later higher-priority scan raises the target's priority and lane", "a later lower-priority scan does NOT lower the target (max holds)" |
| A dynamic resolution naming an **undeclared** lane is rerouted to `"default"` and reported once per offending declaration, rather than stranding the stream at watermark `-1` where no controller claims it and no health surface shows it. The build-time guard sees static lanes only; `TLanes` rejects both forms at compile time, so this backstop fires only when the types are bypassed | `configuration.md` § Conflicting lane assignments; CLAUDE.md "Lanes give intra-process responsiveness" | `dynamic-lane-guard.spec.ts` → "still runs the reaction instead of stranding the stream", "says so — a rerouted lane is not silent", "reports once, not once per matching event" **(#1564)** |
| Both dynamic-lane reports are keyed on the **declaration** — handler name plus lane(s) — never on the resolved target, so one misdeclaration costs one report no matter how many aggregates its resolver mints targets for. Two distinct misdeclarations still report separately | `configuration.md` § Conflicting lane assignments; `report-once.ts` module doc | `dynamic-lane-guard.spec.ts` → describe("reporting once per declaration, not once per aggregate (#1584)"): "still one report when the same declaration reroutes 25 targets", "still one report when the same pair disagrees on 25 targets", "CONTROL — two declarations naming the same bad lane report twice", "CONTROL — two distinct bad pairs report twice" **(#1584)** |
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/architecture/correlation-and-drain.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ It no longer decides *whether* a target is re-subscribed (every marked target is

Only **dynamic** targets live in the LRU. A static target's record is held in a plain map alongside it, never evicted — the collection is the build-time list of static targets, already bounded by the registry ([#1582](https://github.com/Rotorsoft/act-root/issues/1582)). Sharing the bounded map made the `+Infinity` floor a matter of luck: evict a static record and the next dynamic resolution to that target saw no record, read that as never-seen, and re-subscribed the target with its own lane — re-laning a stream whose lane the build-time subscribe owns, and starving it wherever `onlyLanes` had provisioned a worker for the declared lane. There is nothing to warn about now, because there is no path left that reaches it.

Eviction cost, for a dynamic target: the next resolution re-sends its own priority and lane instead of the row's. For those the LRU is a memory bound, not a correctness mechanism.
Eviction cost, for a dynamic target: the next resolution re-sends its own priority and lane instead of the row's. That is harmless because the store merges both — priority keeps the max, and the lane rides that same max ([#1599](https://github.com/Rotorsoft/act-root/issues/1599)), so a resolution that lost the lane cannot take it back by being forgotten. For those the LRU is a memory bound, not a correctness mechanism.

## Drain — claim, fetch, dispatch

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/architecture/extension-points.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Since [#1488](https://github.com/Rotorsoft/act-root/issues/1488) the matching th

`truncate` handles two target shapes in one call. A **full** target (`{ stream, snapshot?, meta? }`) deletes every event for the stream and seeds a single final event — `__snapshot__` when `snapshot` is provided, `__tombstone__` otherwise. A **windowed** target (`{ stream, before, max_id? }`, added in [#1011](https://github.com/Rotorsoft/act-root/issues/1011)) is a pure prefix delete behind a real snapshot the app wrote: the store finds the closest safe boundary — the latest `__snapshot__` with `created < before` and, when `max_id` is given, `id <= max_id` — and deletes events below it, keeping the snapshot and the tail. No seed, no tombstone, and the streams table is untouched, so the stream stays live and claimable. No qualifying snapshot means a no-op, with the stream absent from the result. `snapshot`/`meta` must be omitted on windowed targets. Windowed result entries echo `before`, and their `committed` is the surviving boundary snapshot rather than a new seed — that's how `closed`-event consumers tell prunes from full closes.

`claim` takes an optional `lane` filter (ACT-1103). When set, only streams in the named lane are eligible; when omitted, the claim spans every lane — preserving pre-1103 behavior. Adapters that haven't migrated yet can leave `lane` unread on the SQL side and still satisfy the contract until they opt in. `subscribe`'s row shape gained an optional `lane` field for the same release; adapters UPSERT it on every call so a restarted Act with a new lane assignment moves streams without a manual migration.
`claim` takes an optional `lane` filter (ACT-1103). When set, only streams in the named lane are eligible; when omitted, the claim spans every lane — preserving pre-1103 behavior. Adapters that haven't migrated yet can leave `lane` unread on the SQL side and still satisfy the contract until they opt in. `subscribe`'s row shape gained an optional `lane` field for the same release; adapters write it whenever the incoming priority is at or above the stored one, so a restarted Act with a new lane assignment moves streams without a manual migration (a restart re-subscribes at the same declared priority) while a lower-priority registrant cannot take a lane it lost ([#1599](https://github.com/Rotorsoft/act-root/issues/1599)).

`query_stats` is the per-stream-aggregate primitive (added in [ACT-639](https://github.com/Rotorsoft/act-root/issues/639)). Default returns the head event per stream via an indexed path; opt-in `count`/`tail`/`names` trigger a full scan but share it. Input is `string[]` for an enumerated set or `Pick<StreamFilter, "stream" | "stream_exact">` for pattern selection — subscription-level filters (`source`, `blocked`) live on `query_streams`; compose the two for "stats for blocked subscriptions" workflows. The returned `head`/`tail` **never carry `pii`**: `query_stats` has no actor context and no disclosure gate, so — like any un-gated read — your adapter must omit the pii sidecar here (route pii through the gated `load` path instead), matching InMemory/PG/SQLite ([#1294](https://github.com/Rotorsoft/act-root/issues/1294)).

Expand Down
Loading