Files
sase.tr/apps/api/src/jobs/prefetch-utils.ts
Semih Yesilyurt 7644d91407
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
fix(prefetch): PL24 bütçe/iş-saati kilitlenmesini kır
Prod'da PL24 prefetch tamamen durmuştu: günlük bütçe 600/600 dolu
görünürken gün boyunca SIFIR katalog isteği ve SIFIR yeni kategori
üretiliyordu (ölçüm 2026-09-20).

İki hata birlikte kapalı bir döngü kuruyordu:

1. Günlük bütçe `process()` içinde düşülüyor, iş-saati (ve cooldown)
   kapısı ise her handler'ın başında duruyordu. Pencere dışında uyanan
   bir iş önce bütçeden bir birim yiyor, sonra `time-window` fırlatıp
   hiçbir iş yapmadan erteleniyordu.

2. Bütçesi biten kaynak "bir sonraki UTC gece yarısı"na erteleniyordu.
   PREFETCH_PL24_START=9 ile bu an 03:00 Europe/Istanbul'a denk gelir —
   pencere açılmadan altı saat önce. Uyanan iş yine pencereye takılıyor,
   yine erteleniyor; taze günlük bütçe daha pencere açılmadan bu boş
   uyanmalarla tükeniyordu.

Düzeltme:
- cooldown + iş-saati kapıları `process()` içinde, bütçe düşülmeden
  ÖNCE çalışıyor; handler'lardaki kopyaları kaldırıldı.
- `alignToWindow()` eklendi: bütçe ertelemesi pencerenin içine
  hizalanıyor. Pencere tanımlı değilse (varsayılan 0–24) no-op.
- `currentIstanbulHour` artık `istanbulHourAt`'e deleg ediyor ve h24
  döngüsünün gece yarısı için ürettiği "24" değeri `% 24` ile
  normalleniyor (aksi halde saat hiçbir pencereye düşmez).

8 yeni regresyon testi; api paketi 602 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 08:29:55 +03:00

