Anton Malykhin commited on
Commit
a290b8b
·
1 Parent(s): c8e85db

feat: optimize bucket data caching and document refresh behavior

Browse files

- Add private five-minute browser caching for SvelteKit page-data responses:
- Ranking,
- Details,
- Visualizations.
- Set Cache-Control to private, max-age=300.
- Remove the stale-while-revalidate window so expired route data is refreshed before rendering.
- Support cache handling for both localized and non-localized __data.json routes.

- Add gzip compression for page-data responses larger than 1 KB.
- Respect the client Accept-Encoding header and gzip quality value.
- Set Content-Encoding and Vary headers for compressed responses.
- Preserve uncompressed responses for clients without gzip support.
- Reduce transferred payload sizes approximately:
- Ranking from 171 KB to 58 KB,
- Details from 643 KB to 158 KB,
- Visualizations from 709 KB to 122 KB.

- Refresh the small bucket manifest whenever a browser cache miss reaches the server.
- Add refreshManifest snapshot option to separate manifest checks from full payload refreshes.
- Reuse catalog, leaderboard, and adapted datasets when snapshot_id is unchanged.
- Download and rebuild payloads only after detecting a new snapshot_id.
- Remove independent Ranking and Details TTL checks that could compound cache freshness delays.
- Keep the expected freshness window tied to the five-minute browser cache.

- Key ranking snapshot in-flight requests by snapshot_id.
- Prevent a request for a newly published snapshot from joining an older snapshot download.
- Add a shared Details snapshot cache keyed by snapshot_id.
- Reuse details_matrix between Ranking partial-run detection and the Details page.
- Deduplicate concurrent Details snapshot downloads.
- Refresh the Tools manifest after a browser cache miss while retaining cached visualization data
for an unchanged snapshot.

- Add detailed English and Russian documentation for the current data flow:
- bucket configuration and server-only authentication,
- manifest and snapshot_id versioning,
- route-to-payload mapping,
- browser and in-memory cache behavior,
- gzip response handling,
- data freshness guarantees,
- validation and stale fallback behavior,
- cold starts and multiple replicas,
- atomic snapshot publishing requirements,
- operational verification commands,
- implementation file map.

- Document that every bucket publish must use a new unique snapshot_id and update manifest.json
only after all referenced payload files are available.

