Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
0206305
Add the KV cache monitoring Pro feature and shared types
jamby77 Oct 8, 2026
baaa7c1
Store KV cache engines, samples, footprint snapshots and settings
jamby77 Oct 8, 2026
6469504
Classify LMCache keys stored in Valkey
jamby77 Oct 8, 2026
aec5db6
Estimate the LMCache footprint from scanned and sampled keys
jamby77 Oct 8, 2026
a997166
Scan and sample LMCache keys on every cluster primary
jamby77 Oct 8, 2026
4da4dab
Collect the LMCache footprint on a Pro poller
jamby77 Oct 8, 2026
9b36dfa
Serve LMCache detection and footprint history behind Pro
jamby77 Oct 8, 2026
7136aa0
Test the footprint collector against a real Valkey
jamby77 Oct 8, 2026
6756752
Start the footprint collector before the license resolves and validat…
jamby77 Oct 9, 2026
40b1181
Scale the footprint when matched keys exceed the budget
jamby77 Oct 9, 2026
256a547
Let workspace members refresh the KV cache footprint
jamby77 Oct 9, 2026
8df106c
Keep KV cache footprint refresh admin-only
jamby77 Oct 9, 2026
988c9dc
Fix KV cache orphan ratio on clusters and add back-off
jamby77 Oct 13, 2026
389598f
Clean up KV cache data on removal and skip agent connections
jamby77 Oct 20, 2026
742598e
Match LMCache tags and dtype strings in the KV cache key classifier
jamby77 Oct 20, 2026
371e4ae
Keep KV cache data for retired members
jamby77 Oct 20, 2026
4ebf687
Share planForConnection between key analytics and KV cache
jamby77 Oct 20, 2026
f7f31b6
Apply poller filters to KV cache refresh and return in-flight runs
jamby77 Oct 20, 2026
9773929
Throw when saving KV cache engine samples before Postgres is ready
jamby77 Oct 20, 2026
ca2e273
Keep KV cache TTL stats when MEMORY USAGE fails
jamby77 Oct 20, 2026
8b10359
Read INFO and CLIENT LIST alongside the KV cache scan
jamby77 Oct 20, 2026
47474ee
Classify each KV cache key once per collection
jamby77 Oct 20, 2026
7928f26
Keep readable primaries when one KV cache node read fails
jamby77 Oct 20, 2026
e7aa5c0
Keep KV cache detection and sample key on partial cluster reads
jamby77 Oct 20, 2026
2317310
Merge master into kv-cache/footprint
jamby77 Oct 21, 2026
b9a15fd
Resolve KV cache routes to the registry default connection
jamby77 Oct 21, 2026
b4c50f1
Exclude the actually selected DB from the KV cache other-DB hint
jamby77 Oct 21, 2026
debb6e9
Count layerwise LMCache chunks once instead of once per layer
jamby77 Oct 21, 2026
4c05dea
Compute the per-model KV cache footprint per node from matched keys
jamby77 Oct 21, 2026
99604cc
Count metadata keys missing their kv_bytes as orphans
jamby77 Oct 21, 2026
715dd53
Keep KV cache samples whose TTL call fails
jamby77 Oct 21, 2026
fb79ab6
Measure KV cache scan coverage from keys SCAN returned
jamby77 Oct 21, 2026
1f301fb
Reject KV cache env settings with numeric prefixes and log fallbacks
jamby77 Oct 21, 2026
cc8dfa3
Prune KV cache engine samples at the sample retention cap
jamby77 Oct 21, 2026
d10dab8
Downsample KV cache footprint history instead of truncating it
jamby77 Oct 21, 2026
432f4a5
Align KV cache engine upsert across storage adapters
jamby77 Oct 21, 2026
26c89e1
Drop the dev-only no_ttl_ratio migration from Postgres
jamby77 Oct 21, 2026
d4cadd1
Catch async connection removal listener rejections
jamby77 Oct 21, 2026
19f8fe7
Reuse downsampleSeries for KV cache key sampling
jamby77 Oct 21, 2026
e531661
Mark KV footprint incomplete when cluster primaries go unscanned
jamby77 Oct 21, 2026
6932f64
Recheck CLIENT LIST when a detected node falls below the key threshold
jamby77 Oct 21, 2026
e37b918
Cap KV_CACHE_FOOTPRINT_INTERVAL_MS at 24 hours
jamby77 Oct 21, 2026
1751381
Drop eviction baselines for nodes missing from a KV footprint run
jamby77 Oct 21, 2026
0de821e
Treat any unlimited primary as no KV footprint memory limit
jamby77 Oct 21, 2026
58434db
Resolve the KV footprint plan once and reject retired members
jamby77 Oct 21, 2026
bbf8b7f
Mark KV footprint partial for a cluster seed scanned on its own
jamby77 Oct 21, 2026
395b8cc
Drop KeySample.key in favour of KeySample.parsed.key
jamby77 Oct 21, 2026
80e840f
Report unknown KV cache bytes as null instead of zero
jamby77 Oct 21, 2026
6ae2d0b
Bucket KV footprint history in storage instead of loading every row
jamby77 Oct 21, 2026
25ff849
Break tag/suffix ties in LMCache keys using the node layout
jamby77 Oct 21, 2026
0085e37
Count tagged key pairs as two-key evidence in tag/suffix ties
jamby77 Oct 21, 2026
5d9743e
Compare scanned node ids with cluster slot owners in KV footprint
jamby77 Oct 21, 2026
65bf68f
Require safe integer timestamps in the KV footprint history range
jamby77 Oct 21, 2026
8ff963a
Assert the KV integration spec selected its test db before FLUSHDB
jamby77 Oct 21, 2026
8050218
Break KV tag/suffix ties with two-key evidence from the whole run
jamby77 Oct 21, 2026
7623bb9
Back off KV footprint on partial runs when nothing was detected
jamby77 Oct 21, 2026
8840e86
Key the KV eviction baseline by connection id instead of name
jamby77 Oct 21, 2026
3438154
Read the KV status snapshot and engines in parallel
jamby77 Oct 21, 2026
4a69dbe
Rename ConnectionRegistry.onConnectionRemoved to a distinct name
jamby77 Oct 21, 2026
990fc2a
Take single-key evidence run-wide when resolving tag suffixes
jamby77 Oct 21, 2026
a2e3dee
Clear a carried KV detection after consecutive partial misses
jamby77 Oct 21, 2026
119a4b5
Gate the KV integration spec on a dedicated port and flush only db 9
jamby77 Oct 21, 2026
6bcd3e8
Report KV primaries scanned and scale footprint by scanned slot share
jamby77 Oct 21, 2026
b01fce4
Defer KV CLIENT LIST to the run-wide count and harden footprint refresh
jamby77 Oct 21, 2026
b5c4d7c
Delete KV connection data in bounded chunks
jamby77 Oct 21, 2026
3dd3f57
Skip KV sibling probes for slots the scanned primary does not own
jamby77 Oct 21, 2026
af1b1f6
Register the KV cache footprint env vars in the env schema
jamby77 Oct 21, 2026
942da11
Skip the KV undetected back-off when slot owners were left unscanned
jamby77 Oct 21, 2026
9cca67a
Recognise LMCache multiprocess Valkey L2 adapter keys
jamby77 Oct 21, 2026
26cc059
Stub the snapshot read in the KV footprint integration spec
jamby77 Oct 21, 2026
0e451cc
Back off KV clusters that stay partial with no LMCache found
jamby77 Oct 21, 2026
66f3cbe
Match LMCache GLIDE clients by bare tag and scanned database
jamby77 Oct 21, 2026
abb14c8
Count only slot owners as scanned KV primaries
jamby77 Oct 21, 2026
fc8d0de
Match stale KV members by the parsed cluster node hostname
jamby77 Oct 21, 2026
a122519
Run the KV cache integration spec against the CI test Valkey
jamby77 Oct 21, 2026
7efdab2
Count each LMCache key once when SCAN returns it again
jamby77 Oct 21, 2026
4c841c3
Scan a cluster node once when two connections reach it
jamby77 Oct 21, 2026
c0aa36e
Leave the estimate unscaled when a scanned node owns no known slots
jamby77 Oct 21, 2026
cf4d63b
Skip key sampling on a replica seed instead of collecting MOVED replies
jamby77 Oct 21, 2026
3efe4b9
Store the interval each KV cache eviction delta covers
jamby77 Oct 21, 2026
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: 2 additions & 0 deletions .github/workflows/api-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ jobs:

