Skip to content

perf(pm): memoise the source maskers dispatch-gates re-runs per family (843s to 303s under the verify lock) - #14584

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14521-dispatch-gates-selftest-profile
Sep 2, 2026
Merged

perf(pm): memoise the source maskers dispatch-gates re-runs per family (843s to 303s under the verify lock)#14584
baozhoutao merged 2 commits into
mainfrom
claude/issue-14521-dispatch-gates-selftest-profile

Conversation

@claude

@claude claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #14521

check:pm-dispatch-gates was the lock holder for five consecutive os-verify-lock.sh
queue timeouts on the card that filed this. Measured first, as the triage asked, and the
answer is one line: discovery masks each gate source about twelve times per pass, and
the battery pays for that ninety-plus times over.
The maskers are now memoised on their
input string. No family is dropped, no self-test case is dropped, no assertion is
weakened, and the 540 s budget in os-verify-lock.sh is untouched.

BEFORE / AFTER — the profile

Both columns are the same battery, on the same box, through the same entry point. Every
timing run was detached, per check-dispatch-gates.mjs's own header.

Lock hold time — the way the problem was found (triage rule 5)

Shared-box seconds on a 4-core container; the load average is the one printed at the end
of each run. The last two rows are the pair to read: same base commit, same box, only
the patch differs.

run tree hold waited load avg at end rc
before1 53d368921, unpatched (profiling preload attached) 804 s 536 s 1.20 0
before2 53d368921, unpatched, clean 816 s 232 s 2.60 0
basecmp 7286dd58e, unpatched, clean 843 s 23 s 1.07 0
after1 7286dd58e + this patch (profiling preload attached) 303 s 324 s 4.47 0
after2 e5fa9d54a (merged) + this patch, via pnpm check:pm-dispatch-gates 293 s 196 s 1.43 0

843 s to 303 s on the identical base — 2.78x — and the faster run was the one taken
under the heavier load.
Two BEFORE readings on the previous base bracket it at 804 s and
816 s, and the 804 s vs 816 s pair also says the profiling preload costs nothing readable:
the instrumented run was the faster of the two.

⚠️ Idle-versus-loaded was asked for and cannot be delivered honestly: the box is shared and
never idle. What is above is what the box gave, with its load figures attached, rather than
an invented idle number.

The fleet already recorded the BEFORE population

/tmp/os-heavy-verify.lock.ledger — 445 records over 26 h 48 m — names this family in 25
rows, and it is the ledger's largest single consumer of lock-seconds by a factor of six:

 8365s total   13 run(s)  pnpm check:pm-dispatch-gates
 2078s total    4 run(s)  NODE_OPTIONS=--max-old-space-size=4096 pnpm check:pm-dispatch-gates
 1599s total    3 run(s)  node scripts/pm/check-dispatch-gates.mjs
 1942s total   11 run(s)  pnpm --filter '@objectstack/lint^...' build     ... the next one down

Of those 25 rows, 23 acquired and 2 are queue-timeout at the 540 s budget — the card's
symptom, in the ledger. The 23 holds run 517 s to 842 s, and they are ordered: the four
earliest are 517-521 s, the eight latest are 770-842 s. So the card's "500-900 s" is right,
and the drift inside that range is real rather than noise.

One correction to the triage's baseline

The triage reads 534.5 s out of os-verify-lock.sh:133-134. That figure is rank 1 plus
rank 2
; docs/audits/2026-08-verify-lock-gate-routing-measurement.md:51 measures
check:pm-dispatch-gates alone at 305.5 s. Against that, the observed 800 s-plus is
~2.7x the documented baseline for this one family, not ~1.6x. Nothing in the scope rules
moves — the correction makes the growth larger, not smaller. That audit is a dated reading
and is left exactly as it is; this is a new reading and it lives here.

Where the 803.9 s went — per case, not a total

