Compare commits
2 Commits
promote-ex
...
fix/backfi
| Author | SHA1 | Date | |
|---|---|---|---|
| a7678acbe1 | |||
| 0de6bd3faa |
@@ -115,6 +115,12 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
opts: {
|
||||
removeOnComplete: { count: 48 },
|
||||
removeOnFail: { count: 100 },
|
||||
// lifo so the scan job lands at the tail of the wait list and runs on
|
||||
// the next tick instead of queueing behind the deep-drill backlog —
|
||||
// otherwise the hourly scan is buried for days and never fires. BullMQ
|
||||
// drains wait (RPOPLPUSH from the tail) before the prioritized ZSET, so
|
||||
// lifo — not `priority` — is what jumps an already-deep wait queue.
|
||||
lifo: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
143
apps/api/src/jobs/prefetch-worker.service.spec.ts
Normal file
143
apps/api/src/jobs/prefetch-worker.service.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PrefetchWorkerService } from "./prefetch-worker.service";
|
||||
|
||||
/**
|
||||
* Chainable drizzle mock: select/from/where/orderBy return the chain; the
|
||||
* terminal limit() yields the next queued result array. Mirrors the trick the
|
||||
* other api specs use (await on a plain array resolves to the array itself).
|
||||
*/
|
||||
function makeDb(limitResults: unknown[][]) {
|
||||
const queued = [...limitResults];
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const m of ["select", "from", "where", "orderBy"]) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
chain.limit = vi.fn(() => queued.shift() ?? []);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
|
||||
const queue = {
|
||||
// typed args so `.mock.calls[i]` is `unknown[]` (not a 0-length tuple) — the
|
||||
// production `nest build` compiles spec files and rejects tuple-index access.
|
||||
add: vi.fn((..._args: unknown[]) => Promise.resolve(undefined)),
|
||||
getJobCounts: vi.fn(async () => ({ waiting: opts.waiting, delayed: 0, active: 0 })),
|
||||
};
|
||||
const redis = {
|
||||
exists: vi.fn(async () => false), // no source cooldown, no in-flight guard
|
||||
get: vi.fn(async () => null), // no no-result residue
|
||||
set: vi.fn(async () => undefined),
|
||||
getJson: vi.fn(async () => null), // Phase-2 cursor empty
|
||||
setJson: vi.fn(async () => undefined),
|
||||
};
|
||||
const posthog = { payload: vi.fn(async () => ({})) }; // compiled-in defaults
|
||||
const db = makeDb(opts.limitResults);
|
||||
const service = new PrefetchWorkerService(
|
||||
queue as never,
|
||||
{} as never, // categoriesService — unused by the scan
|
||||
redis as never,
|
||||
posthog as never,
|
||||
db as never,
|
||||
);
|
||||
return { service, queue, redis, posthog, db };
|
||||
}
|
||||
|
||||
describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.CATALOG_BACKFILL_ENABLED = "true"; // force the prod-host gate on
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.CATALOG_BACKFILL_ENABLED = undefined;
|
||||
});
|
||||
|
||||
describe("enqueueInit", () => {
|
||||
it("sets lifo + data.fast when fast", async () => {
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> })
|
||||
.enqueueInit("v1", "emex", true);
|
||||
const [name, data, jobOpts] = queue.add.mock.calls[0];
|
||||
expect(name).toBe("prefetch-init");
|
||||
expect(data).toMatchObject({ vehicleId: "v1", source: "emex", fast: true });
|
||||
expect(jobOpts).toMatchObject({ lifo: true });
|
||||
});
|
||||
|
||||
it("omits lifo when not fast", async () => {
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> })
|
||||
.enqueueInit("v1", "emex", false);
|
||||
const [, data, jobOpts] = queue.add.mock.calls[0];
|
||||
expect(data).toMatchObject({ fast: false });
|
||||
expect((jobOpts as { lifo?: boolean }).lifo).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addJob (child jobs)", () => {
|
||||
it("sets lifo when data.fast is true", async () => {
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
|
||||
"prefetch-children",
|
||||
{ vehicleId: "v1", categoryId: "c1", source: "emex", action: "children", depth: 1, fast: true },
|
||||
);
|
||||
const [, , jobOpts] = queue.add.mock.calls[0];
|
||||
expect(jobOpts).toMatchObject({ lifo: true });
|
||||
});
|
||||
|
||||
it("omits lifo when data.fast is falsy", async () => {
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
|
||||
"prefetch-children",
|
||||
{ vehicleId: "v1", categoryId: "c1", source: "emex", action: "children", depth: 1 },
|
||||
);
|
||||
const [, , jobOpts] = queue.add.mock.calls[0];
|
||||
expect((jobOpts as { lifo?: boolean }).lifo).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("processBackfillScan", () => {
|
||||
it("runs Phase-1 in the fast lane even when the backlog is far over the ceiling", async () => {
|
||||
// waiting 50k ≫ default maxBacklog 1000 → Phase-2 must be suspended, but a
|
||||
// zero-parts vehicle must still be onboarded with lifo.
|
||||
const { service, queue, redis } = makeDeps({
|
||||
waiting: 50_000,
|
||||
limitResults: [[{ id: "empty1", source: "emex" }]], // Phase-1 query result
|
||||
});
|
||||
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
|
||||
|
||||
expect(queue.add).toHaveBeenCalledTimes(1);
|
||||
const [name, data, jobOpts] = queue.add.mock.calls[0];
|
||||
expect(name).toBe("prefetch-init");
|
||||
expect(data).toMatchObject({ vehicleId: "empty1", fast: true });
|
||||
expect(jobOpts).toMatchObject({ lifo: true });
|
||||
// Phase-2 suspended → the rolling cursor is never read or advanced.
|
||||
expect(redis.getJson).not.toHaveBeenCalled();
|
||||
expect(redis.setJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs Phase-2 (normal lane, no lifo) when the backlog is under the ceiling", async () => {
|
||||
// waiting 10 < 1000 → Phase-1 empty, Phase-2 picks a vehicle with fast=false.
|
||||
const { service, queue, redis } = makeDeps({
|
||||
waiting: 10,
|
||||
limitResults: [
|
||||
[], // Phase-1: no zero-parts vehicles
|
||||
[{ id: "stale1", source: "emex", createdAt: new Date("2026-01-01T00:00:00Z") }], // Phase-2
|
||||
],
|
||||
});
|
||||
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
|
||||
|
||||
expect(queue.add).toHaveBeenCalledTimes(1);
|
||||
const [, data, jobOpts] = queue.add.mock.calls[0];
|
||||
expect(data).toMatchObject({ vehicleId: "stale1", fast: false });
|
||||
expect((jobOpts as { lifo?: boolean }).lifo).toBeUndefined();
|
||||
// Phase-2 ran → cursor advanced.
|
||||
expect(redis.setJson).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips entirely when the backfill gate is off (not prod host)", async () => {
|
||||
process.env.CATALOG_BACKFILL_ENABLED = "false";
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
|
||||
expect(queue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -167,7 +167,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
* Init job: walk the category tree for a vehicle and queue sub-jobs.
|
||||
*/
|
||||
private async processInit(job: Job<PrefetchInitJobData>): Promise<void> {
|
||||
const { vehicleId, source } = job.data;
|
||||
const { vehicleId, source, fast = false } = job.data;
|
||||
this.logger.log(`[prefetch] Init for vehicle=${vehicleId}, source=${source}`);
|
||||
|
||||
await checkCooldown(this.redis, source);
|
||||
@@ -244,7 +244,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
for (const child of children) {
|
||||
if (child.unavailable) continue;
|
||||
await this.queueCategoryJob(child, vehicleId, source, 1);
|
||||
await this.queueCategoryJob(child, vehicleId, source, 1, fast);
|
||||
queued++;
|
||||
}
|
||||
} else if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups)) {
|
||||
@@ -262,6 +262,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
source,
|
||||
action: "parts" as const,
|
||||
depth: 0,
|
||||
fast,
|
||||
});
|
||||
queued++;
|
||||
}
|
||||
@@ -273,6 +274,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
source,
|
||||
action: "children" as const,
|
||||
depth: 0,
|
||||
fast,
|
||||
});
|
||||
queued++;
|
||||
}
|
||||
@@ -286,7 +288,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
* Fetch children (sub-categories) for a category.
|
||||
*/
|
||||
private async processChildren(job: Job<PrefetchCategoryJobData>): Promise<void> {
|
||||
const { vehicleId, categoryId, source, depth } = job.data;
|
||||
const { vehicleId, categoryId, source, depth, fast = false } = job.data;
|
||||
this.logger.log(`[prefetch] Children for category=${categoryId}, depth=${depth}`);
|
||||
|
||||
await checkCooldown(this.redis, source);
|
||||
@@ -303,7 +305,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
let queued = 0;
|
||||
for (const child of children) {
|
||||
if (child.unavailable) continue;
|
||||
await this.queueCategoryJob(child, vehicleId, source, depth + 1);
|
||||
await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
||||
queued++;
|
||||
}
|
||||
|
||||
@@ -378,12 +380,16 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
? cfg.maxBacklog
|
||||
: BACKFILL_MAX_BACKLOG;
|
||||
|
||||
// Self-throttle: don't pile on if the queue is already deep — let it drain.
|
||||
// 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.
|
||||
const counts = await this.queue.getJobCounts("waiting", "delayed", "active");
|
||||
const backlog = (counts.waiting ?? 0) + (counts.delayed ?? 0) + (counts.active ?? 0);
|
||||
if (backlog > maxBacklog) {
|
||||
this.logger.log(`[backfill] Skip — queue backlog ${backlog} > ${maxBacklog}`);
|
||||
return;
|
||||
const phase2Allowed = backlog <= maxBacklog;
|
||||
if (!phase2Allowed) {
|
||||
this.logger.log(`[backfill] Backlog ${backlog} > ${maxBacklog} — Phase-1 (fast lane) only`);
|
||||
}
|
||||
|
||||
// Only target sources eligible right now: not in cooldown (user active) and
|
||||
@@ -399,11 +405,14 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const picked: Array<{ id: string; source: string }> = [];
|
||||
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 }): Promise<void> => {
|
||||
const tryPick = async (
|
||||
v: { id: string; source: string | null },
|
||||
fast: boolean,
|
||||
): Promise<void> => {
|
||||
if (picked.length >= batchSize || 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
|
||||
@@ -411,7 +420,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
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 });
|
||||
picked.push({ id: v.id, source: v.source, fast });
|
||||
};
|
||||
|
||||
// Phase 1 — clear the obvious backlog first: decoded vehicles with zero parts.
|
||||
@@ -429,11 +438,12 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
.orderBy(asc(vehicles.createdAt))
|
||||
.limit(overfetch);
|
||||
|
||||
for (const v of noParts) await tryPick(v);
|
||||
for (const v of noParts) await tryPick(v, true);
|
||||
|
||||
// Phase 2 — rolling rescan of ALL decoded vehicles to gap-fill partially-fetched
|
||||
// ones. A createdAt cursor walks forward and wraps around at the end.
|
||||
if (picked.length < batchSize) {
|
||||
// ones. A createdAt cursor walks forward and wraps around at the end. Gated by
|
||||
// the backlog ceiling (above) so it doesn't pile on while the queue is deep.
|
||||
if (phase2Allowed && picked.length < batchSize) {
|
||||
const cursorObj = await this.redis.getJson<{ ts: string }>(BACKFILL_CURSOR_KEY);
|
||||
const cursor = cursorObj?.ts ? new Date(cursorObj.ts) : new Date(0);
|
||||
|
||||
@@ -456,7 +466,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
let lastTs: Date | null = null;
|
||||
for (const v of rolling) {
|
||||
lastTs = v.createdAt;
|
||||
await tryPick(v);
|
||||
await tryPick(v, false);
|
||||
}
|
||||
if (lastTs) {
|
||||
await this.redis.setJson(BACKFILL_CURSOR_KEY, { ts: lastTs.toISOString() }, 30 * 86400);
|
||||
@@ -468,18 +478,26 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const v of picked) await this.enqueueInit(v.id, v.source);
|
||||
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) (sources=${eligible.join(",")}, backlog=${backlog})`,
|
||||
`[backfill] Queued ${picked.length} vehicle(s) (${fastCount} fast-lane, ` +
|
||||
`sources=${eligible.join(",")}, backlog=${backlog})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue a prefetch-init for a vehicle and set the in-flight guard. */
|
||||
private async enqueueInit(vehicleId: string, source: string): Promise<void> {
|
||||
private async enqueueInit(vehicleId: string, source: string, fast = false): Promise<void> {
|
||||
await this.queue.add(
|
||||
"prefetch-init",
|
||||
{ vehicleId, source: source as PrefetchInitJobData["source"] },
|
||||
{ removeOnComplete: { count: 1000 }, removeOnFail: { count: 5000 } },
|
||||
{ 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(`prefetch:scheduled:${vehicleId}`, "1", BACKFILL_SCHEDULED_TTL);
|
||||
@@ -498,6 +516,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
vehicleId: string,
|
||||
source: string,
|
||||
depth: number,
|
||||
fast = false,
|
||||
): Promise<void> {
|
||||
if (cat.unavailable) return;
|
||||
|
||||
@@ -516,6 +535,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
source: source as "pl24" | "emex",
|
||||
action: "parts" as const,
|
||||
depth,
|
||||
fast,
|
||||
});
|
||||
}
|
||||
} else if (cat.linkPath) {
|
||||
@@ -535,7 +555,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
for (const child of children) {
|
||||
if (child.unavailable) continue;
|
||||
await this.queueCategoryJob(child, vehicleId, source, depth + 1);
|
||||
await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
||||
}
|
||||
} else {
|
||||
await this.addJob("prefetch-children", {
|
||||
@@ -544,6 +564,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
source: source as "pl24" | "emex",
|
||||
action: "children" as const,
|
||||
depth,
|
||||
fast,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -583,6 +604,12 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
// "-" instead. The values are UUIDs — the ID only needs to be deterministic
|
||||
// (for dedup), not parseable.
|
||||
jobId: `prefetch-${data.vehicleId}-${data.categoryId}-${data.action}`,
|
||||
// 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 } : {}),
|
||||
};
|
||||
|
||||
// parts-catalogs pacing is handled per-job in process() (PCAT_PACE_MS) + the
|
||||
|
||||
@@ -4,6 +4,13 @@ export type PrefetchSource = "pl24" | "emex" | "parts-catalogs";
|
||||
export interface PrefetchInitJobData {
|
||||
vehicleId: string;
|
||||
source: PrefetchSource;
|
||||
/**
|
||||
* Fast lane: enqueue this job (and its whole sub-job chain) with BullMQ `lifo`
|
||||
* so it lands at the tail of the wait list and is picked before the deep
|
||||
* deep-drill backlog. Set for Phase-1 (zero-parts) backfill so newly-decoded
|
||||
* vehicles aren't starved behind the rolling rescan. See PrefetchWorkerService.
|
||||
*/
|
||||
fast?: boolean;
|
||||
}
|
||||
|
||||
/** Per-category job: fetches children OR parts */
|
||||
@@ -13,4 +20,6 @@ export interface PrefetchCategoryJobData {
|
||||
source: PrefetchSource;
|
||||
action: "children" | "parts";
|
||||
depth: number;
|
||||
/** Inherited from the init job — keeps the whole chain in the fast lane. */
|
||||
fast?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user