503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-20-p0-process-session-index.
|
|
// Responsibility: accelerate retained hwlab.event.v1 session queries without creating another business authority.
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
export function createWorkbenchKafkaSessionIndex(options = {}) {
|
|
const topic = text(options.topic);
|
|
const maxEvents = positiveInteger(options.maxEvents);
|
|
const maxEventsPerSession = positiveInteger(options.maxEventsPerSession);
|
|
const bootstrapTimeoutMs = positiveInteger(options.bootstrapTimeoutMs);
|
|
const rebuildIntervalMs = positiveInteger(options.rebuildIntervalMs);
|
|
const rebuildCooldownMs = positiveInteger(options.rebuildCooldownMs);
|
|
const scanLimit = positiveInteger(options.scanLimit);
|
|
const logger = options.logger ?? console;
|
|
const nowMs = typeof options.nowMs === "function" ? options.nowMs : Date.now;
|
|
if (!topic || !maxEvents || !maxEventsPerSession || !bootstrapTimeoutMs || !rebuildIntervalMs || !rebuildCooldownMs || !scanLimit || typeof options.queryAll !== "function") {
|
|
throw indexError("workbench_kafka_session_index_config_invalid", "Workbench Kafka session index requires complete bounded configuration.");
|
|
}
|
|
|
|
let ready = false;
|
|
let stopped = false;
|
|
let rebuilding = false;
|
|
let rebuildTimer = null;
|
|
let lastRebuildStartedAtMs = null;
|
|
let lastRebuildCompletedAtMs = null;
|
|
let lastFailure = null;
|
|
let bootstrapPromise = null;
|
|
let pendingLiveOverflow = false;
|
|
let state = emptyState();
|
|
const pendingLive = [];
|
|
|
|
function start() {
|
|
if (bootstrapPromise) return bootstrapPromise;
|
|
bootstrapPromise = rebuild("bootstrap");
|
|
rebuildTimer = setInterval(() => {
|
|
void rebuild("interval");
|
|
}, rebuildIntervalMs);
|
|
rebuildTimer.unref?.();
|
|
return bootstrapPromise;
|
|
}
|
|
|
|
function stop() {
|
|
stopped = true;
|
|
if (rebuildTimer) clearInterval(rebuildTimer);
|
|
rebuildTimer = null;
|
|
pendingLive.length = 0;
|
|
}
|
|
|
|
function observeLive(envelope, transport) {
|
|
if (stopped || envelope?.schema !== "hwlab.event.v1") return;
|
|
const record = normalizeRecord({ topic, partition: transport?.partition, offset: transport?.offset, key: sessionId(envelope), timestamp: null, value: envelope });
|
|
if (!record) return;
|
|
if (rebuilding) {
|
|
if (pendingLive.length >= maxEvents) {
|
|
pendingLiveOverflow = true;
|
|
lastFailure = warning("workbench_kafka_session_index_live_buffer_overflow", "Session index rebuild live buffer exceeded its YAML-owned bound.");
|
|
} else {
|
|
pendingLive.push(record);
|
|
}
|
|
}
|
|
if (ready) addRecord(state, record);
|
|
}
|
|
|
|
async function rebuild(reason) {
|
|
if (stopped || rebuilding) return false;
|
|
const startedAtMs = nowMs();
|
|
if (lastRebuildStartedAtMs !== null && reason !== "bootstrap" && startedAtMs - lastRebuildStartedAtMs < rebuildCooldownMs) return false;
|
|
rebuilding = true;
|
|
lastRebuildStartedAtMs = startedAtMs;
|
|
pendingLive.length = 0;
|
|
pendingLiveOverflow = false;
|
|
try {
|
|
const result = await options.queryAll({
|
|
limit: maxEvents,
|
|
scanLimit,
|
|
timeoutMs: bootstrapTimeoutMs
|
|
});
|
|
if (!completeQuery(result)) {
|
|
lastFailure = warning("workbench_kafka_session_index_bootstrap_incomplete", `Session index ${reason} did not reach the Kafka retention barrier.`);
|
|
logWarning(lastFailure, { reason, completionReason: result?.completionReason ?? null });
|
|
return false;
|
|
}
|
|
const candidate = emptyState();
|
|
candidate.endOffsets = normalizeEndOffsets(result.endOffsets);
|
|
for (const raw of Array.isArray(result.events) ? result.events : []) {
|
|
const record = normalizeRecord(raw);
|
|
if (!record) continue;
|
|
addRecord(candidate, record);
|
|
}
|
|
if (pendingLiveOverflow) {
|
|
throw indexError("workbench_kafka_session_index_live_buffer_overflow", "Session index rebuild cannot prove completeness after its live buffer overflowed.");
|
|
}
|
|
for (const record of pendingLive.splice(0)) addRecord(candidate, record);
|
|
state = candidate;
|
|
ready = true;
|
|
lastFailure = null;
|
|
lastRebuildCompletedAtMs = nowMs();
|
|
logger.log?.(JSON.stringify({
|
|
code: "workbench-kafka-session-index-ready",
|
|
reason,
|
|
indexedEventCount: state.records.length,
|
|
indexedSessionCount: state.bySession.size,
|
|
incompleteSessionCount: state.incompleteSessions.size,
|
|
durationMs: lastRebuildCompletedAtMs - startedAtMs,
|
|
valuesPrinted: false
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
lastFailure = warning(error?.code ?? "workbench_kafka_session_index_bootstrap_failed", errorMessage(error));
|
|
logWarning(lastFailure, { reason });
|
|
return false;
|
|
} finally {
|
|
rebuilding = false;
|
|
}
|
|
}
|
|
|
|
function query(params = {}) {
|
|
const startedAtMs = nowMs();
|
|
const requestedSessionId = text(params.sessionId);
|
|
const requestedTraceId = text(params.traceId);
|
|
const fallbackReason = !ready
|
|
? "index-not-ready"
|
|
: requestedSessionId && state.incompleteSessions.has(requestedSessionId)
|
|
? "session-capacity-exceeded"
|
|
: !requestedSessionId && !state.globalComplete
|
|
? "global-capacity-exceeded"
|
|
: null;
|
|
if (fallbackReason) return {
|
|
hit: false,
|
|
fallbackReason,
|
|
status: status(),
|
|
warning: warning("workbench_kafka_session_index_fallback", `Session index cannot prove a complete result (${fallbackReason}).`)
|
|
};
|
|
const source = requestedSessionId ? state.bySession.get(requestedSessionId) ?? [] : state.records;
|
|
const events = source.filter((record) => matchesTrace(record.value, requestedTraceId));
|
|
const limit = positiveInteger(params.limit);
|
|
if (limit && events.length > limit) {
|
|
return {
|
|
hit: false,
|
|
fallbackReason: "matched-event-limit",
|
|
status: status(),
|
|
warning: warning("workbench_kafka_session_index_fallback", "Session index result exceeds the request matched-event limit.")
|
|
};
|
|
}
|
|
const elapsedMs = Math.max(0, nowMs() - startedAtMs);
|
|
const firstScannedOffsets = partitionOffsets(events, "first");
|
|
const lastScannedOffsets = partitionOffsets(events, "last");
|
|
return {
|
|
hit: true,
|
|
result: {
|
|
ok: true,
|
|
stream: "hwlab",
|
|
topic,
|
|
groupId: null,
|
|
count: events.length,
|
|
scannedCount: events.length,
|
|
parsedCount: events.length,
|
|
matchedCount: events.length,
|
|
invalidJsonCount: 0,
|
|
filterRejectedCount: 0,
|
|
keyRejectedCount: 0,
|
|
excludedPostBarrierCount: 0,
|
|
completionReason: "session-index",
|
|
completion: {
|
|
reason: "session-index",
|
|
complete: true,
|
|
barrierReached: true,
|
|
retentionStartVerified: true,
|
|
retentionStartMoved: false,
|
|
timeout: false,
|
|
matchedEventLimitReached: false,
|
|
scanLimitReached: false,
|
|
aborted: false,
|
|
valuesRedacted: true
|
|
},
|
|
reachedEndOffsets: true,
|
|
endOffsets: [...state.endOffsets.values()].sort((left, right) => left.partition - right.partition),
|
|
endOffsetsAvailable: state.endOffsets.size > 0,
|
|
endOffsetsError: null,
|
|
firstScannedOffsets,
|
|
lastScannedOffsets,
|
|
limit: limit ?? null,
|
|
scanLimit: positiveInteger(params.scanLimit),
|
|
timeoutMs: positiveInteger(params.timeoutMs),
|
|
filters: compact({ sessionId: requestedSessionId, traceId: requestedTraceId }),
|
|
partitionKeyScoped: Boolean(text(params.partitionKey)),
|
|
targetPartition: events.length > 0 ? events[0].partition : null,
|
|
timing: {
|
|
endOffsetSnapshotMs: 0,
|
|
groupOffsetsMs: 0,
|
|
consumerConnectMs: 0,
|
|
consumerSubscribeMs: 0,
|
|
scanMs: 0,
|
|
cleanupMs: 0,
|
|
indexLookupMs: elapsedMs,
|
|
totalMs: elapsedMs,
|
|
valuesPrinted: false
|
|
},
|
|
index: {
|
|
hit: true,
|
|
source: "process-session-index",
|
|
indexedEventCount: state.records.length,
|
|
indexedSessionCount: state.bySession.size,
|
|
incompleteSessionCount: state.incompleteSessions.size,
|
|
valuesRedacted: true
|
|
},
|
|
events,
|
|
valuesPrinted: false
|
|
}
|
|
};
|
|
}
|
|
|
|
function status() {
|
|
return {
|
|
enabled: true,
|
|
ready,
|
|
rebuilding,
|
|
indexedEventCount: state.records.length,
|
|
indexedSessionCount: state.bySession.size,
|
|
incompleteSessionCount: state.incompleteSessions.size,
|
|
globalComplete: state.globalComplete,
|
|
lastRebuildStartedAt: iso(lastRebuildStartedAtMs),
|
|
lastRebuildCompletedAt: iso(lastRebuildCompletedAtMs),
|
|
warning: lastFailure,
|
|
blocking: false,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function sessionSummaries() {
|
|
if (!ready || !state.globalComplete) {
|
|
const reason = !ready ? "index-not-ready" : "global-capacity-exceeded";
|
|
return {
|
|
ok: false,
|
|
sessions: [],
|
|
warning: warning("workbench_kafka_session_summary_unavailable", `Session summary index is unavailable (${reason}).`),
|
|
status: status(),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
const sessions = [];
|
|
for (const [id, records] of state.bySession.entries()) {
|
|
if (state.incompleteSessions.has(id)) continue;
|
|
const userEvents = records.map((record) => record.value?.event).filter((event) => firstText(event?.type, event?.eventType) === "user");
|
|
const firstPreview = firstText(userEvents[0]?.text, userEvents[0]?.message);
|
|
if (!firstPreview) continue;
|
|
const lastUserEvent = userEvents.at(-1);
|
|
sessions.push({
|
|
sessionId: id,
|
|
firstUserMessagePreview: firstPreview.slice(0, 240),
|
|
lastUserMessageAt: firstText(lastUserEvent?.createdAt, lastUserEvent?.submittedAt),
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
return { ok: true, sessions, warning: null, status: status(), valuesRedacted: true };
|
|
}
|
|
|
|
function emptyState() {
|
|
return {
|
|
records: [],
|
|
byTransport: new Map(),
|
|
bySession: new Map(),
|
|
incompleteSessions: new Set(),
|
|
endOffsets: new Map(),
|
|
globalComplete: true
|
|
};
|
|
}
|
|
|
|
function addRecord(target, record) {
|
|
const transportKey = `${record.topic}:${record.partition}:${record.offset}`;
|
|
if (target.byTransport.has(transportKey)) return;
|
|
target.byTransport.set(transportKey, record);
|
|
target.records.push(record);
|
|
updateEndOffset(target.endOffsets, record.partition, record.offset);
|
|
const id = indexedSessionId(record);
|
|
if (id) {
|
|
const bucket = target.bySession.get(id) ?? [];
|
|
bucket.push(record);
|
|
target.bySession.set(id, bucket);
|
|
if (bucket.length > maxEventsPerSession) {
|
|
const removed = bucket.shift();
|
|
if (removed) removeRecord(target, removed);
|
|
target.incompleteSessions.add(id);
|
|
}
|
|
}
|
|
if (target.records.length > maxEvents) {
|
|
const removed = target.records[0];
|
|
removeRecord(target, removed);
|
|
const removedSessionId = indexedSessionId(removed);
|
|
if (removedSessionId) target.incompleteSessions.add(removedSessionId);
|
|
target.globalComplete = false;
|
|
}
|
|
}
|
|
|
|
function removeRecord(target, record) {
|
|
if (!record) return;
|
|
const transportKey = `${record.topic}:${record.partition}:${record.offset}`;
|
|
target.byTransport.delete(transportKey);
|
|
const recordIndex = target.records.indexOf(record);
|
|
if (recordIndex >= 0) target.records.splice(recordIndex, 1);
|
|
const id = indexedSessionId(record);
|
|
const bucket = id ? target.bySession.get(id) : null;
|
|
if (bucket) {
|
|
const bucketIndex = bucket.indexOf(record);
|
|
if (bucketIndex >= 0) bucket.splice(bucketIndex, 1);
|
|
if (bucket.length === 0) target.bySession.delete(id);
|
|
}
|
|
}
|
|
|
|
function logWarning(entry, fields = {}) {
|
|
logger.warn?.(JSON.stringify({
|
|
code: entry.code,
|
|
component: "workbench-kafka-session-index",
|
|
message: entry.message,
|
|
blocking: false,
|
|
...fields,
|
|
valuesPrinted: false
|
|
}));
|
|
}
|
|
|
|
return {
|
|
start,
|
|
stop,
|
|
observeLive,
|
|
query,
|
|
sessionSummaries,
|
|
status,
|
|
rebuild,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
function normalizeRecord(raw) {
|
|
const recordTopic = text(raw?.topic);
|
|
const partition = integer(raw?.partition);
|
|
const offset = offsetValue(raw?.offset);
|
|
const value = raw?.value;
|
|
if (!recordTopic || partition === null || offset === null || value?.schema !== "hwlab.event.v1") return null;
|
|
return {
|
|
topic: recordTopic,
|
|
partition,
|
|
offset,
|
|
key: text(raw?.key),
|
|
timestamp: text(raw?.timestamp),
|
|
valueSha256: text(raw?.valueSha256) ?? sha256(JSON.stringify(value)),
|
|
value
|
|
};
|
|
}
|
|
|
|
function completeQuery(result) {
|
|
return result?.completion?.complete === true
|
|
&& result?.completion?.barrierReached === true
|
|
&& result?.completion?.retentionStartVerified === true
|
|
&& result?.reachedEndOffsets === true
|
|
&& result?.endOffsetsAvailable === true;
|
|
}
|
|
|
|
function normalizeEndOffsets(value) {
|
|
const result = new Map();
|
|
for (const entry of Array.isArray(value) ? value : []) {
|
|
const partition = integer(entry?.partition);
|
|
const endOffset = offsetValue(entry?.endOffset);
|
|
if (partition === null || endOffset === null) continue;
|
|
result.set(partition, {
|
|
partition,
|
|
startOffset: offsetValue(entry?.startOffset),
|
|
endOffset
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function updateEndOffset(endOffsets, partition, offset) {
|
|
const next = nextOffset(offset);
|
|
if (next === null) return;
|
|
const existing = endOffsets.get(partition);
|
|
if (!existing || compareOffset(next, existing.endOffset) > 0) {
|
|
endOffsets.set(partition, {
|
|
partition,
|
|
startOffset: existing?.startOffset ?? null,
|
|
endOffset: next
|
|
});
|
|
}
|
|
}
|
|
|
|
function sessionId(value) {
|
|
return firstText(
|
|
value?.sessionId,
|
|
value?.hwlabSessionId,
|
|
value?.event?.sessionId,
|
|
value?.event?.hwlabSessionId,
|
|
value?.context?.sessionId,
|
|
value?.context?.hwlabSessionId
|
|
);
|
|
}
|
|
|
|
function indexedSessionId(record) {
|
|
const envelopeSessionId = sessionId(record?.value);
|
|
const key = text(record?.key);
|
|
if (!envelopeSessionId) return null;
|
|
if (key && key !== envelopeSessionId) return null;
|
|
return envelopeSessionId;
|
|
}
|
|
|
|
function matchesTrace(value, traceId) {
|
|
if (!traceId) return true;
|
|
return [
|
|
value?.traceId,
|
|
value?.event?.traceId,
|
|
value?.context?.traceId,
|
|
value?.sourceEvent?.traceId
|
|
].some((candidate) => text(candidate) === traceId);
|
|
}
|
|
|
|
function partitionOffsets(records, mode) {
|
|
const byPartition = new Map();
|
|
for (const record of records) {
|
|
const existing = byPartition.get(record.partition);
|
|
if (existing === undefined || (mode === "first" ? compareOffset(record.offset, existing) < 0 : compareOffset(record.offset, existing) > 0)) {
|
|
byPartition.set(record.partition, record.offset);
|
|
}
|
|
}
|
|
return [...byPartition.entries()]
|
|
.map(([partition, offset]) => ({ partition, offset }))
|
|
.sort((left, right) => left.partition - right.partition);
|
|
}
|
|
|
|
function warning(code, message) {
|
|
return { code, message, blocking: false, valuesRedacted: true };
|
|
}
|
|
|
|
function indexError(code, message) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
function errorMessage(error) {
|
|
return error instanceof Error ? error.message : String(error ?? "unknown error");
|
|
}
|
|
|
|
function compact(value) {
|
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== ""));
|
|
}
|
|
|
|
function firstText(...values) {
|
|
for (const value of values) {
|
|
const result = text(value);
|
|
if (result) return result;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function text(value) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function integer(value) {
|
|
const result = Number(value);
|
|
return Number.isInteger(result) && result >= 0 ? result : null;
|
|
}
|
|
|
|
function positiveInteger(value) {
|
|
const result = Number(value);
|
|
return Number.isInteger(result) && result > 0 ? result : null;
|
|
}
|
|
|
|
function offsetValue(value) {
|
|
const result = text(String(value ?? ""));
|
|
if (!result) return null;
|
|
try {
|
|
return BigInt(result) >= 0n ? result : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function nextOffset(value) {
|
|
try {
|
|
return String(BigInt(String(value)) + 1n);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function compareOffset(left, right) {
|
|
try {
|
|
const leftValue = BigInt(String(left));
|
|
const rightValue = BigInt(String(right));
|
|
return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0;
|
|
} catch {
|
|
return String(left).localeCompare(String(right));
|
|
}
|
|
}
|
|
|
|
function iso(value) {
|
|
return Number.isFinite(value) ? new Date(value).toISOString() : null;
|
|
}
|
|
|
|
function sha256(value) {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|