Skip to content
Merged
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
15 changes: 15 additions & 0 deletions book/1598-an-omitted-lane-is-a-lane-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# An omitted lane is a lane name

A build-time guard rejects two reactions that route to the same stream on different lanes, because a stream drains on exactly one lane and lanes have no ordering to merge across. That guard can only read what the declaration says out loud. A reaction whose target is a function is opaque until an event arrives, so correlate resolves it and applies the same rules at the only moment the answer exists. It cannot throw there, since an exception inside correlate stops the checkpoint for every stream in the app, so it logs instead. The log is the entire diagnostic. When it stays quiet, the operator has nothing.

The quiet case was the one an operator was most likely to write. Two resolvers agreeing on a target and disagreeing on a lane were compared as they were spelled, and one of the two ways to spell the default lane is to say nothing at all. So a resolver that returned a bare target and one that asked for the slow lane compared as undefined against a string, the comparison was skipped for want of a second lane name, and nothing was reported. The static form of the identical pair had been taught the opposite lesson a few tickets earlier, when the build-time guard learned to normalize both sides before comparing so that an omitted lane and an explicit default would stop being reported as a conflict with each other. That fix normalized one side of a family and left its sibling reading raw values.

What that costs is the whole reason the lane was named. First discovery wins, so the target lands on the default lane, and a worker started with `onlyLanes` set to the slow lane never claims it. The slow reaction does run, inside the default lane's lease and stream budget, on a process nobody sized for it. Every symptom points at the deployment: a lane provisioned and idle, work appearing on the wrong instance, handlers occasionally outliving a lease they were never measured against. The one line that would have named the cause is the line that did not print.

Normalizing both operands is a one-line change and it opens a second hole immediately. If undefined becomes default on the held side, then a target nobody has ever resolved before, whose lane is undefined because there is no record of it at all, suddenly holds the default lane and disagrees with the first reaction that asks for anything else. Every first sighting of a laned target would report a conflict with a stream that does not exist yet. Absent and default look identical once you normalize, so the distinction has to be carried somewhere else: whether there is a record, not what the record says. That is why the report is gated on the existence of the prior resolution rather than on the definedness of its lane.

Reading the two sides against each other turned up a third thing worth correcting in the same pass. The report is meant for ties, the case where neither resolution outranks the other and the winner is therefore arbitrary from the operator's chair. When priority decides, the outcome is the documented maximum rule and not a surprise. But the tie was being tested against the accumulating entry rather than against the thing the lane was actually held by, and for a resolution that beat the target's recorded priority the entry had already taken that resolution's own priority. The test compared a number with itself, passed, and reported a conflict for what was in fact a clean priority upgrade. Comparing against the recorded priority instead makes the predicate say what the comment always claimed it said.

The rejected repair was to make the dynamic path throw like the static one, which reads as consistency and is the one thing this path may never do. The other was to correct the lane rather than report it, moving the stream onto whichever lane the newest resolution asked for. A live stream's lane is the one its in-flight leases were taken under; re-laning it mid-run moves it out from under a worker that is holding it. Re-laning stays restart-driven, and the report stays a report.

