Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

296 lines
9.7 KiB
TypeScript

/**
* Client-only PBL event drainer (#869).
*
* Drains both bounded project outboxes, `runtimeEvents` and
* `engagementEvents`, into the active PBL runtime session. Each ledger has an
* independent device-scoped watermark per `(stageId, sceneId, learnerKey)`.
* If a saved watermark points to an event that has already fallen out of the
* in-project ring buffer, the visible ledger is drained again. This is
* intentionally at-least-once: downstream folds must deduplicate by event id
* instead of assuming RuntimeStore record ids are unique.
*
* The project outboxes are bounded rings (500 events). If more than 500
* runtime or engagement events are appended between drains, the evicted gap is
* unrecoverable from this outbox. That latent-loss window is acceptable during
* dual-write because `projectV2` remains the source of truth; the read flip
* will backfill runtime state from a `projectV2` snapshot.
*
* Server code must not import this module without injecting its own
* `RuntimeStore` and `KVStore`: the defaults lazily touch IndexedDB and
* localStorage through the browser storage backends.
*/
import { BrowserKVStore, type KVStore, type RuntimeStore } from '@openmaic/storage';
import { getLearnerKey } from '@/lib/runtime/learner-key';
import { getRuntimeStore } from '@/lib/runtime/store';
import type { PBLEngagementEvent, PBLProjectV2, PBLRuntimeEvent } from '@/lib/pbl/v2/types';
const PBL_DRAIN_TIMEOUT_MS = 10_000;
const WATERMARK_SCOPE = 'device';
let defaultKv: KVStore | undefined;
const inFlightPblSessions = new Map<string, Promise<string>>();
interface PBLRuntimeDrainWatermark {
lastRuntimeEventId?: string;
lastEngagementEventId?: string;
}
export interface DrainProjectRuntimeArgs {
stageId: string;
sceneId: string;
project: PBLProjectV2;
store?: RuntimeStore;
kv?: KVStore;
learnerKey?: string;
}
function getDefaultKv(): KVStore {
return (defaultKv ??= new BrowserKVStore());
}
function watermarkKey(stageId: string, sceneId: string, learnerKey: string): string {
return `runtime.pblDrain.${stageId}.${sceneId}.${learnerKey}`;
}
function deterministicPBLSessionId(stageId: string, learnerKey: string): string {
return `pbl-${stageId}-${learnerKey}`;
}
/** Reject after `ms`, clearing the timer once the raced promise settles. */
async function withTimeout(work: Promise<void>, ms: number): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
work,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
}),
]);
} finally {
clearTimeout(timer);
}
}
function normalizeWatermark(value: unknown): PBLRuntimeDrainWatermark {
if (!value || typeof value !== 'object') return {};
const maybe = value as {
lastRuntimeEventId?: unknown;
lastEngagementEventId?: unknown;
};
const watermark: PBLRuntimeDrainWatermark = {};
if (typeof maybe.lastRuntimeEventId === 'string') {
watermark.lastRuntimeEventId = maybe.lastRuntimeEventId;
}
if (typeof maybe.lastEngagementEventId === 'string') {
watermark.lastEngagementEventId = maybe.lastEngagementEventId;
}
return watermark;
}
async function readWatermark(kv: KVStore, key: string): Promise<PBLRuntimeDrainWatermark> {
try {
return normalizeWatermark(await kv.get<PBLRuntimeDrainWatermark>(key, WATERMARK_SCOPE));
} catch (error) {
console.warn(
`Ignoring unreadable PBL drain watermark ${key}; redraining visible events:`,
error,
);
return {};
}
}
function undrainedEvents<TEvent extends { id: string }>(
events: readonly TEvent[],
lastEventId: string | undefined,
): TEvent[] {
if (!lastEventId) return [...events];
const drainedIndex = events.findIndex((event) => event.id === lastEventId);
if (drainedIndex < 0) {
// The in-project ledger is a bounded outbox, not the archive. If the
// saved watermark points to an event that has fallen out of the ring buffer
// (or the watermark is otherwise stale), we redrain the visible ledger.
// This is intentionally at-least-once: downstream folds must deduplicate by
// event id instead of treating the RuntimeStore as a unique-id index.
return [...events];
}
return events.slice(drainedIndex + 1);
}
async function ensurePBLSession(
store: RuntimeStore,
stageId: string,
learnerKey: string,
): Promise<string> {
const inFlightKey = `${stageId}:${learnerKey}`;
const inFlight = inFlightPblSessions.get(inFlightKey);
if (inFlight) return inFlight;
const sessionPromise = ensurePBLSessionUnmemoized(store, stageId, learnerKey);
inFlightPblSessions.set(inFlightKey, sessionPromise);
try {
return await sessionPromise;
} finally {
inFlightPblSessions.delete(inFlightKey);
}
}
async function ensurePBLSessionUnmemoized(
store: RuntimeStore,
stageId: string,
learnerKey: string,
): Promise<string> {
const sessions = await store.listSessions(stageId, learnerKey);
const active = sessions.find((session) => session.kind === 'pbl' && session.status === 'active');
if (active) return active.id;
const now = new Date().toISOString();
try {
const created = await store.createSession({
id: deterministicPBLSessionId(stageId, learnerKey),
kind: 'pbl',
stageId,
learnerKey,
status: 'active',
createdAt: now,
updatedAt: now,
});
return created.id;
} catch (error) {
if (!/already exists/i.test(String(error instanceof Error ? error.message : error))) {
throw error;
}
const relisted = await store.listSessions(stageId, learnerKey);
const racedActive = relisted.find(
(session) => session.kind === 'pbl' && session.status === 'active',
);
if (racedActive) return racedActive.id;
throw error;
}
}
function subAnchorFor(event: PBLRuntimeEvent): string | undefined {
return event.microtaskId ?? event.milestoneId;
}
function subAnchorForEngagement(event: PBLEngagementEvent): string | undefined {
return event.microtaskId;
}
type DrainablePBLEvent =
| {
ledger: 'runtime';
event: PBLRuntimeEvent;
index: number;
}
| {
ledger: 'engagement';
event: PBLEngagementEvent;
index: number;
};
function orderedDrainEvents(
runtimeEvents: readonly PBLRuntimeEvent[],
engagementEvents: readonly PBLEngagementEvent[],
): DrainablePBLEvent[] {
return [
...runtimeEvents.map((event, index) => ({ ledger: 'runtime' as const, event, index })),
...engagementEvents.map((event, index) => ({ ledger: 'engagement' as const, event, index })),
].sort((a, b) => {
const byTimestamp = a.event.ts.localeCompare(b.event.ts);
if (byTimestamp !== 0) return byTimestamp;
// Preserve original order inside each ledger; for simultaneous events
// across ledgers, runtime facts precede engagement analytics.
if (a.ledger !== b.ledger) return a.ledger === 'runtime' ? -1 : 1;
return a.index - b.index;
});
}
async function persistWatermark(
kv: KVStore,
key: string,
watermark: PBLRuntimeDrainWatermark,
): Promise<void> {
await kv.set(key, watermark, WATERMARK_SCOPE);
}
export async function clearStageDrainWatermarks(
stageId: string,
kv: KVStore = getDefaultKv(),
): Promise<void> {
const keys = await kv.keys(`runtime.pblDrain.${stageId}.`, WATERMARK_SCOPE);
await Promise.all(keys.map((key) => kv.remove(key, WATERMARK_SCOPE)));
}
async function drainProjectRuntimeWork({
stageId,
sceneId,
project,
store: injectedStore,
kv: injectedKv,
learnerKey: injectedLearnerKey,
}: DrainProjectRuntimeArgs): Promise<void> {
const kv = injectedKv ?? getDefaultKv();
const learnerKey = injectedLearnerKey ?? (await getLearnerKey(kv));
const store = injectedStore ?? getRuntimeStore();
const key = watermarkKey(stageId, sceneId, learnerKey);
const watermark = await readWatermark(kv, key);
const runtimeEvents = undrainedEvents(project.runtimeEvents ?? [], watermark.lastRuntimeEventId);
const engagementEvents = undrainedEvents(
project.engagementEvents ?? [],
watermark.lastEngagementEventId,
);
let nextWatermark: PBLRuntimeDrainWatermark = { ...watermark };
if (runtimeEvents.length === 0 && engagementEvents.length === 0) {
await persistWatermark(kv, key, nextWatermark);
return;
}
const sessionId = await ensurePBLSession(store, stageId, learnerKey);
try {
for (const item of orderedDrainEvents(runtimeEvents, engagementEvents)) {
if (item.ledger === 'runtime') {
await store.appendRecord({
id: item.event.id,
sessionId,
sceneId,
subAnchor: subAnchorFor(item.event),
createdAt: item.event.ts,
payload: item.event,
});
nextWatermark = { ...nextWatermark, lastRuntimeEventId: item.event.id };
} else {
await store.appendRecord({
id: item.event.id,
sessionId,
sceneId,
subAnchor: subAnchorForEngagement(item.event),
createdAt: item.event.ts,
payload: item.event,
});
nextWatermark = { ...nextWatermark, lastEngagementEventId: item.event.id };
}
}
} catch (error) {
await persistWatermark(kv, key, nextWatermark);
throw error;
}
await persistWatermark(kv, key, nextWatermark);
}
export async function drainProjectRuntime(args: DrainProjectRuntimeArgs): Promise<void> {
try {
const work = drainProjectRuntimeWork(args);
// A rejection landing after the timeout already won the race would have no
// listener left. Swallow that branch; the await below still reports it if it
// lands before the timeout.
work.catch(() => {});
await withTimeout(work, PBL_DRAIN_TIMEOUT_MS);
} catch (error) {
console.warn(`Failed to drain PBL events for stage ${args.stageId}:`, error);
}
}