|
|
import type { BrowserContext, Page } from "playwright";
|
|
|
import { QUOTE_SESSION_TTL_SECONDS } from "@/lib/constants/quote-session";
|
|
|
import { withBoundedRedis } from "@/lib/redis";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import {
|
|
|
dismissCookieConsent,
|
|
|
scrollQuoteWidgetIntoView,
|
|
|
} from "@/workers/rpa/page-prep";
|
|
|
|
|
|
type ParkedEntry = {
|
|
|
sessionId: string;
|
|
|
page: Page;
|
|
|
context: BrowserContext;
|
|
|
parkedAt: number;
|
|
|
/** 单会话 TTL;默认地址候选 600s,登录态刷价传 MS_REFINE_TOTAL_MS */
|
|
|
ttlMs: number;
|
|
|
};
|
|
|
|
|
|
const pool = new Map<string, ParkedEntry>();
|
|
|
|
|
|
const PARKED_REDIS_PREFIX = "quote_session:parked:";
|
|
|
|
|
|
function defaultTtlMs(): number {
|
|
|
return QUOTE_SESSION_TTL_SECONDS * 1_000;
|
|
|
}
|
|
|
|
|
|
function isExpired(entry: ParkedEntry): boolean {
|
|
|
return Date.now() - entry.parkedAt > entry.ttlMs;
|
|
|
}
|
|
|
|
|
|
/** 仅 rpa-worker 进程可驻留 page(spawn 子进程退出后内存池无效) */
|
|
|
export function canPersistParkedQuoteSession(): boolean {
|
|
|
return Boolean(process.env.RPA_WORKER_ID?.trim());
|
|
|
}
|
|
|
|
|
|
async function markParkedInRedis(
|
|
|
sessionId: string,
|
|
|
ttlSeconds: number,
|
|
|
): Promise<void> {
|
|
|
const workerId = process.env.RPA_WORKER_ID?.trim();
|
|
|
if (!workerId) {
|
|
|
return;
|
|
|
}
|
|
|
await withBoundedRedis((redis) =>
|
|
|
redis.setex(
|
|
|
`${PARKED_REDIS_PREFIX}${sessionId}`,
|
|
|
Math.max(1, ttlSeconds),
|
|
|
workerId,
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function clearParkedInRedis(sessionId: string): Promise<void> {
|
|
|
await withBoundedRedis((redis) =>
|
|
|
redis.del(`${PARKED_REDIS_PREFIX}${sessionId}`),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** Redis 侧是否曾有 worker 驻留(诊断 spawn/跨进程) */
|
|
|
export async function readParkedSessionWorkerId(
|
|
|
sessionId: string,
|
|
|
): Promise<string | null> {
|
|
|
const v = await withBoundedRedis((redis) =>
|
|
|
redis.get(`${PARKED_REDIS_PREFIX}${sessionId}`),
|
|
|
);
|
|
|
return v ?? null;
|
|
|
}
|
|
|
|
|
|
/** 清理过期驻留会话 */
|
|
|
export function sweepExpiredParkedSessions(): number {
|
|
|
let removed = 0;
|
|
|
for (const [id, entry] of pool) {
|
|
|
if (isExpired(entry)) {
|
|
|
void releaseParkedQuoteSession(id);
|
|
|
removed += 1;
|
|
|
}
|
|
|
}
|
|
|
return removed;
|
|
|
}
|
|
|
|
|
|
/** 候选完成后驻留 page/context,供询价 job 同页续跑 */
|
|
|
export async function parkQuoteSession(
|
|
|
sessionId: string,
|
|
|
page: Page,
|
|
|
context: BrowserContext,
|
|
|
options?: { ttlMs?: number },
|
|
|
): Promise<void> {
|
|
|
if (!canPersistParkedQuoteSession()) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"非 RPA Worker 进程,无法驻留浏览器会话",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
const ttlMs = options?.ttlMs ?? defaultTtlMs();
|
|
|
sweepExpiredParkedSessions();
|
|
|
// 为何:连续询价若 decline 失败会堆积驻留页,占满浏览器导致后续控件 invisible
|
|
|
if (pool.size >= 2) {
|
|
|
const oldest = [...pool.values()].sort((a, b) => a.parkedAt - b.parkedAt)[0];
|
|
|
if (oldest && oldest.sessionId !== sessionId) {
|
|
|
console.warn(
|
|
|
`[parked-session] pool满,释放最旧 session=${oldest.sessionId.slice(0, 8)}…`,
|
|
|
);
|
|
|
await releaseParkedQuoteSession(oldest.sessionId);
|
|
|
}
|
|
|
}
|
|
|
await releaseParkedQuoteSession(sessionId);
|
|
|
pool.set(sessionId, {
|
|
|
sessionId,
|
|
|
page,
|
|
|
context,
|
|
|
parkedAt: Date.now(),
|
|
|
ttlMs,
|
|
|
});
|
|
|
await markParkedInRedis(sessionId, Math.ceil(ttlMs / 1000));
|
|
|
console.log(
|
|
|
`[parked-session] 已驻留 session=${sessionId.slice(0, 8)}… ttlMs=${ttlMs} count=${pool.size} worker=${process.env.RPA_WORKER_ID}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 询价 job 接管驻留会话(从池中移除) */
|
|
|
export async function takeParkedQuoteSession(
|
|
|
sessionId: string,
|
|
|
options?: { waitMs?: number },
|
|
|
): Promise<{ page: Page; context: BrowserContext } | null> {
|
|
|
const waitMs = options?.waitMs ?? 0;
|
|
|
const deadline = Date.now() + waitMs;
|
|
|
|
|
|
while (Date.now() <= deadline) {
|
|
|
sweepExpiredParkedSessions();
|
|
|
const entry = pool.get(sessionId);
|
|
|
if (entry && !isExpired(entry)) {
|
|
|
pool.delete(sessionId);
|
|
|
await clearParkedInRedis(sessionId);
|
|
|
console.log(
|
|
|
`[parked-session] 已接管 session=${sessionId.slice(0, 8)}…(单次 RPA 续跑)`,
|
|
|
);
|
|
|
return { page: entry.page, context: entry.context };
|
|
|
}
|
|
|
if (waitMs <= 0) {
|
|
|
break;
|
|
|
}
|
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
}
|
|
|
|
|
|
const redisWorker = await readParkedSessionWorkerId(sessionId);
|
|
|
if (redisWorker && !pool.has(sessionId)) {
|
|
|
console.warn(
|
|
|
`[parked-session] Redis 记录 worker=${redisWorker} 但本进程无驻留页(可能 spawn 候选或 worker 已重启)session=${sessionId.slice(0, 8)}…`,
|
|
|
);
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** 用户取消或超时:关闭并移除 */
|
|
|
export async function releaseParkedQuoteSession(
|
|
|
sessionId: string,
|
|
|
): Promise<boolean> {
|
|
|
const entry = pool.get(sessionId);
|
|
|
if (!entry) {
|
|
|
await clearParkedInRedis(sessionId);
|
|
|
return false;
|
|
|
}
|
|
|
pool.delete(sessionId);
|
|
|
await clearParkedInRedis(sessionId);
|
|
|
await entry.page.close().catch(() => undefined);
|
|
|
await entry.context.close().catch(() => undefined);
|
|
|
console.log(
|
|
|
`[parked-session] 已释放 session=${sessionId.slice(0, 8)}… count=${pool.size}`,
|
|
|
);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
export async function releaseAllParkedQuoteSessions(): Promise<void> {
|
|
|
const ids = [...pool.keys()];
|
|
|
for (const id of ids) {
|
|
|
await releaseParkedQuoteSession(id);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export function hasParkedQuoteSession(sessionId: string): boolean {
|
|
|
sweepExpiredParkedSessions();
|
|
|
const entry = pool.get(sessionId);
|
|
|
return Boolean(entry && !isExpired(entry));
|
|
|
}
|
|
|
|
|
|
/** 用户确认弹窗期间:轻量维持 widget 就绪 */
|
|
|
export async function preheatParkedQuoteSession(
|
|
|
sessionId: string,
|
|
|
): Promise<boolean> {
|
|
|
sweepExpiredParkedSessions();
|
|
|
const entry = pool.get(sessionId);
|
|
|
if (!entry || isExpired(entry)) {
|
|
|
return false;
|
|
|
}
|
|
|
await dismissCookieConsent(entry.page);
|
|
|
await scrollQuoteWidgetIntoView(entry.page, { force: true });
|
|
|
console.log(
|
|
|
`[parked-session] 已预热 session=${sessionId.slice(0, 8)}…`,
|
|
|
);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** 驻留页复用前轻量检查(跳过 15~45s widget hydration 轮询) */
|
|
|
export async function prepareParkedQuotePage(page: Page): Promise<void> {
|
|
|
await dismissCookieConsent(page);
|
|
|
await scrollQuoteWidgetIntoView(page, { force: true });
|
|
|
}
|
|
|
|
|
|
export function parkedQuoteSessionCount(): number {
|
|
|
sweepExpiredParkedSessions();
|
|
|
return pool.size;
|
|
|
}
|
|
|
|
|
|
/** 测试/诊断:读取驻留 TTL(不存在则 null) */
|
|
|
export function getParkedQuoteSessionTtlMs(sessionId: string): number | null {
|
|
|
const entry = pool.get(sessionId);
|
|
|
return entry?.ttlMs ?? null;
|
|
|
}
|