docs/data-loading-cache_en.md ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data loading and caching
2
+
3
+ This document describes the current implementation of data loading and caching in the
4
+ HiveTrace Guardrail Leaderboard frontend.
5
+
6
+ The implementation has three goals:
7
+
8
+ - keep the private Hugging Face token on the SvelteKit server;
9
+ - avoid downloading and adapting unchanged bucket payloads;
10
+ - make repeated client-side navigation fast while providing a predictable data freshness window.
11
+
12
+ ## Architecture
13
+
14
+ ```text
15
+ Browser
16
+ -> SvelteKit page or __data.json request
17
+ -> Hugging Face Space Node process
18
+ -> private Hugging Face bucket
19
+ -> latest/manifest.json
20
+ -> payload files referenced by the manifest
21
+ ```
22
+
23
+ The browser never reads the private bucket directly. All bucket requests are made by the
24
+ SvelteKit server with the server-only `HF_TOKEN` environment variable.
25
+
26
+ ## Data source
27
+
28
+ The bucket is configured with these runtime variables:
29
+
30
+ ```text
31
+ HF_TOKEN=<private read token>
32
+ HF_BUCKET_ID=hivetrace/leaderboard_frontend_v2
33
+ HF_BUCKET_PREFIX=latest
34
+ HF_BUCKET_REQUEST_TIMEOUT_MS=10000
35
+ HF_BUCKET_ENDPOINT=https://huggingface.co
36
+ ```
37
+
38
+ `HF_BUCKET_ENDPOINT` and `HF_BUCKET_REQUEST_TIMEOUT_MS` are optional. The values above are
39
+ their defaults.
40
+
41
+ `HF_BUCKET_CACHE_TTL_MS` still controls the regular in-memory manifest cache and the direct
42
+ Tools snapshot helper. The main Ranking, Details, and Visualizations page-data flow explicitly
43
+ refreshes the manifest after the browser cache misses, so this variable does not add another
44
+ freshness delay to normal client-side page transitions.
45
+
46
+ ## Manifest as the version pointer
47
+
48
+ `latest/manifest.json` is the single version pointer used by the frontend. Its important fields
49
+ are:
50
+
51
+ - `snapshot_id`: the identity of the published dataset;
52
+ - `files`: paths to all payload files;
53
+ - `hashes`: SHA-256 hashes of the payload files;
54
+ - `schema_version`: the bucket contract version;
55
+ - model, group, and dataset counts used for validation.
56
+
57
+ The frontend decides whether payload data changed by comparing `snapshot_id`. A new publish
58
+ must always use a new, unique `snapshot_id`.
59
+
60
+ Changing payload files or hashes without changing `snapshot_id` is not supported. In that case,
61
+ the frontend considers the snapshot unchanged and can continue using the old in-memory payload
62
+ until the Space process restarts.
63
+
64
+ ## Routes and payloads
65
+
66
+ | Route | Server loader | Bucket data |
67
+ | -------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------- |
68
+ | `/` | `getHfBucketRankingState()` | catalog, leaderboard, and details matrix |
69
+ | `/details` | `getHfBucketDetailsState()` | catalog, leaderboard, and details matrix |
70
+ | `/tools` | `getHfBucketToolsState()` | catalog, leaderboard, drilldown index, radar, scatter, heatmap, grouped bars, Pareto, performance, and robustness |
71
+ | `/methodology` | no bucket page loader | no bucket payload |
72
+
73
+ Ranking also uses the details matrix because the `partial` model status is derived from
74
+ `metrics_evaluated_samples < sample_count`.
75
+
76
+ The root `+layout.server.ts` reads the manifest separately to expose public bucket status such
77
+ as snapshot date and model count. That status uses the regular manifest cache. It is metadata
78
+ for the layout and does not control page-data freshness.
79
+
80
+ ## Client-side navigation
81
+
82
+ SvelteKit intercepts internal menu links and requests route data through endpoints such as:
83
+
84
+ ```text
85
+ /__data.json
86
+ /details/__data.json
87
+ /tools/__data.json
88
+ ```
89
+
90
+ Localized variants are recognized as well, for example `/ru/details/__data.json`.
91
+
92
+ The response optimization in `src/hooks.server.ts` applies only to these three page-data
93
+ routes. It does not apply to Methodology, static assets, errors, or arbitrary API responses.
94
+
95
+ ### Browser cache
96
+
97
+ Successful page-data responses use:
98
+
99
+ ```http
100
+ Cache-Control: private, max-age=300
101
+ ```
102
+
103
+ This means:
104
+
105
+ - each browser can reuse a route response for five minutes;
106
+ - the response is not a public CDN or shared-proxy cache entry;
107
+ - no `stale-while-revalidate` window is used;
108
+ - after five minutes, the next navigation must contact the SvelteKit server before rendering
109
+ that route.
110
+
111
+ The cache is per response URL, including SvelteKit query parameters. Ranking, Details, and
112
+ Visualizations therefore have independent browser cache entries.
113
+
114
+ An already rendered page does not update itself. Fresh data is applied on a later navigation or
115
+ full page reload.
116
+
117
+ ### Gzip compression
118
+
119
+ The same hook compresses a page-data response when all conditions are true:
120
+
121
+ - the response is successful and has a body;
122
+ - its declared size is at least 1,024 bytes;
123
+ - the client accepts `gzip`.
124
+
125
+ The compressed response contains:
126
+
127
+ ```http
128
+ Content-Encoding: gzip
129
+ Vary: Accept-Encoding
130
+ ```
131
+
132
+ `Content-Length` is removed because the compressed body is streamed. Clients without gzip
133
+ support receive the original body with the same five-minute private cache policy.
134
+
135
+ Approximate measured sizes for the current dataset are:
136
+
137
+ | Route data | Uncompressed | Gzip |
138
+ | -------------- | -----------: | -----: |
139
+ | Ranking | 171 KB | 58 KB |
140
+ | Details | 643 KB | 158 KB |
141
+ | Visualizations | 709 KB | 122 KB |
142
+
143
+ These sizes depend on the bucket contents and will change as models and datasets are added.
144
+
145
+ ## Server request flow
146
+
147
+ When the browser does not have a fresh page-data response, the server follows this flow:
148
+
149
+ 1. The page loader calls the corresponding Ranking, Details, or Tools state function.
150
+ 2. The state function calls `fetchBucketRankingSnapshot({ refreshManifest: true })`.
151
+ 3. `refreshManifest: true` bypasses the regular manifest TTL and reads the current small
152
+ `manifest.json` from Hugging Face.
153
+ 4. If the returned `snapshot_id` matches the in-memory ranking snapshot, catalog and leaderboard
154
+ are reused without another payload download.
155
+ 5. If `snapshot_id` changed, catalog and leaderboard are downloaded in parallel, their hashes
156
+ and schemas are validated, and the ranking snapshot is replaced.
157
+ 6. Route-specific data is reused or rebuilt for the same new `snapshot_id`.
158
+ 7. SvelteKit serializes the adapted route data, and the server hook applies the private cache
159
+ header and gzip compression.
160
+
161
+ The important distinction between the snapshot options is:
162
+
163
+ - `refreshManifest: true` always checks the manifest but reuses payloads when `snapshot_id` is
164
+ unchanged;
165
+ - `forceRefresh: true` also bypasses payload reuse and rebuilds the ranking snapshot.
166
+
167
+ Normal page transitions use `refreshManifest`, not `forceRefresh`.
168
+
169
+ ## In-memory caches
170
+
171
+ All server caches are module-level memory in the running Node process. They are private to a
172
+ single Space replica and are cleared when the container restarts, sleeps, or is redeployed.
173
+
174
+ ### Manifest cache
175
+
176
+ `src/lib/server/hf-bucket/cache.ts` stores:
177
+
178
+ - the parsed manifest;
179
+ - the time it was fetched;
180
+ - its expiration time;
181
+ - one shared in-flight manifest request.
182
+
183
+ Regular callers use `HF_BUCKET_CACHE_TTL_MS`, with a five-minute default. Page-data refreshes use
184
+ the forced manifest path described above. Concurrent forced checks join the same in-flight
185
+ request when they overlap.
186
+
187
+ ### Ranking snapshot
188
+
189
+ The ranking snapshot contains the manifest, catalog, and leaderboard. It is cached by
190
+ `snapshot_id`.
191
+
192
+ Concurrent downloads of the same snapshot share one promise. In-flight requests are also keyed
193
+ by `snapshot_id`, so a request for a newly published snapshot does not accidentally reuse a
194
+ download for an older snapshot.
195
+
196
+ ### Details snapshot
197
+
198
+ The details snapshot adds `details_matrix` to the ranking snapshot and is cached by
199
+ `snapshot_id`.
200
+
201
+ This cache is shared by Ranking and Details. Ranking needs the matrix for `partial` detection, so
202
+ opening Details after Ranking does not download and validate `details_matrix` a second time.
203
+ Concurrent requests for the same details snapshot also share one promise.
204
+
205
+ ### Adapted Ranking and Details datasets
206
+
207
+ The UI-ready Ranking and Details objects are cached by `snapshot_id`. When the manifest is
208
+ unchanged, the server returns the already adapted object. A new snapshot is parsed, validated,
209
+ and adapted once per Node process.
210
+
211
+ ### Visualizations snapshot and adapted Tools dataset
212
+
213
+ For a new snapshot, the visualization files are downloaded in parallel. The raw visualization
214
+ snapshot and the adapted Tools dataset are cached separately by `snapshot_id`.
215
+
216
+ The direct `getHfBucketToolsSnapshot()` helper retains its TTL-based fast path. The `/tools`
217
+ page uses the state path, which refreshes the manifest after a browser cache miss.
218
+
219
+ ## Validation
220
+
221
+ Payload reuse is allowed only after the manifest version check. New payloads go through:
222
+
223
+ - JSON parsing;
224
+ - schema parsing;
225
+ - SHA-256 verification when a hash is present;
226
+ - manifest count checks;
227
+ - model, group, and dataset reference checks;
228
+ - matrix completeness and duplicate-pair checks;
229
+ - visualization reference checks.
230
+
231
+ A validation error is handled in the same way as another refresh failure.
232
+
233
+ ## Data freshness guarantee
234
+
235
+ Assume a valid new snapshot is published at time `T` and the bucket remains reachable.
236
+
237
+ ### Route response is already in the browser cache
238
+
239
+ The old route response can be used until its individual five-minute `max-age` expires. The first
240
+ navigation to that route after expiration reads the fresh manifest. If `snapshot_id` changed,
241
+ the same navigation waits for and receives the new payload.
242
+
243
+ Therefore, the expected guarantee is:
244
+
245
+ > No later than the first navigation after the route's five-minute browser cache expires, the
246
+ > user receives the new snapshot.
247
+
248
+ There is no additional stale response window.
249
+
250
+ ### Route response is not in the browser cache
251
+
252
+ The navigation immediately contacts the server and checks the manifest. If the server already
253
+ has the new payload, it reuses it. Otherwise, the navigation waits while the new snapshot is
254
+ downloaded and adapted.
255
+
256
+ ### Full page reload
257
+
258
+ The five-minute policy targets SvelteKit `__data.json` navigation responses. A normal full HTML
259
+ request is not covered by this page-data cache and runs the server page loader again.
260
+
261
+ ### Exceptions
262
+
263
+ The five-minute expectation does not apply when:
264
+
265
+ - Hugging Face or the network is unavailable;
266
+ - the new payload fails parsing, hash checks, or validation;
267
+ - the publisher reused the old `snapshot_id`;
268
+ - the manifest was published before all referenced files became readable.
269
+
270
+ ## Failure behavior
271
+
272
+ The cache is also a resilience mechanism:
273
+
274
+ - if a refresh fails and a usable route dataset exists, the server returns cached data;
275
+ - if a new manifest is readable but a new payload is invalid or unavailable, the route state is
276
+ returned as `stale` and the UI can show a warning;
277
+ - if no usable cache exists, the route returns an `unavailable` state with an empty fallback
278
+ dataset;
279
+ - simultaneous requests share in-flight work to avoid duplicate bucket downloads.
280
+
281
+ One implementation detail is worth noting: when a forced manifest read fails but an older
282
+ manifest is cached, the manifest layer can return that stale manifest. The existing route data
283
+ can then continue to be served. The current route state does not always propagate this specific
284
+ manifest fallback as a visible `stale` warning.
285
+
286
+ ## Publishing a new snapshot
287
+
288
+ The producer must publish atomically from the frontend's point of view:
289
+
290
+ 1. Generate all payload files.
291
+ 2. Use immutable or snapshot-specific file paths where practical.
292
+ 3. Calculate and write the SHA-256 hashes.
293
+ 4. Upload every payload file and verify it is readable.
294
+ 5. Create a manifest with a new unique `snapshot_id`, correct paths, hashes, counts, and schema
295
+ version.
296
+ 6. Upload `latest/manifest.json` last.
297
+
298
+ Publishing the manifest last prevents the frontend from seeing a new version that references
299
+ files which are not available yet.
300
+
301
+ ## Cold starts and replicas
302
+
303
+ After a Space restart or cold start, no in-memory snapshot exists. The first request must read
304
+ the manifest and all payloads required by that route. Later requests reuse the prepared data.
305
+
306
+ If the Space runs multiple replicas, each replica has its own memory cache and warms
307
+ independently. The browser cache remains local to each user.
308
+
309
+ ## Operational checks
310
+
311
+ To inspect the page-data response headers, use an authenticated request to the Space:
312
+
313
+ ```bash
314
+ curl -sS \
315
+ -H "Authorization: Bearer $HF_TOKEN" \
316
+ -H "Accept-Encoding: gzip" \
317
+ -D - \
318
+ -o /dev/null \
319
+ "https://<space-domain>/details/__data.json"
320
+ ```
321
+
322
+ Expected headers include:
323
+
324
+ ```http
325
+ Cache-Control: private, max-age=300
326
+ Content-Encoding: gzip
327
+ Vary: Accept-Encoding
328
+ ```
329
+
330
+ `curl` does not reproduce the browser's navigation cache unless an explicit curl cache is used.
331
+ Repeated curl requests therefore reach the server and can trigger repeated manifest checks.
332
+
333
+ When verifying a publish, check all of the following:
334
+
335
+ - the manifest has a new `snapshot_id`;
336
+ - every referenced file exists;
337
+ - hashes match the published contents;
338
+ - the Space logs contain no bucket validation errors;
339
+ - the first route navigation after browser cache expiration shows the new snapshot.
340
+
341
+ ## Implementation map
342
+
343
+ | Responsibility | File |
344
+ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
345
+ | HTTP bucket client and server-only token | `src/lib/server/hf-bucket/client.ts` |
346
+ | Regular manifest cache and in-flight request | `src/lib/server/hf-bucket/cache.ts` |
347
+ | Ranking snapshot download and validation | `src/lib/server/hf-bucket/ranking-snapshot.ts` |
348
+ | Shared details snapshot | `src/lib/server/hf-bucket/details-snapshot.ts` |
349
+ | Visualization snapshot download | `src/lib/server/hf-bucket/tools-snapshot.ts` |
350
+ | Adapted Ranking cache | `src/lib/server/hf-bucket/ranking-cache.ts` |
351
+ | Adapted Details cache | `src/lib/server/hf-bucket/details-cache.ts` |
352
+ | Raw and adapted Tools caches | `src/lib/server/hf-bucket/tools-cache.ts` |
353
+ | Browser cache headers and gzip | `src/hooks.server.ts` |
354
+ | Root public bucket status | `src/routes/+layout.server.ts` |
355
+ | Route orchestration | `src/routes/+page.server.ts`, `src/routes/details/+page.server.ts`, `src/routes/tools/+page.server.ts` |
docs/data-loading-cache_ru.md ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Загрузка данных и кеширование
2
+
3
+ Этот документ описывает текущую реализацию загрузки данных и кеширования во frontend
4
+ HiveTrace Guardrail Leaderboard.
5
+
6
+ У реализации три основные цели:
7
+
8
+ - хранить приватный Hugging Face token только на сервере SvelteKit;
9
+ - не скачивать и не адаптировать повторно неизменившиеся payload-файлы bucket;
10
+ - ускорить повторные переходы между страницами и сохранить предсказуемое окно актуальности
11
+ данных.
12
+
13
+ ## Архитектура
14
+
15
+ ```text
16
+ Browser
17
+ -> SvelteKit page или __data.json request
18
+ -> Node process в Hugging Face Space
19
+ -> private Hugging Face bucket
20
+ -> latest/manifest.json
21
+ -> payload-файлы, указанные в manifest
22
+ ```
23
+
24
+ Браузер не обращается к приватному bucket напрямую. Все запросы в bucket выполняет сервер
25
+ SvelteKit с помощью server-only переменной `HF_TOKEN`.
26
+
27
+ ## Источник данных
28
+
29
+ Bucket настраивается runtime-переменными:
30
+
31
+ ```text
32
+ HF_TOKEN=<private read token>
33
+ HF_BUCKET_ID=hivetrace/leaderboard_frontend_v2
34
+ HF_BUCKET_PREFIX=latest
35
+ HF_BUCKET_REQUEST_TIMEOUT_MS=10000
36
+ HF_BUCKET_ENDPOINT=https://huggingface.co
37
+ ```
38
+
39
+ `HF_BUCKET_ENDPOINT` и `HF_BUCKET_REQUEST_TIMEOUT_MS` опциональны. Выше указаны их значения по
40
+ умолчанию.
41
+
42
+ `HF_BUCKET_CACHE_TTL_MS` по-прежнему управляет обычным in-memory кешем manifest и прямым helper
43
+ Tools snapshot. Основной поток page data для Ranking, Details и Visualizations после промаха
44
+ браузерного кеша явно обновляет manifest, поэтому эта переменная не добавляет еще одну задержку
45
+ актуальности при обычных переходах по страницам.
46
+
47
+ ## Manifest как указатель версии
48
+
49
+ `latest/manifest.json` является единственным указателем версии для frontend. Основные поля:
50
+
51
+ - `snapshot_id`: идентификатор опубликованного набора данных;
52
+ - `files`: пути ко всем payload-файлам;
53
+ - `hashes`: SHA-256 хеши payload-файлов;
54
+ - `schema_version`: версия контракта bucket;
55
+ - количества моделей, групп и датасетов для валидации.
56
+
57
+ Frontend определяет изменение payload по `snapshot_id`. Каждая новая публикация обязана иметь
58
+ новый уникальный `snapshot_id`.
59
+
60
+ Изменение payload-файлов или хешей без изменения `snapshot_id` не поддерживается. В таком случае
61
+ frontend считает snapshot прежним и может использовать старый in-memory payload до перезапуска
62
+ процесса Space.
63
+
64
+ ## Маршруты и payload-файлы
65
+
66
+ | Маршрут | Server loader | Данные bucket |
67
+ | -------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------- |
68
+ | `/` | `getHfBucketRankingState()` | catalog, leaderboard и details matrix |
69
+ | `/details` | `getHfBucketDetailsState()` | catalog, leaderboard и details matrix |
70
+ | `/tools` | `getHfBucketToolsState()` | catalog, leaderboard, drilldown index, radar, scatter, heatmap, grouped bars, Pareto, performance и robustness |
71
+ | `/methodology` | нет bucket page loader | payload из bucket не используется |
72
+
73
+ Ranking также использует details matrix, потому что статус модели `partial` вычисляется по
74
+ условию `metrics_evaluated_samples < sample_count`.
75
+
76
+ Корневой `+layout.server.ts` отдельно читает manifest и возвращает публичный статус bucket:
77
+ дату snapshot, количество моделей и другие общие поля. Этот статус использует обычный кеш
78
+ manifest. Он является метаданными layout и не управляет актуальностью page data.
79
+
80
+ ## Клиентская навигация
81
+
82
+ SvelteKit перехватывает ��нутренние ссылки главного меню и запрашивает данные маршрутов через
83
+ endpoint вида:
84
+
85
+ ```text
86
+ /__data.json
87
+ /details/__data.json
88
+ /tools/__data.json
89
+ ```
90
+
91
+ Локализованные варианты также распознаются, например `/ru/details/__data.json`.
92
+
93
+ Оптимизация response в `src/hooks.server.ts` применяется только к этим трем page-data
94
+ маршрутам. Она не применяется к Methodology, статическим файлам, ошибкам и произвольным API
95
+ response.
96
+
97
+ ### Браузерный кеш
98
+
99
+ Успешный page-data response получает заголовок:
100
+
101
+ ```http
102
+ Cache-Control: private, max-age=300
103
+ ```
104
+
105
+ Это означает:
106
+
107
+ - браузер может повторно использовать response маршрута в течение пяти минут;
108
+ - response не попадает в общий CDN или shared proxy cache;
109
+ - окно `stale-while-revalidate` отсутствует;
110
+ - после пяти минут следующий переход обязан обратиться к серверу SvelteKit до отрисовки
111
+ маршрута.
112
+
113
+ Кеш привязан к URL response, включая внутренние query-параметры SvelteKit. Поэтому Ranking,
114
+ Details и Visualizations имеют отдельные записи браузерного кеша.
115
+
116
+ Уже открытая страница сама не обновляется. Новые данные применяются при следующем переходе или
117
+ полной перезагрузке страницы.
118
+
119
+ ### Gzip-сжатие
120
+
121
+ Тот же hook сжимает page-data response, когда выполнены все условия:
122
+
123
+ - response успешный и содержит body;
124
+ - заявленный размер не меньше 1 024 bytes;
125
+ - клиент поддерживает `gzip`.
126
+
127
+ Сжатый response содержит:
128
+
129
+ ```http
130
+ Content-Encoding: gzip
131
+ Vary: Accept-Encoding
132
+ ```
133
+
134
+ `Content-Length` удаляется, потому что сжатый body передается потоком. Клиенты без поддержки
135
+ gzip получают исходный body с тем же приватным кешем на пять минут.
136
+
137
+ Примерные измеренные размеры для текущего набора данных:
138
+
139
+ | Данные маршрута | Без сжатия | Gzip |
140
+ | --------------- | ---------: | -----: |
141
+ | Ranking | 171 KB | 58 KB |
142
+ | Details | 643 KB | 158 KB |
143
+ | Visualizations | 709 KB | 122 KB |
144
+
145
+ Размеры зависят от содержимого bucket и будут меняться при добавлении моделей и датасетов.
146
+
147
+ ## Поток server request
148
+
149
+ Если в браузере нет свежего page-data response, сервер выполняет следующие шаги:
150
+
151
+ 1. Page loader вызывает соответствующую state-функцию Ranking, Details или Tools.
152
+ 2. State-функция вызывает `fetchBucketRankingSnapshot({ refreshManifest: true })`.
153
+ 3. `refreshManifest: true` обходит обычный TTL manifest и читает актуальный небольшой
154
+ `manifest.json` из Hugging Face.
155
+ 4. Если полученный `snapshot_id` совпадает с in-memory ranking snapshot, catalog и leaderboard
156
+ используются повторно без скачивания payload.
157
+ 5. Если `snapshot_id` изменился, catalog и leaderboard скачиваются параллельно, проверяются их
158
+ хеши и schema, после чего ranking snapshot заменяется.
159
+ 6. Данные конкретного маршрута переиспользуются или пересобираются для нового `snapshot_id`.
160
+ 7. SvelteKit сериализует адаптированные данные маршрута, а server hook добавляет приватный кеш и
161
+ gzip-сжатие.
162
+
163
+ Разница между параметрами snapshot:
164
+
165
+ - `refreshManifest: true` всегда проверяет manifest, но переиспользует payload при неизменном
166
+ `snapshot_id`;
167
+ - `forceRefresh: true` также отключает переиспользование payload и пересобирает ranking snapshot.
168
+
169
+ Обычные переходы по страницам используют `refreshManifest`, а не `forceRefresh`.
170
+
171
+ ## In-memory кеши сервера
172
+
173
+ Все серверные кеши хранятся в памяти работающего Node process. Они принадлежат одному replica
174
+ Space и очищаются при перезапуске, засыпании контейнера или новом deploy.
175
+
176
+ ### Кеш manifest
177
+
178
+ `src/lib/server/hf-bucket/cache.ts` хранит:
179
+
180
+ - распарсенный manifest;
181
+ - время его получения;
182
+ - время истечения кеша;
183
+ - один общий in-flight request manifest.
184
+
185
+ Обычные callers используют `HF_BUCKET_CACHE_TTL_MS`, по умолчанию пять минут. Page-data refresh
186
+ использует принудительное чтение manifest, описанное выше. Одновременные принудительные проверки
187
+ подключаются к одному in-flight request, если пересекаются по времени.
188
+
189
+ ### Ranking snapshot
190
+
191
+ Ranking snapshot содержит manifest, catalog и leaderboard. Он кешируется по `snapshot_id`.
192
+
193
+ Одновременные загрузки одного snapshot используют общий promise. In-flight request также
194
+ привязан к `snapshot_id`, поэтому запрос нового опубликованного snapshot не использует по ошибке
195
+ загрузку предыдущего snapshot.
196
+
197
+ ### Details snapshot
198
+
199
+ Details snapshot добавляет `details_matrix` к ranking snapshot и кешируется по `snapshot_id`.
200
+
201
+ Этот кеш общий для Ranking и Details. Ranking использует matrix для определения `partial`, поэтому
202
+ после открытия Ranking страница Details не скачивает и не валидирует `details_matrix` повторно.
203
+ Одновременные запросы одного details snapshot также используют общий promise.
204
+
205
+ ### Адаптированные Ranking и Details
206
+
207
+ Готовые для UI объекты Ranking и Details кешируются по `snapshot_id`. Если manifest не изменился,
208
+ сервер возвращает уже адаптированный объект. Новый snapshot парсится, валидируется и адаптируется
209
+ один раз на Node process.
210
+
211
+ ### Visualizations snapshot и адаптированный Tools dataset
212
+
213
+ Для нового snapshot файлы визуализаций скачиваются параллельно. Raw visualization snapshot и
214
+ адаптированный Tools dataset кешируются отдельно по `snapshot_id`.
215
+
216
+ Прямой helper `getHfBucketToolsSnapshot()` сохраняет свой быстрый путь на основе TTL. Страница
217
+ `/tools` использует state-путь, который обновляет manifest после промаха браузерного кеша.
218
+
219
+ ## Валидация
220
+
221
+ Payload используется повторно только после проверки версии manifest. Новый payload проходит:
222
+
223
+ - JSON parsing;
224
+ - schema parsing;
225
+ - проверку SHA-256 при наличии хеша;
226
+ - сверку количеств из manifest;
227
+ - проверку ссылок на модели, группы и датасеты;
228
+ - проверку полноты matrix и дублирующихся пар;
229
+ - проверку ссылок в данных визуализаций.
230
+
231
+ Ошибка валидации обрабатывается так же, как другая ошибка обновления.
232
+
233
+ ## Гарантия актуальности данных
234
+
235
+ Предположим, что корректный новый snapshot опубликован в момент `T`, а bucket доступен.
236
+
237
+ ### Response маршрута уже находится в браузерном кеше
238
+
239
+ Старый response может использоваться до истечения его индивидуального `max-age` в пять минут.
240
+ Первый переход на этот маршрут после истечения кеша читает свежий manifest. Если `snapshot_id`
241
+ изменился, тот же переход ожидает загрузку нового payload и получает новые данные.
242
+
243
+ Ожидаемая гарантия:
244
+
245
+ > Не позднее первого перехода после истечения пятиминутного браузерного кеша маршрута
246
+ > пользователь получает новый snapshot.
247
+
248
+ Дополнительного окна выдачи stale response нет.
249
+
250
+ ### Response маршрута отсутствует в браузерном кеше
251
+
252
+ Переход сразу обращается к серверу и проверяет manifest. Если новый payload уже находится в
253
+ памяти сервера, он используется повторно. Иначе переход ожидает скачивание и адаптацию нового
254
+ snapshot.
255
+
256
+ ### Полная перезагрузка страницы
257
+
258
+ Пятиминутная политика применяется к SvelteKit navigation response `__data.json`. Обычный полный
259
+ HTML request не покрывается этим page-data кешем и снова запускает server page loader.
260
+
261
+ ### Исключения
262
+
263
+ Пятиминутное ожидание неприменимо, если:
264
+
265
+ - Hugging Face или сеть недоступны;
266
+ - новый payload не проходит parsing, проверку хеша или валидацию;
267
+ - publisher повторно использовал старый `snapshot_id`;
268
+ - manifest опубликован раньше, чем стали доступны все указанные в нем файлы.
269
+
270
+ ## Поведение при ошибках
271
+
272
+ Кеш также обеспечивает устойчивость:
273
+
274
+ - если refresh завершился ошибкой и готовые данные маршрута существуют, сервер возвращает кеш;
275
+ - если новый manifest доступен, но новый payload невалиден или недоступен, route state становится
276
+ `stale`, и UI может показать предупреждение;
277
+ - если пригодного кеша нет, маршрут возвращает состояние `unavailable` с пустым fallback dataset;
278
+ - одновременные запросы используют общую in-flight работу и не дублируют загрузку bucket.
279
+
280
+ Важная деталь текущей реализации: если принудительное чтение manifest завершилось ошибкой, но в
281
+ памяти есть предыдущий manifest, слой manifest может вернуть его как stale. После этого сервер
282
+ продолжит выдавать предыдущие route data. Текущий route state не во всех случаях пробрасывает
283
+ именно этот fallback manifest как видимое предупреждение `stale`.
284
+
285
+ ## Публикация нового snapshot
286
+
287
+ Producer должен выполнять публикацию атомарно с точки зрения frontend:
288
+
289
+ 1. Сгенерировать все payload-файлы.
290
+ 2. По возможности использовать immutable или snapshot-specific пути.
291
+ 3. Рассчитать и записать SHA-256 хеши.
292
+ 4. Загрузить каждый payload-файл и проверить, что он доступен для чтения.
293
+ 5. Создать manifest с новым уникальным `snapshot_id`, корректными путями, хешами, количествами и
294
+ версией schema.
295
+ 6. Последним загрузить `latest/manifest.json`.
296
+
297
+ Публикация manifest последним не позволяет frontend увидеть новую версию, которая ссылается на
298
+ еще недоступные файлы.
299
+
300
+ ## Cold start и replicas
301
+
302
+ После перезапуска или cold start Space in-memory snapshot отсутствует. Первый request должен
303
+ прочитать manifest и все payload-файлы, необходимые выбранному маршруту. Следующие запросы
304
+ используют подготовленные данные повторно.
305
+
306
+ Если Space работает с несколькими replicas, каждая имеет собственный кеш в памяти и прогревается
307
+ независимо. Браузерный кеш остается локальным для каждого пользователя.
308
+
309
+ ## Операционная проверка
310
+
311
+ Для проверки заголовков page-data response нужен авторизованный запрос к Space:
312
+
313
+ ```bash
314
+ curl -sS \
315
+ -H "Authorization: Bearer $HF_TOKEN" \
316
+ -H "Accept-Encoding: gzip" \
317
+ -D - \
318
+ -o /dev/null \
319
+ "https://<space-domain>/details/__data.json"
320
+ ```
321
+
322
+ Ожидаемые заголовки:
323
+
324
+ ```http
325
+ Cache-Control: private, max-age=300
326
+ Content-Encoding: gzip
327
+ Vary: Accept-Encoding
328
+ ```
329
+
330
+ `curl` не воспроизводит navigation cache браузера без отдельной настройки собственного кеша.
331
+ Поэтому повторные curl-запросы доходят до сервера и могут повторно запускать проверку manifest.
332
+
333
+ При проверке новой публикации нужно убедиться, что:
334
+
335
+ - manifest содержит новый `snapshot_id`;
336
+ - каждый указанный файл существует;
337
+ - хеши соответствуют опубликованному содержимому;
338
+ - в логах Space нет ошибок валидации bucket;
339
+ - первый переход после истечения браузерного кеша показывает новый snapshot.
340
+
341
+ ## Карта реализации
342
+
343
+ | Ответственность | Файл |
344
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------ |
345
+ | HTTP client bucket и server-only token | `src/lib/server/hf-bucket/client.ts` |
346
+ | Обычный кеш manifest и in-flight request | `src/lib/server/hf-bucket/cache.ts` |
347
+ | Загрузка и валидация Ranking snapshot | `src/lib/server/hf-bucket/ranking-snapshot.ts` |
348
+ | Общий Details snapshot | `src/lib/server/hf-bucket/details-snapshot.ts` |
349
+ | Загрузка Visualization snapshot | `src/lib/server/hf-bucket/tools-snapshot.ts` |
350
+ | Кеш адаптированного Ranking | `src/lib/server/hf-bucket/ranking-cache.ts` |
351
+ | Кеш адаптированного Details | `src/lib/server/hf-bucket/details-cache.ts` |
352
+ | Raw и адаптированный кеш Tools | `src/lib/server/hf-bucket/tools-cache.ts` |
353
+ | Заголовки браузерного кеша и gzip | `src/hooks.server.ts` |
354
+ | Публичный статус bucket в root layout | `src/routes/+layout.server.ts` |
355
+ | Оркестрация маршрутов | `src/routes/+page.server.ts`, `src/routes/details/+page.server.ts`, `src/routes/tools/+page.server.ts` |
src/hooks.server.ts CHANGED
@@ -1,17 +1,82 @@
1
  import type { Handle } from '@sveltejs/kit';
