mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
fix(environment): bound and validate write state history
This commit is contained in:
1 parent
19b50ea668
commit
a057ea9784
8 files changed
+211
-9
No files matched your search
@@ -30,7 +30,10 @@ import {
|
||||
prepareServedState,
|
||||
} from "./environmentState.js";
|
||||
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
|
||||
import { EnvironmentStateStore } from "./environmentStateStore.js";
|
||||
import {
|
||||
EnvironmentStateStore,
|
||||
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
|
||||
} from "./environmentStateStore.js";
|
||||
|
||||
const fixture = environmentStateServeFixture();
|
||||
const { source, state } = environmentStateServeInputs(fixture);
|
||||
@@ -151,6 +154,53 @@ describe("loadEnvironmentState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("served collection history", () => {
|
||||
it("refuses a new collection before retained collection and sequence history exceeds its budget", () => {
|
||||
const store = new EnvironmentStateStore(state);
|
||||
const original = state.entities[0];
|
||||
if (original === undefined) throw new Error("Expected a seeded entity");
|
||||
let refused: ReturnType<typeof store.insert> | undefined;
|
||||
for (let index = 0; index < 2000; index += 1) {
|
||||
const before = JSON.stringify(store.snapshot());
|
||||
const result = store.insert(
|
||||
{
|
||||
...original,
|
||||
collection: `/api/history/${String(index)}-${"x".repeat(900)}`,
|
||||
id: `history-${String(index)}`,
|
||||
},
|
||||
0,
|
||||
);
|
||||
if (!result.ok) {
|
||||
refused = result;
|
||||
expect(JSON.stringify(store.snapshot())).toBe(before);
|
||||
break;
|
||||
}
|
||||
const inserted = store.entity(
|
||||
original.type,
|
||||
`/api/history/${String(index)}-${"x".repeat(900)}`,
|
||||
`history-${String(index)}`,
|
||||
);
|
||||
if (inserted === undefined) throw new Error("Expected inserted entity");
|
||||
store.remove(inserted);
|
||||
}
|
||||
expect(refused?.ok).toBe(false);
|
||||
if (refused?.ok !== false)
|
||||
throw new Error("Expected a history budget refusal");
|
||||
expect(refused.error).toMatchObject({
|
||||
resource: "capacity",
|
||||
retryable: false,
|
||||
reportedLimits: {
|
||||
collection_history_bytes: expect.any(Number),
|
||||
max_collection_history_bytes:
|
||||
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
|
||||
},
|
||||
remediationAction: expect.stringContaining("Reset the served state"),
|
||||
});
|
||||
expect(refused.error.reason).toContain("retained metadata budget");
|
||||
expect(store.knownCollectionsOf(original.type).length).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveEnvironmentState", () => {
|
||||
it("derives the state inspect-analysis-view projects from the capture with the manifest's rules, under the same Evidence id", () => {
|
||||
const derived = deriveEnvironmentState(source, fixture.capture);
|
||||
|
||||
@@ -31,7 +31,8 @@ export const createdCollection = (
|
||||
type: string,
|
||||
): string | undefined => {
|
||||
const segments = callSegments(tool, args);
|
||||
if (segments === undefined) return undefined;
|
||||
if (segments === undefined || segments.some(({ text }) => text.length === 0))
|
||||
return undefined;
|
||||
// The spelling the call supplies verbatim wins, as item calls choose their entity, then the first known: held ones lead.
|
||||
let held: string | undefined;
|
||||
let verbatim = -1;
|
||||
|
||||
@@ -19,6 +19,34 @@ import {
|
||||
type StateContent,
|
||||
} from "./environmentStateDocument.js";
|
||||
|
||||
/** Bound estimated collection and sequence-key metadata retained after served creates are deleted. */
|
||||
export const ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES = 1024 * 1024;
|
||||
|
||||
const collectionHistoryCost = (
|
||||
state: EnvironmentState,
|
||||
type: string,
|
||||
collection: string,
|
||||
): number => {
|
||||
const sequenceKeys = new Set<string>();
|
||||
for (const plan of state.writes.values()) {
|
||||
if (!plan.served || plan.target !== type) continue;
|
||||
for (const rule of plan.rules) {
|
||||
if (rule.rule === "sequence" && rule.scope === "collection")
|
||||
sequenceKeys.add(`${plan.tool} ${rule.field} ${collection}`);
|
||||
else if (rule.rule === "identity" && rule.generator === "sequence")
|
||||
sequenceKeys.add(`${type} ${rule.field} ${collection}`);
|
||||
}
|
||||
}
|
||||
return (
|
||||
64 +
|
||||
Buffer.byteLength(collection, "utf8") +
|
||||
[...sequenceKeys].reduce(
|
||||
(bytes, key) => bytes + 64 + Buffer.byteLength(key, "utf8"),
|
||||
0,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/** What `data/get` reports: the current state as a `rea-state/v1` document. */
|
||||
export interface StateSnapshot {
|
||||
readonly rea_state: {
|
||||
@@ -91,6 +119,8 @@ export class EnvironmentStateStore {
|
||||
#started: readonly StateEntity[] = [];
|
||||
/** Every collection spelling a type's entities have been held under since the last reset or load, kept when a collection empties. */
|
||||
readonly #knownCollections = new Map<string, string[]>();
|
||||
/** Estimated retained collection and sequence-key metadata, reset with the serving history. */
|
||||
#collectionHistoryBytes = 0;
|
||||
/** The collections of the Evidence's own state, which stay known after any load. */
|
||||
#evidenceCollections: ReadonlyMap<string, readonly string[]> = new Map();
|
||||
|
||||
@@ -176,6 +206,7 @@ export class EnvironmentStateStore {
|
||||
this.#started = this.#content.entities;
|
||||
// A reset or load knows the Evidence's spellings and its own, never those of a state it replaced.
|
||||
this.#knownCollections.clear();
|
||||
this.#collectionHistoryBytes = 0;
|
||||
this.#remember(this.#evidenceCollections);
|
||||
this.#remember(this.#typeCollections);
|
||||
}
|
||||
@@ -184,7 +215,14 @@ export class EnvironmentStateStore {
|
||||
for (const [type, collections] of named) {
|
||||
const known = this.#knownCollections.get(type) ?? [];
|
||||
for (const collection of collections)
|
||||
if (!known.includes(collection)) known.push(collection);
|
||||
if (!known.includes(collection)) {
|
||||
known.push(collection);
|
||||
this.#collectionHistoryBytes += collectionHistoryCost(
|
||||
this.state,
|
||||
type,
|
||||
collection,
|
||||
);
|
||||
}
|
||||
this.#knownCollections.set(type, known);
|
||||
}
|
||||
}
|
||||
@@ -209,6 +247,30 @@ export class EnvironmentStateStore {
|
||||
jsonBytes(entity) + (entities.length > 0 ? 1 : 0),
|
||||
);
|
||||
if (!bytes.ok) return bytes;
|
||||
const known = this.#knownCollections.get(entity.type) ?? [];
|
||||
if (!known.includes(entity.collection)) {
|
||||
const historyBytes =
|
||||
this.#collectionHistoryBytes +
|
||||
collectionHistoryCost(this.state, entity.type, entity.collection);
|
||||
if (historyBytes > ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES)
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
"capacity",
|
||||
"Served collection and sequence history would exceed its retained metadata budget",
|
||||
{
|
||||
max_collection_history_bytes:
|
||||
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
|
||||
collection_history_bytes: historyBytes,
|
||||
},
|
||||
{
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"Reset the served state to clear collection history, then use fewer distinct collection paths.",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
const members = this.inCollection(entity.type, entity.collection);
|
||||
const anchor =
|
||||
index < members.length
|
||||
|
||||
@@ -536,6 +536,25 @@ describe("collections a create chooses", () => {
|
||||
?.collection,
|
||||
).toBe("/api/orgs/acme1/books");
|
||||
});
|
||||
|
||||
it("refuses an empty parent segment before storing an unreadable collection", () => {
|
||||
const books = servedFrom(ROWS);
|
||||
const tool = books.source.manifest.tools.find(
|
||||
(candidate) => candidate.method === "POST",
|
||||
)!;
|
||||
const [org] = environmentToolArguments(tool).path.values();
|
||||
const before = books.snapshot();
|
||||
expect(
|
||||
books.refusal(tool.name, { [org!]: "", body: { name: "Invalid" } }),
|
||||
).toMatchObject({
|
||||
code: "invalid_request",
|
||||
message: expect.stringContaining("path argument to be non-empty"),
|
||||
});
|
||||
expect(books.snapshot()).toBe(before);
|
||||
expect(
|
||||
books.store.knownCollectionsOf("/api/orgs/{org}/books"),
|
||||
).not.toContain("/api/orgs//books");
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing entities after a delete", () => {
|
||||
|
||||
@@ -265,7 +265,12 @@ const answerCreate = (
|
||||
);
|
||||
const observed = observedEntity(serving, plan);
|
||||
if (collection === undefined || observed === undefined)
|
||||
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
|
||||
return err(
|
||||
refused(
|
||||
tool,
|
||||
`Tool ${tool.name} needs every path argument to be non-empty.`,
|
||||
),
|
||||
);
|
||||
if (plan.collection !== null && plan.collection !== collection)
|
||||
return err(
|
||||
refused(
|
||||
|
||||
@@ -844,6 +844,32 @@ describe("what a check leaves pending", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("checks only a partial update's fields that remain after a later overwrite", () => {
|
||||
const patched = described(1, "Patched", "survives");
|
||||
const replaced = described(1, "Replaced", "survives");
|
||||
const changedByOtherAction = described(1, "Archived", "survives");
|
||||
const rows: TimedCaptureRow[] = [
|
||||
["GET", "/api/books", summary([FIRST_BOOK, SECOND_BOOK])],
|
||||
["GET", "/api/books/1", described(1, "First Book", "original")],
|
||||
["GET", "/api/books/2", described(2, "Second Book", "other")],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/books/1",
|
||||
patched,
|
||||
200,
|
||||
{ name: "Patched", description: "survives" },
|
||||
],
|
||||
["PUT", "/api/books/1", replaced, 200, { name: "Replaced" }],
|
||||
["POST", "/api/books/1/archive", changedByOtherAction, 204, {}],
|
||||
["GET", "/api/books/1", changedByOtherAction],
|
||||
];
|
||||
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
checked_record_ordinals: [6],
|
||||
contradicted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("neither resynchronises nor checks an entity by a read begun while a write to it still ran", () => {
|
||||
const rows: TimedCaptureRow[] = [
|
||||
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
||||
@@ -880,6 +906,38 @@ describe("what a check leaves pending", () => {
|
||||
});
|
||||
|
||||
describe("what a later call must follow", () => {
|
||||
it("rejects a later create whose unique field repeats a value already held", () => {
|
||||
const existing = {
|
||||
id: "123e4567-e89b-42d3-a456-426614174000",
|
||||
ref: "223e4567-e89b-42d3-a456-426614174000",
|
||||
name: "Existing",
|
||||
};
|
||||
const first = {
|
||||
id: "323e4567-e89b-42d3-a456-426614174000",
|
||||
ref: "423e4567-e89b-42d3-a456-426614174000",
|
||||
name: "First",
|
||||
};
|
||||
const duplicate = {
|
||||
id: "523e4567-e89b-42d3-a456-426614174000",
|
||||
ref: first.ref,
|
||||
name: "Second",
|
||||
};
|
||||
const rows: TimedCaptureRow[] = [
|
||||
["GET", "/api/items", [existing]],
|
||||
["GET", `/api/items/${existing.id}`, existing],
|
||||
["POST", "/api/items", first, 201, { name: "First" }],
|
||||
["GET", `/api/items/${first.id}`, first],
|
||||
["GET", "/api/items", [existing, first]],
|
||||
["POST", "/api/items", duplicate, 201, { name: "Second" }],
|
||||
["GET", `/api/items/${duplicate.id}`, duplicate],
|
||||
["GET", "/api/items", [existing, first, duplicate]],
|
||||
];
|
||||
expect(gated(rows, "post_items")).toMatchObject({
|
||||
served: false,
|
||||
reason: "unexplained-change",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a create whose later call does not follow the sequence it learned", () => {
|
||||
const ranked = (at: Book, rank: number) => ({ ...at, rank });
|
||||
const third = ranked(book(3, "Third Book", bookClock(3)), 3);
|
||||
|
||||
@@ -220,8 +220,10 @@ export class WriteEffects {
|
||||
const own = this.#changed.get(at.entity);
|
||||
for (const [tool, claim] of own ?? []) {
|
||||
const left = tool === at.writer ? claim : remaining(claim, write);
|
||||
if (left !== undefined) own!.set(tool, left);
|
||||
else {
|
||||
if (left !== undefined) {
|
||||
own!.set(tool, left);
|
||||
if (tool !== at.writer) touched.get(at.entity)?.set(tool, left);
|
||||
} else {
|
||||
own!.delete(tool);
|
||||
touched.get(at.entity)?.delete(tool);
|
||||
}
|
||||
|
||||
@@ -199,10 +199,15 @@ const followsGenerated = (
|
||||
return (
|
||||
JSON.stringify(readClock(value)?.format) === JSON.stringify(rule.format)
|
||||
);
|
||||
if (rule.rule === "unique")
|
||||
return (
|
||||
isUuid(value) &&
|
||||
!context.state
|
||||
.ofType(created.type)
|
||||
.some((held) => held.fields[rule.field] === value)
|
||||
);
|
||||
return (
|
||||
(rule.rule !== "unique" &&
|
||||
(rule.rule !== "identity" || rule.generator !== "uuid")) ||
|
||||
isUuid(value)
|
||||
rule.rule !== "identity" || rule.generator !== "uuid" || isUuid(value)
|
||||
);
|
||||
}) && followsSequences(context, draft, created);
|
||||
|
||||
|
||||
Reference in new issue
Block a user