redis-inspector.service.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. import { BadRequestException, Injectable } from '@nestjs/common';
  2. import { RedisService } from '@box/db/redis/redis.service';
  3. import { InspectRedisKeyDto } from './dto/inspect-redis-key.dto';
  4. import { RebuildCacheDto } from './dto/rebuild-cache.dto';
  5. import {
  6. RedisInspectorGroupCode,
  7. ScanRedisKeysDto,
  8. } from './dto/scan-redis-keys.dto';
  9. import {
  10. RedisInspectorKeySummary,
  11. RedisInspectorRecordDetail,
  12. RedisInspectorRebuildResult,
  13. RedisInspectorRebuildSupportResult,
  14. RedisInspectorScanResult,
  15. RedisInspectorKeyRebuildResult,
  16. } from './types/redis-inspector.types';
  17. import {
  18. getRedisCacheRegistry,
  19. RedisCacheCode,
  20. RedisCacheKeyAllowList,
  21. RedisCacheRebuildHandler,
  22. } from './redis-cache-registry';
  23. import { LatestVideosCacheBuilder } from '@box/core/cache/video/latest/latest-videos-cache.builder';
  24. import { RecommendedVideosCacheBuilder } from '@box/core/cache/video/recommended/recommended-videos-cache.builder';
  25. import { TagCacheBuilder } from '@box/core/cache/tag/tag-cache.builder';
  26. import { CategoryCacheBuilder } from '@box/core/cache/category/category-cache.builder';
  27. import { ChannelCacheBuilder } from '@box/core/cache/channel/channel-cache.builder';
  28. import { AdPoolBuilder } from '@box/core/ad/ad-pool.builder';
  29. import { RebuildCacheByKeyDto } from './dto/rebuild-by-key.dto';
  30. import { RebuildSupportDto } from './dto/rebuild-support.dto';
  31. const GROUP_PATTERNS: Record<RedisInspectorGroupCode, string> = {
  32. [RedisInspectorGroupCode.CHANNEL]: 'box:app:channel*',
  33. [RedisInspectorGroupCode.CATEGORY]: 'box:app:category*',
  34. [RedisInspectorGroupCode.TAG]: 'box:app:tag*',
  35. [RedisInspectorGroupCode.VIDEO]: 'box:app:video*',
  36. [RedisInspectorGroupCode.ADS]: 'box:app:adpool*',
  37. };
  38. const MAX_JSON_PARSE_BYTES = 2 * 1024 * 1024;
  39. @Injectable()
  40. export class RedisInspectorService {
  41. private readonly maxPreviewBytes = 32 * 1024;
  42. private readonly cacheRegistry: RedisCacheRebuildHandler[];
  43. constructor(
  44. private readonly redisService: RedisService,
  45. latestVideosCacheBuilder: LatestVideosCacheBuilder,
  46. recommendedVideosCacheBuilder: RecommendedVideosCacheBuilder,
  47. tagCacheBuilder: TagCacheBuilder,
  48. categoryCacheBuilder: CategoryCacheBuilder,
  49. channelCacheBuilder: ChannelCacheBuilder,
  50. adPoolBuilder: AdPoolBuilder,
  51. ) {
  52. this.cacheRegistry = getRedisCacheRegistry({
  53. latestVideosCacheBuilder,
  54. recommendedVideosCacheBuilder,
  55. tagCacheBuilder,
  56. categoryCacheBuilder,
  57. channelCacheBuilder,
  58. adPoolBuilder,
  59. });
  60. }
  61. async scan(dto: ScanRedisKeysDto): Promise<RedisInspectorScanResult> {
  62. const count = this.normalizePageSize(dto.pageSize);
  63. const matchPattern = this.buildMatchPattern(dto);
  64. let cursor = this.normalizeCursor(dto.cursor);
  65. const keys: string[] = [];
  66. do {
  67. const [cursorNext, batch] = await this.redisService.scan(
  68. cursor,
  69. 'MATCH',
  70. matchPattern,
  71. 'COUNT',
  72. count,
  73. );
  74. if (batch?.length) {
  75. keys.push(...batch);
  76. }
  77. cursor = cursorNext;
  78. } while (cursor !== '0' && keys.length < count);
  79. if (!keys?.length) {
  80. return { cursorNext: cursor, items: [] };
  81. }
  82. const details = await this.redisService.pipelineTypeTtl(keys);
  83. const items: RedisInspectorKeySummary[] = details.map((detail) => ({
  84. key: detail.key,
  85. type: detail.type,
  86. ttlSec: detail.ttlSec,
  87. }));
  88. return {
  89. cursorNext: cursor,
  90. items,
  91. };
  92. }
  93. async inspect(dto: InspectRedisKeyDto): Promise<RedisInspectorRecordDetail> {
  94. const key = dto.key;
  95. const [type, ttlSec] = await Promise.all([
  96. this.redisService.type(key),
  97. this.redisService.ttl(key),
  98. ]);
  99. if (type === 'none' || ttlSec === -2) {
  100. return {
  101. key,
  102. type: 'none',
  103. ttlSec: -2,
  104. meta: {},
  105. preview: { format: 'text', data: null },
  106. };
  107. }
  108. const limit = this.normalizePageSize(dto.limit);
  109. const start = Math.max(0, dto.start ?? 0);
  110. const cursor = this.normalizeCursor(dto.cursor);
  111. switch (type) {
  112. case 'string':
  113. return this.inspectString(key, ttlSec);
  114. case 'list':
  115. return this.inspectList(key, ttlSec, limit, start);
  116. case 'zset':
  117. return this.inspectZset(key, ttlSec, limit, start);
  118. case 'hash':
  119. return this.inspectHash(key, ttlSec, limit, cursor);
  120. case 'set':
  121. return this.inspectSet(key, ttlSec, limit, cursor);
  122. default:
  123. return {
  124. key,
  125. type,
  126. ttlSec,
  127. meta: {},
  128. preview: {
  129. format: 'text',
  130. data: `Unsupported type: ${type}`,
  131. },
  132. };
  133. }
  134. }
  135. async rebuild(dto: RebuildCacheDto): Promise<RedisInspectorRebuildResult> {
  136. const handler = this.cacheRegistry.find(
  137. (entry) => entry.cacheCode === dto.cacheCode,
  138. );
  139. if (!handler) {
  140. throw new BadRequestException('Unknown cacheCode');
  141. }
  142. const result = await handler.rebuild();
  143. return {
  144. cacheCode: dto.cacheCode,
  145. status: 'OK',
  146. rebuiltAtSec: Math.floor(Date.now() / 1000),
  147. message: result.message,
  148. affected: result.affected,
  149. };
  150. }
  151. async rebuildByKey(
  152. dto: RebuildCacheByKeyDto,
  153. ): Promise<RedisInspectorKeyRebuildResult> {
  154. const key = dto.key?.trim() ?? '';
  155. const rebuiltAtSec = Math.floor(Date.now() / 1000);
  156. if (!key || !key.startsWith('box:app:')) {
  157. return {
  158. key,
  159. status: 'NOT_SUPPORTED',
  160. rebuiltAtSec,
  161. message: 'Invalid key format',
  162. };
  163. }
  164. const entry = RedisCacheKeyAllowList.find(({ matcher }) => matcher(key));
  165. if (!entry) {
  166. return {
  167. key,
  168. status: 'NOT_SUPPORTED',
  169. rebuiltAtSec,
  170. message: 'Key is not mapped to a rebuildable cache',
  171. };
  172. }
  173. const handler = this.findHandler(entry.cacheCode);
  174. if (!handler) {
  175. return {
  176. key,
  177. cacheCode: entry.cacheCode,
  178. status: 'NOT_SUPPORTED',
  179. rebuiltAtSec,
  180. message: `No rebuild handler registered for ${entry.description.toLowerCase()}`,
  181. };
  182. }
  183. const result = await handler.rebuild();
  184. return {
  185. key,
  186. cacheCode: entry.cacheCode,
  187. status: 'OK',
  188. rebuiltAtSec,
  189. message: result.message,
  190. affected: result.affected,
  191. };
  192. }
  193. async rebuildSupport(
  194. dto: RebuildSupportDto,
  195. ): Promise<RedisInspectorRebuildSupportResult> {
  196. const key = dto.key?.trim() ?? '';
  197. if (!key || !key.startsWith('box:app:')) {
  198. return {
  199. key,
  200. supported: false,
  201. reason: 'Invalid key format',
  202. };
  203. }
  204. const entry = RedisCacheKeyAllowList.find(({ matcher }) => matcher(key));
  205. if (!entry) {
  206. return {
  207. key,
  208. supported: false,
  209. reason: 'Key not in allow list',
  210. };
  211. }
  212. const handler = this.findHandler(entry.cacheCode);
  213. if (!handler) {
  214. return {
  215. key,
  216. cacheCode: entry.cacheCode,
  217. supported: false,
  218. reason: 'No rebuild handler registered for this cacheCode',
  219. description: entry.description,
  220. };
  221. }
  222. return {
  223. key,
  224. cacheCode: entry.cacheCode,
  225. supported: true,
  226. description: entry.description,
  227. };
  228. }
  229. private findHandler(cacheCode: RedisCacheCode) {
  230. return this.cacheRegistry.find((entry) => entry.cacheCode === cacheCode);
  231. }
  232. private async inspectString(
  233. key: string,
  234. ttlSec: number,
  235. ): Promise<RedisInspectorRecordDetail> {
  236. const byteLen = await this.redisService.strLen(key);
  237. const rawValue = (await this.redisService.get(key)) ?? '';
  238. const trimmedValue = rawValue.trim();
  239. const { text, truncated } = this.prepareStringPreview(rawValue);
  240. const meta: Record<string, any> = { byteLen };
  241. const preview: Record<string, any> = {
  242. format: 'text',
  243. data: text,
  244. };
  245. if (byteLen > MAX_JSON_PARSE_BYTES && trimmedValue.startsWith('[')) {
  246. const extracted = this.extractFirstJsonArrayElements(trimmedValue, 5);
  247. if (extracted && extracted.length) {
  248. preview.format = 'json';
  249. preview.data = extracted.map((item) => {
  250. try {
  251. return JSON.parse(item);
  252. } catch {
  253. return item;
  254. }
  255. });
  256. const totalItems = this.countTopLevelJsonArrayElements(trimmedValue);
  257. if (totalItems !== null) {
  258. meta.itemCount = totalItems;
  259. preview.truncated = totalItems > extracted.length;
  260. } else {
  261. preview.truncated = true;
  262. }
  263. return {
  264. key,
  265. type: 'string',
  266. ttlSec,
  267. meta,
  268. preview,
  269. };
  270. }
  271. }
  272. const canParseJson = byteLen <= MAX_JSON_PARSE_BYTES;
  273. const candidate = canParseJson ? this.tryParseJson(rawValue) : undefined;
  274. if (candidate !== undefined && Array.isArray(candidate)) {
  275. meta.itemCount = candidate.length;
  276. preview.format = 'json';
  277. preview.data = candidate.slice(0, 5);
  278. if (candidate.length > 5) {
  279. preview.truncated = true;
  280. }
  281. return {
  282. key,
  283. type: 'string',
  284. ttlSec,
  285. meta,
  286. preview,
  287. };
  288. }
  289. if (!truncated && candidate !== undefined) {
  290. preview.format = 'json';
  291. preview.data = candidate;
  292. }
  293. if (truncated) {
  294. preview.truncated = true;
  295. }
  296. return {
  297. key,
  298. type: 'string',
  299. ttlSec,
  300. meta,
  301. preview,
  302. };
  303. }
  304. private countTopLevelJsonArrayElements(raw: string): number | null {
  305. const trimmed = raw.trim();
  306. if (!trimmed.startsWith('[')) {
  307. return null;
  308. }
  309. let depth = 0;
  310. let inString = false;
  311. let escape = false;
  312. let seenNonWhitespace = false;
  313. let count = 0;
  314. for (let i = 1; i < trimmed.length; i++) {
  315. const char = trimmed[i];
  316. if (inString) {
  317. if (escape) {
  318. escape = false;
  319. } else if (char === '\\') {
  320. escape = true;
  321. } else if (char === '"') {
  322. inString = false;
  323. }
  324. continue;
  325. }
  326. if (char === '"') {
  327. inString = true;
  328. seenNonWhitespace = true;
  329. continue;
  330. }
  331. if (char === '{' || char === '[') {
  332. depth++;
  333. seenNonWhitespace = true;
  334. continue;
  335. }
  336. if (char === '}' || char === ']') {
  337. if (depth > 0) {
  338. depth--;
  339. continue;
  340. }
  341. }
  342. if (depth === 0 && (char === ',' || char === ']')) {
  343. if (seenNonWhitespace) {
  344. count++;
  345. seenNonWhitespace = false;
  346. }
  347. if (char === ']') {
  348. break;
  349. }
  350. continue;
  351. }
  352. if (!/\s/.test(char)) {
  353. seenNonWhitespace = true;
  354. }
  355. }
  356. return count;
  357. }
  358. private extractFirstJsonArrayElements(
  359. raw: string,
  360. maxItems: number,
  361. ): string[] | null {
  362. if (!raw || !raw.startsWith('[')) {
  363. return null;
  364. }
  365. const elements: string[] = [];
  366. let depth = 0;
  367. let inString = false;
  368. let escape = false;
  369. let elementStart: number | null = null;
  370. for (let i = 1; i < raw.length && elements.length < maxItems; i++) {
  371. const char = raw[i];
  372. if (elementStart === null) {
  373. if (/\s/.test(char)) {
  374. continue;
  375. }
  376. if (char === ']') {
  377. break;
  378. }
  379. elementStart = i;
  380. }
  381. if (inString) {
  382. if (escape) {
  383. escape = false;
  384. } else if (char === '\\') {
  385. escape = true;
  386. } else if (char === '"') {
  387. inString = false;
  388. }
  389. continue;
  390. }
  391. if (char === '"') {
  392. inString = true;
  393. continue;
  394. }
  395. if (char === '{' || char === '[') {
  396. depth++;
  397. continue;
  398. }
  399. if (char === '}' || char === ']') {
  400. if (depth > 0) {
  401. depth--;
  402. continue;
  403. }
  404. }
  405. if ((char === ',' || char === ']') && depth === 0) {
  406. const segment = raw.slice(elementStart, i).trim();
  407. if (segment) {
  408. elements.push(segment);
  409. }
  410. elementStart = null;
  411. if (char === ']') {
  412. break;
  413. }
  414. }
  415. }
  416. return elements.length ? elements : null;
  417. }
  418. private async inspectList(
  419. key: string,
  420. ttlSec: number,
  421. limit: number,
  422. start: number,
  423. ): Promise<RedisInspectorRecordDetail> {
  424. const length = await this.redisService.llen(key);
  425. const end = start + limit - 1;
  426. const values = await this.redisService.lrange(key, start, end);
  427. const nextStart = start + values.length;
  428. return {
  429. key,
  430. type: 'list',
  431. ttlSec,
  432. meta: { len: length },
  433. preview: { format: 'json', data: values },
  434. paging: {
  435. nextStart,
  436. hasMore: nextStart < length,
  437. },
  438. };
  439. }
  440. private async inspectZset(
  441. key: string,
  442. ttlSec: number,
  443. limit: number,
  444. start: number,
  445. ): Promise<RedisInspectorRecordDetail> {
  446. const card = await this.redisService.zcard(key);
  447. const end = start + limit - 1;
  448. const entries = await this.redisService.zrangeWithScores(key, start, end);
  449. const nextStart = start + entries.length;
  450. return {
  451. key,
  452. type: 'zset',
  453. ttlSec,
  454. meta: { card },
  455. preview: { format: 'json', data: entries },
  456. paging: {
  457. nextStart,
  458. hasMore: nextStart < card,
  459. },
  460. };
  461. }
  462. private async inspectHash(
  463. key: string,
  464. ttlSec: number,
  465. limit: number,
  466. cursor: string,
  467. ): Promise<RedisInspectorRecordDetail> {
  468. const length = await this.redisService.hlen(key);
  469. const [cursorNext, rawEntries] = await this.redisService.hscan(
  470. key,
  471. cursor,
  472. limit,
  473. );
  474. const entries = this.buildHashEntries(rawEntries);
  475. return {
  476. key,
  477. type: 'hash',
  478. ttlSec,
  479. meta: { len: length },
  480. preview: { format: 'json', data: entries },
  481. paging: {
  482. cursorNext,
  483. hasMore: cursorNext !== '0',
  484. },
  485. };
  486. }
  487. private async inspectSet(
  488. key: string,
  489. ttlSec: number,
  490. limit: number,
  491. cursor: string,
  492. ): Promise<RedisInspectorRecordDetail> {
  493. const card = await this.redisService.scard(key);
  494. const [cursorNext, members] = await this.redisService.sscan(
  495. key,
  496. cursor,
  497. limit,
  498. );
  499. return {
  500. key,
  501. type: 'set',
  502. ttlSec,
  503. meta: { card },
  504. preview: { format: 'json', data: members ?? [] },
  505. paging: {
  506. cursorNext,
  507. hasMore: cursorNext !== '0',
  508. },
  509. };
  510. }
  511. private prepareStringPreview(value: string): {
  512. text: string;
  513. truncated: boolean;
  514. } {
  515. if (!value) return { text: '', truncated: false };
  516. const buffer = Buffer.from(value, 'utf8');
  517. if (buffer.length <= this.maxPreviewBytes) {
  518. return { text: value, truncated: false };
  519. }
  520. const truncated = buffer.subarray(0, this.maxPreviewBytes).toString('utf8');
  521. return { text: truncated, truncated: true };
  522. }
  523. private tryParseJson(value: string): unknown | undefined {
  524. try {
  525. return JSON.parse(value);
  526. } catch {
  527. return undefined;
  528. }
  529. }
  530. private buildHashEntries(raw: string[]): Array<{
  531. field: string;
  532. value: unknown;
  533. }> {
  534. const entries: Array<{ field: string; value: unknown }> = [];
  535. for (let i = 0; i < raw.length; i += 2) {
  536. const field = raw[i];
  537. const value = raw[i + 1];
  538. if (field === undefined || value === undefined) {
  539. continue;
  540. }
  541. entries.push({
  542. field,
  543. value: this.parseHashValue(value),
  544. });
  545. }
  546. return entries;
  547. }
  548. private parseHashValue(value: string): unknown {
  549. if (!value) return value;
  550. if (Buffer.byteLength(value, 'utf8') > this.maxPreviewBytes) {
  551. return value;
  552. }
  553. const trimmed = value.trim();
  554. if (!this.looksLikeJson(trimmed)) {
  555. return value;
  556. }
  557. try {
  558. return JSON.parse(value);
  559. } catch {
  560. return value;
  561. }
  562. }
  563. private looksLikeJson(value: string): boolean {
  564. return (
  565. value.startsWith('{') || value.startsWith('[') || value.startsWith('"')
  566. );
  567. }
  568. private buildMatchPattern(dto: ScanRedisKeysDto): string {
  569. const basePattern = GROUP_PATTERNS[dto.groupCode];
  570. const keyword = dto.keyword?.trim();
  571. if (!keyword) {
  572. return basePattern;
  573. }
  574. const normalizedBase = basePattern.endsWith('*')
  575. ? basePattern.slice(0, -1)
  576. : basePattern;
  577. const containsWildcard = keyword.includes('*') || keyword.includes('?');
  578. const suffix = containsWildcard ? keyword : `*${keyword}*`;
  579. return `${normalizedBase}${suffix}`;
  580. }
  581. private normalizeCursor(cursor?: string): string {
  582. const trimmed = cursor?.trim();
  583. return trimmed && trimmed !== '' ? trimmed : '0';
  584. }
  585. private normalizePageSize(pageSize?: number): number {
  586. const fallback = 50;
  587. if (!pageSize) return fallback;
  588. return Math.min(Math.max(pageSize, 1), 200);
  589. }
  590. }