@@ -31,6 +31,11 @@ import { PrefetchSource } from "../jobs/prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue" ;
import { RedisService } from "../redis/redis.service" ;
/** Per-call stage timings, populated by resolveVin so logQuery can persist them. */
interface ResolveContext {
timings : Record < string , number > ;
}
interface VinResolveResult {
brandName : string | null ;
model : string | null ;
@@ -67,6 +72,7 @@ export class VehiclesService {
async decodeVin ( vin : string , userId : string , pcatCarId? : string , emexCarIndex? : number ) {
const startTime = Date . now ( ) ;
const ctx : ResolveContext = { timings : { } } ;
if ( ! isValidVin ( vin ) ) {
throw new BadRequestException ( "Geçersiz şase numarası " ) ;
@@ -81,33 +87,65 @@ export class VehiclesService {
await this . checkBrandAccess ( userId , existing . brandId ) ;
}
await this . ensureUserVehicleLink ( userId , existing . id ) ;
await this . logQuery ( userId , vin , existing . brandId , "cache" , true , Date . now ( ) - startTime ) ;
await this . logQuery (
userId ,
vin ,
existing . brandId ,
"cache" ,
true ,
Date . now ( ) - startTime ,
undefined ,
{ db_hit : 1 } ,
) ;
return existing ;
}
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
const resolved = await this . resolveVin ( vin , pcatCarId , emexCarIndex , userId ) ;
const resolved = await this . resolveVin ( vin , pcatCarId , emexCarIndex , userId , ctx );
if ( ! resolved ) {
const finalSource = ctx . timings . aborted ? "aborted" : "none" ;
const errMsg = ctx . timings . aborted
? ` Decode budget exceeded ( ${ VehiclesService . RESOLVE_BUDGET_MS } ms) `
: "Unknown VIN/brand" ;
await this . logQuery (
userId ,
vin ,
null ,
"corgi" ,
finalSource ,
false ,
Date . now ( ) - startTime ,
"Unknown VIN/brand" ,
errMsg ,
ctx . timings ,
) ;
throw new BadRequestException ( "Şase numarası tanı namadı . Marka desteklenmiyor." ) ;
}
// 2b. If resolveVin returned multiple candidates, return them for frontend selection
if ( resolved . pcatCandidates && resolved . pcatCandidates . length > 1 ) {
await this . logQuery ( userId , vin , null , "parts-catalogs" , true , Date . now ( ) - startTime ) ;
await this . logQuery (
userId ,
vin ,
null ,
"parts-catalogs" ,
true ,
Date . now ( ) - startTime ,
undefined ,
ctx . timings ,
) ;
return { candidates : resolved.pcatCandidates , vin , source : "parts-catalogs" } ;
}
if ( resolved . emexCandidates && resolved . emexCandidates . length > 1 ) {
await this . logQuery ( userId , vin , null , "emex" , true , Date . now ( ) - startTime ) ;
await this . logQuery (
userId ,
vin ,
null ,
"emex" ,
true ,
Date . now ( ) - startTime ,
undefined ,
ctx . timings ,
) ;
return { candidates : resolved.emexCandidates , vin , source : "emex" } ;
}
@@ -169,7 +207,16 @@ export class VehiclesService {
await this . schedulePrefetch ( savedVehicle . id , source as PrefetchSource ) ;
}
await this . logQuery ( userId , vin , brandId , source , true , Date . now ( ) - startTime ) ;
await this . logQuery (
userId ,
vin ,
brandId ,
source ,
true ,
Date . now ( ) - startTime ,
undefined ,
ctx . timings ,
) ;
return savedVehicle ;
}
@@ -214,11 +261,67 @@ export class VehiclesService {
} ;
}
// ─── Resolve cache TTLs & budgets ──────────────────────
// VIN decode result is deterministic; cache aggressively. Negatives shorter
// so transient upstream errors don't poison results for a full day.
private static readonly RESOLVE_TTL_POSITIVE_S = 86 _400 ; // 24h
private static readonly RESOLVE_TTL_NEGATIVE_S = 21 _600 ; // 6h
private static readonly RESOLVE_LOCK_TTL_S = 60 ;
private static readonly RESOLVE_WAIT_POLL_MS = 250 ;
private static readonly RESOLVE_WAIT_TIMEOUT_MS = 30 _000 ;
// Hard ceiling for the full decode chain. The previous record was a 17-min
// PL24 decode (responseTimeMs=1006123) that ran long after the request was
// already aborted by TimeoutInterceptor. Anything past this is wasted work.
private static readonly RESOLVE_BUDGET_MS = 25 _000 ;
// ─── PL24 circuit breaker ──────────────────────────────
// PL24 had p95=12min in production. When it goes bad it stays bad — every
// user serially eats the long timeout. After 3 consecutive failures we open
// the circuit for 30s so subsequent decodes fall through to other sources or
// fail fast. The failure counter auto-expires if no recent activity.
private static readonly PL24_CB_FAILURE_THRESHOLD = 3 ;
private static readonly PL24_CB_COOLDOWN_MS = 30 _000 ;
private static readonly PL24_CB_COUNTER_TTL_S = 300 ;
private static readonly PL24_CB_FAILURE_KEY = "pl24:cb:consec_failures" ;
private static readonly PL24_CB_COOLDOWN_KEY = "pl24:cb:cooldown_until" ;
private async isPl24CircuitOpen ( ) : Promise < boolean > {
const until = await this . redis . get ( VehiclesService . PL24_CB_COOLDOWN_KEY ) ;
return until !== null && Number ( until ) > Date . now ( ) ;
}
private async recordPl24Failure ( ) : Promise < void > {
const c = await this . redis . incr ( VehiclesService . PL24_CB_FAILURE_KEY ) ;
await this . redis . expire (
VehiclesService . PL24_CB_FAILURE_KEY ,
VehiclesService . PL24_CB_COUNTER_TTL_S ,
) ;
if ( c >= VehiclesService . PL24_CB_FAILURE_THRESHOLD ) {
await this . redis . set (
VehiclesService . PL24_CB_COOLDOWN_KEY ,
String ( Date . now ( ) + VehiclesService . PL24_CB_COOLDOWN_MS ) ,
Math . ceil ( VehiclesService . PL24_CB_COOLDOWN_MS / 1000 ) + 5 ,
) ;
this . logger . warn (
` PL24 circuit opened for ${ VehiclesService . PL24_CB_COOLDOWN_MS } ms ( ${ c } consecutive failures) ` ,
) ;
}
}
private async recordPl24Success ( ) : Promise < void > {
await this . redis . del ( VehiclesService . PL24_CB_FAILURE_KEY ) ;
}
/**
* Shared VIN decode chain with 5-minute Redis cache .
* Corgi (offline) → PartsCatalogs → PL24 fallback → EMEX fallback.
* Shared VIN decode chain — Corgi → PartsCatalogs → PL24 → EMEX .
*
* @param pcatCarId If provided, skip resolve chain and use this specific PC car
* Wrapper handles:
* - cache read (positive & negative)
* - in-flight dedup via Redis SETNX lock (so 3 concurrent requests for the
* same VIN don't fire 3 parallel decodes)
* - cache write on completion
*
* @param pcatCarId If provided, skip resolve chain and use this specific PC car
* @param emexCarIndex If provided, skip resolve chain and use this EMEX candidate index
*/
private async resolveVin (
@@ -226,8 +329,9 @@ export class VehiclesService {
pcatCarId? : string ,
emexCarIndex? : number ,
userId? : string ,
ctx? : ResolveContext ,
) : Promise < VinResolveResult | null > {
// If user selected a specific PC car from candidates, resolve directly
// Candidate-pick paths bypass the shared cache (user-driven, per-request).
if ( pcatCarId ) {
return this . resolvePcatCarById ( vin , pcatCarId ) ;
}
@@ -236,28 +340,148 @@ export class VehiclesService {
}
const cacheKey = ` vin:resolve: ${ vin } ` ;
const negKey = ` vin:resolve:neg: ${ vin } ` ;
const lockKey = ` vin:lock: ${ vin } ` ;
// 1. Cache hits — return immediately.
const cached = await this . redis . getJson < VinResolveResult > ( cacheKey ) ;
if ( cached ) {
this . logger . debug ( ` VIN resolve cache hit for ${ vin } ` ) ;
if ( ctx ) ctx . timings . cache_hit = 1 ;
return cached ;
}
if ( await this . redis . exists ( negKey ) ) {
this . logger . debug ( ` VIN resolve negative cache hit for ${ vin } ` ) ;
if ( ctx ) ctx . timings . cache_neg_hit = 1 ;
return null ;
}
const corgiResult = null ;
// 2. Try to acquire the in-flight lock.
const acquired = await this . redis . setNx ( lockKey , "1" , VehiclesService . RESOLVE_LOCK_TTL_S ) ;
if ( ! acquired ) {
// Another request is decoding this VIN — wait for its result.
const waitStart = Date . now ( ) ;
const waited = await this . waitForResolve ( vin , cacheKey , negKey ) ;
if ( ctx ) ctx . timings . lock_wait = Date . now ( ) - waitStart ;
if ( waited !== "timeout" ) return waited ;
// Waiter timed out (decode is taking longer than expected). Fall through
// and decode ourselves — duplicate work, but better than blocking forever.
this . logger . warn ( ` VIN ${ vin } lock wait timed out — decoding ourselves ` ) ;
}
// 3. Set up a hard abort budget. If the chain exceeds it, every fetch with
// this signal aborts and we return null without poisoning the negative
// cache (transient timeout ≠ unknown VIN).
const ac = new AbortController ( ) ;
const budgetTimer = setTimeout ( ( ) = > ac . abort ( ) , VehiclesService . RESOLVE_BUDGET_MS ) ;
try {
const result = await this . doResolveVin ( vin , userId , ctx , ac . signal ) ;
if ( ac . signal . aborted ) {
this . logger . warn (
` VIN ${ vin } decode aborted by budget ( ${ VehiclesService . RESOLVE_BUDGET_MS } ms) ` ,
) ;
if ( ctx ) ctx . timings . aborted = 1 ;
// Don't cache: this was a transient timeout, not a permanent failure.
return null ;
}
if ( result ) {
await this . redis . setJson ( cacheKey , result , VehiclesService . RESOLVE_TTL_POSITIVE_S ) ;
} else {
await this . redis . set ( negKey , "1" , VehiclesService . RESOLVE_TTL_NEGATIVE_S ) ;
}
return result ;
} finally {
clearTimeout ( budgetTimer ) ;
await this . redis . del ( lockKey ) . catch ( ( ) = > {
/* lock TTL will expire; ignore */
} ) ;
}
}
/**
* Race a promise against an AbortSignal. When the signal aborts, returns null
* even if the underlying promise is still in flight. Use this at integration
* boundaries that don't natively accept AbortSignal.
*/
private raceWithSignal < T > ( promise : Promise < T > , signal? : AbortSignal ) : Promise < T | null > {
if ( ! signal ) return promise ;
return Promise . race ( [
promise ,
new Promise < null > ( ( resolve ) = > {
if ( signal . aborted ) {
resolve ( null ) ;
return ;
}
signal . addEventListener ( "abort" , ( ) = > resolve ( null ) , { once : true } ) ;
} ) ,
] ) ;
}
/** Poll the resolve cache while another request holds the lock. */
private async waitForResolve (
vin : string ,
cacheKey : string ,
negKey : string ,
) : Promise < VinResolveResult | null | "timeout" > {
const deadline = Date . now ( ) + VehiclesService . RESOLVE_WAIT_TIMEOUT_MS ;
while ( Date . now ( ) < deadline ) {
await new Promise ( ( r ) = > setTimeout ( r , VehiclesService . RESOLVE_WAIT_POLL_MS ) ) ;
const cached = await this . redis . getJson < VinResolveResult > ( cacheKey ) ;
if ( cached ) {
this . logger . debug ( ` VIN ${ vin } resolved by concurrent request ` ) ;
return cached ;
}
if ( await this . redis . exists ( negKey ) ) {
this . logger . debug ( ` VIN ${ vin } negative-resolved by concurrent request ` ) ;
return null ;
}
}
return "timeout" ;
}
/** Actual decode chain — does NOT touch the cache or lock. */
private async doResolveVin (
vin : string ,
userId? : string ,
ctx? : ResolveContext ,
signal? : AbortSignal ,
) : Promise < VinResolveResult | null > {
const corgiKnown = false ;
let brandName : string | null = null ;
// ── Parallel: PartsCatalogs + EMEX (EMEX capped at 3s) ──────────────
const EMEX_RACE_MS = 3000 ;
const pcatPromise = this . partsCatalogsService . decodeVin ( vin ) . catch ( ( err : Error ) = > {
this . logger . warn ( ` PartsCatalogs decode failed for ${ vin } : ${ err . message } ` ) ;
return null ;
} ) ;
const pcatStart = Date . now ( ) ;
let pcatResolved = false ;
const pcatPromise = this . partsCatalogsService
. decodeVin ( vin , signal )
. catch ( ( err : Error ) = > {
this . logger . warn ( ` PartsCatalogs decode failed for ${ vin } : ${ err . message } ` ) ;
return null ;
} )
. finally ( ( ) = > {
pcatResolved = true ;
if ( ctx && ctx . timings . pcat === undefined ) {
ctx . timings . pcat = Date . now ( ) - pcatStart ;
}
} ) ;
const emexBasePromise = this . emexService . decodeVinOrCandid ates ( vin ) . catch ( ( err : Error ) = > {
this . logger . warn ( ` EMEX decode failed for ${ vin } : ${ err . message } ` ) ;
return null ;
} ) ;
const emexStart = D ate . now ( ) ;
let emexResolved = false ;
const emexBasePromise = this . emexService
. decodeVinOrCandidates ( vin )
. catch ( ( err : Error ) = > {
this . logger . warn ( ` EMEX decode failed for ${ vin } : ${ err . message } ` ) ;
return null ;
} )
. finally ( ( ) = > {
emexResolved = true ;
if ( ctx && ctx . timings . emex === undefined ) {
ctx . timings . emex = Date . now ( ) - emexStart ;
}
} ) ;
// Emex result capped at 3s — if it doesn't arrive in time, falls back to PL24
const emexTimedPromise = Promise . race ( [
@@ -265,8 +489,36 @@ export class VehiclesService {
new Promise < null > ( ( resolve ) = > setTimeout ( ( ) = > resolve ( null ) , EMEX_RACE_MS ) ) ,
] ) ;
// Wait for PartsCatalogs first
const pcatResult = await pcatPromise ;
// ── 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 ;
let pcatResult : Awaited < typeof pcatPromise > = null ;
let emexResult : Awaited < typeof emexTimedPromise > = null ;
if ( earlyWinner . kind === "pcat" ) pcatResult = earlyWinner . r ;
else emexResult = earlyWinner . r ;
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.
if ( earlyWinner . kind === "pcat" && ! emexResolved ) {
emexResult = await emexTimedPromise ;
} else if ( earlyWinner . kind === "emex" && ! pcatResolved ) {
pcatResult = await pcatPromise ;
}
if ( signal ? . aborted ) return null ;
}
// Decision 1: pcat returned exactly 1 car → use it, ignore EMEX
if ( pcatResult ? . cars ? . length === 1 ) {
@@ -290,12 +542,10 @@ export class VehiclesService {
corgiKnown ,
corgiResult : null ,
} ;
await this . redis . setJson ( cacheKey , result , 300 ) ;
return result ;
}
// Decision 2: pcat ≠ 1 → check EMEX result (may already be resol ved )
const emexResult = await emexTimedPromise ;
// Decision 2: pcat ≠ 1 → check EMEX result (already set by the race abo ve)
const emexSingleVehicle =
emexResult ? . type === "vehicle" && emexResult . vehicle . brand !== "UNKNOWN"
? emexResult.vehicle
@@ -316,17 +566,37 @@ export class VehiclesService {
corgiKnown ,
corgiResult : null ,
} ;
await this . redis . setJson ( cacheKey , result , 300 ) ;
return result ;
}
// Decision 3: EMEX timed out / returned multiple / returned nothing → PL24
this . logger . log ( ` PL24 fallback triggered for ${ vin } (EMEX: ${ emexResult ? . type ? ? "timeout" } ) ` ) ;
if ( this . pl24Service . isDecodeable ( vi n) ) {
const pl24CircuitOpen = await this . isPl24CircuitOpe n( ) ;
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 pl24Start = Date . now ( ) ;
try {
const pl24Vehicle = await this . pl24Service . decodeVin ( vin , userId ) ;
// 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 ;
}
if ( pl24Vehicle ) {
await this . recordPl24Success ( ) ;
if ( ! brandName ) brandName = this . pl24Service . getBrandName ( vin ) || null ;
const result : VinResolveResult = {
brandName ,
@@ -340,10 +610,11 @@ export class VehiclesService {
corgiKnown ,
corgiResult : null ,
} ;
await this . redis . setJson ( cacheKey , result , 300 ) ;
return result ;
}
} catch ( err ) {
if ( ctx ) ctx . timings . pl24 = Date . now ( ) - pl24Start ;
await this . recordPl24Failure ( ) ;
this . logger . warn ( ` PL24 decode failed for ${ vin } : ${ ( err as Error ) . message } ` ) ;
}
}
@@ -707,6 +978,7 @@ export class VehiclesService {
success : boolean ,
responseTimeMs : number ,
errorMessage? : string ,
timings? : Record < string , number > ,
) {
try {
await this . db . insert ( queryLogs ) . values ( {
@@ -717,6 +989,7 @@ export class VehiclesService {
success ,
responseTimeMs ,
errorMessage : errorMessage || null ,
timings : timings && Object . keys ( timings ) . length > 0 ? timings : null ,
} ) ;
} catch ( err ) {
this . logger . error ( "Failed to log query" , err ) ;