refactor(json): consolidate representation size accounting

Share bounded UTF-8 and UTF-16 JSON measurements across state serving, write retention and source-map admission. Remove duplicate counters and distinguish retained state estimates from encoded bytes while preserving limits and exact refusal diagnostics.
This commit is contained in:
morluto committed 2026-10-23 20:54:19 +08:00
1 parent 5d6b8a0ab6
commit 3c5c887a2c
11 files changed
+118 -51

No files matched your search

@@ -419,6 +419,59 @@ describe("recorded and document answers", () => {
});
});
it("caps a state-backed document by its exact serialized UTF-8 body bytes", () => {
const capture = timedCaptureEvidence([
["GET", "/api/settings", { theme: "dark" }],
["PUT", "/api/settings", { theme: "light" }],
["GET", "/api/settings", { theme: "light" }],
]);
const serving = servingOf(
environmentStateServeInputs(environmentStateServeFixture(capture)),
);
const loadDocument = (value: string) =>
load(serving, (entities) => entities, [
{ tool: "get_settings", path: "/api/settings", value },
]);
const exactLimit = "x".repeat(REPLAY_BODY_LIMIT_BYTES - 2);
expect(Buffer.byteLength(JSON.stringify(exactLimit), "utf8")).toBe(
REPLAY_BODY_LIMIT_BYTES,
);
loadDocument(exactLimit);
expect(answer(serving, "get_settings", {})).toMatchObject({
body: exactLimit,
replay: { truncated: false },
state: { source: "state", view: "document" },
});
// The content is under the limit as text, but JSON escaping makes the
// actual reply body exceed it. The refusal reports that encoded extent.
const escaped = "\n".repeat(REPLAY_BODY_LIMIT_BYTES / 2 + 1);
const bodyBytes = Buffer.byteLength(JSON.stringify(escaped), "utf8");
expect(escaped.length).toBeLessThan(REPLAY_BODY_LIMIT_BYTES);
expect(bodyBytes).toBeGreaterThan(REPLAY_BODY_LIMIT_BYTES);
loadDocument(escaped);
const before = serving.store.snapshot();
const result = answerFromState(
serving,
toolOf(serving.source, "get_settings"),
{},
);
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Expected the oversized state answer to be refused");
expect(result.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
operation: "environment-serve",
resource: "capacity",
reportedLimits: {
max_body_bytes: REPLAY_BODY_LIMIT_BYTES,
body_bytes: bodyBytes,
},
});
expect(serving.store.snapshot()).toEqual(before);
});
it("answers a document's recorded line with the state's value for it", () => {
const capture = timedCaptureEvidence([
["GET", "/api/settings", { theme: "dark", density: "compact" }],
@@ -32,6 +32,7 @@ import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { jsonByteLength } from "../../domain/jsonSerialization.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
@@ -171,7 +172,7 @@ export const stateAnswer = (
body: JsonValue,
state: StateProvenance,
): Result<StateAnswer, AnalysisError> => {
const bytes = Buffer.byteLength(JSON.stringify(body), "utf8");
const bytes = jsonByteLength(body);
if (bytes > REPLAY_BODY_LIMIT_BYTES)
return err(
new AnalysisResourceConstraintError(
@@ -8,7 +8,7 @@ import {
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { jsonParts } from "../../domain/jsonSerialization.js";
import { jsonByteLength } from "../../domain/jsonSerialization.js";
import { err, ok, type Result } from "../../domain/result.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
@@ -65,16 +65,9 @@ export interface StateSnapshot {
};
}
/** The UTF-8 bytes a value's JSON takes, counted part by part so it is never one string. */
const jsonBytes = (value: unknown): number => {
let bytes = 0;
for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8");
return bytes;
};
/** The bytes JSON writes for these array items and the commas between them. */
const itemsBytes = (items: readonly unknown[]): number =>
items.reduce<number>((sum, item) => sum + jsonBytes(item), 0) +
items.reduce<number>((sum, item) => sum + jsonByteLength(item), 0) +
Math.max(0, items.length - 1);
/** What `data/get` reports for some content of a state. */
@@ -94,7 +87,7 @@ const snapshotOf = (
/** What `data/get` writes for a state as the Evidence holds it, in UTF-8 bytes, summed item by item. */
export const initialStateBytes = (state: EnvironmentState): number =>
jsonBytes(snapshotOf(state, [], [])) +
jsonByteLength(snapshotOf(state, [], [])) +
itemsBytes(state.entities) +
itemsBytes(state.documents);
@@ -137,7 +130,7 @@ export class EnvironmentStateStore {
);
this.#clockStart =
state.writeClock === null ? 0 : Date.parse(state.writeClock);
this.#frameBytes = jsonBytes(snapshotOf(state, [], []));
this.#frameBytes = jsonByteLength(snapshotOf(state, [], []));
this.#initialBytes = initialStateBytes(state);
this.reset();
}
@@ -250,7 +243,7 @@ export class EnvironmentStateStore {
): Result<void, AnalysisResourceConstraintError> {
const entities = this.#content.entities;
const bytes = this.#grown(
jsonBytes(entity) + (entities.length > 0 ? 1 : 0),
jsonByteLength(entity) + (entities.length > 0 ? 1 : 0),
);
if (!bytes.ok) return bytes;
const known = this.#knownCollections.get(entity.type);
@@ -309,7 +302,7 @@ export class EnvironmentStateStore {
const key = compositeKey([entity.type, entity.collection, entity.id]);
const held = this.#entities.get(key);
if (held === undefined) return ok(undefined);
const bytes = this.#grown(jsonBytes(entity) - jsonBytes(held));
const bytes = this.#grown(jsonByteLength(entity) - jsonByteLength(held));
if (!bytes.ok) return bytes;
this.#install(
{
@@ -333,7 +326,7 @@ export class EnvironmentStateStore {
);
this.#install(
{ ...this.#content, entities },
this.#bytes - jsonBytes(held) - (entities.length > 0 ? 1 : 0),
this.#bytes - jsonByteLength(held) - (entities.length > 0 ? 1 : 0),
);
}
@@ -345,7 +338,7 @@ export class EnvironmentStateStore {
const held = this.#documents.get(tool);
if (held === undefined) return ok(undefined);
const written = { tool: held.tool, path: held.path, value };
const bytes = this.#grown(jsonBytes(written) - jsonBytes(held));
const bytes = this.#grown(jsonByteLength(written) - jsonByteLength(held));
if (!bytes.ok) return bytes;
this.#install(
{
+16 -19
View File
@@ -24,7 +24,7 @@ import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import { safeParseJson } from "../domain/safeJson.js";
import { sourceMapJsonText } from "../domain/sourceMapEnvelope.js";
import { resolveJavaScriptSourceMapUrl } from "../domain/javascript/javascriptSourceMapPaths.js";
import { jsonParts } from "../domain/jsonSerialization.js";
import { jsonByteLength } from "../domain/jsonSerialization.js";
import {
inspectSourceMapValue,
SourceMapFormatFailure,
@@ -287,7 +287,10 @@ const fetchOne = async (
const { response, fetchedUrl } = fetched;
try {
// Admit the resolution identity before reading or retaining its body.
consumeExpandedBytes(budget.decodedRecords, jsonBytes(fetchedUrl) - 4);
consumeExpandedBytes(
budget.decodedRecords,
jsonByteLength(fetchedUrl) - 4,
);
} catch (cause: unknown) {
try {
await response.body?.cancel();
@@ -587,7 +590,7 @@ const normalizeSourceMap = (
SOURCE_MAP_SUCCESS_ENVELOPE_BYTES;
consumeExpandedBytes(
context.budget,
jsonBytes(text) +
jsonByteLength(text) +
artifactMetadataBytes("application/source-map+json", text) -
4,
);
@@ -629,7 +632,7 @@ const normalizeSourceMap = (
(originalSources.length === 0 ? 0 : 1) +
(content === null
? 0
: jsonBytes(content) +
: jsonByteLength(content) +
artifactMetadataBytes(mediaType, content) -
4),
);
@@ -699,7 +702,7 @@ const normalizeSourceMap = (
if (modules.incomplete.length > 0)
consumeExpandedBytes(
context.budget,
jsonBytes(
jsonByteLength(
`Module edges are incomplete: ${modules.incomplete.length} of ${originalSources.filter(({ artifact }) => artifact !== null).length} original sources could not be parsed in full (${modules.incomplete.join(", ")}).`,
) - 4,
);
@@ -938,7 +941,7 @@ const preflightResolvedSourceBytes = (
resolvedBytes += bytes;
sourcesForRoot.set(sourceKey, resolved);
const sanitized = cachedSanitized(sanitizedByResolved, resolved);
estimate += jsonBytes(sanitized);
estimate += jsonByteLength(sanitized);
if (estimate > WEB_SOURCE_MAP_LIMITS.outputBytes)
throw expandedOutputFailure();
}
@@ -979,12 +982,6 @@ const resolvedSourceLimitFailure = (): SourceMapFormatFailure =>
`Resolved source-map identities exceed the ${String(SOURCE_MAP_RESPONSE_BYTES / (1024 * 1024))} MiB decoder representation budget. ${noRuntimeOverrideSentence("That representation budget stays fixed.", "source_map_fetch_timeout_ms")}`,
);
const jsonBytes = (value: string): number => {
let bytes = 0;
for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8");
return bytes;
};
const artifactMetadataBytes = (mediaType: string, text: string): number =>
Buffer.byteLength(
JSON.stringify({
@@ -1024,17 +1021,17 @@ const sourceMapFailureReservation = (
>["status"] = "fetch_failed",
): number =>
SOURCE_MAP_FAILURE_ENVELOPE_BYTES +
jsonBytes(request.declaredUrl) +
jsonBytes(request.scriptKey) +
(effectiveUrl === null ? 0 : jsonBytes(effectiveUrl) - 4) +
jsonByteLength(request.declaredUrl) +
jsonByteLength(request.scriptKey) +
(effectiveUrl === null ? 0 : jsonByteLength(effectiveUrl) - 4) +
Math.max(
0,
(limitation === undefined ? 0 : jsonBytes(limitation)) -
(limitation === undefined ? 0 : jsonByteLength(limitation)) -
SOURCE_MAP_FAILURE_LIMITATION_RESERVE -
2,
) +
jsonBytes(status) -
jsonBytes("fetch_failed");
jsonByteLength(status) -
jsonByteLength("fetch_failed");
const sourceEntryOverheadBytes = (
sourceIndex: number,
@@ -1047,7 +1044,7 @@ const sourceEntryOverheadBytes = (
const cachedJsonBytes = (cache: Map<string, number>, value: string): number => {
const cached = cache.get(value);
if (cached !== undefined) return cached;
const bytes = jsonBytes(value);
const bytes = jsonByteLength(value);
cache.set(value, bytes);
return bytes;
};
@@ -1,3 +1,4 @@
import { jsonByteLength } from "../jsonSerialization.js";
import type { JsonValue } from "../jsonValue.js";
import {
compareUnicodeCodePoints as compare,
@@ -15,7 +16,7 @@ import {
isParam,
isQueryPost,
isStateWrite,
jsonBytes,
stateValueRetentionBytes,
lastSegmentValue,
STATE_BODY_LIMIT_BYTES,
listItems,
@@ -536,7 +537,7 @@ export class EnvironmentStateExtraction {
if (position < first || (position === first && index < at))
entity.order = [position, index];
}
this.#retain(ENTRY_BYTES + jsonBytes(object));
this.#retain(ENTRY_BYTES + stateValueRetentionBytes(object));
entity.observations.push({
position,
ordinal: record.ordinal,
@@ -642,15 +643,14 @@ export class EnvironmentStateExtraction {
// Parsing can lengthen a body that was admitted under the recorded-text limit:
// `1e3` is four bytes once rendered as `1000`. Serving refuses that rendering,
// so an oversized document stays the recorded replay instead of a failing read.
if (
Buffer.byteLength(JSON.stringify(value), "utf8") >
STATE_BODY_LIMIT_BYTES
) {
if (jsonByteLength(value) > STATE_BODY_LIMIT_BYTES) {
this.#recorded(tool, "rendered-oversize");
continue;
}
this.#retain(
ENTRY_BYTES + jsonBytes(value) + textBytes(record.path.length),
ENTRY_BYTES +
stateValueRetentionBytes(value) +
textBytes(record.path.length),
);
documents.push({
tool: tool.name,
@@ -383,16 +383,17 @@ export const rootOf = (
};
/** Value bytes as retained state, charged before a value is kept. */
export const jsonBytes = (value: JsonValue): number => {
export const stateValueRetentionBytes = (value: JsonValue): number => {
if (typeof value === "string") return textBytes(value.length);
if (Array.isArray(value))
return value.reduce<number>(
(sum, item) => sum + jsonBytes(item),
(sum, item) => sum + stateValueRetentionBytes(item),
ENTRY_BYTES,
);
if (isObject(value))
return Object.entries(value).reduce<number>(
(sum, [name, item]) => sum + textBytes(name.length) + jsonBytes(item),
(sum, [name, item]) =>
sum + textBytes(name.length) + stateValueRetentionBytes(item),
ENTRY_BYTES,
);
return ENTRY_BYTES;
@@ -1,3 +1,4 @@
import { jsonTextCodeUnits } from "../jsonSerialization.js";
import type { JsonValue } from "../jsonValue.js";
import { ok } from "../result.js";
import {
@@ -310,7 +311,7 @@ const insertCreated = (
const members = fieldsOf(
context.state.members(entity.type, entity.collection),
);
context.io.retain(64 + JSON.stringify(entity.fields).length * 2);
context.io.retain(64 + jsonTextCodeUnits(entity.fields) * 2);
context.state.put(
entity,
draft.plan.insert === null
@@ -1,3 +1,4 @@
import { jsonTextCodeUnits } from "../jsonSerialization.js";
import type { JsonValue } from "../jsonValue.js";
import {
at as valueAt,
@@ -140,7 +141,7 @@ export const applyDocument = (
: "recorded",
pointer: null,
}));
context.io.retain(64 + JSON.stringify(written.value).length * 2);
context.io.retain(64 + jsonTextCodeUnits(written.value) * 2);
context.state.setDocument(tool, written.value);
const fields = writtenPointers(draft, body!, {
before: value!,
@@ -1,3 +1,4 @@
import { jsonTextCodeUnits } from "../jsonSerialization.js";
import type { JsonValue } from "../jsonValue.js";
import { ok } from "../result.js";
import {
@@ -306,8 +307,7 @@ export const retainFixed = (
context: EffectContext,
fixed: readonly { readonly sent: JsonValue }[],
): void => {
if (fixed.length > 0)
context.io.retain(64 + JSON.stringify(fixed).length * 2);
if (fixed.length > 0) context.io.retain(64 + jsonTextCodeUnits(fixed) * 2);
};
/** Build an update plan from its exemplar, or add what a later call showed. */
@@ -438,10 +438,10 @@ export const applyUpdate = (
if (!written.ok || disagrees || unknown) return recorded();
const facts = { shown: scalars(after.object), sent: scalars(body), kept };
context.io.retain(
64 + JSON.stringify([...facts.shown, ...facts.sent, kept]).length * 2,
64 + jsonTextCodeUnits([...facts.shown, ...facts.sent, kept]) * 2,
);
draft.updates.push(facts);
context.io.retain(64 + JSON.stringify(written.value).length * 2);
context.io.retain(64 + jsonTextCodeUnits(written.value) * 2);
context.state.put(updatedEntity(entity, written.value));
// A later read that also shows an unmodelled change checks only the members this call sent.
const sent = Object.keys(body).filter(
+6
View File
@@ -5,11 +5,16 @@ import {
bufferedJsonParts,
canonicalJsonParts,
jsonParts,
jsonByteLength,
jsonTextCodeUnits,
equalJsonText,
} from "./jsonSerialization.js";
/** Compact and pretty streamed JSON must equal native JSON. */
const expectNativeJson = (value: unknown): void => {
const text = JSON.stringify(value);
expect(jsonByteLength(value)).toBe(Buffer.byteLength(text, "utf8"));
expect(jsonTextCodeUnits(value)).toBe(text.length);
expect([...bufferedJsonParts(jsonParts(value))].join("")).toBe(
JSON.stringify(value),
);
@@ -30,6 +35,7 @@ describe("streamed JSON representation", () => {
it("preserves surrogate pairs across string and buffer boundaries", () => {
const value = { text: `${"x".repeat(8190)}😀${'中文\n"'.repeat(20000)}` };
expectNativeJson(value);
const parts = [...bufferedJsonParts(jsonParts(value))];
expect(
Buffer.concat(parts.map((part) => Buffer.from(part))).toString(),
+14
View File
@@ -23,6 +23,20 @@ export function* jsonParts(value: unknown, pretty = false): Generator<string> {
yield* encodeValue(value, pretty, false);
}
/** Exact UTF-8 bytes of ordinary JSON without allocating the whole document. */
export const jsonByteLength = (value: unknown): number => {
let bytes = 0;
for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8");
return bytes;
};
/** Exact UTF-16 code units of ordinary JSON without allocating the whole document. */
export const jsonTextCodeUnits = (value: unknown): number => {
let length = 0;
for (const part of jsonParts(value)) length += part.length;
return length;
};
/** Encode validated JSON with canonical key ordering without allocating the whole document. */
export function* canonicalJsonParts(value: unknown): Generator<string> {
yield* encodeValue(value, false, true);