refactor(prefetch): kapı sırasını en ucuzdan pahalıya diz
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Pencere kontrolü saf saat aritmetiği, hiç I/O yapmıyor ve pencere
dışındaki iş zaten hiçbir faydalı iş yapamıyor — dolayısıyla dakikalık
Redis sayacından da önce gelmeli. Yeni sıra: pencere → cooldown →
dakikalık tavan → günlük bütçe. Böylece pencere dışında uyanan iş
hiçbir sayacı kirletmiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 08:31:55 +03:00
parent 7644d91407
commit ae8a8046bf
2 changed files with 24 additions and 14 deletions

View File

@@ -164,6 +164,9 @@ describe("process() gate order — window before daily budget", () => {
expect(job.moveToDelayed).toHaveBeenCalled();
expect(dailyIncrs(incrCalls)).toHaveLength(0);
// The per-minute counter is not charged either: the window gate is pure
// clock arithmetic and runs before any Redis write.
expect(incrCalls).toHaveLength(0);
// …and the handler never ran, so nothing was fetched upstream either.
expect(categoriesService.getCategoryWithParts).not.toHaveBeenCalled();
});

View File

@@ -355,23 +355,30 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
job.name === "prefetch-parts")
) {
const lane = (job.data as { fast?: boolean }).fast ? "fast" : "main";
await this.checkSourceRate(data.source, lane);
// Cooldown + business-hours window BEFORE the daily charge. These two
// gates used to live at the top of each handler, i.e. AFTER the budget
// was already debited, so every job that woke outside the window paid a
// GATE ORDER IS LOAD-BEARING — cheapest first, and every gate that can
// reject the job must run BEFORE any counter is debited.
//
// The business-hours window and the cooldown used to sit at the top of
// each handler, i.e. AFTER the per-minute counter and the daily budget
// had already been charged. So a job that woke outside the window paid a
// budget unit to do nothing. Combined with a deferral target of "next UTC
// midnight" (= 03:00 Europe/Istanbul, six hours before a 09:00 window
// opens) that formed a closed loop: the whole daily allowance was burned
// by no-op wake-ups before the window ever opened, so the source never
// ran again. Measured on prod 2026-09-20: pl24 at 600/600 with 0 catalog
// requests and 0 new categories for the day. Order matters here.
await checkCooldown(this.redis, data.source);
// opens) that closed a loop: the whole daily allowance was burned by
// no-op wake-ups before the window ever opened, so the source never ran
// again. Measured on prod 2026-09-20 pl24 at 600/600 with 0 catalog
// requests and 0 new categories for the day.
//
// 1. Window: pure clock arithmetic, no I/O, and an out-of-window job can
// never do useful work — so nothing else is worth spending on it.
checkTimeWindow(data.source);
// Daily budget AFTER the per-minute gate: a job deferred on the minute
// ceiling above never reaches here, so rate-limited retries don't inflate
// the daily counter — only jobs about to do real work are counted. The
// lane decides which threshold applies (backfill stops at the main limit,
// the user's fast lane may use the full budget).
// 2. Cooldown: one Redis TTL read. Pauses the whole worker (see catch).
await checkCooldown(this.redis, data.source);
// 3. Per-minute ceiling.
await this.checkSourceRate(data.source, lane);
// 4. Daily budget last: a job deferred on any gate above never reaches
// here, so only jobs about to do real work are counted. The lane
// decides which threshold applies (backfill stops at the main limit,
// the user's fast lane may use the full budget).
await this.checkSourceDailyBudget(data.source, lane);
}
if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) {