- name: Run API tests
working-directory: apps/api
env:
KV_CACHE_IT_VALKEY_PORT: '6390'
run: pnpm test

- name: Run entitlement unit tests
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/__tests__/kv-cache-feature.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Feature, TIER_FEATURES, Tier, DEFAULT_KV_CACHE_HIT_RATE_THRESHOLD } from '@betterdb/shared';

describe('kv cache feature', () => {
it('is a Pro feature', () => {
expect(Feature.KV_CACHE_MONITORING).toBe('kvCacheMonitoring');
expect(TIER_FEATURES[Tier.pro]).toContain(Feature.KV_CACHE_MONITORING);
expect(TIER_FEATURES[Tier.community]).not.toContain(Feature.KV_CACHE_MONITORING);
});

it('defaults the hit rate threshold to 0.2', () => {
expect(DEFAULT_KV_CACHE_HIT_RATE_THRESHOLD).toBe(0.2);
});
});
10 changes: 10 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ let LatencyRegressionModule: any = null;
let WebhookProModule: any = null;
let InferenceLatencyProModule: any = null;
let ScalingReadinessProModule: any = null;
let KvCacheModule: any = null;
let CacheProposalsModule: any = null;
let MemoryProposalsModule: any = null;
let AgentModule: any = null;
Expand Down Expand Up @@ -126,6 +127,14 @@ try {
// Proprietary module not available
}

