feat(pcat-auth): cache JWT slot in Redis (PL24-style cross-restart hydration)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

A captured slot now lives in Redis under `pcat:jwt:slot` for the JWT's
remaining lifetime (minus a 60s safety buffer). On module init we try
Redis before launching Playwright — if a fresh slot is there we adopt it
and schedule its refresh, skipping the ~5s capture entirely. After every
successful capture+validation we publish to Redis so the next restart (or
any sibling pod) can inherit. invalidateSession deletes the Redis copy
because a 401/403 means the cached IP-binding is dead.

Token is still IP-bound to its proxyPort. If a hydrating container reads
the slot but the proxy has rotated away from the captured IP, the next
upstream call 401s and the existing invalidateSession fallback re-captures
locally — so worst case = today's cold-capture behavior, never worse.

Note: dev and prod use separate Redis instances. This patch reaches PL24
parity (same-env redeploy hydration); a true dev↔prod shared cache would
need either an external Redis or an internal-token bridge.
This commit is contained in:
2026-06-01 22:08:45 +03:00
parent 30be00465b
commit dcf7e068c9

View File

@@ -18,8 +18,17 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Browser, BrowserContext } from "playwright";
import { RedisService } from "../../redis/redis.service";
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
// Shared single-slot cache so dev + prod (and any restarted container) can
// inherit a freshly-captured warm slot instead of paying the Playwright
// capture cost from cold. Token is still IP-bound to its proxyPort; if a
// reader on a different IP gets 401/403, invalidateSession's existing
// fallback re-captures locally. So worst case = today's behavior.
const REDIS_SLOT_KEY = "pcat:jwt:slot";
const REDIS_SAFETY_BUFFER_S = 60; // don't serve from Redis if <60s of life left
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
const CAPTURE_POLL_INTERVAL = 500; // ms
@@ -129,7 +138,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private readonly proxyUser: string;
private readonly proxyPass: string;
constructor(private configService: ConfigService) {
constructor(
private configService: ConfigService,
private readonly redis: RedisService,
) {
this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
@@ -144,8 +156,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
this.logger.error(`Failed to launch browser on init: ${(err as Error).message}`);
}
// Start business hours scheduling
if (this.isBusinessHours()) {
// Try Redis first — if another container has a fresh slot, inherit it.
const hydrated = await this.hydrateFromRedis();
if (hydrated) {
this.pool.push(hydrated);
this.scheduleSlotRefresh(hydrated);
const ttl = hydrated.jwt.exp - Math.floor(Date.now() / 1000);
this.logger.log(
`JWT pool: slot hydrated from Redis, TTL: ${ttl}s, pool size: ${this.pool.length}`,
);
} else if (this.isBusinessHours()) {
this.captureToPool().catch((err) => {
this.logger.error(`Initial pool capture failed: ${(err as Error).message}`);
});
@@ -199,6 +219,11 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
);
}
// The Redis-shared slot may point at a now-401'd JWT (e.g. IP rotated
// away on the proxy). Drop it so other containers re-capture instead of
// inheriting the same dead slot.
this.deleteFromRedis().catch(() => {});
// Capture replacement in background (don't block the caller's retry)
if (this.isBusinessHours() || this.pool.length === 0) {
this.captureToPool().catch((err) => {
@@ -293,6 +318,9 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// Add to pool
this.pool.push(slot);
// Publish to Redis so other containers can hydrate from this slot.
this.persistToRedis(slot).catch(() => {});
// Schedule refresh if in business hours
if (this.isBusinessHours()) {
this.scheduleSlotRefresh(slot);
@@ -356,6 +384,41 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
}
// ─── Redis persistence (cross-container slot sharing) ─────
/**
* Read the shared slot from Redis if one exists and still has >REDIS_SAFETY_BUFFER_S
* of life. Returns a fresh JwtSlot ready to push to the in-memory pool;
* refreshTimer is null (scheduler arms it on insert).
*/
private async hydrateFromRedis(): Promise<JwtSlot | null> {
try {
const raw = await this.redis.getJson<Omit<JwtSlot, "refreshTimer">>(REDIS_SLOT_KEY);
if (!raw) return null;
const now = Math.floor(Date.now() / 1000);
if (raw.jwt.exp - now < REDIS_SAFETY_BUFFER_S) return null;
return { ...raw, refreshTimer: null };
} catch {
return null;
}
}
/**
* Publish a freshly-captured slot to Redis under REDIS_SLOT_KEY, TTL aligned
* with the JWT's own expiry (minus a safety buffer). refreshTimer is stripped
* because it's a non-serializable Node Timer handle.
*/
private async persistToRedis(slot: JwtSlot): Promise<void> {
const ttl = slot.jwt.exp - Math.floor(Date.now() / 1000) - REDIS_SAFETY_BUFFER_S;
if (ttl <= 0) return;
const { refreshTimer: _ignored, ...persisted } = slot;
await this.redis.setJson(REDIS_SLOT_KEY, persisted, ttl).catch(() => {});
}
private async deleteFromRedis(): Promise<void> {
await this.redis.del(REDIS_SLOT_KEY).catch(() => {});
}
// ─── Timer-based refresh per slot ─────────────────────────
private scheduleSlotRefresh(slot: JwtSlot): void {