mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user