From the instrumented before1 run. A case's cost is the wall time between the previous
verdict line and its own, i.e. everything the battery did to decide it; that is the only
attribution a stream of verdict timestamps supports. The AFTER column is the same case in
after1.

# case before after saved
1 a convention-triggered card is really convention-triggered 62.0 s 26.8 s 35.2 s
2 all three ratchets are still discovered with hints, so nothing below is vacuous 41.8 s 8.7 s 33.1 s
3 the seam card still derives at all 32.3 s 13.5 s 18.8 s
4 --commands answers 32.0 s 13.7 s 18.3 s
5 a record that is --commands output copied verbatim reconciles GREEN and exits 0 31.7 s 12.6 s 19.1 s
6 --json puts a single parseable document on stdout 31.2 s 13.2 s 18.1 s
7 a run whose surface ALREADY carries a changeset answers at all 31.2 s 13.8 s 17.4 s
8 dropping ONE line from that record exits 1 — a verdict a report cannot paraphrase 31.0 s 12.8 s 18.2 s
9 the DEFAULT run answers at all 30.9 s 13.6 s 17.3 s
10 the published harvest of the pasted block no longer yields the CI-measured command 30.7 s 12.3 s 18.5 s

Over the 1216 cases present in both profiled runs: 760.2 s to 285.7 s. The top 20 cases
carry 73.3% of the BEFORE total, and every one of them is a case that either spawns the
tool's own CLI or calls discoverFamilies().

Child processes — 58% of the run, and the count does not move

before1 after1
spawnSync calls 216 216
self-spawns of this tool's own CLI 28 calls, 465.1 s 28 calls, 196.8 s
git (fixture repos, ls-files) 184 calls, 2.3 s 184 calls, 3.5 s
everything else 4 calls, 0.2 s 4 calls, 0.2 s
child-process total 467.7 s (58.2% of the run) 200.5 s

The counts are identical on both sides. That is the point: nothing was removed, each of the
28 self-spawns just stopped paying for the same masking a dozen times. Each one costs
16.6 s before and 7.0 s after.

Inside one discoverFamilies() call — the V8 profile that names the defect

201 families, 196 distinct gate sources, 11.8 MB of source. Self time, attributed to the
nearest frame declared in dispatch-gates.mjs:

frame self share
maskSelfTests 4.54 s 31.7%
anchoredReadTargets 1.55 s 10.8%
spawnedProgramTargets 1.24 s 8.7%
garbage collector 0.97 s 6.8%
extractWatchHints 0.92 s 6.5%
selfTestOnlyCallables 0.87 s 6.1%
firstPartyImportTargets 0.84 s 5.9%
payloadEnvDependence 0.80 s 5.6%
packageManifestTargets 0.79 s 5.5%
everything else 1.80 s 12.4%
total 14.32 s

Six of those frames are the six analysers discoverFamilies calls on the same source
string, in one pass of its per-family loop
— and every one of them re-derives the masked
body from scratch. Two of them do it twice (they mask, then hand the masked text to
anchoredReadTargets, which masks again). One source therefore pays maskComments about
seven times and maskSelfTests about five, per discovery, for bytes that cannot have
changed in between.

That is the triage's own target, verbatim: repeated filesystem walks and work that belongs
outside a per-family loop.

The change

scripts/pm/dispatch-gates.mjs only — 74 insertions, 9 deletions, one new module-body
block and nine call sites rewritten to go through it.

Both maskers are pure functions of their input string, so they are memoised on it, behind a
cache bounded by bytes rather than entries (the gate set is ~12 MB and fits whole, so
repeated discoveries in one process reuse it; a tracked-corpus sweep evicts oldest-first
instead of growing without limit). A memo over a pure function is observationally identical
to calling through, and JavaScript strings are immutable, so one caller cannot edit the
shared result under another. A non-string argument bypasses the cache entirely, so a memo
never becomes the thing that decides what a masker does with one.

Unit cost of a single discoverFamilies() call on this tree, three consecutive calls each
way:

before   13616 / 13432 / 14270 ms
after     4923 /  2640 /  2683 ms      ... 2.8x cold, 5.1x warm

The warm number is why the in-process half improves at all: the battery calls
discoverFamilies() about fifteen times.

⛔ Not done, deliberately, and each is a triage rule: no family removed, no case removed, no
assertion relaxed (rule 3); scripts/pm/os-verify-lock.sh not touched and the 540 s budget
not raised (rule 4); docs/audits/2026-08-verify-lock-gate-routing-measurement.md not
rewritten; no workflow changed. Cases were not parallelised — the profile did not need
it once the repetition was gone, and a flaky parallel battery is worse than a slow serial
one.

Byte-identical proof

Both trees at base 7286dd58e, the only difference being this patch. basecmp is a
detached worktree at that commit; the run is the same battery through the same lock.

artifact unpatched patched diff
verdict set (every streamed check/cross line, sorted, timestamps stripped) 1241 cases, 1241 pass, 0 fail 1241 cases, 1241 pass, 0 fail empty
battery's own summary line dispatch-gates self-test: 1240 cases pass. identical
discovered-family list (key, resolved files, hints, reads, selfTest, direct, filter, workflows — sorted) 202 families 202 families empty
runner exit code 0 0

Control, so the empty diffs mean something: two BEFORE runs of the unpatched battery on the
same tree (before1, before2) also produce a byte-identical verdict set, so the
comparison above is measuring the patch and not run-to-run drift.

Gates

Derived on the real diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(15 families; the tool read the change set from git itself), and the union was run at
e5fa9d54a — the merged head this branch now carries. Fourteen exit 0; the fifteenth,
node scripts/check-test-completeness.mjs, exits 3 — PREREQUISITE NOT MET, which its
own text calls NOT MEASURED and not a finding: it grades a saved turbo run test log and
there is none locally. --ran reconciles the union: 15 derived, 15 run, 0 UNRUN, exit 0.
Per-gate exit codes are in the report comment on #14521.

Declared: the fourteen scripted gates were run off the verify lock. That is what the
lock does today rather than a narrowing — its own header records that routing gate runs
through it was measured one-directional and refused, and --status prints check:* gate
scripts among the work it "does NOT see". The battery itself, which is genuinely heavy, went
through the lock every time, which is where the hold-time table above comes from.

Scripts-only, nothing published from any package, so no changeset — skip-changeset.

Generated by Claude Code


Generated by Claude Code

`discoverFamilies` hands the same source string to six analysers in one pass
of its per-family loop, and each re-derives the masked body from scratch — two
of them twice, since they mask and then hand the masked text to
`anchoredReadTargets`, which masks again. One source pays `maskComments` about
seven times and `maskSelfTests` about five, per discovery, for bytes that
cannot have changed in between.

A V8 CPU profile of one `discoverFamilies()` call (201 families, 196 distinct
gate sources, 11.8 MB) spent 14.3 s, of which `maskSelfTests` was 4.5 s of self
time (31.7%) and the `maskComments` inside those six analysers most of another
5.2 s — the largest entry in the profile, and everything above the first pass
is repetition.

Both maskers are pure functions of their input string, so they are memoised on
it, behind a byte-bounded cache. Nothing about what is masked, scanned or
discovered changes: same bytes in, same bytes out, the same derivation run once
instead of a dozen times. The set of families and the verdict of every
self-test case are held byte-identical.

Measured on this tree: one discovery 13.6 s -> 4.9 s cold, 13.4 s -> 2.6 s on a
repeat within the same process.
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 2, 2026
@github-actions github-actions Bot added the size/s label Sep 2, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 2, 2026 13:52
@baozhoutao
baozhoutao enabled auto-merge September 2, 2026 13:52
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 3bca6a4 Sep 2, 2026
36 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14521-dispatch-gates-selftest-profile branch September 2, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] check:pm-dispatch-gates repeatedly holds the shared verify lock 500-900s+, starving the queue

2 participants