246 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { RedisService } from "../redis/redis.service";
/**
* True ONLY on the real production deployment (sase.tr).
*
* NOTE: both dev.sase.tr (staging) and sase.tr (prod) run with
* `NODE_ENV=production` (hard-coded in the Dockerfile / compose) but have
* SEPARATE databases. So NODE_ENV cannot tell them apart. The catalog backfill
* must only sweep the prod DB, so we key off the canonical prod host. An
* explicit `CATALOG_BACKFILL_ENABLED` env wins (e.g. to force-test on staging).
* Anything else (local dev, unknown host) → disabled, the safe default.
*/
export function isCatalogBackfillEnabled(): boolean {
const flag = process.env.CATALOG_BACKFILL_ENABLED;
if (flag != null && flag !== "") return flag === "true";
return (
process.env.COOLIFY_FQDN === "sase.tr" || process.env.BETTER_AUTH_URL === "https://sase.tr"
);
}
/**
* True ONLY on the real production deployment (sase.tr).
*
* Same rationale as {@link isCatalogBackfillEnabled} — both prod and dev run
* with `NODE_ENV=production`, so we gate on the prod host. Without this gate
* the dev deploy would send duplicate trial-ending / win-back e-mails to real
* users (sase_dev DB is a copy of prod data). An explicit
* `LIFECYCLE_EMAIL_ENABLED` env wins (e.g. to force-test on staging).
*/
export function isLifecycleEmailEnabled(): boolean {
const flag = process.env.LIFECYCLE_EMAIL_ENABLED;
if (flag != null && flag !== "") return flag === "true";
return (
process.env.COOLIFY_FQDN === "sase.tr" || process.env.BETTER_AUTH_URL === "https://sase.tr"
);
}
/**
* Signals the worker that this job is rate-limited and should be deferred.
*
* `cause` distinguishes the deferral strategy:
* - "cooldown" — active user on the source. The cooldown key is refreshed by
* every user request, so per-job 60s defer livelocks: the job comes back,
* cooldown is still set, defers again. Handled by pausing the WHOLE worker
* for retryAfterMs (BullMQ `worker.rateLimit()`), so the queue waits once
* instead of cycling.
* - "time-window" — off-hours; only PL24/parts-catalogs are affected. Use
* per-job moveToDelayed so EMEX jobs (no window) keep flowing.
*/
export class RateLimitError extends Error {
constructor(
public readonly retryAfterMs: number,
public readonly cause: "cooldown" | "time-window" | "source-rate",
) {
super(`Rate limited (${cause}) — retry after ${retryAfterMs}ms`);
this.name = "RateLimitError";
}
}
/**
* Check if a *user* is actively using the source (the cooldown key is set only
* by user-facing requests; worker fetches skip it via the backfill context).
* Throws RateLimitError (retry after the key's remaining lifetime) if set.
*/
export async function checkCooldown(redis: RedisService, source: string): Promise<void> {
const key = `prefetch:activity:${source}`;
// ttl() returns the key's remaining lifetime in seconds (-2 = no key,
// -1 = no expiry). Pause for exactly that long so the worker waits once —
// until the real user-activity window ends — instead of re-checking every
// fixed 60s and burning ~5 empty cycles per key lifetime.
const ttl = await redis.ttl(key);
if (ttl > 0) {
throw new RateLimitError(ttl * 1000, "cooldown");
}
}
/** Europe/Istanbul hour (023) at an arbitrary instant. */
function istanbulHourAt(ms: number): number {
const hourStr = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Istanbul",
hour: "numeric",
hour12: false,
}).format(new Date(ms));
// `% 24` because the h24 hour cycle renders midnight as "24", which would put
// the hour outside every window and silently park the source forever.
return Number.parseInt(hourStr, 10) % 24;
}
/** Current hour (023) in Europe/Istanbul. */
function currentIstanbulHour(): number {
return istanbulHourAt(Date.now());
}
/**
* Whether `source` may be scraped right now.
*
* Time windows are DISABLED by default — PL24 (and everything else) may be
* scraped 24/7. The window is still env-tunable: set PREFETCH_PL24_START /
* PREFETCH_PL24_END (Europe/Istanbul hours) to re-narrow it (e.g.
* PREFETCH_PL24_START=9 PREFETCH_PL24_END=18). EMEX and parts-catalogs never
* had a window — their Redis-persisted warm JWT pool keeps captures alive
* through the night.
*/
const PL24_WINDOW_START = Number(process.env.PREFETCH_PL24_START) || 0;
const PL24_WINDOW_END = Number(process.env.PREFETCH_PL24_END) || 24;
export function isWithinTimeWindow(source: string): boolean {
if (source !== "pl24") return true;
// Default (024) means the window is disabled → PL24 flows 24/7.
if (PL24_WINDOW_START <= 0 && PL24_WINDOW_END >= 24) return true;
const h = currentIstanbulHour();
return h >= PL24_WINDOW_START && h < PL24_WINDOW_END;
}
/**
* Check PL24 business hours (09:0018:00 Europe/Istanbul).
* Throws RateLimitError with delay until next 09:00 if outside window.
*/
export function checkTimeWindow(source: string): void {
if (!isWithinTimeWindow(source)) {
throw new RateLimitError(msUntilNext9AM(), "time-window");
}
}
/**
* The first instant at or after `fromMs` that falls inside `source`'s scrape
* window. Returns `fromMs` unchanged when the window is disabled (the default
* 024) or when `fromMs` is already inside it.
*
* WHY (plv2.md, finding consumers_jobs-03 — the budget/window deadlock):
* `checkSourceDailyBudget` defers a spent source to the next UTC midnight. With
* PREFETCH_PL24_START=9 that midnight lands at 03:00 Europe/Istanbul — six hours
* BEFORE the window opens. The woken job therefore did no work, threw
* `time-window`, and was deferred again to 09:00 — by which point the fresh
* daily budget had already been spent by the same stampede of no-op wake-ups.
* Measured on prod 2026-09-20: 600/600 pl24 budget consumed, 0 catalog requests
* and 0 new categories for the whole day. Landing the deferral inside the window
* breaks the cycle.
*/
export function alignToWindow(source: string, fromMs: number): number {
if (source !== "pl24") return fromMs;
if (PL24_WINDOW_START <= 0 && PL24_WINDOW_END >= 24) return fromMs;
let t = fromMs;
// Step by the hour rather than constructing a local-midnight date: DST-safe and
// free of month/year rollover edge cases. 48 steps covers any window shape.
for (let i = 0; i < 48; i++) {
const h = istanbulHourAt(t);
if (h >= PL24_WINDOW_START && h < PL24_WINDOW_END) return t;
t += 3_600_000;
}
return t;
}
/**
* Milliseconds until the next 09:00 Europe/Istanbul.
*/
export function msUntilNext9AM(): number {
const now = new Date();
// Get current Istanbul time components
const istParts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Istanbul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(now);
const get = (type: string) =>
Number.parseInt(istParts.find((p) => p.type === type)?.value || "0", 10);
const hour = get("hour");
const minute = get("minute");
const second = get("second");
// Before the window opens → wait until it opens today; otherwise → tomorrow.
let hoursToWait: number;
if (hour < PL24_WINDOW_START) {
hoursToWait = PL24_WINDOW_START - hour;
} else {
hoursToWait = 24 - hour + PL24_WINDOW_START;
}
const ms = hoursToWait * 3600_000 - minute * 60_000 - second * 1000;
// At least 1 minute, at most 15 hours
return Math.max(60_000, Math.min(ms, 15 * 3600_000));
}
/** Redis hash key for prefetch progress */
export function progressKey(vehicleId: string): string {
return `prefetch:progress:${vehicleId}`;
}
export interface PrefetchProgress {
status: "running" | "completed" | "error";
total: number;
completed: number;
errors: number;
startedAt: string;
updatedAt: string;
}
export async function initProgress(redis: RedisService, vehicleId: string): Promise<void> {
const now = new Date().toISOString();
await redis.setJson(
progressKey(vehicleId),
{
status: "running",
total: 0,
completed: 0,
errors: 0,
startedAt: now,
updatedAt: now,
} satisfies PrefetchProgress,
86400,
);
}
export async function updateProgress(
redis: RedisService,
vehicleId: string,
update: Partial<PrefetchProgress>,
): Promise<void> {
const key = progressKey(vehicleId);
const current = await redis.getJson<PrefetchProgress>(key);
if (!current) return;
const updated: PrefetchProgress = {
...current,
...update,
updatedAt: new Date().toISOString(),
};
await redis.setJson(key, updated, 86400);
}
export async function getProgress(
redis: RedisService,
vehicleId: string,
): Promise<PrefetchProgress | null> {
return redis.getJson<PrefetchProgress>(progressKey(vehicleId));
}