| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653 |
- 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, string> = {
- [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<RedisInspectorScanResult> {
- 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<RedisInspectorRecordDetail> {
- 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<RedisInspectorRebuildResult> {
- 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<RedisInspectorKeyRebuildResult> {
- 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<RedisInspectorRebuildSupportResult> {
- 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<RedisInspectorRecordDetail> {
- 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<string, any> = { byteLen };
- const preview: Record<string, any> = {
- 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<RedisInspectorRecordDetail> {
- 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<RedisInspectorRecordDetail> {
- 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<RedisInspectorRecordDetail> {
- 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<RedisInspectorRecordDetail> {
- 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);
- }
- }
|