From a130c61631f67304286e1d3162636637d98a909e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 25 Sep 2026 00:34:48 -0700 Subject: [PATCH] =?UTF-8?q?docs(signals):=20lean=20posture=20landed=20?= =?UTF-8?q?=E2=80=94=20contract=20answers,=20tripwire=20cap=2014=20?= =?UTF-8?q?=E2=86=92=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the lean-posture proposal in documentation/plans/responsiveness-findings-plan.md §Cost (landed with with the gate as shipped — `wantsRerun() = log || folds.length > 0 || OBSERVE.records.observed("rerun")`, read at recomputeStart, honoured at recordRerun — and the four contract questions answered: - history("rerun") is empty for runs made while nothing wanted a record and holds every record from the moment something did; why() is a view of that buffer and shares its gate; subscriptions() reads the graph and is unaffected. No enable({ reruns: true }) — document, no option. - OBSERVE.records.subscribe("rerun", …) turns record-building on from the next run start and off on unsubscribe; log and a registered fold do the same. The bare attribution.subscribe went with #3644. - The checks read the frame, not the record: checkEffectCycle, checkRelayTear, checkHotRuns, checkHotTime, checkWastedRecompute, checkDepWidth all run before the gate. - @sentry/solid-2 dropping its rerun subscription is the follow-up in getsentry/sentry-javascript#24517. JSDoc on why()/subscriptions() and the diagnostics doc lines say the same; attribution-lean-gate.test.ts pins WASTED_RECOMPUTE without a record and why()/subscriptions() on a lean engine. Tripwire (attribution-engine-cost.test.ts) re-measured 2026-09-24 under vitest, six runs: folded 1.98–2.14x, listened 1.91–2.11x, lean 1.71–1.90x. The section's 490/55 ns ≈ 9x table was a different flush-per-write micro-harness; the tripwire's numbers are the baseline from here. Cap 14 → 4 (~85–100% headroom; trips when the engine costs roughly double what it does today). Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- .changeset/lean-posture-landed.md | 5 + .../plans/responsiveness-findings-plan.md | 116 ++++++++++-------- documentation/solid-2.0/08-dev-diagnostics.md | 4 +- .../signals/src/core/attribution-queries.ts | 11 +- .../tests/attribution-engine-cost.test.ts | 41 ++++--- .../tests/attribution-lean-gate.test.ts | 74 ++++++++++- 6 files changed, 183 insertions(+), 68 deletions(-) create mode 100644 .changeset/lean-posture-landed.md diff --git a/.changeset/lean-posture-landed.md b/.changeset/lean-posture-landed.md new file mode 100644 index 000000000..0e49e9d20 --- /dev/null +++ b/.changeset/lean-posture-landed.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Document the lean-posture contract for `why()`/`subscriptions()` on the records channel (`why` shares `history("rerun")`'s gate; `subscriptions` reads the graph and is unaffected); ratchet the engine-cost tripwire cap to 4. diff --git a/documentation/plans/responsiveness-findings-plan.md b/documentation/plans/responsiveness-findings-plan.md index 625a4dd54..ad5837001 100644 --- a/documentation/plans/responsiveness-findings-plan.md +++ b/documentation/plans/responsiveness-findings-plan.md @@ -286,7 +286,8 @@ Three facts, in order of weight: keep their window on the node (`_dev*` fields); a per-run `WeakMap`, `now()` or allocation is the shape to refuse in review. The `attribution-engine-cost` tripwire (enabled/idle ratio, best-of-k, - interleaved, cap 14 against a measured ~10) is what catches it. + interleaved, cap 4 against a measured ~2.0–2.1× folded — see Lean + posture below) is what catches it. - **The engine's fixed per-re-run cost is the number that matters**: ~435 ns above idle with every check off, ~9× idle. It is the `RerunEvent` — the `causes` array, `depsAdded`/`depsRemoved` name arrays, `preview()` strings @@ -296,53 +297,72 @@ Three facts, in order of weight: adapter that holds the engine for interaction/hold tracing, whether or not it reads a re-run. -### Lean posture — proposal, needs a decision - -_Status._ Landed as proposed: `wantsRerun()` — a `rerun` listener on -`OBSERVE.records`, an imported fold (`costs`/`feedback`) or `log` — gates the -record at run start; the checks read the frame's facts; `history("rerun")` -is empty while nothing wants records. The text below is the proposal as -written. - -Build the `RerunEvent` only when someone can read it. The engine knows at -`recomputeEnd` whether anyone can: a `rerun` subscriber, a registered fold -(`costs`/`feedback`/`why`/`subscriptions` import), `log: true`, or a -consumer that will call `history("rerun")`. When none holds, keep only what the -other records need — the frame's interaction for `runs`/`runMs` on -`InteractionEvent`, the cause→interaction link for holds and flights, the -per-node counters the checks read — and skip the record: no causes array, -no dep-name diffs, no previews, no ring-buffer push, no `recordSubject`. -Expected: enabled cost for a records-only consumer falls from ~9× idle -toward 3–4×; measure before promising. - -What it changes, and therefore what to decide: - -- `history("rerun")`, `why()`, `subscriptions()` on a lean engine return nothing - for runs that happened before a consumer of them appeared. Either - document that (they are dev-console tools; the observe consumer that - wants them subscribes to `rerun` or imports a fold, which turns records - on from that moment), or add an explicit `enable({ reruns: true })` that - forces record-building — the most-demanding merge makes that compose. -- `OBSERVE.records.subscribe("rerun", …)` must turn record-building on, the - way the `create`/`effect`/`flush`/`flight`/`fallback` timeline records - already work ("subscribing is what turns them on"). -- The checks that read the record today (`checkHotRuns` reads - `event.causes` for its cause key and message; `checkWastedRecompute` - reads `changed`, `selfMs`, `phase`, `at`) need those facts from the frame - instead of the record — they are all on the frame before the record is - built. -- `@sentry/solid-2` subscribes to `rerun` only to fold a per-interaction - hot list; `InteractionEvent.runs`/`runMs` and the `HOT_SCOPE_*` / - `WASTED_RECOMPUTE` findings cover that, so the adapter can drop the - subscription and become a lean consumer. The Performance Tracks adapter - needs re-run records by design and stays a full one. - -Not a shortcut: the record is the engine's unit of truth for the -dev-console tools, and a lean engine must produce the identical record the -moment a consumer asks. The proof is the existing attribution suite run -twice — once with a `rerun` subscriber armed, once without — and the -engine-cost tripwire's ratio dropping, with its cap ratcheted to hold the -gain. +### Lean posture — LANDED (#3644) + +Build the `RerunEvent` only when someone can read it. Implemented as +`wantsRerun() = log || folds.length > 0 || OBSERVE.records.observed("rerun")` +(`packages/signals/src/core/attribution.ts`), read at `recomputeStart` — +it decides whether the dep snapshot the record's subscription diff needs +is captured (`frame.prevDeps`, `null` when nothing wants the record) — and +honoured at `recordRerun`. Without an audience the run still leaves its +facts on the node (run sequence and count, causes, interaction), feeds the +interaction and flush counters and runs every check; the record — cause +copy, dep diff, previews — the ring-buffer push, the fold and the emit are +skipped. Not `recomputeEnd` as proposed: the snapshot is the first cost, +so the decision has to precede the run, and a listener arriving mid-run +gets the next record. Pinned in `tests/attribution-lean-gate.test.ts`. + +Decision on the contract — document, no option. There is no +`enable({ reruns: true })`: a consumer that wants re-run records subscribes +to `rerun` or imports a fold, the same "subscribing is what turns them on" +the timeline records already had, so the engine has one gate rather than +two that compose. The questions the proposal listed, as answered: + +- `history("rerun")` is empty for runs made while nothing wanted a record + and holds every record from the moment something did; the run count + kept meanwhile is on the first record (`nodeRuns`). Documented on + `Attribution.history`, on `RecordTypes` (`dev.ts`) and in + `08-dev-diagnostics.md`. `why(node)` is a view of that buffer and shares + its gate (a node whose runs left no record has no `nodeId` and no + history) — documented on `why`. `subscriptions(node)` was never a record + reader: it walks the node's live `_deps`, so it answers with or without + an audience (and with the engine disabled) — documented on + `subscriptions`. Both pinned in the lean-gate test. +- `OBSERVE.records.subscribe("rerun", …)` turns record-building on from the + next run start and off again when the listener unsubscribes; `log: true` + and a registered fold (`costs`/`feedback` import) do the same. The bare + `attribution.subscribe(listener)` went with the channel consolidation in + the same PR; there is no untyped form to gate. +- The checks read the frame, not the record: `checkEffectCycle`, + `checkRelayTear`, `checkHotRuns` (causes), `checkHotTime` (`selfMs`, + causes), `checkWastedRecompute` (`frame.start`, `phase`, `changed`, + `selfMs`, causes) and `checkDepWidth` all run in `recordRerun` before the + `prevDeps === null` return that is the gate. `HOT_SCOPE_RERUNS` and + `WASTED_RECOMPUTE` firing without a record are pinned. +- `@sentry/solid-2` dropping its `rerun` subscription to become a lean + consumer (`InteractionEvent.runs`/`runMs` and the `HOT_SCOPE_*` / + `WASTED_RECOMPUTE` findings cover its per-interaction hot list) is the + follow-up in the Sentry PR, getsentry/sentry-javascript#24517, together + with the channel migration. The Performance Tracks adapter needs re-run + records by design and stays a full consumer. + +Measured, `attribution-engine-cost` (enabled/idle ratio on the observe +artifacts, best-of-5 interleaved, under vitest, M-series). The section's +table above (490/55 ns ≈ 9×) came from a different flush-per-write +micro-harness; the tripwire's apples-to-apples numbers are the baseline +from here: + +| Engine | folded | listened | lean | +| ------------------------------------------ | ---------- | ---------- | ---------- | +| #3613 (record always built), 2026-09-24 | ~2.5–2.8× | — | — | +| #3644 (one channel, lean gate), 2026-09-24 | ~2.1–2.2× | ~2.0–2.3× | ~1.8–1.9× | +| #3644 + #3646, 2026-09-24, six runs | 1.98–2.14× | 1.91–2.11× | 1.71–1.90× | + +The gap between postures is inside this harness's noise; what the gate +buys is the allocation and GC pressure of the record, which a 5,000-re-run +ratio does not resolve. Cap ratcheted 14 → 4 (~85–100% headroom over the +folded baseline; trips when the engine costs roughly double what it does +today, the same discipline as `observe-idle-cost`'s 1.25 over 1.03–1.09). ## Consumers — showing the facts where developers already look diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index 476a6d5bd..5e060a4fd 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -1010,8 +1010,8 @@ import { costs, feedback, why, subscriptions, formatRerun, formatOrigin } from " costs(); // { scopes, writes } ranked cost tables (since enable()) feedback(); // responsiveness tables (below) -why(someMemo); // re-run history for one node -subscriptions(fn); // current dep names of one scope +why(someMemo); // re-run history for one node — a view of history("rerun"), same gate: empty for runs nothing wanted a record of +subscriptions(fn); // current dep names of one scope — read from the graph, not a record; unaffected by the gate formatRerun(event); // the console line for one RerunEvent formatOrigin(origin); // `click on button#next "Next →"` — a ChangeOrigin as a name diff --git a/packages/signals/src/core/attribution-queries.ts b/packages/signals/src/core/attribution-queries.ts index fbac2937d..45bfc8a9b 100644 --- a/packages/signals/src/core/attribution-queries.ts +++ b/packages/signals/src/core/attribution-queries.ts @@ -18,7 +18,10 @@ function nodeOf(target: unknown): Computed { * diagnostics bridge holds no node, only the `nodeName` the records * carry). Records name their scope by `nodeId`; a node that has never run * under the engine has none, and no history. By name, every scope of that - * name answers. + * name answers. A view of `history("rerun")`, so it shares that buffer's + * gate: runs nothing wanted a record of (no `rerun` listener, fold or log + * at the time) left no record and are not here — a console session that + * wants them subscribes or imports a fold first. */ export function why(target: unknown): RerunEvent[] { const history = attribution.history("rerun"); @@ -28,7 +31,11 @@ export function why(target: unknown): RerunEvent[] { return history.filter(event => event.nodeId === id); } -/** Current dependency names of one scope — the devtools subscription view. */ +/** + * Current dependency names of one scope — the devtools subscription view. + * Read from the graph, not a record, so it answers with or without an + * audience for re-run records (and with the engine disabled). + */ export function subscriptions(target: unknown): string[] { const node = nodeOf(target); const names: string[] = []; diff --git a/packages/signals/tests/attribution-engine-cost.test.ts b/packages/signals/tests/attribution-engine-cost.test.ts index 9f85cbaa0..c7a008031 100644 --- a/packages/signals/tests/attribution-engine-cost.test.ts +++ b/packages/signals/tests/attribution-engine-cost.test.ts @@ -16,7 +16,8 @@ * - listened — a `rerun` subscriber on the records channel: the record is * built, kept and delivered; * - folded — the `costs`/`feedback` folds loaded as well, as a consumer that - * imports the `attribution` entry has them. The cap is on this one. + * imports the `attribution` entry has them. The cap is on this one; the + * other two are measured for the failure message. * * The engine is imported from its core module, not the entry, so the folds * (which register on import) arrive only when the test loads them. @@ -127,21 +128,31 @@ describe.skipIf(!existsSync(OBSERVE) || !existsSync(ENGINE))("attribution engine // The folds, as a consumer of the `attribution` entry has them from // import; from here on every re-run is folded into the cost and - // feedback tables as well. Measured 2026-09-23 (M-series, engine at - // #3613, folds, record always built): ~9.5–10x — the RerunEvent - // (causes, dep diffs, previews), the history ring buffer, - // recordSubject, the six checks (~10% of the whole) and emitRecord. - // Re-measured 2026-09-24 on the same class of machine, interleaved with - // that engine: #3613 ~2.5–2.8x; records on one channel (no per-emit - // allocation, no subject map, checks off the facts) folded ~2.1–2.2x, - // listened ~2.0–2.3x, lean ~1.8–1.9x. The cap trips when the folded - // engine costs ~40% more per re-run than the #3613 figure; a check that - // grew a map lookup or a clock read on the hot path moves this by a few - // percent, a record that grew a per-run allocation by more. Ratchet the - // cap as the lean-posture work in - // documentation/plans/responsiveness-findings-plan.md lands. + // feedback tables as well. + // + // Baseline, measured 2026-09-24 (M-series, this file under vitest, six + // runs, dist/observe at #3644 + #3646): folded 1.98–2.14x, listened + // 1.91–2.11x, lean 1.71–1.90x (idle ~6.2–7.0ms / 5,000 re-runs; the + // engine ~13–14ms folded). The #3613 engine, re-measured by this + // harness the same day beside this one, was ~2.5–2.8x; its ~9.5–10x + // figure of 2026-09-23 came from a different, flush-per-write + // micro-harness and is not comparable — this harness's numbers are + // the baseline from here. The ratio is harness-sensitive in the other direction too: a + // bare `node` process (no vitest transform in the worker) measures + // idle at ~2.8ms and the folded engine at ~3.1–3.7x, so a faster idle + // build raises the ratio without the engine changing. + // + // Cap 4: ~85–100% headroom over the folded baseline — trips when the + // engine costs roughly double what it does today per re-run, the same + // discipline as observe-idle-cost's 1.25 over a 1.03–1.09 baseline. + // A check that grew a map lookup or a clock read on the hot path + // moves the ratio by a few percent, a record that grew a per-run + // allocation by more; both stay under this until they compound. This + // is the ratchet the #3613 cap (14, against the old ~10) asked for + // once the lean-posture work landed (#3644; see the Lean posture + // section of documentation/plans/responsiveness-findings-plan.md). for (const fold of FOLDS) await import(fold); - const CAP = 14; + const CAP = 4; let best = Infinity; let detail = ""; for (let round = 0; round < 3 && best >= CAP; round++) { diff --git a/packages/signals/tests/attribution-lean-gate.test.ts b/packages/signals/tests/attribution-lean-gate.test.ts index 8d5daab27..7bb5174ef 100644 --- a/packages/signals/tests/attribution-lean-gate.test.ts +++ b/packages/signals/tests/attribution-lean-gate.test.ts @@ -12,7 +12,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { attribution, nodeIdOf, registerFold } from "../src/core/attribution.js"; import type { RerunEvent } from "../src/core/attribution.js"; -import { createEffect, createRoot, createSignal, flush, getOwner, OBSERVE } from "../src/index.js"; +// The point queries register nothing (unlike the folds), so importing them +// here leaves the gate alone. +import { subscriptions, why } from "../src/core/attribution-queries.js"; +import { + createEffect, + createMemo, + createRoot, + createSignal, + flush, + getOwner, + OBSERVE +} from "../src/index.js"; import type { RecordListener } from "../src/core/dev.js"; // Channel subscriptions are the consumer's, not the engine's: released here, @@ -143,6 +154,67 @@ describe("attribution engine: lean gate", () => { expect(String(warn.mock.calls[0][0])).toContain("HOT_SCOPE"); }); + it("the checks run without a record: wasted recompute still warns, from the frame's facts", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // A memo over a whole object that reads one field: every write to the + // other field re-runs it to the same value. + const [user, setUser] = createSignal({ name: "Ada", visits: 0 }, { name: "user" }); + const greeting = createMemo(() => `Hello, ${user().name}`, { name: "greeting" }); + createRoot(() => + createEffect( + () => greeting(), + () => {}, + { name: "header" } + ) + ); + flush(); + attribution.enable({ + log: false, + hotRuns: false, + hotTime: false, + wastedRecompute: { minRuns: 5, ratio: 0.8, budgetMs: 0, windowMs: 60_000 } + }); + for (let i = 1; i <= 6; i++) { + setUser({ name: "Ada", visits: i }); + flush(); + } + // `changed`, `selfMs`, `phase`, `at` and the causes came off the frame: + // no record was built, and the finding names the cause all the same. + expect(attribution.history("rerun")).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0][0]); + expect(message).toContain("WASTED_RECOMPUTE"); + expect(message).toContain('memo "greeting" re-ran 5 times'); + expect(message).toContain('Latest cause: "user" (write)'); + }); + + it("why() is a view of the gated buffer; subscriptions() reads the graph", () => { + const [n, setN] = createSignal(0, { name: "n" }); + const doubled = createMemo(() => n() * 2, { name: "doubled" }); + createRoot(() => + createEffect( + () => doubled(), + () => {}, + { name: "e" } + ) + ); + flush(); + attribution.enable({ log: false }); + setN(1); + flush(); + // Nobody wanted a record of that run: nothing to ask `why` about — but + // the dependency view is the graph's, and answers regardless. + expect(why(doubled)).toEqual([]); + expect(subscriptions(doubled)).toEqual(["n"]); + + onRerun(() => {}); + setN(2); + flush(); + // From the moment a consumer appeared: the run it saw, and only that one. + expect(why(doubled).map(e => e.nodeRuns)).toEqual([2]); + expect(subscriptions(doubled)).toEqual(["n"]); + }); + // Last: registering a fold is for the rest of the process. it("a registered fold wants the record", () => { const folded: RerunEvent[] = [];