fix(prefetch/faz6): generic-model skip'i KALDIR — PSA'yı yanlış zehirliyordu
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Tespit: generic-model (model==brand) sadece Opel'i değil PSA'yı (Peugeot/Citroën)
da kapsıyor; ama PSA generic-model PATLAMIYOR (~200 kat) ve PARÇA ALIYOR (512/290).
Skip onları yanlışlıkla poison'lar + prefetch'i keserdi. Tek doğru guard =
CATEGORY_CAP (marka-bağımsız; yalnız gerçek patlamada >3000 tetiklenir → Opel'i
durdurur, küçük PSA'yı korur). isGenericModel + processInit gate kaldırıldı,
spec sadeleşti (cap + scan-skip kaldı).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 06:35:59 +03:00
parent 95061dfa53
commit b3dd119c99
2 changed files with 8 additions and 45 deletions

View File

@@ -224,25 +224,9 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
});
});
describe("poison guards (Faz 6: generic-model + category cap)", () => {
type PI = { processInit: (j: unknown) => Promise<void> };
describe("poison guard (Faz 6: category cap — brand-agnostic anti-explosion)", () => {
type PC = { processChildren: (j: unknown) => Promise<void> };
it("processInit skips + marks poison for a generic-model vehicle (model == brand)", async () => {
const { service, queue, redis } = makeDeps({
waiting: 0,
limitResults: [[{ id: "op1", brandName: "Opel", model: "Opel" }]], // vehicle lookup
});
await (service as never as PI).processInit({
data: { vehicleId: "op1", source: "pl24" },
} as never);
const poisonSet = redis.set.mock.calls.some((c) =>
String(c[0]).includes("prefetch:poison:op1"),
);
expect(poisonSet).toBe(true);
expect(queue.add).not.toHaveBeenCalled(); // tree never drilled
});
it("processChildren marks poison once progress.total exceeds CATEGORY_CAP", async () => {
const { service, queue, redis } = makeDeps({ waiting: 0, limitResults: [] });
redis.getJson.mockImplementation(async (...a: unknown[]) =>

View File

@@ -275,7 +275,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
await checkCooldown(this.redis, source);
checkTimeWindow(source);
// Already flagged as poison (over-cap / generic model on a prior run) — skip.
// 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;
@@ -283,7 +283,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
// Verify vehicle still exists
const [vehicle] = await this.db
.select({ id: vehicles.id, brandName: vehicles.brandName, model: vehicles.model })
.select({ id: vehicles.id })
.from(vehicles)
.where(eq(vehicles.id, vehicleId))
.limit(1);
@@ -293,19 +293,6 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
return;
}
// Generic-model guard: when VIN decode fails to resolve the model it stays
// equal to the brand (e.g. model "Opel" for brand "Opel") and the catalog
// fetch lands on the brand's ROOT tree — the whole model universe, hundreds
// of thousands of part-less categories. Don't drill it; mark poison until the
// decode is fixed. A real model is always more specific than the brand.
if (this.isGenericModel(vehicle.brandName, vehicle.model)) {
this.logger.warn(
`[prefetch] Skip generic-model vehicle=${vehicleId} (brand=${vehicle.brandName}, model=${vehicle.model}) — marking poison`,
);
await this.markPoison(vehicleId);
return;
}
await initProgress(this.redis, vehicleId); // resets progress.total — the cap's tree-size proxy
// Get all top-level categories for this vehicle
@@ -823,19 +810,11 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
return `prefetch:poison:${vehicleId}`;
}
/**
* True when VIN decode failed to resolve a model: it stays equal to the brand
* (case-insensitive) or is empty. Such a vehicle can't be catalog-scoped and
* fetches the brand's whole root tree — see CATEGORY_CAP.
*/
private isGenericModel(brandName: string | null, model: string | null): boolean {
const m = (model ?? "").trim();
if (!m) return true;
return m.toLowerCase() === (brandName ?? "").trim().toLowerCase();
}
/** Flag a vehicle as poison (generic-model / over-cap) so init/scan skip it,
* and clear its in-flight + progress state. */
/** 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(`prefetch:scheduled:${vehicleId}`);