submission CacheModule Circular Dependency Hotfix + StudyMemberGuard Redis Consolidation
SummaryThe submission CacheModule added in Sprint 194 introduced a NestJS DI circular dependency (bidirectional import between cache.module.ts and stats-cache.service.ts), causing the new image to throw deterministically 26ms into bootstrap → CrashLoopBackOff → rollout blocked. Extracted the REDIS_CLIENT token into cache.constants.ts to structurally break the cycle, and added a cache.module.spec.ts regression test that compiles the real module graph (closing the gap unit tests missed). As a side cleanup, consolidated StudyMemberGuard's standalone ioredis instance into the global REDIS_CLIENT DI injection (connection pool 2→1). build 0, ESLint 0 errors, jest 379 pass, Critic (Codex gpt-5.5) 0 findings, CI #343 36 pass / 0 fail. Cluster redeploy is handled server-side.
Goal
- Resolve the submission-service bootstrap failure (NestJS DI circular dependency) introduced by Sprint 194 code, removing the rollout blocker.
- Add a regression defense test so the same class of bug (module graph compilation failure) is caught by CI.
- (Side cleanup) Consolidate
StudyMemberGuard's standalone ioredis instance into the globalREDIS_CLIENT, reducing the connection pool 2→1.
Background
- Sprint 194 added a global
CacheModule(REDIS_CLIENT) +StatsCacheServiceto the Submission service (dashboard stats Cache-Aside). - After merge the new image fell into CrashLoopBackOff. A server-agent diagnosis plus code cross-verification confirmed the cause: 26ms into bootstrap the process throws
A circular dependency has been detected inside CacheModuleand exits. Being deterministic, every retry fails at the exact same point. - No user impact: the previous ReplicaSet (2 Pods) kept serving normally. Only the rollout was blocked, so the new feature (stats cache) was not deployed.
- Not OOM / Redis failure: Last State Terminated
Exit Code 1(not 137 OOMKilled), 1Gi memory limit with headroom, initdb-migrateExit 0, Redis Pod up 73 days. The prior handoff's "lazy connect non-blocking" analysis was correct, but the throw occurs earlier, during the DI graph build stage.
Decision
D1. Scope — Hotfix + Guard Redis consolidation (user)
- Confirmed via AskUserQuestion. ① circular dependency hotfix ② consolidate
StudyMemberGuard's standalone ioredis intoCacheModule'sREDIS_CLIENT, in one PR. The originally planned Sprint 195 work (problem.tagsJSON migration) is deferred to Sprint 196.
D2. Rollout handling — Redeploy server-side after merge (user)
- Since the previous version is serving normally, no emergency rollback is needed. Code hotfix + PR + CI green is handled in this workflow; the cluster redeploy and CrashLoopBackOff alert clearing are handled server-side (user).
D3. Cycle break — Extract token into a separate file
- Root cause:
cache.module.tsdefines theREDIS_CLIENTtoken (:13) while also importingStatsCacheService(:11), andstats-cache.service.tsback-references that token fromcache.module(:10) → at module evaluation timeStatsCacheServiceisundefined, so Nest throws. - Extracted
REDIS_CLIENTintocache/cache.constants.ts, with bothcache.module.tsandstats-cache.service.tsimporting from there → the bidirectional reference between the two files disappears. Resulting graph: module→service, module→constants, service→constants (zero cycles).
D4. Regression defense — Real module graph compilation test
- The existing
stats-cache.service.spec.tsdid not import the realCacheModule; it assembled providers manually → it never compiled the DI graph that triggers the cycle, so it could not catch the throw. - Added
cache.module.spec.ts:Test.createTestingModule({ imports: [LoggerModule, CacheModule] }).overrideProvider(REDIS_CLIENT).useValue(mockRedis).compile()compiles the real module graph and assertsStatsCacheService/REDIS_CLIENTresolution. Before the fix it throws (fails); after, it passes → CI blocks this class of regression. (LoggerModuleis@Globalso it providesStructuredLoggerService; the REDIS_CLIENT override avoids a real Redis connection; afterEachclose()prevents open handles.)
D5. Guard Redis consolidation — @Inject(REDIS_CLIENT)
- Removed the
new Redis(redisUrl)direct instantiation in theStudyMemberGuardconstructor and injected@Inject(REDIS_CLIENT) redis: Redisinstead. The redundantredis.on('error')handler is removed since thecache.modulefactory centralizes it.ConfigServiceis kept forGATEWAY_INTERNAL_URL/INTERNAL_KEY_GATEWAY. The guard is used via@UseGuardson the submission/review/study-note controllers — sinceCacheModuleis@Global,REDIS_CLIENTresolves globally, so no provider registration change is required.
Implementation
Implementation commits (2 commits, PR #343 squash → 2e1502e)
d050b9afix(submission) — resolve cache module circular dependency (token extraction) + bootstrap regression test- New:
cache/cache.constants.ts(REDIS_CLIENT token) ·cache/cache.module.spec.ts(3 DI-graph compilation regression tests) - Modified:
cache.module.ts(remove local token definition → import from constants) ·stats-cache.service.ts·stats-cache.service.spec.ts(import source → constants)
- New:
60f2e1frefactor(submission) — consolidate StudyMemberGuard into CacheModule REDIS_CLIENT (ioredis instances 2→1)- Modified:
common/guards/study-member.guard.ts(removenew Redis()→@Inject(REDIS_CLIENT), remove redundant handler) ·study-member.guard.spec.ts(removejest.mock('ioredis')→ inject DI mock)
- Modified:
Verification
- Type/build:
tsc --noEmit0 errors.npm run lint(eslint{src,test}/**/*.ts) 0 errors, 9 warnings (all pre-existingno-unused-vars, unrelated to this change).mockRedis as anyis confined to a spec file —.eslintrc.jsoverrides setno-explicit-any: 'off'for*.spec.ts(different from Sprint 194's source-fileas any). - Tests: jest 379 passed / 0 failed (24 suites). All coverage thresholds pass (statements 98%+, branches 94%+, functions 96%+). The new
cache.module.spec.ts(3 tests) verifies real DI-graph compilation and StatsCacheService/REDIS_CLIENT resolution. - Critic:
codex review --base main(gpt-5.5) — Critical/High/Medium/Low: 0 findings. "The StudyMemberGuard's new shared Redis injection is backed by the global CacheModule in the application module, and the added tests cover the intended DI regression." - CI #343: 36 pass / 14 skip / 0 fail — Quality, Coverage Gate, Build Submission, Test Submission, E2E, Trivy all pass.
- Operations: no downtime since the previous version keeps serving. The redeploy brings up a healthy new ReplicaSet and clears CrashLoopBackOff (server-side).
New Patterns
- Place DI tokens in a dependency-free constants file — so the module (provider definitions) and the token consumer (service) reference the same token without importing each other. The standard NestJS circular-dependency prevention.
- New modules require a "DI graph compilation" regression test —
Test.createTestingModule({ imports: [RealModule] }).compile()for bootstrap-equivalent verification. A spec that only assembles providers manually cannot catch the graph-build throw. Avoid external connections (Redis, etc.) viaoverrideProvider().useValue(mock).