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
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user