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:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user