mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15:24 +00:00
fix: stop full database rewrites on every plugin db call
This commit is contained in:
1 parent
040fbfe315
commit
853615af09
11 files changed
+312
-25
No files matched your search
@@ -68,4 +68,8 @@ export async function runCoreBootMigrations(): Promise<void> {
|
|||||||
const { runExternalAutoProvisionMigration } =
|
const { runExternalAutoProvisionMigration } =
|
||||||
await import("./upgrade/external-auto-provision-migration.js");
|
await import("./upgrade/external-auto-provision-migration.js");
|
||||||
await runExternalAutoProvisionMigration();
|
await runExternalAutoProvisionMigration();
|
||||||
|
|
||||||
|
const { runAuditNoiseCleanup } =
|
||||||
|
await import("./upgrade/audit-noise-cleanup.js");
|
||||||
|
await runAuditNoiseCleanup();
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
import { and, asc, desc, eq, gte, inArray, lt, lte, sql } from "drizzle-orm";
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
lt,
|
||||||
|
lte,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
import { auditLogs } from "../db/schema.js";
|
import { auditLogs } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
||||||
@@ -26,6 +37,17 @@ export const AUDIT_RETENTION_DAYS_ENV = "AUDIT_LOG_RETENTION_DAYS";
|
|||||||
export const AUDIT_MAX_ENTRIES_ENV = "AUDIT_LOG_MAX_ENTRIES";
|
export const AUDIT_MAX_ENTRIES_ENV = "AUDIT_LOG_MAX_ENTRIES";
|
||||||
|
|
||||||
const DEFAULT_MAX_ENTRIES = 10000;
|
const DEFAULT_MAX_ENTRIES = 10000;
|
||||||
|
|
||||||
|
// 2.9.1 logged these on every background plugin query and pushed real
|
||||||
|
// entries out of the capped log. They are now audited once per activation.
|
||||||
|
export const ROUTINE_PLUGIN_AUDIT_ACTIONS = [
|
||||||
|
"plugin_db_client",
|
||||||
|
"plugin_db_refs",
|
||||||
|
"plugin_kv_get",
|
||||||
|
"plugin_kv_list",
|
||||||
|
"plugin_files_data_dir",
|
||||||
|
"plugin_settings_read_core",
|
||||||
|
];
|
||||||
const PRUNE_TARGET_RATIO = 0.9;
|
const PRUNE_TARGET_RATIO = 0.9;
|
||||||
|
|
||||||
function positiveIntEnv(key: string, env: NodeJS.ProcessEnv): number | null {
|
function positiveIntEnv(key: string, env: NodeJS.ProcessEnv): number | null {
|
||||||
@@ -166,6 +188,24 @@ export class AuditLogRepository {
|
|||||||
return rowsAffected(result);
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteRoutinePluginNoise(): Promise<number> {
|
||||||
|
AuditLogRepository.cachedCount = null;
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.delete(auditLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(auditLogs.userId),
|
||||||
|
inArray(auditLogs.action, ROUTINE_PLUGIN_AUDIT_ACTIONS),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rowsAffected(result) > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowsAffected(result);
|
||||||
|
}
|
||||||
|
|
||||||
private buildWhere(filters: AuditLogFilters) {
|
private buildWhere(filters: AuditLogFilters) {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function createCurrentRepositoryWriteHook(
|
|||||||
*
|
*
|
||||||
* Marks the in-memory database dirty and lets DatabaseSaveTrigger's existing
|
* Marks the in-memory database dirty and lets DatabaseSaveTrigger's existing
|
||||||
* debounce coalesce the actual serialize+encrypt, instead of forcing one on
|
* debounce coalesce the actual serialize+encrypt, instead of forcing one on
|
||||||
* every single sample. A lost 2-second window of telemetry on crash is
|
* every single sample. Losing up to 30 seconds of these on a crash is
|
||||||
* acceptable; blocking the event loop that serves SSH traffic on every metric
|
* acceptable; blocking the event loop that serves SSH traffic on every metric
|
||||||
* sample is not.
|
* sample is not.
|
||||||
*/
|
*/
|
||||||
@@ -128,7 +128,8 @@ export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
|||||||
export function createCurrentAuditLogRepository(): AuditLogRepository {
|
export function createCurrentAuditLogRepository(): AuditLogRepository {
|
||||||
return new AuditLogRepository(
|
return new AuditLogRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
createCurrentRepositoryWriteHook("audit_log_repository_write"),
|
// Rows land on every audited call; the audited action saves on its own.
|
||||||
|
createCurrentRepositoryLazyWriteHook("audit_log_repository_write"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ interface AuditOptions {
|
|||||||
action: string;
|
action: string;
|
||||||
/** Short description of the call shape. Never a value. */
|
/** Short description of the call shape. Never a value. */
|
||||||
details?: () => string;
|
details?: () => string;
|
||||||
|
/**
|
||||||
|
* Audit only the first success. For handles plugins fetch before every
|
||||||
|
* query: an audit row per call rewrote the whole database each time.
|
||||||
|
*/
|
||||||
|
once?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +129,7 @@ function guarded<Args extends unknown[], Result>(
|
|||||||
fn: (...args: Args) => Promise<Result>,
|
fn: (...args: Args) => Promise<Result>,
|
||||||
options: AuditOptions,
|
options: AuditOptions,
|
||||||
): (...args: Args) => Promise<Result> {
|
): (...args: Args) => Promise<Result> {
|
||||||
|
let audited = false;
|
||||||
return async (...args: Args): Promise<Result> => {
|
return async (...args: Args): Promise<Result> => {
|
||||||
// A refusal is audited inside assertCapability, under this action.
|
// A refusal is audited inside assertCapability, under this action.
|
||||||
await assertCapability(
|
await assertCapability(
|
||||||
@@ -135,7 +141,10 @@ function guarded<Args extends unknown[], Result>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fn(...args);
|
const result = await fn(...args);
|
||||||
|
if (!options.once || !audited) {
|
||||||
|
audited = true;
|
||||||
await writeAudit(manifest, options, { success: true });
|
await writeAudit(manifest, options, { success: true });
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await writeAudit(manifest, options, {
|
await writeAudit(manifest, options, {
|
||||||
@@ -242,7 +251,7 @@ export function createPluginContext(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ action: "kv_get" },
|
{ action: "kv_get", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const kvSet = guarded(
|
const kvSet = guarded(
|
||||||
@@ -296,7 +305,7 @@ export function createPluginContext(
|
|||||||
await import("../database/repositories/factory.js");
|
await import("../database/repositories/factory.js");
|
||||||
return createCurrentPluginStorageRepository().listKeys(pluginId);
|
return createCurrentPluginStorageRepository().listKeys(pluginId);
|
||||||
},
|
},
|
||||||
{ action: "kv_list" },
|
{ action: "kv_list", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const filesDataDir = guarded(
|
const filesDataDir = guarded(
|
||||||
@@ -309,7 +318,7 @@ export function createPluginContext(
|
|||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
return dir;
|
return dir;
|
||||||
},
|
},
|
||||||
{ action: "files_data_dir" },
|
{ action: "files_data_dir", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const dbDefine = guarded(
|
const dbDefine = guarded(
|
||||||
@@ -329,7 +338,7 @@ export function createPluginContext(
|
|||||||
const { getDb } = await import("../database/db/index.js");
|
const { getDb } = await import("../database/db/index.js");
|
||||||
return getDb();
|
return getDb();
|
||||||
},
|
},
|
||||||
{ action: "db_client" },
|
{ action: "db_client", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// The one settings call that leaves the plugin's own namespace, so the one
|
// The one settings call that leaves the plugin's own namespace, so the one
|
||||||
@@ -341,6 +350,7 @@ export function createPluginContext(
|
|||||||
{
|
{
|
||||||
action: "settings_read_core",
|
action: "settings_read_core",
|
||||||
details: () => "read a core server setting",
|
details: () => "read a core server setting",
|
||||||
|
once: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -359,7 +369,7 @@ export function createPluginContext(
|
|||||||
userRoles: schema.userRoles,
|
userRoles: schema.userRoles,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ action: "db_refs" },
|
{ action: "db_refs", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const desktopOpenIsolatedWindow = guarded(
|
const desktopOpenIsolatedWindow = guarded(
|
||||||
@@ -488,8 +498,8 @@ export function createPluginContext(
|
|||||||
define: (definition) => dbDefine(definition) as never,
|
define: (definition) => dbDefine(definition) as never,
|
||||||
client: () => dbClient() as never,
|
client: () => dbClient() as never,
|
||||||
refs: () => dbRefs() as never,
|
refs: () => dbRefs() as never,
|
||||||
// Gated but not audited: it follows every write, and the write itself
|
// Gated but not audited: it follows every write, and client() was
|
||||||
// went through a client() call that already left an audit line.
|
// already audited when the plugin first asked for it.
|
||||||
persist: async (options) => {
|
persist: async (options) => {
|
||||||
await assertCapability(pluginId, "db:own", manifest.capabilities);
|
await assertCapability(pluginId, "db:own", manifest.capabilities);
|
||||||
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
|
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
|
||||||
|
|||||||
@@ -142,6 +142,42 @@ describe("AuditLogRepository", () => {
|
|||||||
).toEqual(["user-2"]);
|
).toEqual(["user-2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears only background routine plugin entries", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const repo = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
const entry = (userId: string | null, action: string) =>
|
||||||
|
repo.create({
|
||||||
|
userId,
|
||||||
|
username: "plugin:host-metrics",
|
||||||
|
action,
|
||||||
|
resourceType: "plugin",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await entry(null, "plugin_db_client");
|
||||||
|
await entry(null, "plugin_kv_get");
|
||||||
|
await entry(null, "plugin_kv_set");
|
||||||
|
await entry("user-1", "plugin_db_client");
|
||||||
|
await entry(null, "plugin_as_user");
|
||||||
|
writeCount = 0;
|
||||||
|
|
||||||
|
expect(await repo.deleteRoutinePluginNoise()).toBe(2);
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
const left = (
|
||||||
|
await repo.listPage({ filters: {}, limit: 10, offset: 0 })
|
||||||
|
).logs.map((log) => `${log.userId}:${log.action}`);
|
||||||
|
expect(left.sort()).toEqual([
|
||||||
|
"null:plugin_as_user",
|
||||||
|
"null:plugin_kv_set",
|
||||||
|
"user-1:plugin_db_client",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await repo.deleteRoutinePluginNoise()).toBe(0);
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps entries when their user is deleted, detaching instead of removing", async () => {
|
it("keeps entries when their user is deleted, detaching instead of removing", async () => {
|
||||||
const repo = await createRepository();
|
const repo = await createRepository();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ vi.mock("../../../database/db/index.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
createCurrentAuditLogRepository,
|
||||||
createCurrentSessionRepository,
|
createCurrentSessionRepository,
|
||||||
createCurrentRepositoryContext,
|
createCurrentRepositoryContext,
|
||||||
createCurrentRepositoryWriteHook,
|
createCurrentRepositoryWriteHook,
|
||||||
@@ -119,3 +120,42 @@ it("wires SQLite activity to the periodic flush and token changes to immediate p
|
|||||||
else process.env[DATABASE_DIALECT_ENV] = saved;
|
else process.env[DATABASE_DIALECT_ENV] = saved;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit rows land on every audited call; a forced save per row rewrote the
|
||||||
|
// whole SQLite file each time.
|
||||||
|
it("saves SQLite audit rows lazily instead of forcing a save per row", async () => {
|
||||||
|
const saved = process.env[DATABASE_DIALECT_ENV];
|
||||||
|
process.env[DATABASE_DIALECT_ENV] = "sqlite";
|
||||||
|
const { getDb } = await import("../../../database/db/index.js");
|
||||||
|
const { DatabaseSaveTrigger } =
|
||||||
|
await import("../../../utils/database-save-trigger.js");
|
||||||
|
const lazy = vi.spyOn(DatabaseSaveTrigger, "triggerSave").mockResolvedValue();
|
||||||
|
const force = vi.spyOn(DatabaseSaveTrigger, "forceSave").mockResolvedValue();
|
||||||
|
const query = {
|
||||||
|
insert: () => query,
|
||||||
|
values: async () => ({ changes: 1 }),
|
||||||
|
select: () => query,
|
||||||
|
from: async () => [{ count: 1 }],
|
||||||
|
};
|
||||||
|
vi.mocked(getDb).mockReturnValue(
|
||||||
|
query as unknown as ReturnType<typeof getDb>,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const repo = createCurrentAuditLogRepository();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await repo.create({
|
||||||
|
userId: null,
|
||||||
|
username: "plugin:host-metrics",
|
||||||
|
action: "plugin_db_client",
|
||||||
|
resourceType: "plugin",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(force).not.toHaveBeenCalled();
|
||||||
|
expect(lazy).toHaveBeenCalledTimes(100);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
|
||||||
|
else process.env[DATABASE_DIALECT_ENV] = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -212,6 +212,48 @@ describe("ctx.db capability", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every audit row rewrote the whole SQLite file, and pollers fetch the
|
||||||
|
// client before every query (2.9.1 wrote 100 GB in hours).
|
||||||
|
describe("ctx audit volume", () => {
|
||||||
|
it("audits the client handle once however often it is fetched", async () => {
|
||||||
|
grants.set("demo", ["db:own"]);
|
||||||
|
const { ctx } = contextFor("demo", ["db:own"]);
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) await ctx.db.client();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
auditEntries.filter((row) => row.action === "plugin_db_client"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audits kv reads once but every kv write", async () => {
|
||||||
|
grants.set("demo", ["kv:own"]);
|
||||||
|
const { ctx } = contextFor("demo", ["kv:own"]);
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) await ctx.kv.get("k");
|
||||||
|
for (let i = 0; i < 3; i++) await ctx.kv.set("k", i);
|
||||||
|
|
||||||
|
const actions = auditEntries.map((row) => row.action);
|
||||||
|
expect(actions.filter((a) => a === "plugin_kv_get")).toHaveLength(1);
|
||||||
|
expect(actions.filter((a) => a === "plugin_kv_set")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still audits a refusal after the first success", async () => {
|
||||||
|
grants.set("demo", ["db:own"]);
|
||||||
|
const { ctx } = contextFor("demo", ["db:own"]);
|
||||||
|
await ctx.db.client();
|
||||||
|
|
||||||
|
grants.set("demo", []);
|
||||||
|
invalidatePluginPermissionCache("demo");
|
||||||
|
await expect(ctx.db.client()).rejects.toThrow();
|
||||||
|
|
||||||
|
const entries = auditEntries.filter(
|
||||||
|
(row) => row.action === "plugin_db_client",
|
||||||
|
);
|
||||||
|
expect(entries.map((row) => row.success)).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ctx.kv limits", () => {
|
describe("ctx.kv limits", () => {
|
||||||
it("refuses a key over the length cap", async () => {
|
it("refuses a key over the length cap", async () => {
|
||||||
grants.set("demo", ["kv:own"]);
|
grants.set("demo", ["kv:own"]);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const deleteRoutinePluginNoise = vi.fn<() => Promise<number>>();
|
||||||
|
const forceSave = vi.fn<(reason: string) => Promise<void>>();
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentAuditLogRepository: () => ({ deleteRoutinePluginNoise }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||||
|
DatabaseSaveTrigger: { forceSave },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { runAuditNoiseCleanup } =
|
||||||
|
await import("../../upgrade/audit-noise-cleanup.js");
|
||||||
|
|
||||||
|
describe("runAuditNoiseCleanup", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
deleteRoutinePluginNoise.mockReset();
|
||||||
|
forceSave.mockReset().mockResolvedValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves once after removing entries", async () => {
|
||||||
|
deleteRoutinePluginNoise.mockResolvedValue(9000);
|
||||||
|
|
||||||
|
await expect(runAuditNoiseCleanup()).resolves.toBe(9000);
|
||||||
|
expect(forceSave).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save when there was nothing to remove", async () => {
|
||||||
|
deleteRoutinePluginNoise.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await expect(runAuditNoiseCleanup()).resolves.toBe(0);
|
||||||
|
expect(forceSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never fails the boot", async () => {
|
||||||
|
deleteRoutinePluginNoise.mockRejectedValue(new Error("locked"));
|
||||||
|
|
||||||
|
await expect(runAuditNoiseCleanup()).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,7 +71,21 @@ describe("DatabaseSaveTrigger", () => {
|
|||||||
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
|
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves within the max wait even while writes keep coming", async () => {
|
it("saves at most once per 30 seconds while writes keep coming", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
DatabaseSaveTrigger.initialize(save);
|
||||||
|
// One sample every 3 seconds used to rewrite the database after each one.
|
||||||
|
for (let tick = 0; tick < 40; tick++) {
|
||||||
|
void DatabaseSaveTrigger.triggerSave("sample");
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
}
|
||||||
|
// 120 seconds: the first save after 2s, then one per 30s window.
|
||||||
|
expect(save.mock.calls.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(save.mock.calls.length).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still saves a steady stream of writes", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const save = vi.fn().mockResolvedValue(undefined);
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
DatabaseSaveTrigger.initialize(save);
|
DatabaseSaveTrigger.initialize(save);
|
||||||
@@ -79,7 +93,38 @@ describe("DatabaseSaveTrigger", () => {
|
|||||||
void DatabaseSaveTrigger.triggerSave("sample");
|
void DatabaseSaveTrigger.triggerSave("sample");
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
}
|
}
|
||||||
|
expect(save.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(save.mock.calls.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a lone write after a quiet period within the debounce", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
DatabaseSaveTrigger.initialize(save);
|
||||||
|
void DatabaseSaveTrigger.triggerSave("first");
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
expect(save).toHaveBeenCalledTimes(1);
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
void DatabaseSaveTrigger.triggerSave("later");
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(save).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a force save be throttled", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
DatabaseSaveTrigger.initialize(save);
|
||||||
|
void DatabaseSaveTrigger.triggerSave("sample");
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
// Inside the 30s window, but a critical write saves right away.
|
||||||
|
vi.useRealTimers();
|
||||||
|
await DatabaseSaveTrigger.forceSave("user_create");
|
||||||
|
expect(save).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("queues a force save behind an in-flight save", async () => {
|
it("queues a force save behind an in-flight save", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Clears the audit rows 2.9.1 wrote on every background plugin query.
|
||||||
|
*
|
||||||
|
* They filled the capped audit log and pushed real entries out. Idempotent,
|
||||||
|
* so it runs on every boot, before plugins activate and audit again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { databaseLogger } from "../utils/logger.js";
|
||||||
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
|
import { createCurrentAuditLogRepository } from "../database/repositories/factory.js";
|
||||||
|
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||||
|
|
||||||
|
export async function runAuditNoiseCleanup(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const removed =
|
||||||
|
await createCurrentAuditLogRepository().deleteRoutinePluginNoise();
|
||||||
|
if (removed > 0) {
|
||||||
|
await DatabaseSaveTrigger.forceSave("audit_noise_cleanup");
|
||||||
|
databaseLogger.info("Removed routine plugin audit entries", {
|
||||||
|
operation: "audit_noise_cleanup",
|
||||||
|
removed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Failed to remove routine plugin audit entries", {
|
||||||
|
operation: "audit_noise_cleanup_failed",
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import { getErrorMessage } from "./error-message.js";
|
|||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
const MAX_DEBOUNCE_WAIT_MS = 30_000;
|
// Every save rewrites the whole database file, so background writes (metrics
|
||||||
|
// samples, audit rows) may only cause one save per this window.
|
||||||
|
const MIN_LAZY_SAVE_GAP_MS = 30_000;
|
||||||
|
|
||||||
export class DatabaseSaveTrigger {
|
export class DatabaseSaveTrigger {
|
||||||
private static saveFunction: (() => Promise<void>) | null = null;
|
private static saveFunction: (() => Promise<void>) | null = null;
|
||||||
@@ -11,7 +13,7 @@ export class DatabaseSaveTrigger {
|
|||||||
private static pendingSave = false;
|
private static pendingSave = false;
|
||||||
private static activeSave: Promise<void> | null = null;
|
private static activeSave: Promise<void> | null = null;
|
||||||
private static saveTimeout: NodeJS.Timeout | null = null;
|
private static saveTimeout: NodeJS.Timeout | null = null;
|
||||||
private static firstTriggerAt: number | null = null;
|
private static lastSaveAt = 0;
|
||||||
private static _dirty = false;
|
private static _dirty = false;
|
||||||
private static batch = new AsyncLocalStorage<{
|
private static batch = new AsyncLocalStorage<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -49,22 +51,16 @@ export class DatabaseSaveTrigger {
|
|||||||
|
|
||||||
this._dirty = true;
|
this._dirty = true;
|
||||||
|
|
||||||
if (this.saveTimeout) {
|
// A save already pending picks this write up too.
|
||||||
clearTimeout(this.saveTimeout);
|
if (this.saveTimeout) return;
|
||||||
}
|
|
||||||
|
|
||||||
// Each call pushes the save back, so steady writes (samples every few
|
|
||||||
// seconds) would keep it from ever running. Cap how long it can wait.
|
|
||||||
const now = Date.now();
|
|
||||||
this.firstTriggerAt ??= now;
|
|
||||||
const delay = Math.max(
|
const delay = Math.max(
|
||||||
0,
|
DEBOUNCE_MS,
|
||||||
Math.min(DEBOUNCE_MS, this.firstTriggerAt + MAX_DEBOUNCE_WAIT_MS - now),
|
this.lastSaveAt + MIN_LAZY_SAVE_GAP_MS - Date.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.saveTimeout = setTimeout(async () => {
|
this.saveTimeout = setTimeout(async () => {
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
this.firstTriggerAt = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runSave();
|
await this.runSave();
|
||||||
@@ -123,7 +119,6 @@ export class DatabaseSaveTrigger {
|
|||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
}
|
}
|
||||||
this.firstTriggerAt = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runSave();
|
await this.runSave();
|
||||||
@@ -156,6 +151,7 @@ export class DatabaseSaveTrigger {
|
|||||||
.finally(() => new Promise<void>((resolve) => setImmediate(resolve)));
|
.finally(() => new Promise<void>((resolve) => setImmediate(resolve)));
|
||||||
this.activeSave = save;
|
this.activeSave = save;
|
||||||
this.pendingSave = true;
|
this.pendingSave = true;
|
||||||
|
this.lastSaveAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await save;
|
await save;
|
||||||
@@ -184,7 +180,7 @@ export class DatabaseSaveTrigger {
|
|||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
}
|
}
|
||||||
this.firstTriggerAt = null;
|
this.lastSaveAt = 0;
|
||||||
|
|
||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
this.activeSave = null;
|
this.activeSave = null;
|
||||||
|
|||||||
Reference in new issue
Block a user