Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 3 backfill'i açmadan önce hacmi ölçtüm ve raporun işaret ettiğinden
çok daha büyük bir israf çıktı.
**Mitsubishi'nin parça listesi grup sanılıyordu.**
`/p5mitsubishi/extern/details/vinDetails` yanıtı `partno`/`qty` taşıyan
bir PARÇA listesi (canlı doğrulama: 16 kayıt), ama her kaydın kendi
linki `partInfoTable` ve ne wid ne yol sınıflandırıcıda karşılık
buluyordu. Sonuç (prod ölçümü): 2.193 parça listesi grup düğümüne
döndü, içlerindeki 19.576 tekil parça ("SCREW,LOCK CYLINDER",
"BOLT,STEERING COLUMN WASHER") kategori olarak kaydedildi. Bu 19.576
sahte düğümün TOPLAM 2 tanesinde parça var ve hepsi her prefetch
turunda yeniden çekiliyor. İkisi de %100 Mitsubishi.
- `detailsTable` artık yaprak, `isPl24PartDetailNode()` ile
`partInfoTable` hiç kuyruklanmıyor.
- `isLeafLinkPath` artık `linkWid`'i de geçiriyor. Okuma yolu bu
güvenilir sinyali hep kullanıyordu ama kuyruklama yolu düşürüyordu —
sınıflandırıcı `detailsTable`'ı öğrense bile burada yine grup
sayılacaktı.
- Migration 0036: 19.576 sahte kategori siliniyor. Okuma yolu bir
düğümün ÖNCE çocuklarına baktığı için bu silme düzeltmenin parçası,
ayrı temizlik değil. Kuru çalıştırma: 19.576 kategori, 9 araç, 2
parça, Mitsubishi dışı 0.
**Backfill anahtarı.** `PL24_BACKFILL_ENABLED` eklendi, varsayılan
KAPALI. Eski `PL24_TR_DISABLED` adı "tr hesabı öldü" diyordu ama işi
"toplu yükü tek sağ kalan hesaptan uzak tut"tu. Eski değişken hâlâ
kapatabiliyor — yarım deploy musluğu sessizce açamasın. Kullanıcı
tetikli fast-lane bu anahtardan etkilenmiyor.
9 yeni test. api 647 test geçiyor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1311 lines
58 KiB
TypeScript
1311 lines
58 KiB
TypeScript
import {
|
|
Inject,
|
|
Injectable,
|
|
Logger,
|
|
type OnModuleDestroy,
|
|
type OnModuleInit,
|
|
} from "@nestjs/common";
|
|
import { DelayedError, type Job, type Queue, Worker } from "bullmq";
|
|
import { and, asc, eq, gt, inArray, isNull, notExists, sql } from "drizzle-orm";
|
|
import { CategoriesService } from "../categories/categories.service";
|
|
import { DATABASE, type Database } from "../database/database.provider";
|
|
import { categories, parts, vehicles } from "../database/schema/core";
|
|
import { isPl24LeafNode, isPl24PartDetailNode } from "../integrations/pl24/pl24-tree";
|
|
import { PostHogService } from "../posthog/posthog.service";
|
|
import { RedisService } from "../redis/redis.service";
|
|
import { QUEUE_NAMES, getBullConnection } from "./bull.config";
|
|
import { backfillContext } from "./prefetch-context";
|
|
import {
|
|
RateLimitError,
|
|
alignToWindow,
|
|
checkCooldown,
|
|
checkTimeWindow,
|
|
initProgress,
|
|
isCatalogBackfillEnabled,
|
|
isWithinTimeWindow,
|
|
updateProgress,
|
|
} from "./prefetch-utils";
|
|
import { PrefetchCategoryJobData, PrefetchInitJobData } from "./prefetch.types";
|
|
import {
|
|
CATALOG_PREFETCH_FAST_QUEUE,
|
|
CATALOG_PREFETCH_QUEUE,
|
|
} from "./queues/catalog-prefetch.queue";
|
|
|
|
/**
|
|
* Depth cap for the prefetch BFS — a safety ceiling, NOT the target depth.
|
|
*
|
|
* Goal is COMPLETE coverage: every decoded vehicle's full category tree + all
|
|
* parts in the DB so viewing is instant (DB read). The BFS terminates naturally
|
|
* at real leaves (isLeafLinkPath); this cap only guards against pathological /
|
|
* cyclic trees running away. Real PL24/EMEX/pcat trees are ~5-7 deep, so 12 is
|
|
* comfortably beyond every genuine leaf while still bounding a runaway.
|
|
* Efficiency is handled separately: queueCategoryJob never enqueues a children
|
|
* job past the cap (no early-return no-ops), and completed vehicles are skipped
|
|
* by the scan. Env-tunable (PREFETCH_MAX_DEPTH).
|
|
*/
|
|
const MAX_DEPTH = Number(process.env.PREFETCH_MAX_DEPTH) || 12;
|
|
|
|
// ── Backfill scan (hourly cron) tuning ──
|
|
/**
|
|
* Vehicles queued per scan wave. Was 20 while pcat needed business-hours
|
|
* gating; doubled to 40 once the warm JWT pool + Redis hydration made the
|
|
* scrape stack 24/7. The MAX_BACKLOG guard below still gates run-away
|
|
* pileups, and per-source cooldown still yields to live users.
|
|
*/
|
|
const BACKFILL_BATCH_SIZE = 40;
|
|
/** Skip the wave entirely if the queue already has more than this many jobs pending. */
|
|
const BACKFILL_MAX_BACKLOG = 1000;
|
|
/**
|
|
* In-flight guard TTL (seconds) — safety net if a run dies without clearing.
|
|
*
|
|
* 36h, NOT 6h: a chain deferred on the daily budget lives until the next UTC
|
|
* midnight (~24h). With a 6h TTL the guard expired mid-chain, the scan re-picked
|
|
* the vehicle, processInit reset progress.total, and the in-flight jobs'
|
|
* incrementCompleted then compared `completed >= total` against the NEW total —
|
|
* corrupting completion accounting for a still-partial vehicle.
|
|
*/
|
|
const BACKFILL_SCHEDULED_TTL = Number(process.env.PREFETCH_SCHEDULED_TTL_H || 36) * 60 * 60;
|
|
/**
|
|
* Only delayed jobs due within this horizon count as queue PRESSURE.
|
|
*
|
|
* `delayed` lumps two very different things together: per-minute source-rate
|
|
* defers (<60s) and retry backoff (30/60s), which ARE load, versus daily-budget
|
|
* and off-hours defers (hours, parked on the next UTC midnight), which are merely
|
|
* THROTTLED FUTURE WORK. Counting the latter as backlog froze Phase-2 for most of
|
|
* the day (2026-08-01: delayed=11495, all pcat, all parked at 00:00:01 UTC; the
|
|
* partial drain fell to ~25 vehicles/day, ETA 79 days).
|
|
*/
|
|
const PRESSURE_HORIZON_MS = Number(process.env.PREFETCH_PRESSURE_HORIZON_MS) || 10 * 60_000;
|
|
/**
|
|
* Absolute ceiling on TOTAL pending jobs (waiting+active+delayed+prioritized)
|
|
* across BOTH lanes — the runaway backstop that `delayed` used to provide by
|
|
* accident. Excluding long defers from the pressure gate removes the only
|
|
* negative feedback on production, so the pool needs its own hard stop: Phase-2
|
|
* can otherwise produce ~288k jobs/day against ~65k/day of budget. 50k ≈ 15h of
|
|
* the combined daily budgets, so the pool always drains inside a window; 1/9 of
|
|
* the guard-less BFS runaway (470k, months to drain). Set 0 to disable Phase-2.
|
|
*/
|
|
const HARD_MAX_TOTAL_JOBS = Number(process.env.PREFETCH_MAX_TOTAL_JOBS ?? 50_000);
|
|
/**
|
|
* Rough fan-out of one Phase-2 re-drill. Admission control is done in JOB units,
|
|
* not vehicle units: 40 vehicles/wave is meaningless when one vehicle is 100-3000
|
|
* jobs (p95 tree = 744 categories).
|
|
*/
|
|
const EST_JOBS_PER_VEHICLE = Number(process.env.PREFETCH_EST_JOBS_PER_VEHICLE) || 400;
|
|
/**
|
|
* Share of each source's daily budget reserved for the FAST lane. The daily
|
|
* counter has no lane component, so backfill spending the budget also parked the
|
|
* user's fresh-decode chain until midnight. One shared counter (the total
|
|
* upstream/bandwidth ceiling stays exactly SOURCE_DAILY_MAX) but two thresholds:
|
|
* the main (backfill) lane stops at 80%, the fast lane may use 100%.
|
|
*/
|
|
const DAILY_FAST_RESERVE = 0.2;
|
|
/** Only these decode sources have catalogs worth prefetching. */
|
|
/**
|
|
* Whether the PL24 *background* backfill lane may run. The user-triggered fast
|
|
* lane is never gated by this.
|
|
*
|
|
* Default is OFF. Two PL24 accounts were banned while bulk background load ran
|
|
* against them, so the background lane has to be switched on deliberately and
|
|
* watched, never inherited from an unset variable.
|
|
*
|
|
* `PL24_BACKFILL_ENABLED` replaces the old `PL24_TR_DISABLED`, whose name said
|
|
* "the tr account is dead" while its actual job was "keep bulk load off the one
|
|
* surviving account". The old variable is still honoured so a half-applied
|
|
* deploy cannot silently open the tap: it can only keep the lane closed.
|
|
*/
|
|
function isPl24BackfillEnabled(): boolean {
|
|
if (process.env.PL24_BACKFILL_ENABLED !== "true") return false;
|
|
// Legacy kill switch still wins while it is explicitly set.
|
|
return process.env.PL24_TR_DISABLED !== "true";
|
|
}
|
|
|
|
const BACKFILL_SOURCES = ["pl24", "emex", "parts-catalogs"];
|
|
/** Redis key holding the rolling rescan cursor (last createdAt seen). */
|
|
const BACKFILL_CURSOR_KEY = "prefetch:backfill:cursor";
|
|
|
|
// ── In-process scan timer (replaces the BullMQ cron, which gets buried) ──
|
|
/** How often the in-process backfill scan runs. */
|
|
const BACKFILL_SCAN_INTERVAL_MS = 60 * 60 * 1000;
|
|
/** Delay before the first scan after boot, so startup settles first. */
|
|
const BACKFILL_SCAN_KICKOFF_MS = 60 * 1000;
|
|
/** NX-lock TTL (seconds) — just under the interval so each cycle re-acquires. */
|
|
const BACKFILL_SCAN_LOCK_TTL_S = 55 * 60;
|
|
|
|
// ── Throughput knobs (env-tunable so prod can ramp without a redeploy) ──
|
|
/**
|
|
* Concurrent jobs the worker runs. emex/pl24 fetches parallelise well, so >1
|
|
* lets a slow parts-catalogs job stop head-of-line-blocking emex. A user-active
|
|
* cooldown still pauses the whole worker, so this only speeds up idle periods.
|
|
*/
|
|
const WORKER_CONCURRENCY = Number(process.env.PREFETCH_CONCURRENCY) || 8;
|
|
/**
|
|
* Global queue rate ceiling (jobs/min) across ALL sources — now a SAFETY CAP,
|
|
* not the real throttle. Per-source limits (below) do the actual pacing so a
|
|
* slow/limited source can't starve the others. Defaults to the sum of the
|
|
* per-source ceilings + headroom. Was a hard 5/min, then a shared 20/min.
|
|
*/
|
|
const WORKER_RATE_MAX = Number(process.env.PREFETCH_RATE_MAX) || 180;
|
|
/**
|
|
* Per-source rate ceilings (jobs/min). Each source is throttled INDEPENDENTLY
|
|
* (Redis fixed-window) so PL24, EMEX and parts-catalogs run concurrently at
|
|
* their own safe rates instead of fighting over one global budget. 0 = unlimited
|
|
* for that source. PL24 stays conservative (upstream ban risk); raise it only
|
|
* with rotating residential proxies. pcat is already paced by PCAT_PACE_MS.
|
|
*/
|
|
const SOURCE_RATE_MAX: Record<string, number> = {
|
|
pl24: Number(process.env.PREFETCH_RATE_PL24) || 20,
|
|
emex: Number(process.env.PREFETCH_RATE_EMEX) || 20,
|
|
// pcat runs through a DataImpulse rotating gateway (74.81.81.81:10000-10999,
|
|
// ~1000 port-rotated exit IPs — verified 2026-06-25), so per-IP ban load is
|
|
// spread; safe to run well above the old shared cap. Ramp step 2 = 90/min
|
|
// (2026-07-08: 93k backlog %89 pcat idi — 20/min + 5s pace drenaja yetmiyordu).
|
|
"parts-catalogs": Number(process.env.PREFETCH_RATE_PCAT) || 90,
|
|
};
|
|
/**
|
|
* Per-source ROLLING-DAY budget (backfill jobs/source/UTC-day). A storm guard on
|
|
* top of the per-minute ceilings: those cap burst rate but not the daily TOTAL,
|
|
* so a source running near its ceiling for many hours drains the proxy budget
|
|
* (2026-07-09: a backlog drain pushed pcat to ~74k calls / ~10 GB in one day).
|
|
* When a source hits its daily budget, further backfill jobs defer until the
|
|
* window rolls. User-facing decodes are unaffected — they don't pass through the
|
|
* worker. Only counts jobs that already cleared the per-minute gate, so
|
|
* rate-limited retries don't inflate it. 0 = unlimited. Env-tunable: raise to
|
|
* drain a backlog faster, lower to conserve proxy budget harder.
|
|
*/
|
|
const SOURCE_DAILY_MAX: Record<string, number> = {
|
|
pl24: Number(process.env.PREFETCH_DAILY_PL24) || 15_000,
|
|
emex: Number(process.env.PREFETCH_DAILY_EMEX) || 20_000,
|
|
// 45k (1.5x): the 2026-07-09 incident that created this guard was ~74k calls /
|
|
// ~10 GB in a day, so 45k ≈ 61% of that — a reversible step at ~6 GB/day. With
|
|
// DAILY_FAST_RESERVE the backfill (main) lane gets 36k, i.e. +20% over the old
|
|
// effective 30k while still leaving 9k for user decodes. Burst rate is
|
|
// UNCHANGED (SOURCE_RATE_MAX pcat 90/min), so per-IP ban pressure is the same —
|
|
// this only sustains that pace for more of the day. Rollback signal: any hour
|
|
// with >2 pcat HTTP 402 (DataImpulse quota) → back to 30_000.
|
|
"parts-catalogs": Number(process.env.PREFETCH_DAILY_PCAT) || 45_000,
|
|
};
|
|
/**
|
|
* Per-job pacing for parts-catalogs only (its browser/JWT capture is heavy).
|
|
* Set 0 to disable. Other sources are paced by the limiter + cooldown alone.
|
|
*/
|
|
const PCAT_PACE_MS = Number(process.env.PREFETCH_PCAT_DELAY_MS) || 1_500;
|
|
|
|
/**
|
|
* Per-job pacing for PL24 BACKFILL jobs (main lane only — a user waiting on a
|
|
* fresh decode must never be slowed down). Both account bans followed days of
|
|
* thousands of back-to-back PL24 calls; a paced, jittered stream looks nothing
|
|
* like that. Set 0 to disable.
|
|
*/
|
|
const PL24_PACE_MS = Number(process.env.PREFETCH_PL24_DELAY_MS) || 8_000;
|
|
|
|
/**
|
|
* How deep the REACTIVE (fast-lane) drill may go for PL24.
|
|
*
|
|
* A freshly decoded vehicle used to be walked to the bottom immediately: one
|
|
* Passat produced 1,251 categories, one L200 2,323 — 1.4k-6.8k categories/day
|
|
* from 3-17 decodes, which is exactly the volume that preceded both bans
|
|
* (plv2.md §2.2). Depth 1 = top groups and their direct children; anything
|
|
* deeper is fetched lazily when the user actually opens that node, or by the
|
|
* budgeted backfill lane. Other sources keep MAX_DEPTH.
|
|
*/
|
|
const PL24_FAST_MAX_DEPTH = Number(process.env.PREFETCH_PL24_FAST_DEPTH) || 1;
|
|
|
|
/** Depth ceiling for this source+lane. */
|
|
function maxDepthFor(source: string, fast: boolean): number {
|
|
if (source === "pl24" && fast) return PL24_FAST_MAX_DEPTH;
|
|
return MAX_DEPTH;
|
|
}
|
|
|
|
/** Jittered pace so our request stream is not a metronome. */
|
|
function jitter(ms: number): number {
|
|
return Math.round(ms * (0.5 + Math.random()));
|
|
}
|
|
|
|
/** Test-only surface for the pure helpers above. */
|
|
export const __testables = { maxDepthFor, jitter, isPl24BackfillEnabled };
|
|
|
|
// ── Phase-1 residue exclusion ──
|
|
/**
|
|
* Skip a zero-parts vehicle once this many backfill attempts have completed with
|
|
* still-zero parts (genuine upstream-data gaps — e.g. a VIN in no catalog).
|
|
* Re-picking these every wave wastes capacity and starves the Phase-2 rescan.
|
|
*/
|
|
const NORESULT_MAX_ATTEMPTS = Number(process.env.PREFETCH_NORESULT_MAX) || 2;
|
|
/** TTL for the no-result counter — excluded vehicles retry after this, so a later
|
|
* catalog fix eventually re-fills them (default 7 days). */
|
|
const NORESULT_TTL_S = (Number(process.env.PREFETCH_NORESULT_TTL_DAYS) || 7) * 86_400;
|
|
/** TTL for the "fully fetched" marker. A vehicle whose whole prefetch chain
|
|
* finished WITH parts is marked complete and skipped by the Phase-2 rescan, so
|
|
* the scan stops re-walking finished trees (the old behaviour churned the queue
|
|
* forever). The TTL re-validates periodically so a later upstream catalog change
|
|
* is eventually picked up (default 21 days). Env-tunable. */
|
|
const COMPLETE_TTL_S = (Number(process.env.PREFETCH_COMPLETE_TTL_DAYS) || 21) * 86_400;
|
|
/**
|
|
* Per-vehicle category ceiling — anti-poison guard. A correctly-decoded PL24
|
|
* vehicle has ~100-800 categories (p95 744). A vehicle whose tree blows past
|
|
* this is almost always a decode that failed to resolve the model and landed on
|
|
* the generic ROOT catalog (the whole brand universe — e.g. 5 Opels with
|
|
* model="Opel" produced 420k/102k/21k categories and ZERO parts). Once a
|
|
* vehicle's discovered tree exceeds this, stop drilling it and mark it poison so
|
|
* it never gets re-picked. Env-tunable. */
|
|
const CATEGORY_CAP = Number(process.env.PREFETCH_CATEGORY_CAP) || 3000;
|
|
/** TTL for the poison marker (generic-model / over-cap vehicles). Long, because
|
|
* the underlying cause is a decode bug — re-validate monthly. */
|
|
const POISON_TTL_S = (Number(process.env.PREFETCH_POISON_TTL_DAYS) || 30) * 86_400;
|
|
|
|
@Injectable()
|
|
export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|
private readonly logger = new Logger(PrefetchWorkerService.name);
|
|
private worker: Worker | null = null;
|
|
private fastWorker?: Worker;
|
|
private backfillKickoff?: ReturnType<typeof setTimeout>;
|
|
private backfillInterval?: ReturnType<typeof setInterval>;
|
|
|
|
constructor(
|
|
@Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue,
|
|
@Inject(CATALOG_PREFETCH_FAST_QUEUE) private fastQueue: Queue,
|
|
private categoriesService: CategoriesService,
|
|
private redis: RedisService,
|
|
private posthog: PostHogService,
|
|
@Inject(DATABASE) private db: Database,
|
|
) {}
|
|
|
|
onModuleInit() {
|
|
this.worker = new Worker(
|
|
QUEUE_NAMES.CATALOG_PREFETCH,
|
|
// Run every job inside the backfill context so source services' touchActivity()
|
|
// skips the user-activity cooldown key — the worker must not throttle itself.
|
|
(job, token) => backfillContext.run(true, () => this.process(job, token)),
|
|
{
|
|
connection: getBullConnection(),
|
|
concurrency: WORKER_CONCURRENCY,
|
|
limiter: { max: WORKER_RATE_MAX, duration: 60_000 },
|
|
},
|
|
);
|
|
|
|
this.worker.on("failed", (job, err) => {
|
|
// RateLimitError is handled in process() via moveToDelayed (a DelayedError,
|
|
// not a failure), so anything reaching here is a genuine failure.
|
|
this.logger.warn(`[prefetch] Job ${job?.name} failed: ${err.message}`);
|
|
});
|
|
|
|
this.worker.on("error", (err) => {
|
|
this.logger.error(`[prefetch] Worker error: ${err.message}`);
|
|
});
|
|
|
|
// FAST lane: own queue + worker so fresh decodes never compete with the
|
|
// deep-drill backlog (whose production>consumption regime starves lifo tails).
|
|
// Same process fn; the shared per-source redis rate gate still protects
|
|
// upstream across BOTH workers.
|
|
this.fastWorker = new Worker(
|
|
QUEUE_NAMES.CATALOG_PREFETCH_FAST,
|
|
(job, token) => backfillContext.run(true, () => this.process(job, token)),
|
|
{
|
|
connection: getBullConnection(),
|
|
concurrency: 4,
|
|
limiter: { max: 120, duration: 60_000 },
|
|
},
|
|
);
|
|
this.fastWorker.on("failed", (job, err) => {
|
|
this.logger.warn(`[prefetch:fast] Job ${job?.name} failed: ${err.message}`);
|
|
});
|
|
this.fastWorker.on("error", (err) => {
|
|
this.logger.error(`[prefetch:fast] Worker error: ${err.message}`);
|
|
});
|
|
this.logger.log("[prefetch] Fast worker started (concurrency=4, 120/min)");
|
|
|
|
this.logger.log(
|
|
`[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, global ${WORKER_RATE_MAX}/min, ` +
|
|
`per-source ${JSON.stringify(SOURCE_RATE_MAX)}, daily ${JSON.stringify(SOURCE_DAILY_MAX)}, pcatPace=${PCAT_PACE_MS}ms)`,
|
|
);
|
|
|
|
// Hourly backfill scan — run IN-PROCESS, not as a BullMQ cron job. A cron
|
|
// scheduler enqueues a delayed marker; BullMQ promotes delayed jobs to the
|
|
// wait-list HEAD with LPUSH (lifo is ignored on delayed promotion) while the
|
|
// worker pops from the tail, so behind a deep prefetch backlog the scan marker
|
|
// is buried for days and never fires. An in-process timer sidesteps the queue
|
|
// entirely; the scan's Phase-1 then enqueues `lifo` init jobs that DO jump the
|
|
// wait list. Prod-host gated (same as the scan body); single-fired via a Redis
|
|
// NX lock so restarts / multiple instances don't double-scan.
|
|
if (isCatalogBackfillEnabled()) {
|
|
const run = () =>
|
|
backfillContext.run(true, () =>
|
|
this.runBackfillScan().catch((err) =>
|
|
this.logger.error(`[backfill] scan crashed: ${(err as Error).message}`),
|
|
),
|
|
);
|
|
this.backfillKickoff = setTimeout(run, BACKFILL_SCAN_KICKOFF_MS);
|
|
this.backfillInterval = setInterval(run, BACKFILL_SCAN_INTERVAL_MS);
|
|
this.logger.log("[backfill] in-process hourly scan enabled");
|
|
}
|
|
}
|
|
|
|
async onModuleDestroy() {
|
|
if (this.backfillKickoff) clearTimeout(this.backfillKickoff);
|
|
if (this.backfillInterval) clearInterval(this.backfillInterval);
|
|
if (this.worker) {
|
|
await this.fastWorker?.close();
|
|
await this.worker.close();
|
|
this.worker = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hourly scan entry point used by the in-process timer. A Redis NX lock keeps it
|
|
* single-fire across restarts and (future) multiple instances; processBackfillScan
|
|
* is idempotent, so a missed cycle is harmless.
|
|
*/
|
|
private async runBackfillScan(): Promise<void> {
|
|
const got = await this.redis.setNx("prefetch:backfill:lock", "1", BACKFILL_SCAN_LOCK_TTL_S);
|
|
if (!got) return;
|
|
await this.processBackfillScan();
|
|
}
|
|
|
|
private async process(job: Job, token?: string): Promise<void> {
|
|
try {
|
|
const data = job.data as { source?: string };
|
|
// Per-source rate gate FIRST (before the pcat pace) so we don't burn the 15s
|
|
// sleep on a job we're about to defer. Scan jobs are exempt.
|
|
if (
|
|
data.source &&
|
|
(job.name === "prefetch-init" ||
|
|
job.name === "prefetch-children" ||
|
|
job.name === "prefetch-parts")
|
|
) {
|
|
const lane = (job.data as { fast?: boolean }).fast ? "fast" : "main";
|
|
// GATE ORDER IS LOAD-BEARING — cheapest first, and every gate that can
|
|
// reject the job must run BEFORE any counter is debited.
|
|
//
|
|
// The business-hours window and the cooldown used to sit at the top of
|
|
// each handler, i.e. AFTER the per-minute counter and the daily budget
|
|
// had already been charged. So a job that woke outside the window paid a
|
|
// budget unit to do nothing. Combined with a deferral target of "next UTC
|
|
// midnight" (= 03:00 Europe/Istanbul, six hours before a 09:00 window
|
|
// opens) that closed a loop: the whole daily allowance was burned by
|
|
// no-op wake-ups before the window ever opened, so the source never ran
|
|
// again. Measured on prod 2026-09-20 — pl24 at 600/600 with 0 catalog
|
|
// requests and 0 new categories for the day.
|
|
//
|
|
// 1. Window: pure clock arithmetic, no I/O, and an out-of-window job can
|
|
// never do useful work — so nothing else is worth spending on it.
|
|
checkTimeWindow(data.source);
|
|
// 2. Cooldown: one Redis TTL read. Pauses the whole worker (see catch).
|
|
await checkCooldown(this.redis, data.source);
|
|
// 3. Per-minute ceiling.
|
|
await this.checkSourceRate(data.source, lane);
|
|
// 4. Daily budget last: a job deferred on any gate above never reaches
|
|
// here, so only jobs about to do real work are counted. The lane
|
|
// decides which threshold applies (backfill stops at the main limit,
|
|
// the user's fast lane may use the full budget).
|
|
await this.checkSourceDailyBudget(data.source, lane);
|
|
}
|
|
if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) {
|
|
await new Promise((r) => setTimeout(r, PCAT_PACE_MS));
|
|
}
|
|
// PL24 backfill only: pace + jitter. The fast (user) lane is never delayed.
|
|
if (
|
|
data.source === "pl24" &&
|
|
PL24_PACE_MS > 0 &&
|
|
!(job.data as { fast?: boolean }).fast &&
|
|
(job.name === "prefetch-children" || job.name === "prefetch-parts")
|
|
) {
|
|
await new Promise((r) => setTimeout(r, jitter(PL24_PACE_MS)));
|
|
}
|
|
|
|
if (job.name === "backfill-scan") {
|
|
return await this.processBackfillScan();
|
|
}
|
|
if (job.name === "prefetch-init") {
|
|
return await this.processInit(job as Job<PrefetchInitJobData>);
|
|
}
|
|
if (job.name === "prefetch-children") {
|
|
return await this.processChildren(job as Job<PrefetchCategoryJobData>);
|
|
}
|
|
if (job.name === "prefetch-parts") {
|
|
return await this.processParts(job as Job<PrefetchCategoryJobData>);
|
|
}
|
|
this.logger.warn(`[prefetch] Unknown job name: ${job.name}`);
|
|
} catch (err) {
|
|
if (err instanceof RateLimitError) {
|
|
const delayMs = Math.max(1000, err.retryAfterMs);
|
|
if (err.cause === "cooldown") {
|
|
// Pause the WHOLE worker for the cooldown window: the per-source
|
|
// activity key is refreshed by every user request, so per-job defer
|
|
// livelocks (job comes back, key is still set, defer again, …). One
|
|
// global wait is correct: when it ends and cooldown is still active,
|
|
// we'll pause again — at most ~1 cycle per retryAfterMs instead of
|
|
// cycling every queued job through.
|
|
const lane = (job.data as { fast?: boolean }).fast ? this.fastWorker : this.worker;
|
|
await lane?.rateLimit(delayMs);
|
|
this.logger.debug(`[prefetch] Worker paused ${delayMs}ms (cooldown ${job.data.source})`);
|
|
throw Worker.RateLimitError();
|
|
}
|
|
// time-window (off-hours, up to hours) and source-rate (this source hit
|
|
// its per-minute ceiling, ~sub-minute) both defer PER-JOB so other sources
|
|
// keep flowing — only cooldown pauses the whole worker.
|
|
await job.moveToDelayed(Date.now() + delayMs, token);
|
|
this.logger.debug(`[prefetch] Job ${job.name} deferred ${delayMs}ms (${err.cause})`);
|
|
throw new DelayedError();
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Init job: walk the category tree for a vehicle and queue sub-jobs.
|
|
*/
|
|
private async processInit(job: Job<PrefetchInitJobData>): Promise<void> {
|
|
const { vehicleId, source, fast = false } = job.data;
|
|
this.logger.log(`[prefetch] Init for vehicle=${vehicleId}, source=${source}`);
|
|
|
|
// Cooldown + time-window are enforced in process() before the daily budget
|
|
// is debited — see the comment there; re-checking here would be a no-op.
|
|
|
|
// Already flagged as poison (tree exceeded CATEGORY_CAP on a prior run) — skip.
|
|
if (await this.redis.exists(this.poisonKey(vehicleId))) {
|
|
this.logger.debug(`[prefetch] Skip poison vehicle=${vehicleId}`);
|
|
return;
|
|
}
|
|
|
|
// Verify vehicle still exists
|
|
const [vehicle] = await this.db
|
|
.select({ id: vehicles.id })
|
|
.from(vehicles)
|
|
.where(eq(vehicles.id, vehicleId))
|
|
.limit(1);
|
|
|
|
if (!vehicle) {
|
|
this.logger.warn(`[prefetch] Vehicle ${vehicleId} not found, skipping`);
|
|
return;
|
|
}
|
|
|
|
await initProgress(this.redis, vehicleId); // resets progress.total — the cap's tree-size proxy
|
|
|
|
// Get all top-level categories for this vehicle
|
|
let topCategories = await this.db
|
|
.select()
|
|
.from(categories)
|
|
.where(and(eq(categories.vehicleId, vehicleId), isNull(categories.parentId)));
|
|
|
|
// Self-seed: a vehicle that was decoded but never viewed has no categories in
|
|
// DB yet. getCategoryTree fetches & inserts the top-level groups from upstream
|
|
// (PL24/PSA/EMEX) so backfill can proceed without a user opening the page.
|
|
// Cooldown/time-window were already enforced above, so this only runs in-window.
|
|
if (topCategories.length === 0) {
|
|
try {
|
|
await this.categoriesService.getCategoryTree(vehicleId);
|
|
topCategories = await this.db
|
|
.select()
|
|
.from(categories)
|
|
.where(and(eq(categories.vehicleId, vehicleId), isNull(categories.parentId)));
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`[prefetch] Top-category seed failed for ${vehicleId}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (topCategories.length === 0) {
|
|
this.logger.log(`[prefetch] No categories for vehicle=${vehicleId}`);
|
|
// No categories after a self-seed attempt = no catalog data for this VIN.
|
|
// Count it as residue so Phase-1 stops re-picking it every wave.
|
|
await this.markNoResult(vehicleId);
|
|
await updateProgress(this.redis, vehicleId, {
|
|
status: "completed",
|
|
total: 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let queued = 0;
|
|
|
|
for (const cat of topCategories) {
|
|
if (cat.unavailable) continue;
|
|
|
|
// Check if this category already has children in DB
|
|
const [childCheck] = await this.db
|
|
.select({ id: categories.id })
|
|
.from(categories)
|
|
.where(eq(categories.parentId, cat.id))
|
|
.limit(1);
|
|
|
|
if (childCheck) {
|
|
// Has children — queue recursive exploration of children
|
|
const children = await this.db
|
|
.select()
|
|
.from(categories)
|
|
.where(eq(categories.parentId, cat.id));
|
|
|
|
for (const child of children) {
|
|
if (child.unavailable) continue;
|
|
// Count jobs ACTUALLY queued, not nodes walked — see addJob's doc: an
|
|
// inflated progress.total makes the chain never reach "finished".
|
|
queued += await this.queueCategoryJob(child, vehicleId, source, 1, fast);
|
|
}
|
|
} else if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups, cat.linkWid)) {
|
|
// Leaf — check if parts already fetched
|
|
const [partCheck] = await this.db
|
|
.select({ id: parts.id })
|
|
.from(parts)
|
|
.where(eq(parts.categoryId, cat.id))
|
|
.limit(1);
|
|
|
|
if (
|
|
!partCheck &&
|
|
cat.linkPath &&
|
|
(await this.addJob("prefetch-parts", {
|
|
vehicleId,
|
|
categoryId: cat.id,
|
|
source,
|
|
action: "parts" as const,
|
|
depth: 0,
|
|
fast,
|
|
}))
|
|
) {
|
|
queued++;
|
|
}
|
|
} else if (
|
|
cat.linkPath &&
|
|
// Non-leaf without children — needs children fetch
|
|
(await this.addJob("prefetch-children", {
|
|
vehicleId,
|
|
categoryId: cat.id,
|
|
source,
|
|
action: "children" as const,
|
|
depth: 0,
|
|
fast,
|
|
}))
|
|
) {
|
|
queued++;
|
|
}
|
|
}
|
|
|
|
await updateProgress(this.redis, vehicleId, { total: queued });
|
|
if (queued === 0) {
|
|
// Nothing left to fetch — the tree is already complete in DB. Without this
|
|
// there is no job to fire incrementCompleted, so progress sits at {0,0}, the
|
|
// vehicle is never marked fullyFetched, and the scan re-picks it every wave
|
|
// forever while burning a daily-budget slot each time.
|
|
this.logger.log(`[prefetch] Nothing to queue for vehicle=${vehicleId} — already complete`);
|
|
await this.finalizeVehicle(vehicleId);
|
|
return;
|
|
}
|
|
this.logger.log(`[prefetch] Queued ${queued} sub-jobs for vehicle=${vehicleId}`);
|
|
}
|
|
|
|
/**
|
|
* Fetch children (sub-categories) for a category.
|
|
*/
|
|
private async processChildren(job: Job<PrefetchCategoryJobData>): Promise<void> {
|
|
const { vehicleId, categoryId, source, depth, fast = false } = job.data;
|
|
this.logger.log(`[prefetch] Children for category=${categoryId}, depth=${depth}`);
|
|
|
|
// Cooldown + time-window are enforced in process() before the daily budget
|
|
// is debited — see the comment there; re-checking here would be a no-op.
|
|
|
|
const depthCeiling = maxDepthFor(source, fast);
|
|
if (depth >= depthCeiling) {
|
|
// For the PL24 fast lane this is the normal stopping point, not a problem:
|
|
// deeper nodes are drilled lazily on user click or by the backfill lane.
|
|
const level = source === "pl24" && fast ? "log" : "warn";
|
|
this.logger[level](
|
|
`[prefetch] Depth ceiling ${depthCeiling} reached for category=${categoryId} (source=${source}, lane=${fast ? "fast" : "main"})`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Anti-poison cap measured from the DB, NOT from progress.total: the ephemeral
|
|
// counter is reset to 0 by every processInit, so a re-picked vehicle could
|
|
// drill another CATEGORY_CAP nodes per round and never trip the guard (the
|
|
// generic-ROOT Opels: 420k/102k/21k categories, ZERO parts). The stored tree
|
|
// is the real, cumulative size.
|
|
const [capRow] = await this.db
|
|
.select({ n: sql<number>`count(*)::int` })
|
|
.from(categories)
|
|
.where(eq(categories.vehicleId, vehicleId))
|
|
.limit(1);
|
|
if ((capRow?.n ?? 0) >= CATEGORY_CAP) {
|
|
this.logger.warn(
|
|
`[prefetch] Category cap ${CATEGORY_CAP} hit for vehicle=${vehicleId} (tree=${capRow?.n}) — marking poison, stop drilling`,
|
|
);
|
|
await this.markPoison(vehicleId);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const children = await this.categoriesService.getChildren(categoryId);
|
|
|
|
let queued = 0;
|
|
for (const child of children) {
|
|
if (child.unavailable) continue;
|
|
// Real queued-job count (see addJob) — walking a node that dedupes or
|
|
// already has parts must not inflate progress.total.
|
|
queued += await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
|
}
|
|
|
|
if (queued > 0) {
|
|
await updateProgress(this.redis, vehicleId, {
|
|
total:
|
|
((await this.redis.getJson<{ total: number }>(`prefetch:progress:${vehicleId}`))
|
|
?.total || 0) + queued,
|
|
});
|
|
}
|
|
|
|
await this.incrementCompleted(vehicleId);
|
|
} catch (err) {
|
|
if (err instanceof RateLimitError) throw err;
|
|
this.logger.error(
|
|
`[prefetch] Children fetch failed for ${categoryId}: ${(err as Error).message}`,
|
|
);
|
|
await this.incrementErrors(vehicleId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch parts + schema for a leaf category.
|
|
*/
|
|
private async processParts(job: Job<PrefetchCategoryJobData>): Promise<void> {
|
|
const { vehicleId, categoryId, source } = job.data;
|
|
this.logger.log(`[prefetch] Parts for category=${categoryId}`);
|
|
|
|
// Cooldown + time-window are enforced in process() before the daily budget
|
|
// is debited — see the comment there; re-checking here would be a no-op.
|
|
|
|
try {
|
|
await this.categoriesService.getCategoryWithParts(categoryId);
|
|
await this.incrementCompleted(vehicleId);
|
|
} catch (err) {
|
|
if (err instanceof RateLimitError) throw err;
|
|
this.logger.error(
|
|
`[prefetch] Parts fetch failed for ${categoryId}: ${(err as Error).message}`,
|
|
);
|
|
await this.incrementErrors(vehicleId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Backfill scan (hourly cron): find decoded vehicles whose catalog isn't fully
|
|
* prefetched and queue prefetch-init for them. prefetch-init is idempotent — it
|
|
* only fetches missing categories/parts, so re-running over a complete vehicle is
|
|
* cheap. Self-throttling via a queue-depth guard, per-source cooldown, and the
|
|
* business-hours window. Goal: no decoded vehicle is left without catalog data.
|
|
*/
|
|
private async processBackfillScan(): Promise<void> {
|
|
// Production (sase.tr) only — dev.sase.tr uses a separate DB and must never
|
|
// sweep/scrape. Defense-in-depth in case a scan job lands here via a stale
|
|
// scheduler. NODE_ENV is "production" on both envs, so gate on the prod host.
|
|
if (!isCatalogBackfillEnabled()) {
|
|
this.logger.log("[backfill] Skip — not prod host");
|
|
return;
|
|
}
|
|
|
|
// Live-tunable knobs via the `cfg-backfill-tuning` remote-config flag — change
|
|
// batch size / backlog ceiling / business-hours gating from the PostHog UI
|
|
// without a redeploy. Falls back to the compiled-in defaults on any miss.
|
|
const cfg = await this.posthog.payload<{
|
|
batchSize?: number;
|
|
maxBacklog?: number;
|
|
businessHoursOnly?: boolean;
|
|
}>("cfg-backfill-tuning", {});
|
|
const batchSize =
|
|
typeof cfg.batchSize === "number" && cfg.batchSize > 0 ? cfg.batchSize : BACKFILL_BATCH_SIZE;
|
|
const maxBacklog =
|
|
typeof cfg.maxBacklog === "number" && cfg.maxBacklog > 0
|
|
? cfg.maxBacklog
|
|
: BACKFILL_MAX_BACKLOG;
|
|
|
|
// Self-throttle: when the queue is already deep, suspend only the Phase-2
|
|
// rolling rescan (the part that piles on). Phase-1 still runs every wave so
|
|
// genuinely-empty vehicles keep getting onboarded through the fast lane even
|
|
// while a large deep-drill backlog is still draining — otherwise a single
|
|
// backlog spike freezes new-vehicle coverage until the whole queue clears.
|
|
// Two INDEPENDENT ceilings (Phase-1 fast lane is never gated, so genuinely
|
|
// empty vehicles keep getting onboarded):
|
|
// 1. pressure = waiting + active + delayed-due-within-PRESSURE_HORIZON_MS,
|
|
// both lanes, vs maxBacklog — "are the workers actually swamped?".
|
|
// Budget/off-hours defers are parked hours out and no longer count, so a
|
|
// spent daily budget stops freezing Phase-2 for the rest of the day.
|
|
// 2. total = every pending job, both lanes, vs HARD_MAX_TOTAL_JOBS — the
|
|
// runaway backstop that `delayed` used to provide by accident.
|
|
const depth = await this.getQueueDepth();
|
|
const headroom = Math.max(0, HARD_MAX_TOTAL_JOBS - depth.total);
|
|
// Admission control in JOB units: one wave fans out to batchSize * ~400 jobs
|
|
// long before the next hourly scan can react, so shrink the wave as the pool
|
|
// fills instead of stepping over the cap by a whole batch.
|
|
const phase2Budget = Math.min(batchSize, Math.floor(headroom / EST_JOBS_PER_VEHICLE));
|
|
const phase2Allowed = depth.pressure <= maxBacklog && phase2Budget > 0;
|
|
if (!phase2Allowed) {
|
|
this.logger.log(
|
|
`[backfill] Phase-1 (fast lane) only — pressure=${depth.pressure}/${maxBacklog}, ` +
|
|
`total=${depth.total}/${HARD_MAX_TOTAL_JOBS} (delayed=${depth.delayed}, imminent=${depth.imminent})`,
|
|
);
|
|
}
|
|
if (HARD_MAX_TOTAL_JOBS > 0 && depth.total > HARD_MAX_TOTAL_JOBS * 1.5) {
|
|
// Should be unreachable — admission control caps intake once per hour. If it
|
|
// fires, real fan-out is far above EST_JOBS_PER_VEHICLE.
|
|
this.logger.error(
|
|
`[backfill] Queue pool ${depth.total} > 1.5x hard cap ${HARD_MAX_TOTAL_JOBS} — investigate fan-out`,
|
|
);
|
|
}
|
|
|
|
// Only target sources eligible right now: not in cooldown (user active),
|
|
// inside their scrape window, AND still inside today's main-lane budget.
|
|
// The budget check is THE key guard: deferring at job level only MOVES work
|
|
// into `delayed`, it does not stop PRODUCING it — and budget defers never
|
|
// exhaust attempts (moveToDelayed skipAttempt), so nothing drops them. Once a
|
|
// source's main-lane budget is spent the scan must stop feeding it for the
|
|
// rest of the UTC day; feeding a throttled source is exactly how the 470k
|
|
// runaway was built. This also preserves the fast-lane reserve for real users.
|
|
const eligible: string[] = [];
|
|
for (const s of BACKFILL_SOURCES) {
|
|
// Background backfill is opt-in per source; pl24 defaults to OFF so bulk
|
|
// load can never burn the one surviving account by accident.
|
|
if (s === "pl24" && !isPl24BackfillEnabled()) continue;
|
|
if (await this.redis.exists(`prefetch:activity:${s}`)) continue;
|
|
if (cfg.businessHoursOnly !== false && !isWithinTimeWindow(s)) continue;
|
|
const mainLimit = this.dailyMainLimit(s);
|
|
if (mainLimit > 0) {
|
|
const spent = Number((await this.redis.get(this.dailyKey(s))) ?? 0);
|
|
if (spent >= mainLimit) {
|
|
this.logger.log(
|
|
`[backfill] ${s} daily budget spent (${spent}/${mainLimit}) — source skipped this wave`,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
eligible.push(s);
|
|
}
|
|
if (eligible.length === 0) {
|
|
this.logger.log("[backfill] Skip — no eligible sources (cooldown / off-hours)");
|
|
return;
|
|
}
|
|
|
|
const picked: Array<{ id: string; source: string; fast: boolean }> = [];
|
|
const seen = new Set<string>();
|
|
const overfetch = batchSize * 4; // headroom for in-flight skips
|
|
|
|
const tryPick = async (
|
|
v: { id: string; source: string | null },
|
|
fast: boolean,
|
|
limit: number = batchSize,
|
|
): Promise<void> => {
|
|
if (picked.length >= limit || seen.has(v.id) || !v.source) return;
|
|
// TWO different in-flight guards, both must be clear:
|
|
// - prefetch:scheduled:backfill:<id> — ours (36h, covers a budget-parked chain)
|
|
// - prefetch:scheduled:<id> — the USER decode path's (vehicles.service.ts
|
|
// schedulePrefetch, short TTL). We deliberately stopped WRITING the bare
|
|
// key: our long TTL made schedulePrefetch() silently skip the user's
|
|
// fresh-decode fast-lane init.
|
|
if (await this.redis.exists(this.scheduledKey(v.id))) return;
|
|
if (await this.redis.exists(`prefetch:scheduled:${v.id}`)) return; // user chain in flight
|
|
// Skip exhausted residue: vehicles whose prefetch keeps finishing with zero
|
|
// parts (no catalog data). They'd otherwise be re-picked every wave forever.
|
|
const noResult = await this.redis.get(this.noResultKey(v.id));
|
|
if (noResult && Number(noResult) >= NORESULT_MAX_ATTEMPTS) return;
|
|
// Skip vehicles whose full tree is already fetched (marker set on chain
|
|
// completion with parts). Stops Phase-2 from re-walking finished vehicles
|
|
// every wave; the marker's TTL re-validates them periodically. Phase-1
|
|
// (zero-parts) vehicles never carry this marker, so this is a no-op there.
|
|
if (await this.redis.exists(this.completeKey(v.id))) return;
|
|
// Skip poison vehicles (generic-model / over-cap catalog explosions).
|
|
if (await this.redis.exists(this.poisonKey(v.id))) return;
|
|
seen.add(v.id);
|
|
picked.push({ id: v.id, source: v.source, fast });
|
|
};
|
|
|
|
// Phase 1 — clear the obvious backlog first: decoded vehicles with zero parts.
|
|
const noParts = await this.db
|
|
.select({ id: vehicles.id, source: vehicles.source })
|
|
.from(vehicles)
|
|
.where(
|
|
and(
|
|
inArray(vehicles.source, eligible),
|
|
notExists(
|
|
this.db.select({ one: sql`1` }).from(parts).where(eq(parts.vehicleId, vehicles.id)),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(asc(vehicles.createdAt))
|
|
.limit(overfetch);
|
|
|
|
for (const v of noParts) await tryPick(v, true);
|
|
|
|
// Phase 2 — rolling rescan of vehicles that are NOT fully fetched, to gap-fill
|
|
// partials. Targets the DURABLE `fullyFetched` flag (indexed) instead of
|
|
// walking the whole fleet and relying only on the ephemeral 21-day
|
|
// `prefetch:complete:` marker — a Redis flush would otherwise re-drill every
|
|
// vehicle at once. The fullyFetchedAt clause keeps the periodic re-validation
|
|
// the TTL used to provide. Gated by the ceilings above + a JOB-unit budget.
|
|
const phase2Limit = Math.min(batchSize, picked.length + phase2Budget);
|
|
if (phase2Allowed && picked.length < phase2Limit) {
|
|
const cursorObj = await this.redis.getJson<{ ts: string }>(BACKFILL_CURSOR_KEY);
|
|
const cursor = cursorObj?.ts ? new Date(cursorObj.ts) : new Date(0);
|
|
|
|
const rolling = await this.db
|
|
.select({ id: vehicles.id, source: vehicles.source, createdAt: vehicles.createdAt })
|
|
.from(vehicles)
|
|
.where(
|
|
and(
|
|
inArray(vehicles.source, eligible),
|
|
gt(vehicles.createdAt, cursor),
|
|
sql`(${vehicles.fullyFetched} = false OR ${vehicles.fullyFetchedAt} < now() - interval '21 days')`,
|
|
),
|
|
)
|
|
.orderBy(asc(vehicles.createdAt))
|
|
.limit(overfetch);
|
|
|
|
if (rolling.length === 0 && cursor.getTime() > 0) {
|
|
// Reached the end — restart the rescan from the beginning next wave.
|
|
await this.redis.setJson(
|
|
BACKFILL_CURSOR_KEY,
|
|
{ ts: new Date(0).toISOString() },
|
|
30 * 86400,
|
|
);
|
|
}
|
|
|
|
let lastTs: Date | null = null;
|
|
for (const v of rolling) {
|
|
lastTs = v.createdAt;
|
|
await tryPick(v, false, phase2Limit);
|
|
}
|
|
if (lastTs) {
|
|
await this.redis.setJson(BACKFILL_CURSOR_KEY, { ts: lastTs.toISOString() }, 30 * 86400);
|
|
}
|
|
}
|
|
|
|
if (picked.length === 0) {
|
|
this.logger.log("[backfill] No candidates this wave");
|
|
return;
|
|
}
|
|
|
|
for (const v of picked) await this.enqueueInit(v.id, v.source, v.fast);
|
|
const fastCount = picked.filter((v) => v.fast).length;
|
|
this.logger.log(
|
|
`[backfill] Queued ${picked.length} vehicle(s) (${fastCount} fast-lane, ` +
|
|
`sources=${eligible.join(",")}, pressure=${depth.pressure}, total=${depth.total}, ` +
|
|
`phase2Budget=${phase2Budget})`,
|
|
);
|
|
}
|
|
|
|
/** Queue a prefetch-init for a vehicle and set the in-flight guard. */
|
|
private async enqueueInit(vehicleId: string, source: string, fast = false): Promise<void> {
|
|
const q = fast ? this.fastQueue : this.queue;
|
|
await q.add(
|
|
"prefetch-init",
|
|
{ vehicleId, source: source as PrefetchInitJobData["source"], fast },
|
|
{
|
|
removeOnComplete: { count: 1000 },
|
|
removeOnFail: { count: 5000 },
|
|
// Fast lane (Phase-1): lifo so a zero-parts vehicle's whole chain jumps
|
|
// the deep-drill backlog instead of queueing behind it (see addJob).
|
|
...(fast ? { lifo: true } : {}),
|
|
},
|
|
);
|
|
// Guard cleared on completion (incrementCompleted) or by TTL if the run dies.
|
|
await this.redis.set(this.scheduledKey(vehicleId), "1", BACKFILL_SCHEDULED_TTL);
|
|
}
|
|
|
|
// ==================== Helpers ====================
|
|
|
|
private async queueCategoryJob(
|
|
cat: {
|
|
id: string;
|
|
linkPath: string | null;
|
|
linkWid?: string | null;
|
|
source: string;
|
|
unavailable: boolean;
|
|
hasSubgroups?: boolean | null;
|
|
},
|
|
vehicleId: string,
|
|
source: string,
|
|
depth: number,
|
|
fast = false,
|
|
): Promise<number> {
|
|
if (cat.unavailable) return 0;
|
|
|
|
// A per-part detail node (Mitsubishi `partInfoTable` /details/vinpartinfo) is
|
|
// neither a group nor a listing: its parent's response already carried the
|
|
// part. Queueing it costs one upstream request and returns nothing. Prod had
|
|
// 19,576 of these, with 2 parts between them.
|
|
if (
|
|
cat.source === "pl24" &&
|
|
isPl24PartDetailNode({ linkPath: cat.linkPath, linkWid: cat.linkWid })
|
|
) {
|
|
return 0;
|
|
}
|
|
|
|
if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups, cat.linkWid)) {
|
|
// Leaf — check if already has parts
|
|
const [partCheck] = await this.db
|
|
.select({ id: parts.id })
|
|
.from(parts)
|
|
.where(eq(parts.categoryId, cat.id))
|
|
.limit(1);
|
|
|
|
if (!partCheck && cat.linkPath) {
|
|
return (await this.addJob("prefetch-parts", {
|
|
vehicleId,
|
|
categoryId: cat.id,
|
|
source: source as "pl24" | "emex",
|
|
action: "parts" as const,
|
|
depth,
|
|
fast,
|
|
}))
|
|
? 1
|
|
: 0;
|
|
}
|
|
return 0;
|
|
}
|
|
if (cat.linkPath && depth < MAX_DEPTH) {
|
|
// Non-leaf within the depth cap — explore children. The `depth < MAX_DEPTH`
|
|
// gate mirrors processChildren's early-return: without it we'd enqueue a
|
|
// prefetch-children job that processChildren just drops, burning a rate-limit
|
|
// slot on a no-op (this was ~85% of the queue at MAX_DEPTH=2).
|
|
const [childCheck] = await this.db
|
|
.select({ id: categories.id })
|
|
.from(categories)
|
|
.where(eq(categories.parentId, cat.id))
|
|
.limit(1);
|
|
|
|
if (childCheck) {
|
|
// Already has children — explore them recursively
|
|
const children = await this.db
|
|
.select()
|
|
.from(categories)
|
|
.where(eq(categories.parentId, cat.id));
|
|
|
|
let n = 0;
|
|
for (const child of children) {
|
|
if (child.unavailable) continue;
|
|
n += await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
|
}
|
|
return n;
|
|
}
|
|
return (await this.addJob("prefetch-children", {
|
|
vehicleId,
|
|
categoryId: cat.id,
|
|
source: source as "pl24" | "emex",
|
|
action: "children" as const,
|
|
depth,
|
|
fast,
|
|
}))
|
|
? 1
|
|
: 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
private isLeafLinkPath(
|
|
linkPath: string | null,
|
|
source: string,
|
|
hasSubgroups?: boolean | null,
|
|
linkWid?: string | null,
|
|
): boolean {
|
|
if (!linkPath) return false;
|
|
// EMEX: Vehicle.aspx group nodes are parents to drill; Unit.aspx (hierarchical
|
|
// tree) and QuickDetails.aspx (legacy flat) leaves carry parts.
|
|
if (source === "emex") return !linkPath.includes("Vehicle.aspx");
|
|
// parts-catalogs: EVERY node has a pcat: linkPath, so the old "pcat: prefix =
|
|
// leaf" rule mis-flagged every mid-group folder as a leaf — the worker fetched
|
|
// its "parts" (a 1-level drill that just revealed sub-groups) and never
|
|
// recursed, leaving deep pcat trees a single level shallow (no parts → empty
|
|
// part counts, dead cross-tree search). Use the captured hasSubgroups flag
|
|
// instead: a node is a parts leaf only when it is NOT a known parent group, so
|
|
// folders queue a children job and the recursion drills to full depth. Unknown
|
|
// flag (null, rare pre-migration rows) → treated as leaf, preserving the old
|
|
// 1-level behaviour for those.
|
|
if (source === "parts-catalogs") return hasSubgroups !== true;
|
|
// PL24: one shared classifier (integrations/pl24/pl24-tree). The old inline
|
|
// list was case-sensitive, so p5psa/p5volvo's camelCase `/details/vin/
|
|
// bomDetails` was never recognised as a leaf and its parts were never
|
|
// prefetched.
|
|
// `linkWid` is passed through on purpose: it is the reliable cross-brand
|
|
// marker and the read path has always used it, but this queueing path used
|
|
// to drop it and fall back to path matching alone. That is why Mitsubishi's
|
|
// `detailsTable` parts list was queued as a group here even after the shared
|
|
// classifier learned about it.
|
|
return isPl24LeafNode({ linkPath, hasSubgroups, linkWid });
|
|
}
|
|
|
|
/**
|
|
* Queue one sub-job. Returns whether a NEW job was actually created.
|
|
*
|
|
* The return value is the fix for a completion-accounting bug: `progress.total`
|
|
* is the chain's denominator (incrementCompleted fires "finished" at
|
|
* `completed >= total`), but callers used to increment it for every node they
|
|
* WALKED — including nodes where this deterministic jobId deduped the add, and
|
|
* leaves that already had parts. An inflated total means the chain never
|
|
* reaches "finished", so the vehicle is never marked fullyFetched and the scan
|
|
* re-picks it every wave forever (the ~25 vehicles/day plateau). Queue
|
|
* behaviour is UNCHANGED — BullMQ already no-op'd a duplicate jobId.
|
|
*/
|
|
private async addJob(name: string, data: PrefetchCategoryJobData): Promise<boolean> {
|
|
// BullMQ rejects custom job IDs containing ":" (its key separator), so use
|
|
// "-" instead. The values are UUIDs — the ID only needs to be deterministic
|
|
// (for dedup), not parseable.
|
|
const jobId = `prefetch-${data.vehicleId}-${data.categoryId}-${data.action}`;
|
|
const opts: Record<string, unknown> = {
|
|
jobId,
|
|
// Fast lane (Phase-1 / reactive): add with `lifo` so the job RPUSHes to the
|
|
// TAIL of the wait list, where BullMQ's RPOPLPUSH picks it next — i.e. ahead
|
|
// of the deep deep-drill backlog already sitting in wait. (BullMQ 5 drains
|
|
// the wait list before the prioritized ZSET, so `priority` would do the
|
|
// OPPOSITE here and starve the job behind the backlog; lifo is correct.)
|
|
...(data.fast ? { lifo: true } : {}),
|
|
};
|
|
const q = data.fast ? this.fastQueue : this.queue;
|
|
if (await q.getJob(jobId)) return false;
|
|
|
|
// parts-catalogs pacing is handled per-job in process() (PCAT_PACE_MS) + the
|
|
// limiter. The old cumulative `index * 20s` delay was pathological (the Nth
|
|
// leaf of a vehicle waited N*20s) and is gone.
|
|
|
|
await q.add(name, data, opts);
|
|
return true;
|
|
}
|
|
|
|
private async incrementCompleted(vehicleId: string): Promise<void> {
|
|
const progress = await this.redis.getJson<{
|
|
completed: number;
|
|
total: number;
|
|
}>(`prefetch:progress:${vehicleId}`);
|
|
if (!progress) return;
|
|
|
|
const completed = (progress.completed || 0) + 1;
|
|
const isFinished = completed >= progress.total;
|
|
|
|
await updateProgress(this.redis, vehicleId, {
|
|
completed,
|
|
...(isFinished ? { status: "completed" } : {}),
|
|
});
|
|
|
|
if (isFinished) {
|
|
this.logger.log(`[prefetch] Completed all jobs for vehicle=${vehicleId}`);
|
|
await this.finalizeVehicle(vehicleId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Settle a vehicle whose prefetch chain is done: mark it complete (with parts)
|
|
* or count it as residue (still zero parts), then clear the run's Redis state.
|
|
* Called both when the last sub-job finishes AND when processInit finds there
|
|
* is nothing left to queue — otherwise an already-complete tree would never be
|
|
* settled and the scan would re-pick it forever.
|
|
*/
|
|
private async finalizeVehicle(vehicleId: string): Promise<void> {
|
|
// Genuine residue: the whole chain finished but the vehicle still has no
|
|
// parts (all leaves empty / no catalog data). Count it so Phase-1 stops
|
|
// re-picking it every wave.
|
|
const [hasPart] = await this.db
|
|
.select({ id: parts.id })
|
|
.from(parts)
|
|
.where(eq(parts.vehicleId, vehicleId))
|
|
.limit(1);
|
|
if (hasPart) {
|
|
// Fully fetched with parts → mark complete so the Phase-2 rescan skips it
|
|
// (re-validates after the TTL). A chain with any failed job never reaches
|
|
// isFinished, so partially-fetched vehicles are never marked — they keep
|
|
// getting gap-filled.
|
|
await this.redis.set(this.completeKey(vehicleId), "1", COMPLETE_TTL_S);
|
|
// Durable completeness flag (never expires) — the reliable "% fully
|
|
// fetched" measurement, independent of the ephemeral Redis marker.
|
|
// Re-set on every re-drill completion so fullyFetchedAt tracks last-verified.
|
|
await this.db
|
|
.update(vehicles)
|
|
.set({ fullyFetched: true, fullyFetchedAt: new Date() })
|
|
.where(eq(vehicles.id, vehicleId));
|
|
} else {
|
|
await this.markNoResult(vehicleId);
|
|
}
|
|
// Clean up Redis keys — data is in PostgreSQL now
|
|
await this.redis.del(this.scheduledKey(vehicleId));
|
|
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
|
}
|
|
|
|
private async incrementErrors(vehicleId: string): Promise<void> {
|
|
const progress = await this.redis.getJson<{ errors: number }>(`prefetch:progress:${vehicleId}`);
|
|
if (!progress) return;
|
|
await updateProgress(this.redis, vehicleId, {
|
|
errors: (progress.errors || 0) + 1,
|
|
});
|
|
}
|
|
|
|
private noResultKey(vehicleId: string): string {
|
|
return `prefetch:noresult:${vehicleId}`;
|
|
}
|
|
|
|
/**
|
|
* Backfill's own in-flight guard — namespaced so it can't shadow the user
|
|
* decode path's `prefetch:scheduled:<id>` (vehicles.service.ts reads that key
|
|
* and skips the fresh-decode fast-lane init while it is set).
|
|
*/
|
|
private scheduledKey(vehicleId: string): string {
|
|
return `prefetch:scheduled:backfill:${vehicleId}`;
|
|
}
|
|
|
|
/**
|
|
* Queue depth across BOTH lanes, split into "pressure" (work the workers will
|
|
* pick up within PRESSURE_HORIZON_MS) and "total" (everything pending).
|
|
*
|
|
* BullMQ stores delayed jobs in a ZSET scored `dueMs * 0x1000 + seq` (12-bit
|
|
* collision counter — addDelayedJob / moveToDelayed lua), so "due within X ms"
|
|
* is a plain ZCOUNT with the bound encoded the same way. RedisService exposes
|
|
* no zcount, so we borrow each queue's OWN ioredis connection and its toKey()
|
|
* rather than hand-building `bull:<name>:delayed`.
|
|
*/
|
|
private async getQueueDepth(): Promise<{
|
|
pressure: number;
|
|
total: number;
|
|
imminent: number;
|
|
delayed: number;
|
|
}> {
|
|
// String bound so ioredis can't render it in exponent notation.
|
|
const maxScore = String((Date.now() + PRESSURE_HORIZON_MS + 1) * 0x1000 - 1);
|
|
let pressure = 0;
|
|
let total = 0;
|
|
let imminent = 0;
|
|
let delayed = 0;
|
|
for (const q of [this.queue, this.fastQueue]) {
|
|
const c = await q.getJobCounts("waiting", "active", "delayed", "prioritized");
|
|
const live = (c.waiting ?? 0) + (c.active ?? 0);
|
|
const d = c.delayed ?? 0;
|
|
// Fail CLOSED: if the ZCOUNT can't be taken, count every delayed job as
|
|
// pressure — fall back to the old over-conservative gate rather than
|
|
// silently unlocking Phase-2 with no visibility. A repeating warn here
|
|
// means Phase-2 is suspended again.
|
|
let due = d;
|
|
try {
|
|
const client = await q.client;
|
|
due = await client.zcount(q.toKey("delayed"), "-inf", maxScore);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`[backfill] delayed zcount failed on ${q.name} (${(err as Error).message}) — counting all delayed as pressure`,
|
|
);
|
|
}
|
|
pressure += live + due;
|
|
total += live + d + (c.prioritized ?? 0);
|
|
imminent += due;
|
|
delayed += d;
|
|
}
|
|
return { pressure, total, imminent, delayed };
|
|
}
|
|
|
|
private completeKey(vehicleId: string): string {
|
|
return `prefetch:complete:${vehicleId}`;
|
|
}
|
|
|
|
private poisonKey(vehicleId: string): string {
|
|
return `prefetch:poison:${vehicleId}`;
|
|
}
|
|
|
|
/** Flag a vehicle as poison (its tree blew past CATEGORY_CAP — a generic
|
|
* root-catalog explosion from a decode that didn't resolve the model) so
|
|
* init/scan skip it, and clear its in-flight + progress state. Brand-agnostic:
|
|
* only an actual explosion trips it, so small legit generic-model PSA vehicles
|
|
* (which stay ~200 categories and DO yield parts) are never affected. */
|
|
private async markPoison(vehicleId: string): Promise<void> {
|
|
await this.redis.set(this.poisonKey(vehicleId), "1", POISON_TTL_S);
|
|
await this.redis.del(this.scheduledKey(vehicleId));
|
|
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
|
}
|
|
|
|
/**
|
|
* Per-source fixed-window (60s) rate limit. Increments the source's window
|
|
* counter; once it exceeds the source's ceiling, throws RateLimitError
|
|
* ("source-rate") so process() defers THIS job to the window's end while other
|
|
* sources keep flowing. 0 / unknown source ceiling = unlimited.
|
|
*/
|
|
private async checkSourceRate(source: string, lane: "main" | "fast" = "main"): Promise<void> {
|
|
// Per-LANE budgets: the main worker's deep-drill backlog would otherwise eat
|
|
// the whole per-source window and starve the fast lane with 18s defers
|
|
// (observed: fresh BMW init couldn't get a single pcat slot). Totals per
|
|
// source stay the same as before, so upstream load is unchanged.
|
|
const total = SOURCE_RATE_MAX[source] ?? 0;
|
|
// Park already-queued pl24 MAIN-lane jobs while background backfill is off
|
|
// (long defer, no attempt consumed) — the eligibility scan stops producing
|
|
// new ones, this stops an existing backlog from draining through the account.
|
|
if (source === "pl24" && lane === "main" && !isPl24BackfillEnabled()) {
|
|
throw new RateLimitError(15 * 60_000, "source-rate");
|
|
}
|
|
if (total <= 0) return;
|
|
const fastShare = Math.max(1, Math.floor(total / 3));
|
|
const max = lane === "fast" ? fastShare : total - fastShare;
|
|
const windowMs = 60_000;
|
|
const now = Date.now();
|
|
const key = `prefetch:rate:${lane}:${source}:${Math.floor(now / windowMs)}`;
|
|
const n = await this.redis.incr(key);
|
|
if (n === 1) await this.redis.expire(key, 61);
|
|
if (n > max) {
|
|
throw new RateLimitError(windowMs - (now % windowMs) + 50, "source-rate");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-source rolling-day budget (storm guard). UTC-day fixed window in Redis;
|
|
* when the source's daily job count exceeds its ceiling, defer the job until
|
|
* the window rolls so a long run can't drain the proxy budget. 0 = unlimited.
|
|
*/
|
|
private async checkSourceDailyBudget(
|
|
source: string,
|
|
lane: "main" | "fast" = "main",
|
|
): Promise<void> {
|
|
const max = SOURCE_DAILY_MAX[source] ?? 0;
|
|
if (max <= 0) return;
|
|
const limit = lane === "fast" ? max : this.dailyMainLimit(source);
|
|
const dayMs = 86_400_000;
|
|
const now = Date.now();
|
|
const key = this.dailyKey(source, now);
|
|
|
|
// READ-then-INCR (was INCR-then-check). A REJECTED attempt must not count:
|
|
// the old order inflated the counter with every defer (observed 48531 against
|
|
// a 30000 budget), which (a) made the number useless for capacity decisions
|
|
// and (b) — now that the scan reads the same counter to stop feeding a spent
|
|
// source — would let pure defer churn lock the source out. Worst-case
|
|
// overshoot under the read/incr race is WORKER_CONCURRENCY jobs: acceptable.
|
|
const n = Number((await this.redis.get(key)) ?? 0);
|
|
if (n >= limit) {
|
|
// Defer to the next UTC day, plus up to 45min of JITTER. Without jitter every
|
|
// deferred job wakes in the SAME millisecond (observed: 11495 jobs all at
|
|
// 00:00:01 UTC) — the promotion lands as one burst and the pressure signal
|
|
// flaps. Other sources keep flowing (per-job defer, not a worker pause).
|
|
const rollover = now + dayMs - (now % dayMs) + 1000 + Math.floor(Math.random() * 45 * 60_000);
|
|
// Land the retry INSIDE the source's scrape window. The UTC rollover alone
|
|
// is 03:00 Europe/Istanbul, so with a 09:00 window every deferred job woke
|
|
// six hours early, failed the window check and was deferred again — the
|
|
// other half of the deadlock fixed in process(). alignToWindow is a no-op
|
|
// when no window is configured (the default).
|
|
const msLeft = Math.max(1000, alignToWindow(source, rollover) - now);
|
|
if (n === limit) {
|
|
this.logger.warn(
|
|
`[prefetch] ${source} daily budget hit (lane=${lane}, ${n}/${limit} of ${max}) — ` +
|
|
`deferring ~${Math.round(msLeft / 3_600_000)}h to the next in-window slot`,
|
|
);
|
|
}
|
|
throw new RateLimitError(msLeft, "source-rate");
|
|
}
|
|
const after = await this.redis.incr(key);
|
|
if (after === 1) await this.redis.expire(key, 90_000); // ~25h, outlives the window
|
|
}
|
|
|
|
/** Redis key for a source's UTC-day budget counter (shared by both lanes). */
|
|
private dailyKey(source: string, now = Date.now()): string {
|
|
return `prefetch:daily:${source}:${Math.floor(now / 86_400_000)}`;
|
|
}
|
|
|
|
/** Main (backfill) lane threshold — the fast lane's reserve is never available
|
|
* to backfill, so a sweep can't park the user's fresh-decode chain. */
|
|
private dailyMainLimit(source: string): number {
|
|
const max = SOURCE_DAILY_MAX[source] ?? 0;
|
|
return max <= 0 ? 0 : Math.floor(max * (1 - DAILY_FAST_RESERVE));
|
|
}
|
|
|
|
/** Record that a backfill attempt finished with the vehicle still at zero parts. */
|
|
private async markNoResult(vehicleId: string): Promise<void> {
|
|
const key = this.noResultKey(vehicleId);
|
|
const n = await this.redis.incr(key);
|
|
await this.redis.expire(key, NORESULT_TTL_S);
|
|
this.logger.debug(`[prefetch] no-result #${n} for vehicle=${vehicleId}`);
|
|
}
|
|
}
|