The lesson is smaller than the machinery around it. A value with two spellings has one meaning, and every place that compares it has to agree on which spelling it compares in. Fixing one comparison and leaving its sibling raw is how a normalization bug survives its own fix, which is precisely what happened between the static guard and the dynamic one.
1 change: 1 addition & 0 deletions docs/docs/architecture/behavior-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ backed by unit/integration specs under `libs/act/test/`.
| 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)** |
| Two dynamic resolutions disagreeing on one target's lane at **equal priority** keep the first-discovered lane and report the conflict — the lane is not corrected mid-run, because a live stream's lane is the one its in-flight leases were taken under and re-laning is restart-driven. The equivalent static declaration still throws at build | `configuration.md` § Conflicting lane assignments | `dynamic-lane-guard.spec.ts` → "CONTROL — the static form is still rejected at build", "says so — first-discovery-wins is not silent" **(#1567)** |
| The **dynamic** lane-conflict report compares the resolved lane name too, so an omitted lane and a declared one are the disagreement the static guard throws on — and it fires only on a genuine tie: a never-seen target has no held lane to disagree with, and a resolution that beats the target's recorded priority outranks rather than conflicts | `configuration.md` § Conflicting lane assignments | `dynamic-lane-guard.spec.ts` → describe("an omitted lane disagreeing with a declared one (#1598)"): "reports the same disagreement on the dynamic path", "reports it in the other discovery order too", "reports it across scans, where the held lane comes from the row", "reports a rerouted undeclared lane against a stream already laned", "CONTROL — a first resolution onto a never-seen target is not a conflict", "CONTROL — a higher-priority resolution outranks, it does not conflict" **(#1598)** |
| The static lane-agreement guard compares the **resolved** lane name, so an omitted `lane` and an explicit `lane: "default"` on the same target agree and the build succeeds; a genuine disagreement still throws, naming the two distinct lanes | `configuration.md` § Conflicting lane assignments | `lanes.spec.ts` → "accepts an explicit 'default' followed by an omitted lane", "accepts an omitted lane followed by an explicit 'default'", "builds the failure scenario from #1583 with no cast anywhere", "still rejects a declared lane against an omitted one" **(#1583)** |
| `prioritize` sets priority **directly**, overriding `subscribe`'s max() rule | `Store.prioritize` doc | `store-tck.ts` → "sets priority directly, overriding subscribe's max() rule" |
| `unblock` returns 0 when the stream is not blocked; `reset`/`unblock` return 0 for unknown/empty input | `Store.unblock` / `Store.reset` docs | `store-tck.ts` → "returns 0 when the stream is not blocked", "returns 0 for unknown streams and empty input" |
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/concepts/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,8 @@ act()

A **dynamic** `.to(fn)` resolver is a function until an event arrives, so the build-time scan has nothing to inspect. Correlate applies the same rules where the answer finally exists:

- Two dynamic resolutions disagreeing on one target's lane at equal priority keep the lane the target was first discovered on, and log an error naming both. The lane isn't corrected mid-run — a live stream's lane is the one its in-flight leases were taken under, and re-laning is restart-driven. Until you align the resolvers, the losing reaction runs inside the winner's `leaseMillis` and `streamLimit`, and a process restricted to the losing lane via `onlyLanes` never runs it at all.
- A resolution naming an **undeclared** lane is rerouted to `default` and logged. No controller claims an undeclared lane, so the stream would otherwise sit at watermark `-1` forever, invisible to `blocked_streams()` and every other health surface. Note that `TLanes` already rejects an undeclared lane at compile time for both resolver forms, so this backstop only fires when the types are bypassed — JavaScript callers, a cast, or a helper whose return type widens `lane` to `string`.
- Two dynamic resolutions disagreeing on one target's lane at equal priority keep the lane the target was first discovered on, and log an error naming both. The lane isn't corrected mid-run — a live stream's lane is the one its in-flight leases were taken under, and re-laning is restart-driven. Until you align the resolvers, the losing reaction runs inside the winner's `leaseMillis` and `streamLimit`, and a process restricted to the losing lane via `onlyLanes` never runs it at all. "Disagreeing" is decided on the **resolved** lane name here too: an omitted `lane` is the default lane, so a resolution that leaves it off and one that names `slow` are the same conflict the build-time guard throws on, reported with `"default"` spelled out. A resolution that outranks what the target already carries isn't a conflict — priority decides the lane, and that outcome is the documented `max()` rule rather than a silent tie.
- A resolution naming an **undeclared** lane is rerouted to `default` and logged — and the rerouted lane is compared like any other, so a target already on `slow` reports the conflict too. No controller claims an undeclared lane, so the stream would otherwise sit at watermark `-1` forever, invisible to `blocked_streams()` and every other health surface. Note that `TLanes` already rejects an undeclared lane at compile time for both resolver forms, so this backstop only fires when the types are bypassed — JavaScript callers, a cast, or a helper whose return type widens `lane` to `string`.

Both are reported **once per offending declaration, not once per target**. A resolver fires for every matching event and the documented per-aggregate shape `.to(e => ({target: e.stream}))` mints a fresh target each time, so a per-target report would scale the log with your aggregate count instead of with the number of declarations you have to fix. The report is keyed on the reaction's handler name plus the lane(s) involved; one resolved target rides along in the message as a concrete example to go look at. Two different misdeclarations still report separately — and a lane conflict reports once per direction it lands, since which side wins is "first discovered" and can differ from target to target.

Expand Down
72 changes: 46 additions & 26 deletions libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -7971,7 +7971,7 @@ export class ConsoleLogger implements Logger {
*/

import { createHash, randomUUID } from "node:crypto";
import { log, store } from "../ports.js";
import { DEFAULT_LANE, log, store } from "../ports.js";
import type {
EventRegister,
Query,
Expand Down Expand Up @@ -8657,22 +8657,32 @@ export class CorrelateCycle<
// rejects for static declarations (#1567). Priority still decides
// below; this only reports the tie the operator can't otherwise
// see. Compare against what this target already carries, whether
// that came from an earlier reaction in this scan or a past one.
const held = correlated.has(resolved.target)
? entry.lane
: recorded?.lane;
if (
held !== undefined &&
lane !== undefined &&
held !== lane &&
priority === entry.priority
)
// that came from an earlier reaction in this scan or a past one,
// and against that same source's priority — a resolution that
// beat the floor outranks what it found rather than tying with
// itself.
//
// Both lanes are compared by their resolved name, exactly as the
// static guard does (#1583): an omitted lane *is* the default
// lane, and the default lane is what the subscription row ends up
// holding, so an omitted lane against a declared one is a real
// disagreement. A never-seen target holds no lane at all, which is
// not the same as holding "default" — the record's existence is
// what gates the report.
const seen_in_scan = correlated.has(resolved.target);
const held_lane =
(seen_in_scan ? entry.lane : recorded?.lane) ?? DEFAULT_LANE;
const held_priority = seen_in_scan
? entry.priority
: recorded?.priority;
const resolved_lane = lane ?? DEFAULT_LANE;
if (held_priority === priority && held_lane !== resolved_lane)
report_lane_conflict(
this._reported,
reaction.handler.name,
resolved.target,
held,
lane
held_lane,
resolved_lane
);
// Multiple reactions targeting the same stream within a
// single correlate scan — keep the max priority, and carry the
Expand Down Expand Up @@ -27614,7 +27624,7 @@ export class ConsoleLogger implements Logger {
*/

import { createHash, randomUUID } from "node:crypto";
import { log, store } from "../ports.js";
import { DEFAULT_LANE, log, store } from "../ports.js";
import type {
EventRegister,
Query,
Expand Down Expand Up @@ -28300,22 +28310,32 @@ export class CorrelateCycle<
// rejects for static declarations (#1567). Priority still decides
// below; this only reports the tie the operator can't otherwise
// see. Compare against what this target already carries, whether
// that came from an earlier reaction in this scan or a past one.
const held = correlated.has(resolved.target)
? entry.lane
: recorded?.lane;
if (
held !== undefined &&
lane !== undefined &&
held !== lane &&
priority === entry.priority
)
// that came from an earlier reaction in this scan or a past one,
// and against that same source's priority — a resolution that
// beat the floor outranks what it found rather than tying with
// itself.
//
// Both lanes are compared by their resolved name, exactly as the
// static guard does (#1583): an omitted lane *is* the default
// lane, and the default lane is what the subscription row ends up
// holding, so an omitted lane against a declared one is a real
// disagreement. A never-seen target holds no lane at all, which is
// not the same as holding "default" — the record's existence is
// what gates the report.
const seen_in_scan = correlated.has(resolved.target);
const held_lane =
(seen_in_scan ? entry.lane : recorded?.lane) ?? DEFAULT_LANE;
const held_priority = seen_in_scan
? entry.priority
: recorded?.priority;
const resolved_lane = lane ?? DEFAULT_LANE;
if (held_priority === priority && held_lane !== resolved_lane)
report_lane_conflict(
this._reported,
reaction.handler.name,
resolved.target,
held,
lane
held_lane,
resolved_lane
);
// Multiple reactions targeting the same stream within a
// single correlate scan — keep the max priority, and carry the
Expand Down
36 changes: 23 additions & 13 deletions libs/act/src/internal/correlate-cycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import { createHash, randomUUID } from "node:crypto";
import { log, store } from "../ports.js";
import { DEFAULT_LANE, log, store } from "../ports.js";
import type {
EventRegister,
Query,
Expand Down Expand Up @@ -705,22 +705,32 @@ export class CorrelateCycle<
// rejects for static declarations (#1567). Priority still decides
// below; this only reports the tie the operator can't otherwise
// see. Compare against what this target already carries, whether
// that came from an earlier reaction in this scan or a past one.
const held = correlated.has(resolved.target)
? entry.lane
: recorded?.lane;
if (
held !== undefined &&
lane !== undefined &&
held !== lane &&
priority === entry.priority
)
// that came from an earlier reaction in this scan or a past one,
// and against that same source's priority — a resolution that
// beat the floor outranks what it found rather than tying with
// itself.
//
// Both lanes are compared by their resolved name, exactly as the
// static guard does (#1583): an omitted lane *is* the default
// lane, and the default lane is what the subscription row ends up
// holding, so an omitted lane against a declared one is a real
// disagreement. A never-seen target holds no lane at all, which is
// not the same as holding "default" — the record's existence is
// what gates the report.
const seen_in_scan = correlated.has(resolved.target);
const held_lane =
(seen_in_scan ? entry.lane : recorded?.lane) ?? DEFAULT_LANE;
const held_priority = seen_in_scan
? entry.priority
: recorded?.priority;
const resolved_lane = lane ?? DEFAULT_LANE;
if (held_priority === priority && held_lane !== resolved_lane)
report_lane_conflict(
this._reported,
reaction.handler.name,
resolved.target,
held,
lane
held_lane,
resolved_lane
);
// Multiple reactions targeting the same stream within a
// single correlate scan — keep the max priority, and carry the
Expand Down
Loading