jskelet 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,31 @@ const store = new Map();
47
47
  /** @type {Map<string, Promise<unknown>>} */
48
48
  const inflight = new Map();
49
49
 
50
+ /**
51
+ * Süreç ömrü boyunca biriken sayaçlar.
52
+ *
53
+ * Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
54
+ * bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
55
+ * hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
56
+ * fren çağrıları yavaşlatır, sayısını azaltmaz.
57
+ */
58
+ const stats = {
59
+ /** Taze girdiden servis edildi. */
60
+ hits: 0,
61
+ /** Bayat girdiden servis edildi; tazeleme arkada koştu. */
62
+ stale: 0,
63
+ /** Girdi yoktu, çağıran bekledi. */
64
+ misses: 0,
65
+ /** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
66
+ coalesced: 0,
67
+ /** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
68
+ shared: 0,
69
+ /** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
70
+ produced: 0,
71
+ /** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
72
+ bypassed: 0,
73
+ };
74
+
50
75
  /**
51
76
  * Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
52
77
  * script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
@@ -171,7 +196,10 @@ function refresh(key, ttlSeconds, producer, options) {
171
196
  // Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
172
197
  // Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
173
198
  const pending = inflight.get(key);
174
- if (pending) return pending;
199
+ if (pending) {
200
+ stats.coalesced += 1;
201
+ return pending;
202
+ }
175
203
 
176
204
  const staleFactor = options.staleFactor ?? settings().staleFactor;
177
205
 
@@ -196,10 +224,12 @@ async function produce(key, ttlSeconds, producer, options, staleFactor) {
196
224
  // Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
197
225
  const shared = await readShared(key);
198
226
  if (shared) {
227
+ stats.shared += 1;
199
228
  promote(key, shared);
200
229
  return shared.value;
201
230
  }
202
231
 
232
+ stats.produced += 1;
203
233
  const value = await producer();
204
234
 
205
235
  const empty = value === undefined || value === null;
@@ -224,7 +254,10 @@ async function produce(key, ttlSeconds, producer, options, staleFactor) {
224
254
  * @template T
225
255
  */
226
256
  export async function withDataCache(key, ttlSeconds, producer, options = {}) {
227
- if (!ttlSeconds) return producer();
257
+ if (!ttlSeconds) {
258
+ stats.bypassed += 1;
259
+ return producer();
260
+ }
228
261
 
229
262
  // Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
230
263
  // sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
@@ -233,6 +266,9 @@ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
233
266
  const hit = read(key);
234
267
 
235
268
  if (hit) {
269
+ if (hit.stale) stats.stale += 1;
270
+ else stats.hits += 1;
271
+
236
272
  // Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
237
273
  // etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
238
274
  // olması sayfaya yansımaz.
@@ -244,6 +280,8 @@ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
244
280
  return /** @type {T} */ (hit.value);
245
281
  }
246
282
 
283
+ stats.misses += 1;
284
+
247
285
  try {
248
286
  return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
249
287
  } catch (error) {
@@ -346,15 +384,67 @@ function clearLocal(prefix) {
346
384
  // Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
347
385
  // `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
348
386
  onCacheEvent((event) => {
387
+ if (event.type === "data:drop") {
388
+ if (typeof event.key !== "string") return;
389
+ store.delete(event.key);
390
+ invalidateHtmlByDependency([event.key]);
391
+ return;
392
+ }
393
+
349
394
  if (event.type !== "data:clear") return;
350
395
  clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
351
396
  });
352
397
 
398
+ /**
399
+ * Tek bir veri anahtarını düşürür.
400
+ *
401
+ * `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
402
+ * `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
403
+ * olarak o anahtar olmalı, komşusu değil.
404
+ *
405
+ * @param {string} key
406
+ * @returns {boolean} Girdi var mıydı.
407
+ */
408
+ export function dropDataCacheKey(key) {
409
+ const existed = store.delete(key);
410
+
411
+ // Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
412
+ // yalnızca Redis'te sıcak olabilir.
413
+ invalidateHtmlByDependency([key]);
414
+
415
+ if (redisShares("data")) {
416
+ void redisDropMatching("data", (candidate) => candidate === key);
417
+ }
418
+
419
+ publishCacheEvent({ type: "data:drop", key });
420
+
421
+ return existed;
422
+ }
423
+
353
424
  /** @returns {number} */
354
425
  export function getDataCacheSize() {
355
426
  return store.size;
356
427
  }
357
428
 
429
+ /**
430
+ * Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
431
+ * geri kalan her şey upstream'e hiç gitmemiş bir okuma.
432
+ *
433
+ * @returns {typeof stats & { reads: number, hitRatio: number }}
434
+ * `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
435
+ * upstream'e gitmediği (0–1).
436
+ */
437
+ export function getDataCacheStats() {
438
+ const reads = stats.hits + stats.stale + stats.misses;
439
+ const avoided = stats.hits + stats.stale;
440
+
441
+ return {
442
+ ...stats,
443
+ reads,
444
+ hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
445
+ };
446
+ }
447
+
358
448
  /**
359
449
  * Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
360
450
  * tamamını bir teşhis ucundan dışa vermek istenmez.
@@ -13,8 +13,9 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import zlib from "node:zlib";
15
15
  import { getHtmlCacheEntries, getHtmlCacheSize } from "../html-cache.js";
16
- import { getDataCacheSize } from "../data-cache.js";
16
+ import { getDataCacheSize, getDataCacheStats } from "../data-cache.js";
17
17
  import { getRedisStatus } from "../redis.js";
18
+ import { getUpstreamLimiterStatus } from "../upstream-limiter.js";
18
19
  import { prewarmProgress } from "../prewarm.js";
19
20
  import { getConfig } from "../../config/index.js";
20
21
 
@@ -351,10 +352,16 @@ export function buildReport(devtools) {
351
352
  // Veri önbelleğinden yalnızca sayaç: uzun kuyruklu bir sitede on
352
353
  // binlerce anahtar oluyor ve dökümü rapora koymak faydasız bir yük.
353
354
  data: getDataCacheSize(),
355
+ // Sayaçlar dökümün yerine geçmiyor, onu tamamlıyor: "kaç girdi var"
356
+ // sorusundan çok "kaç okuma upstream'e gitti" sorusu karar veriyor.
357
+ dataStats: getDataCacheStats(),
354
358
  // Paylaşımlı kademe kapalıyken de basılır: "Redis'i açtım ama neden
355
359
  // çalışmıyor" sorusunun cevabı en çok burada aranıyor.
356
360
  redis: getRedisStatus(),
357
361
  },
362
+ // Hız freninin o anki durumu. 429 fırtınasında "şu an saniyede kaça
363
+ // indi" bilgisi olmadan ayar yapmak körlemesine oluyor.
364
+ upstream: getUpstreamLimiterStatus(),
358
365
  prewarm: { ...prewarmProgress },
359
366
  requests: devtools.requests,
360
367
  errors: devtools.errors,
@@ -686,6 +686,11 @@ onCacheEvent((event) => {
686
686
  return;
687
687
  }
688
688
 
689
+ if (event.type === "html:drop") {
690
+ if (typeof event.key === "string") dropLocalKey(event.key);
691
+ return;
692
+ }
693
+
689
694
  if (event.type !== "html:invalidate") return;
690
695
 
691
696
  const targets = /** @type {(string | RegExp)[]} */ (
@@ -742,6 +747,37 @@ export function invalidateHtmlByDependency(dataKeys) {
742
747
  return keys.size;
743
748
  }
744
749
 
750
+ /**
751
+ * Tek bir önbellek **anahtarını** düşürür.
752
+ *
753
+ * `invalidateHtmlCache()` yol deseniyle çalışıyor ve bir yolun bütün query
754
+ * varyantlarını birlikte düşürüyor. Yönetim paneli listedeki tek satırı
755
+ * silebilmek istiyor: `/liste?sayfa=2` düşerken `/liste?sayfa=3` sıcak
756
+ * kalmalı. Desen sözdiziminde `?` kaçırılamadığı için ayrı bir yüzey.
757
+ *
758
+ * @param {string} key `yol?query` biçiminde tam anahtar.
759
+ * @returns {boolean} Girdi var mıydı.
760
+ */
761
+ export function dropHtmlCacheKey(key) {
762
+ const existed = dropLocalKey(key);
763
+
764
+ if (redisShares("html")) redisDrop([cacheKey("html", key)]);
765
+ publishCacheEvent({ type: "html:drop", key });
766
+
767
+ return existed;
768
+ }
769
+
770
+ /**
771
+ * @param {string} key
772
+ * @returns {boolean}
773
+ */
774
+ function dropLocalKey(key) {
775
+ // Uçuştaki tazeleme de geçersiz: silinen girdiyi geri yazmamalı.
776
+ tokens.delete(key);
777
+ invalidated.delete(key);
778
+ return drop(key);
779
+ }
780
+
745
781
  /**
746
782
  * Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
747
783
  * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
@@ -22,6 +22,9 @@ import process from "node:process";
22
22
  import { getConfig, hook } from "../config/index.js";
23
23
  import { getRequestContext } from "../http/request-context.js";
24
24
  import { takeInvalidatedPaths } from "./html-cache.js";
25
+ import { getDataCacheStats } from "./data-cache.js";
26
+ import { isTransientStatus } from "./upstream-tracking.js";
27
+ import { upstreamCooldownMs } from "./upstream-limiter.js";
25
28
 
26
29
  /**
27
30
  * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
@@ -266,7 +269,10 @@ function devGateHeader() {
266
269
  * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
267
270
  * anlamı değiştiği için çağıran taraf kendi formülünü verir.
268
271
  * @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
269
- * @returns {Promise<{ ok: number, failed: number, failedPaths: string[] }>}
272
+ * @returns {Promise<{ ok: number, failed: number,
273
+ * failures: { path: string, status: number }[] }>}
274
+ * `failures` durum koduyla birlikte döner: tekrar turuna yalnızca geçici
275
+ * hatalar alınıyor, kalıcı olanı yeniden denemek boşa çağrı.
270
276
  */
271
277
  async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
272
278
  const { brand } = getConfig();
@@ -275,8 +281,8 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
275
281
  let index = 0;
276
282
  let ok = 0;
277
283
  let failed = 0;
278
- /** @type {string[]} */
279
- const failedPaths = [];
284
+ /** @type {{ path: string, status: number }[]} */
285
+ const failures = [];
280
286
 
281
287
  async function worker() {
282
288
  while (index < paths.length) {
@@ -301,7 +307,7 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
301
307
  if (response.ok) ok += 1;
302
308
  else {
303
309
  failed += 1;
304
- failedPaths.push(target);
310
+ failures.push({ path: target, status: response.status });
305
311
  }
306
312
 
307
313
  prewarmProgress.entries.push({
@@ -314,7 +320,8 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
314
320
  });
315
321
  } catch (error) {
316
322
  failed += 1;
317
- failedPaths.push(target);
323
+ // Yanıt hiç gelmedi: ağ hatası her zaman geçici, tekrar denenir.
324
+ failures.push({ path: target, status: 0 });
318
325
  prewarmProgress.entries.push({
319
326
  path: target,
320
327
  status: 0,
@@ -339,7 +346,66 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
339
346
  Array.from({ length: Math.min(concurrency, paths.length) }, worker),
340
347
  );
341
348
 
342
- return { ok, failed, failedPaths };
349
+ return { ok, failed, failures };
350
+ }
351
+
352
+ /**
353
+ * Tekrar turuna girecek yollar. Yalnızca geçici hatalar: `400`/`403`/`404` gibi
354
+ * deterministik cevaplar tekrar denemekle düzelmez ve o çağrılar kotadan
355
+ * karşılıksız yiyor. Sınıflandırma render tarafıyla aynı listeden
356
+ * (`isTransientStatus`), yoksa iki yer birbirinden kayar.
357
+ *
358
+ * @param {{ path: string, status: number }[]} failures
359
+ * @returns {string[]}
360
+ */
361
+ function retryablePaths(failures) {
362
+ return failures
363
+ .filter((failure) => isTransientStatus(failure.status))
364
+ .map((failure) => failure.path);
365
+ }
366
+
367
+ /**
368
+ * Tekrar turundan önce beklenecek süre.
369
+ *
370
+ * Sabit bekleme yanlış soruyu cevaplıyordu: doğru süreyi upstream biliyor.
371
+ * Hız freni açıkken `Retry-After` ya da devre kesicinin soğuma süresi zaten
372
+ * elimizde; 10 saniye kapalı kalacak bir kesiciden 2 saniye sonra tekrar
373
+ * denemek, aynı 429'u peşin peşin almak demek.
374
+ *
375
+ * @returns {number} ms
376
+ */
377
+ function retryDelayMs() {
378
+ const configured = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
379
+ const cooldown = upstreamCooldownMs();
380
+
381
+ // Fren kapalıysa `cooldown` 0 olur ve davranış eskisi gibi kalır. Üst sınır
382
+ // turun sonsuza kadar açık kalmasını engelliyor.
383
+ return Math.min(Math.max(configured, cooldown), 60_000);
384
+ }
385
+
386
+ /**
387
+ * Turun veri önbelleği üzerinden upstream'e ne kadar dokunduğunu özetler.
388
+ *
389
+ * @param {ReturnType<typeof getDataCacheStats>} before Tur başındaki sayaçlar.
390
+ * @returns {string} Okunacak bir şey yoksa boş dize.
391
+ */
392
+ function upstreamUsage(before) {
393
+ const after = getDataCacheStats();
394
+ const reads = after.reads - before.reads;
395
+ if (reads <= 0) return "";
396
+
397
+ const produced = after.produced - before.produced;
398
+ const coalesced = after.coalesced - before.coalesced;
399
+ const shared = after.shared - before.shared;
400
+ const served = reads - produced;
401
+ const ratio = Math.round((served / reads) * 100);
402
+
403
+ return (
404
+ `${produced} upstream call${produced === 1 ? "" : "s"} for ${reads} data read${reads === 1 ? "" : "s"} ` +
405
+ `(${ratio}% from the data cache` +
406
+ `${coalesced ? `, ${coalesced} coalesced` : ""}` +
407
+ `${shared ? `, ${shared} from the shared tier` : ""})`
408
+ );
343
409
  }
344
410
 
345
411
  /**
@@ -392,13 +458,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
392
458
  });
393
459
  suppressed.clear();
394
460
 
461
+ // Kotanın gerçekten ne kadar harcandığı ancak veri önbelleğinden görülür:
462
+ // 400 sayfalık bir tur, ortak bir uç için tek çağrı da yapabilir dört yüz de.
463
+ const dataBefore = getDataCacheStats();
464
+
395
465
  let ok = 0;
396
466
  let failed = 0;
397
467
  let recovered = 0;
468
+ let skippedRetry = 0;
398
469
  try {
399
- /** @type {string[]} */
400
- let failedPaths;
401
- ({ ok, failed, failedPaths } = await crawl(
470
+ /** @type {{ path: string, status: number }[]} */
471
+ let failures;
472
+ ({ ok, failed, failures } = await crawl(
402
473
  origin,
403
474
  paths,
404
475
  concurrency,
@@ -409,18 +480,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
409
480
  // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
410
481
  // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
411
482
  // önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
412
- if (failedPaths.length) {
483
+ const retryPaths = retryablePaths(failures);
484
+ skippedRetry = failures.length - retryPaths.length;
485
+
486
+ if (retryPaths.length) {
413
487
  const firstOk = ok;
414
488
  const firstFailed = failed;
415
489
 
416
- // Rate limit pencereleri saniye mertebesinde; hemen tekrar denemek aynı
417
- // 429'u almak demek.
418
- const retryDelay = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
419
- await sleep(retryDelay);
490
+ await sleep(retryDelayMs());
420
491
 
421
492
  const retry = await crawl(
422
493
  origin,
423
- failedPaths,
494
+ retryPaths,
424
495
  1,
425
496
  (retriedOk) => {
426
497
  // Tekrar turunda her başarı bir hatayı başarıya çevirir.
@@ -450,10 +521,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
450
521
  `${pending.length ? `, ${pending.length} invalidated` : ""}` +
451
522
  `${failed ? `, ${failed} failed` : ""}` +
452
523
  `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
524
+ `${skippedRetry ? `, ${skippedRetry} not retried (permanent)` : ""}` +
453
525
  `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
454
526
  ` (${(elapsed / 1000).toFixed(1)}s)`,
455
527
  );
456
528
 
529
+ // Turun upstream'e ne kadar dokunduğu. Asıl karar bu satıra bakılarak
530
+ // veriliyor: oran düşükse çözüm hız freni değil, `withDataCache` TTL'ini
531
+ // tur aralığından uzun tutmak — fren çağrıları yavaşlatır, sayısını
532
+ // azaltmaz.
533
+ const upstreamCalls = upstreamUsage(dataBefore);
534
+ if (upstreamCalls) console.log(`[prewarm] ${upstreamCalls}`);
535
+
457
536
  // Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
458
537
  // kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
459
538
  // görünür tutmak.
@@ -405,6 +405,114 @@ export function getRedisStatus() {
405
405
  };
406
406
  }
407
407
 
408
+ /**
409
+ * Bağlantının **nereye** kurulduğu ve hangi ayarlarla çalıştığı.
410
+ *
411
+ * Şifre asla dönmez: bağlantı URL'i `redis://user:pass@host` biçiminde
412
+ * olabiliyor ve panelin işi adresi göstermek, sırrı değil. Ayrıştırılamayan
413
+ * bir URL için adres `"custom"` olur — bozuk bir değer teşhis ucunu
414
+ * düşürmemeli.
415
+ *
416
+ * @returns {{ address: string, secure: boolean, db: string | null,
417
+ * namespace: string, keyPrefix: string, html: boolean, data: boolean,
418
+ * storeEncoded: boolean, events: boolean, commandTimeoutMs: number,
419
+ * subscribed: boolean }}
420
+ */
421
+ export function getRedisDetails() {
422
+ let address = "localhost:6379 (ioredis default)";
423
+ let secure = false;
424
+ /** @type {string | null} */
425
+ let db = null;
426
+
427
+ if (settings.url) {
428
+ try {
429
+ const parsed = new URL(settings.url);
430
+ address = `${parsed.hostname}:${parsed.port || 6379}`;
431
+ secure = parsed.protocol === "rediss:";
432
+ const name = parsed.pathname.replace(/^\//, "");
433
+ db = name || null;
434
+ } catch {
435
+ address = "custom";
436
+ }
437
+ }
438
+
439
+ return {
440
+ address,
441
+ secure,
442
+ db,
443
+ namespace: settings.namespace,
444
+ keyPrefix: settings.keyPrefix,
445
+ html: settings.html === true,
446
+ data: settings.data === true,
447
+ storeEncoded: settings.storeEncoded === true,
448
+ events: settings.events === true,
449
+ commandTimeoutMs: settings.commandTimeoutMs,
450
+ subscribed: Boolean(subscriber),
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Paylaşımlı kademede gerçekten **ne durduğunu** sayar: tür başına anahtar
456
+ * sayısı ve sunucunun bildirdiği bellek kullanımı.
457
+ *
458
+ * Ayrı bir çağrı olması gerekiyor. Sayım `SCAN` turu demek ve panelin döküm
459
+ * ucu birkaç saniyede bir yenileniyor; her turda tüm keyspace'i taramak
460
+ * Redis'i teşhis uğruna yormak olurdu. Panel bunu düğmeye basınca çağırır.
461
+ *
462
+ * @returns {Promise<{ ok: boolean, html: number, data: number,
463
+ * usedMemory: string | null, totalKeys: number | null }>}
464
+ */
465
+ export async function inspectRedis() {
466
+ if (!usable()) return { ok: false, html: 0, data: 0, usedMemory: null, totalKeys: null };
467
+
468
+ try {
469
+ const [html, data] = await Promise.all([count("html"), count("data")]);
470
+
471
+ /** @type {string | null} */
472
+ let usedMemory = null;
473
+ /** @type {number | null} */
474
+ let totalKeys = null;
475
+
476
+ try {
477
+ const info = await client.info("memory");
478
+ usedMemory = /used_memory_human:(\S+)/.exec(String(info))?.[1] ?? null;
479
+ totalKeys = Number(await client.dbsize());
480
+ } catch {
481
+ // `INFO`/`DBSIZE` kısıtlı bir kurulumda (managed Redis) reddedilebilir;
482
+ // anahtar sayıları yine geçerli.
483
+ }
484
+
485
+ noteSuccess();
486
+ return { ok: true, html, data, usedMemory, totalKeys };
487
+ } catch (error) {
488
+ noteFailure("inspect", error);
489
+ return { ok: false, html: 0, data: 0, usedMemory: null, totalKeys: null };
490
+ }
491
+ }
492
+
493
+ /**
494
+ * @param {"html" | "data"} kind
495
+ * @returns {Promise<number>}
496
+ */
497
+ async function count(kind) {
498
+ let cursor = "0";
499
+ let total = 0;
500
+
501
+ do {
502
+ const [next, keys] = await client.scan(
503
+ cursor,
504
+ "MATCH",
505
+ `${prefix}:${kind}:*`,
506
+ "COUNT",
507
+ 500,
508
+ );
509
+ cursor = next;
510
+ total += keys.length;
511
+ } while (cursor !== "0");
512
+
513
+ return total;
514
+ }
515
+
408
516
  /**
409
517
  * Bağlantıları kapatır. `SIGTERM` sonrası uçuştaki komutların bitmesi
410
518
  * beklenir (`quit`), zorla kesilmez.
@@ -207,8 +207,13 @@ export function route(controller, options = {}) {
207
207
  const revalidate = isPrivate
208
208
  ? undefined
209
209
  : resolveRevalidate(req.path, options.revalidate);
210
- const cacheable = !isPrivate && req.method === "GET" && Boolean(revalidate);
211
- const cacheKey = `${req.path}?${new URLSearchParams(
210
+ // Anahtar `null` ise query bu yol için cache'lenebilir değil: sayfa
211
+ // dinamik davranır. Anahtar yine de gerekiyor (hata sayfası ölçümü,
212
+ // teşhis) ama TTL sıfırlanıp cache yolu kapatılır.
213
+ const key = buildCacheKey(req.path, ctx.query);
214
+ const cacheable =
215
+ !isPrivate && req.method === "GET" && Boolean(revalidate) && key !== null;
216
+ const cacheKey = key ?? `${req.path}?${new URLSearchParams(
212
217
  Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
213
218
  ).toString()}`;
214
219
 
@@ -409,6 +414,73 @@ const revalidateByPath = new Map();
409
414
  */
410
415
  const REVALIDATE_CACHE_MAX = 2000;
411
416
 
417
+ /**
418
+ * Aynı gerekçeyle (yakalayıcı route'ta sınırsız büyüme) query kuralı da yol
419
+ * başına hatırlanır.
420
+ *
421
+ * @type {Map<string, true | string[] | null>}
422
+ */
423
+ const queryPolicyByPath = new Map();
424
+
425
+ /**
426
+ * @param {string} pathname
427
+ * @returns {true | string[] | null} `null`: bu yol için hiçbir parametreye
428
+ * izin verilmiyor.
429
+ */
430
+ function resolveQueryPolicy(pathname) {
431
+ if (queryPolicyByPath.has(pathname)) {
432
+ return queryPolicyByPath.get(pathname) ?? null;
433
+ }
434
+
435
+ const rules = getConfig().cacheQuery;
436
+ const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
437
+
438
+ if (queryPolicyByPath.size >= REVALIDATE_CACHE_MAX) {
439
+ const oldest = queryPolicyByPath.keys().next().value;
440
+ if (oldest !== undefined) queryPolicyByPath.delete(oldest);
441
+ }
442
+
443
+ const policy = match ? match.allow : null;
444
+ queryPolicyByPath.set(pathname, policy);
445
+ return policy;
446
+ }
447
+
448
+ /**
449
+ * HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
450
+ *
451
+ * Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
452
+ * altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
453
+ * bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
454
+ * sayfaları dışarı atar.
455
+ *
456
+ * İzin verilen parametreler **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı
457
+ * sayfa olduğu için aynı anahtarı almalı.
458
+ *
459
+ * @param {string} pathname
460
+ * @param {Record<string, unknown>} query
461
+ * @returns {string | null}
462
+ */
463
+ function buildCacheKey(pathname, query) {
464
+ const entries = Object.entries(query);
465
+ if (!entries.length) return `${pathname}?`;
466
+
467
+ const policy = resolveQueryPolicy(pathname);
468
+ if (policy === null) return null;
469
+
470
+ const kept =
471
+ policy === true ? entries : entries.filter(([name]) => policy.includes(name));
472
+
473
+ // İzin listesi dışındaki parametreler anahtara girmez: sayfa cache'lenir ve
474
+ // bütün kampanya varyantları tek kopyayı paylaşır.
475
+ const params = new URLSearchParams(
476
+ kept
477
+ .map(/** @returns {[string, string]} */ ([name, value]) => [name, String(value)])
478
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
479
+ );
480
+
481
+ return `${pathname}?${params.toString()}`;
482
+ }
483
+
412
484
  /**
413
485
  * @param {string} pathname
414
486
  * @param {number | undefined} fallback