Преглед на файлове

feat(redis-inspector): add Redis inspector module with key inspection and cache rebuilding functionality

Dave преди 3 месеца
родител
ревизия
63030fb8a5

+ 2 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/feature.module.ts

@@ -11,6 +11,7 @@ import { TagModule } from './tag/tag.module';
 import { VideoMediaModule } from './video-media/video-media.module';
 import { HealthModule } from './health/health.module';
 import { ProviderVideoSyncModule } from './provider-video-sync/provider-video-sync.module';
+import { RedisInspectorModule } from './redis-inspector/redis-inspector.module';
 
 @Module({
   imports: [
@@ -24,6 +25,7 @@ import { ProviderVideoSyncModule } from './provider-video-sync/provider-video-sy
     MgntHttpServiceModule,
     VideoMediaModule,
     HealthModule,
+    RedisInspectorModule,
     ProviderVideoSyncModule,
   ],
 })

+ 52 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/redis-inspector/dto/inspect-redis-key.dto.ts

@@ -0,0 +1,52 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+  IsInt,
+  IsOptional,
+  IsString,
+  Max,
+  Min,
+  IsNotEmpty,
+} from 'class-validator';
+
+export class InspectRedisKeyDto {
+  @ApiProperty({
+    description: 'Redis key to inspect',
+    example: 'box:app:video:category:list:default',
+  })
+  @IsString()
+  @IsNotEmpty()
+  key: string;
+
+  @ApiPropertyOptional({
+    description: 'Maximum number of preview items',
+    example: 50,
+    default: 50,
+  })
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  @Max(200)
+  limit?: number;
+
+  @ApiPropertyOptional({
+    description: 'Scan cursor for HSCAN/SSCAN',
+    example: '0',
+    default: '0',
+  })
+  @IsOptional()
+  @IsString()
+  cursor?: string;
+
+  @ApiPropertyOptional({
+    description: 'Start index for list/zset paging',
+    example: 0,
+    default: 0,
+  })
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  start?: number;
+}

+ 12 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/redis-inspector/dto/rebuild-cache.dto.ts

@@ -0,0 +1,12 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsString } from 'class-validator';
+
+export class RebuildCacheDto {
+  @ApiProperty({
+    description: 'Logical cache code that should be rebuilt',
+    example: 'VIDEO_CATEGORY_LIST',
+  })
+  @IsString()
+  @IsNotEmpty()
+  cacheCode: string;
+}

+ 48 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/redis-inspector/dto/scan-redis-keys.dto.ts

@@ -0,0 +1,48 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
+
+export enum RedisInspectorGroupCode {
+  VIDEO = 'VIDEO',
+  ADS = 'ADS',
+  MOVIE = 'MOVIE',
+}
+
+export class ScanRedisKeysDto {
+  @ApiProperty({
+    description: 'Redis key group',
+    enum: RedisInspectorGroupCode,
+    example: RedisInspectorGroupCode.VIDEO,
+  })
+  @IsEnum(RedisInspectorGroupCode)
+  groupCode: RedisInspectorGroupCode;
+
+  @ApiPropertyOptional({
+    description: 'Keyword filter applied to key names',
+    example: 'box:video',
+  })
+  @IsOptional()
+  @IsString()
+  keyword?: string;
+
+  @ApiPropertyOptional({
+    description: 'Scan cursor (Redis SCAN/HSCAN/SSCAN)',
+    example: '0',
+    default: '0',
+  })
+  @IsOptional()
+  @IsString()
+  cursor?: string;
+
+  @ApiPropertyOptional({
+    description: 'Page size (max 200)',
+    example: 50,
+    default: 50,
+  })
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  @Max(200)
+  pageSize?: number;
+}

+ 47 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/redis-inspector/redis-cache-registry.ts

