mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
The Session kept every document it loaded or created in memory for the life of the process: every conversation and session document ever touched, built-in and third-party. Now a cached document that no watch or document state observes leaves the cache once it was not read or written for `documentRetentionMs` (HarnessSettings and createSession options; default ten minutes, 0 drops it right after use, Infinity keeps documents; anything that is not a number of milliseconds means the default), and loads from Storage again on its next access. An observer pins the incarnation it observes and releases it when that incarnation retires. Eviction runs on the mutation line, after every commit outcome (published, no-write, rejected, failed settlement) and from one unreferenced timer for the next expiry, so a transaction never loses a tracker it holds. Where timers cannot be unreferenced only commits evict. Cache bookkeeping is contained: a throwing clock or setting never changes an operation's outcome. A hot document moves in the eviction order at most once per granule (a tenth of the retention, up to 1 s), keeping the fan-out bench at HEAD's speed. `npm test` runs the durable suite a second time with every unattached document evicted right after use. 5,000 conversations kept 26,000 documents and about 3.8 KB each; with a 50 ms retention, after waiting, none (bench:document-cache).
162 lines
5.2 KiB
TypeScript
162 lines
5.2 KiB
TypeScript
import type { Context } from "@earendil-works/chord";
|
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
import {
|
|
type CommitPublication,
|
|
type ConversationId,
|
|
type DocumentAddress,
|
|
type DocumentCommitChange,
|
|
type DocumentId,
|
|
type DocumentPoint,
|
|
type DocumentRecord,
|
|
type Id,
|
|
MemoryStorage,
|
|
type Seq,
|
|
type StorageWrite,
|
|
} from "@earendil-works/pi-durable";
|
|
import { SessionImpl } from "../src/session/session.ts";
|
|
|
|
export const context: Context = BACKGROUND_CONTEXT;
|
|
|
|
type Deferred = { readonly promise: Promise<void>; readonly resolve: () => void };
|
|
|
|
function deferred(): Deferred {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
/** A gate that holds calls until released and reports when the first held call arrives. */
|
|
export type Gate = {
|
|
readonly entered: Promise<void>;
|
|
release(): void;
|
|
};
|
|
|
|
/** Memory storage with observable commits, held calls, and injected commit failures. */
|
|
export class ControlledStorage extends MemoryStorage {
|
|
/** Exact borrowed batches admitted by Session. */
|
|
readonly admittedCommits: (readonly StorageWrite[])[] = [];
|
|
/** Detached batches for value assertions. */
|
|
readonly commits: (readonly StorageWrite[])[] = [];
|
|
mintCount = 0;
|
|
documentReadCount = 0;
|
|
#commitGate: { gate: Deferred; entered: Deferred } | undefined;
|
|
#findGate: { gate: Deferred; entered: Deferred } | undefined;
|
|
#commitFailure: Error | undefined;
|
|
|
|
holdCommits(): Gate {
|
|
const held = { gate: deferred(), entered: deferred() };
|
|
this.#commitGate = held;
|
|
return { entered: held.entered.promise, release: () => this.#release("commit", held) };
|
|
}
|
|
|
|
holdFindDocument(): Gate {
|
|
const held = { gate: deferred(), entered: deferred() };
|
|
this.#findGate = held;
|
|
return { entered: held.entered.promise, release: () => this.#release("find", held) };
|
|
}
|
|
|
|
/** Simulate a crash during the held commit: it never reaches storage, and later commits proceed. */
|
|
crash(): void {
|
|
this.#commitGate = undefined;
|
|
}
|
|
|
|
failNextCommit(error: Error): void {
|
|
this.#commitFailure = error;
|
|
}
|
|
|
|
/** Reopen after `close()`, as a fresh process would reopen the same database, keeping every committed record. */
|
|
reopen(): this {
|
|
(this as unknown as { closed: boolean }).closed = false;
|
|
return this;
|
|
}
|
|
|
|
#release(kind: "commit" | "find", held: { gate: Deferred }): void {
|
|
if (kind === "commit" && this.#commitGate === held) this.#commitGate = undefined;
|
|
if (kind === "find" && this.#findGate === held) this.#findGate = undefined;
|
|
held.gate.resolve();
|
|
}
|
|
|
|
override async commit(writes: readonly StorageWrite[], commitContext: Context): Promise<Seq> {
|
|
this.admittedCommits.push(writes);
|
|
this.commits.push(structuredClone(writes));
|
|
const held = this.#commitGate;
|
|
if (held !== undefined) {
|
|
held.entered.resolve();
|
|
await held.gate.promise;
|
|
}
|
|
const failure = this.#commitFailure;
|
|
if (failure !== undefined) {
|
|
this.#commitFailure = undefined;
|
|
throw failure;
|
|
}
|
|
return super.commit(writes, commitContext);
|
|
}
|
|
|
|
override mintId<I extends Id<string>>(): Promise<I> {
|
|
this.mintCount++;
|
|
return super.mintId<I>();
|
|
}
|
|
|
|
override document(id: DocumentId, at: DocumentPoint, callContext: Context) {
|
|
this.documentReadCount++;
|
|
return super.document(id, at, callContext);
|
|
}
|
|
|
|
override async findDocument(
|
|
address: DocumentAddress,
|
|
at: DocumentPoint,
|
|
callContext: Context,
|
|
): Promise<DocumentRecord | undefined> {
|
|
const held = this.#findGate;
|
|
if (held !== undefined) {
|
|
held.entered.resolve();
|
|
await held.gate.promise;
|
|
}
|
|
return super.findDocument(address, at, callContext);
|
|
}
|
|
}
|
|
|
|
/** Session kernel plus its controlled storage and every committed publication. */
|
|
export function openTestSession(): {
|
|
readonly storage: ControlledStorage;
|
|
readonly session: SessionImpl;
|
|
readonly publications: CommitPublication[];
|
|
} {
|
|
const storage = new ControlledStorage();
|
|
// These tests pin the cache's identities and loads, so documents stay as long as the default keeps them.
|
|
const session = new SessionImpl(storage, Date.now, () => 600_000);
|
|
const publications: CommitPublication[] = [];
|
|
session.subscribeCommits((publication) => {
|
|
publications.push(publication);
|
|
});
|
|
return { storage, session, publications };
|
|
}
|
|
|
|
export function documentChanges(
|
|
publication: CommitPublication,
|
|
): readonly Extract<DocumentCommitChange, { readonly type: "document" }>[] {
|
|
return publication.changes.filter(
|
|
(change): change is Extract<DocumentCommitChange, { readonly type: "document" }> => change.type === "document",
|
|
);
|
|
}
|
|
|
|
export function documentCopyChanges(
|
|
publication: CommitPublication,
|
|
): readonly Extract<DocumentCommitChange, { readonly type: "document.copy" }>[] {
|
|
return publication.changes.filter(
|
|
(change): change is Extract<DocumentCommitChange, { readonly type: "document.copy" }> =>
|
|
change.type === "document.copy",
|
|
);
|
|
}
|
|
|
|
/** Create one conversation and return its ID. */
|
|
export async function createConversation(session: SessionImpl): Promise<ConversationId> {
|
|
return session.commit(async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, context);
|
|
}
|
|
|
|
/** Resolve after pending microtasks and one macrotask turn. */
|
|
export function flush(): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|