mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
perf(cache): shard the session cache by provider and month
A provider's shard held its whole history, so one appended session rewrote 95 MB. Each provider's files are now split by the UTC month of their first turn - a bucket that is stable across appends, so a growing session never migrates shards - and every shard records the newest month it holds so a ranged load can skip the ones that cannot contribute. Dirty tracking is per bucket: markCacheDirty takes an optional file path and marks both the bucket the entry was last saved in and the one it is in now. A save writes only dirty buckets, carries the refs of months it never loaded, and merges the on-disk shard back in when a bucket is dirty but was never loaded. v8 and v7 caches re-lay-out losslessly.
This commit is contained in:
parent
7cf7a0152c
commit
e9dcb363af
1 changed files with 366 additions and 82 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promises'
|
||||
import { readFile, stat, open, rename, unlink, readdir, mkdir, rm } from 'fs/promises'
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { join } from 'path'
|
||||
|
|
@ -161,14 +161,21 @@ export type SessionCache = {
|
|||
// shards plus a small envelope, so a launch that only touched one provider
|
||||
// rewrites just that provider's file. The turn shape is unchanged, so a v7 file
|
||||
// migrates losslessly (migrateSingleFileCache) rather than re-parsing.
|
||||
export const CACHE_VERSION = 8
|
||||
// v9: on-disk layout only - a provider's shard split further by the UTC month of
|
||||
// each cached file, so one appended session rewrites one month instead of the
|
||||
// provider's whole (100MB-scale) history, and a ranged query loads only the
|
||||
// months it can possibly report on. Turn shape unchanged, so v8 and v7 both
|
||||
// migrate losslessly.
|
||||
export const CACHE_VERSION = 9
|
||||
|
||||
// The cache directory is version-suffixed for the same reason the file used to
|
||||
// be: different binaries (an old launchd menubar, a newer desktop app) each own
|
||||
// a distinct layout and can never clobber each other's incompatible schema.
|
||||
const CACHE_DIR_NAME = `session-cache.v${CACHE_VERSION}`
|
||||
// Written LAST on every save: it names the shard file of every provider, so the
|
||||
// rename that publishes it is the single point at which a save becomes visible.
|
||||
// The v8 shard directory, read once by the lossless v8 -> v9 re-layout.
|
||||
const PRIOR_SHARD_DIR_NAME = 'session-cache.v8'
|
||||
// Written LAST on every save: it names the shard file of every provider-month, so
|
||||
// the rename that publishes it is the single point at which a save becomes visible.
|
||||
const ENVELOPE_FILE = 'envelope.json'
|
||||
// The pre-versioning filename. Never written or deleted anymore — old binaries
|
||||
// still own it. On first load we adopt-copy it once (see loadCache) when the
|
||||
|
|
@ -304,34 +311,117 @@ export function sessionCacheDir(): string {
|
|||
return join(getCodeburnCacheDir(), CACHE_DIR_NAME)
|
||||
}
|
||||
|
||||
// `until` is the UTC month of the newest turn any file in the shard holds. The
|
||||
// shard's own key is the month of the OLDEST (a file is bucketed by its first
|
||||
// turn), so the pair bounds every turn the shard can contribute and a ranged
|
||||
// load can skip the shard outright when the two do not overlap the query.
|
||||
type ShardRef = { name: string; until: string }
|
||||
type EnvelopeProvider = {
|
||||
envFingerprint: string
|
||||
durable?: boolean
|
||||
/** month (`YYYY-MM`, or `0000-00` for turn-less files) -> shard */
|
||||
shards: Record<string, ShardRef>
|
||||
}
|
||||
type CacheEnvelope = {
|
||||
version: number
|
||||
complete?: boolean
|
||||
nonce: string
|
||||
shards: Record<string, string>
|
||||
providers: Record<string, EnvelopeProvider>
|
||||
}
|
||||
|
||||
// Files with no turns (failure markers, empty sessions) have no month to bucket
|
||||
// by. They live in one always-loaded bucket, which is also what makes the only
|
||||
// possible re-bucketing safe: a file leaves this bucket the first time it gains
|
||||
// a turn, and the bucket it leaves is guaranteed to be in memory.
|
||||
const UNDATED_BUCKET = '0000-00'
|
||||
// Sentinel inside `dirtyBuckets`: every bucket of the provider is dirty.
|
||||
const ALL_BUCKETS = '*'
|
||||
|
||||
function monthKey(timestamp: string | undefined): string | null {
|
||||
if (!timestamp) return null
|
||||
const ms = Date.parse(timestamp)
|
||||
if (Number.isNaN(ms)) return null
|
||||
const d = new Date(ms)
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** The shard bucket a cached file belongs to: the UTC month of its FIRST turn.
|
||||
* First-turn (not last, not mtime) is the only choice that is stable across
|
||||
* appends — an appended session must never migrate shards, or the entry would
|
||||
* be written to a new shard while the old one still holds a stale copy. */
|
||||
export function cacheBucketMonth(file: CachedFile): string {
|
||||
return monthKey(file.turns[0]?.timestamp) ?? UNDATED_BUCKET
|
||||
}
|
||||
|
||||
// Turns are appended in order, so the last one carries the newest timestamp.
|
||||
function newestMonth(file: CachedFile): string {
|
||||
return monthKey(file.turns[file.turns.length - 1]?.timestamp) ?? cacheBucketMonth(file)
|
||||
}
|
||||
|
||||
// Save bookkeeping, held beside the cache rather than on it so it never lands in
|
||||
// a shard's JSON or in a caller's deep-equality. `shards` is the
|
||||
// provider -> shard filename map the last load/save published; a provider that
|
||||
// is neither dirty nor already sharded is written on the next save.
|
||||
type CacheState = { dirty: boolean; dirtyProviders: Set<string>; shards: Record<string, string> }
|
||||
// a shard's JSON or in a caller's deep-equality.
|
||||
type CacheState = {
|
||||
dirty: boolean
|
||||
/** provider -> dirty months (or `ALL_BUCKETS`). */
|
||||
dirtyBuckets: Map<string, Set<string>>
|
||||
/** provider -> the shard refs the last load/save published. */
|
||||
shards: Map<string, Record<string, ShardRef>>
|
||||
/** provider -> months held in memory; `null` when the whole provider loaded. */
|
||||
loaded: Map<string, Set<string> | null>
|
||||
/** provider -> the envFingerprint the published envelope recorded. */
|
||||
fingerprints: Map<string, string>
|
||||
/** `provider\0path` -> the bucket the entry was loaded/saved under, so a
|
||||
* delete or a re-bucketing can dirty the bucket it is leaving. */
|
||||
bucketOf: Map<string, string>
|
||||
/** The load scope this cache was read under, for the cross-request memo. */
|
||||
scope: string
|
||||
}
|
||||
const cacheStates = new WeakMap<SessionCache, CacheState>()
|
||||
|
||||
function stateOf(cache: SessionCache): CacheState {
|
||||
let state = cacheStates.get(cache)
|
||||
if (!state) {
|
||||
state = { dirty: false, dirtyProviders: new Set(), shards: {} }
|
||||
state = {
|
||||
dirty: false,
|
||||
dirtyBuckets: new Map(),
|
||||
shards: new Map(),
|
||||
loaded: new Map(),
|
||||
fingerprints: new Map(),
|
||||
bucketOf: new Map(),
|
||||
scope: 'all',
|
||||
}
|
||||
cacheStates.set(cache, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** Record that `provider`'s section changed, so the next save rewrites its shard. */
|
||||
export function markCacheDirty(cache: SessionCache, provider: string): void {
|
||||
const state = stateOf(cache)
|
||||
function markBucketDirty(state: CacheState, provider: string, bucket: string): void {
|
||||
state.dirty = true
|
||||
let buckets = state.dirtyBuckets.get(provider)
|
||||
if (!buckets) { buckets = new Set(); state.dirtyBuckets.set(provider, buckets) }
|
||||
buckets.add(bucket)
|
||||
}
|
||||
|
||||
function isBucketDirty(state: CacheState, provider: string, bucket: string): boolean {
|
||||
const buckets = state.dirtyBuckets.get(provider)
|
||||
return buckets !== undefined && (buckets.has(ALL_BUCKETS) || buckets.has(bucket))
|
||||
}
|
||||
|
||||
/** Record that `provider`'s section changed, so the next save rewrites the
|
||||
* affected shards. Pass `filePath` whenever the change is scoped to one cached
|
||||
* file — both the bucket it was last saved in and the bucket it is in now are
|
||||
* marked, so a delete, a rewrite and a re-bucketing are all covered whichever
|
||||
* order the caller mutates and marks in. Omitting it dirties every bucket. */
|
||||
export function markCacheDirty(cache: SessionCache, provider: string, filePath?: string): void {
|
||||
const state = stateOf(cache)
|
||||
if (filePath === undefined) { markBucketDirty(state, provider, ALL_BUCKETS); return }
|
||||
const prior = state.bucketOf.get(`${provider}\0${filePath}`)
|
||||
if (prior !== undefined) markBucketDirty(state, provider, prior)
|
||||
const file = cache.providers[provider]?.files[filePath]
|
||||
if (file) markBucketDirty(state, provider, cacheBucketMonth(file))
|
||||
// A path with neither a prior bucket nor a live entry (deleted before this
|
||||
// process ever saw it) still has to move `dirty`, or the save is skipped.
|
||||
state.dirty = true
|
||||
state.dirtyProviders.add(provider)
|
||||
}
|
||||
|
||||
/** True when any provider section changed since the last save. */
|
||||
|
|
@ -481,6 +571,12 @@ function validateCachedFile(f: unknown): f is CachedFile {
|
|||
&& (o['turns'] as unknown[]).every(validateTurn)
|
||||
}
|
||||
|
||||
// A shard's payload: the provider's `files` map, restricted to one month.
|
||||
function validateFiles(v: unknown): v is Record<string, CachedFile> {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return false
|
||||
return Object.values(v as Record<string, unknown>).every(validateCachedFile)
|
||||
}
|
||||
|
||||
function validateProviderSection(s: unknown): s is ProviderSection {
|
||||
if (!s || typeof s !== 'object') return false
|
||||
const o = s as Record<string, unknown>
|
||||
|
|
@ -589,34 +685,79 @@ async function adoptNewestPriorCache(): Promise<SessionCache | null> {
|
|||
// process mints a new nonce and forces a reload, so cross-process freshness is
|
||||
// preserved; saveCache updates the memo write-through so the object handed out
|
||||
// stays the canonical one after a refresh.
|
||||
let cacheMemo: { dir: string; nonce: string; cache: SessionCache } | null = null
|
||||
let cacheMemo: { dir: string; nonce: string; scope: string; cache: SessionCache } | null = null
|
||||
|
||||
export function clearLoadCacheMemo(): void {
|
||||
cacheMemo = null
|
||||
}
|
||||
|
||||
/** Months (UTC `YYYY-MM`, inclusive) a query can possibly report on. */
|
||||
export type CacheLoadScope = { fromMonth: string; toMonth: string }
|
||||
|
||||
export function monthScopeForRange(start: Date, end: Date): CacheLoadScope {
|
||||
return { fromMonth: monthKey(start.toISOString())!, toMonth: monthKey(end.toISOString())! }
|
||||
}
|
||||
|
||||
function previousMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number) as [number, number]
|
||||
return m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// A shard is in scope when its [bucket .. until] span overlaps the query. One
|
||||
// extra month of slack below the range covers the cross-range carries that read
|
||||
// turns from BEFORE the window: the pre-range PR set / git branch a session
|
||||
// carries into its first in-range turn (both resolved from the same file, so
|
||||
// they only need the file loaded at all), and the out-of-range subagent-spawn
|
||||
// ANCHOR whose in-range child folds into it. The undated bucket has no span and
|
||||
// is always loaded.
|
||||
function shardInScope(bucket: string, until: string, scope: CacheLoadScope): boolean {
|
||||
if (bucket === UNDATED_BUCKET) return true
|
||||
return bucket <= scope.toMonth && until >= previousMonth(scope.fromMonth)
|
||||
}
|
||||
|
||||
function isShardRef(v: unknown): v is ShardRef {
|
||||
if (!v || typeof v !== 'object') return false
|
||||
const o = v as Record<string, unknown>
|
||||
return typeof o['name'] === 'string' && typeof o['until'] === 'string'
|
||||
}
|
||||
|
||||
function isEnvelope(raw: unknown): raw is CacheEnvelope {
|
||||
if (!raw || typeof raw !== 'object') return false
|
||||
const o = raw as Record<string, unknown>
|
||||
return o['version'] === CACHE_VERSION
|
||||
&& typeof o['nonce'] === 'string'
|
||||
&& !!o['shards'] && typeof o['shards'] === 'object' && !Array.isArray(o['shards'])
|
||||
&& Object.values(o['shards'] as Record<string, unknown>).every(v => typeof v === 'string')
|
||||
if (o['version'] !== CACHE_VERSION || typeof o['nonce'] !== 'string') return false
|
||||
const providers = o['providers']
|
||||
if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return false
|
||||
return Object.values(providers as Record<string, unknown>).every(p => {
|
||||
if (!p || typeof p !== 'object') return false
|
||||
const e = p as Record<string, unknown>
|
||||
if (typeof e['envFingerprint'] !== 'string') return false
|
||||
if (!e['shards'] || typeof e['shards'] !== 'object' || Array.isArray(e['shards'])) return false
|
||||
return Object.values(e['shards'] as Record<string, unknown>).every(isShardRef)
|
||||
})
|
||||
}
|
||||
|
||||
// A shard that is missing or malformed is treated as an ABSENT provider, not as
|
||||
// a corrupt cache: only that provider re-parses, instead of the old all-or-
|
||||
// nothing where one bad turn discarded every provider's history.
|
||||
async function loadShard(path: string): Promise<ProviderSection | null> {
|
||||
// A shard that is missing or malformed costs exactly the provider-months it
|
||||
// held, not the provider and never the whole cache: those files re-parse while
|
||||
// every other month keeps serving.
|
||||
async function loadShard(path: string): Promise<Record<string, CachedFile> | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8'))
|
||||
return validateProviderSection(parsed) ? parsed : null
|
||||
return validateFiles(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCache(): Promise<SessionCache> {
|
||||
/**
|
||||
* Read the cache. With a `scope`, only the shards whose months can contribute a
|
||||
* turn to that range are read — everything else stays on disk and is carried
|
||||
* across the next save untouched (see saveCache). Durable providers and any
|
||||
* provider whose recorded fingerprint no longer matches are always read in
|
||||
* full: the first because its cache is the only surviving record of pruned
|
||||
* usage, the second because a fingerprint change discards the whole section and
|
||||
* must see every entry it is discarding.
|
||||
*/
|
||||
export async function loadCache(scope?: CacheLoadScope): Promise<SessionCache> {
|
||||
const dir = sessionCacheDir()
|
||||
let envelope: CacheEnvelope
|
||||
try {
|
||||
|
|
@ -626,28 +767,56 @@ export async function loadCache(): Promise<SessionCache> {
|
|||
} catch {
|
||||
return afterMissingShardCache()
|
||||
}
|
||||
if (cacheMemo && cacheMemo.dir === dir && cacheMemo.nonce === envelope.nonce) return cacheMemo.cache
|
||||
const scopeKey = scope ? `${scope.fromMonth}..${scope.toMonth}` : 'all'
|
||||
if (cacheMemo && cacheMemo.dir === dir && cacheMemo.nonce === envelope.nonce
|
||||
&& (cacheMemo.scope === 'all' || cacheMemo.scope === scopeKey)) return cacheMemo.cache
|
||||
|
||||
const cache: SessionCache = { version: CACHE_VERSION, providers: {}, complete: envelope.complete === true }
|
||||
const shards: Record<string, string> = {}
|
||||
for (const [provider, file] of Object.entries(envelope.shards)) {
|
||||
const section = await loadShard(join(dir, file))
|
||||
if (!section) continue
|
||||
const state = stateOf(cache)
|
||||
const reads: Promise<void>[] = []
|
||||
for (const [provider, meta] of Object.entries(envelope.providers)) {
|
||||
const section: ProviderSection = {
|
||||
envFingerprint: meta.envFingerprint,
|
||||
files: {},
|
||||
...(meta.durable ? { durable: true } : {}),
|
||||
}
|
||||
// Recorded even when every shard is skipped or unreadable: the section is
|
||||
// what tells the next save which provider these carried-forward shard refs
|
||||
// belong to, and what stops the reconcile from re-parsing under a
|
||||
// fingerprint the envelope already agrees with.
|
||||
cache.providers[provider] = section
|
||||
shards[provider] = file
|
||||
const full = !scope || meta.durable === true || meta.envFingerprint !== computeEnvFingerprint(provider)
|
||||
const loaded: Set<string> | null = full ? null : new Set()
|
||||
for (const [bucket, ref] of Object.entries(meta.shards)) {
|
||||
if (loaded && !shardInScope(bucket, ref.until, scope!)) continue
|
||||
loaded?.add(bucket)
|
||||
reads.push(loadShard(join(dir, ref.name)).then(files => {
|
||||
// Unreadable: the bucket counts as loaded-and-empty and is marked
|
||||
// dirty, so the re-parsed files replace it instead of the stale shard
|
||||
// being carried forward forever.
|
||||
if (!files) { markBucketDirty(state, provider, bucket); return }
|
||||
for (const path of Object.keys(files)) state.bucketOf.set(`${provider}\0${path}`, bucket)
|
||||
Object.assign(section.files, files)
|
||||
}))
|
||||
}
|
||||
state.loaded.set(provider, loaded)
|
||||
state.shards.set(provider, meta.shards)
|
||||
state.fingerprints.set(provider, meta.envFingerprint)
|
||||
}
|
||||
stateOf(cache).shards = shards
|
||||
cacheMemo = { dir, nonce: envelope.nonce, cache }
|
||||
await Promise.all(reads)
|
||||
state.scope = scopeKey
|
||||
cacheMemo = { dir, nonce: envelope.nonce, scope: scopeKey, cache }
|
||||
return cache
|
||||
}
|
||||
|
||||
// The shard directory is absent/unreadable. Prefer the LOSSLESS re-layout of the
|
||||
// v7 single-file cache (same turn shape, so nothing re-parses); failing that,
|
||||
// The shard directory is absent/unreadable. Prefer a LOSSLESS re-layout of the
|
||||
// newest prior layout that is present (v8 provider shards, then the v7 single
|
||||
// file — both hold the current turn shape, so nothing re-parses); failing that,
|
||||
// adopt the prior versions' expired-source PR orphans, then the legacy
|
||||
// unversioned file. Either way the shard directory is minted on the next save.
|
||||
async function afterMissingShardCache(): Promise<SessionCache> {
|
||||
const migrated = await migrateSingleFileCache()
|
||||
if (migrated) return migrated
|
||||
const relaid = await migrateProviderShardCache() ?? await migrateSingleFileCache()
|
||||
if (relaid) return relaid
|
||||
const prior = await adoptNewestPriorCache()
|
||||
if (prior) return prior
|
||||
// validateCache requires the version to match, so a different-version legacy
|
||||
|
|
@ -656,10 +825,33 @@ async function afterMissingShardCache(): Promise<SessionCache> {
|
|||
return adoptLegacyCache()
|
||||
}
|
||||
|
||||
// One-time, lossless migration of the v7 single-file cache: v8 changed the
|
||||
// on-disk LAYOUT only, so every section moves across verbatim and nothing
|
||||
// re-parses. Every section is marked dirty so the save below writes each shard;
|
||||
// the v7 file is removed only once that save has published.
|
||||
// One-time, lossless re-layout of the v8 per-provider shard directory: v9
|
||||
// changed the on-disk LAYOUT only, so every entry moves across verbatim (just
|
||||
// re-bucketed by month in memory) and nothing re-parses. The v8 directory is
|
||||
// removed only once the v9 save has published.
|
||||
async function migrateProviderShardCache(): Promise<SessionCache | null> {
|
||||
const dir = join(getCodeburnCacheDir(), PRIOR_SHARD_DIR_NAME)
|
||||
let envelope: { complete?: boolean; shards: Record<string, string> }
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(join(dir, ENVELOPE_FILE), 'utf-8')) as Record<string, unknown>
|
||||
if (parsed['version'] !== 8 || !parsed['shards'] || typeof parsed['shards'] !== 'object') return null
|
||||
envelope = parsed as { complete?: boolean; shards: Record<string, string> }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const cache: SessionCache = { version: CACHE_VERSION, providers: {}, complete: envelope.complete === true }
|
||||
await Promise.all(Object.entries(envelope.shards).map(async ([provider, name]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(join(dir, name), 'utf-8'))
|
||||
if (validateProviderSection(parsed)) cache.providers[provider] = parsed
|
||||
} catch { /* one unreadable v8 shard costs that provider, as it already did */ }
|
||||
}))
|
||||
return publishRelaidCache(cache, () => rm(dir, { recursive: true, force: true }))
|
||||
}
|
||||
|
||||
// One-time, lossless re-layout of the v7 single-file cache. v7 never wrote a
|
||||
// shard directory, so it is migrated straight to v9 without minting a v8 in
|
||||
// between.
|
||||
async function migrateSingleFileCache(): Promise<SessionCache | null> {
|
||||
const v7Path = join(getCodeburnCacheDir(), priorCacheFile(7))
|
||||
let parsed: unknown
|
||||
|
|
@ -669,14 +861,18 @@ async function migrateSingleFileCache(): Promise<SessionCache | null> {
|
|||
return null
|
||||
}
|
||||
if (!validateCache(parsed, 7)) return null
|
||||
const cache: SessionCache = {
|
||||
version: CACHE_VERSION,
|
||||
providers: parsed.providers,
|
||||
complete: parsed.complete === true,
|
||||
}
|
||||
return publishRelaidCache(
|
||||
{ version: CACHE_VERSION, providers: parsed.providers, complete: parsed.complete === true },
|
||||
() => unlink(v7Path),
|
||||
)
|
||||
}
|
||||
|
||||
// Every section is marked dirty so the save writes each month's shard; the old
|
||||
// layout is retired only once that save has published.
|
||||
async function publishRelaidCache(cache: SessionCache, retire: () => Promise<unknown>): Promise<SessionCache> {
|
||||
for (const provider of Object.keys(cache.providers)) markCacheDirty(cache, provider)
|
||||
const published = await saveCache(cache).catch(() => false)
|
||||
if (published) await retryCacheFileMutation(() => unlink(v7Path))
|
||||
if (published) await retryCacheFileMutation(async () => { await retire() })
|
||||
return cache
|
||||
}
|
||||
|
||||
|
|
@ -697,8 +893,8 @@ async function adoptLegacyCache(): Promise<SessionCache> {
|
|||
// the file the currently-published envelope points at: readers keep seeing a
|
||||
// consistent set until the envelope rename publishes the new one, and a writer
|
||||
// that loses the ownership fence leaves the canonical shards untouched.
|
||||
function shardFileName(provider: string): string {
|
||||
return `${provider.replace(/[^A-Za-z0-9_-]/g, '_')}.${randomBytes(8).toString('hex')}.json`
|
||||
function shardFileName(provider: string, bucket: string): string {
|
||||
return `${provider.replace(/[^A-Za-z0-9_-]/g, '_')}.${bucket}.${randomBytes(8).toString('hex')}.json`
|
||||
}
|
||||
|
||||
// The temp name carries a nonce: two processes writing the SAME final path
|
||||
|
|
@ -730,34 +926,94 @@ async function writeFileAtomic(finalPath: string, payload: string): Promise<void
|
|||
}
|
||||
}
|
||||
|
||||
function bucketFiles(section: ProviderSection): Map<string, Record<string, CachedFile>> {
|
||||
const buckets = new Map<string, Record<string, CachedFile>>()
|
||||
for (const [path, file] of Object.entries(section.files)) {
|
||||
const bucket = cacheBucketMonth(file)
|
||||
let group = buckets.get(bucket)
|
||||
if (!group) { group = {}; buckets.set(bucket, group) }
|
||||
group[path] = file
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
function untilMonth(files: Record<string, CachedFile>): string {
|
||||
let until = UNDATED_BUCKET
|
||||
for (const file of Object.values(files)) {
|
||||
const month = newestMonth(file)
|
||||
if (month > until) until = month
|
||||
}
|
||||
return until
|
||||
}
|
||||
|
||||
export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Promise<boolean>): Promise<boolean> {
|
||||
const dir = sessionCacheDir()
|
||||
if (!existsSync(dir)) await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
|
||||
const state = stateOf(cache)
|
||||
const priorShards = state.shards
|
||||
const shards: Record<string, string> = {}
|
||||
const providers: Record<string, EnvelopeProvider> = {}
|
||||
const written: string[] = []
|
||||
// Deferred so the fence check below stays the last thing before publication.
|
||||
const payloads = new Map<string, Record<string, CachedFile>>()
|
||||
|
||||
const writeShard = async (provider: string): Promise<void> => {
|
||||
const name = shardFileName(provider)
|
||||
await writeFileAtomic(join(dir, name), JSON.stringify(cache.providers[provider]))
|
||||
const writeShard = async (provider: string, bucket: string): Promise<ShardRef> => {
|
||||
const files = payloads.get(`${provider}\0${bucket}`)!
|
||||
const name = shardFileName(provider, bucket)
|
||||
await writeFileAtomic(join(dir, name), JSON.stringify(files))
|
||||
written.push(name)
|
||||
shards[provider] = name
|
||||
return { name, until: untilMonth(files) }
|
||||
}
|
||||
|
||||
try {
|
||||
for (const provider of Object.keys(cache.providers)) {
|
||||
const prior = priorShards[provider]
|
||||
// `priorShards` is this process's snapshot from its last load or save.
|
||||
// ANOTHER process may have republished that provider since, unlinking the
|
||||
// file we are about to name — so reuse is conditional on the file still
|
||||
// being there, and a vanished one is rewritten from memory.
|
||||
if (prior && !state.dirtyProviders.has(provider) && existsSync(join(dir, prior))) {
|
||||
shards[provider] = prior
|
||||
continue
|
||||
for (const [provider, section] of Object.entries(cache.providers)) {
|
||||
const priorRefs = state.shards.get(provider) ?? {}
|
||||
const loaded = state.loaded.get(provider) ?? null
|
||||
// A fingerprint change discards the section outright (see
|
||||
// getOrCreateProviderSection), so the months it did not load must be
|
||||
// dropped rather than carried — they hold entries under the old
|
||||
// fingerprint. loadCache never scopes such a provider, so `loaded` is
|
||||
// null here in practice; the guard is what makes that safe to rely on.
|
||||
const priorFingerprint = state.fingerprints.get(provider)
|
||||
const reset = priorFingerprint !== undefined && priorFingerprint !== section.envFingerprint
|
||||
const refs: Record<string, ShardRef> = {}
|
||||
|
||||
for (const [bucket, files] of bucketFiles(section)) {
|
||||
const prior = priorRefs[bucket]
|
||||
// `priorRefs` is this process's snapshot from its last load or save.
|
||||
// ANOTHER process may have republished that shard since, unlinking the
|
||||
// file we are about to name — so reuse is conditional on the file still
|
||||
// being there, and a vanished one is rewritten from memory.
|
||||
if (prior && !isBucketDirty(state, provider, bucket) && existsSync(join(dir, prior.name))) {
|
||||
refs[bucket] = prior
|
||||
continue
|
||||
}
|
||||
let payload = files
|
||||
// Dirty but never loaded: memory holds only the entries this run wrote
|
||||
// into the bucket, so the shard's other entries have to be merged back
|
||||
// in or the save would silently drop them. Nothing in an unloaded
|
||||
// bucket can have been deleted — every delete goes through
|
||||
// `section.files`, which only holds what was loaded.
|
||||
if (loaded && !loaded.has(bucket) && prior) {
|
||||
const onDisk = await loadShard(join(dir, prior.name))
|
||||
if (onDisk) payload = { ...onDisk, ...files }
|
||||
}
|
||||
payloads.set(`${provider}\0${bucket}`, payload)
|
||||
refs[bucket] = await writeShard(provider, bucket)
|
||||
}
|
||||
|
||||
// Months this run never loaded keep their published shard verbatim. This
|
||||
// is the invariant that makes a scoped load safe to save from.
|
||||
if (loaded && !reset) {
|
||||
for (const [bucket, ref] of Object.entries(priorRefs)) {
|
||||
if (refs[bucket] || loaded.has(bucket)) continue
|
||||
if (existsSync(join(dir, ref.name))) refs[bucket] = ref
|
||||
}
|
||||
}
|
||||
providers[provider] = {
|
||||
envFingerprint: section.envFingerprint,
|
||||
...(section.durable ? { durable: true } : {}),
|
||||
shards: refs,
|
||||
}
|
||||
await writeShard(provider)
|
||||
}
|
||||
|
||||
// The warm refresh transaction passes an ownership fence. It must be the
|
||||
|
|
@ -771,35 +1027,61 @@ export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Pr
|
|||
}
|
||||
|
||||
// Last look before publishing: a concurrent save may have unlinked a reused
|
||||
// shard while this one was writing its own. An envelope must never name a
|
||||
// file that is already gone — that reads back as a corrupt provider and
|
||||
// drops its history, including orphans no re-parse can recover.
|
||||
for (const [provider, name] of Object.entries(shards)) {
|
||||
if (written.includes(name) || existsSync(join(dir, name))) continue
|
||||
await writeShard(provider)
|
||||
// or carried shard while this one was writing its own. An envelope must
|
||||
// never name a file that is already gone — that reads back as a corrupt
|
||||
// month and drops its history, including orphans no re-parse can recover.
|
||||
for (const [provider, meta] of Object.entries(providers)) {
|
||||
for (const [bucket, ref] of Object.entries(meta.shards)) {
|
||||
if (written.includes(ref.name) || existsSync(join(dir, ref.name))) continue
|
||||
if (!payloads.has(`${provider}\0${bucket}`)) {
|
||||
// A carried month whose file vanished: its content was never in
|
||||
// memory, so there is nothing to rewrite. Dropping the reference is
|
||||
// the only honest option, and the sweep retires the name.
|
||||
delete meta.shards[bucket]
|
||||
continue
|
||||
}
|
||||
meta.shards[bucket] = await writeShard(provider, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
const envelope: CacheEnvelope = {
|
||||
version: CACHE_VERSION,
|
||||
complete: cache.complete === true,
|
||||
nonce: randomBytes(8).toString('hex'),
|
||||
shards,
|
||||
providers,
|
||||
}
|
||||
await writeFileAtomic(join(dir, ENVELOPE_FILE), JSON.stringify(envelope))
|
||||
|
||||
state.dirty = false
|
||||
state.dirtyProviders.clear()
|
||||
state.shards = shards
|
||||
// Write-through: the object just published IS the freshest state, so the
|
||||
// next loadCache in this process reuses it instead of re-parsing.
|
||||
cacheMemo = { dir, nonce: envelope.nonce, cache }
|
||||
// Shards the new envelope no longer references are garbage; a reader that
|
||||
// already opened one keeps reading it, and any failure here is swept later
|
||||
// by cleanupOrphanedTempFiles.
|
||||
for (const [provider, name] of Object.entries(priorShards)) {
|
||||
if (shards[provider] === name) continue
|
||||
await retryCacheFileMutation(() => unlink(join(dir, name)))
|
||||
const retired: string[] = []
|
||||
for (const [provider, priorRefs] of state.shards) {
|
||||
const kept = providers[provider]?.shards ?? {}
|
||||
for (const [bucket, ref] of Object.entries(priorRefs)) {
|
||||
if (kept[bucket]?.name !== ref.name) retired.push(ref.name)
|
||||
}
|
||||
}
|
||||
|
||||
state.dirty = false
|
||||
state.dirtyBuckets.clear()
|
||||
state.shards.clear()
|
||||
state.fingerprints.clear()
|
||||
state.bucketOf.clear()
|
||||
// `loaded` deliberately survives: a merged-and-rewritten shard is complete
|
||||
// on disk but still partial in memory, so the next save has to merge again.
|
||||
for (const [provider, meta] of Object.entries(providers)) {
|
||||
state.shards.set(provider, meta.shards)
|
||||
state.fingerprints.set(provider, meta.envFingerprint)
|
||||
for (const [path, file] of Object.entries(cache.providers[provider]!.files)) {
|
||||
state.bucketOf.set(`${provider}\0${path}`, cacheBucketMonth(file))
|
||||
}
|
||||
}
|
||||
// Write-through: the object just published IS the freshest state, so the
|
||||
// next loadCache in this process reuses it instead of re-parsing. Its scope
|
||||
// is whatever was loaded, not `all` — a save never widens what is in memory.
|
||||
cacheMemo = { dir, nonce: envelope.nonce, scope: state.scope, cache }
|
||||
for (const name of retired) await retryCacheFileMutation(() => unlink(join(dir, name)))
|
||||
return true
|
||||
} catch (err) {
|
||||
for (const name of written) await retryCacheFileMutation(() => unlink(join(dir, name)))
|
||||
|
|
@ -988,7 +1270,9 @@ export async function cleanupOrphanedTempFiles(): Promise<void> {
|
|||
try {
|
||||
const parsed = JSON.parse(await readFile(join(dir, ENVELOPE_FILE), 'utf-8'))
|
||||
if (isEnvelope(parsed)) {
|
||||
for (const name of Object.values(parsed.shards)) referenced.add(name)
|
||||
for (const meta of Object.values(parsed.providers)) {
|
||||
for (const ref of Object.values(meta.shards)) referenced.add(ref.name)
|
||||
}
|
||||
envelopeRead = true
|
||||
}
|
||||
} catch {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue