Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/daemon/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,7 +921,7 @@ export async function createDaemonApp(options: CreateDaemonAppOptions): Promise<
SKILL_VALIDATE,
createSkillValidateHandler(skillsManager, db.appMcpServers)
);
jobProcessor.register(JOB_QUEUE_CLEANUP, createCleanupHandler(jobQueue));
jobProcessor.register(JOB_QUEUE_CLEANUP, createCleanupHandler(jobQueue, db.getDatabase()));
jobProcessor.register(
MEMORY_CONSOLIDATION,
createMemoryConsolidationHandler(db.agentMemory, jobQueue)
Expand Down
37 changes: 34 additions & 3 deletions packages/daemon/src/lib/job-handlers/cleanup.handler.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
import type { Database as BunDatabase } from 'bun:sqlite';
import type { Job, JobQueueRepository } from '../../storage/repositories/job-queue-repository';
import { JOB_QUEUE_CLEANUP } from '../job-queue-constants';
import { Logger } from '../logger';
import {
loadRetentionConfig,
type RetentionConfig,
type RetentionStats,
runRetention,
} from './retention';

const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const NEXT_RUN_DELAY_MS = 24 * 60 * 60 * 1000; // 24 hours

export function createCleanupHandler(jobQueue: JobQueueRepository) {
return async (_job: Job): Promise<{ deletedJobs: number; nextRunAt: number }> => {
const logger = new Logger('Cleanup');

/**
* Build the daily cleanup job handler.
*
* Two responsibilities, both self-perpetuating on a 24h cadence:
* 1. Reap terminal job_queue rows older than 7 days (pre-existing).
* 2. Run retention sweeps (events / audit / goal events) + reclaim freed pages
* via incremental_vacuum. Retention deletion is OFF by default; see
* `loadRetentionConfig`. `retentionConfig` is optional and mainly for tests —
* production reads env vars per run so operators can tune TTLs live.
*/
export function createCleanupHandler(
jobQueue: JobQueueRepository,
db: BunDatabase,
retentionConfig?: RetentionConfig
) {
return async (
_job: Job
): Promise<{ deletedJobs: number; retention: RetentionStats; nextRunAt: number }> => {
const deletedJobs = jobQueue.cleanup(Date.now() - DEFAULT_MAX_AGE_MS);

const retention = runRetention(db, retentionConfig ?? loadRetentionConfig());
logger.info(
`retention sweep: external=${retention.externalEvents} deliveries=${retention.deliveries} github=${retention.githubEvents} mcpAudit=${retention.mcpAudit} goalEvents=${retention.goalEvents} vacuumedPages=${retention.vacuumedPages}`
);

const nextRunAt = Date.now() + NEXT_RUN_DELAY_MS;

// Self-schedule: only enqueue next cleanup if none is already pending
Expand All @@ -16,6 +47,6 @@ export function createCleanupHandler(jobQueue: JobQueueRepository) {
jobQueue.enqueue({ queue: JOB_QUEUE_CLEANUP, payload: {}, runAt: nextRunAt });
}

return { deletedJobs, nextRunAt };
return { deletedJobs, retention, nextRunAt };
};
}
233 changes: 233 additions & 0 deletions packages/daemon/src/lib/job-handlers/retention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* Retention sweeps for append-heavy / event-log tables.
*
* The daemon DB grows monotonically: external/github events, the MCP audit log
* and space goal events are written continuously and never reaped. Over time the
* file outgrows the OS page cache and scans hit cold disk. This module prunes
* rows that have reached a terminal state and are older than a configurable TTL,
* then reclaims the freed pages via `incremental_vacuum`.
*
* Policy:
* - Deletion is OFF by default (`HYPERNEO_RETENTION_ENABLED=1` to activate) and
* each TTL is independently configurable. Default windows only matter once
* enabled — they are intentionally conservative.
* - Only terminal-state event rows are pruned; in-flight states (published /
* routed / received / pending) are always kept so an active pipeline never
* loses work.
* - `incremental_vacuum(500)` runs every cycle regardless of `enabled`: it is
* space maintenance, not deletion, and reclaims pages freed by any delete
* (including the pre-existing 7-day job_queue and worktree reapers). It is a
* no-op unless the DB is in incremental-vacuum mode (see migration 170 /
* DatabaseCore's fresh-DB pragma).
*/

import type { Database as BunDatabase } from 'bun:sqlite';

const DAY_MS = 24 * 60 * 60 * 1000;

export interface RetentionConfig {
/** Master switch for deletion sweeps. Vacuum still runs when disabled. */
enabled: boolean;
/** TTL (days) for terminal external + github events and their deliveries. */
eventsDays: number;
/** TTL (days) for mcp_audit_log rows. */
mcpAuditDays: number;
/** TTL (days) for space_goal_events rows. */
goalEventsDays: number;
/** Max pages reclaimed per incremental_vacuum. 0 disables vacuum. */
vacuumPages: number;
}

export interface RetentionStats {
externalEvents: number;
deliveries: number;
githubEvents: number;
mcpAudit: number;
goalEvents: number;
vacuumedPages: number;
}

function emptyStats(): RetentionStats {
return {
externalEvents: 0,
deliveries: 0,
githubEvents: 0,
mcpAudit: 0,
goalEvents: 0,
vacuumedPages: 0,
};
}

function readEnv(name: string): string | undefined {
return process.env[`HYPERNEO_RETENTION_${name}`];
}

function envBool(value: string | undefined): boolean {
return value === '1' || value === 'true';
}

function envInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
return Math.floor(parsed);
}

/**
* Read retention configuration from `HYPERNEO_RETENTION_*` env vars.
*
* Read per-run (not cached at startup) so operators can adjust TTLs without a
* daemon restart. Defaults are conservative and only take effect once enabled.
*/
export function loadRetentionConfig(): RetentionConfig {
return {
enabled: envBool(readEnv('ENABLED')),
eventsDays: envInt(readEnv('EVENTS_DAYS'), 14),
mcpAuditDays: envInt(readEnv('MCP_AUDIT_DAYS'), 30),
goalEventsDays: envInt(readEnv('GOAL_EVENTS_DAYS'), 60),
vacuumPages: envInt(readEnv('VACUUM_PAGES'), 500),
};
}

// Terminal (resolved) states for the external-event pipeline. `published` is
// in-flight (may still be delivered) and is never pruned. Migration 124
// collapsed this enum to ('published','delivered','failed','ignored') and
// migrated the legacy `routed`/`delivery_failed`/`ambiguous` values away, so
// only these three terminal states can exist.
const EXTERNAL_EVENT_TERMINAL_STATES = ['delivered', 'failed', 'ignored'] as const;

// Deliveries: `pending` is in-flight; only resolved deliveries are pruned.
const DELIVERY_TERMINAL_STATES = ['delivered', 'failed'] as const;

// GitHub events: `received` / `routed` are in-flight; the rest are resolved.
const GITHUB_EVENT_TERMINAL_STATES = [
'processed',
'ignored',
'ambiguous',
'delivered',
'failed',
] as const;

function tableExists(db: BunDatabase, name: string): boolean {
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
}

function placeholders(count: number): string {
return Array.from({ length: count }, () => '?').join(',');
}

/**
* Count the matching rows, then delete them, returning the pre-delete count.
*
* We count first because `DELETE ... .changes` includes rows removed by FK
* CASCADE (e.g. a pruned external event pulls its deliveries), which would
* inflate the per-table stat. Each target table has a covering index for its
* retention predicate — `(state, updated_at)` on external events + deliveries,
* and the migration-171 indexes on github events / mcp_audit_log / goal events
* — so both the COUNT and DELETE are indexed range scans, not full table scans.
* The `whereClause` may carry `?` placeholders bound by `params`, used
* identically for the count and the delete.
*/
function prune(
db: BunDatabase,
table: string,
whereClause: string,
params: (string | number)[]
): number {
const count = (
db.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE ${whereClause}`).get(...params) as {
c: number;
}
).c;
if (count > 0) {
db.prepare(`DELETE FROM ${table} WHERE ${whereClause}`).run(...params);
}
return count;
}

/** Current page count of the database file. */
function pageCount(db: BunDatabase): number {
const row = db.prepare('PRAGMA page_count').get() as { page_count?: number } | null;
return Number(row?.page_count ?? 0);
}

/**
* Reclaim up to `maxPages` free pages from the end of the file.
*
* Returns the number of pages freed (page_count delta). A no-op — returns 0 —
* when the DB is not in incremental-vacuum mode (auto_vacuum != INCREMENTAL),
* which covers pre-migration existing databases.
*/
export function incrementalVacuum(db: BunDatabase, maxPages: number): number {
if (maxPages <= 0) return 0;
const mode = db.prepare('PRAGMA auto_vacuum').get() as { auto_vacuum?: number } | null;
if (Number(mode?.auto_vacuum ?? 0) !== 2) return 0; // 2 == INCREMENTAL

const before = pageCount(db);
// Pragma arguments cannot be bound; maxPages is a validated non-negative int.
db.exec(`PRAGMA incremental_vacuum(${maxPages})`);
const after = pageCount(db);
const freed = before - after;
return freed > 0 ? freed : 0;
}

/**
* Run retention sweeps for the configured tables, then reclaim freed pages.
*
* Each table is guarded by `tableExists` so this is safe to run against a DB
* that hasn't yet had a given table created (e.g. minimal test schemas, or a
* fresh DB mid-bootstrap). When `config.enabled` is false, no rows are deleted
* but `incremental_vacuum` still runs.
*/
export function runRetention(db: BunDatabase, config: RetentionConfig): RetentionStats {
const stats = emptyStats();

if (config.enabled) {
const now = Date.now();
const eventsCutoff = now - config.eventsDays * DAY_MS;
const mcpCutoff = now - config.mcpAuditDays * DAY_MS;
const goalCutoff = now - config.goalEventsDays * DAY_MS;

// Prune resolved deliveries BEFORE their events: the independent sweep reaps
// old resolved deliveries under a kept (in-flight) event, and runs with an
// accurate count. The event delete below then cascades any remaining
// deliveries of pruned events (uncounted, by design).
if (tableExists(db, 'space_external_event_deliveries')) {
stats.deliveries = prune(
db,
'space_external_event_deliveries',
`state IN (${placeholders(DELIVERY_TERMINAL_STATES.length)}) AND updated_at < ?`,
[...DELIVERY_TERMINAL_STATES, eventsCutoff]
);
}

if (tableExists(db, 'space_external_events')) {
stats.externalEvents = prune(
db,
'space_external_events',
`state IN (${placeholders(EXTERNAL_EVENT_TERMINAL_STATES.length)}) AND updated_at < ?`,
[...EXTERNAL_EVENT_TERMINAL_STATES, eventsCutoff]
);
}

if (tableExists(db, 'space_github_events')) {
stats.githubEvents = prune(
db,
'space_github_events',
`state IN (${placeholders(GITHUB_EVENT_TERMINAL_STATES.length)}) AND updated_at < ?`,
[...GITHUB_EVENT_TERMINAL_STATES, eventsCutoff]
);
}

if (tableExists(db, 'mcp_audit_log')) {
stats.mcpAudit = prune(db, 'mcp_audit_log', 'timestamp < ?', [mcpCutoff]);
}

if (tableExists(db, 'space_goal_events')) {
stats.goalEvents = prune(db, 'space_goal_events', 'created_at < ?', [goalCutoff]);
}
}

stats.vacuumedPages = incrementalVacuum(db, config.vacuumPages);
return stats;
}
18 changes: 17 additions & 1 deletion packages/daemon/src/storage/database-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,25 @@ export class DatabaseCore {
mkdirSync(dir, { recursive: true });
}

// Open database
// Open database. Detect a fresh file (didn't exist before opening) so we can
// set auto_vacuum = INCREMENTAL before any table is created — the pragma is
// only effective on an empty DB.
const dbFileExisted = existsSync(this.dbPath);
this.db = new BunDatabase(this.dbPath);

// Enable incremental auto-vacuum on FRESH databases only. auto_vacuum is a
// header flag that only takes effect when set before the first table is
// created; on an existing DB the flip is a silent no-op until a full VACUUM.
// Existing (auto_vacuum = NONE) databases are converted by the opt-in
// migration 170 (HYPERNEO_DB_VACUUM_MIGRATION) — VACUUM on a multi-GB DB is a
// long, disk-intensive operation to schedule deliberately. Once INCREMENTAL,
// the daily cleanup job runs incremental_vacuum(500) to reclaim pages freed
// by retention sweeps and normal deletes, so the file shrinks over time
// instead of growing monotonically.
if (!dbFileExisted) {
this.db.exec('PRAGMA auto_vacuum = INCREMENTAL');
}

// Enable WAL mode for better concurrency and crash recovery
// WAL mode provides:
// - Better performance for concurrent reads/writes
Expand Down
Loading
Loading