jskelet 0.2.0 → 0.2.2

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.
@@ -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
@@ -20,7 +20,11 @@
20
20
  */
21
21
  import process from "node:process";
22
22
  import { getConfig, hook } from "../config/index.js";
23
+ import { getRequestContext } from "../http/request-context.js";
23
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";
24
28
 
25
29
  /**
26
30
  * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
@@ -45,11 +49,13 @@ export const prewarmProgress = {
45
49
  };
46
50
 
47
51
  /**
48
- * Isıtma turu sırasında bastırılan istek hataları: mesaj → kaç kez görüldü.
52
+ * Isıtma turu sırasında bastırılan uyarılar: mesaj → kaç kez görüldü.
49
53
  *
50
54
  * Yüzlerce yolu tarayan bir tur, upstream bir an için tıksırdığında yüzlerce
51
- * yığın izini loga döküyor ve asıl bilgi (kaç sayfa ısındı) kayboluyor. Tur
52
- * boyunca hatalar burada toplanır, tur bitince tek satırda özetlenir.
55
+ * satır loga döküyor ve asıl bilgi (kaç sayfa ısındı) kayboluyor. Tur boyunca
56
+ * mesajlar burada toplanır, tur bitince tek satırda özetlenir. Yol adı
57
+ * anahtara girmez: gruplanabilmesi için mesajın kendisi yeterli, tek bir yolun
58
+ * ayrıntısı zaten `entries` üzerinden dev panelinde görünüyor.
53
59
  * @type {Map<string, number>}
54
60
  */
55
61
  const suppressed = new Map();
@@ -59,19 +65,41 @@ const suppressed = new Map();
59
65
  * ısıtmanın user-agent'ıyla geldiğinde doğru; gerçek trafiğin hataları her
60
66
  * zaman loglanmaya devam eder.
61
67
  *
62
- * @param {{ get?: (name: string) => string | undefined }} req
68
+ * @param {{ get?: (name: string) => string | undefined } | null | undefined} req
63
69
  * @returns {boolean}
64
70
  */
65
71
  export function isPrewarmRequest(req) {
66
- if (!prewarmProgress.active) return false;
72
+ if (!prewarmProgress.active || !req) return false;
67
73
  const ua = req.get?.("user-agent");
68
74
  return Boolean(ua) && ua === getConfig().brand.prewarmUserAgent;
69
75
  }
70
76
 
