fix: stabilize qa lab capture store cleanup

This commit is contained in:
Peter Steinberger
2026-04-26 09:13:18 +01:00
parent 7e376e5aba
commit 1323683d72
5 changed files with 86 additions and 5 deletions

View File

@@ -113,6 +113,10 @@ const captureMock = vi.hoisted(() => {
});
vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({
acquireDebugProxyCaptureStore: () => ({
store: captureMock.store,
release: captureMock.store.close,
}),
getDebugProxyCaptureStore: () => captureMock.store,
resolveDebugProxySettings: () => ({
dbPath: process.env.OPENCLAW_DEBUG_PROXY_DB_PATH ?? "",

View File

@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage } from "node:http";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
getDebugProxyCaptureStore,
acquireDebugProxyCaptureStore,
resolveDebugProxySettings,
} from "openclaw/plugin-sdk/proxy-capture";
import { closeQaHttpServer, handleQaBusRequest, writeError, writeJson } from "./bus-server.js";
@@ -168,7 +168,11 @@ export async function startQaLabServer(
): Promise<QaLabServerHandle> {
const repoRoot = path.resolve(params?.repoRoot ?? process.cwd());
const captureSettings = resolveDebugProxySettings();
const captureStore = getDebugProxyCaptureStore(captureSettings.dbPath, captureSettings.blobDir);
const captureStoreLease = acquireDebugProxyCaptureStore(
captureSettings.dbPath,
captureSettings.blobDir,
);
const captureStore = captureStoreLease.store;
const state = createQaBusState();
let latestReport: QaLabLatestReport | null = null;
let latestScenarioRun: QaLabScenarioRun | null = null;
@@ -639,7 +643,7 @@ export async function startQaLabServer(
await runnerModelCatalogPromise?.catch(() => undefined);
await gateway?.stop();
await closeQaHttpServer(server);
captureStore.close();
captureStoreLease.release();
},
};
labHandle = lab;

View File

@@ -4,7 +4,9 @@ export {
resolveEffectiveDebugProxyUrl,
} from "../proxy-capture/env.js";
export {
acquireDebugProxyCaptureStore,
DebugProxyCaptureStore,
closeDebugProxyCaptureStore,
getDebugProxyCaptureStore,
} from "../proxy-capture/store.sqlite.js";
export {

View File

@@ -2,11 +2,18 @@ import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DebugProxyCaptureStore, persistEventPayload } from "./store.sqlite.js";
import {
acquireDebugProxyCaptureStore,
closeDebugProxyCaptureStore,
DebugProxyCaptureStore,
getDebugProxyCaptureStore,
persistEventPayload,
} from "./store.sqlite.js";
const cleanupDirs: string[] = [];
afterEach(() => {
closeDebugProxyCaptureStore();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (dir) {
@@ -22,6 +29,35 @@ function makeStore() {
}
describe("DebugProxyCaptureStore", () => {
it("keeps the cached store open until the last lease releases", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-capture-lease-"));
cleanupDirs.push(root);
const dbPath = path.join(root, "capture.sqlite");
const blobDir = path.join(root, "blobs");
const first = acquireDebugProxyCaptureStore(dbPath, blobDir);
const second = acquireDebugProxyCaptureStore(dbPath, blobDir);
expect(second.store).toBe(first.store);
first.release();
expect(first.store.isClosed).toBe(false);
second.release();
expect(first.store.isClosed).toBe(true);
const reopened = getDebugProxyCaptureStore(dbPath, blobDir);
expect(Object.is(reopened, first.store)).toBe(false);
expect(reopened.isClosed).toBe(false);
});
it("ignores duplicate close calls", () => {
const store = makeStore();
store.close();
expect(() => store.close()).not.toThrow();
expect(store.isClosed).toBe(true);
});
it("stores sessions, blobs, and duplicate-send query results", () => {
const store = makeStore();
store.upsertSession({

View File

@@ -93,6 +93,7 @@ function sortObservedCounts(counts: Map<string, number>): CaptureObservedDimensi
export class DebugProxyCaptureStore {
readonly db: DatabaseSync;
private closed = false;
constructor(
readonly dbPath: string,
@@ -102,7 +103,15 @@ export class DebugProxyCaptureStore {
}
close(): void {
if (this.closed) {
return;
}
this.db.close();
this.closed = true;
}
get isClosed(): boolean {
return this.closed;
}
upsertSession(session: CaptureSessionRecord): void {
@@ -448,12 +457,14 @@ export class DebugProxyCaptureStore {
let cachedStore: DebugProxyCaptureStore | null = null;
let cachedKey = "";
let cachedStoreLeases = 0;
export function getDebugProxyCaptureStore(dbPath: string, blobDir: string): DebugProxyCaptureStore {
const key = `${dbPath}:${blobDir}`;
if (!cachedStore || cachedKey !== key) {
if (!cachedStore || cachedStore.isClosed || cachedKey !== key) {
cachedStore = new DebugProxyCaptureStore(dbPath, blobDir);
cachedKey = key;
cachedStoreLeases = 0;
}
return cachedStore;
}
@@ -465,6 +476,30 @@ export function closeDebugProxyCaptureStore(): void {
cachedStore.close();
cachedStore = null;
cachedKey = "";
cachedStoreLeases = 0;
}
export function acquireDebugProxyCaptureStore(
dbPath: string,
blobDir: string,
): { store: DebugProxyCaptureStore; release: () => void } {
const store = getDebugProxyCaptureStore(dbPath, blobDir);
const key = cachedKey;
cachedStoreLeases += 1;
let released = false;
return {
store,
release: () => {
if (released) {
return;
}
released = true;
cachedStoreLeases = Math.max(0, cachedStoreLeases - 1);
if (cachedStoreLeases === 0 && cachedStore === store && cachedKey === key) {
closeDebugProxyCaptureStore();
}
},
};
}
export function persistEventPayload(