2
- import { getTextDirection } from '$lib/paraglide/runtime';
3
  import { paraglideMiddleware } from '$lib/paraglide/server';
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  const handleParaglide: Handle = ({ event, resolve }) =>
6
- paraglideMiddleware(event.request, ({ request, locale }) => {
7
  event.request = request;
8
 
9
- return resolve(event, {
10
  transformPageChunk: ({ html }) =>
11
  html
12
  .replace('%paraglide.lang%', locale)
13
  .replace('%paraglide.dir%', getTextDirection(locale))
14
  });
 
 
15
  });
16
 
17
  export const handle: Handle = handleParaglide;
 
1
  import type { Handle } from '@sveltejs/kit';
2
+ import { getTextDirection, isLocale } from '$lib/paraglide/runtime';
3
  import { paraglideMiddleware } from '$lib/paraglide/server';
4
 
5
+ const DATA_CACHE_CONTROL = 'private, max-age=300';
6
+ const MIN_GZIP_SIZE_BYTES = 1024;
7
+
8
+ function isBucketDataRequest(request: Request) {
9
+ const segments = new URL(request.url).pathname.split('/').filter(Boolean);
10
+ if (segments.at(-1) !== '__data.json') return false;
11
+
12
+ segments.pop();
13
+ if (segments[0] && isLocale(segments[0])) segments.shift();
14
+
15
+ const route = segments.join('/');
16
+ return route === '' || route === 'details' || route === 'tools';
17
+ }
18
+
19
+ function acceptsGzip(request: Request) {
20
+ return (request.headers.get('accept-encoding') ?? '').split(',').some((entry) => {
21
+ const [encoding, ...parameters] = entry.trim().toLowerCase().split(';');
22
+ if (encoding !== 'gzip' && encoding !== '*') return false;
23
+
24
+ const quality = parameters
25
+ .map((parameter) => parameter.trim())
26
+ .find((parameter) => parameter.startsWith('q='));
27
+ return quality ? Number(quality.slice(2)) > 0 : true;
28
+ });
29
+ }
30
+
31
+ function appendVary(headers: Headers, value: string) {
32
+ const values = new Set(
33
+ (headers.get('vary') ?? '')
34
+ .split(',')
35
+ .map((item) => item.trim())
36
+ .filter(Boolean)
37
+ );
38
+ values.add(value);
39
+ headers.set('vary', [...values].join(', '));
40
+ }
41
+
42
+ function optimizeBucketDataResponse(request: Request, response: Response) {
43
+ if (!response.ok || !response.body || !isBucketDataRequest(request)) return response;
44
+
45
+ const headers = new Headers(response.headers);
46
+ headers.set('cache-control', DATA_CACHE_CONTROL);
47
+ appendVary(headers, 'Accept-Encoding');
48
+
49
+ const contentLength = Number(headers.get('content-length') ?? 0);
50
+ if (request.method === 'HEAD' || contentLength < MIN_GZIP_SIZE_BYTES || !acceptsGzip(request)) {
51
+ return new Response(response.body, {
52
+ status: response.status,
53
+ statusText: response.statusText,
54
+ headers
55
+ });
56
+ }
57
+
58
+ headers.delete('content-length');
59
+ headers.set('content-encoding', 'gzip');
60
+
61
+ return new Response(response.body.pipeThrough(new CompressionStream('gzip')), {
62
+ status: response.status,
63
+ statusText: response.statusText,
64
+ headers
65
+ });
66
+ }
67
+
68
  const handleParaglide: Handle = ({ event, resolve }) =>
