feat(daemon): _signals table retention policy (#152)

- Add `retention` field to SenseConfig (default 10000 max rows)
- Parse optional `retention` positive integer in nerve.yaml sense config
- Prune old _signals rows every 100 inserts for amortized performance
- Pass retention from config through sense-worker to openSenseDb
- Add unit tests for config parsing and runtime pruning
This commit is contained in:
2026-04-27 15:40:54 +08:00
parent 2fdb6a5edd
commit 63a54d4641
18 changed files with 621 additions and 80 deletions
+7 -1
View File
@@ -181,7 +181,13 @@ const e2eRootCommand = defineCommand({
function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig {
return {
senses: {
counter: { group: "e2e", throttle: null, timeout: null, gracePeriod: null },
counter: {
group: "e2e",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {
@@ -96,6 +96,7 @@ async function runTestCli(fakeHome: string, args: string[]): Promise<CliRunResul
} finally {
process.exit = origExit;
if (prevHome === undefined) {
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
@@ -38,12 +38,14 @@ describe("parseNerveConfig", () => {
throttle: 5000,
timeout: null,
gracePeriod: null,
retention: 10_000,
});
expect(result.value.senses.memory).toEqual({
group: "system",
throttle: null,
timeout: 10_000,
gracePeriod: 3000,
retention: 10_000,
});
expect(result.value.reflexes).toHaveLength(2);
expect(result.value.reflexes[0]).toEqual({
@@ -109,9 +111,24 @@ reflexes: []
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
});
});
it("parses optional retention as a positive integer", () => {
const yaml = `
senses:
cpu:
group: system
retention: 5000
reflexes: []
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.senses.cpu.retention).toBe(5000);
});
it("accepts all valid duration suffixes (s, m, h)", () => {
const yaml = `
senses:
@@ -344,6 +361,48 @@ workflows:
expect(result.error.message).toMatch(/workflow.*not supported/);
});
it("returns error when retention is zero", () => {
const yaml = `
senses:
cpu:
group: system
retention: 0
reflexes: []
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/retention.*positive integer/);
});
it("returns error when retention is not an integer", () => {
const yaml = `
senses:
cpu:
group: system
retention: 1.5
reflexes: []
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/retention.*positive integer/);
});
it("returns error when retention is not a number", () => {
const yaml = `
senses:
cpu:
group: system
retention: "5000"
reflexes: []
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/retention.*positive integer/);
});
it("returns error for invalid throttle format", () => {
const yaml = `
senses:
+5
View File
@@ -1,8 +1,13 @@
/** Default max rows kept in each sense's `_signals` SQLite table (see `retention` on `SenseConfig`). */
export const DEFAULT_SENSE_SIGNAL_RETENTION = 10_000;
export type SenseConfig = {
group: string;
throttle: number | null;
timeout: number | null;
gracePeriod: number | null;
/** Max rows to retain in `_signals`; older rows are pruned periodically after inserts. */
retention: number;
};
export type SenseReflexConfig = {
+1
View File
@@ -1,3 +1,4 @@
export { DEFAULT_SENSE_SIGNAL_RETENTION } from "./config.js";
export type {
SenseConfig,
SenseReflexConfig,
+21 -6
View File
@@ -1,11 +1,12 @@
import { parse } from "yaml";
import type {
NerveApiConfig,
NerveConfig,
ReflexConfig,
SenseConfig,
WorkflowConfig,
import {
DEFAULT_SENSE_SIGNAL_RETENTION,
type NerveApiConfig,
type NerveConfig,
type ReflexConfig,
type SenseConfig,
type WorkflowConfig,
} from "./config.js";
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
@@ -30,6 +31,16 @@ function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value);
}
function parseRetentionField(name: string, field: unknown): Result<number> {
if (field === undefined || field === null) {
return ok(DEFAULT_SENSE_SIGNAL_RETENTION);
}
if (typeof field !== "number" || !Number.isInteger(field) || field < 1) {
return err(new Error(`senses.${name}.retention: must be a positive integer`));
}
return ok(field);
}
function parseDurationField(field: unknown, label: string): Result<number | null> {
if (field === undefined || field === null) return ok(null);
if (typeof field !== "string") {
@@ -74,11 +85,15 @@ function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
if (!graceResult.ok) return graceResult;
const retentionResult = parseRetentionField(name, obj.retention);
if (!retentionResult.ok) return retentionResult;
return ok({
group: obj.group,
throttle: throttleResult.value,
timeout: timeoutResult.value,
gracePeriod: graceResult.value,
retention: retentionResult.value,
});
}
@@ -25,7 +25,13 @@ const MOCK_WORKER = join(__dir, "fixtures", "mock-worker.mjs");
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -75,9 +81,27 @@ describe("kernel integration — real child processes", () => {
it("returns correct groups and senseCount", () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-io": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"disk-io": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
});
kernel = createKernel(config, nerveRoot, {
@@ -120,8 +144,20 @@ describe("kernel integration — real child processes", () => {
it("graceful shutdown: stop() resolves after all workers exit", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
});
kernel = createKernel(config, nerveRoot, {
@@ -73,7 +73,13 @@ const { createKernel } = await import("../kernel.js");
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -104,9 +110,27 @@ describe("kernel — getHealth", () => {
it("returns correct health shape", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"disk-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
});
const kernel = createKernel(config, nerveRoot);
@@ -192,8 +216,20 @@ describe("kernel — reloadConfig", () => {
kernel.reloadConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -210,8 +246,20 @@ describe("kernel — reloadConfig", () => {
it("removes group worker when its senses are all removed", async () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -227,7 +275,13 @@ describe("kernel — reloadConfig", () => {
kernel.reloadConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -250,8 +304,20 @@ describe("kernel — reloadConfig", () => {
kernel.reloadConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"disk-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -92,7 +92,13 @@ function makeMockLogStore() {
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -135,8 +141,20 @@ describe("kernel.triggerSense()", () => {
it("sends a compute message to the worker for the correct group", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-io": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-io": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
});
@@ -165,8 +183,20 @@ describe("kernel.triggerSense()", () => {
it("sends a compute message to the correct worker when multiple senses share a group", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"disk-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
});
@@ -95,7 +95,13 @@ function makeLogStore() {
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -129,7 +135,13 @@ describe("kernel + workflowManager integration", () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: { "my-workflow": { concurrency: 2, overflow: "drop" } },
@@ -173,7 +185,13 @@ describe("kernel + workflowManager integration", () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: { "alert-workflow": { concurrency: 1, overflow: "drop" } },
@@ -222,8 +240,20 @@ describe("kernel + workflowManager integration", () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-io": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"disk-io": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: { "my-workflow": { concurrency: 1, overflow: "drop" } },
@@ -266,7 +296,13 @@ describe("kernel + workflowManager integration", () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } },
@@ -303,7 +339,13 @@ describe("kernel + workflowManager integration", () => {
const logStore = makeLogStore();
const initialConfig = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -318,7 +360,13 @@ describe("kernel + workflowManager integration", () => {
// Reload with a workflow added
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: { "new-workflow": { concurrency: 1, overflow: "drop" } },
@@ -358,7 +406,13 @@ describe("kernel + workflowManager integration", () => {
const logStore = makeLogStore();
const initialConfig = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: { "old-workflow": { concurrency: 1, overflow: "drop" } },
@@ -372,7 +426,13 @@ describe("kernel + workflowManager integration", () => {
// Reload with the workflow removed
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -418,7 +478,13 @@ describe("kernel + workflowManager integration", () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: { "shutdown-test": { concurrency: 1, overflow: "drop" } },
@@ -467,7 +533,13 @@ describe("kernel + workflowManager integration", () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: { "health-wf": { concurrency: 2, overflow: "drop" } },
+70 -10
View File
@@ -56,7 +56,13 @@ const { createLogStore } = await import("@uncaged/nerve-store");
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -87,7 +93,13 @@ describe("kernel — message routing", () => {
it("routes signal message to bus without throwing", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
});
@@ -109,7 +121,13 @@ describe("kernel — message routing", () => {
try {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
});
@@ -130,7 +148,13 @@ describe("kernel — message routing", () => {
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
});
@@ -151,7 +175,13 @@ describe("kernel — message routing", () => {
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
});
@@ -171,7 +201,13 @@ describe("kernel — message routing", () => {
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
});
@@ -204,9 +240,27 @@ describe("kernel — groupForSense mapping", () => {
it("spawns one worker per unique group", async () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-usage": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"disk-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-usage": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -223,7 +277,13 @@ describe("kernel — groupForSense mapping", () => {
it("sends compute to the correct worker on interval trigger", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: [] }],
});
@@ -26,7 +26,13 @@ describe("LogStore + ReflexScheduler integration", () => {
it("logs run_start when reflex triggers a compute", () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
workflows: {},
@@ -55,7 +61,13 @@ describe("LogStore + ReflexScheduler integration", () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
workflows: {},
@@ -87,7 +99,13 @@ describe("LogStore + ReflexScheduler integration", () => {
it("logs cannot trigger reflexes (architectural constraint)", () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
workflows: {},
@@ -22,7 +22,13 @@ const ERROR_WORKER = join(__dir, "fixtures", "error-worker.mjs");
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -147,8 +153,20 @@ describe("phase6 — reloadConfig", () => {
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -168,8 +186,20 @@ describe("phase6 — reloadConfig", () => {
it("removes group when all its senses are removed", async () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -185,7 +215,13 @@ describe("phase6 — reloadConfig", () => {
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -222,8 +258,20 @@ describe("phase6 — error isolation", () => {
it("error from one sense does not crash the worker — other senses still work", async () => {
const config: NerveConfig = {
senses: {
"good-sense": { group: "mixed", throttle: null, timeout: null, gracePeriod: null },
"bad-sense": { group: "mixed", throttle: null, timeout: null, gracePeriod: null },
"good-sense": {
group: "mixed",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"bad-sense": {
group: "mixed",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -303,9 +351,27 @@ describe("phase6 — getHealth", () => {
it("returns health snapshot with correct shape", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"disk-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
});
kernel = createKernel(config, nerveRoot, {
@@ -333,8 +399,20 @@ describe("phase6 — getHealth", () => {
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"net-rx": {
group: "network",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -7,7 +7,13 @@ import { createSignalBus } from "../signal-bus.js";
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -34,7 +40,13 @@ describe("ReflexScheduler — throttle + pending deferred trigger", () => {
const triggered: string[] = [];
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: 2000, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: 2000,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
});
@@ -63,7 +75,13 @@ describe("ReflexScheduler — throttle + pending deferred trigger", () => {
const triggered: string[] = [];
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: 2000, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: 2000,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
});
@@ -96,7 +114,13 @@ describe("ReflexScheduler — throttle + pending deferred trigger", () => {
const triggered: string[] = [];
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: 2000, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: 2000,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
});
@@ -11,9 +11,27 @@ import { createSignalBus } from "../signal-bus.js";
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"system-health": { group: "derived", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"disk-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
"system-health": {
group: "derived",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [],
workflows: {},
@@ -167,7 +185,13 @@ describe("ReflexScheduler — throttle", () => {
const triggered: string[] = [];
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: 2000, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: 2000,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
});
@@ -189,7 +213,13 @@ describe("ReflexScheduler — throttle", () => {
const triggered: string[] = [];
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: 1000, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: 1000,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
});
@@ -294,7 +324,13 @@ describe("ReflexScheduler — workflow reflexes ignored", () => {
const config: NerveConfig = {
maxRounds: 10,
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
},
},
reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["cpu-usage"] } as any],
workflows: {
@@ -131,6 +131,24 @@ describe("openSenseDb", () => {
const result = openSenseDb(dbPath, "/nonexistent/migrations");
expect(result.ok).toBe(false);
});
it("prunes _signals to retention after every 100 inserts", () => {
const dbPath = makeTempDbPath();
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
const result = openSenseDb(dbPath, migrationsDir, 5);
expect(result.ok).toBe(true);
if (!result.ok) return;
const { sqlite, persistSignal } = result.value;
for (let i = 0; i < 100; i++) {
persistSignal({ n: i });
}
const count = sqlite.prepare("SELECT COUNT(*) AS c FROM _signals").get() as { c: number };
expect(count.c).toBe(5);
sqlite.close();
});
});
// ---------------------------------------------------------------------------
+15 -1
View File
@@ -6,7 +6,7 @@ import { drizzle } from "drizzle-orm/node-sqlite";
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
import type { Result } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
import { DEFAULT_SENSE_SIGNAL_RETENTION, err, isPlainRecord, ok } from "@uncaged/nerve-core";
import type { BlobStore } from "@uncaged/nerve-store";
@@ -129,9 +129,13 @@ export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Resu
* Open (or create) the SQLite file at `dbPath`, run all migrations in
* `migrationsDir`, and wrap with Drizzle ORM.
*/
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
const SIGNAL_INSERTS_PER_PRUNE = 100;
export function openSenseDb(
dbPath: string,
migrationsDir: string,
retention: number = DEFAULT_SENSE_SIGNAL_RETENTION,
): Result<{ sqlite: DatabaseSync; db: DrizzleDB; persistSignal: (payload: unknown) => void }> {
let sqlite: DatabaseSync;
@@ -157,10 +161,20 @@ export function openSenseDb(
);
const insertStmt = sqlite.prepare("INSERT INTO _signals (payload, timestamp) VALUES (?, ?)");
const pruneStmt = sqlite.prepare(
"DELETE FROM _signals WHERE id NOT IN (SELECT id FROM _signals ORDER BY id DESC LIMIT ?)",
);
let insertsSincePrune = 0;
function persistSignal(payload: unknown): void {
const json = JSON.stringify(payload);
insertStmt.run(json, Date.now());
insertsSincePrune += 1;
if (insertsSincePrune >= SIGNAL_INSERTS_PER_PRUNE) {
insertsSincePrune = 0;
pruneStmt.run(retention);
}
}
// Drizzle infers a schema-specific DB type; senses are schema-agnostic at this layer.
+4 -2
View File
@@ -75,12 +75,13 @@ function readConfig(nerveRoot: string): NerveConfig {
async function initSense(
nerveRoot: string,
senseName: string,
retention: number,
): Promise<{ db: DrizzleDB; runtime: SenseRuntime }> {
const dbPath = join(nerveRoot, "data", "senses", `${senseName}.db`);
const migrationsDir = join(nerveRoot, "senses", senseName, "migrations");
const senseIndexPath = resolve(join(nerveRoot, "senses", senseName, "index.js"));
const dbResult = openSenseDb(dbPath, migrationsDir);
const dbResult = openSenseDb(dbPath, migrationsDir, retention);
if (!dbResult.ok) {
throw new Error(`Failed to init DB for "${senseName}": ${dbResult.error.message}`);
}
@@ -276,7 +277,8 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
for (const senseName of groupSenses) {
try {
const { db, runtime } = await initSense(nerveRoot, senseName);
const retention = config.senses[senseName].retention;
const { db, runtime } = await initSense(nerveRoot, senseName, retention);
ownDbs.set(senseName, db);
runtimes.set(senseName, runtime);
} catch (e: unknown) {