71
77
  /**
72
- * Bastırılan bir hatayı sayaca ekler. Yığın izi saklanmaz: özet satırının
73
- * amacı "neyin bozulduğunu" göstermek, hatayı ayıklamak değil — tek bir yol
74
- * için ayrıntı isteyen dev paneli `entries` üzerinden bakar.
78
+ * Şu an işlenen istek ısıtma turuna mı ait? `req` elde olmayan derin
79
+ * katmanlar (render, cache) için: yanıt nesnesi istek bağlamında taşınıyor.
80
+ *
81
+ * @returns {boolean}
82
+ */
83
+ function inPrewarmRequest() {
84
+ if (!prewarmProgress.active) return false;
85
+ return isPrewarmRequest(getRequestContext()?.res?.req);
86
+ }
87
+
88
+ /**
89
+ * Isıtma turuna ait bir uyarıyı loglamak yerine sayar.
90
+ *
91
+ * @param {string} message Gruplama anahtarı; yol adı içermemeli.
92
+ * @returns {boolean} `true` ise sayıldı, çağıran taraf loglamamalı.
93
+ */
94
+ export function suppressForPrewarm(message) {
95
+ if (!inPrewarmRequest()) return false;
96
+ suppressed.set(message, (suppressed.get(message) ?? 0) + 1);
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Bastırılan bir istek hatasını sayaca ekler. Yığın izi saklanmaz: özet
102
+ * satırının amacı "neyin bozulduğunu" göstermek, hatayı ayıklamak değil.
75
103
  *
76
104
  * @param {number} status
77
105
  * @param {unknown} error
@@ -241,7 +269,10 @@ function devGateHeader() {
241
269
  * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
242
270
  * anlamı değiştiği için çağıran taraf kendi formülünü verir.
243
271
  * @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
244
- * @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ı.
245
276
  */
246
277
  async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
247
278
  const { brand } = getConfig();
@@ -250,8 +281,8 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
250
281
  let index = 0;
251
282
  let ok = 0;
252
283
  let failed = 0;
253
- /** @type {string[]} */
254
- const failedPaths = [];
284
+ /** @type {{ path: string, status: number }[]} */
285
+ const failures = [];
255
286
 
256
287
  async function worker() {
257
288
  while (index < paths.length) {
@@ -276,7 +307,7 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
276
307
  if (response.ok) ok += 1;
277
308
  else {
278
309
  failed += 1;
279
- failedPaths.push(target);
310
+ failures.push({ path: target, status: response.status });
280
311
  }
281
312
 
282
313
  prewarmProgress.entries.push({
@@ -289,7 +320,8 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
289
320
  });
290
321
  } catch (error) {
291
322
  failed += 1;
292
- failedPaths.push(target);
323
+ // Yanıt hiç gelmedi: ağ hatası her zaman geçici, tekrar denenir.
324
+ failures.push({ path: target, status: 0 });
293
325
  prewarmProgress.entries.push({
294
326
  path: target,
295
327
  status: 0,
@@ -314,7 +346,66 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
314
346
  Array.from({ length: Math.min(concurrency, paths.length) }, worker),
315
347
  );
316
348
 
317
- 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
+ );
318
409
  }
319
410
 
320
411
  /**
@@ -367,13 +458,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
367
458
  });
368
459
  suppressed.clear();
369
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
+
370
465
  let ok = 0;
371
466
  let failed = 0;
372
467
  let recovered = 0;
468
+ let skippedRetry = 0;
373
469
  try {
374
- /** @type {string[]} */
375
- let failedPaths;
376
- ({ ok, failed, failedPaths } = await crawl(
470
+ /** @type {{ path: string, status: number }[]} */
471
+ let failures;
472
+ ({ ok, failed, failures } = await crawl(
377
473
  origin,
378
474
  paths,
379
475
  concurrency,
@@ -384,18 +480,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
384
480
  // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
385
481
  // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
386
482
  // önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
387
- if (failedPaths.length) {
483
+ const retryPaths = retryablePaths(failures);
484
+ skippedRetry = failures.length - retryPaths.length;
485
+
486
+ if (retryPaths.length) {
388
487
  const firstOk = ok;
389
488
  const firstFailed = failed;
390
489
 
391
- // Rate limit pencereleri saniye mertebesinde; hemen tekrar denemek aynı
392
- // 429'u almak demek.
393
- const retryDelay = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
394
- await sleep(retryDelay);
490
+ await sleep(retryDelayMs());
395
491
 
396
492
  const retry = await crawl(
397
493
  origin,
398
- failedPaths,
494
+ retryPaths,
399
495
  1,
400
496
  (retriedOk) => {
401
497
  // Tekrar turunda her başarı bir hatayı başarıya çevirir.
@@ -425,10 +521,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
425
521
  `${pending.length ? `, ${pending.length} invalidated` : ""}` +
426
522
  `${failed ? `, ${failed} failed` : ""}` +
427
523
  `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
524
+ `${skippedRetry ? `, ${skippedRetry} not retried (permanent)` : ""}` +
428
525
  `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
429
526
  ` (${(elapsed / 1000).toFixed(1)}s)`,
430
527
  );
431
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
+
432
536
  // Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
433
537
  // kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
434
538
  // görünür tutmak.
@@ -439,7 +543,7 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
439
543
  const total = ranked.reduce((sum, [, n]) => sum + n, 0);
440
544
 
441
545
  console.warn(
442
- `[prewarm] ${total} request error${total === 1 ? "" : "s"} were not logged individually:\n` +
546
+ `[prewarm] ${total} problem${total === 1 ? " was" : "s were"} not logged individually:\n` +
443
547
  shown.map(([message, n]) => ` ${n}× ${message}`).join("\n") +
444
548
  (rest ? `\n … ${rest} more in ${ranked.length - shown.length} other kinds` : ""),
445
549
  );
@@ -39,6 +39,7 @@ import * as html from "../views/helpers/html.js";
39
39
  import * as tags from "../views/helpers/tags.js";
40
40
  import { loadComponents } from "../views/components/loader.js";
41
41
  import { renderStatusPage } from "./status-page.js";
42
+ import { suppressForPrewarm } from "./prewarm.js";
42
43
 
43
44
  const isDev = process.env.NODE_ENV === "development";
44
45
 
@@ -530,10 +531,12 @@ async function produce(controller, ctx) {
530
531
  let failures = first.transient;
531
532
 
532
533
  for (let round = 1; round <= attempts; round += 1) {
533
- console.warn(
534
- `[render] ${ctx.pathname} returned notFound() while upstream is failing ` +
535
- `(${summarizeFailures(failures)}), retrying (${round}/${attempts})`,
536
- );
534
+ const retryDetail =
535
+ `returned notFound() while upstream is failing ` +
536
+ `(${summarizeFailures(failures)}), retrying (${round}/${attempts})`;
537
+ if (!suppressForPrewarm(retryDetail)) {
538
+ console.warn(`[render] ${ctx.pathname} ${retryDetail}`);
539
+ }
537
540
 
538
541
  // Beklemeden tekrar denemek rate limit'e girmiş bir API'de aynı 429'u
539
542
  // getirir; kısa bekleme hem pencerenin dönmesine şans verir hem de
@@ -558,10 +561,12 @@ async function produce(controller, ctx) {
558
561
 
559
562
  // Denemeler tükendi. 404 yerine 503: önbelleğe girmez, `Retry-After` taşır
560
563
  // ve bir sonraki istek gerçek içeriği üretebilir.
561
- console.warn(
562
- `[render] ${ctx.pathname} could not be produced, upstream is still failing ` +
563
- `(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`,
564
- );
564
+ const outageDetail =
565
+ `could not be produced, upstream is still failing ` +
566
+ `(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`;
567
+ if (!suppressForPrewarm(outageDetail)) {
568
+ console.warn(`[render] ${ctx.pathname} ${outageDetail}`);
569
+ }
565
570
 
566
571
  return {
567
572
  html: await renderStatusPage(503),
@@ -624,17 +629,22 @@ function hasUpstreamFailures(pathname) {
624
629
  const transient = failures.filter((failure) => isTransientStatus(failure.status));
625
630
  const permanent = failures.filter((failure) => !isTransientStatus(failure.status));
626
631
 
632
+ // Isıtma turu yüzlerce yolu tarıyor; aynı upstream arızası her yol için bir
633
+ // satır basınca tur özeti kayboluyor. Turun uyarıları sayılıp sonunda
634
+ // toplanır (bkz. `suppressForPrewarm`).
627
635
  if (permanent.length) {
628
- console.warn(
629
- `[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarizeFailures(permanent)})`,
630
- );
636
+ const detail = `missing data, upstream is failing permanently (${summarizeFailures(permanent)})`;
637
+ if (!suppressForPrewarm(detail)) {
638
+ console.warn(`[render] ${pathname} was produced with ${detail}`);
639
+ }
631
640
  }
632
641
 
633
642
  if (!transient.length) return false;
634
643
 
635
- console.warn(
636
- `[render] ${pathname} was produced with missing data, not caching it (${summarizeFailures(transient)})`,
637
- );
644
+ const detail = `missing data, not caching it (${summarizeFailures(transient)})`;
645
+ if (!suppressForPrewarm(detail)) {
646
+ console.warn(`[render] ${pathname} was produced with ${detail}`);
647
+ }
638
648
 
639
649
  return true;
640
650
  }