From 24fee05488709eee4fcc2de00eb42513bbc92a16 Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Wed, 7 Oct 2026 17:44:34 -0400 Subject: [PATCH] hosting: bound in-process memory and make the budgets configurable nothing in hosting reads the observability log/metric rings (5000 logs, 10000 request metrics), so hosting now keeps none; console output and the loki/otlp exporters are unchanged. site manifests get a byte budget (MANIFEST_CACHE_SIZE, 16 MiB estimated, count stays as MANIFEST_CACHE_COUNT) and the cache sweep runs every minute instead of every 30, so manifests whose 10 s ttl lapsed no longer sit resident. the hot tier defaults to 32 MiB for a 1 GiB edge (HOT_CACHE_SIZE still raises it) and releases expired entries on its next write. buffered reads borrow the tier's bytes instead of copying the whole object per read; hosting only hashes, decodes or serves them. scripts/memory-bench.ts serves 2000 sites from a local hosting process with a fake postgres and reports rss, js heap and cache sizes, plus a digest of every response so a change in bytes or headers shows up. --- apps/hosting-service/.env.example | 5 + .../scripts/memory-bench-fixtures.ts | 204 +++++++++++++++ .../scripts/memory-bench-preload.ts | 74 ++++++ apps/hosting-service/scripts/memory-bench.ts | 236 ++++++++++++++++++ apps/hosting-service/src/index.ts | 17 +- .../src/lib/cache-manager.test.ts | 53 +++- apps/hosting-service/src/lib/cache-manager.ts | 53 +++- apps/hosting-service/src/lib/file-serving.ts | 8 +- apps/hosting-service/src/lib/html-prewarm.ts | 2 +- apps/hosting-service/src/lib/redirects.ts | 2 +- apps/hosting-service/src/lib/storage.test.ts | 15 +- apps/hosting-service/src/lib/storage.ts | 22 +- docs/src/content/docs/architecture.md | 2 +- docs/src/content/docs/deployment.md | 6 +- packages/@wispplace/observability/src/core.ts | 40 ++- .../observability/src/retention.test.ts | 44 ++++ .../tiered-storage/src/TieredStorage.ts | 30 ++- .../@wispplace/tiered-storage/src/index.ts | 7 +- .../test/TieredStorage.borrow.test.ts | 55 ++++ 19 files changed, 831 insertions(+), 44 deletions(-) create mode 100644 apps/hosting-service/scripts/memory-bench-fixtures.ts create mode 100644 apps/hosting-service/scripts/memory-bench-preload.ts create mode 100644 apps/hosting-service/scripts/memory-bench.ts create mode 100644 packages/@wispplace/observability/src/retention.test.ts create mode 100644 packages/@wispplace/tiered-storage/test/TieredStorage.borrow.test.ts diff --git a/apps/hosting-service/.env.example b/apps/hosting-service/.env.example index 0ea76a7..54ffbba 100644 --- a/apps/hosting-service/.env.example +++ b/apps/hosting-service/.env.example @@ -29,3 +29,8 @@ AWS_SECRET_ACCESS_KEY= # For local disk fallback (when S3_BUCKET is empty) CACHE_DIR=./cache/sites + +# Memory budgets. Defaults fit a 1 GiB edge; raise them on larger boxes. +# HOT_CACHE_SIZE=33554432 # file bytes held in memory +# MANIFEST_CACHE_SIZE=16777216 # estimated bytes of cached site manifests +# MANIFEST_CACHE_COUNT=5000 diff --git a/apps/hosting-service/scripts/memory-bench-fixtures.ts b/apps/hosting-service/scripts/memory-bench-fixtures.ts new file mode 100644 index 0000000..d2301a6 --- /dev/null +++ b/apps/hosting-service/scripts/memory-bench-fixtures.ts @@ -0,0 +1,204 @@ +/** + * Deterministic sites and objects for scripts/memory-bench.ts. + * + * Manifest rows are rebuilt on every query, so the bench fixtures hold no + * per-site memory in the server process. + */ +import { gzipSync } from 'node:zlib' +import { computeCID } from '@wispplace/atproto-utils' +import { casKey } from '@wispplace/fs-utils' + +export const BENCH_BASE_HOST = 'bench.test' + +type ObjectKind = 'index' | 'html' | 'css' | 'js' | 'img' | 'video' | 'bigHtml' + +interface KindSpec { + count: number + ext: string + mimeType: string + gzip: boolean + minBytes: number + maxBytes: number +} + +const KINDS: Record, KindSpec> = { + html: { count: 40, ext: 'html', mimeType: 'text/html', gzip: true, minBytes: 3_000, maxBytes: 40_000 }, + css: { count: 10, ext: 'css', mimeType: 'text/css', gzip: true, minBytes: 5_000, maxBytes: 60_000 }, + js: { count: 20, ext: 'js', mimeType: 'text/javascript', gzip: true, minBytes: 20_000, maxBytes: 400_000 }, + img: { count: 40, ext: 'webp', mimeType: 'image/webp', gzip: false, minBytes: 10_000, maxBytes: 400_000 }, + video: { count: 4, ext: 'mp4', mimeType: 'video/mp4', gzip: false, minBytes: 3 << 20, maxBytes: 16 << 20 }, + bigHtml: { count: 2, ext: 'html', mimeType: 'text/html', gzip: true, minBytes: 2 << 20, maxBytes: 4 << 20 }, +} + +export type ObjectPool = Record + +/** What a manifest needs to name an object. */ +export interface PoolRef { + cid: string + key: string +} + +export interface PoolObject extends PoolRef { + kind: ObjectKind + mimeType: string + encoding?: 'gzip' + stored: Uint8Array + uncompressedSize: number +} + +export interface ManifestEntry { + path: string + kind: ObjectKind + index: number +} + +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const WORDS = 'wisp place static site atproto blob manifest cache tier edge docs post'.split(' ') + +function textBody(kind: ObjectKind, size: number, random: () => number): Uint8Array { + const parts: string[] = [] + let length = 0 + if (kind === 'html' || kind === 'bigHtml') + parts.push('') + while (length < size) { + const word = WORDS[Math.floor(random() * WORDS.length)] ?? 'wisp' + const chunk = + kind === 'js' + ? `const ${word}${length}=${Math.floor(random() * 1e6)};` + : `

${word} ${word}

