feat(api): fastest-source-wins decode race (PCAT+EMEX parallel, PL24 on PCAT miss)

Replaces the capped-EMEX-then-sequential-PL24 fallback (incl. the Phase-3 8s EMEX
cap) with a dynamic first-definitive race. PCAT + EMEX fire in parallel; the
instant PCAT settles without a single car, PL24 joins the race (a definitive EMEX
would already have returned). Whichever source yields a definitive result FIRST
wins — no artificial wait. At the parts counter the dealer gets the OEM match as
fast as any one source can answer.

- Removes EMEX_RACE_MS (reverts the 8s cap from 9b13c4a).
- PL24 still gated by isDecodeable + circuit breaker, raced against the 25s budget.
- Preserves Phase-1 transient tagging (no negative-cache poisoning), ctx timings,
  and the candidate fallback.
- Adds race tests: PCAT-1-car wins (no PL24); PCAT-miss + EMEX vehicle;
  PCAT-miss + EMEX-miss -> PL24 wins; all-miss -> throws.

Supersedes the Phase-3 EMEX-cap approach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:54:16 +03:00
parent 8e9439450b
commit f5e4de2aab
2 changed files with 223 additions and 152 deletions

View File

@@ -438,6 +438,95 @@ describe("VehiclesService", () => {
expect(partsCatalogsService.decodeVin).toHaveBeenCalled();
expect(emexService.decodeVinOrCandidates).toHaveBeenCalledWith("WBAPH5C55BA123456");
});
// ── Source race: PCAT + EMEX in parallel, PL24 joins the moment PCAT misses ──
function raceDb(savedVehicle: Record<string, unknown>) {
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.innerJoin = vi.fn().mockReturnValue(chain);
chain.leftJoin = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockReturnValue([]);
return {
select: vi.fn().mockReturnValue(chain),
insert: vi.fn().mockImplementation(() => {
const c: Record<string, any> = {};
c.values = vi.fn().mockReturnValue(c);
c.onConflictDoUpdate = vi.fn().mockReturnValue(c);
c.onConflictDoNothing = vi.fn().mockReturnValue(c);
c.returning = vi.fn().mockReturnValue([savedVehicle]);
return c;
}),
};
}
it("PCAT single car wins and PL24 is never raced", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const savedVehicle = { id: "v-pcat", vin: "WVWZZZ1JZ3W597935", source: "parts-catalogs" };
const { service, partsCatalogsService, emexService, pl24Service } = createService(
raceDb(savedVehicle),
);
partsCatalogsService.decodeVin.mockResolvedValue({
cars: [{ id: "c1", name: "Golf", catalogId: "vw", parameters: [] }],
});
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
const result = await service.decodeVin("WVWZZZ1JZ3W597935", "u1");
expect(result).toEqual(savedVehicle);
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
});
it("PCAT miss + EMEX vehicle → decode succeeds via EMEX", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const savedVehicle = { id: "v-emex", vin: "WBAPH5C55BA123456", source: "emex" };
const { service, partsCatalogsService, emexService } = createService(raceDb(savedVehicle));
partsCatalogsService.decodeVin.mockResolvedValue(null);
emexService.decodeVinOrCandidates.mockResolvedValue({
type: "vehicle",
vehicle: { brand: "BMW", model: "320i", year: 2020, engineCode: "N20", raw: {} },
});
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle);
expect(emexService.decodeVinOrCandidates).toHaveBeenCalled();
});
it("PCAT miss + EMEX miss → PL24 is raced and its vehicle wins", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const savedVehicle = { id: "v-pl24", vin: "VR3EFYHZ3PJ674071", source: "pl24" };
const { service, partsCatalogsService, emexService, pl24Service } = createService(
raceDb(savedVehicle),
);
partsCatalogsService.decodeVin.mockResolvedValue(null);
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
pl24Service.isDecodeable.mockReturnValue(true);
pl24Service.getBrandName.mockReturnValue("Peugeot");
pl24Service.decodeVin.mockResolvedValue({
model: "208",
year: 2023,
engineType: "Petrol",
transmission: "Auto",
bodyType: "Hatchback",
});
const result = await service.decodeVin("VR3EFYHZ3PJ674071", "u1");
expect(result).toEqual(savedVehicle);
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VR3EFYHZ3PJ674071", "u1");
});
it("all sources miss → throws and PL24 was attempted", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const { service, partsCatalogsService, emexService, pl24Service } = createService();
partsCatalogsService.decodeVin.mockResolvedValue(null);
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
pl24Service.isDecodeable.mockReturnValue(true);
pl24Service.decodeVin.mockResolvedValue(null);
await expect(service.decodeVin("VR3EFYHZ3PJ674071", "u1")).rejects.toThrow(
"Şase numarası tanınamadı",
);
expect(pl24Service.decodeVin).toHaveBeenCalled();
});
});
describe("getHistory", () => {

View File

@@ -511,11 +511,7 @@ export class VehiclesService {
const corgiKnown = false;
let brandName: string | null = null;
// ── Parallel: PartsCatalogs + EMEX (EMEX capped by EMEX_RACE_MS) ──────────────
// EMEX HTTP scrapes typically answer in ~4-5s; the old 3s cap abandoned those
// wins and forced a PL24 fallback that doesn't cover EMEX-only brands. Give EMEX
// a realistic window (env-tunable), still well inside the 25s decode budget.
const EMEX_RACE_MS = Number(process.env.EMEX_RACE_MS) || 8000;
// ── PartsCatalogs + EMEX fire in parallel; PL24 joins the race on PCAT miss ──
const pcatStart = Date.now();
let pcatResolved = false;
@@ -547,164 +543,150 @@ export class VehiclesService {
}
});
// Emex result capped by EMEX_RACE_MS — if it doesn't arrive in time, fall back to PL24
const emexTimedPromise = Promise.race([
emexBasePromise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), EMEX_RACE_MS)),
]);
// ── Real race: whichever returns a definitive single result first wins ───
// "Definitive" = PCAT has exactly 1 car, OR EMEX returns a non-UNKNOWN single
// vehicle. If the first completer is definitive we skip the rest entirely;
// otherwise we fall back to the previous decision-tree (await both, decide).
const earlyWinner = await Promise.race([
pcatPromise.then((r) => ({ kind: "pcat" as const, r })),
emexTimedPromise.then((r) => ({ kind: "emex" as const, r })),
]);
if (signal?.aborted) return null;
// ── Fastest-source-wins race ─────────────────────────────────────────────
// PCAT + EMEX are already in flight. The instant PCAT settles WITHOUT a single
// car, PL24 joins the race (a definitive EMEX would already have returned). The
// first source to yield a definitive result wins — no artificial wait, so the
// dealer at the counter gets parts as fast as any one source can answer. EMEX
// takes no AbortSignal, so it's raced against the budget signal.
type Pl24R = Awaited<ReturnType<PL24Service["decodeVin"]>>;
type Tagged =
| { kind: "pcat"; r: Awaited<typeof pcatPromise> }
| { kind: "emex"; r: Awaited<typeof emexBasePromise> }
| { kind: "pl24"; r: Pl24R };
let pcatResult: Awaited<typeof pcatPromise> = null;
let emexResult: Awaited<typeof emexTimedPromise> = null;
if (earlyWinner.kind === "pcat") pcatResult = earlyWinner.r;
else emexResult = earlyWinner.r;
let emexResult: Awaited<typeof emexBasePromise> = null;
let pl24Launched = false;
const earlyPcatDefinitive = earlyWinner.kind === "pcat" && earlyWinner.r?.cars?.length === 1;
const earlyEmexDefinitive =
earlyWinner.kind === "emex" &&
earlyWinner.r?.type === "vehicle" &&
earlyWinner.r.vehicle.brand !== "UNKNOWN";
if (!earlyPcatDefinitive && !earlyEmexDefinitive) {
// First completer wasn't definitive — wait for the other one.
// Always await the other promise (if already resolved, returns immediately).
if (earlyWinner.kind === "pcat") {
emexResult = await emexTimedPromise;
} else if (earlyWinner.kind === "emex") {
pcatResult = await pcatPromise;
}
if (signal?.aborted) return null;
}
if (ctx) {
ctx.timings.pcat_car_count = pcatResult?.cars?.length ?? 0;
ctx.timings.emex_candidate_count =
emexResult?.type === "candidates" ? emexResult.candidates.length : 0;
}
// Decision 1: pcat returned exactly 1 car → use it, ignore EMEX
if (pcatResult?.cars?.length === 1) {
const car = pcatResult.cars[0];
if (!brandName) brandName = this.extractBrandFromPcatCar(car) || null;
const result: VinResolveResult = {
brandName,
model: car.name || null,
year: this.extractYearFromPcatCar(car) || null,
engine: translateEngineType(this.extractParamFromPcatCar(car, "engine") || null),
transmission: translateTransmission(
this.extractParamFromPcatCar(car, "transmission") || null,
),
bodyType: translateBodyType(this.extractParamFromPcatCar(car, "body") || null),
rawData: {
source: "parts-catalogs",
catalogId: car.catalogId,
carId: car.id,
parameters: car.parameters || [],
pcatCar: car,
},
source: "parts-catalogs",
corgiKnown,
corgiResult: null,
};
return result;
}
// Decision 2: pcat ≠ 1 → check EMEX result (already set by the race above)
const emexSingleVehicle =
emexResult?.type === "vehicle" && emexResult.vehicle.brand !== "UNKNOWN"
? emexResult.vehicle
: null;
if (emexSingleVehicle) {
// pcat >1 or 0 AND emex returned exactly 1 vehicle → use EMEX
if (!brandName) brandName = emexSingleVehicle.brand || null;
const result: VinResolveResult = {
brandName,
model: emexSingleVehicle.model || null,
year: emexSingleVehicle.year || null,
// engineCode is alphanumeric (e.g. "N20B20") — leave raw; only the
// engineType fallback ("Petrol"/"Diesel") goes through the dict.
engine:
emexSingleVehicle.engineCode || translateEngineType(emexSingleVehicle.engineType || null),
transmission: translateTransmission(emexSingleVehicle.transmission || null),
bodyType: translateBodyType(emexSingleVehicle.bodyType || null),
rawData: emexSingleVehicle.raw || null,
source: "emex",
corgiKnown,
corgiResult: null,
};
return result;
}
// Decision 3: EMEX timed out / returned multiple / returned nothing → PL24
this.logger.log(`PL24 fallback triggered for ${vin} (EMEX: ${emexResult?.type ?? "timeout"})`);
// EMEX caught an internal failure (timeout/transport) → not a definitive miss.
if (outcome && emexResult?.type === "error") outcome.transient = true;
const pl24CircuitOpen = await this.isPl24CircuitOpen();
if (ctx) ctx.timings.pl24_circuit_open = pl24CircuitOpen;
if (pl24CircuitOpen) {
this.logger.warn(`PL24 skipped for ${vin}: circuit breaker is open`);
if (ctx) ctx.timings.pl24_skipped = 1;
}
if (this.pl24Service.isDecodeable(vin) && !pl24CircuitOpen) {
const launchPl24 = (): Promise<Tagged> | null => {
if (pl24Launched || !this.pl24Service.isDecodeable(vin)) return null;
pl24Launched = true;
const pl24Start = Date.now();
try {
// PL24 internal fetch chain doesn't accept AbortSignal yet; race it at the
// boundary so the budget timer returns control to the caller. Background
// work may continue (PL24 has its own per-fetch 30s cap), but the user
// and HTTP response are unblocked.
const pl24Vehicle = await this.raceWithSignal(
this.pl24Service.decodeVin(vin, userId),
signal,
);
if (ctx) ctx.timings.pl24 = Date.now() - pl24Start;
if (signal?.aborted) {
// Budget aborted while waiting on PL24 — counts as a failure for CB.
await this.recordPl24Failure();
return null;
return (async (): Promise<Tagged> => {
if (await this.isPl24CircuitOpen()) {
if (ctx) {
ctx.timings.pl24_circuit_open = true;
ctx.timings.pl24_skipped = 1;
}
this.logger.warn(`PL24 skipped for ${vin}: circuit breaker is open`);
return { kind: "pl24", r: null };
}
if (pl24Vehicle) {
await this.recordPl24Success();
if (!brandName) brandName = this.pl24Service.getBrandName(vin) || null;
const result: VinResolveResult = {
this.logger.log(`PL24 race entered for ${vin}`);
try {
// PL24's fetch chain takes no AbortSignal; race it at the boundary so the
// budget timer can unblock the response (background work may still finish).
const v = await this.raceWithSignal(this.pl24Service.decodeVin(vin, userId), signal);
if (ctx) ctx.timings.pl24 = Date.now() - pl24Start;
if (v) await this.recordPl24Success();
else if (signal?.aborted) await this.recordPl24Failure();
return { kind: "pl24", r: v };
} catch (err) {
if (ctx) ctx.timings.pl24 = Date.now() - pl24Start;
await this.recordPl24Failure();
const e = err as Error & { cause?: unknown };
if (
outcome &&
(e.name === "TimeoutError" ||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|terminated|UND_ERR|ConnectTimeout|timeout|aborted/i.test(
`${e.message} ${String(e.cause ?? "")}`,
))
) {
outcome.transient = true;
}
this.logger.warn(`PL24 decode failed for ${vin}: ${e.message}`);
return { kind: "pl24", r: null };
}
})();
};
const pending = new Map<string, Promise<Tagged>>();
pending.set(
"pcat",
pcatPromise.then((r): Tagged => ({ kind: "pcat", r })),
);
pending.set(
"emex",
this.raceWithSignal(emexBasePromise, signal).then((r): Tagged => ({ kind: "emex", r })),
);
while (pending.size > 0) {
const settled = await Promise.race(pending.values());
if (signal?.aborted) return null;
pending.delete(settled.kind);
if (settled.kind === "pcat") {
pcatResult = settled.r;
if (ctx) ctx.timings.pcat_car_count = pcatResult?.cars?.length ?? 0;
if (pcatResult?.cars?.length === 1) {
const car = pcatResult.cars[0];
if (!brandName) brandName = this.extractBrandFromPcatCar(car) || null;
return {
brandName,
model: pl24Vehicle.model || null,
year: pl24Vehicle.year || null,
engine: pl24Vehicle.engineType || pl24Vehicle.engineCode || null,
transmission: pl24Vehicle.transmission || null,
bodyType: pl24Vehicle.bodyType || null,
rawData: pl24Vehicle,
source: "pl24",
model: car.name || null,
year: this.extractYearFromPcatCar(car) || null,
engine: translateEngineType(this.extractParamFromPcatCar(car, "engine") || null),
transmission: translateTransmission(
this.extractParamFromPcatCar(car, "transmission") || null,
),
bodyType: translateBodyType(this.extractParamFromPcatCar(car, "body") || null),
rawData: {
source: "parts-catalogs",
catalogId: car.catalogId,
carId: car.id,
parameters: car.parameters || [],
pcatCar: car,
},
source: "parts-catalogs",
corgiKnown,
corgiResult: null,
};
return result;
}
} catch (err) {
if (ctx) ctx.timings.pl24 = Date.now() - pl24Start;
await this.recordPl24Failure();
const e = err as Error & { cause?: unknown };
if (
outcome &&
(e.name === "TimeoutError" ||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|terminated|UND_ERR|ConnectTimeout|timeout|aborted/i.test(
`${e.message} ${String(e.cause ?? "")}`,
))
) {
outcome.transient = true;
// PCAT gave 0/multiple → bring PL24 into the race (a definitive EMEX would
// already have returned above).
const p = launchPl24();
if (p) pending.set("pl24", p);
} else if (settled.kind === "emex") {
emexResult = settled.r;
if (ctx) {
ctx.timings.emex_candidate_count =
emexResult?.type === "candidates" ? emexResult.candidates.length : 0;
}
this.logger.warn(`PL24 decode failed for ${vin}: ${e.message}`);
if (outcome && emexResult?.type === "error") outcome.transient = true;
if (emexResult?.type === "vehicle" && emexResult.vehicle.brand !== "UNKNOWN") {
const v = emexResult.vehicle;
if (!brandName) brandName = v.brand || null;
return {
brandName,
model: v.model || null,
year: v.year || null,
// engineCode is alphanumeric (e.g. "N20B20") — leave raw; only the
// engineType fallback ("Petrol"/"Diesel") goes through the dict.
engine: v.engineCode || translateEngineType(v.engineType || null),
transmission: translateTransmission(v.transmission || null),
bodyType: translateBodyType(v.bodyType || null),
rawData: v.raw || null,
source: "emex",
corgiKnown,
corgiResult: null,
};
}
} else if (settled.r) {
// PL24 returned a vehicle → fastest definitive wins.
if (!brandName) brandName = this.pl24Service.getBrandName(vin) || null;
const v = settled.r;
return {
brandName,
model: v.model || null,
year: v.year || null,
engine: v.engineType || v.engineCode || null,
transmission: v.transmission || null,
bodyType: v.bodyType || null,
rawData: v,
source: "pl24",
corgiKnown,
corgiResult: null,
};
}
}