69
+ paraglideMiddleware(event.request, async ({ request, locale }) => {
70
  event.request = request;
71
 
72
+ const response = await resolve(event, {
73
  transformPageChunk: ({ html }) =>
74
  html
75
  .replace('%paraglide.lang%', locale)
76
  .replace('%paraglide.dir%', getTextDirection(locale))
77
  });
78
+
79
+ return optimizeBucketDataResponse(request, response);
80
  });
81
 
82
  export const handle: Handle = handleParaglide;
src/lib/server/hf-bucket/details-cache.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { env } from '$env/dynamic/private';
2
  import type { BucketDataState } from '$lib/types/bucket-state';
3
  import type { DetailsDataset } from '$lib/types/details-data';
4
  import { adaptBucketDetails } from './adapt-details';
@@ -7,28 +6,19 @@ import { emptyDetailsDataset } from './fallback-data';
7
  import { toPublicBucketError } from './public-error';
8
  import { fetchBucketRankingSnapshot } from './ranking-snapshot';
9
 
10
- const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
11
-
12
- let cached: { value: DetailsDataset; snapshotId: string; expiresAt: number } | undefined;
13
  let inFlight: Promise<DetailsDataset> | undefined;
14
 
15
- function cacheTtlMs() {
16
- const configured = Number(env.HF_BUCKET_CACHE_TTL_MS);
17
- return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_CACHE_TTL_MS;
18
- }
19
-
20
  async function refreshDetails() {
21
- const rankingSnapshot = await fetchBucketRankingSnapshot();
22
  if (cached?.snapshotId === rankingSnapshot.manifest.snapshot_id) {
23
- cached.expiresAt = Date.now() + cacheTtlMs();
24
  return cached.value;
25
  }
26
 
27
  const value = adaptBucketDetails(await fetchBucketDetailsSnapshot(rankingSnapshot));
28
  cached = {
29
  value,
30
- snapshotId: rankingSnapshot.manifest.snapshot_id,
31
- expiresAt: Date.now() + cacheTtlMs()
32
  };
33
  return value;
34
  }