@@ -0,0 +1,47 @@
+import { LatestVideosCacheBuilder } from '@box/core/cache/video/latest/latest-videos-cache.builder';
+import { RecommendedVideosCacheBuilder } from '@box/core/cache/video/recommended/recommended-videos-cache.builder';
+
+export type RedisCacheCode = 'VIDEO_LATEST' | 'VIDEO_RECOMMENDED';
+
+export interface RedisCacheRebuildHandler {
+  cacheCode: RedisCacheCode;
+  label: string;
+  description?: string;
+  rebuild: () => Promise<{ ok: boolean; message?: string; affected?: number }>;
+}
+
+interface RedisCacheRegistryDeps {
+  latestVideosCacheBuilder: LatestVideosCacheBuilder;
+  recommendedVideosCacheBuilder: RecommendedVideosCacheBuilder;
+}
+
+export function getRedisCacheRegistry(
+  deps: RedisCacheRegistryDeps,
+): RedisCacheRebuildHandler[] {
+  return [
+    {
+      cacheCode: 'VIDEO_LATEST',
+      label: 'Latest Videos Cache',
+      description: 'Rebuilds the latest video feed cache.',
+      rebuild: async () => {
+        await deps.latestVideosCacheBuilder.buildAll();
+        return {
+          ok: true,
+          message: 'Latest videos cache rebuilt',
+        };
+      },
+    },
+    {
+      cacheCode: 'VIDEO_RECOMMENDED',
+      label: 'Recommended Videos Cache',
+      description: 'Rebuilds the homepage recommended video cache.',
+      rebuild: async () => {
+        await deps.recommendedVideosCacheBuilder.buildAll();
+        return {
+          ok: true,
+          message: 'Recommended videos cache rebuilt',
+        };
+      },
+    },
+  ];
+}

+ 142 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/redis-inspector/redis-inspector.controller.ts

@@ -0,0 +1,142 @@
+import { Body, Controller, Get, Post, Query } from '@nestjs/common';
+import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { InspectRedisKeyDto } from './dto/inspect-redis-key.dto';
+import { RebuildCacheDto } from './dto/rebuild-cache.dto';
+import {
+  ScanRedisKeysDto,
+  RedisInspectorGroupCode,
+} from './dto/scan-redis-keys.dto';
+import { RedisInspectorService } from './redis-inspector.service';
+import {
+  RedisInspectorRecordDetail,
+  RedisInspectorRebuildResult,
+  RedisInspectorScanResult,
+} from './types/redis-inspector.types';
+
+@ApiTags('缓存管理 - Redis 探查器', 'Cache Management - Redis Inspector')
+@Controller('redis')
+export class RedisInspectorController {
+  constructor(private readonly service: RedisInspectorService) {}
+
+  @Get('scan')
+  @ApiOperation({ summary: 'Scan Redis keys by group' })
+  @ApiQuery({
+    name: 'groupCode',
+    required: true,
+    enum: RedisInspectorGroupCode,
+    description: 'Key group used in Redis cache',
+  })
+  @ApiQuery({
+    name: 'keyword',
+    required: false,
+    description: 'Optional key keyword matcher',
+  })
+  @ApiQuery({
+    name: 'cursor',
+    required: false,
+    description: 'Scan cursor',
+    example: '0',
+  })
+  @ApiQuery({
+    name: 'pageSize',
+    required: false,
+    description: 'Max items to return (1-200)',
+    example: 50,
+  })
+  @ApiResponse({
+    status: 200,
+    description: 'Placeholder scan result',
+    schema: {
+      type: 'object',
+      properties: {
+        cursorNext: { type: 'string', example: '0' },
+        items: {
+          type: 'array',
+          items: {
+            type: 'object',
+            properties: {
+              key: { type: 'string' },
+              type: { type: 'string' },
+              ttlSec: { type: 'integer' },
+            },
+          },
+        },
+      },
+    },
+  })
+  scan(@Query() dto: ScanRedisKeysDto): Promise<RedisInspectorScanResult> {
+    return this.service.scan(dto);
+  }
+
+  @Get('inspect')
+  @ApiOperation({ summary: 'Inspect a specific Redis key' })
+  @ApiQuery({
+    name: 'key',
+    required: true,
+    description: 'Full Redis key to inspect',
+  })
+  @ApiQuery({
+    name: 'limit',
+    required: false,
+    description: 'Preview limit (1-200)',
+    example: 50,
+  })
+  @ApiQuery({
+    name: 'cursor',
+    required: false,
+    description: 'HSCAN/SSCAN cursor',
+    example: '0',
+  })
+  @ApiQuery({
+    name: 'start',
+    required: false,
+    description: 'Start index for list/zset paging',
+    example: 0,
+  })
+  @ApiResponse({
+    status: 200,
+    description: 'Placeholder inspection details',
+    schema: {
+      type: 'object',
+      properties: {
+        key: { type: 'string' },
+        type: { type: 'string' },
+        ttlSec: { type: 'integer' },
+        meta: { type: 'object' },
+        preview: { type: 'object' },
+        paging: { type: 'object' },
+      },
+    },
+  })
+  inspect(
+    @Query() dto: InspectRedisKeyDto,
+  ): Promise<RedisInspectorRecordDetail> {
+    return this.service.inspect(dto);
+  }
+
+  @Post('rebuild')
+  @ApiOperation({ summary: 'Rebuild a logical Redis cache' })
+  @ApiResponse({
+    status: 200,
+    description: 'Placeholder rebuild response',
+    schema: {
+      type: 'object',
+      properties: {
+        cacheCode: { type: 'string' },
+        status: { type: 'string', example: 'OK' },
+        rebuiltAtSec: { type: 'integer' },
+        message: {
+          type: 'string',
+          example: 'Latest videos cache rebuilt',
+        },
+        affected: {
+          type: 'integer',
+          example: 0,
+        },
+      },
+    },
+  })
+  rebuild(@Body() dto: RebuildCacheDto): Promise<RedisInspectorRebuildResult> {
+    return this.service.rebuild(dto);
+  }
+}

