perf(backfill): Phase-1 residue exclusion — stop re-picking dead vehicles (Tier 3)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Phase-1 of the backfill scan selects all zero-parts decoded vehicles every wave.
Genuine residue (VINs with no catalog data anywhere — model-indexed HKN,
EMEX-uncovered, etc.) stays zero-parts forever, so it filled the batch every
wave, re-attempting dead vehicles and starving the Phase-2 rolling rescan (its
cursor was stuck for a week).

Track a per-vehicle no-result counter (prefetch:noresult:<id>) incremented when a
prefetch attempt finishes with the vehicle still at zero parts (0 categories in
init, or 0 parts after the whole chain). tryPick skips vehicles past
PREFETCH_NORESULT_MAX (default 2) attempts; the counter has a TTL
(PREFETCH_NORESULT_TTL_DAYS, default 7) so a later catalog fix re-fills them.
Frees capacity for fillable vehicles and lets Phase-2 run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:15:57 +03:00
parent 48aea65b5c
commit bb60a95ec3

View File

@@ -59,6 +59,17 @@ const WORKER_RATE_MAX = Number(process.env.PREFETCH_RATE_MAX) || 20;
*/
const PCAT_PACE_MS = Number(process.env.PREFETCH_PCAT_DELAY_MS) || 15_000;
// ── 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;
@Injectable()
export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrefetchWorkerService.name);
@@ -200,6 +211,9 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
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,
@@ -375,6 +389,10 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
const tryPick = async (v: { id: string; source: string | null }): Promise<void> => {
if (picked.length >= BACKFILL_BATCH_SIZE || seen.has(v.id) || !v.source) return;
if (await this.redis.exists(`prefetch:scheduled:${v.id}`)) return; // already 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;
seen.add(v.id);
picked.push({ id: v.id, source: v.source });
};
@@ -553,6 +571,15 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
if (isFinished) {
this.logger.log(`[prefetch] Completed all jobs for vehicle=${vehicleId}`);
// 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) await this.markNoResult(vehicleId);
// Clean up Redis keys — data is in PostgreSQL now
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
await this.redis.del(`prefetch:progress:${vehicleId}`);
@@ -566,4 +593,16 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
errors: (progress.errors || 0) + 1,
});
}
private noResultKey(vehicleId: string): string {
return `prefetch:noresult:${vehicleId}`;
}
/** 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}`);
}
}