mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
608e365ce4
* perf(scripts): report SQLite scenarios independently * perf(sqlite): benchmark production state workloads * test(sqlite): use tracked benchmark temp dirs * perf(sqlite): harden workload evidence * fix(perf): require matching SQLite workloads
888 lines
29 KiB
TypeScript
888 lines
29 KiB
TypeScript
// SQLite state benchmark seeds OpenClaw DBs and reports hot-query proof lines.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
|
import { pathToFileURL } from "node:url";
|
|
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
|
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "../src/state/openclaw-agent-db-contract.js";
|
|
import {
|
|
openOpenClawAgentDatabase,
|
|
closeOpenClawAgentDatabasesForTest,
|
|
} from "../src/state/openclaw-agent-db.js";
|
|
import { OPENCLAW_STATE_SCHEMA_VERSION } from "../src/state/openclaw-state-db-contract.js";
|
|
import {
|
|
closeOpenClawStateDatabaseForTest,
|
|
openOpenClawStateDatabase,
|
|
} from "../src/state/openclaw-state-db.js";
|
|
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
|
|
import {
|
|
collectSqliteQueryPlanEvidence,
|
|
type SqliteQueryPlanEvidence,
|
|
} from "./lib/sqlite-query-plan-evidence.js";
|
|
import {
|
|
CliUsageError,
|
|
parseSqliteStateBenchmarkCli,
|
|
type ProfileId,
|
|
} from "./lib/sqlite-state-benchmark-cli.js";
|
|
|
|
type ProfileConfig = {
|
|
agentCacheEntries: number;
|
|
agentCount: number;
|
|
channelIngressEvents: number;
|
|
cronJobs: number;
|
|
cronTaskRuns: number;
|
|
deliveryQueueEntries: number;
|
|
pluginStateEntries: number;
|
|
queryRuns: number;
|
|
};
|
|
|
|
type TimedQuery = {
|
|
database: "agent" | "state";
|
|
id: string;
|
|
p50Ms: number;
|
|
p95Ms: number;
|
|
plan: SqliteQueryPlanEvidence;
|
|
runs: number;
|
|
rows: number;
|
|
sql: string;
|
|
};
|
|
|
|
type BenchmarkReport = {
|
|
integrity: {
|
|
agent: string[];
|
|
state: string;
|
|
};
|
|
node: string;
|
|
schemaVersion: 2;
|
|
versions: {
|
|
agentSchema: number;
|
|
sqlite: string;
|
|
stateSchema: number;
|
|
};
|
|
paths: {
|
|
agentDatabases: string[];
|
|
artifact: string | null;
|
|
stateDatabase: string;
|
|
stateDir: string;
|
|
};
|
|
profile: ProfileId;
|
|
queries: TimedQuery[];
|
|
rows: {
|
|
agentCacheEntries: number;
|
|
agentDatabases: number;
|
|
channelIngressEvents: number;
|
|
cronJobs: number;
|
|
cronTaskRuns: number;
|
|
deliveryQueueEntries: number;
|
|
pluginStateEntries: number;
|
|
stateRows: number;
|
|
transcriptEvents: number;
|
|
};
|
|
timingsMs: {
|
|
checkpoint: number;
|
|
seed: number;
|
|
total: number;
|
|
};
|
|
walBytes: {
|
|
agentAfter: number[];
|
|
agentBefore: number[];
|
|
stateAfter: number;
|
|
stateBefore: number;
|
|
};
|
|
};
|
|
|
|
const PROFILES: Record<ProfileId, ProfileConfig> = {
|
|
smoke: {
|
|
agentCacheEntries: 1_000,
|
|
agentCount: 2,
|
|
channelIngressEvents: 1_000,
|
|
cronJobs: 100,
|
|
cronTaskRuns: 1_000,
|
|
deliveryQueueEntries: 1_000,
|
|
pluginStateEntries: 1_000,
|
|
queryRuns: 20,
|
|
},
|
|
default: {
|
|
agentCacheEntries: 20_000,
|
|
agentCount: 5,
|
|
channelIngressEvents: 10_000,
|
|
cronJobs: 1_000,
|
|
cronTaskRuns: 50_000,
|
|
deliveryQueueEntries: 50_000,
|
|
pluginStateEntries: 20_000,
|
|
queryRuns: 30,
|
|
},
|
|
large: {
|
|
agentCacheEntries: 50_000,
|
|
agentCount: 10,
|
|
channelIngressEvents: 100_000,
|
|
cronJobs: 5_000,
|
|
cronTaskRuns: 250_000,
|
|
deliveryQueueEntries: 200_000,
|
|
pluginStateEntries: 100_000,
|
|
queryRuns: 40,
|
|
},
|
|
};
|
|
|
|
const SQLITE_PERF_FULL_LOAD_RUNS = 20;
|
|
const SQLITE_PERF_TRANSCRIPT_EVENTS = 256;
|
|
const SQLITE_PERF_TRANSCRIPT_MESSAGE_BYTES = 4_096;
|
|
const SQLITE_PERF_TRANSCRIPT_PAGE_MESSAGES = 256;
|
|
const SQLITE_PERF_TRANSCRIPT_SESSION_ID = "perf-history";
|
|
const SQLITE_PERF_INGRESS_QUEUE = JSON.stringify(["telegram", "bench-account"]);
|
|
const SQLITE_PERF_PLUGIN_ID = "benchmark-plugin";
|
|
const SQLITE_PERF_PLUGIN_NAMESPACE = "journal";
|
|
const SQLITE_PERF_PLUGIN_NOW = 1_750_000_000_000;
|
|
const SQLITE_PERF_CATALOG_SCOPE = "plugin-model-catalog-v1";
|
|
const SQLITE_PERF_PAGE_SIZE = 100;
|
|
|
|
function applyScale(config: ProfileConfig): ProfileConfig {
|
|
const scale = parseStrictIntegerOption({
|
|
fallback: 1,
|
|
label: "SQLITE_PERF_SCALE",
|
|
min: 1,
|
|
raw: process.env["SQLITE_PERF_SCALE"],
|
|
});
|
|
if (scale === 1) {
|
|
return config;
|
|
}
|
|
return {
|
|
agentCacheEntries: config.agentCacheEntries * scale,
|
|
agentCount: config.agentCount,
|
|
channelIngressEvents: config.channelIngressEvents * scale,
|
|
cronJobs: config.cronJobs * scale,
|
|
cronTaskRuns: config.cronTaskRuns * scale,
|
|
deliveryQueueEntries: config.deliveryQueueEntries * scale,
|
|
pluginStateEntries: config.pluginStateEntries * scale,
|
|
queryRuns: config.queryRuns,
|
|
};
|
|
}
|
|
|
|
function printUsage(): void {
|
|
console.log(`OpenClaw SQLite state benchmark
|
|
|
|
Usage:
|
|
node --import tsx scripts/bench-sqlite-state.ts [options]
|
|
|
|
Options:
|
|
--profile <smoke|default|large> Data volume profile (default: default)
|
|
--state-dir <path> Reuse a state directory instead of a temp dir
|
|
--output <path> Write machine-readable JSON report
|
|
--help Show this text
|
|
|
|
Environment:
|
|
SQLITE_PERF_SCALE=<n> Multiplies row counts for the selected profile
|
|
`);
|
|
}
|
|
|
|
function nowMs(): number {
|
|
return Number(process.hrtime.bigint()) / 1e6;
|
|
}
|
|
|
|
function fileSize(pathname: string): number {
|
|
try {
|
|
return fs.statSync(pathname).size;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function walSize(pathname: string): number {
|
|
return fileSize(`${pathname}-wal`);
|
|
}
|
|
|
|
function stateRowCount(config: ProfileConfig): number {
|
|
return (
|
|
config.channelIngressEvents +
|
|
config.cronJobs +
|
|
config.cronTaskRuns +
|
|
config.deliveryQueueEntries +
|
|
config.pluginStateEntries
|
|
);
|
|
}
|
|
|
|
function seedStateDatabase(db: DatabaseSync, config: ProfileConfig): void {
|
|
db.exec("BEGIN IMMEDIATE;");
|
|
try {
|
|
seedCronJobs(db, config.cronJobs);
|
|
seedCronTaskRuns(db, config.cronTaskRuns, config.cronJobs);
|
|
seedDeliveryQueue(db, config.deliveryQueueEntries);
|
|
seedPluginState(db, config.pluginStateEntries);
|
|
seedChannelIngress(db, config.channelIngressEvents);
|
|
db.exec("COMMIT;");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK;");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function seedCronJobs(db: DatabaseSync, count: number): void {
|
|
const insert = db.prepare(`
|
|
INSERT INTO cron_jobs (
|
|
store_key, job_id, name, enabled, agent_id, payload_kind,
|
|
job_json, state_json, runtime_updated_at_ms, schedule_identity, sort_order, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, 'agentTurn', ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
for (let i = 0; i < count; i += 1) {
|
|
const jobId = `job-${String(i).padStart(8, "0")}`;
|
|
const storeKey = `/state/cron/jobs-${i % 8}.json`;
|
|
const updatedAt = 1_700_000_000_000 + i;
|
|
const name = `Benchmark job ${i}`;
|
|
const enabled = i % 5 !== 0;
|
|
const agentId = `agent-${i % 16}`;
|
|
const job = {
|
|
id: jobId,
|
|
name,
|
|
enabled,
|
|
createdAtMs: updatedAt - 100_000,
|
|
agentId,
|
|
sessionKey: `agent:${agentId}:main`,
|
|
schedule: {
|
|
kind: "every",
|
|
everyMs: 60_000 + (i % 120) * 1_000,
|
|
anchorMs: updatedAt - 60_000,
|
|
},
|
|
sessionTarget: "isolated",
|
|
wakeMode: "now",
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: `Benchmark payload ${i}`,
|
|
model: "openai/gpt-5.6-luna",
|
|
timeoutSeconds: 60,
|
|
allowUnsafeExternalContent: false,
|
|
lightContext: true,
|
|
},
|
|
delivery: {
|
|
mode: "announce",
|
|
channel: "telegram",
|
|
to: `chat-${i % 32}`,
|
|
accountId: "bench-account",
|
|
bestEffort: true,
|
|
},
|
|
state: {},
|
|
};
|
|
const state = {
|
|
nextRunAtMs: updatedAt + (i % 2_000) * 1_000,
|
|
lastRunAtMs: updatedAt - 1_000,
|
|
lastRunStatus: "completed",
|
|
lastDurationMs: 50 + (i % 500),
|
|
consecutiveErrors: 0,
|
|
consecutiveSkipped: 0,
|
|
scheduleErrorCount: 0,
|
|
lastDeliveryStatus: "sent",
|
|
lastDelivered: true,
|
|
};
|
|
insert.run(
|
|
storeKey,
|
|
jobId,
|
|
name,
|
|
enabled ? 1 : 0,
|
|
agentId,
|
|
JSON.stringify(job),
|
|
JSON.stringify(state),
|
|
updatedAt,
|
|
`schedule-${i % 512}`,
|
|
i,
|
|
updatedAt,
|
|
);
|
|
}
|
|
}
|
|
|
|
function seedCronTaskRuns(db: DatabaseSync, count: number, cronJobCount: number): void {
|
|
const insert = db.prepare(`
|
|
INSERT INTO task_runs (
|
|
task_id, runtime, source_id, requester_session_key, owner_key, scope_kind,
|
|
child_session_key, run_id, task, status, delivery_status, notify_policy,
|
|
created_at, started_at, ended_at, last_event_at, error, terminal_summary,
|
|
terminal_outcome, detail_json
|
|
) VALUES (?, 'cron', ?, '', '', 'system', ?, ?, ?, ?, 'not_applicable', 'silent',
|
|
?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
for (let i = 0; i < count; i += 1) {
|
|
const jobIndex = i % 4 === 0 ? 0 : i % Math.max(1, cronJobCount);
|
|
const jobId = `job-${String(jobIndex).padStart(8, "0")}`;
|
|
const ts = 1_700_000_000_000 + i;
|
|
const succeeded = i % 17 !== 0;
|
|
const runId = `run-${i}`;
|
|
const status = succeeded ? "ok" : "error";
|
|
const storeKey = `/state/cron/jobs-${i % 8}.json`;
|
|
insert.run(
|
|
`cron-benchmark-${i}`,
|
|
jobId,
|
|
`agent:agent-${i % 16}:main`,
|
|
runId,
|
|
jobId,
|
|
succeeded ? "succeeded" : "failed",
|
|
ts,
|
|
ts,
|
|
ts + 20 + (i % 1_000),
|
|
ts + 20 + (i % 1_000),
|
|
succeeded ? null : `run ${i} failed`,
|
|
`run ${i}`,
|
|
succeeded ? "succeeded" : null,
|
|
JSON.stringify({ kind: "cron-run", storeKey, action: "finished", status, runId }),
|
|
);
|
|
}
|
|
}
|
|
|
|
function seedDeliveryQueue(db: DatabaseSync, count: number): void {
|
|
const insert = db.prepare(`
|
|
INSERT INTO delivery_queue_entries (
|
|
queue_name, id, status, entry_kind, session_key, channel, target, account_id,
|
|
retry_count, last_attempt_at, last_error, recovery_state, platform_send_started_at,
|
|
entry_json, enqueued_at, updated_at, failed_at
|
|
) VALUES (?, ?, ?, 'message', ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?)
|
|
`);
|
|
for (let i = 0; i < count; i += 1) {
|
|
const status = i % 13 === 0 ? "failed" : i % 17 === 0 ? "completed" : "pending";
|
|
const queueName = i % 5 === 0 ? "session" : "outbound";
|
|
const enqueuedAt = 1_700_000_000_000 + i;
|
|
const channel = i % 2 === 0 ? "telegram" : "discord";
|
|
const target = `target-${i % 256}`;
|
|
const accountId = `account-${i % 8}`;
|
|
const sessionKey = `agent:agent-${i % 16}:main`;
|
|
insert.run(
|
|
queueName,
|
|
`delivery-${String(i).padStart(8, "0")}`,
|
|
status,
|
|
sessionKey,
|
|
channel,
|
|
target,
|
|
accountId,
|
|
i % 5,
|
|
status === "failed" ? enqueuedAt + 500 : null,
|
|
JSON.stringify({
|
|
id: `delivery-${String(i).padStart(8, "0")}`,
|
|
retryCount: i % 5,
|
|
enqueuedAt,
|
|
sessionKey,
|
|
route: { channel, to: target, accountId },
|
|
}),
|
|
enqueuedAt,
|
|
enqueuedAt + 100,
|
|
status === "failed" ? enqueuedAt + 1_000 : null,
|
|
);
|
|
}
|
|
}
|
|
|
|
function seedPluginState(db: DatabaseSync, count: number): void {
|
|
const insert = db.prepare(`
|
|
INSERT INTO plugin_state_entries (
|
|
plugin_id, namespace, entry_key, value_json, created_at, expires_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
`);
|
|
for (let i = 0; i < count; i += 1) {
|
|
const concentrated = i < Math.ceil(count * 0.75);
|
|
const expiresAt =
|
|
i % 10 === 0
|
|
? SQLITE_PERF_PLUGIN_NOW - 1_000 - i
|
|
: i % 4 === 0
|
|
? SQLITE_PERF_PLUGIN_NOW + 1_000 + i
|
|
: null;
|
|
insert.run(
|
|
concentrated ? SQLITE_PERF_PLUGIN_ID : `plugin-${i % 12}`,
|
|
concentrated ? SQLITE_PERF_PLUGIN_NAMESPACE : `namespace-${i % 16}`,
|
|
`entry-${String(i).padStart(8, "0")}`,
|
|
JSON.stringify({ value: i, text: `payload ${i}` }),
|
|
1_700_000_000_000 + i,
|
|
expiresAt,
|
|
);
|
|
}
|
|
}
|
|
|
|
function seedChannelIngress(db: DatabaseSync, count: number): void {
|
|
const insert = db.prepare(`
|
|
INSERT INTO channel_ingress_events (
|
|
queue_name, event_id, channel_id, account_id, status, lane_key, payload_json,
|
|
metadata_json, received_at, updated_at, claim_token, claim_owner, claimed_at,
|
|
attempts, last_attempt_at, last_error, failed_reason, failed_at, completed_at,
|
|
completed_metadata_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, NULL)
|
|
`);
|
|
for (let i = 0; i < count; i += 1) {
|
|
const status = i % 17 === 0 ? "claimed" : i % 29 === 0 ? "completed" : "pending";
|
|
const timestamp = 1_700_000_000_000 + i;
|
|
insert.run(
|
|
SQLITE_PERF_INGRESS_QUEUE,
|
|
`event-${String(i).padStart(8, "0")}`,
|
|
"telegram",
|
|
"bench-account",
|
|
status,
|
|
`lane-${i % 128}`,
|
|
JSON.stringify({ messageId: `message-${i}`, text: `message ${i}` }),
|
|
JSON.stringify({ source: "benchmark" }),
|
|
timestamp,
|
|
timestamp,
|
|
status === "claimed" ? `claim-${i}` : null,
|
|
status === "claimed" ? "benchmark-worker" : null,
|
|
status === "claimed" ? timestamp : null,
|
|
i % 3,
|
|
status === "claimed" ? timestamp : null,
|
|
status === "completed" ? timestamp : null,
|
|
);
|
|
}
|
|
}
|
|
|
|
function seedAgentDatabase(db: DatabaseSync, count: number, agentIndex: number): void {
|
|
db.exec("BEGIN IMMEDIATE;");
|
|
try {
|
|
const insert = db.prepare(`
|
|
INSERT INTO cache_entries (scope, key, value_json, blob, expires_at, updated_at)
|
|
VALUES (?, ?, ?, NULL, ?, ?)
|
|
`);
|
|
const catalogEntries = Math.min(count, 64);
|
|
for (let i = 0; i < count; i += 1) {
|
|
const isCatalog = i < catalogEntries;
|
|
insert.run(
|
|
isCatalog ? SQLITE_PERF_CATALOG_SCOPE : `runtime-cache-${i % 16}`,
|
|
isCatalog
|
|
? `plugin-${String(i).padStart(3, "0")}`
|
|
: `agent-${agentIndex}-entry-${String(i).padStart(8, "0")}`,
|
|
JSON.stringify(
|
|
isCatalog
|
|
? {
|
|
generatedBy: "openclaw-plugin-model-catalog-v1",
|
|
models: [{ id: `model-${i}`, name: `Benchmark model ${i}` }],
|
|
pluginId: `plugin-${i}`,
|
|
}
|
|
: { agentIndex, i, value: `cache ${i}` },
|
|
),
|
|
null,
|
|
1_700_000_000_000 + i,
|
|
);
|
|
}
|
|
if (agentIndex === 0) {
|
|
seedTranscriptHistory(db);
|
|
}
|
|
db.exec("COMMIT;");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK;");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function seedTranscriptHistory(db: DatabaseSync): void {
|
|
const sessionKey = "agent:perf-agent-0:history";
|
|
db.prepare(
|
|
`INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at)
|
|
VALUES (?, ?, ?, ?)`,
|
|
).run(sessionKey, SQLITE_PERF_TRANSCRIPT_SESSION_ID, "{}", 1_700_000_000_000);
|
|
db.prepare(
|
|
`INSERT INTO session_windows (session_id, session_key, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?)`,
|
|
).run(SQLITE_PERF_TRANSCRIPT_SESSION_ID, sessionKey, 1_700_000_000_000, 1_700_000_000_000);
|
|
|
|
const insertEvent = db.prepare(
|
|
`INSERT INTO transcript_events (session_id, seq, event_json, created_at)
|
|
VALUES (?, ?, ?, ?)`,
|
|
);
|
|
const insertIdentity = db.prepare(
|
|
`INSERT INTO transcript_event_identities
|
|
(session_id, event_id, seq, event_type, parent_id, message_idempotency_key, created_at)
|
|
VALUES (?, ?, ?, 'message', NULL, NULL, ?)`,
|
|
);
|
|
const insertActive = db.prepare(
|
|
`INSERT INTO session_transcript_active_events
|
|
(session_id, active_position, event_seq, message_position)
|
|
VALUES (?, ?, ?, ?)`,
|
|
);
|
|
const messageContent = "x".repeat(SQLITE_PERF_TRANSCRIPT_MESSAGE_BYTES);
|
|
for (let seq = 1; seq <= SQLITE_PERF_TRANSCRIPT_EVENTS; seq += 1) {
|
|
const eventId = `history-${seq}`;
|
|
const message = {
|
|
type: "message",
|
|
id: eventId,
|
|
message: { role: "user", content: messageContent },
|
|
};
|
|
insertEvent.run(SQLITE_PERF_TRANSCRIPT_SESSION_ID, seq, JSON.stringify(message), seq);
|
|
insertIdentity.run(SQLITE_PERF_TRANSCRIPT_SESSION_ID, eventId, seq, seq);
|
|
insertActive.run(SQLITE_PERF_TRANSCRIPT_SESSION_ID, seq - 1, seq, seq - 1);
|
|
}
|
|
db.prepare(
|
|
`INSERT INTO session_transcript_index_state
|
|
(session_id, indexed_seq, leaf_event_id, active_event_count, active_message_count, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
SQLITE_PERF_TRANSCRIPT_SESSION_ID,
|
|
SQLITE_PERF_TRANSCRIPT_EVENTS,
|
|
`history-${SQLITE_PERF_TRANSCRIPT_EVENTS}`,
|
|
SQLITE_PERF_TRANSCRIPT_EVENTS,
|
|
SQLITE_PERF_TRANSCRIPT_EVENTS,
|
|
1_700_000_000_000,
|
|
);
|
|
}
|
|
|
|
function readIntegrity(db: DatabaseSync): string {
|
|
const row = db.prepare("PRAGMA integrity_check").get() as { integrity_check?: unknown };
|
|
return typeof row.integrity_check === "string" ? row.integrity_check : "missing";
|
|
}
|
|
|
|
function readSqliteVersion(db: DatabaseSync): string {
|
|
const row = db.prepare("SELECT sqlite_version() AS version").get() as { version?: unknown };
|
|
return typeof row.version === "string" ? row.version : "unknown";
|
|
}
|
|
|
|
function checkpoint(db: DatabaseSync): void {
|
|
db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").all();
|
|
}
|
|
|
|
function percentile(values: number[], pct: number): number {
|
|
if (values.length === 0) {
|
|
return 0;
|
|
}
|
|
const sorted = values.toSorted((left, right) => left - right);
|
|
const index = Math.min(sorted.length - 1, Math.ceil((pct / 100) * sorted.length) - 1);
|
|
return Number(expectDefined(sorted[index], `SQLite benchmark percentile ${pct}`).toFixed(3));
|
|
}
|
|
|
|
function readQueryPlan(
|
|
db: DatabaseSync,
|
|
sql: string,
|
|
params: SQLInputValue[],
|
|
): SqliteQueryPlanEvidence {
|
|
const raw = (
|
|
db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as Array<{ detail?: unknown }>
|
|
).map((row) => (typeof row.detail === "string" ? row.detail : JSON.stringify(row.detail ?? "")));
|
|
return collectSqliteQueryPlanEvidence(raw);
|
|
}
|
|
|
|
function runTimedQuery(params: {
|
|
database: "agent" | "state";
|
|
db: DatabaseSync;
|
|
fullLoad?: boolean;
|
|
id: string;
|
|
queryParams: SQLInputValue[];
|
|
requestedRuns: number;
|
|
sql: string;
|
|
}): TimedQuery {
|
|
const runs = params.fullLoad
|
|
? Math.min(params.requestedRuns, SQLITE_PERF_FULL_LOAD_RUNS)
|
|
: params.requestedRuns;
|
|
const statement = params.db.prepare(params.sql);
|
|
const samples: number[] = [];
|
|
let rows = statement.all(...params.queryParams).length;
|
|
for (let i = 0; i < runs; i += 1) {
|
|
const started = nowMs();
|
|
rows = statement.all(...params.queryParams).length;
|
|
samples.push(nowMs() - started);
|
|
}
|
|
return {
|
|
database: params.database,
|
|
id: params.id,
|
|
p50Ms: percentile(samples, 50),
|
|
p95Ms: percentile(samples, 95),
|
|
plan: readQueryPlan(params.db, params.sql, params.queryParams),
|
|
runs,
|
|
rows,
|
|
sql: params.sql,
|
|
};
|
|
}
|
|
|
|
function taskRunSelectSql(where: string): string {
|
|
return `SELECT
|
|
task_id, runtime, task_kind, source_id, requester_session_key, owner_key, scope_kind,
|
|
child_session_key, parent_flow_id, parent_task_id, agent_id, requester_agent_id,
|
|
run_id, label, task, status, delivery_status, notify_policy, created_at, started_at,
|
|
ended_at, last_event_at, cleanup_after, tool_use_count, last_tool_name, error,
|
|
progress_summary, terminal_summary, terminal_outcome, detail_json
|
|
FROM task_runs
|
|
WHERE ${where}
|
|
ORDER BY created_at ASC, task_id ASC`;
|
|
}
|
|
|
|
function runHotQueries(params: {
|
|
agentDb: DatabaseSync;
|
|
config: ProfileConfig;
|
|
stateDb: DatabaseSync;
|
|
}): TimedQuery[] {
|
|
const transcriptPositions = Array.from(
|
|
{ length: SQLITE_PERF_TRANSCRIPT_PAGE_MESSAGES },
|
|
(_, index) => SQLITE_PERF_TRANSCRIPT_EVENTS - SQLITE_PERF_TRANSCRIPT_PAGE_MESSAGES + index,
|
|
);
|
|
const transcriptPlaceholders = transcriptPositions.map(() => "?").join(", ");
|
|
return [
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
id: "cron.store.load",
|
|
queryParams: ["/state/cron/jobs-0.json"],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT *
|
|
FROM cron_jobs
|
|
WHERE store_key = ?
|
|
ORDER BY sort_order ASC, updated_at ASC, job_id ASC`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
fullLoad: true,
|
|
id: "task-runs.cron.list",
|
|
queryParams: ["cron"],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: taskRunSelectSql("runtime = ?"),
|
|
}),
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
fullLoad: true,
|
|
id: "task-runs.cron-source.list",
|
|
queryParams: ["cron", "job-00000000"],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: taskRunSelectSql("runtime = ? AND source_id = ?"),
|
|
}),
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
fullLoad: true,
|
|
id: "delivery.pending.load",
|
|
queryParams: ["outbound", "pending"],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT id, entry_json, enqueued_at, retry_count, last_attempt_at, last_error,
|
|
platform_send_started_at, recovery_state
|
|
FROM delivery_queue_entries
|
|
WHERE queue_name = ? AND status = ?
|
|
ORDER BY enqueued_at ASC, id ASC`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
id: "ingress.pending.first-page",
|
|
queryParams: [SQLITE_PERF_INGRESS_QUEUE, "pending", SQLITE_PERF_PAGE_SIZE],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT *
|
|
FROM channel_ingress_events
|
|
WHERE queue_name = ? AND status = ?
|
|
ORDER BY received_at ASC, event_id ASC
|
|
LIMIT ?`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
id: "ingress.pending.seek-page",
|
|
queryParams: [
|
|
SQLITE_PERF_INGRESS_QUEUE,
|
|
"pending",
|
|
1_700_000_000_500,
|
|
1_700_000_000_500,
|
|
"event-00000500",
|
|
SQLITE_PERF_PAGE_SIZE,
|
|
],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT *
|
|
FROM channel_ingress_events
|
|
WHERE queue_name = ? AND status = ?
|
|
AND (received_at > ? OR (received_at = ? AND event_id > ?))
|
|
ORDER BY received_at ASC, event_id ASC
|
|
LIMIT ?`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
id: "ingress.pending.id-page",
|
|
queryParams: [SQLITE_PERF_INGRESS_QUEUE, "pending", SQLITE_PERF_PAGE_SIZE],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT *
|
|
FROM channel_ingress_events
|
|
WHERE queue_name = ? AND status = ?
|
|
ORDER BY event_id ASC
|
|
LIMIT ?`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
id: "ingress.pending.id-seek-page",
|
|
queryParams: [SQLITE_PERF_INGRESS_QUEUE, "pending", "event-00000500", SQLITE_PERF_PAGE_SIZE],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT *
|
|
FROM channel_ingress_events
|
|
WHERE queue_name = ? AND status = ? AND event_id > ?
|
|
ORDER BY event_id ASC
|
|
LIMIT ?`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "state",
|
|
db: params.stateDb,
|
|
fullLoad: true,
|
|
id: "plugin-state.namespace.live",
|
|
queryParams: [SQLITE_PERF_PLUGIN_ID, SQLITE_PERF_PLUGIN_NAMESPACE, SQLITE_PERF_PLUGIN_NOW],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT plugin_id, namespace, entry_key, value_json, created_at, expires_at
|
|
FROM plugin_state_entries
|
|
WHERE plugin_id = ? AND namespace = ?
|
|
AND (expires_at IS NULL OR expires_at > ?)
|
|
ORDER BY created_at ASC, entry_key ASC`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "agent",
|
|
db: params.agentDb,
|
|
id: "agent-cache.plugin-model-catalog.list",
|
|
queryParams: [SQLITE_PERF_CATALOG_SCOPE],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT key, value_json
|
|
FROM cache_entries
|
|
WHERE scope = ?
|
|
ORDER BY key ASC`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "agent",
|
|
db: params.agentDb,
|
|
id: "transcript.tail.metadata",
|
|
queryParams: [SQLITE_PERF_TRANSCRIPT_SESSION_ID, ...transcriptPositions],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT active.message_position,
|
|
LENGTH(CAST(event.event_json AS BLOB)) + 1 AS serialized_bytes
|
|
FROM session_transcript_active_events AS active
|
|
JOIN transcript_events AS event
|
|
ON event.session_id = active.session_id AND event.seq = active.event_seq
|
|
WHERE active.session_id = ?
|
|
AND active.message_position IN (${transcriptPlaceholders})
|
|
ORDER BY active.message_position DESC`,
|
|
}),
|
|
runTimedQuery({
|
|
database: "agent",
|
|
db: params.agentDb,
|
|
id: "transcript.tail.payload",
|
|
queryParams: [SQLITE_PERF_TRANSCRIPT_SESSION_ID, ...transcriptPositions],
|
|
requestedRuns: params.config.queryRuns,
|
|
sql: `SELECT active.message_position, event.event_json
|
|
FROM session_transcript_active_events AS active
|
|
JOIN transcript_events AS event
|
|
ON event.session_id = active.session_id AND event.seq = active.event_seq
|
|
WHERE active.session_id = ?
|
|
AND active.message_position IN (${transcriptPlaceholders})
|
|
ORDER BY active.message_position ASC`,
|
|
}),
|
|
];
|
|
}
|
|
|
|
function printProofLines(report: BenchmarkReport): void {
|
|
const p95 = Math.max(...report.queries.map((query) => query.p95Ms));
|
|
console.log(`SQLITE_PERF_PROFILE=${report.profile}`);
|
|
console.log(`SQLITE_PERF_STATE_ROWS=${report.rows.stateRows}`);
|
|
console.log(`SQLITE_PERF_AGENT_ROWS=${report.rows.agentCacheEntries}`);
|
|
console.log(`SQLITE_PERF_TRANSCRIPT_ROWS=${report.rows.transcriptEvents}`);
|
|
console.log(`SQLITE_PERF_INTEGRITY=${report.integrity.state}`);
|
|
console.log(`SQLITE_PERF_WAL_BYTES_BEFORE=${report.walBytes.stateBefore}`);
|
|
console.log(`SQLITE_PERF_WAL_BYTES_AFTER=${report.walBytes.stateAfter}`);
|
|
console.log(`SQLITE_PERF_QUERY_P95_MS=${p95.toFixed(3)}`);
|
|
for (const query of report.queries) {
|
|
console.log(`SQLITE_PERF_SCENARIO ${JSON.stringify(query)}`);
|
|
}
|
|
if (report.paths.artifact) {
|
|
console.log(`SQLITE_PERF_ARTIFACT=${report.paths.artifact}`);
|
|
}
|
|
}
|
|
|
|
function main(): void {
|
|
const argv = process.argv.slice(2);
|
|
const cli = parseSqliteStateBenchmarkCli(argv);
|
|
if (cli.help) {
|
|
printUsage();
|
|
return;
|
|
}
|
|
const { options } = cli;
|
|
const config = applyScale(PROFILES[options.profile]);
|
|
const stateDir =
|
|
options.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-perf-"));
|
|
const env = { OPENCLAW_STATE_DIR: stateDir };
|
|
const started = nowMs();
|
|
try {
|
|
const stateDatabase = openOpenClawStateDatabase({ env });
|
|
const agentDatabases = Array.from({ length: config.agentCount }, (_, index) =>
|
|
openOpenClawAgentDatabase({ agentId: `perf-agent-${index}`, env }),
|
|
);
|
|
|
|
const seedStarted = nowMs();
|
|
seedStateDatabase(stateDatabase.db, config);
|
|
const perAgentEntries = Math.ceil(config.agentCacheEntries / config.agentCount);
|
|
agentDatabases.forEach((database, index) =>
|
|
seedAgentDatabase(database.db, perAgentEntries, index),
|
|
);
|
|
const seedMs = nowMs() - seedStarted;
|
|
|
|
const stateWalBefore = walSize(stateDatabase.path);
|
|
const agentWalBefore = agentDatabases.map((database) => walSize(database.path));
|
|
const stateIntegrity = readIntegrity(stateDatabase.db);
|
|
const agentIntegrity = agentDatabases.map((database) => readIntegrity(database.db));
|
|
const queries = runHotQueries({
|
|
agentDb: agentDatabases[0]?.db ?? stateDatabase.db,
|
|
config,
|
|
stateDb: stateDatabase.db,
|
|
});
|
|
|
|
const checkpointStarted = nowMs();
|
|
checkpoint(stateDatabase.db);
|
|
agentDatabases.forEach((database) => checkpoint(database.db));
|
|
const checkpointMs = nowMs() - checkpointStarted;
|
|
|
|
const report: BenchmarkReport = {
|
|
integrity: {
|
|
agent: agentIntegrity,
|
|
state: stateIntegrity,
|
|
},
|
|
node: process.version,
|
|
schemaVersion: 2,
|
|
paths: {
|
|
agentDatabases: agentDatabases.map((database) => database.path),
|
|
artifact: options.output,
|
|
stateDatabase: stateDatabase.path,
|
|
stateDir,
|
|
},
|
|
profile: options.profile,
|
|
queries,
|
|
versions: {
|
|
agentSchema: OPENCLAW_AGENT_SCHEMA_VERSION,
|
|
sqlite: readSqliteVersion(stateDatabase.db),
|
|
stateSchema: OPENCLAW_STATE_SCHEMA_VERSION,
|
|
},
|
|
rows: {
|
|
agentCacheEntries: perAgentEntries * config.agentCount,
|
|
agentDatabases: config.agentCount,
|
|
channelIngressEvents: config.channelIngressEvents,
|
|
cronJobs: config.cronJobs,
|
|
cronTaskRuns: config.cronTaskRuns,
|
|
deliveryQueueEntries: config.deliveryQueueEntries,
|
|
pluginStateEntries: config.pluginStateEntries,
|
|
stateRows: stateRowCount(config),
|
|
transcriptEvents: SQLITE_PERF_TRANSCRIPT_EVENTS,
|
|
},
|
|
timingsMs: {
|
|
checkpoint: Number(checkpointMs.toFixed(3)),
|
|
seed: Number(seedMs.toFixed(3)),
|
|
total: Number((nowMs() - started).toFixed(3)),
|
|
},
|
|
walBytes: {
|
|
agentAfter: agentDatabases.map((database) => walSize(database.path)),
|
|
agentBefore: agentWalBefore,
|
|
stateAfter: walSize(stateDatabase.path),
|
|
stateBefore: stateWalBefore,
|
|
},
|
|
};
|
|
|
|
if (options.output) {
|
|
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
|
fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
}
|
|
printProofLines(report);
|
|
} finally {
|
|
closeOpenClawAgentDatabasesForTest();
|
|
closeOpenClawStateDatabaseForTest();
|
|
if (!options.stateDir) {
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
if (error instanceof CliUsageError) {
|
|
console.error(`error: ${error.message}`);
|
|
process.exit(2);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|