+ 13 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/redis-inspector/redis-inspector.module.ts

@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { CacheManagerModule } from '@box/core/cache/cache-manager.module';
+import { RedisModule } from '@box/db/redis/redis.module';
+import { RedisInspectorController } from './redis-inspector.controller';
+import { RedisInspectorService } from './redis-inspector.service';
+
+@Module({
+  imports: [RedisModule, CacheManagerModule],
+  controllers: [RedisInspectorController],
+  providers: [RedisInspectorService],
+  exports: [RedisInspectorService],
+})
+export class RedisInspectorModule {}

+ 358 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/redis-inspector/redis-inspector.service.ts

@@ -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);
+  }
+}

+ 41 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/redis-inspector/types/redis-inspector.types.ts

@@ -0,0 +1,41 @@
+export interface RedisInspectorKeySummary {
+  key: string;
+  type?: string;
+  ttlSec?: number;
+}
+
+export interface RedisInspectorScanResult {
+  cursorNext: string;
+  items: RedisInspectorKeySummary[];
+}
+
+export interface RedisInspectorPaging {
+  // request echo (optional)
+  cursor?: string;
+  limit?: number;
+  start?: number;
+
+  // list/zset paging
+  nextStart?: number;
+  hasMore?: boolean;
+
+  // hash/set paging
+  cursorNext?: string;
+}
+
+export interface RedisInspectorRecordDetail {
+  key: string;
+  type?: string;
+  ttlSec?: number;
+  meta?: any;
+  preview?: any;
+  paging?: RedisInspectorPaging;
+}
+
+export interface RedisInspectorRebuildResult {
+  cacheCode: string;
+  status: 'OK';
+  rebuiltAtSec: number;
+  message?: string;
+  affected?: number;
+}

+ 2 - 0
apps/box-mgnt-api/src/mgnt-backend/mgnt-backend.module.ts

@@ -20,6 +20,7 @@ import { VideoMediaModule } from './feature/video-media/video-media.module';
 import { HealthModule } from './feature/health/health.module';
 import { CacheSyncModule } from '../cache-sync/cache-sync.module';
 import { ProviderVideoSyncModule } from './feature/provider-video-sync/provider-video-sync.module';