@@ -40,14 +30,6 @@ export async function getHfBucketDetails() {
40
  }
41
 
42
  export async function getHfBucketDetailsState(): Promise<BucketDataState<DetailsDataset>> {
43
- if (cached && cached.expiresAt > Date.now()) {
44
- return {
45
- status: 'ok',
46
- source: 'cache',
47
- data: cached.value
48
- };
49
- }
50
-
51
  if (!inFlight) {
52
  inFlight = refreshDetails().finally(() => {
53
  inFlight = undefined;
 
 
1
  import type { BucketDataState } from '$lib/types/bucket-state';
2
  import type { DetailsDataset } from '$lib/types/details-data';
3
  import { adaptBucketDetails } from './adapt-details';
 
6
  import { toPublicBucketError } from './public-error';
7
  import { fetchBucketRankingSnapshot } from './ranking-snapshot';
8
 
9
+ let cached: { value: DetailsDataset; snapshotId: string } | undefined;
 
 
10
  let inFlight: Promise<DetailsDataset> | undefined;
11
 
 
 
 
 
 
12
  async function refreshDetails() {
13
+ const rankingSnapshot = await fetchBucketRankingSnapshot({ refreshManifest: true });
14
  if (cached?.snapshotId === rankingSnapshot.manifest.snapshot_id) {
 
15
  return cached.value;
16
  }
17
 
18
  const value = adaptBucketDetails(await fetchBucketDetailsSnapshot(rankingSnapshot));
19
  cached = {
20
  value,
21
+ snapshotId: rankingSnapshot.manifest.snapshot_id
 
22
  };
23
  return value;
24
  }
 
30
  }
31
 
32
  export async function getHfBucketDetailsState(): Promise<BucketDataState<DetailsDataset>> {
 
 
 
 
 
 
 
 
33
  if (!inFlight) {
34
  inFlight = refreshDetails().finally(() => {
35
  inFlight = undefined;
src/lib/server/hf-bucket/details-snapshot.ts CHANGED
@@ -3,6 +3,9 @@ import { parseBucketDetailsMatrix } from './details-payload';
3
  import { fetchBucketRankingSnapshot, verifyBucketFileHash } from './ranking-snapshot';
4
  import type { BucketDetailsSnapshot, BucketRankingSnapshot } from './types';
5
 
 
 
 
6
  function parseJson(text: string, path: string) {
7
  try {
8
  return JSON.parse(text) as unknown;
@@ -72,6 +75,26 @@ export async function fetchBucketDetailsSnapshot(
72
  rankingSnapshot?: BucketRankingSnapshot
73
  ): Promise<BucketDetailsSnapshot> {
74
  rankingSnapshot ??= await fetchBucketRankingSnapshot();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  const path = rankingSnapshot.manifest.files.details_matrix;
76
  const text = await fetchHfBucketText(path);
77
  verifyBucketFileHash(rankingSnapshot.manifest, 'details_matrix', text);
 
3
  import { fetchBucketRankingSnapshot, verifyBucketFileHash } from './ranking-snapshot';
4
  import type { BucketDetailsSnapshot, BucketRankingSnapshot } from './types';
5
 
6
+ let cachedSnapshot: BucketDetailsSnapshot | undefined;
7
+ let inFlight: { snapshotId: string; promise: Promise<BucketDetailsSnapshot> } | undefined;
8
+
9
  function parseJson(text: string, path: string) {
10
  try {
11
  return JSON.parse(text) as unknown;
 
75
  rankingSnapshot?: BucketRankingSnapshot
76
  ): Promise<BucketDetailsSnapshot> {
77
  rankingSnapshot ??= await fetchBucketRankingSnapshot();
78
+ const snapshotId = rankingSnapshot.manifest.snapshot_id;
79
+
80
+ if (cachedSnapshot?.manifest.snapshot_id === snapshotId) return cachedSnapshot;
81
+ if (inFlight?.snapshotId === snapshotId) return inFlight.promise;
82
+
83
+ const promise = fetchRemoteDetailsSnapshot(rankingSnapshot);
84
+ const currentRequest = { snapshotId, promise };
85
+ inFlight = currentRequest;
86
+
87
+ try {
88
+ cachedSnapshot = await promise;
89
+ return cachedSnapshot;
90
+ } finally {
91
+ if (inFlight === currentRequest) inFlight = undefined;
92
+ }
93
+ }
94
+
95
+ async function fetchRemoteDetailsSnapshot(
96
+ rankingSnapshot: BucketRankingSnapshot
97
+ ): Promise<BucketDetailsSnapshot> {
98
  const path = rankingSnapshot.manifest.files.details_matrix;
99
  const text = await fetchHfBucketText(path);
100
  verifyBucketFileHash(rankingSnapshot.manifest, 'details_matrix', text);
src/lib/server/hf-bucket/ranking-cache.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { env } from '$env/dynamic/private';
2
  import type { BucketDataState } from '$lib/types/bucket-state';
3
  import type { RankingDataset } from '$lib/types/ranking-data';
4
  import { adaptBucketRanking } from './adapt-ranking';
@@ -7,28 +6,19 @@ import { emptyRankingDataset } from './fallback-data';
7
  import { toPublicBucketError } from './public-error';
8
  import { fetchBucketRankingSnapshot } from './ranking-snapshot';
9
 
10
- const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
11
-
12
- let cached: { value: RankingDataset; snapshotId: string; expiresAt: number } | undefined;
13
  let inFlight: Promise<RankingDataset> | undefined;
14
 
15
- function cacheTtlMs() {
16
- const configured = Number(env.HF_BUCKET_CACHE_TTL_MS);
17
- return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_CACHE_TTL_MS;
18
- }
19
-
20
  async function refreshRanking() {
21
- const snapshot = await fetchBucketRankingSnapshot();
22
  if (cached?.snapshotId === snapshot.manifest.snapshot_id) {
23
- cached.expiresAt = Date.now() + cacheTtlMs();
24
  return cached.value;
25
  }
26
 
27
  const value = adaptBucketRanking(await fetchBucketDetailsSnapshot(snapshot));
28
  cached = {
29
  value,
30
- snapshotId: snapshot.manifest.snapshot_id,
31
- expiresAt: Date.now() + cacheTtlMs()
32
  };
33
  return value;
34
  }
@@ -40,14 +30,6 @@ export async function getHfBucketRanking() {
40
  }
41
 
42
  export async function getHfBucketRankingState(): Promise<BucketDataState<RankingDataset>> {
43
- if (cached && cached.expiresAt > Date.now()) {
44
- return {
45
- status: 'ok',
46
- source: 'cache',
47
- data: cached.value
48
- };
49
- }
50
-
51
  if (!inFlight) {
52
  inFlight = refreshRanking().finally(() => {
53
  inFlight = undefined;
 
 
1
  import type { BucketDataState } from '$lib/types/bucket-state';
2
  import type { RankingDataset } from '$lib/types/ranking-data';
3
  import { adaptBucketRanking } from './adapt-ranking';
 
6
  import { toPublicBucketError } from './public-error';
7
  import { fetchBucketRankingSnapshot } from './ranking-snapshot';
8
 
9
+ let cached: { value: RankingDataset; snapshotId: string } | undefined;
 
 
10
  let inFlight: Promise<RankingDataset> | undefined;
11
 
 
 
 
 
 
12
  async function refreshRanking() {
13
+ const snapshot = await fetchBucketRankingSnapshot({ refreshManifest: true });
14
  if (cached?.snapshotId === snapshot.manifest.snapshot_id) {
 
15
  return cached.value;
16
  }
17
 
18
  const value = adaptBucketRanking(await fetchBucketDetailsSnapshot(snapshot));
19
  cached = {
20
  value,
21
+ snapshotId: snapshot.manifest.snapshot_id
 
22
  };
23
  return value;
24
  }
 
30
  }
31
 
32
  export async function getHfBucketRankingState(): Promise<BucketDataState<RankingDataset>> {
 
 
 
 
 
 
 
 
33
  if (!inFlight) {
34
  inFlight = refreshRanking().finally(() => {
35
  inFlight = undefined;
src/lib/server/hf-bucket/ranking-snapshot.ts CHANGED
@@ -90,7 +90,7 @@ function verifySnapshot(snapshot: BucketRankingSnapshot) {
90
  }
91
 
92
  let cachedSnapshot: BucketRankingSnapshot | undefined;
93
- let inFlight: Promise<BucketRankingSnapshot> | undefined;
94
 
95
  async function fetchRemoteRankingSnapshot(
96
  manifest: HfBucketManifest
@@ -123,23 +123,26 @@ async function fetchRemoteRankingSnapshot(
123
 
124
  export async function fetchBucketRankingSnapshot(options?: {
125
  forceRefresh?: boolean;
 
126
  }): Promise<BucketRankingSnapshot> {
127
- const { manifest } = await getHfBucketManifest({ forceRefresh: options?.forceRefresh });
 
 
128
 
129
  if (!options?.forceRefresh && cachedSnapshot?.manifest.snapshot_id === manifest.snapshot_id) {
130
  return cachedSnapshot;
131
  }
132
 
133
- if (!inFlight) {
134
- inFlight = fetchRemoteRankingSnapshot(manifest)
135
- .then((snapshot) => {
136
- cachedSnapshot = snapshot;
137
- return snapshot;
138
- })
139
- .finally(() => {
140
- inFlight = undefined;
141
- });
142
- }
143
 
144
- return inFlight;
 
 
 
 
 
145
  }
 
90
  }
91
 
92
  let cachedSnapshot: BucketRankingSnapshot | undefined;
93
+ let inFlight: { snapshotId: string; promise: Promise<BucketRankingSnapshot> } | undefined;
94
 
95
  async function fetchRemoteRankingSnapshot(
96
  manifest: HfBucketManifest
 
123
 
124
  export async function fetchBucketRankingSnapshot(options?: {
125
  forceRefresh?: boolean;
126
+ refreshManifest?: boolean;
127
  }): Promise<BucketRankingSnapshot> {
128
+ const { manifest } = await getHfBucketManifest({
129
+ forceRefresh: options?.forceRefresh || options?.refreshManifest
130
+ });
131
 
132
  if (!options?.forceRefresh && cachedSnapshot?.manifest.snapshot_id === manifest.snapshot_id) {
133
  return cachedSnapshot;
134
  }
135
 
136
+ if (inFlight?.snapshotId === manifest.snapshot_id) return inFlight.promise;
137
+
138
+ const promise = fetchRemoteRankingSnapshot(manifest);
139
+ const currentRequest = { snapshotId: manifest.snapshot_id, promise };
140
+ inFlight = currentRequest;
 
 
 
 
 
141
 
142
+ try {
143
+ cachedSnapshot = await promise;
144
+ return cachedSnapshot;
145
+ } finally {
146
+ if (inFlight === currentRequest) inFlight = undefined;
147
+ }
148
  }
src/lib/server/hf-bucket/tools-cache.ts CHANGED
@@ -21,7 +21,7 @@ function cacheTtlMs() {
21
  }
22
 
23
  async function refreshToolsSnapshot() {
24
- const rankingSnapshot = await fetchBucketRankingSnapshot();
25
  if (cached?.snapshotId === rankingSnapshot.manifest.snapshot_id) {
26
  cached.expiresAt = Date.now() + cacheTtlMs();
27
  return cached.value;
@@ -62,14 +62,6 @@ export async function getHfBucketToolsSnapshot() {
62
  async function getHfBucketToolsSnapshotState(): Promise<
63
  BucketDataState<BucketVisualizationSnapshot>
64
  > {
65
- if (cached && cached.expiresAt > Date.now()) {
66
- return {
67
- status: 'ok',
68
- source: 'cache',
69
- data: cached.value
70
- };
71
- }
72
-
73
  if (!inFlight) {
74
  inFlight = refreshToolsSnapshot().finally(() => {
75
  inFlight = undefined;
 
21
  }
22
 
23
  async function refreshToolsSnapshot() {
24
+ const rankingSnapshot = await fetchBucketRankingSnapshot({ refreshManifest: true });
25
  if (cached?.snapshotId === rankingSnapshot.manifest.snapshot_id) {
26
  cached.expiresAt = Date.now() + cacheTtlMs();
27
  return cached.value;
 
62
  async function getHfBucketToolsSnapshotState(): Promise<
63
  BucketDataState<BucketVisualizationSnapshot>
64
  > {
 
 
 
 
 
 
 
 
65
  if (!inFlight) {
66
  inFlight = refreshToolsSnapshot().finally(() => {
67
  inFlight = undefined;