@genkit-ai/google-cloud
Version:
Genkit AI framework plugin for Google Cloud Platform including Firestore trace/state store and deployment helpers for Cloud Functions for Firebase.
458 lines • 17.1 kB
JavaScript
import {
Firestore
} from "@google-cloud/firestore";
import {
GenkitError,
applyPatch,
diff
} from "genkit/beta";
import { logger } from "genkit/logging";
const DEFAULT_CHECKPOINT_INTERVAL = 25;
const DEFAULT_SHARD_SIZE = 512 * 1024;
const DEFAULT_PREFIX = "global";
function sanitize(value) {
return JSON.parse(JSON.stringify(value ?? null));
}
class FirestoreSessionStore {
db;
collection;
checkpointInterval;
shardSize;
snapshotPathPrefix;
constructor(opts) {
this.db = opts?.db ?? new Firestore();
this.collection = opts?.collection ?? "genkit-sessions";
this.checkpointInterval = opts?.checkpointInterval ?? DEFAULT_CHECKPOINT_INTERVAL;
this.shardSize = opts?.shardSize ?? DEFAULT_SHARD_SIZE;
this.snapshotPathPrefix = opts?.snapshotPathPrefix;
}
/** Resolves the (per-tenant) prefix for the given call options. */
prefixFor(options) {
return this.snapshotPathPrefix ? this.snapshotPathPrefix(options) : DEFAULT_PREFIX;
}
/** The (per-tenant) snapshots subcollection. */
snapshotsCol(options) {
return this.db.collection(this.collection).doc(this.prefixFor(options)).collection("snapshots");
}
/** The (per-tenant) pointers subcollection. */
pointersCol(options) {
return this.db.collection(`${this.collection}-pointers`).doc(this.prefixFor(options)).collection("pointers");
}
/** The (per-tenant) shards subcollection. */
shardsCol(options) {
return this.db.collection(`${this.collection}-shards`).doc(this.prefixFor(options)).collection("shards");
}
async getSnapshot(opts) {
const { snapshotId, sessionId } = this.normalize(opts);
const options = { context: opts.context };
return this.db.runTransaction(
async (tx) => {
const reader = this.reader(tx);
if (sessionId) {
const pointerSnap = await tx.get(
this.pointersCol(options).doc(sessionId)
);
if (!pointerSnap.exists) return void 0;
const pointer = pointerSnap.data();
const reconstructed2 = await this.reconstructFrom(
reader,
pointer.checkpointId,
pointer.checkpointShardCount,
pointer.segmentPath,
pointer.currentSnapshotId,
options
);
if (!reconstructed2) return void 0;
return this.toSnapshot(reconstructed2.doc, reconstructed2.state);
}
const reconstructed = await this.reconstruct(
reader,
snapshotId,
options
);
if (!reconstructed) return void 0;
return this.toSnapshot(reconstructed.doc, reconstructed.state);
},
{ readOnly: true }
);
}
async saveSnapshot(snapshotId, mutator, options) {
return this.db.runTransaction(async (tx) => {
const reader = this.reader(tx);
let existing;
if (snapshotId) {
existing = await this.reconstruct(reader, snapshotId, options);
}
const current = existing ? this.toSnapshot(existing.doc, existing.state) : void 0;
const result = mutator(current);
if (result === null) return null;
const id = snapshotId || result.snapshotId || globalThis.crypto.randomUUID();
const sessionId = result.sessionId ?? result.state?.sessionId;
if (!sessionId) {
throw new GenkitError({
status: "INVALID_ARGUMENT",
message: `FirestoreSessionStore requires 'sessionId' to be set on the snapshot.`
});
}
const newState = result.state ?? {};
const pointerRef = this.pointersCol(options).doc(sessionId);
const pointerSnap = await tx.get(pointerRef);
const pointer = pointerSnap.exists ? pointerSnap.data() : void 0;
let kind;
let checkpointId;
let checkpointShardCount;
let segmentPath;
let statePatch;
if (existing) {
if (existing.doc.kind === "checkpoint") {
({
kind,
checkpointId,
checkpointShardCount,
segmentPath,
statePatch
} = this.writeCheckpoint(
tx,
id,
newState,
options,
existing.doc.checkpointShardCount
));
} else {
const parentState = existing.doc.parentId ? (await this.reconstruct(reader, existing.doc.parentId, options))?.state : void 0;
const candidatePatch = diff(parentState, newState);
if (this.byteLength(candidatePatch) > this.shardSize) {
({
kind,
checkpointId,
checkpointShardCount,
segmentPath,
statePatch
} = this.writeCheckpoint(tx, id, newState, options));
} else {
kind = "diff";
checkpointId = existing.doc.checkpointId;
checkpointShardCount = existing.doc.checkpointShardCount;
segmentPath = existing.doc.segmentPath;
statePatch = candidatePatch;
}
}
} else {
let parentMeta;
if (result.parentId) {
parentMeta = await this.loadParentChainMeta(
reader,
result.parentId,
pointer,
options
);
}
if (!result.parentId || !parentMeta || parentMeta.segmentPath.length + 1 >= this.checkpointInterval) {
({
kind,
checkpointId,
checkpointShardCount,
segmentPath,
statePatch
} = this.writeCheckpoint(tx, id, newState, options));
} else {
const parentState = (await this.reconstructFrom(
reader,
parentMeta.checkpointId,
parentMeta.checkpointShardCount,
parentMeta.segmentPath,
result.parentId,
options
))?.state;
const candidatePatch = diff(parentState, newState);
if (this.byteLength(candidatePatch) > this.shardSize) {
({
kind,
checkpointId,
checkpointShardCount,
segmentPath,
statePatch
} = this.writeCheckpoint(tx, id, newState, options));
} else {
kind = "diff";
checkpointId = parentMeta.checkpointId;
checkpointShardCount = parentMeta.checkpointShardCount;
segmentPath = [...parentMeta.segmentPath, id];
statePatch = candidatePatch;
}
}
}
const doc = {
snapshotId: id,
sessionId,
parentId: result.parentId,
createdAt: result.createdAt,
updatedAt: result.updatedAt ?? result.createdAt,
status: result.status,
heartbeatAt: result.heartbeatAt,
finishReason: result.finishReason,
error: result.error,
kind,
checkpointId,
checkpointShardCount,
segmentPath,
statePatch
};
tx.set(this.snapshotsCol(options).doc(id), sanitize(doc));
const isNew = !existing;
if (isNew || !pointer || pointer.currentSnapshotId === id) {
tx.set(
pointerRef,
sanitize({
currentSnapshotId: isNew || !pointer ? id : pointer.currentSnapshotId,
checkpointId,
checkpointShardCount,
segmentPath,
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
})
);
}
return id;
});
}
onSnapshotStateChange(snapshotId, callback, options) {
const ref = this.snapshotsCol(options).doc(snapshotId);
return ref.onSnapshot(async (docSnap) => {
if (!docSnap.exists) return;
try {
const snapshot = await this.getSnapshot({
snapshotId,
context: options?.context
});
if (snapshot) callback(snapshot);
} catch (err) {
logger.error(
`FirestoreSessionStore.watch failed to load snapshot ${snapshotId}`,
err
);
}
});
}
/**
* Validates that exactly one of `snapshotId` / `sessionId` is provided, and
* that the provided one is a non-blank string. Blank / whitespace-only ids
* are rejected up front (rather than silently treated as "absent") so callers
* get a clear `INVALID_ARGUMENT` instead of an unusable document key.
*/
normalize(opts) {
const snapshotId = opts.snapshotId?.trim() ? opts.snapshotId : void 0;
const sessionId = opts.sessionId?.trim() ? opts.sessionId : void 0;
if (!!snapshotId === !!sessionId) {
throw new GenkitError({
status: "INVALID_ARGUMENT",
message: `getSnapshot requires exactly one non-empty 'snapshotId' or 'sessionId' (got ${snapshotId ? "snapshotId" : opts.snapshotId !== void 0 ? "blank snapshotId" : "neither"}${sessionId ? " and sessionId" : opts.sessionId !== void 0 ? " and blank sessionId" : ""}).`
});
}
return { snapshotId, sessionId };
}
/** Builds a {@link Reader} bound to a transaction or the bare instance. */
reader(tx) {
if (tx) {
return {
get: (ref) => tx.get(ref),
getAll: (refs) => refs.length ? tx.getAll(...refs) : Promise.resolve([])
};
}
return {
get: (ref) => ref.get(),
getAll: (refs) => refs.length ? this.db.getAll(...refs) : Promise.resolve([])
};
}
/**
* Resolves a parent's chain metadata (nearest checkpoint, shard count and
* segment path) *without* materializing its - potentially large - state.
*
* In the common linear case the parent is the session's current leaf, so the
* metadata is read straight off the pointer and this performs *zero*
* document reads. Otherwise it reads the single parent document. Crucially,
* resolving only the metadata lets `saveSnapshot` decide diff-vs-checkpoint
* (which only needs `segmentPath.length`) before paying for a full state
* reconstruction - so checkpoint-boundary turns, which would rewrite the
* whole state anyway, skip reconstruction entirely.
*/
async loadParentChainMeta(reader, parentId, pointer, options) {
if (pointer && pointer.currentSnapshotId === parentId) {
return {
checkpointId: pointer.checkpointId,
checkpointShardCount: pointer.checkpointShardCount,
segmentPath: pointer.segmentPath
};
}
const snap = await reader.get(this.snapshotsCol(options).doc(parentId));
if (!snap.exists) return void 0;
const d = snap.data();
return {
checkpointId: d.checkpointId,
checkpointShardCount: d.checkpointShardCount,
segmentPath: d.segmentPath
};
}
/**
* Reconstructs the state of `id` by reading its document to learn its
* checkpoint and segment path, then materializing from that checkpoint.
* Returns `undefined` when the snapshot does not exist.
*/
async reconstruct(reader, id, options) {
const snap = await reader.get(this.snapshotsCol(options).doc(id));
if (!snap.exists) return void 0;
const d = snap.data();
return this.reconstructFrom(
reader,
d.checkpointId,
d.checkpointShardCount,
d.segmentPath,
id,
options
);
}
/**
* Materializes the state of `targetId` from a known checkpoint using a single
* batched, strongly-consistent `getAll`: the checkpoint's shards, the
* (bounded) segment of diff documents along `segmentPath`, and - only when
* the target *is* the checkpoint - the checkpoint document itself. The diffs
* are then applied in order onto the checkpoint's state. Cost is bounded by
* `checkpointInterval` + shard count, independent of total session length.
*
* Note: when `segmentPath` is non-empty the state comes entirely from the
* shards and the target's metadata from the last segment document, so the
* checkpoint *document* is not read - saving one read on the common path.
*/
async reconstructFrom(reader, checkpointId, shardCount, segmentPath, targetId, options) {
const targetIsCheckpoint = segmentPath.length === 0;
const snapshotsCol = this.snapshotsCol(options);
const shardsCol = this.shardsCol(options);
const checkpointRef = snapshotsCol.doc(checkpointId);
const shardRefs = Array.from(
{ length: shardCount },
(_, i) => shardsCol.doc(`${checkpointId}_${i}`)
);
const segRefs = segmentPath.map((sid) => snapshotsCol.doc(sid));
const snaps = await reader.getAll([
// The checkpoint document is only needed when it is itself the target;
// otherwise the last segment document carries the target metadata.
...targetIsCheckpoint ? [checkpointRef] : [],
...shardRefs,
...segRefs
]);
const byPath = /* @__PURE__ */ new Map();
for (const s of snaps) byPath.set(s.ref.path, s);
const shardSnaps = shardRefs.map((ref) => byPath.get(ref.path));
let state = this.stitch(shardSnaps);
if (targetIsCheckpoint) {
const checkpointSnap = byPath.get(checkpointRef.path);
if (!checkpointSnap?.exists) return void 0;
const checkpointDoc = checkpointSnap.data();
if (checkpointDoc.snapshotId !== targetId) return void 0;
return { doc: checkpointDoc, state: state ?? {} };
}
let targetDoc;
for (const ref of segRefs) {
const segSnap = byPath.get(ref.path);
if (!segSnap?.exists) return void 0;
const segDoc = segSnap.data();
state = applyPatch(state, segDoc.statePatch ?? []);
targetDoc = segDoc;
}
if (!targetDoc || targetDoc.snapshotId !== targetId) return void 0;
return { doc: targetDoc, state: state ?? {} };
}
/**
* Serializes `state` to UTF-8, splits it into `shardSize`-byte chunks, and
* writes them at `<checkpointId>_<index>`. When `oldShardCount` exceeds the
* new count (a shrinking re-checkpoint), the now-stale trailing shards are
* deleted. Returns the number of shards written.
*/
writeShards(tx, checkpointId, state, options, oldShardCount = 0) {
const shardsCol = this.shardsCol(options);
const buf = Buffer.from(JSON.stringify(state ?? null), "utf8");
const count = Math.max(1, Math.ceil(buf.length / this.shardSize));
for (let i = 0; i < count; i++) {
const chunk = Buffer.from(
buf.subarray(i * this.shardSize, (i + 1) * this.shardSize)
);
tx.set(shardsCol.doc(`${checkpointId}_${i}`), {
chunk
});
}
for (let i = count; i < oldShardCount; i++) {
tx.delete(shardsCol.doc(`${checkpointId}_${i}`));
}
return count;
}
/**
* Writes a full-state checkpoint at `id` (sharding the state via
* {@link writeShards}) and returns the snapshot metadata describing it: a
* checkpoint anchors itself (`checkpointId === id`), has an empty
* `segmentPath`, and carries no `statePatch`.
*
* This is the shared promotion path used whenever a snapshot must be a
* checkpoint rather than a diff - the session root, an orphaned parent, a
* checkpoint-interval boundary, an in-place checkpoint rewrite, and the
* promotion of an oversized diff (whether new turn or leaf upsert) so that no
* single document approaches Firestore's 1 MiB limit. Pass `oldShardCount`
* when re-checkpointing an existing checkpoint so stale trailing shards are
* pruned.
*/
writeCheckpoint(tx, id, state, options, oldShardCount = 0) {
return {
kind: "checkpoint",
checkpointId: id,
checkpointShardCount: this.writeShards(
tx,
id,
state,
options,
oldShardCount
),
segmentPath: [],
statePatch: void 0
};
}
/** Concatenates ordered shard documents and parses the materialized state. */
stitch(shardSnaps) {
if (shardSnaps.length === 0) return void 0;
const buffers = [];
for (const s of shardSnaps) {
if (!s.exists) {
throw new GenkitError({
status: "DATA_LOSS",
message: `FirestoreSessionStore: missing checkpoint shard '${s.id}'.`
});
}
buffers.push(s.data().chunk);
}
return JSON.parse(Buffer.concat(buffers).toString("utf8"));
}
/** UTF-8 byte length of a JSON-serializable value. */
byteLength(value) {
return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
}
/** Assembles a {@link SessionSnapshot} from a document and its state. */
toSnapshot(doc, state) {
const snapshot = {
snapshotId: doc.snapshotId,
sessionId: doc.sessionId,
createdAt: doc.createdAt,
// Normalize to plain objects: values reconstructed from Firestore
// documents (e.g. patch operands) can carry non-plain prototypes.
state: sanitize(state)
};
if (doc.parentId !== void 0) snapshot.parentId = doc.parentId;
if (doc.updatedAt !== void 0) snapshot.updatedAt = doc.updatedAt;
if (doc.status !== void 0) snapshot.status = doc.status;
if (doc.heartbeatAt !== void 0) snapshot.heartbeatAt = doc.heartbeatAt;
if (doc.finishReason !== void 0)
snapshot.finishReason = doc.finishReason;
if (doc.error !== void 0) snapshot.error = doc.error;
return snapshot;
}
}
export {
FirestoreSessionStore
};
//# sourceMappingURL=firestore.mjs.map