` + parts.push(chunk) + length += chunk.length + } + return new TextEncoder().encode(parts.join('')) +} + +function binaryBody(size: number, random: () => number): Uint8Array { + const bytes = new Uint8Array(size) + for (let offset = 0; offset < size; offset += 4) { + const value = Math.floor(random() * 4294967296) + bytes[offset] = value & 0xff + bytes[offset + 1] = (value >>> 8) & 0xff + bytes[offset + 2] = (value >>> 16) & 0xff + bytes[offset + 3] = value >>> 24 + } + return bytes +} + +/** Each site's own home page; every other path shares a pooled object. */ +const INDEX_KIND: KindSpec = { + count: 0, + ext: 'html', + mimeType: 'text/html', + gzip: true, + minBytes: 2_000, + maxBytes: 30_000, +} + +function kindSpec(kind: ObjectKind, siteCount: number): KindSpec { + return kind === 'index' ? { ...INDEX_KIND, count: siteCount } : KINDS[kind] +} + +/** Every object a bench manifest can reference, built from a fixed seed. */ +export function buildObjectPool(siteCount: number): ObjectPool { + const random = mulberry32(0x5eed) + const pool = {} as ObjectPool + for (const kind of ['index', ...Object.keys(KINDS)] as ObjectKind[]) { + const spec = kindSpec(kind, siteCount) + pool[kind] = Array.from({ length: spec.count }, () => { + const size = Math.floor(spec.minBytes + random() * (spec.maxBytes - spec.minBytes)) + const raw = spec.gzip ? textBody(kind, size, random) : binaryBody(size, random) + const stored = spec.gzip ? new Uint8Array(gzipSync(raw)) : raw + const cid = computeCID(raw) + const encoding = spec.gzip ? ('gzip' as const) : undefined + const key = casKey({ cid, path: `x.${spec.ext}`, mimeType: spec.mimeType, encoding }) + return { kind, cid, key, mimeType: spec.mimeType, encoding, stored, uncompressedSize: raw.byteLength } + }) + } + return pool +} + +export function siteHost(site: number): string { + return `s${site}.${BENCH_BASE_HOST}` +} + +export function siteFromHost(host: string): number | null { + const match = /^s(\d+)\./.exec(host) + return match ? Number(match[1]) : null +} + +export function siteDid(site: number): string { + const letters = String(site) + .split('') + .map((digit) => 'abcdefghij'[Number(digit)]) + .join('') + return `did:plc:${letters.padStart(24, 'z')}` +} + +export function siteRkey(site: number): string { + return `site-${site}` +} + +/** 1% of sites carry 1000 files, 9% carry 200, the rest 25. */ +export function siteFileCount(site: number): number { + if (site % 100 === 0) return 1000 + if (site % 10 === 0) return 200 + return 25 +} + +export function siteHasVideo(site: number): boolean { + return site % 50 === 7 +} + +function siteHasBigIndex(site: number): boolean { + return site % 200 === 3 +} + +export function siteManifest(site: number): ManifestEntry[] { + const pick = (kind: Exclude, salt: number) => (site * 31 + salt * 7) % KINDS[kind].count + const entries: ManifestEntry[] = [ + siteHasBigIndex(site) + ? { path: 'index.html', kind: 'bigHtml', index: pick('bigHtml', 0) } + : { path: 'index.html', kind: 'index', index: site }, + { path: 'assets/style.css', kind: 'css', index: pick('css', 1) }, + { path: 'assets/app.js', kind: 'js', index: pick('js', 2) }, + ] + if (siteHasVideo(site)) entries.push({ path: 'media/video.mp4', kind: 'video', index: pick('video', 3) }) + for (let file = entries.length; file < siteFileCount(site); file++) { + if (file % 3 === 0) entries.push({ path: `posts/post-${file}/index.html`, kind: 'html', index: pick('html', file) }) + else if (file % 3 === 1) entries.push({ path: `img/photo-${file}.webp`, kind: 'img', index: pick('img', file) }) + else entries.push({ path: `assets/chunk-${file}.js`, kind: 'js', index: pick('js', file) }) + } + return entries +} + +/** The `site_cache` row hosting reads for one site. */ +export function siteCacheRow(site: number, pool: ObjectPool) { + const fileCids: Record = {} + const fileObjects: Record = {} + for (const entry of siteManifest(site)) { + const object = pool[entry.kind][entry.index] + if (!object) continue + fileCids[entry.path] = object.cid + fileObjects[entry.path] = object.key + } + return { + did: siteDid(site), + rkey: siteRkey(site), + record_cid: `bafyrecord${site}`, + file_cids: fileCids, + file_objects: fileObjects, + cached_at: 0, + updated_at: 0, + absent_since: null, + } +} diff --git a/apps/hosting-service/scripts/memory-bench-preload.ts b/apps/hosting-service/scripts/memory-bench-preload.ts new file mode 100644 index 0000000..403a683 --- /dev/null +++ b/apps/hosting-service/scripts/memory-bench-preload.ts @@ -0,0 +1,74 @@ +/** + * Preloaded into the hosting process by scripts/memory-bench.ts. + * + * Replaces the postgres driver with an in-memory fake that answers the three + * public lookups hosting makes, and serves a memory sample taken after a full + * GC on BENCH_CONTROL_PORT. + */ + +import { heapStats } from 'bun:jsc' +import { mock } from 'bun:test' +import { readFileSync } from 'node:fs' +import { type ObjectPool, siteCacheRow, siteDid, siteFromHost, siteRkey } from './memory-bench-fixtures' + +const poolFile = process.env.BENCH_POOL_FILE +if (!poolFile) throw new Error('BENCH_POOL_FILE is required') +const pool = JSON.parse(readFileSync(poolFile, 'utf8')) as ObjectPool +const siteCount = Number(process.env.BENCH_SITES) + +function siteOf(did: unknown, rkey: unknown): number | null { + if (typeof rkey !== 'string' || !rkey.startsWith('site-')) return null + const site = Number(rkey.slice('site-'.length)) + return Number.isSafeInteger(site) && site < siteCount && did === siteDid(site) && rkey === siteRkey(site) + ? site + : null +} + +function answer(query: string, values: unknown[]): unknown[] { + if (query.includes('FROM domains')) { + const site = typeof values[0] === 'string' ? siteFromHost(values[0]) : null + return site !== null && site < siteCount ? [{ did: siteDid(site), rkey: siteRkey(site) }] : [] + } + if (query.includes('FROM site_cache')) { + const site = siteOf(values[0], values[1]) + // Round-trip through JSON so every query returns fresh strings, as the driver's jsonb parsing does. + return site === null ? [] : [JSON.parse(JSON.stringify(siteCacheRow(site, pool)))] + } + return [] +} + +function fakePostgres() { + const sql = (strings: TemplateStringsArray, ...values: unknown[]) => + Promise.resolve(answer(strings.join('?'), values)) + return Object.assign(sql, { end: async () => {} }) +} + +mock.module('postgres', () => ({ default: fakePostgres })) + +async function takeSample() { + const { cache } = await import('../src/lib/cache-manager') + const { hotTier } = await import('../src/lib/storage') + const { logCollector, metricsCollector } = await import('@wispplace/observability') + const siteCache = cache.getStats().siteCache + // The inner tier reports without the TTL wrapper's pruning side effects. + const hot = await hotTier.inner.getStats() + Bun.gc(true) + const memory = process.memoryUsage() + return { + rss: memory.rss, + heapSize: heapStats().heapSize, + arrayBuffers: memory.arrayBuffers, + siteCacheEntries: siteCache.entries, + siteCacheBytes: siteCache.sizeBytes, + hotItems: hot.items, + hotBytes: hot.bytes, + logRing: logCollector.getLogs({ limit: Number.MAX_SAFE_INTEGER }).length, + metricRing: metricsCollector.getMetrics().length, + } +} + +Bun.serve({ + hostname: '127.0.0.1', + port: Number(process.env.BENCH_CONTROL_PORT), + fetch: async () => Response.json(await takeSample()), +}) diff --git a/apps/hosting-service/scripts/memory-bench.ts b/apps/hosting-service/scripts/memory-bench.ts new file mode 100644 index 0000000..5d93442 --- /dev/null +++ b/apps/hosting-service/scripts/memory-bench.ts @@ -0,0 +1,236 @@ +#!/usr/bin/env bun +/** + * Local memory benchmark for hosting-service. + * + * Seeds a disk-source object store, starts `src/index.ts` with a fake postgres + * (scripts/memory-bench-preload.ts) and no Redis or S3, then serves a mix of + * small and large sites over HTTP and reports process memory after a full GC. + * + * bun apps/hosting-service/scripts/memory-bench.ts [--sites 2000] [--passes 4] [--idle 75] [--concurrency 16] + * + * Extra environment (HOT_CACHE_SIZE, MANIFEST_CACHE_SIZE, ...) is passed to the + * server unchanged. Run it with the bun that production uses (1.4.2) for + * comparable numbers. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseArgs } from 'node:util' +import { DiskStorageTier, TieredStorage } from '@wispplace/tiered-storage' +import { + BENCH_BASE_HOST, + buildObjectPool, + type ObjectPool, + type PoolObject, + siteHasVideo, + siteHost, +} from './memory-bench-fixtures' + +const { values: options } = parseArgs({ + options: { + sites: { type: 'string', default: '2000' }, + passes: { type: 'string', default: '4' }, + idle: { type: 'string', default: '75' }, + concurrency: { type: 'string', default: '16' }, + port: { type: 'string', default: '39301' }, + }, +}) +const SITES = Number(options.sites) +const PASSES = Number(options.passes) +const IDLE_SECONDS = Number(options.idle) +const CONCURRENCY = Number(options.concurrency) +const PORT = Number(options.port) + +interface Sample { + rss: number + heapSize: number + arrayBuffers: number + siteCacheEntries: number + siteCacheBytes: number + hotItems: number + hotBytes: number + logRing: number + metricRing: number +} + +async function seedObjects(cacheDir: string, pool: ObjectPool): Promise { + const storage = new TieredStorage({ + tiers: { cold: new DiskStorageTier({ directory: cacheDir, encodeColons: false }) }, + compression: false, + serialization: { serialize: async (data) => data as Uint8Array, deserialize: async (data) => data }, + }) + for (const object of Object.values(pool).flat()) { + await storage.set(object.key, object.stored, { + metadata: { + mimeType: object.mimeType, + sourceCid: object.cid, + uncompressedSize: String(object.uncompressedSize), + ...(object.encoding && { encoding: object.encoding }), + }, + }) + } +} + +function siteRequests(site: number): string[] { + const paths = ['/', '/assets/style.css', '/assets/app.js'] + if (siteHasVideo(site)) paths.push('/media/video.mp4') + return paths +} + +/** A fixed permutation per pass, so every run requests the same sequence. */ +function passOrder(pass: number): number[] { + return Array.from({ length: SITES }, (_, index) => (index * 7919 + pass * 104_729) % SITES) +} + +let requestsServed = 0 +const statusCounts = new Map() +// Date, the serving tier and the framing that follows it (a streamed tier is +// chunked, a hot hit may carry Content-Length) vary between requests; every +// other header and the body must not. +const VOLATILE_HEADERS = new Set(['date', 'x-cache-tier', 'content-length', 'transfer-encoding']) +const responseDigests = new Map() +let changedResponses = 0 + +function responseDigest(response: Response, body: ArrayBuffer): string { + const hash = new Bun.CryptoHasher('sha256') + hash.update(String(response.status)) + for (const [name, value] of [...response.headers].sort(([a], [b]) => a.localeCompare(b))) { + if (!VOLATILE_HEADERS.has(name)) hash.update(`\n${name}: ${value}`) + } + hash.update(new Uint8Array(body)) + return hash.digest('hex') +} + +function recordResponse(request: string, digest: string): void { + const previous = responseDigests.get(request) + if (previous !== undefined && previous !== digest) changedResponses++ + responseDigests.set(request, digest) +} + +/** One digest over every distinct request's status, stable headers and body. */ +function corpusDigest(): string { + const hash = new Bun.CryptoHasher('sha256') + for (const [request, digest] of [...responseDigests].sort(([a], [b]) => a.localeCompare(b))) { + hash.update(`${request} ${digest}\n`) + } + return hash.digest('hex').slice(0, 16) +} + +async function visit(site: number): Promise { + for (const path of siteRequests(site)) { + const response = await fetch(`http://127.0.0.1:${PORT}${path}`, { + headers: { + host: siteHost(site), + accept: path === '/' ? 'text/html' : '*/*', + 'accept-encoding': 'gzip', + }, + decompress: false, + signal: AbortSignal.timeout(60_000), + }) + recordResponse(`${site}${path}`, responseDigest(response, await response.arrayBuffer())) + requestsServed++ + statusCounts.set(response.status, (statusCounts.get(response.status) ?? 0) + 1) + } +} + +async function runPass(order: number[]): Promise { + let next = 0 + await Promise.all( + Array.from({ length: CONCURRENCY }, async () => { + while (next < order.length) { + const site = order[next++] + if (site !== undefined) await visit(site) + } + }), + ) +} + +const scratch = mkdtempSync(join(tmpdir(), 'wisp-hosting-memory-bench-')) +const cacheDir = join(scratch, 'sites') +const poolFile = join(scratch, 'pool.json') +const pool = buildObjectPool(SITES) +await seedObjects(cacheDir, pool) +writeFileSync( + poolFile, + JSON.stringify( + Object.fromEntries( + Object.entries(pool).map(([kind, objects]) => [kind, objects.map(({ cid, key }) => ({ cid, key }))]), + ), + ), +) + +const server = Bun.spawn([process.execPath, '--preload', './scripts/memory-bench-preload.ts', 'src/index.ts'], { + cwd: join(import.meta.dir, '..'), + env: { + ...process.env, + NODE_ENV: 'production', + HOSTING_ALLOW_DISK_SOURCE: 'true', + CACHE_DIR: cacheDir, + PORT: String(PORT), + BASE_HOST: BENCH_BASE_HOST, + BENCH_POOL_FILE: poolFile, + BENCH_SITES: String(SITES), + BENCH_CONTROL_PORT: String(PORT + 1), + REDIS_URL: '', + S3_BUCKET: '', + PRIVATE_S3_BUCKET: '', + GRAFANA_LOKI_URL: '', + GRAFANA_PROMETHEUS_URL: '', + }, + stdout: 'ignore', + stderr: 'inherit', +}) + +function sample(): Promise { + return fetch(`http://127.0.0.1:${PORT + 1}/`).then((response) => response.json() as Promise) +} + +async function waitForServer(): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + try { + if ((await fetch(`http://127.0.0.1:${PORT}/live`)).ok) return + } catch {} + await Bun.sleep(50) + } + throw new Error('hosting did not start') +} + +const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(1) +const rows: Array<[string, Sample, number]> = [] +function record(label: string, value: Sample): void { + rows.push([label, value, requestsServed]) + console.error(`${label}: ${requestsServed} requests, rss ${mib(value.rss)} MiB`) +} + +try { + await waitForServer() + record('started', await sample()) + await runPass(passOrder(0)) + record('after warmup', await sample()) + for (let pass = 1; pass <= PASSES; pass++) await runPass(passOrder(pass)) + record('steady state', await sample()) + if (IDLE_SECONDS > 0) { + await Bun.sleep(IDLE_SECONDS * 1000) + record(`idle ${IDLE_SECONDS}s`, await sample()) + } +} finally { + server.kill('SIGTERM') + await server.exited + rmSync(scratch, { recursive: true, force: true }) +} + +console.log( + `bun ${Bun.version}, ${SITES} sites, ${PASSES} steady passes, concurrency ${CONCURRENCY}, statuses ${JSON.stringify(Object.fromEntries(statusCounts))}`, +) +console.log( + `responses: ${responseDigests.size} distinct requests, digest ${corpusDigest()}, ${changedResponses} changed between passes`, +) +console.log( + '| phase | requests | RSS MiB | JS heap MiB | ArrayBuffers MiB | manifests (est. MiB) | hot items (MiB) | log/metric ring |', +) +console.log('|---|---|---|---|---|---|---|---|') +for (const [label, value, requests] of rows) { + console.log( + `| ${label} | ${requests} | ${mib(value.rss)} | ${mib(value.heapSize)} | ${mib(value.arrayBuffers)} | ${value.siteCacheEntries} (${mib(value.siteCacheBytes)}) | ${value.hotItems} (${mib(value.hotBytes)}) | ${value.logRing}/${value.metricRing} |`, + ) +} diff --git a/apps/hosting-service/src/index.ts b/apps/hosting-service/src/index.ts index eeef3a6..39cdb9e 100644 --- a/apps/hosting-service/src/index.ts +++ b/apps/hosting-service/src/index.ts @@ -1,5 +1,10 @@ import { existsSync, mkdirSync } from 'node:fs' -import { createLogger, initializeGrafanaExporters, shutdownGrafanaExporters } from '@wispplace/observability' +import { + createLogger, + initializeGrafanaExporters, + setInMemoryRetention, + shutdownGrafanaExporters, +} from '@wispplace/observability' import { startCacheInvalidationSubscriber, stopCacheInvalidationSubscriber } from './lib/cache-invalidation' import { cache } from './lib/cache-manager' import { closeDatabase } from './lib/db' @@ -11,6 +16,10 @@ import { onceAsync, stopHttpServerWithGrace } from './shutdown' const logger = createLogger('hosting-service') +// Nothing in hosting reads the in-process log or request-metric rings; console +// output and the Loki/OTLP exporters carry them instead. +setInMemoryRetention({ logs: 0, metrics: 0 }) + // Initialize Grafana exporters if configured initializeGrafanaExporters({ serviceName: 'hosting-service', @@ -21,6 +30,7 @@ const DEFAULT_PORT = 3001 const DEFAULT_BOOTSTRAP_HOT_LIMIT = 100 const MAX_BOOTSTRAP_HOT_LIMIT = 10_000 const HTTP_SHUTDOWN_GRACE_PERIOD_MS = 10_000 +const CACHE_SWEEP_INTERVAL_MS = 60_000 function parseBoundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number): number { const normalized = value?.trim() @@ -38,8 +48,9 @@ if (!existsSync(CACHE_DIR)) { logger.info('Created cache directory') } -// Start in-memory cache cleanup -cache.startCleanup() +// Sweep expired lookups every minute. Most namespaces live 10 s, so the default +// half-hour sweep left expired site manifests resident for up to 30 minutes. +cache.startCleanup(CACHE_SWEEP_INTERVAL_MS) // Start cache invalidation subscriber (listens for firehose-service updates via Redis pub/sub) startCacheInvalidationSubscriber() diff --git a/apps/hosting-service/src/lib/cache-manager.test.ts b/apps/hosting-service/src/lib/cache-manager.test.ts index c420725..a381314 100644 --- a/apps/hosting-service/src/lib/cache-manager.test.ts +++ b/apps/hosting-service/src/lib/cache-manager.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { CacheManager } from './cache-manager' +import { CacheManager, estimateJsonBytes } from './cache-manager' type TestNS = 'ttl' | 'lru' | 'sized' | 'combo' @@ -525,3 +525,54 @@ describe('CacheManager', () => { }) }) }) + +function manifestRow(files: number) { + const fileCids: Record = {} + const fileObjects: Record = {} + for (let index = 0; index < files; index++) { + const path = `posts/post-${index}/index.html` + fileCids[path] = `bafkreiexample${index}`.padEnd(59, 'a') + fileObjects[path] = `cas/bafkreiexample${index}.0123abcd.html` + } + return { did: 'did:plc:example', rkey: 'site', file_cids: fileCids, file_objects: fileObjects } +} + +describe('estimateJsonBytes', () => { + test('grows with every path, CID and CAS key a manifest row holds', () => { + const small = estimateJsonBytes(manifestRow(10)) + const large = estimateJsonBytes(manifestRow(1000)) + // Each path appears in both maps: two keys, a CID and a CAS key. + const perFile = (large - small) / 990 + expect(perFile).toBeGreaterThan(2 * 'posts/post-100/index.html'.length + 59 + 33) + expect(perFile).toBeLessThan(1024) + }) + + test('counts arrays, nested values and scalars', () => { + expect(estimateJsonBytes([['a.html', 'cid']])).toBeGreaterThan(estimateJsonBytes([])) + expect(estimateJsonBytes({ a: null, b: 1, c: true })).toBeGreaterThan(estimateJsonBytes({})) + expect(estimateJsonBytes('x'.repeat(100))).toBeGreaterThanOrEqual(100) + }) +}) + +describe('site manifest cache budget', () => { + test('evicts the oldest manifests once their estimated bytes exceed MANIFEST_CACHE_SIZE', async () => { + const previous = process.env.MANIFEST_CACHE_SIZE + const row = manifestRow(200) + const rowBytes = estimateJsonBytes(row) + process.env.MANIFEST_CACHE_SIZE = String(rowBytes * 3) + try { + const budgetedModule = './cache-manager?manifest-budget' + const { cache } = (await import(budgetedModule)) as typeof import('./cache-manager') + for (let site = 0; site < 10; site++) cache.set('siteCache', `did:plc:example:site-${site}`, manifestRow(200)) + + const stats = cache.getStats().siteCache + expect(stats.entries).toBe(3) + expect(stats.sizeBytes).toBeLessThanOrEqual(rowBytes * 3) + expect(cache.get('siteCache', 'did:plc:example:site-0')).toBeUndefined() + expect(cache.get('siteCache', 'did:plc:example:site-9')).toBeDefined() + } finally { + if (previous === undefined) delete process.env.MANIFEST_CACHE_SIZE + else process.env.MANIFEST_CACHE_SIZE = previous + } + }) +}) diff --git a/apps/hosting-service/src/lib/cache-manager.ts b/apps/hosting-service/src/lib/cache-manager.ts index e77f00a..ec10188 100644 --- a/apps/hosting-service/src/lib/cache-manager.ts +++ b/apps/hosting-service/src/lib/cache-manager.ts @@ -357,6 +357,52 @@ type CacheNamespace = | 'siteFiles' | 'sourceCidMismatches' +// Rough JavaScriptCore costs: a string cell plus header, an object or array +// with its property storage, and one property or element slot. +const STRING_OVERHEAD_BYTES = 32 +const CONTAINER_OVERHEAD_BYTES = 64 +const SLOT_BYTES = 16 + +/** + * Estimate the resident size of a JSON-shaped value, such as a postgres row + * with jsonb columns. Strings count one byte per UTF-16 unit, which is exact + * for the Latin-1 paths and CIDs that dominate site manifests. Against Bun + * 1.4.2 heap growth it errs high, by about 1.4x for a 1000-file manifest. + */ +export function estimateJsonBytes(value: unknown): number { + let bytes = 0 + const pending: unknown[] = [value] + while (pending.length > 0) { + const next = pending.pop() + if (typeof next === 'string') { + bytes += STRING_OVERHEAD_BYTES + next.length + } else if (Array.isArray(next)) { + bytes += CONTAINER_OVERHEAD_BYTES + next.length * SLOT_BYTES + for (const item of next) pending.push(item) + } else if (next !== null && typeof next === 'object') { + bytes += CONTAINER_OVERHEAD_BYTES + for (const [key, item] of Object.entries(next)) { + bytes += STRING_OVERHEAD_BYTES + key.length + SLOT_BYTES + pending.push(item) + } + } else { + bytes += SLOT_BYTES + } + } + return bytes +} + +function positiveIntegerFromEnv(value: string | undefined, fallback: number): number { + if (!value?.trim()) return fallback + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback +} + +// Site manifests hold every path, CID and CAS key of a site, so they are capped +// by estimated bytes as well as by count. Sized for a 1 GiB edge; raise with env. +const MANIFEST_CACHE_SIZE = positiveIntegerFromEnv(process.env.MANIFEST_CACHE_SIZE, 16 * 1024 * 1024) +const MANIFEST_CACHE_COUNT = positiveIntegerFromEnv(process.env.MANIFEST_CACHE_COUNT, 5000) + // Invalidation remains immediate. These TTLs bound stale data when an invalidation event cannot be delivered. export const cache = new CacheManager({ domains: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 5000 }, @@ -369,7 +415,12 @@ export const cache = new CacheManager({ maxSize: 10 * 1024 * 1024, estimateSize: (v) => (v as unknown[]).length * 100, }, - siteCache: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 5000 }, + siteCache: { + ttl: INVALIDATION_SAFETY_TTL_MS, + maxEntries: MANIFEST_CACHE_COUNT, + maxSize: MANIFEST_CACHE_SIZE, + estimateSize: estimateJsonBytes, + }, // Negative-result cache for per-site fallback files (SPA, custom 404, auto-detected 404 pages). // Stores null when a file is confirmed absent so repeated 404 responses don't re-hit S3. siteFiles: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 10_000 }, diff --git a/apps/hosting-service/src/lib/file-serving.ts b/apps/hosting-service/src/lib/file-serving.ts index cb8530b..ac45ab6 100644 --- a/apps/hosting-service/src/lib/file-serving.ts +++ b/apps/hosting-service/src/lib/file-serving.ts @@ -246,6 +246,8 @@ async function hasExpectedSourceCid( /** * Retrieve a file and, when a manifest CID is available, verify that the * stored object is from that manifest version before it can be served. + * The bytes are borrowed from the storage tiers: hash, decode or serve them, + * never write to them. */ async function getFileWithMetadata( did: string, @@ -257,7 +259,7 @@ async function getFileWithMetadata( const key = resolveStorageKey(did, rkey, filePath, fileObjects) if (key === null) return null if (expectedSourceCid === undefined) { - const result = await storage.getWithMetadata(key) + const result = await storage.getWithMetadata(key, { borrowData: true }) if (!result) return null logStorageResult(did, rkey, filePath, result) @@ -272,7 +274,7 @@ async function getFileWithMetadata( SOURCE_CID_MISMATCH_NAMESPACE, sourceCidMismatchKey, async () => { - const result = await storage.getWithMetadata(key) + const result = await storage.getWithMetadata(key, { borrowData: true }) if (!result) return { kind: 'not-found' } if (await hasExpectedSourceCid(result, expectedSourceCid, did, rkey, key)) { @@ -301,7 +303,7 @@ async function getFileWithMetadata( // closed without destructive cache oscillation. let coldResult: FileStorageResult | null try { - coldResult = await storage.getWithMetadata(key) + coldResult = await storage.getWithMetadata(key, { borrowData: true }) } catch (error) { // A transient cold-tier outage is not evidence that the manifest is stale. // Let the request boundary return 503 without scheduling a repair. diff --git a/apps/hosting-service/src/lib/html-prewarm.ts b/apps/hosting-service/src/lib/html-prewarm.ts index 842837c..187f61f 100644 --- a/apps/hosting-service/src/lib/html-prewarm.ts +++ b/apps/hosting-service/src/lib/html-prewarm.ts @@ -61,7 +61,7 @@ async function loadSiteHtmlKeysIntoHotTier( // mid-warmup) must not abandon the rest of the site's HTML. try { // getWithMetadata uses eager promotion and moves the key into hot tier. - const result = await storage.getWithMetadata(key) + const result = await storage.getWithMetadata(key, { borrowData: true }) if (result) warmedHtmlKeys++ } catch (err) { failedKeys++ diff --git a/apps/hosting-service/src/lib/redirects.ts b/apps/hosting-service/src/lib/redirects.ts index e8247f3..23a8d64 100644 --- a/apps/hosting-service/src/lib/redirects.ts +++ b/apps/hosting-service/src/lib/redirects.ts @@ -32,7 +32,7 @@ export async function loadRedirectRules( const key = resolveStorageKey(did, rkey, '_redirects', fileObjects) if (key === null) return [] try { - const data = await storage.get(key) + const data = await storage.get(key, { borrowData: true }) if (!data) return [] const rules = parseRedirectsFileBytes(data) diff --git a/apps/hosting-service/src/lib/storage.test.ts b/apps/hosting-service/src/lib/storage.test.ts index 02a3272..c5cc603 100644 --- a/apps/hosting-service/src/lib/storage.test.ts +++ b/apps/hosting-service/src/lib/storage.test.ts @@ -78,7 +78,7 @@ afterAll(async () => { describe('hosting storage configuration', () => { test('falls back from invalid numeric storage environment values', () => { expect(getStorageConfig()).toMatchObject({ - hotCacheSize: '100MB', + hotCacheSize: '32MB', hotCacheCount: 500, warmCacheSize: '10.0GB', }) @@ -187,6 +187,19 @@ describe('TTLMemoryTier timestamp bookkeeping', () => { expect(insertedAt.has('failed')).toBe(false) }) + + test('releases expired entries on the next write instead of holding them until LRU eviction', async () => { + const data = new Uint8Array(1024) + await hotTier.set('expired', data, metadata('expired', data.byteLength)) + const internals = hotTier as unknown as TTLMemoryTierInternals + internals.insertedAt.set('expired', Date.now() - internals.ttlMs - 1) + + await hotTier.set('fresh', data, metadata('fresh', data.byteLength)) + + expect(await hotTier.inner.exists('expired')).toBe(false) + expect(internals.insertedAt.has('expired')).toBe(false) + expect(await hotTier.inner.getStats()).toMatchObject({ items: 1, bytes: data.byteLength }) + }) }) describe('hosting storage source configuration', () => { diff --git a/apps/hosting-service/src/lib/storage.ts b/apps/hosting-service/src/lib/storage.ts index 4fe0512..64e3605 100644 --- a/apps/hosting-service/src/lib/storage.ts +++ b/apps/hosting-service/src/lib/storage.ts @@ -26,7 +26,8 @@ import { const logger = createLogger('hosting-storage') const DEFAULT_CACHE_DIR = './cache/sites' -const DEFAULT_HOT_CACHE_SIZE = 104857600 // 100MB +// Sized for the smallest (1 GiB) edge; larger edges raise it with HOT_CACHE_SIZE. +const DEFAULT_HOT_CACHE_SIZE = 32 * 1024 * 1024 const DEFAULT_HOT_CACHE_COUNT = 500 const DEFAULT_WARM_CACHE_SIZE = 10737418240 // 10GB const DEFAULT_HOT_CACHE_TTL_SECONDS = 60 @@ -688,10 +689,17 @@ class TTLMemoryTier implements StorageTier { return false } - /** Remove timestamps for entries TinyLRU evicted without notifying this wrapper. */ - private async pruneMissingTimestamps(): Promise { - for (const key of this.insertedAt.keys()) { - if (!(await this.inner.exists(key))) { + /** + * Release expired entries, which reads would refuse anyway, and drop + * timestamps for entries TinyLRU evicted without notifying this wrapper. + */ + private async pruneTimestamps(): Promise { + const now = Date.now() + for (const [key, insertedAt] of this.insertedAt) { + if (now - insertedAt > this.ttlMs) { + this.insertedAt.delete(key) + await this.inner.delete(key) + } else if (!(await this.inner.exists(key))) { this.insertedAt.delete(key) } } @@ -703,7 +711,7 @@ class TTLMemoryTier implements StorageTier { } else { this.insertedAt.delete(key) } - await this.pruneMissingTimestamps() + await this.pruneTimestamps() } async get(key: string) { @@ -781,7 +789,7 @@ class TTLMemoryTier implements StorageTier { } async getStats() { - await this.pruneMissingTimestamps() + await this.pruneTimestamps() return this.inner.getStats() } diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 5bb6f95..a75905b 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -44,7 +44,7 @@ Write: Hot ← Warm ← Cold The **hot tier** is an in-memory LRU cache. Fast, small, and lost on restart — repopulated from warm/cold on access. ```bash -HOT_CACHE_SIZE=104857600 # 100 MB +HOT_CACHE_SIZE=33554432 # 32 MiB default HOT_CACHE_COUNT=500 ``` diff --git a/docs/src/content/docs/deployment.md b/docs/src/content/docs/deployment.md index b520f71..56a3b66 100644 --- a/docs/src/content/docs/deployment.md +++ b/docs/src/content/docs/deployment.md @@ -234,8 +234,10 @@ PORT=3001 # Tiered storage CACHE_DIR="./cache/sites" -HOT_CACHE_SIZE=104857600 # 100 MB, in-memory LRU +HOT_CACHE_SIZE=33554432 # 32 MiB default, in-memory LRU HOT_CACHE_COUNT=500 +MANIFEST_CACHE_SIZE=16777216 # 16 MiB default, estimated bytes of cached site manifests +MANIFEST_CACHE_COUNT=5000 HOT_CACHE_TTL=60 # seconds WARM_CACHE_SIZE=10737418240 # 10 GB, disk; this is not a RAM allowance WARM_EVICTION_POLICY="lru" # lru, fifo, or size @@ -268,7 +270,7 @@ REDIS_URL="rediss://redis.example:6379" TRACE_REQUESTS=false ``` -Keep `HOT_CACHE_SIZE` consistent across regions. It is the byte cap for resident file data; `WARM_CACHE_SIZE` only caps the disk tier. The process also needs memory for Bun/Node, metadata, request buffers, and shared caches, so the container RSS can exceed `HOT_CACHE_SIZE`. After measuring a fixed-image canary under representative traffic, configure a container memory limit above the observed peak plus operating headroom. Do not run production hosting containers without a memory limit indefinitely. +Keep `HOT_CACHE_SIZE` consistent across regions. It is the byte cap for resident file data; `WARM_CACHE_SIZE` only caps the disk tier. The `HOT_CACHE_SIZE` and `MANIFEST_CACHE_SIZE` defaults are sized for a 1 GiB edge. The process also needs memory for Bun/Node, metadata, request buffers, and shared caches, so the container RSS can exceed `HOT_CACHE_SIZE`. After measuring a fixed-image canary under representative traffic, configure a container memory limit above the observed peak plus operating headroom. Do not run production hosting containers without a memory limit indefinitely. ```bash cd hosting-service diff --git a/packages/@wispplace/observability/src/core.ts b/packages/@wispplace/observability/src/core.ts index a742084..408de59 100644 --- a/packages/@wispplace/observability/src/core.ts +++ b/packages/@wispplace/observability/src/core.ts @@ -82,9 +82,15 @@ export interface MetricStats { // Configuration // ============================================================================ -const MAX_LOGS = 5000 const MAX_ERRORS = 500 -const MAX_METRICS = 10000 + +/** How many recent entries each in-process ring keeps for getLogs()/getMetrics(). */ +export interface InMemoryRetention { + logs: number + metrics: number +} + +const retention: InMemoryRetention = { logs: 5000, metrics: 10000 } // ============================================================================ // Storage @@ -143,11 +149,9 @@ export const logCollector = { eventType: extractEventType(safeMessage), } - logs.unshift(entry) - - // Rotate if needed - if (logs.length > MAX_LOGS) { - logs.splice(MAX_LOGS) + if (retention.logs > 0) { + logs.unshift(entry) + if (logs.length > retention.logs) logs.splice(retention.logs) } // Send only the sanitized entry to Loki. @@ -315,15 +319,13 @@ export const metricsCollector = { service: sanitizeLogString(service), } - metrics.unshift(entry) + if (retention.metrics > 0) { + metrics.unshift(entry) + if (metrics.length > retention.metrics) metrics.splice(retention.metrics) + } // Send to Prometheus/OTLP exporter metricsExporter.recordMetric(entry) - - // Rotate if needed - if (metrics.length > MAX_METRICS) { - metrics.splice(MAX_METRICS) - } }, /** Per-site traffic counter; exported only, not kept in the in-memory ring. */ @@ -387,6 +389,18 @@ export const metricsCollector = { }, } +/** + * Resize the in-process log and request-metric rings. Console output and the + * Loki/OTLP exporters are unaffected; a service that never calls getLogs() or + * getMetrics() can set both to 0 and keep no entries at all. + */ +export function setInMemoryRetention(next: Partial): void { + if (next.logs !== undefined) retention.logs = Math.max(0, Math.floor(next.logs)) + if (next.metrics !== undefined) retention.metrics = Math.max(0, Math.floor(next.metrics)) + logs.splice(retention.logs) + metrics.splice(retention.metrics) +} + // ============================================================================ // Logger Factory // ============================================================================ diff --git a/packages/@wispplace/observability/src/retention.test.ts b/packages/@wispplace/observability/src/retention.test.ts new file mode 100644 index 0000000..974e770 --- /dev/null +++ b/packages/@wispplace/observability/src/retention.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { logCollector, metricsCollector, setInMemoryRetention } from './core' + +const service = 'retention-test' + +afterEach(() => { + setInMemoryRetention({ logs: 5000, metrics: 10000 }) + logCollector.clear() + metricsCollector.clear() +}) + +describe('setInMemoryRetention', () => { + test('keeps no log or metric entries at zero, while console output continues', () => { + const info = spyOn(console, 'info').mockImplementation(() => {}) + try { + setInMemoryRetention({ logs: 0, metrics: 0 }) + logCollector.info('[Retention] still printed', service) + metricsCollector.recordRequest('/*', 'GET', 200, 3, service) + + expect(logCollector.getLogs({ service })).toHaveLength(0) + expect(metricsCollector.getMetrics({ service })).toHaveLength(0) + expect(info).toHaveBeenCalledWith('[retention-test] [Retention] still printed') + } finally { + info.mockRestore() + } + }) + + test('trims existing entries to a smaller ring and keeps the newest', () => { + const info = spyOn(console, 'info').mockImplementation(() => {}) + try { + for (let index = 0; index < 5; index++) { + logCollector.info(`entry ${index}`, service) + metricsCollector.recordRequest(`/${index}`, 'GET', 200, 1, service) + } + setInMemoryRetention({ logs: 2, metrics: 3 }) + logCollector.info('entry 5', service) + + expect(logCollector.getLogs({ service }).map((log) => log.message)).toEqual(['entry 5', 'entry 4']) + expect(metricsCollector.getMetrics({ service }).map((metric) => metric.path)).toEqual(['/4', '/3', '/2']) + } finally { + info.mockRestore() + } + }) +}) diff --git a/packages/@wispplace/tiered-storage/src/TieredStorage.ts b/packages/@wispplace/tiered-storage/src/TieredStorage.ts index 88ef3d2..862c183 100644 --- a/packages/@wispplace/tiered-storage/src/TieredStorage.ts +++ b/packages/@wispplace/tiered-storage/src/TieredStorage.ts @@ -64,9 +64,18 @@ function cloneStorageMetadata(metadata: StorageMetadata): StorageMetadata { } } -function cloneTierGetResult(result: TierGetResult): TierGetResult { +/** Options for buffered reads. */ +export interface BufferedReadOptions { + /** + * Return the tier's bytes without a defensive copy. The caller must not + * mutate them: they may be the hot-tier entry or shared with coalesced reads. + */ + borrowData?: boolean +} + +function consumerTierGetResult(result: TierGetResult, options: BufferedReadOptions): TierGetResult { return { - data: new Uint8Array(result.data), + data: options.borrowData === true ? result.data : new Uint8Array(result.data), metadata: cloneStorageMetadata(result.metadata), } } @@ -257,6 +266,7 @@ export class TieredStorage { * Retrieve data for a key. * * @param key - The key to retrieve + * @param options - Set `borrowData` to skip the defensive copy of read-only bytes * @returns The data, or null if not found or expired * * @remarks @@ -265,8 +275,8 @@ export class TieredStorage { * Automatically handles decompression and deserialization. * Returns null if key doesn't exist or has expired (TTL). */ - async get(key: string): Promise { - const result = await this.getWithMetadata(key) + async get(key: string, options: BufferedReadOptions = {}): Promise { + const result = await this.getWithMetadata(key, options) return result ? result.data : null } @@ -274,6 +284,7 @@ export class TieredStorage { * Retrieve data with metadata and source tier information. * * @param key - The key to retrieve + * @param options - Set `borrowData` to skip the defensive copy of read-only bytes * @returns The data, metadata, and source tier, or null if not found * * @remarks @@ -282,12 +293,12 @@ export class TieredStorage { * - Metadata like access count, TTL, checksum * - When the data was created/last accessed */ - async getWithMetadata(key: string): Promise | null> { + async getWithMetadata(key: string, options: BufferedReadOptions = {}): Promise | null> { const fence = this.acquireReadFence(key) const generation = fence.generation const upperTierPromotionEpoch = this.upperTierPromotionEpoch try { - return await this.getWithMetadataWithFence(key, fence, generation, upperTierPromotionEpoch) + return await this.getWithMetadataWithFence(key, fence, generation, upperTierPromotionEpoch, options) } finally { this.releaseReadFence(key, fence) } @@ -298,6 +309,7 @@ export class TieredStorage { fence: KeyFence, generation: number, upperTierPromotionEpoch: number, + options: BufferedReadOptions, ): Promise | null> { // 1. Check hot tier first if (this.config.tiers.hot) { @@ -315,7 +327,7 @@ export class TieredStorage { } // Fire-and-forget access stats update (non-critical) void this.updateAccessStats(key, 'hot') - const consumerResult = cloneTierGetResult(result) + const consumerResult = consumerTierGetResult(result, options) return { data: (await this.deserializeData(consumerResult.data)) as T, metadata: consumerResult.metadata, @@ -347,7 +359,7 @@ export class TieredStorage { } // Fire-and-forget access stats update (non-critical) void this.updateAccessStats(key, 'warm') - const consumerResult = cloneTierGetResult(result) + const consumerResult = consumerTierGetResult(result, options) return { data: (await this.deserializeData(consumerResult.data)) as T, metadata: consumerResult.metadata, @@ -390,7 +402,7 @@ export class TieredStorage { await this.promoteIfCurrent(key, fence, generation, upperTierPromotionEpoch, promotions) } - const consumerResult = cloneTierGetResult(result) + const consumerResult = consumerTierGetResult(result, options) return { data: (await this.deserializeData(consumerResult.data)) as T, metadata: consumerResult.metadata, diff --git a/packages/@wispplace/tiered-storage/src/index.ts b/packages/@wispplace/tiered-storage/src/index.ts index 4b3e5e7..f05463e 100644 --- a/packages/@wispplace/tiered-storage/src/index.ts +++ b/packages/@wispplace/tiered-storage/src/index.ts @@ -8,7 +8,12 @@ */ // Main class -export { TieredStorage, type UpperTierInvalidationFailure, type UpperTierInvalidationResult } from './TieredStorage.js' +export { + type BufferedReadOptions, + TieredStorage, + type UpperTierInvalidationFailure, + type UpperTierInvalidationResult, +} from './TieredStorage.js' export { DiskStorageTier, type DiskStorageTierConfig, diff --git a/packages/@wispplace/tiered-storage/test/TieredStorage.borrow.test.ts b/packages/@wispplace/tiered-storage/test/TieredStorage.borrow.test.ts new file mode 100644 index 0000000..e8e7f43 --- /dev/null +++ b/packages/@wispplace/tiered-storage/test/TieredStorage.borrow.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test' +import { TieredStorage } from '../src/TieredStorage.js' +import { MemoryStorageTier } from '../src/tiers/MemoryStorageTier.js' + +const identity = { + serialize: async (data: unknown) => data as Uint8Array, + deserialize: async (data: Uint8Array) => data, +} + +function createStorage() { + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }) + const cold = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }) + const storage = new TieredStorage({ + tiers: { hot, cold }, + promotionStrategy: 'eager', + serialization: identity, + }) + return { hot, cold, storage } +} + +describe('TieredStorage buffered read ownership', () => { + test('copies bytes by default so callers can mutate their result', async () => { + const { hot, storage } = createStorage() + await storage.set('site/index.html', new Uint8Array([1, 2, 3])) + + const result = await storage.getWithMetadata('site/index.html') + result!.data[0] = 99 + + expect(result!.data).not.toBe(await hot.get('site/index.html')) + expect(await hot.get('site/index.html')).toEqual(new Uint8Array([1, 2, 3])) + }) + + test('borrowData returns the hot-tier bytes without a copy', async () => { + const { hot, storage } = createStorage() + await storage.set('site/index.html', new Uint8Array([1, 2, 3])) + + const result = await storage.getWithMetadata('site/index.html', { borrowData: true }) + + expect(result?.source).toBe('hot') + expect(result!.data).toBe((await hot.get('site/index.html'))!) + expect(await storage.get('site/index.html', { borrowData: true })).toBe((await hot.get('site/index.html'))!) + }) + + test('borrowData shares a cold read with the promoted hot entry and still isolates metadata', async () => { + const { hot, storage } = createStorage() + await storage.set('site/app.js', new Uint8Array([4, 5, 6]), { onlyTiers: ['cold'], metadata: { owner: 'cold' } }) + + const result = await storage.getWithMetadata('site/app.js', { borrowData: true }) + result!.metadata.customMetadata!.owner = 'mutated' + + expect(result?.source).toBe('cold') + expect(result!.data).toBe((await hot.get('site/app.js'))!) + expect((await hot.getMetadata('site/app.js'))?.customMetadata).toEqual({ owner: 'cold' }) + }) +}) -- 2.54.0