perf(evidence): validate ledger mutations incrementally
This commit is contained in:
parent
adc4852f3a
commit
809dfbfe48
|
|
@ -3,6 +3,7 @@ import canonicalize from "canonicalize";
|
|||
import {
|
||||
createEvidenceBundle,
|
||||
parseEvidenceBundle,
|
||||
validateResidualUnknownAddition,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
|
|
@ -216,8 +217,6 @@ export class EvidenceLedger {
|
|||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("invalid-transition", { cause }));
|
||||
}
|
||||
const pendingRecords = new Map(this.#records);
|
||||
pendingRecords.set(evidence.evidence_id, evidence);
|
||||
const existingUnknown = this.#unknownHeads.get(unknown.unknown_id);
|
||||
if (existingUnknown !== undefined) {
|
||||
if (
|
||||
|
|
@ -225,20 +224,12 @@ export class EvidenceLedger {
|
|||
existingUnknown.revision_digest !== unknown.revision_digest
|
||||
)
|
||||
return err(new UnknownRegistryError("already-exists"));
|
||||
const checked = this.#validateCandidate(
|
||||
pendingRecords,
|
||||
this.#unknownRevisions,
|
||||
);
|
||||
const checked = this.#appendIncremental([evidence]);
|
||||
if (!checked.ok) return checked;
|
||||
this.#commit(pendingRecords, this.#unknownRevisions, checked.value);
|
||||
return ok(null);
|
||||
}
|
||||
pendingRecords.set(mutation.evidence_id, mutation);
|
||||
const pendingUnknowns = new Map(this.#unknownRevisions);
|
||||
pendingUnknowns.set(unknownRevisionKey(unknown), unknown);
|
||||
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
|
||||
const checked = this.#appendIncremental([evidence, mutation], unknown);
|
||||
if (!checked.ok) return checked;
|
||||
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
||||
return ok(structuredClone(unknown));
|
||||
}
|
||||
|
||||
|
|
@ -332,16 +323,68 @@ export class EvidenceLedger {
|
|||
}),
|
||||
);
|
||||
}
|
||||
const pendingRecords = new Map(this.#records);
|
||||
pendingRecords.set(mutationEvidence.evidence_id, mutationEvidence);
|
||||
const pendingUnknowns = new Map(this.#unknownRevisions);
|
||||
pendingUnknowns.set(unknownRevisionKey(unknown), unknown);
|
||||
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
|
||||
const checked = this.#appendIncremental([mutationEvidence], unknown);
|
||||
if (!checked.ok) return checked;
|
||||
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
||||
return ok(structuredClone(unknown));
|
||||
}
|
||||
|
||||
#appendIncremental(
|
||||
records: readonly Evidence[],
|
||||
unknown?: ResidualUnknown,
|
||||
): Result<
|
||||
void,
|
||||
EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError
|
||||
> {
|
||||
const additions = new Map<string, Evidence>();
|
||||
let addedBytes = 0;
|
||||
for (const evidence of records) {
|
||||
const existing =
|
||||
additions.get(evidence.evidence_id) ??
|
||||
this.#records.get(evidence.evidence_id);
|
||||
if (existing !== undefined) {
|
||||
if (!recordsAgree(existing, evidence))
|
||||
return err(new EvidenceIntegrityError("Conflicting evidence record"));
|
||||
continue;
|
||||
}
|
||||
additions.set(evidence.evidence_id, evidence);
|
||||
addedBytes += serializedBytes(evidence);
|
||||
}
|
||||
const unknownKey =
|
||||
unknown === undefined ? undefined : unknownRevisionKey(unknown);
|
||||
if (unknownKey !== undefined && this.#unknownRevisions.has(unknownKey))
|
||||
return err(new UnknownRegistryError("integrity"));
|
||||
const unknownBytes = unknown === undefined ? 0 : serializedBytes(unknown);
|
||||
if (
|
||||
this.#exceedsRecordLimit(
|
||||
this.#records.size + additions.size,
|
||||
this.#unknownRevisions.size + (unknown === undefined ? 0 : 1),
|
||||
)
|
||||
)
|
||||
return err(new EvidenceLimitError("records", this.limits.maxRecords));
|
||||
if (this.#bytes + addedBytes + unknownBytes > this.limits.maxBytes)
|
||||
return err(new EvidenceLimitError("bytes", this.limits.maxBytes));
|
||||
if (unknown !== undefined)
|
||||
try {
|
||||
validateResidualUnknownAddition(
|
||||
unknown,
|
||||
{
|
||||
get: (id) => additions.get(id) ?? this.#records.get(id),
|
||||
has: (id) => additions.has(id) || this.#records.has(id),
|
||||
},
|
||||
this.#unknownHeads,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("integrity", { cause }));
|
||||
}
|
||||
for (const [id, evidence] of additions) this.#records.set(id, evidence);
|
||||
if (unknown !== undefined && unknownKey !== undefined) {
|
||||
this.#unknownRevisions.set(unknownKey, unknown);
|
||||
this.#unknownHeads.set(unknown.unknown_id, unknown);
|
||||
}
|
||||
this.#bytes += addedBytes + unknownBytes;
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
#validateCandidate(
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
unknowns: ReadonlyMap<string, ResidualUnknown>,
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ const validateUnknownGraph = (
|
|||
|
||||
const validateVerifiedResolution = (
|
||||
unknown: ResidualUnknown,
|
||||
evidenceById: ReadonlyMap<string, Evidence>,
|
||||
evidenceById: Pick<ReadonlyMap<string, Evidence>, "get">,
|
||||
): void => {
|
||||
if (unknown.contradicting_evidence_ids.length > 0)
|
||||
throw new TypeError(
|
||||
|
|
@ -260,6 +260,78 @@ const validateVerifiedResolution = (
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate one new residual-unknown revision against an already valid ledger.
|
||||
* This keeps ordinary mutations proportional to the affected revision while
|
||||
* full bundle imports continue to validate the complete graph.
|
||||
*/
|
||||
export const validateResidualUnknownAddition = (
|
||||
unknown: ResidualUnknown,
|
||||
evidenceById: Pick<ReadonlyMap<string, Evidence>, "get" | "has">,
|
||||
currentHeads: ReadonlyMap<string, ResidualUnknown>,
|
||||
): void => {
|
||||
const current = currentHeads.get(unknown.unknown_id);
|
||||
if (
|
||||
current === undefined
|
||||
? unknown.revision !== 1 || unknown.previous_revision_digest !== null
|
||||
: unknown.revision !== current.revision + 1 ||
|
||||
unknown.previous_revision_digest !== current.revision_digest
|
||||
)
|
||||
throw new TypeError(
|
||||
`Residual unknown ${unknown.unknown_id} revision chain is broken`,
|
||||
);
|
||||
for (const evidenceId of referencedEvidenceIds(unknown))
|
||||
if (!evidenceById.has(evidenceId))
|
||||
throw new TypeError(
|
||||
`Residual unknown references missing evidence ${evidenceId}`,
|
||||
);
|
||||
for (const relationship of unknown.relationships)
|
||||
if (
|
||||
relationship.unknown_id !== unknown.unknown_id &&
|
||||
!currentHeads.has(relationship.unknown_id)
|
||||
)
|
||||
throw new TypeError(
|
||||
`Residual unknown references missing unknown ${relationship.unknown_id}`,
|
||||
);
|
||||
if (unknown.resolution?.disposition === "verified")
|
||||
validateVerifiedResolution(unknown, evidenceById);
|
||||
for (const relationship of unknown.relationships)
|
||||
if (
|
||||
relationship.type === "depends-on" &&
|
||||
reachesUnknown(
|
||||
relationship.unknown_id,
|
||||
unknown.unknown_id,
|
||||
currentHeads,
|
||||
unknown,
|
||||
)
|
||||
)
|
||||
throw new TypeError("Residual unknown dependency graph contains a cycle");
|
||||
};
|
||||
|
||||
const reachesUnknown = (
|
||||
start: string,
|
||||
goal: string,
|
||||
currentHeads: ReadonlyMap<string, ResidualUnknown>,
|
||||
candidate: ResidualUnknown,
|
||||
): boolean => {
|
||||
const pending = [start];
|
||||
const visited = new Set<string>();
|
||||
while (pending.length > 0) {
|
||||
const id = pending.pop();
|
||||
if (id === undefined) break;
|
||||
if (id === goal) return true;
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
const unknown =
|
||||
id === candidate.unknown_id ? candidate : currentHeads.get(id);
|
||||
if (unknown === undefined) continue;
|
||||
for (const relationship of unknown.relationships)
|
||||
if (relationship.type === "depends-on")
|
||||
pending.push(relationship.unknown_id);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const evidenceQualifies = (
|
||||
unknown: ResidualUnknown,
|
||||
evidence: Evidence,
|
||||
|
|
|
|||
Loading…
Reference in New Issue