import { BadRequestException, Injectable } from '@nestjs/common'; import { RedisService } from '@box/db/redis/redis.service'; import { InspectRedisKeyDto } from './dto/inspect-redis-key.dto'; import { RebuildCacheDto } from './dto/rebuild-cache.dto'; import { RedisInspectorGroupCode, ScanRedisKeysDto, } from './dto/scan-redis-keys.dto'; import { RedisInspectorKeySummary, RedisInspectorRecordDetail, RedisInspectorRebuildResult, RedisInspectorRebuildSupportResult, RedisInspectorScanResult, RedisInspectorKeyRebuildResult, } from './types/redis-inspector.types'; import { getRedisCacheRegistry, RedisCacheCode, RedisCacheKeyAllowList, RedisCacheRebuildHandler, } from './redis-cache-registry'; import { LatestVideosCacheBuilder } from '@box/core/cache/video/latest/latest-videos-cache.builder'; import { RecommendedVideosCacheBuilder } from '@box/core/cache/video/recommended/recommended-videos-cache.builder'; import { TagCacheBuilder } from '@box/core/cache/tag/tag-cache.builder'; import { CategoryCacheBuilder } from '@box/core/cache/category/category-cache.builder'; import { ChannelCacheBuilder } from '@box/core/cache/channel/channel-cache.builder'; import { AdPoolBuilder } from '@box/core/ad/ad-pool.builder'; import { RebuildCacheByKeyDto } from './dto/rebuild-by-key.dto'; import { RebuildSupportDto } from './dto/rebuild-support.dto'; const GROUP_PATTERNS: Record = { [RedisInspectorGroupCode.CHANNEL]: 'box:app:channel*', [RedisInspectorGroupCode.CATEGORY]: 'box:app:category*', [RedisInspectorGroupCode.TAG]: 'box:app:tag*', [RedisInspectorGroupCode.VIDEO]: 'box:app:video*', [RedisInspectorGroupCode.ADS]: 'box:app:adpool*', }; const MAX_JSON_PARSE_BYTES = 2 * 1024 * 1024; @Injectable() export class RedisInspectorService { private readonly maxPreviewBytes = 32 * 1024; private readonly cacheRegistry: RedisCacheRebuildHandler[]; constructor( private readonly redisService: RedisService, latestVideosCacheBuilder: LatestVideosCacheBuilder, recommendedVideosCacheBuilder: RecommendedVideosCacheBuilder, tagCacheBuilder: TagCacheBuilder, categoryCacheBuilder: CategoryCacheBuilder, channelCacheBuilder: ChannelCacheBuilder, adPoolBuilder: AdPoolBuilder, ) { this.cacheRegistry = getRedisCacheRegistry({ latestVideosCacheBuilder, recommendedVideosCacheBuilder, tagCacheBuilder, categoryCacheBuilder, channelCacheBuilder, adPoolBuilder, }); } async scan(dto: ScanRedisKeysDto): Promise { const count = this.normalizePageSize(dto.pageSize); const matchPattern = this.buildMatchPattern(dto); let cursor = this.normalizeCursor(dto.cursor); const keys: string[] = []; do { const [cursorNext, batch] = await this.redisService.scan( cursor, 'MATCH', matchPattern, 'COUNT', count, ); if (batch?.length) { keys.push(...batch); } cursor = cursorNext; } while (cursor !== '0' && keys.length < count); if (!keys?.length) { return { cursorNext: cursor, items: [] }; } const details = await this.redisService.pipelineTypeTtl(keys); const items: RedisInspectorKeySummary[] = details.map((detail) => ({ key: detail.key, type: detail.type, ttlSec: detail.ttlSec, })); return { cursorNext: cursor, items, }; } async inspect(dto: InspectRedisKeyDto): Promise { const key = dto.key; const [type, ttlSec] = await Promise.all([ this.redisService.type(key), this.redisService.ttl(key), ]); if (type === 'none' || ttlSec === -2) { return { key, type: 'none', ttlSec: -2, meta: {}, preview: { format: 'text', data: null }, }; } const limit = this.normalizePageSize(dto.limit); const start = Math.max(0, dto.start ?? 0); const cursor = this.normalizeCursor(dto.cursor); switch (type) { case 'string': return this.inspectString(key, ttlSec); case 'list': return this.inspectList(key, ttlSec, limit, start); case 'zset': return this.inspectZset(key, ttlSec, limit, start); case 'hash': return this.inspectHash(key, ttlSec, limit, cursor); case 'set': return this.inspectSet(key, ttlSec, limit, cursor); default: return { key, type, ttlSec, meta: {}, preview: { format: 'text', data: `Unsupported type: ${type}`, }, }; } } async rebuild(dto: RebuildCacheDto): Promise { const handler = this.cacheRegistry.find( (entry) => entry.cacheCode === dto.cacheCode, ); if (!handler) { throw new BadRequestException('Unknown cacheCode'); } const result = await handler.rebuild(); return { cacheCode: dto.cacheCode, status: 'OK', rebuiltAtSec: Math.floor(Date.now() / 1000), message: result.message, affected: result.affected, }; } async rebuildByKey( dto: RebuildCacheByKeyDto, ): Promise { const key = dto.key?.trim() ?? ''; const rebuiltAtSec = Math.floor(Date.now() / 1000); if (!key || !key.startsWith('box:app:')) { return { key, status: 'NOT_SUPPORTED', rebuiltAtSec, message: 'Invalid key format', }; } const entry = RedisCacheKeyAllowList.find(({ matcher }) => matcher(key)); if (!entry) { return { key, status: 'NOT_SUPPORTED', rebuiltAtSec, message: 'Key is not mapped to a rebuildable cache', }; } const handler = this.findHandler(entry.cacheCode); if (!handler) { return { key, cacheCode: entry.cacheCode, status: 'NOT_SUPPORTED', rebuiltAtSec, message: `No rebuild handler registered for ${entry.description.toLowerCase()}`, }; } const result = await handler.rebuild(); return { key, cacheCode: entry.cacheCode, status: 'OK', rebuiltAtSec, message: result.message, affected: result.affected, }; } async rebuildSupport( dto: RebuildSupportDto, ): Promise { const key = dto.key?.trim() ?? ''; if (!key || !key.startsWith('box:app:')) { return { key, supported: false, reason: 'Invalid key format', }; } const entry = RedisCacheKeyAllowList.find(({ matcher }) => matcher(key)); if (!entry) { return { key, supported: false, reason: 'Key not in allow list', }; } const handler = this.findHandler(entry.cacheCode); if (!handler) { return { key, cacheCode: entry.cacheCode, supported: false, reason: 'No rebuild handler registered for this cacheCode', description: entry.description, }; } return { key, cacheCode: entry.cacheCode, supported: true, description: entry.description, }; } private findHandler(cacheCode: RedisCacheCode) { return this.cacheRegistry.find((entry) => entry.cacheCode === cacheCode); } private async inspectString( key: string, ttlSec: number, ): Promise { const byteLen = await this.redisService.strLen(key); const rawValue = (await this.redisService.get(key)) ?? ''; const trimmedValue = rawValue.trim(); const { text, truncated } = this.prepareStringPreview(rawValue); const meta: Record = { byteLen }; const preview: Record = { format: 'text', data: text, }; if (byteLen > MAX_JSON_PARSE_BYTES && trimmedValue.startsWith('[')) { const extracted = this.extractFirstJsonArrayElements(trimmedValue, 5); if (extracted && extracted.length) { preview.format = 'json'; preview.data = extracted.map((item) => { try { return JSON.parse(item); } catch { return item; } }); const totalItems = this.countTopLevelJsonArrayElements(trimmedValue); if (totalItems !== null) { meta.itemCount = totalItems; preview.truncated = totalItems > extracted.length; } else { preview.truncated = true; } return { key, type: 'string', ttlSec, meta, preview, }; } } const canParseJson = byteLen <= MAX_JSON_PARSE_BYTES; const candidate = canParseJson ? this.tryParseJson(rawValue) : undefined; if (candidate !== undefined && Array.isArray(candidate)) { meta.itemCount = candidate.length; preview.format = 'json'; preview.data = candidate.slice(0, 5); if (candidate.length > 5) { preview.truncated = true; } return { key, type: 'string', ttlSec, meta, preview, }; } if (!truncated && candidate !== undefined) { preview.format = 'json'; preview.data = candidate; } if (truncated) { preview.truncated = true; } return { key, type: 'string', ttlSec, meta, preview, }; } private countTopLevelJsonArrayElements(raw: string): number | null { const trimmed = raw.trim(); if (!trimmed.startsWith('[')) { return null; } let depth = 0; let inString = false; let escape = false; let seenNonWhitespace = false; let count = 0; for (let i = 1; i < trimmed.length; i++) { const char = trimmed[i]; if (inString) { if (escape) { escape = false; } else if (char === '\\') { escape = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; seenNonWhitespace = true; continue; } if (char === '{' || char === '[') { depth++; seenNonWhitespace = true; continue; } if (char === '}' || char === ']') { if (depth > 0) { depth--; continue; } } if (depth === 0 && (char === ',' || char === ']')) { if (seenNonWhitespace) { count++; seenNonWhitespace = false; } if (char === ']') { break; } continue; } if (!/\s/.test(char)) { seenNonWhitespace = true; } } return count; } private extractFirstJsonArrayElements( raw: string, maxItems: number, ): string[] | null { if (!raw || !raw.startsWith('[')) { return null; } const elements: string[] = []; let depth = 0; let inString = false; let escape = false; let elementStart: number | null = null; for (let i = 1; i < raw.length && elements.length < maxItems; i++) { const char = raw[i]; if (elementStart === null) { if (/\s/.test(char)) { continue; } if (char === ']') { break; } elementStart = i; } if (inString) { if (escape) { escape = false; } else if (char === '\\') { escape = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; continue; } if (char === '{' || char === '[') { depth++; continue; } if (char === '}' || char === ']') { if (depth > 0) { depth--; continue; } } if ((char === ',' || char === ']') && depth === 0) { const segment = raw.slice(elementStart, i).trim(); if (segment) { elements.push(segment); } elementStart = null; if (char === ']') { break; } } } return elements.length ? elements : null; } private async inspectList( key: string, ttlSec: number, limit: number, start: number, ): Promise { const length = await this.redisService.llen(key); const end = start + limit - 1; const values = await this.redisService.lrange(key, start, end); const nextStart = start + values.length; return { key, type: 'list', ttlSec, meta: { len: length }, preview: { format: 'json', data: values }, paging: { nextStart, hasMore: nextStart < length, }, }; } private async inspectZset( key: string, ttlSec: number, limit: number, start: number, ): Promise { const card = await this.redisService.zcard(key); const end = start + limit - 1; const entries = await this.redisService.zrangeWithScores(key, start, end); const nextStart = start + entries.length; return { key, type: 'zset', ttlSec, meta: { card }, preview: { format: 'json', data: entries }, paging: { nextStart, hasMore: nextStart < card, }, }; } private async inspectHash( key: string, ttlSec: number, limit: number, cursor: string, ): Promise { const length = await this.redisService.hlen(key); const [cursorNext, rawEntries] = await this.redisService.hscan( key, cursor, limit, ); const entries = this.buildHashEntries(rawEntries); return { key, type: 'hash', ttlSec, meta: { len: length }, preview: { format: 'json', data: entries }, paging: { cursorNext, hasMore: cursorNext !== '0', }, }; } private async inspectSet( key: string, ttlSec: number, limit: number, cursor: string, ): Promise { const card = await this.redisService.scard(key); const [cursorNext, members] = await this.redisService.sscan( key, cursor, limit, ); return { key, type: 'set', ttlSec, meta: { card }, preview: { format: 'json', data: members ?? [] }, paging: { cursorNext, hasMore: cursorNext !== '0', }, }; } private prepareStringPreview(value: string): { text: string; truncated: boolean; } { if (!value) return { text: '', truncated: false }; const buffer = Buffer.from(value, 'utf8'); if (buffer.length <= this.maxPreviewBytes) { return { text: value, truncated: false }; } const truncated = buffer.subarray(0, this.maxPreviewBytes).toString('utf8'); return { text: truncated, truncated: true }; } private tryParseJson(value: string): unknown | undefined { try { return JSON.parse(value); } catch { return undefined; } } private buildHashEntries(raw: string[]): Array<{ field: string; value: unknown; }> { const entries: Array<{ field: string; value: unknown }> = []; for (let i = 0; i < raw.length; i += 2) { const field = raw[i]; const value = raw[i + 1]; if (field === undefined || value === undefined) { continue; } entries.push({ field, value: this.parseHashValue(value), }); } return entries; } private parseHashValue(value: string): unknown { if (!value) return value; if (Buffer.byteLength(value, 'utf8') > this.maxPreviewBytes) { return value; } const trimmed = value.trim(); if (!this.looksLikeJson(trimmed)) { return value; } try { return JSON.parse(value); } catch { return value; } } private looksLikeJson(value: string): boolean { return ( value.startsWith('{') || value.startsWith('[') || value.startsWith('"') ); } private buildMatchPattern(dto: ScanRedisKeysDto): string { const basePattern = GROUP_PATTERNS[dto.groupCode]; const keyword = dto.keyword?.trim(); if (!keyword) { return basePattern; } const normalizedBase = basePattern.endsWith('*') ? basePattern.slice(0, -1) : basePattern; const containsWildcard = keyword.includes('*') || keyword.includes('?'); const suffix = containsWildcard ? keyword : `*${keyword}*`; return `${normalizedBase}${suffix}`; } private normalizeCursor(cursor?: string): string { const trimmed = cursor?.trim(); return trimmed && trimmed !== '' ? trimmed : '0'; } private normalizePageSize(pageSize?: number): number { const fallback = 50; if (!pageSize) return fallback; return Math.min(Math.max(pageSize, 1), 200); } }