+import { RedisInspectorModule } from './feature/redis-inspector/redis-inspector.module';
 
 @Module({
   imports: [
@@ -45,6 +46,7 @@ import { ProviderVideoSyncModule } from './feature/provider-video-sync/provider-
           VideoMediaModule,
           HealthModule,
           CacheSyncModule,
+          RedisInspectorModule,
           ProviderVideoSyncModule,
         ],
       },

+ 134 - 0
libs/db/src/redis/redis.service.ts

@@ -146,6 +146,108 @@ export class RedisService {
     return client.type(key);
   }
 
+  /**
+   * Get the TTL of a key (seconds). Returns -2 if key missing.
+   */
+  async ttl(key: string): Promise<number> {
+    const client = this.ensureClient();
+    return client.ttl(key);
+  }
+
+  /**
+   * String length in bytes.
+   */
+  async strLen(key: string): Promise<number> {
+    const client = this.ensureClient();
+    return client.strlen(key);
+  }
+
+  /**
+   * Number of members in a sorted set.
+   */
+  async zcard(key: string): Promise<number> {
+    const client = this.ensureClient();
+    return client.zcard(key);
+  }
+
+  /**
+   * ZRANGE WITHSCORES helper returning typed tuples.
+   */
+  async zrangeWithScores(
+    key: string,
+    start: number,
+    stop: number,
+  ): Promise<Array<{ member: string; score: number }>> {
+    const client = this.ensureClient();
+    const response = (await (client.zrange as any)(
+      key,
+      start,
+      stop,
+      'WITHSCORES',
+    )) as string[];
+    const items: Array<{ member: string; score: number }> = [];
+    for (let i = 0; i < response.length; i += 2) {
+      const member = response[i];
+      const scoreValue = response[i + 1];
+      const score = Number(scoreValue);
+      items.push({
+        member,
+        score: Number.isNaN(score) ? 0 : score,
+      });
+    }
+    return items;
+  }
+
+  /**
+   * Pipeline TYPE + TTL for a batch of keys using a single round-trip.
+   */
+  async pipelineTypeTtl(
+    keys: string[],
+  ): Promise<Array<{ key: string; type?: string; ttlSec?: number }>> {
+    if (!keys.length) return [];
+
+    const client = this.ensureClient();
+    const pipeline = client.pipeline();
+
+    for (const key of keys) {
+      pipeline.type(key);
+      pipeline.ttl(key);
+    }
+
+    const results = await pipeline.exec();
+    if (!results) return [];
+
+    const items: Array<{ key: string; type?: string; ttlSec?: number }> = [];
+
+    for (let i = 0; i < keys.length; i++) {
+      const typeEntry = results[i * 2];
+      const ttlEntry = results[i * 2 + 1];
+
+      const type =
+        typeEntry && typeEntry[0] === null && typeof typeEntry[1] === 'string'
+          ? typeEntry[1]
+          : undefined;
+
+      const ttlCandidate =
+        ttlEntry && ttlEntry[0] === null && typeof ttlEntry[1] === 'number'
+          ? ttlEntry[1]
+          : undefined;
+
+      const ttlSec =
+        typeof ttlCandidate === 'number' && ttlCandidate >= 0
+          ? ttlCandidate
+          : undefined;
+
+      items.push({
+        key: keys[i],
+        type,
+        ttlSec,
+      });
+    }
+
+    return items;
+  }
+
   // ─────────────────────────────────────────────
   // List operations
   // ─────────────────────────────────────────────
@@ -222,6 +324,38 @@ export class RedisService {
   }
 
   /**
+   * Get the number of hash fields.
+   */
+  async hlen(key: string): Promise<number> {
+    const client = this.ensureClient();
+    return client.hlen(key);
+  }
+
+  /**
+   * Scan a hash key (cursor-based, COUNT semantics).
+   */
+  async hscan(
+    key: string,
+    cursor: string,
+    count: number,
+  ): Promise<[string, string[]]> {
+    const client = this.ensureClient();
+    return (client.hscan as any)(key, cursor, 'COUNT', count);
+  }
+
+  /**
+   * Scan a set key (cursor-based).
+   */
+  async sscan(
+    key: string,
+    cursor: string,
+    count: number,
+  ): Promise<[string, string[]]> {
+    const client = this.ensureClient();
+    return (client.sscan as any)(key, cursor, 'COUNT', count);
+  }
+
+  /**
    * Get a range of elements from a Redis ZSET in reverse score order.
    * Returns elements from offset to offset+limit-1.
    * Useful for pagination: zrevrange(key, (page-1)*pageSize, page*pageSize-1)