feat(load): <nonce> generator for non-idempotent write replay + Location-classified 3xx counters (DD-038) - #91
Conversation
…xx counters
Component 1: reserved ${{nonce}} ref (AtomicLong seeded with millis, verbatim digits in
LoadRun.substitute) so every replayed write body differs -> each save is a real change (fixes
idempotent-write-replay no-op against change-detecting apps like JSPWiki). Component 2 (Option A):
LoadRun.fire stops auto-following redirects; add redirects + bounded redirectTargets counters to
summaryJson so no-op/reject 302s stop being invisible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
…nent-2 hardening (approver review)
- Component 1 is now a <nonce> GENERATOR primitive (author names their own rule; no reserved name
in the ${{}} correlation namespace; collision-proof @nonce wire marker) instead of a reserved
${{nonce}} token.
- Fix the cross-run uniqueness flaw: RUN_SALT (per-process millis) + within-run AtomicLong counter,
emitted <millis>-<counter> (a single seeded counter collides across runs under load).
- Component 2: best-effort cardinality cap + 64-char key truncation; document the DD-035
cookie-through-redirect interaction; corrected test note (LoadFireTest unaffected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
… substitution Fable review found real gaps; all folded in: - F4 (user-approved): substitution now covers the FULL request line (path+body) at every fire site (load worker, runSequence, single-step explore request) — <nonce> in a query no longer silently no-ops. - F1 (critical): self-redirects fold to a reserved "self" key so JSPWiki success 302s can't evict the actual rejects (SessionExpired/PageModified) from the bounded map. - F2 (critical): fire returns FireResult(code, location); int overloads delegate (LoadFireTest untouched); worker classifies via a pure static normalizeLocation. - F3: lintCorrelationOrdering exempts @nonce (generated, not captured). - F5: RUN_SALT folds in pid (two JVMs same millisecond differ). - F6: hard-bound redirectTargets (top-N) — the 4KB kubelet cap drops the whole summary on overflow; fix normalization (exact page= param, strip query, absolute Locations). - F7: no-follow is a session-carry FIX, not a limitation (the followed 302's anonymous Set-Cookie was clobbering the jar's JSESSIONID). - F8: documented the capture-GET-that-302s captureMiss class (no shipped grammar affected). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
…stitute, page= parse, JSON-escape)
Second fable pass caught 3 issues the F4/F1 fixes introduced:
- N1: substitute NPEs on a null body once a PATH marker sets needsSubstitution; substitute path
unconditionally, guard the body (GET /x?rev=${{@nonce}} has body==null).
- N2: F1 self-fold used [?&]page= which misses the body-LEADING page= of the JSPWiki save
(page=${page}&...), so requestPage==null and success saves crowd out rejects again; use
(^|[?&])page= for both requestPage and normalizeLocation.
- N3: summaryJson uses raw String.format, so a fuzzed Location with a quote voids the whole
summary; JSON-escape the redirectTargets keys / restrict normalizeLocation charset.
Plus minors: fireR is a distinct method (not a return-type overload); lint scans path+body;
RUN_SALT comment corrected; single-step explore null-return = skip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
…erload) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
- C1 (critical): single-step request(base,String) must pass the RAW step as the label, not r.format() — format() canonicalizes away a GET prefix and would silently rewrite every explore finding, breaking the DD-036 raw-recipe invariant. - I1: LoadRun imports only AtomicLong/AtomicLongArray; the local LongAdder needs an import. - I2: the grammar test goes in runner.coverage.GrammarCorrelationTest (has write/load helpers); test.RequestGrammarTest is a different package and would not match --tests. - M1: LoadFireTest has one server/base — register 3 contexts, not invented base fields. - M2: the 3-arg fire passes a single null. - Task 3: RENAME the 4-arg fire body into fireR (it holds the DD-037 capture branch, drain, and -1 path); convert BOTH return sites to FireResult. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
…_SALT (DD-038) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
…-safe; lint exempts @nonce (DD-038) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
… (DD-038 review) Task-3 review minor: no test exercised summaryJson with a non-empty redirects/redirectTargets, though the brief asked to assert the JSON-safety claim. Add one that includes a fuzzed Location (quote/angle chars) routed through normalizeLocation/safeKey and asserts the emitted summary keeps quotes paired and braces balanced — the operator drops the WHOLE summary on malformed JSON. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
…is a real change (DD-038)
Add $rev = <nonce> and append ${rev} to _editedtext. Validated: the marker survives expansion
into the corpus and substitute() fills it uniquely per fire (…-0 then …-1), so JSPWiki's
saveText() identical-text no-op guard never triggers on a replay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
…ts, precompile PAGE_PARAM (DD-038 review) Whole-branch review of DD-038 (no Criticals — hardening + doc honesty). - reqPage now checks the path/query THEN the body instead of one-or-the-other. A step with page= in the query and a non-page body left reqPage null, so a SUCCESS 302 classified under the page name instead of "self" and crowded the 12-slot cap, pushing the real rejects (SessionExpired/PageModified) into "other" — the exact invisibility the feature exists to kill. jspwiki's body-leading edit_save is unaffected (its POST path carries no query). - Back the DD-038 record's Verified claims with real assertions: paramValue's (^|[?&])page= rule on all three shapes plus frompage= rejection, the self-fold composed from a body-leading page= (paramValue -> normalizeLocation, what the worker actually does), and the 64-char truncation bound. The N2 parse fix had zero regression guard. - Extract the admission cap into a pure static admitKey(targets, key) (behavior identical; the worker keeps its get() fast path) and unit-test it, including that a key ALREADY present in a full map is still admitted rather than fragmented into "other". Reuse REDIRECT_TARGETS_CAP for summaryJson's top-N. - Document the load-baseline discontinuity in both DESIGN-DECISIONS and the CHANGELOG: with no follow hop, p50/p90/p99 and throughputRps for redirecting corpora are not comparable to pre-DD-038 numbers — re-baseline. - Minor: precompile PAGE_PARAM (paramValue compiled a Pattern per call twice per redirect in the throughput-ceiling loop; signature kept for tests); correct FireResult's javadoc (a 3xx code with a null location IS reachable when the body read throws); note the int fire overloads are test-facing wrappers; add the "explore keeps following" clause so the record is self-contained. Tests: 211 -> 215, suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
|
@claude please review this PR. Focus areas:
Please flag anything that would make a broken run look like a clean one — that's the failure mode this whole change is about. |
|
Claude finished @basquin-bot[bot]'s task in 4m 7s —— View job Review: DD-038
|
…claude review) The driver runs as a Kubernetes Job. Inside a container's own PID namespace every driver's main process is renumbered from a small integer -- commonly 1 -- so a millis+pid RUN_SALT collides outright for two pods started in the same millisecond (a parallel Job, or two campaigns off one controller tick). Both then emit an identical <salt>-<counter> stream, which revives the saveText() no-op DD-038 exists to kill -- across campaigns instead of within one, and while looking like a clean run. HOSTNAME is the pod name in Kubernetes (DD-013), so it separates them; pid stays the fallback for a local run where HOSTNAME is unset. buildRunSalt is pure + package-private so the collision-resistance is testable without pods, and runs the hostname through safeKey so the salt stays URL-safe when it is spliced verbatim into an encoded body. Also records the reserved-key caveat @claude noted: "self"/"other" are bucket names, so a target route literally named self/other merges into them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXSdVrJQy6DvELDbtptG5p
|
Thanks @claude — finding #1 is real and I've fixed it in You're right that the record's justification didn't hold in this repo's deployment model. The driver runs as a Kubernetes Job, so inside the container's own PID namespace the main process is renumbered from a small integer (commonly
New test Your minor on the reserved Confirming the two non-findings you verified by reading: the suite is green at 216 tests locally ( |
|
Claude finished @basquin-bot[bot]'s task in 1m 31s —— View job Verified:
|
ianp94
left a comment
There was a problem hiding this comment.
Approving. I gated this feature's spec earlier, so I checked the implementation against both my spec-review points and the Claude App's finding — all landed in code, several with refinements beyond what was asked.
My spec concern #1 (cross-run nonce collision) is fixed the right way. The spec's original single-AtomicLong-seeded-with-millis scheme couldn't be cross-run-unique under load (counter advances by save-count, not milliseconds). This adopts the per-run salt I recommended — RUN_SALT = <millis>x<node>x<pid> then -<counter> — and buildRunSalt is pure/package-private so the collision-resistance is unit-testable without pods. Good testability discipline.
The Claude App's k8s finding is real and correctly fixed. millis+pid alone collides across pods (PID namespaces renumber from 1, and pods can start in the same millisecond); folding in HOSTNAME (the unique pod name, pid fallback for local runs) closes it, with buildRunSalt made testable specifically for that regression. Verified the fold is present and the token stays URL-safe (safeKey-restricted node, unreserved separators).
My spec concern #2 (cookie-through-redirect under no-follow) is addressed with the exact test I asked for. captureSessionCookie(c, jar) still runs on the direct response in fireR, and a302SetCookieStillPopulatesTheJar pins that a 302 with Set-Cookie still populates the jar — session continuity survives the no-follow flip.
My spec concern #3 (LoadFireTest audit) is handled cleanly. Rather than flipping the existing fire's default (which Java can't overload on return type anyway), they added a distinct fireR returning FireResult(code, location); the int fire(...) delegates via .code(), so existing LoadFireTest assertions are untouched and the redirect tests are additive.
Component 2 exceeded the guidance. The redirectTargets cap uses the best-effort admitKey pattern I recommended (benign race, ≤12 + "other"), AND the summary emits a deterministic top-12-by-count. Beyond that, they caught a robustness issue I only gestured at: because summaryJson builds JSON via raw String.format, a fuzzed/reflected Location could emit an invalid JSON key and make the operator's json.Unmarshal silently drop the entire summary — so normalizeLocation restricts output to [A-Za-z0-9._-]. That's the right defensive call at the 4 KB termination-message boundary.
Design improvement over the spec: moving nonce from a reserved ${{nonce}} name to a <nonce> grammar generator (author writes $rev = <nonce>) reserves nothing in the author-controlled ${{}} namespace, and the ${{@nonce}} fire-time marker (with @ outside Capture.NAME_PATTERN) is collision-proof by construction. The generator→marker split correctly separates grammar-expansion-time from fire-time, which is necessary — a baked value would repeat across fires of one expanded sequence, defeating the whole point. Verified @nonce fills without masking a genuine unbound ${{ref}} (the else-branch still returns null → step skips) and is exempt from lintCorrelationOrdering.
All CI green including the in-cluster e2e; 216 tests; summaryJson's new params updated at both LoadDriftUnavailableTest call sites. Clean close of the DD-038 pair.
(reviewed at d52ff7e)
DD-038 —
<nonce>generator + Location-classified 3xx countersTwo defects surfaced while validating DD-037's correlated JSPWiki writes end-to-end. Both made a failing write path look like a working one.
1. A fixed-corpus write replay is idempotent, so it stops being a write. JSPWiki's
DefaultPageManager.saveTextreturns before writing whenoldText.equals(proposedText)— and still 302s "success". So after the first replay the page never changes and the save costs nothing, while the load driver counts full-price 2xx/3xx traffic. This is a general CMS pattern, not a JSPWiki quirk.2. A rejected write was invisible.
fireauto-followed redirects, so a302 → /Wiki.jsp?page=SessionExpired(orPageModified) was counted as an ordinary request: not 4xx, not 5xx, and capture succeeded so not acaptureMiss. A run could reject 100% of its saves and report clean.What this adds
<nonce>is a grammar generator primitive — same<…>namespace as<int>/<string>, so the author names their own rule and it reserves nothing in the${{}}correlation namespace:It expands to the fire-time marker
${{@nonce}}(@can't be aCapturename → collision-proof), whichLoadRun.substitutefills per fire withRUN_SALT-counter,RUN_SALT= millis + pid. Note a bare millis-seededAtomicLongdoes not work: the seed advances in wall-clock ms while the counter advances by number-of-saves, so a later run's seed lands inside a prior run's emitted range and re-emits still-saved values — reintroducing the no-op. Salt + counter is uniqueness by construction, within a run and across parallel JVMs.Substitution is also widened from body-only to the full request line (path+body), null-safe — generators are usually used in a URL query.
Load mode stops following redirects.
fireRreturnsFireResult(code, location)(theint fireoverloads stay as test-facing wrappers) and a pure-staticnormalizeLocationclassifies the target into boundedredirects/redirectTargetscounters in the terminal summary. Self-redirects fold to one"self"key so JSPWiki's success 302s can't evict the real rejects from the bounded map; keys are charset-restricted and the map hard-bounded, because the operator drops the whole summary on invalid/oversized JSON. Explore mode still follows redirects — it wants coverage, not redirect metrics.No-follow is also a session-carry fix, not a regression: with follow on, a 302 was re-issued as a cookieless GET whose anonymous
Set-Cookieoverwrote the jar's valid JSESSIONID.captureSessionCookieruns on the direct 3xx before any follow, so the real session is kept.For any corpus whose steps redirect (JSPWiki
edit_save, jpetstore Stripes POSTs), the driver no longer pays the follow hop — post-DD-038p50/p90/p99andthroughputRpsare not comparable to earlier numbers. Re-baseline before comparing.Verification
215 tests green (211 before). Nonce uniqueness + never-masks-an-unbound-
${{ref}}; a path-only marker substituting without NPE;normalizeLocationself-fold (composed from a body-leadingpage=, as the worker does),frompage=rejection, absolute URLs, 64-char truncation; theadmitKeyoverflow cap (including: a key already present in a full map is still admitted, so counts don't fragment);fireRlocation on 302, null on 200, and a 302-carrying-Set-Cookiestill populating the jar;summaryJsonvalid + bounded JSON with a populatedredirectTargets. Grammar validation shows the marker survives expansion and fills uniquely per fire.Backward compatible: a v1/v2 corpus line with no
${{/no capture parses, formats, and fires byte-for-byte as before. The DD-036 invariant is intact — findings are still labeled with the raw recipe step, never a substituted body.Process
Spec and plan were fable-reviewed before any code (16 issues caught pre-implementation, incl. the nonce cross-run collision). Built via SDD — fresh implementer + reviewer per task — then a whole-branch Opus review: 8/8 binding checklist items PASS, no Criticals; its 4 Important + 4 cheap Minor findings are fixed in
b2d4abf.Docs:
DD-038indocs/DESIGN-DECISIONS.md,docs/LOAD-MODE-DESIGN.md§12,runner/CHANGELOG.md.@claude please review.