try {
const kvCacheModule = require('../../../proprietary/kv-cache/kv-cache.module');
KvCacheModule = kvCacheModule.KvCacheModule;
console.log('[KvCache] Proprietary module loaded');
} catch {
// Proprietary module not available
}

try {
const cacheProposalsModule = require('../../../proprietary/cache-proposals/cache-proposals.module');
CacheProposalsModule = cacheProposalsModule.CacheProposalsModule;
Expand Down Expand Up @@ -241,6 +250,7 @@ const proprietaryImports = [
WebhookProModule,
InferenceLatencyProModule,
ScalingReadinessProModule,
KvCacheModule,
CacheProposalsModule,
MemoryProposalsModule,
AiModule,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/common/interfaces/database-port.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface DatabasePort {
disconnect(): Promise<void>;
isConnected(): boolean;
sampleVersion?(): number | null;
getActiveDbIndex?(): number;
ping(): Promise<boolean>;
getInfo(sections?: string[]): Promise<Record<string, unknown>>;
getCapabilities(): DatabaseCapabilities;
Expand Down
48 changes: 48 additions & 0 deletions apps/api/src/common/interfaces/storage-port.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import type {
ReadinessDimension,
ReadinessDimensionKey,
ScalingReadinessSettings,
KvCacheEngineSample,
KvCacheEngineSource,
KvCacheFootprintSnapshot,
KvCacheSettings,
} from '@betterdb/shared';
export type { MetricForecastSettings, MetricKind } from '@betterdb/shared';
export type { StoredCveDataset, CveScanResult } from '@betterdb/shared';
Expand Down Expand Up @@ -403,6 +407,37 @@ export interface ScalingReadinessScoreQuery {
limit?: number;
}

export interface StoredKvCacheEngine {
id: string;
connectionId: string;
name: string;
source: KvCacheEngineSource;
scrapeUrl: string | null;
scrapeAuthHeader: string | null;
scrapeAuthEncrypted: boolean;
otlpEngineId: string | null;
enabled: boolean;
createdAt: number;
lastSeenAt: number | null;
lastError: string | null;
}

export interface KvCacheRangeQuery {
connectionId: string;
from?: number;
to?: number;
limit?: number;
}

export interface KvCacheFootprintQuery extends KvCacheRangeQuery {
bucketMs?: number;
}

export interface KvCacheSampleQuery extends KvCacheRangeQuery {
engineId?: string;
modelName?: string;
}

export interface MemorySnapshotQueryOptions {
connectionId?: string;
startTime?: number;
Expand Down Expand Up @@ -615,6 +650,19 @@ export interface StoragePort {
pruneOldScalingReadinessScores(cutoffTimestamp: number, connectionId?: string): Promise<number>;
getScalingReadinessSettings(connectionId: string): Promise<ScalingReadinessSettings | null>;
saveScalingReadinessSettings(settings: ScalingReadinessSettings): Promise<ScalingReadinessSettings>;
saveKvCacheFootprintSnapshot(snapshot: KvCacheFootprintSnapshot): Promise<void>;
getKvCacheFootprintSnapshots(query: KvCacheFootprintQuery): Promise<KvCacheFootprintSnapshot[]>;
pruneOldKvCacheFootprintSnapshots(cutoffTimestamp: number, connectionId?: string): Promise<number>;
saveKvCacheEngineSamples(samples: KvCacheEngineSample[]): Promise<void>;
getKvCacheEngineSamples(query: KvCacheSampleQuery): Promise<KvCacheEngineSample[]>;
pruneOldKvCacheEngineSamples(cutoffTimestamp: number, connectionId?: string): Promise<number>;
saveKvCacheEngine(engine: StoredKvCacheEngine): Promise<StoredKvCacheEngine>;
getKvCacheEngines(connectionId?: string): Promise<StoredKvCacheEngine[]>;
getKvCacheEngine(id: string): Promise<StoredKvCacheEngine | null>;
deleteKvCacheEngine(id: string): Promise<boolean>;
getKvCacheSettings(connectionId: string): Promise<KvCacheSettings | null>;
saveKvCacheSettings(settings: KvCacheSettings): Promise<KvCacheSettings>;
deleteKvCacheConnectionData(connectionId: string): Promise<void>;

// Command Stats Sample Methods - connectionId required for writes
saveCommandStatsSamples(
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/config/env.schema.kv-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { envSchema } from './env.schema';

const KV_CACHE_FOOTPRINT_VARS = [
'KV_CACHE_FOOTPRINT_INTERVAL_MS',
'KV_CACHE_SCAN_MAX_KEYS',
'KV_CACHE_MATCH_MAX_KEYS',
'KV_CACHE_SAMPLE_KEYS',
'KV_CACHE_UNDETECTED_RECHECK_MS',
] as const;

describe('KV cache footprint env vars', () => {
it.each(KV_CACHE_FOOTPRINT_VARS)('registers %s', (name) => {
expect((envSchema.parse({ [name]: '600000' }) as Record<string, unknown>)[name]).toBe('600000');
});

it.each(KV_CACHE_FOOTPRINT_VARS)('leaves an invalid %s to the service fallback instead of failing startup', (name) => {
expect(envSchema.safeParse({ [name]: 'abc' }).success).toBe(true);
});
});
6 changes: 6 additions & 0 deletions apps/api/src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export const envSchema = z
ANOMALY_CACHE_TTL_MS: z.coerce.number().int().min(1000).default(3600000),
ANOMALY_PROMETHEUS_INTERVAL_MS: z.coerce.number().int().min(1000).default(30000),

KV_CACHE_FOOTPRINT_INTERVAL_MS: z.string().optional(),
KV_CACHE_SCAN_MAX_KEYS: z.string().optional(),
KV_CACHE_MATCH_MAX_KEYS: z.string().optional(),
KV_CACHE_SAMPLE_KEYS: z.string().optional(),
KV_CACHE_UNDETECTED_RECHECK_MS: z.string().optional(),

// License configuration (optional)
BETTERDB_LICENSE_KEY: z.string().optional(),
// Offline (air-gapped) license: the signed token itself, or a path to it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -635,4 +635,35 @@ describe('ConnectionRegistry membership', () => {
await registry.retireChild('adopted');
expect(registry.getDefaultId()).toBe('seed');
});

it('notifies removal listeners after a connection and its auto children are deleted', async () => {
const { registry, storage } = build();
put(registry, seed);
put(registry, { id: 'child', membership: { seedId: 'seed', nodeId: 'c', origin: 'auto', source: 'cluster' } });
const removed: string[] = [];
registry.addConnectionRemovalListener((id) => {
expect(storage.deleteConnection).toHaveBeenCalledWith(id);
removed.push(id);
});
const unsubscribe = registry.addConnectionRemovalListener(() => {
throw new Error('boom');
});
await registry.removeConnection('seed');
expect(removed).toEqual(['child', 'seed']);
unsubscribe();
});

it('logs a removal listener that rejects asynchronously', async () => {
const { registry } = build();
put(registry, seed);
const warn = jest
.spyOn((registry as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn')
.mockImplementation(() => undefined);
registry.addConnectionRemovalListener(async () => {
throw new Error('async boom');
});
await registry.removeConnection('seed');
await new Promise((resolve) => setImmediate(resolve));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async boom'));
});
});
25 changes: 25 additions & 0 deletions apps/api/src/connections/connection-registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { ENV_DEFAULT_ID } from './connection.constants';

export const CHILD_CONNECT_TIMEOUT_MS = 10_000;

export type ConnectionRemovalListener = (id: string) => void | Promise<void>;

function certificateHint(errorMsg: string, node: DesiredNode): string {
if (node.hostname !== undefined || !/altnames|ERR_TLS_CERT_ALTNAME_INVALID/i.test(errorMsg)) {
return '';
Expand Down Expand Up @@ -91,6 +93,7 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {
private readonly encryption: EnvelopeEncryptionService | null;
private startupConnectionErrors: Array<{ name: string; host: string; port: number; error: string }> = [];
private readonly seedLocks = new Map<string, Promise<unknown>>();
private readonly removalListeners = new Set<ConnectionRemovalListener>();

constructor(
@Inject('STORAGE_CLIENT') private readonly storage: StoragePort,
Expand Down Expand Up @@ -709,6 +712,27 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {
this.discoveredInstances?.forgetPushed(discoveredHost, discoveredPort, Date.now());
}

addConnectionRemovalListener(listener: ConnectionRemovalListener): () => void {
this.removalListeners.add(listener);
return () => {
this.removalListeners.delete(listener);
};
}

private notifyRemoved(id: string): void {
const report = (error: unknown) => {
const reason = error instanceof Error ? error.message : String(error);
this.logger.warn(`Connection removal listener failed for ${id}: ${reason}`);
};
for (const listener of this.removalListeners) {
try {
void Promise.resolve(listener(id)).catch(report);
} catch (error: unknown) {
report(error);
}
}
}

async removeConnection(id: string): Promise<void> {
if (id === ENV_DEFAULT_ID) {
throw new Error('Cannot remove the default environment connection');
Expand Down Expand Up @@ -751,6 +775,7 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {
this.runtimeCapabilityTracker.removeConnection(id);
this.externalMetricsStore.clear(id);
await this.storage.deleteConnection(id);
this.notifyRemoved(id);

if (removedConfig) {
this.usageTelemetry?.trackDbRemove({
Expand Down
14 changes: 3 additions & 11 deletions apps/api/src/database/adapters/unified.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '../../common/interfaces/database-port.interface';
import { InfoParser } from '../parsers/info.parser';
import { MetricsParser } from '../parsers/metrics.parser';
import { parseKeyspaceDatabases, type KeyspaceDatabase } from '../parsers/keyspace-databases';
import { CLUSTER_TOTAL_SLOTS } from '../../common/constants/cluster.constants';
import {
InfoResponse,
Expand Down Expand Up @@ -884,22 +885,13 @@ export class UnifiedDatabaseAdapter implements DatabasePort {
* proxies that hide it), in which case the caller falls back to scanning
* the connection's own database.
*/
private async listKeyspaceDatabases(): Promise<Array<{ db: number; keys: number }>> {
private async listKeyspaceDatabases(): Promise<KeyspaceDatabase[]> {
try {
// Reuse the centralized INFO keyspace parser (MetricsParser via
// getInfoParsed) rather than a second regex: it is the single definition
// of a db entry and already drops malformed `db<N>:` lines as raw strings.
const { keyspace } = await this.getInfoParsed(['keyspace']);
const dbs: Array<{ db: number; keys: number }> = [];
for (const [key, info] of Object.entries(keyspace ?? {})) {
const match = /^db(\d+)$/.exec(key);
// A raw-string value is a malformed line the parser kept verbatim; only
// typed entries with a positive key count are non-empty databases.
if (match && typeof info === 'object' && info.keys > 0) {
dbs.push({ db: parseInt(match[1], 10), keys: info.keys });
}
}
return dbs.sort((a, b) => a.db - b.db);
return parseKeyspaceDatabases(keyspace);
} catch (error) {
this.logger.debug(
`Could not list keyspace databases: ${error instanceof Error ? error.message : error}`,
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/database/parsers/keyspace-databases.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { parseKeyspaceDatabases } from './keyspace-databases';

describe('parseKeyspaceDatabases', () => {
it('lists non-empty databases sorted by index', () => {
expect(
parseKeyspaceDatabases({
db3: { keys: 2, expires: 0, avg_ttl: 0 },
db0: { keys: 5, expires: 0, avg_ttl: 0 },
db4: { keys: 0, expires: 0, avg_ttl: 0 },
junk: 'x',
db7: 'malformed',
}),
).toEqual([
{ db: 0, keys: 5 },
{ db: 3, keys: 2 },
]);
});

it('returns an empty list without a keyspace section', () => {
expect(parseKeyspaceDatabases(undefined)).toEqual([]);
});
});
17 changes: 17 additions & 0 deletions apps/api/src/database/parsers/keyspace-databases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { KeyspaceInfo } from '../../common/types/metrics.types';

export interface KeyspaceDatabase {
db: number;
keys: number;
}

export function parseKeyspaceDatabases(keyspace: KeyspaceInfo | undefined): KeyspaceDatabase[] {
const dbs: KeyspaceDatabase[] = [];
for (const [key, info] of Object.entries(keyspace ?? {})) {
const match = /^db(\d+)$/.exec(key);
if (match && typeof info === 'object' && info.keys > 0) {
dbs.push({ db: parseInt(match[1], 10), keys: info.keys });
}
}
return dbs.sort((a, b) => a.db - b.db);
}
11 changes: 11 additions & 0 deletions apps/api/src/retention/__tests__/local-retention.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ describe('LocalRetentionService', () => {
pruneOldLatencyHistograms: jest.fn().mockResolvedValue(0),
pruneOldMemorySnapshots: jest.fn().mockResolvedValue(0),
pruneOldScalingReadinessScores: jest.fn().mockResolvedValue(3),
pruneOldKvCacheFootprintSnapshots: jest.fn().mockResolvedValue(4),
pruneOldKvCacheEngineSamples: jest.fn().mockResolvedValue(5),
pruneOldCaptureChunks: jest.fn().mockResolvedValue(0),
pruneOldCaptureSessions: jest.fn().mockResolvedValue(0),
pruneOldCaptureTriggers: jest.fn().mockResolvedValue(0),
Expand Down Expand Up @@ -78,6 +80,15 @@ describe('LocalRetentionService', () => {
expect(results.scaling_readiness_scores).toBe(3);
});

it('reports pruned kv cache rows under their own names', async () => {
const results = await runRetentionSweep(storage, 1_234, { error: jest.fn() } as any);

expect(storage.pruneOldKvCacheFootprintSnapshots).toHaveBeenCalledWith(1_234);
expect(storage.pruneOldKvCacheEngineSamples).toHaveBeenCalledWith(1_234);
expect(results.kv_cache_footprint_snapshots).toBe(4);
expect(results.kv_cache_engine_samples).toBe(5);
});

it('does nothing in cloud mode even when a window is configured', async () => {
process.env.CLOUD_MODE = 'true';
await makeService(30).runSweep();
Expand Down
8 changes: 5 additions & 3 deletions apps/api/src/retention/retention-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { StoragePort } from '../common/interfaces/storage-port.interface';
* self-hosted local sweep so the two never drift on which tables are covered.
*
* `sampleCutoff` applies to the high-volume sample stores (command/latency
* stats samples, vector index snapshots), which in cloud keep a tighter 7-day
* cap than the tier's analytics window. Self-hosted passes one cutoff for
* everything, so it defaults to `cutoff`.
* stats samples, vector index snapshots, KV cache engine samples), which in
* cloud keep a tighter 7-day cap than the tier's analytics window.
* Self-hosted passes one cutoff for everything, so it defaults to `cutoff`.
*
* A store that fails to prune is reported as -1 and does not stop the sweep.
*/
Expand All @@ -33,6 +33,8 @@ export async function runRetentionSweep(
{ name: 'latency_histograms', fn: () => storage.pruneOldLatencyHistograms(cutoff) },
{ name: 'memory_snapshots', fn: () => storage.pruneOldMemorySnapshots(cutoff) },
{ name: 'scaling_readiness_scores', fn: () => storage.pruneOldScalingReadinessScores(cutoff) },
{ name: 'kv_cache_footprint_snapshots', fn: () => storage.pruneOldKvCacheFootprintSnapshots(cutoff) },
{ name: 'kv_cache_engine_samples', fn: () => storage.pruneOldKvCacheEngineSamples(sampleCutoff) },
{ name: 'capture_chunks', fn: () => storage.pruneOldCaptureChunks(cutoff) },
{ name: 'capture_sessions', fn: () => storage.pruneOldCaptureSessions(cutoff) },
{ name: 'capture_triggers', fn: () => storage.pruneOldCaptureTriggers(cutoff) },
Expand Down
Loading
Loading