/** * Preflight check: estimate key count, type distribution, notify-keyspace-events (SPEC_F §F.9 Step 0). */ import type { MigrationRedisClient } from './redis-client.ts'; export interface KeyspaceEventsReadResult { value: string | null; /** False when the command is disabled or renamed and the default name fails. */ available: boolean; } export interface SetKeyspaceEventsOptions { configCommand?: string; merge?: boolean; } export interface SetKeyspaceEventsResult { ok: boolean; previous: string | null; applied: string; error?: string; } export interface PreflightOptions { configCommand?: string; } export interface PreflightResult { keyCountEstimate: number; typeDistribution: Record; notifyKeyspaceEvents: string | null; configCommandAvailable: boolean; recommended: { scan_count: number; max_concurrency: number; max_rps: number }; } const KEYSPACE_PARAM = 'notify-keyspace-events'; /** * Read the current `notify-keyspace-events` value from Redis. * Uses raw sendCommand so it works even if the CONFIG command has been renamed. */ export async function readKeyspaceEvents( redisClient: MigrationRedisClient, configCommand: string = 'CONFIG' ): Promise { try { const raw = await redisClient.sendCommand([configCommand, 'GET', KEYSPACE_PARAM]); // Redis returns a flat array ['notify-keyspace-events', ''] if (Array.isArray(raw) && raw.length >= 2) { return { value: String(raw[1] ?? ''), available: true }; } // redis@4 may return an object { 'notify-keyspace-events': '' } if (raw && typeof raw === 'object' && !Array.isArray(raw)) { const v = (raw as Record)[KEYSPACE_PARAM]; return { value: typeof v === 'string' ? v : null, available: true }; } return { value: null, available: true }; } catch (_) { return { value: null, available: false }; } } /** * Set `notify-keyspace-events` to `value` on Redis. * Optionally merges the new flags into the existing value instead of overwriting. * * @param value - Flags to apply (e.g. `'Kgxe'`). */ export async function setKeyspaceEvents( redisClient: MigrationRedisClient, value: string, { configCommand = 'CONFIG', merge = true }: SetKeyspaceEventsOptions = {} ): Promise { const { value: previous, available } = await readKeyspaceEvents(redisClient, configCommand); if (!available) { return { ok: false, previous: null, applied: value, error: `CONFIG command not available (it may have been renamed). ` + `Use the configCommand option to supply the correct name, ` + `or set notify-keyspace-events manually: ${configCommand} SET ${KEYSPACE_PARAM} ${value}`, }; } const applied = merge && previous ? mergeFlags(previous, value) : value; try { await redisClient.sendCommand([configCommand, 'SET', KEYSPACE_PARAM, applied]); return { ok: true, previous, applied }; } catch (err) { return { ok: false, previous, applied, error: err instanceof Error ? err.message : String(err) }; } } /** * Merge two Redis notify-keyspace-events flag strings. * Preserves all flags from both; result is sorted for readability. */ function mergeFlags(existing: string, required: string): string { const merged = new Set([...existing, ...required]); return [...merged].sort().join(''); } export async function runPreflight( redisClient: MigrationRedisClient, { configCommand = 'CONFIG' }: PreflightOptions = {} ): Promise { let keyCountEstimate = 0; try { keyCountEstimate = await redisClient.dbSize(); } catch (_) { keyCountEstimate = 0; } const typeDistribution: Record = { string: 0, hash: 0, set: 0, list: 0, zset: 0, other: 0 }; const sampleSize = Math.min(1000, Math.max(100, Math.floor(keyCountEstimate / 10))); let cursor = 0; let sampled = 0; if (keyCountEstimate > 0) { do { const result = await redisClient.scan(cursor, { COUNT: 200 }); const keys = Array.isArray(result) ? result[1] : (result?.keys || []); cursor = Array.isArray(result) ? parseInt(result[0], 10) : (result?.cursor ?? 0); for (const key of keys) { if (sampled >= sampleSize) break; try { const type = (await redisClient.type(key)).toLowerCase(); if (typeDistribution[type] !== undefined) typeDistribution[type]++; else typeDistribution.other++; sampled++; } catch (_) {} } if (sampled >= sampleSize) break; } while (cursor !== 0); } const { value: notifyKeyspaceEvents, available: configCommandAvailable } = await readKeyspaceEvents(redisClient, configCommand); const recommended = { scan_count: 1000, max_concurrency: 32, max_rps: keyCountEstimate > 100000 ? 2000 : 1000, }; return { keyCountEstimate, typeDistribution, notifyKeyspaceEvents, configCommandAvailable, recommended, }; }