jskelet 0.2.5 → 0.3.0
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.
- package/AGENTS.md +132 -132
- package/CHANGELOG.md +403 -383
- package/LICENSE +21 -21
- package/bin/jskelet.mjs +103 -103
- package/docs/01-baslangic.md +285 -285
- package/docs/02-mimari.md +293 -287
- package/docs/03-routing.md +486 -480
- package/docs/04-render-ve-sablonlar.md +490 -490
- package/docs/05-islands.md +482 -482
- package/docs/06-cache.md +1231 -1209
- package/docs/07-yapilandirma.md +44 -21
- package/docs/08-build.md +366 -366
- package/docs/09-dev-araclari.md +335 -335
- package/docs/10-dagitim.md +329 -329
- package/docs/11-tasima.md +1 -0
- package/docs/12-panel-ve-oturum.md +384 -384
- package/docs/README.md +105 -105
- package/docs/en/01-getting-started.md +292 -292
- package/docs/en/02-architecture.md +311 -305
- package/docs/en/03-routing.md +503 -497
- package/docs/en/04-rendering.md +504 -504
- package/docs/en/05-islands.md +492 -492
- package/docs/en/06-caching.md +1197 -1198
- package/docs/en/07-configuration.md +1009 -986
- package/docs/en/08-build.md +383 -383
- package/docs/en/09-dev-tools.md +342 -342
- package/docs/en/10-deployment.md +332 -332
- package/docs/en/11-migration.md +360 -359
- package/docs/en/12-dashboards-and-sessions.md +392 -392
- package/docs/en/README.md +112 -112
- package/package.json +102 -102
- package/src/build/ensure-build.mjs +15 -15
- package/src/build/paths.mjs +143 -143
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +268 -268
- package/src/build/tasks/css.mjs +124 -124
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +224 -224
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/client/{cache-panel → admin}/i18n.js +756 -670
- package/src/client/{cache-panel → admin}/login.html +74 -74
- package/src/client/{cache-panel → admin}/panel.css +804 -756
- package/src/client/admin/panel.html +486 -0
- package/src/client/{cache-panel → admin}/panel.js +1242 -915
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +725 -725
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +35 -35
- package/src/client/registry.js +297 -297
- package/src/client/safe-image.js +91 -91
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/config/defaults.js +16 -7
- package/src/config/index.js +38 -22
- package/src/config/pattern.js +107 -107
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies.js +257 -257
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +162 -162
- package/src/index.js +83 -83
- package/src/init.mjs +221 -221
- package/src/log.mjs +58 -0
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -0
- package/src/server/admin/auth.js +125 -0
- package/src/server/admin/event-log.js +151 -0
- package/src/server/admin/gate.js +209 -0
- package/src/server/admin/inventory.js +188 -0
- package/src/server/admin/mount.js +56 -0
- package/src/server/admin/router.js +216 -0
- package/src/server/admin/snapshot.js +126 -0
- package/src/server/assets.js +147 -147
- package/src/server/cache-deps.js +42 -42
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +295 -291
- package/src/server/data-cache.js +462 -462
- package/src/server/dev/report.js +369 -369
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/html-cache.js +817 -817
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +62 -62
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/static-precompressed.js +100 -100
- package/src/server/middleware/trailing-slash.js +53 -0
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/prewarm.js +601 -601
- package/src/server/redis.js +569 -569
- package/src/server/router.js +128 -128
- package/src/server/status-page.js +164 -164
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/start.mjs +7 -7
- package/src/templates/layout.ejs +44 -44
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +85 -85
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +245 -245
- package/src/client/cache-panel/panel.html +0 -308
- package/src/server/cache-panel.js +0 -759
package/src/server/data-cache.js
CHANGED
|
@@ -1,462 +1,462 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Upstream veri için TTL + stale-while-revalidate LRU önbelleği.
|
|
3
|
-
*
|
|
4
|
-
* HTML önbelleği (`html-cache.js`) yalnızca **trafiği olan** sayfaları tutar:
|
|
5
|
-
* girdi başına yüz kilobayt düştüğü için sınırı 500 civarındadır ve on binlerce
|
|
6
|
-
* yolluk bir site onu ısıtmaya çalıştığında kendi ısıttığını siler. Uzun kuyruk
|
|
7
|
-
* için doğru katman bu modül: aynı sayfanın JSON'u HTML'inden onlarca kat
|
|
8
|
-
* küçük olduğu için on binlerce girdi bellekte durur.
|
|
9
|
-
*
|
|
10
|
-
* Kazanç iki taraflı:
|
|
11
|
-
* - Hiç ısıtılmamış bir uzun kuyruk sayfası ilk ziyaretçide render edilir ama
|
|
12
|
-
* upstream'e gitmez; gecikme yüzlerce ms değil, şablon render'ı kadardır.
|
|
13
|
-
* - Periyodik ısıtma turları API kotası harcamaz, veri katmanından okur.
|
|
14
|
-
*
|
|
15
|
-
* `null`/`undefined` **saklanmaz**: uygulamaların HTTP istemcisi hatada
|
|
16
|
-
* genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
|
|
17
|
-
* yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
|
|
18
|
-
* `storeEmpty: true` verir.
|
|
19
|
-
*
|
|
20
|
-
* `cache.redis` açıkken bu önbellek ikinci bir kademeye (L2) yaslanır. Redis'e
|
|
21
|
-
* en uygun katman burası: JSON küçük, sıkıştırılmış varyant sorunu yok ve
|
|
22
|
-
* kazanç doğrudan API kotasına yazılıyor — bir node'un çektiği veri hepsine
|
|
23
|
-
* yeter. Redis kapalı ya da erişilemez olduğunda bu modül birebir eskisi gibi
|
|
24
|
-
* çalışır.
|
|
25
|
-
*/
|
|
26
|
-
import { getConfig } from "../config/index.js";
|
|
27
|
-
import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
|
|
28
|
-
import { recordDependency } from "./cache-deps.js";
|
|
29
|
-
import { invalidateHtmlByDependency } from "./html-cache.js";
|
|
30
|
-
import {
|
|
31
|
-
cacheKey,
|
|
32
|
-
onCacheEvent,
|
|
33
|
-
publishCacheEvent,
|
|
34
|
-
redisDropMatching,
|
|
35
|
-
redisGetJson,
|
|
36
|
-
redisSetJson,
|
|
37
|
-
redisShares,
|
|
38
|
-
} from "./redis.js";
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
/** @type {Map<string, DataEntry>} */
|
|
45
|
-
const store = new Map();
|
|
46
|
-
|
|
47
|
-
/** @type {Map<string, Promise<unknown>>} */
|
|
48
|
-
const inflight = new Map();
|
|
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
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
|
|
77
|
-
* script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
|
|
78
|
-
* kod varsayılanına düşülür.
|
|
79
|
-
*
|
|
80
|
-
* @returns {{ maxEntries: number, staleFactor: number }}
|
|
81
|
-
*/
|
|
82
|
-
function settings() {
|
|
83
|
-
try {
|
|
84
|
-
const { data } = getConfig();
|
|
85
|
-
return {
|
|
86
|
-
maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
|
|
87
|
-
staleFactor: Number.isFinite(Number(data?.staleFactor))
|
|
88
|
-
? Number(data.staleFactor)
|
|
89
|
-
: DEFAULT_DATA_CACHE.staleFactor,
|
|
90
|
-
};
|
|
91
|
-
} catch {
|
|
92
|
-
return { ...DEFAULT_DATA_CACHE };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* @param {string} key
|
|
98
|
-
* @returns {{ value: unknown, stale: boolean } | null}
|
|
99
|
-
*/
|
|
100
|
-
function read(key) {
|
|
101
|
-
const entry = store.get(key);
|
|
102
|
-
if (!entry) return null;
|
|
103
|
-
|
|
104
|
-
const now = Date.now();
|
|
105
|
-
if (now >= entry.staleUntil) {
|
|
106
|
-
store.delete(key);
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// LRU: erişilen girdiyi sona taşı.
|
|
111
|
-
store.delete(key);
|
|
112
|
-
store.set(key, entry);
|
|
113
|
-
|
|
114
|
-
return { value: entry.value, stale: now >= entry.expiresAt };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Girdi sınırını aşan en eski kayıtları düşürür. */
|
|
118
|
-
function evict() {
|
|
119
|
-
const { maxEntries } = settings();
|
|
120
|
-
while (store.size > maxEntries) {
|
|
121
|
-
const oldest = store.keys().next().value;
|
|
122
|
-
if (oldest === undefined) break;
|
|
123
|
-
store.delete(oldest);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* @param {string} key
|
|
129
|
-
* @param {unknown} value
|
|
130
|
-
* @param {number} ttlSeconds
|
|
131
|
-
* @param {number} staleFactor
|
|
132
|
-
*/
|
|
133
|
-
function write(key, value, ttlSeconds, staleFactor) {
|
|
134
|
-
const now = Date.now();
|
|
135
|
-
const ttl = ttlSeconds * 1000;
|
|
136
|
-
|
|
137
|
-
/** @type {DataEntry} */
|
|
138
|
-
const entry = {
|
|
139
|
-
value,
|
|
140
|
-
expiresAt: now + ttl,
|
|
141
|
-
staleUntil: now + ttl + ttl * staleFactor,
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
store.set(key, entry);
|
|
145
|
-
|
|
146
|
-
// Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
|
|
147
|
-
// bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
|
|
148
|
-
if (redisShares("data")) {
|
|
149
|
-
redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
evict();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
|
|
157
|
-
* mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
|
|
158
|
-
* süresiz tazelik kazanır.
|
|
159
|
-
*
|
|
160
|
-
* @param {string} key
|
|
161
|
-
* @param {DataEntry} entry
|
|
162
|
-
*/
|
|
163
|
-
function promote(key, entry) {
|
|
164
|
-
store.set(key, entry);
|
|
165
|
-
evict();
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Paylaşımlı kademeden okur.
|
|
170
|
-
*
|
|
171
|
-
* Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
|
|
172
|
-
* tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
|
|
173
|
-
* yine Redis'i okur ve `producer` hiç çalışmaz.
|
|
174
|
-
*
|
|
175
|
-
* @param {string} key
|
|
176
|
-
* @returns {Promise<DataEntry | null>}
|
|
177
|
-
*/
|
|
178
|
-
async function readShared(key) {
|
|
179
|
-
if (!redisShares("data")) return null;
|
|
180
|
-
|
|
181
|
-
const entry = await redisGetJson(cacheKey("data", key));
|
|
182
|
-
if (!entry || typeof entry.expiresAt !== "number") return null;
|
|
183
|
-
if (Date.now() >= entry.expiresAt) return null;
|
|
184
|
-
|
|
185
|
-
return /** @type {DataEntry} */ (entry);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* @param {string} key
|
|
190
|
-
* @param {number} ttlSeconds
|
|
191
|
-
* @param {() => Promise<unknown>} producer
|
|
192
|
-
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
193
|
-
* @returns {Promise<unknown>}
|
|
194
|
-
*/
|
|
195
|
-
function refresh(key, ttlSeconds, producer, options) {
|
|
196
|
-
// Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
|
|
197
|
-
// Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
|
|
198
|
-
const pending = inflight.get(key);
|
|
199
|
-
if (pending) {
|
|
200
|
-
stats.coalesced += 1;
|
|
201
|
-
return pending;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const staleFactor = options.staleFactor ?? settings().staleFactor;
|
|
205
|
-
|
|
206
|
-
const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
|
|
207
|
-
inflight.delete(key);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
inflight.set(key, task);
|
|
211
|
-
return task;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* @param {string} key
|
|
216
|
-
* @param {number} ttlSeconds
|
|
217
|
-
* @param {() => Promise<unknown>} producer
|
|
218
|
-
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
219
|
-
* @param {number} staleFactor
|
|
220
|
-
* @returns {Promise<unknown>}
|
|
221
|
-
*/
|
|
222
|
-
async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
223
|
-
// Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
|
|
224
|
-
// Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
|
|
225
|
-
const shared = await readShared(key);
|
|
226
|
-
if (shared) {
|
|
227
|
-
stats.shared += 1;
|
|
228
|
-
promote(key, shared);
|
|
229
|
-
return shared.value;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
stats.produced += 1;
|
|
233
|
-
const value = await producer();
|
|
234
|
-
|
|
235
|
-
const empty = value === undefined || value === null;
|
|
236
|
-
if (!empty || options.storeEmpty === true) {
|
|
237
|
-
write(key, value, ttlSeconds, staleFactor);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return value;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
|
|
245
|
-
*
|
|
246
|
-
* @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
|
|
247
|
-
* anahtara yazılır (`quote:v2:${symbol}`).
|
|
248
|
-
* @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
|
|
249
|
-
* @param {() => Promise<T>} producer
|
|
250
|
-
* @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
|
|
251
|
-
* `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
|
|
252
|
-
* penceresini ayarlar (0 → bayat servis yok).
|
|
253
|
-
* @returns {Promise<T>}
|
|
254
|
-
* @template T
|
|
255
|
-
*/
|
|
256
|
-
export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
257
|
-
if (!ttlSeconds) {
|
|
258
|
-
stats.bypassed += 1;
|
|
259
|
-
return producer();
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
|
|
263
|
-
// sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
|
|
264
|
-
recordDependency(key);
|
|
265
|
-
|
|
266
|
-
const hit = read(key);
|
|
267
|
-
|
|
268
|
-
if (hit) {
|
|
269
|
-
if (hit.stale) stats.stale += 1;
|
|
270
|
-
else stats.hits += 1;
|
|
271
|
-
|
|
272
|
-
// Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
|
|
273
|
-
// etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
|
|
274
|
-
// olması sayfaya yansımaz.
|
|
275
|
-
if (hit.stale) {
|
|
276
|
-
void refresh(key, ttlSeconds, producer, options).catch((error) => {
|
|
277
|
-
console.error(`[data-cache] background refresh failed: ${key}`, error);
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
return /** @type {T} */ (hit.value);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
stats.misses += 1;
|
|
284
|
-
|
|
285
|
-
try {
|
|
286
|
-
return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
|
|
287
|
-
} catch (error) {
|
|
288
|
-
// Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
|
|
289
|
-
// durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
|
|
290
|
-
// ziyaretçiye hata sayfası göstermekten iyidir.
|
|
291
|
-
const stale = read(key);
|
|
292
|
-
if (!stale) throw error;
|
|
293
|
-
|
|
294
|
-
console.warn(
|
|
295
|
-
`[data-cache] producer failed, serving stale value: ${key}`,
|
|
296
|
-
error instanceof Error ? error.message : error,
|
|
297
|
-
);
|
|
298
|
-
return /** @type {T} */ (stale.value);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
|
|
304
|
-
* `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
|
|
305
|
-
* arasında ve TTL'li.
|
|
306
|
-
*
|
|
307
|
-
* @param {F} fn
|
|
308
|
-
* @param {{ key: string, revalidate: number, storeEmpty?: boolean,
|
|
309
|
-
* staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
|
|
310
|
-
* @returns {F}
|
|
311
|
-
* @template {(...args: any[]) => Promise<any>} F
|
|
312
|
-
*/
|
|
313
|
-
export function dataCache(fn, options) {
|
|
314
|
-
const wrapped = (...args) =>
|
|
315
|
-
withDataCache(
|
|
316
|
-
args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
|
|
317
|
-
options.revalidate,
|
|
318
|
-
() => fn(...args),
|
|
319
|
-
options,
|
|
320
|
-
);
|
|
321
|
-
|
|
322
|
-
return /** @type {F} */ (wrapped);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
|
|
327
|
-
* "bu haber güncellendi" bilgisi geldiğinde kullanılır.
|
|
328
|
-
*
|
|
329
|
-
* Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
|
|
330
|
-
* uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
|
|
331
|
-
* gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
|
|
332
|
-
*
|
|
333
|
-
* `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
|
|
334
|
-
* node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
|
|
335
|
-
* önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
|
|
336
|
-
*
|
|
337
|
-
* @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
|
|
338
|
-
* @returns {number} Silinen girdi sayısı.
|
|
339
|
-
*/
|
|
340
|
-
export function clearDataCache(prefix) {
|
|
341
|
-
const removed = clearLocal(prefix);
|
|
342
|
-
|
|
343
|
-
if (redisShares("data")) {
|
|
344
|
-
void redisDropMatching(
|
|
345
|
-
"data",
|
|
346
|
-
prefix === undefined ? undefined : (key) => key.startsWith(prefix),
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
|
|
351
|
-
// kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
|
|
352
|
-
publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
|
|
353
|
-
|
|
354
|
-
return removed;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
|
|
359
|
-
* bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
|
|
360
|
-
*
|
|
361
|
-
* @param {string} [prefix]
|
|
362
|
-
* @returns {number}
|
|
363
|
-
*/
|
|
364
|
-
function clearLocal(prefix) {
|
|
365
|
-
/** @type {string[]} */
|
|
366
|
-
const removed = [];
|
|
367
|
-
|
|
368
|
-
if (prefix === undefined) {
|
|
369
|
-
removed.push(...store.keys());
|
|
370
|
-
store.clear();
|
|
371
|
-
} else {
|
|
372
|
-
for (const key of store.keys()) {
|
|
373
|
-
if (key.startsWith(prefix)) {
|
|
374
|
-
store.delete(key);
|
|
375
|
-
removed.push(key);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
if (removed.length) invalidateHtmlByDependency(removed);
|
|
381
|
-
return removed.length;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
|
|
385
|
-
// `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
|
|
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
|
-
|
|
394
|
-
if (event.type !== "data:clear") return;
|
|
395
|
-
clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
|
|
396
|
-
});
|
|
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
|
-
|
|
424
|
-
/** @returns {number} */
|
|
425
|
-
export function getDataCacheSize() {
|
|
426
|
-
return store.size;
|
|
427
|
-
}
|
|
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
|
-
|
|
448
|
-
/**
|
|
449
|
-
* Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
|
|
450
|
-
* tamamını bir teşhis ucundan dışa vermek istenmez.
|
|
451
|
-
*
|
|
452
|
-
* @returns {{ key: string, stale: boolean, expiresIn: number }[]}
|
|
453
|
-
*/
|
|
454
|
-
export function getDataCacheEntries() {
|
|
455
|
-
const now = Date.now();
|
|
456
|
-
|
|
457
|
-
return [...store.entries()].map(([key, entry]) => ({
|
|
458
|
-
key,
|
|
459
|
-
stale: now >= entry.expiresAt,
|
|
460
|
-
expiresIn: Math.round((entry.expiresAt - now) / 1000),
|
|
461
|
-
}));
|
|
462
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Upstream veri için TTL + stale-while-revalidate LRU önbelleği.
|
|
3
|
+
*
|
|
4
|
+
* HTML önbelleği (`html-cache.js`) yalnızca **trafiği olan** sayfaları tutar:
|
|
5
|
+
* girdi başına yüz kilobayt düştüğü için sınırı 500 civarındadır ve on binlerce
|
|
6
|
+
* yolluk bir site onu ısıtmaya çalıştığında kendi ısıttığını siler. Uzun kuyruk
|
|
7
|
+
* için doğru katman bu modül: aynı sayfanın JSON'u HTML'inden onlarca kat
|
|
8
|
+
* küçük olduğu için on binlerce girdi bellekte durur.
|
|
9
|
+
*
|
|
10
|
+
* Kazanç iki taraflı:
|
|
11
|
+
* - Hiç ısıtılmamış bir uzun kuyruk sayfası ilk ziyaretçide render edilir ama
|
|
12
|
+
* upstream'e gitmez; gecikme yüzlerce ms değil, şablon render'ı kadardır.
|
|
13
|
+
* - Periyodik ısıtma turları API kotası harcamaz, veri katmanından okur.
|
|
14
|
+
*
|
|
15
|
+
* `null`/`undefined` **saklanmaz**: uygulamaların HTTP istemcisi hatada
|
|
16
|
+
* genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
|
|
17
|
+
* yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
|
|
18
|
+
* `storeEmpty: true` verir.
|
|
19
|
+
*
|
|
20
|
+
* `cache.redis` açıkken bu önbellek ikinci bir kademeye (L2) yaslanır. Redis'e
|
|
21
|
+
* en uygun katman burası: JSON küçük, sıkıştırılmış varyant sorunu yok ve
|
|
22
|
+
* kazanç doğrudan API kotasına yazılıyor — bir node'un çektiği veri hepsine
|
|
23
|
+
* yeter. Redis kapalı ya da erişilemez olduğunda bu modül birebir eskisi gibi
|
|
24
|
+
* çalışır.
|
|
25
|
+
*/
|
|
26
|
+
import { getConfig } from "../config/index.js";
|
|
27
|
+
import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
|
|
28
|
+
import { recordDependency } from "./cache-deps.js";
|
|
29
|
+
import { invalidateHtmlByDependency } from "./html-cache.js";
|
|
30
|
+
import {
|
|
31
|
+
cacheKey,
|
|
32
|
+
onCacheEvent,
|
|
33
|
+
publishCacheEvent,
|
|
34
|
+
redisDropMatching,
|
|
35
|
+
redisGetJson,
|
|
36
|
+
redisSetJson,
|
|
37
|
+
redisShares,
|
|
38
|
+
} from "./redis.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** @type {Map<string, DataEntry>} */
|
|
45
|
+
const store = new Map();
|
|
46
|
+
|
|
47
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
48
|
+
const inflight = new Map();
|
|
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
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
|
|
77
|
+
* script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
|
|
78
|
+
* kod varsayılanına düşülür.
|
|
79
|
+
*
|
|
80
|
+
* @returns {{ maxEntries: number, staleFactor: number }}
|
|
81
|
+
*/
|
|
82
|
+
function settings() {
|
|
83
|
+
try {
|
|
84
|
+
const { data } = getConfig();
|
|
85
|
+
return {
|
|
86
|
+
maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
|
|
87
|
+
staleFactor: Number.isFinite(Number(data?.staleFactor))
|
|
88
|
+
? Number(data.staleFactor)
|
|
89
|
+
: DEFAULT_DATA_CACHE.staleFactor,
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return { ...DEFAULT_DATA_CACHE };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} key
|
|
98
|
+
* @returns {{ value: unknown, stale: boolean } | null}
|
|
99
|
+
*/
|
|
100
|
+
function read(key) {
|
|
101
|
+
const entry = store.get(key);
|
|
102
|
+
if (!entry) return null;
|
|
103
|
+
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (now >= entry.staleUntil) {
|
|
106
|
+
store.delete(key);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// LRU: erişilen girdiyi sona taşı.
|
|
111
|
+
store.delete(key);
|
|
112
|
+
store.set(key, entry);
|
|
113
|
+
|
|
114
|
+
return { value: entry.value, stale: now >= entry.expiresAt };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Girdi sınırını aşan en eski kayıtları düşürür. */
|
|
118
|
+
function evict() {
|
|
119
|
+
const { maxEntries } = settings();
|
|
120
|
+
while (store.size > maxEntries) {
|
|
121
|
+
const oldest = store.keys().next().value;
|
|
122
|
+
if (oldest === undefined) break;
|
|
123
|
+
store.delete(oldest);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} key
|
|
129
|
+
* @param {unknown} value
|
|
130
|
+
* @param {number} ttlSeconds
|
|
131
|
+
* @param {number} staleFactor
|
|
132
|
+
*/
|
|
133
|
+
function write(key, value, ttlSeconds, staleFactor) {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
const ttl = ttlSeconds * 1000;
|
|
136
|
+
|
|
137
|
+
/** @type {DataEntry} */
|
|
138
|
+
const entry = {
|
|
139
|
+
value,
|
|
140
|
+
expiresAt: now + ttl,
|
|
141
|
+
staleUntil: now + ttl + ttl * staleFactor,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
store.set(key, entry);
|
|
145
|
+
|
|
146
|
+
// Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
|
|
147
|
+
// bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
|
|
148
|
+
if (redisShares("data")) {
|
|
149
|
+
redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
evict();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
|
|
157
|
+
* mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
|
|
158
|
+
* süresiz tazelik kazanır.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} key
|
|
161
|
+
* @param {DataEntry} entry
|
|
162
|
+
*/
|
|
163
|
+
function promote(key, entry) {
|
|
164
|
+
store.set(key, entry);
|
|
165
|
+
evict();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Paylaşımlı kademeden okur.
|
|
170
|
+
*
|
|
171
|
+
* Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
|
|
172
|
+
* tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
|
|
173
|
+
* yine Redis'i okur ve `producer` hiç çalışmaz.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} key
|
|
176
|
+
* @returns {Promise<DataEntry | null>}
|
|
177
|
+
*/
|
|
178
|
+
async function readShared(key) {
|
|
179
|
+
if (!redisShares("data")) return null;
|
|
180
|
+
|
|
181
|
+
const entry = await redisGetJson(cacheKey("data", key));
|
|
182
|
+
if (!entry || typeof entry.expiresAt !== "number") return null;
|
|
183
|
+
if (Date.now() >= entry.expiresAt) return null;
|
|
184
|
+
|
|
185
|
+
return /** @type {DataEntry} */ (entry);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} key
|
|
190
|
+
* @param {number} ttlSeconds
|
|
191
|
+
* @param {() => Promise<unknown>} producer
|
|
192
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
193
|
+
* @returns {Promise<unknown>}
|
|
194
|
+
*/
|
|
195
|
+
function refresh(key, ttlSeconds, producer, options) {
|
|
196
|
+
// Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
|
|
197
|
+
// Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
|
|
198
|
+
const pending = inflight.get(key);
|
|
199
|
+
if (pending) {
|
|
200
|
+
stats.coalesced += 1;
|
|
201
|
+
return pending;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const staleFactor = options.staleFactor ?? settings().staleFactor;
|
|
205
|
+
|
|
206
|
+
const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
|
|
207
|
+
inflight.delete(key);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
inflight.set(key, task);
|
|
211
|
+
return task;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {string} key
|
|
216
|
+
* @param {number} ttlSeconds
|
|
217
|
+
* @param {() => Promise<unknown>} producer
|
|
218
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
219
|
+
* @param {number} staleFactor
|
|
220
|
+
* @returns {Promise<unknown>}
|
|
221
|
+
*/
|
|
222
|
+
async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
223
|
+
// Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
|
|
224
|
+
// Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
|
|
225
|
+
const shared = await readShared(key);
|
|
226
|
+
if (shared) {
|
|
227
|
+
stats.shared += 1;
|
|
228
|
+
promote(key, shared);
|
|
229
|
+
return shared.value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
stats.produced += 1;
|
|
233
|
+
const value = await producer();
|
|
234
|
+
|
|
235
|
+
const empty = value === undefined || value === null;
|
|
236
|
+
if (!empty || options.storeEmpty === true) {
|
|
237
|
+
write(key, value, ttlSeconds, staleFactor);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
|
|
247
|
+
* anahtara yazılır (`quote:v2:${symbol}`).
|
|
248
|
+
* @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
|
|
249
|
+
* @param {() => Promise<T>} producer
|
|
250
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
|
|
251
|
+
* `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
|
|
252
|
+
* penceresini ayarlar (0 → bayat servis yok).
|
|
253
|
+
* @returns {Promise<T>}
|
|
254
|
+
* @template T
|
|
255
|
+
*/
|
|
256
|
+
export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
257
|
+
if (!ttlSeconds) {
|
|
258
|
+
stats.bypassed += 1;
|
|
259
|
+
return producer();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
|
|
263
|
+
// sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
|
|
264
|
+
recordDependency(key);
|
|
265
|
+
|
|
266
|
+
const hit = read(key);
|
|
267
|
+
|
|
268
|
+
if (hit) {
|
|
269
|
+
if (hit.stale) stats.stale += 1;
|
|
270
|
+
else stats.hits += 1;
|
|
271
|
+
|
|
272
|
+
// Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
|
|
273
|
+
// etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
|
|
274
|
+
// olması sayfaya yansımaz.
|
|
275
|
+
if (hit.stale) {
|
|
276
|
+
void refresh(key, ttlSeconds, producer, options).catch((error) => {
|
|
277
|
+
console.error(`[data-cache] background refresh failed: ${key}`, error);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return /** @type {T} */ (hit.value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
stats.misses += 1;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
|
|
287
|
+
} catch (error) {
|
|
288
|
+
// Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
|
|
289
|
+
// durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
|
|
290
|
+
// ziyaretçiye hata sayfası göstermekten iyidir.
|
|
291
|
+
const stale = read(key);
|
|
292
|
+
if (!stale) throw error;
|
|
293
|
+
|
|
294
|
+
console.warn(
|
|
295
|
+
`[data-cache] producer failed, serving stale value: ${key}`,
|
|
296
|
+
error instanceof Error ? error.message : error,
|
|
297
|
+
);
|
|
298
|
+
return /** @type {T} */ (stale.value);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
|
|
304
|
+
* `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
|
|
305
|
+
* arasında ve TTL'li.
|
|
306
|
+
*
|
|
307
|
+
* @param {F} fn
|
|
308
|
+
* @param {{ key: string, revalidate: number, storeEmpty?: boolean,
|
|
309
|
+
* staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
|
|
310
|
+
* @returns {F}
|
|
311
|
+
* @template {(...args: any[]) => Promise<any>} F
|
|
312
|
+
*/
|
|
313
|
+
export function dataCache(fn, options) {
|
|
314
|
+
const wrapped = (...args) =>
|
|
315
|
+
withDataCache(
|
|
316
|
+
args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
|
|
317
|
+
options.revalidate,
|
|
318
|
+
() => fn(...args),
|
|
319
|
+
options,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return /** @type {F} */ (wrapped);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
|
|
327
|
+
* "bu haber güncellendi" bilgisi geldiğinde kullanılır.
|
|
328
|
+
*
|
|
329
|
+
* Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
|
|
330
|
+
* uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
|
|
331
|
+
* gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
|
|
332
|
+
*
|
|
333
|
+
* `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
|
|
334
|
+
* node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
|
|
335
|
+
* önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
|
|
336
|
+
*
|
|
337
|
+
* @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
|
|
338
|
+
* @returns {number} Silinen girdi sayısı.
|
|
339
|
+
*/
|
|
340
|
+
export function clearDataCache(prefix) {
|
|
341
|
+
const removed = clearLocal(prefix);
|
|
342
|
+
|
|
343
|
+
if (redisShares("data")) {
|
|
344
|
+
void redisDropMatching(
|
|
345
|
+
"data",
|
|
346
|
+
prefix === undefined ? undefined : (key) => key.startsWith(prefix),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
|
|
351
|
+
// kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
|
|
352
|
+
publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
|
|
353
|
+
|
|
354
|
+
return removed;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
|
|
359
|
+
* bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
|
|
360
|
+
*
|
|
361
|
+
* @param {string} [prefix]
|
|
362
|
+
* @returns {number}
|
|
363
|
+
*/
|
|
364
|
+
function clearLocal(prefix) {
|
|
365
|
+
/** @type {string[]} */
|
|
366
|
+
const removed = [];
|
|
367
|
+
|
|
368
|
+
if (prefix === undefined) {
|
|
369
|
+
removed.push(...store.keys());
|
|
370
|
+
store.clear();
|
|
371
|
+
} else {
|
|
372
|
+
for (const key of store.keys()) {
|
|
373
|
+
if (key.startsWith(prefix)) {
|
|
374
|
+
store.delete(key);
|
|
375
|
+
removed.push(key);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (removed.length) invalidateHtmlByDependency(removed);
|
|
381
|
+
return removed.length;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
|
|
385
|
+
// `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
|
|
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
|
+
|
|
394
|
+
if (event.type !== "data:clear") return;
|
|
395
|
+
clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
|
|
396
|
+
});
|
|
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
|
+
|
|
424
|
+
/** @returns {number} */
|
|
425
|
+
export function getDataCacheSize() {
|
|
426
|
+
return store.size;
|
|
427
|
+
}
|
|
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
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
|
|
450
|
+
* tamamını bir teşhis ucundan dışa vermek istenmez.
|
|
451
|
+
*
|
|
452
|
+
* @returns {{ key: string, stale: boolean, expiresIn: number }[]}
|
|
453
|
+
*/
|
|
454
|
+
export function getDataCacheEntries() {
|
|
455
|
+
const now = Date.now();
|
|
456
|
+
|
|
457
|
+
return [...store.entries()].map(([key, entry]) => ({
|
|
458
|
+
key,
|
|
459
|
+
stale: now >= entry.expiresAt,
|
|
460
|
+
expiresIn: Math.round((entry.expiresAt - now) / 1000),
|
|
461
|
+
}));
|
|
462
|
+
}
|