Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1884433
feat(key-analytics): scan every non-empty logical database and honor …
KIvanow Oct 15, 2026
9ea709b
fix(key-analytics): use a plain space in the dedupe identity, not a r…
KIvanow Oct 15, 2026
51bd46d
fix(key-analytics): apply code-review fixes for multi-db support (#542)
KIvanow Oct 16, 2026
b729b13
feat(key-analytics): per-db hot-key ranking and agent db attribution …
KIvanow Oct 16, 2026
96227e6
Merge remote-tracking branch 'origin/master' into feat/key-analytics-…
KIvanow Oct 16, 2026
4f020b9
fix(key-analytics): pin the summary's latest snapshot per (pattern, db)
KIvanow Oct 16, 2026
77d04f0
fix(key-analytics): make dbIndex activation resilient and share the s…
KIvanow Oct 16, 2026
f58a7b8
fix(key-analytics): select latest summary rows via a grouped join, no…
KIvanow Oct 16, 2026
16743f0
fix(key-analytics): limit pattern rows per database instead of globally
KIvanow Oct 16, 2026
1d62659
fix(key-analytics): early-terminating per-db pattern queries instead …
KIvanow Oct 16, 2026
ec76ac1
fix(key-analytics): validate dbIndex on save, close the SELECT reconn…
KIvanow Oct 16, 2026
b57e003
perf(key-analytics): enumerate populated databases with a loose index…
KIvanow Oct 16, 2026
a9b4b50
fix(key-analytics): correct multi-db Prometheus export and hot-key ra…
KIvanow Oct 16, 2026
92fc87a
fix(key-analytics): close review gaps on multi-db hot-key/label fixes
KIvanow Oct 16, 2026
a93e266
fix(key-analytics): close second-round review findings on multi-db ra…
KIvanow Oct 16, 2026
4ecb725
fix(key-analytics): correct the composite endpoint across databases
KIvanow Oct 16, 2026
f2b8f84
fix(key-analytics): per-db pinned composite reads + default read limits
KIvanow Oct 16, 2026
edb7652
fix(key-analytics): enumerate composite databases from hot_key_stats
KIvanow Oct 16, 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
7 changes: 6 additions & 1 deletion apps/api/src/common/interfaces/database-port.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ export interface DatabasePort {
getConfigValues(pattern: string): Promise<ConfigGetResponse>;
getDbSize(): Promise<number>;
getLastSaveTime(): Promise<number>;
collectKeyAnalytics(options: KeyAnalyticsOptions): Promise<KeyAnalyticsResult>;
/**
* One result per scanned logical database. Adapters that can discover
* sibling databases (via `INFO keyspace`) scan every non-empty one; others
* return a single result for the connected database.
*/
collectKeyAnalytics(options: KeyAnalyticsOptions): Promise<KeyAnalyticsResult[]>;
getVectorIndexList(): Promise<string[]>;
getVectorIndexInfo(indexName: string): Promise<VectorIndexInfo>;
getHashFieldBuffer(key: string, field: string): Promise<Buffer | null>;
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/common/interfaces/storage-port.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,12 +507,14 @@ export interface StoragePort {
startTime?: number,
endTime?: number,
connectionId?: string,
db?: number,
): Promise<KeyAnalyticsSummary | null>;
getKeyPatternTrends(
pattern: string,
startTime: number,
endTime: number,
connectionId?: string,
db?: number,
): Promise<
Array<{
timestamp: number;
Expand All @@ -522,10 +524,18 @@ export interface StoragePort {
}>
>;
pruneOldKeyPatternSnapshots(cutoffTimestamp: number, connectionId?: string): Promise<number>;
/** Distinct logical database indexes present in stored key-pattern snapshots. */
getKeyAnalyticsDatabases(connectionId?: string): Promise<number[]>;

// Hot Key Stats Methods - connectionId required for writes, optional filter for reads
saveHotKeys(entries: HotKeyEntry[], connectionId: string): Promise<number>;
getHotKeys(options?: HotKeyQueryOptions): Promise<HotKeyEntry[]>;
/**
* Distinct logical database indexes present in stored hot-key stats. Separate
* from getKeyAnalyticsDatabases because hot-key / composite rows live in
* hot_key_stats, which can diverge from key_pattern_snapshots.
*/
getHotKeyDatabases(connectionId?: string): Promise<number[]>;
pruneOldHotKeys(cutoffTimestamp: number, connectionId?: string): Promise<number>;

// Settings Methods (global, not connection-scoped)
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/config/env.schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { MAX_RETENTION_DAYS, parseRetentionDaysToken } from '@betterdb/shared';
import {
clampTopN,
DEFAULT_KEY_DETAILS_TOP_N,
DEFAULT_KEY_PATTERNS_TOP_N,
DEFAULT_SLOT_STATS_TOP_N,
Expand Down Expand Up @@ -42,7 +43,7 @@ const topNEnv = (max: number, def: number) =>
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
z.coerce
.number()
.transform((n) => (Number.isFinite(n) ? Math.min(max, Math.max(0, Math.floor(n))) : def))
.transform((n) => (Number.isFinite(n) ? clampTopN(n, max) : def))
.default(def)
.catch(def),
);
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/connections/connection-registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {
port: config.port,
username: config.username || 'default',
password: config.password || '',
dbIndex: config.dbIndex,
connectionName,
tls: config.tls,
tlsServername: config.membership?.origin === 'auto' ? config.membership.hostname : undefined,
Expand Down Expand Up @@ -645,6 +646,21 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {
throw new Error(`Connection failed: ${errorMsg}`);
}

// Validation parity with testConnection: a live connection tolerates a
// rejected SELECT by falling back to db0 (so stored configs survive an
// upgrade), but Save must not silently persist a connection that monitors
// a different database than the user asked for.
if (
adapter instanceof UnifiedDatabaseAdapter &&
(config.dbIndex ?? 0) > 0 &&
adapter.getActiveDbIndex() !== config.dbIndex
) {
await adapter.disconnect().catch(() => {});
throw new Error(
`Connection failed: could not SELECT database ${config.dbIndex} — the server rejected it (index out of range, cluster mode, or ACL without +select)`,
);
}

// Connection succeeded - now persist state
// If storage fails, disconnect the adapter to prevent leaks
try {
Expand Down Expand Up @@ -802,6 +818,7 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {
username: request.username || 'default',
password: request.password || '',
tls: request.tls,
dbIndex: request.dbIndex,
// Unique id so a test tunnel never collides with a live connection's tunnel.
connectionId: `test:${randomUUID()}`,
sshTunnel: this.sanitizeSshTunnelInput(request.sshTunnel),
Expand All @@ -810,6 +827,16 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {

try {
await adapter.connect();
// A live connection tolerates a rejected SELECT by falling back to db0
// (availability), but a TEST is validation: surface the bad index here
// so the user fixes it before saving instead of silently monitoring db0.
if ((request.dbIndex ?? 0) > 0 && adapter.getActiveDbIndex() !== request.dbIndex) {
await adapter.disconnect();
return {
success: false,
error: `Could not SELECT database ${request.dbIndex} — the server rejected it (index out of range, cluster mode, or ACL without +select)`,
};
}
const capabilities = adapter.getCapabilities();
await adapter.disconnect();

Expand Down Expand Up @@ -1152,6 +1179,11 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {

async adoptChild(id: string, seedId: string, node: DesiredNode): Promise<void> {
await this.setMembership(id, { seedId, origin: 'adopted', ...membershipFields(node) });
// The adopted connection keeps its configured dbIndex: whether SELECT on a
// non-zero db is legal on a cluster node depends on the server (illegal
// before Valkey 9, legal on Valkey 9 multi-db clusters), so the connect
// path decides at runtime — a rejected SELECT falls back to db0 with a
// warning instead of this registry guessing and clobbering the config.
}

async refreshChild(id: string, node: DesiredNode): Promise<void> {
Expand Down
Loading
Loading