feat: group shadow config and log mismatch JSON - #113
Conversation
ReviewI checked out the branch and verified behavior empirically rather than reading only. Baseline: 414 tests pass, I also fuzzed the previewer (~2,500 generated values, every prefix length of multibyte strings, proxies, cycles, null-prototype records, lone surrogates) — the byte accounting is genuinely correct. Reserving the marker by shrinking Now the problems. 1. A throw during detail construction kills the process (High — introduced by this PR)
I mocked the preview module to throw and confirmed: the metric records This is a regression, not pre-existing: on 2. A stale runtime
|
Re-review —
|
| rope size | first codePointAt(0) |
|---|---|
| 1 Mchar | 0.31 ms |
| 8 Mchar | 2.54 ms |
| 32 Mchar | 9.16 ms |
And encodeInto from a 32 Mchar rope into a 2 KiB destination costs 9.81 ms while reading only 2048 code units — it pays the full flatten plus an O(n) allocation. So the guard is justified and correctly placed. The residual tradeoff is that a large flat string (a 100 KB JSON blob) also loses its whole preview rather than its first 8 KiB — a real but minor cost, and the conservative default is defensible.
Residual, non-blocking
- Map/Set show size only.
new Map([["a",1]])andnew Map([["zzz",999]])both renderMap(1)— same size, different contents, identical preview. Unavoidable without invoking iterators, which the design deliberately refuses, and the README now states contents are omitted. Worth knowing that a Map-valued divergence still is not diagnosable. completeUtf8PrefixLengthdropped theutf8SequenceLengthternary for a barereturn sequenceStart. That is provably equivalent: whenbytes[proposedLength]is a continuation byte, every byte in(sequenceStart, proposedLength]is one too, so the sequence always overruns and the old ternary could only picksequenceStart. Re-fuzzed to confirm — caps hold at every prefix length across 500 random structures, no throws, no lone surrogates, marker always present whentruncated.previewShadowLogValueno longer has its own try/catch, so isolation now lives solely inrecordShadowValidation. Correct for this caller; any future caller inherits the throw risk.- Module-load-time
Object.getOwnPropertyDescriptor(...)!.get!forMap.sizeand the typed-array getters would throw at import on a runtime missing them, taking down the whole library rather than just logging. Node >= 22 always has them, so this is theoretical. runtime-config.ts:131—assertKeyConfig'sshadowRampthrow fordefaultConfigis now unreachable, sincesnapshotDefaultConfigrejects it at registration. Harmless redundant guard.
This clears both blockers and the three medium findings. LGTM.
Follow-up: consider replacing the custom previewer with bounded
|
| approach | 200k-key record | 200k-elem array | intermediate string |
|---|---|---|---|
naive JSON.stringify + slice |
100 ms | 60 ms | 8–13 MB |
JSON.stringify + aborting replacer |
34 ms | 0.2 ms | none |
| current custom walk | ~35 ms (163 ms at 1M keys) | ~0 ms | none |
JSON.stringify enumerates all own keys upfront, so wide records cost O(n) either way — the same V8 enum-cache materialization as the current for...in. That cost is irreducible in JS, which means the 470 lines are buying fidelity, not safety.
For reference, util.inspect with getters: false, customInspect: false invokes no user code at all (verified: getters, toJSON, iterators, and proxy traps all stay untriggered) and its defaults already bound depth, arrays at 100, and strings at 10,000. Its one unbounded dimension is object key count.
Suggested shape
Asymmetric, because the cached side is free. At the mismatch point we still hold flight.cachedPayload, which is already a flat serialized string or Buffer:
- Buffer →
<binary N bytes>, do not decode - string →
.slice(0, SHADOW_LOG_VALUE_MAX_BYTES)
That is O(cap), invokes no user code, and is the actual stored bytes. Only the source side is a live object, and it can use a bounded stringify:
const ABORT = Symbol("abort");
let budget = 16_384;
JSON.stringify(v, (_k, val) => {
if (budget <= 0) throw ABORT;
budget -= typeof val === "string" ? val.length + 3 : 8;
return val;
});Skip binary on both sides — otherwise a Buffer renders as {"type":"Buffer","data":[104,101,...]}, which is both useless and large. Worth widening that skip to all Uint8Array, not just Buffer.
The naive stringify-then-slice variant is the one thing to avoid: 3x slower and it builds an 8–13 MB string per mismatch per side before discarding 99.9% of it. The aborting replacer is what makes this viable.
What we would give up — deliberately
Each of these I confirmed by test, so they should be a conscious call rather than a surprise:
- Getters and
toJSONget invoked. This is the one hard guarantee lost. It matters less than it first appears, sinceputForShadowalready callsserializer.dump(sourceValue)on the fill path, so source values are already serialized in normal operation. But it downgrades "never touches user code" from an invariant to best-effort, and a lazy getter with side effects would fire on a logging path. MapandSetrender as{}. Total loss — worse than today'sMap(1), and much worse thanutil.inspect'sMap(2) { 'a' => 1 }.- bigint and cycles throw, losing the whole preview rather than one field. The current code degrades to
[Circular]and continues. NaN/Infinitybecomenull,-0becomes0. Silently wrong, and numeric divergence is precisely what this feature diagnoses. Worth one replacer branch to emit"NaN"as a string.undefined, functions, and symbol-keyed fields vanish silently.
Two caveats that stay caveats either way: with a custom non-JSON serializer the two previews end up in different formats and stop being eyeball-comparable (fine for the default JSON serializer), and the wide-record enumeration stall persists, so the README's "keep detailed logging disabled for unbounded record shapes" guidance still applies.
Net
Roughly 25–40 lines replacing 470, deleting the Utf8PreviewWriter, the UTF-8 boundary arithmetic, completeUtf8PrefixLength, all 13 markers, typedArrayBrand's 13 branches, renderDate/renderCollectionSize/renderTypedArray, the escape table, and five of the six limit constants. Same wide-record cost, same byte bound where it matters.
Separately and independent of the above, two config simplifications worth folding in:
logMismatches+logMismatchDetailsencode four states, one of which (details: true, base: false) is silently inert. A singlelogMismatches: "off" | "metadata" | "detailed"makes the invalid state unrepresentable and removes the flag reconciliation, the two validation branches, and the collapsed-error logic added in393db5c.- Shadow config is validated in three places with three slightly different rule sets:
cloneShadowConfig(shape only, no range),snapshotDefaultConfig(range plus booleans), andscheduleShadowValidation/resolveShadowLogging(range plus booleans again, with its own copy of the 0–100 bounds). Worth collapsing to one boundary.
Happy to be talked out of any of this — the fidelity argument for the current renderer is real, I just do not think it is worth 470 lines pre-adoption.
Summary
shadowRampkey policy with a groupedshadowpolicy.shadow.logMismatchesswitch that logs the logical key plus native JSON for the cached and source values on a confirmed mismatch.metrics.shadowValidationactivation gate, confirmation read, and fail-open observer behavior.API and migration
Before:
After:
This is an intentional MVP breaking change. The public
DialCacheKeyConfigconstructor, static defaults, and raw runtime-provider results rejectshadowRampwith a migration error. At runtime, that config-resolution failure safely executes the uncached fallback rather than inheriting defaults. Migrate the field toshadow.ramp.Runtime defaults and per-key overrides merge
shadow.rampandshadow.logMismatchesindependently.DialCacheKeyConfig.disabled()explicitly sets the shadow ramp to0and mismatch logging tofalse.Mismatch behavior
shadow.logMismatchesfalsetrueThe warning is emitted only for terminal
mismatch: the cached Redis payload observed before comparison must still be byte-identical on the confirmation read. Matches, superseded comparisons, timeouts, dropped work, and errors remain metric-only.The warning fields are:
cacheNamespace,useCase,keyType, andoutcome: "mismatch"cacheKey: the logical DialCache URN, capped at 2 KiB of UTF-8cachedValueJson: native JSON for the deserialized cached comparator input, capped at 8 KiBsourceValueJson: native JSON for the source comparator input, capped at 8 KiBByte-clipped fields end in
...[truncated]within their cap. Each value is stringified independently. IfJSON.stringifythrows or returnsundefined, that field isnulland the other side is still attempted. DialCache does not callserializer.dumpagain or compute a diff.Safety and operational limits
metrics.shadowValidation.shadow.logMismatchesrecordsconfig_resolution, suppresses the warning, and preserves cache and shadow outcomes.toJSONmethods may run, unsupported values may be omitted or normalized, and cycles orbigintmake that sidenull.JSON.stringifyreturns. They bound emitted fields, not input traversal, hook execution, synchronous event-loop time, or the intermediate JSON string; enable logging only for trusted, reasonably bounded values.Validation
Validated from
main@9f6c933b69c33ac2f52c2931e581f953fe99a5c0with Node 22.22.0:corepack pnpm check— typecheck, 405 unit tests with coverage, build, and packed ESM/CommonJS consumer checkscorepack pnpm test:integration— 93 Redis, Valkey, and cluster testscorepack pnpm audit --prod— no known vulnerabilitiesgit diff --check