|
|
@@ -0,0 +1,358 @@
|
|
|
+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,
|
|
|
+ RedisInspectorScanResult,
|
|
|
+} from './types/redis-inspector.types';
|
|
|
+import {
|
|
|
+ getRedisCacheRegistry,
|
|
|
+ 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';
|
|
|
+
|
|
|
+const GROUP_PATTERNS: Record<RedisInspectorGroupCode, string> = {
|
|
|
+ [RedisInspectorGroupCode.VIDEO]: 'box:app:video*',
|
|
|
+ [RedisInspectorGroupCode.ADS]: 'box:app:ads*',
|
|
|
+ [RedisInspectorGroupCode.MOVIE]: 'box:app:movie*',
|
|
|
+};
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class RedisInspectorService {
|
|
|
+ private readonly maxPreviewBytes = 32 * 1024;
|
|
|
+ private readonly cacheRegistry: RedisCacheRebuildHandler[];
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private readonly redisService: RedisService,
|
|
|
+ latestVideosCacheBuilder: LatestVideosCacheBuilder,
|
|
|
+ recommendedVideosCacheBuilder: RecommendedVideosCacheBuilder,
|
|
|
+ ) {
|
|
|
+ this.cacheRegistry = getRedisCacheRegistry({
|
|
|
+ latestVideosCacheBuilder,
|
|
|
+ recommendedVideosCacheBuilder,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async scan(dto: ScanRedisKeysDto): Promise<RedisInspectorScanResult> {
|
|
|
+ const cursor = this.normalizeCursor(dto.cursor);
|
|
|
+ const count = this.normalizePageSize(dto.pageSize);
|
|
|
+ const matchPattern = this.buildMatchPattern(dto);
|
|
|
+ const [cursorNext, keys] = await this.redisService.scan(
|
|
|
+ cursor,
|
|
|
+ 'MATCH',
|
|
|
+ matchPattern,
|
|
|
+ 'COUNT',
|
|
|
+ count,
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!keys?.length) {
|
|
|
+ return { cursorNext, 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,
|
|
|
+ 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,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private async inspectString(
|
|
|
+ key: string,
|
|
|
+ ttlSec: number,
|
|
|
+ ): Promise<RedisInspectorRecordDetail> {
|
|
|
+ const byteLen = await this.redisService.strLen(key);
|
|
|
+ const rawValue = (await this.redisService.get(key)) ?? '';
|
|
|
+ const { text, truncated } = this.prepareStringPreview(rawValue);
|
|
|
+ const preview: Record<string, any> = {
|
|
|
+ format: 'text',
|
|
|
+ data: text,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (!truncated && text) {
|
|
|
+ const parsed = this.tryParseJson(text);
|
|
|
+ if (parsed !== undefined) {
|
|
|
+ preview.format = 'json';
|
|
|
+ preview.data = parsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (truncated) {
|
|
|
+ preview.truncated = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ key,
|
|
|
+ type: 'string',
|
|
|
+ ttlSec,
|
|
|
+ meta: { byteLen },
|
|
|
+ preview,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+}
|