/** * Process-local L1 tier for immutable, release-scoped file cache entries. * * `FileCache` reaches the distributed backend once per key per request, so an * immutable release asset costs one HTTP round trip every time any request * touches it. This module holds those values in process between requests. * * It sits in front of `ApiCacheBackend`'s per-request credential gate, and that * gate only establishes that a token is PRESENT. Validity is decided server * side by the very call an L1 hit skips. So an L1 hit is served without * revalidation, and no keying scheme changes that. Four bounds contain it: * * 1. only immutable release-scoped keys are admitted, never branch-scoped ones * 2. entries are scoped on the credential identity and project reference the * backend read would have used, so no entry crosses a project or a credential * 3. a short TTL bounds how long a revoked credential can keep reading, and how * far a publish on another pod can lag behind * 4. entry-count, per-value and total-byte ceilings bound what the tier can * cost the process, because the values it holds are file CONTENT * * @module cache/immutable-l1 */ import { type ResolvedCacheAuthority } from "./request-authority.js"; /** * How long an admitted entry may be served without the backend being consulted * again. * * This is a security bound, not a performance tuning value, and it bounds two * separate things at once. Raising it widens both linearly. * * 1. An L1 hit performs no server-side authorization, so this is the upper * bound on how long a credential revoked mid-flight can keep reading * release assets of a project it was already authorized for. * 2. `file:release:` keys are immutable by construction but not immutable in * operation: a publish poke wipes the whole `file:release:` prefix * (`platform/adapters/fs/veryfront/websocket-manager.ts`). That wipe drops * the L1 entries of the pod that received the poke, and nothing else. Every * other pod keeps serving its warm entries until they expire, so this is * also the upper bound on cross-pod publish-invalidation lag: raising it * delays how long a publish takes to become visible on pods that did not * handle the poke. */ export declare const IMMUTABLE_L1_DEFAULT_TTL_MS = 5000; /** * Hard upper bound on the configured entry lifetime, applied to * `IMMUTABLE_L1_TTL_ENV_VAR` after parsing. * * The TTL is not a performance knob that only costs staleness when it is set * too high. It is the width of two separate windows at once, and a clamp is * what keeps a typo from widening either of them without bound: * * 1. CREDENTIAL REVOCATION. An L1 hit is served with no server-side * authorization, so a credential revoked mid-flight keeps reading release * assets of a project it was already authorized for for up to this long. * 2. CROSS-POD PUBLISH VISIBILITY. A publish poke drops the `file:release:` * entries of the pod that received it and of no other, so every other pod * keeps serving warm entries until they expire. This is the upper bound on * how long a publish stays invisible on pods that did not handle the poke. * * Without a clamp, `VERYFRONT_FILE_CACHE_L1_TTL_MS=5000000` parses cleanly and * silently buys 83 minutes of BOTH windows in place of the intended 5 seconds. * 60 seconds is the outer edge at which both remain defensible operationally: * it is twelve times the default, so it leaves real room to trade round trips * for staleness, while keeping revocation lag and publish lag inside the minute * an operator would already tolerate from a rolling restart. */ export declare const IMMUTABLE_L1_MAX_TTL_MS = 60000; /** Entry-count ceiling, so the store cannot grow without limit. */ export declare const IMMUTABLE_L1_DEFAULT_MAX_ENTRIES = 2000; /** * Per-value ceiling. A value larger than this is never admitted. * * The entry-count ceiling alone bounds nothing that matters here, because the * values are file CONTENT and a single release asset can be arbitrarily large: * 2000 entries of a 64 MB asset is 128 GB. A per-value ceiling is the half of * the bound that keeps one large asset from displacing the entire working set * this tier exists to hold, and it is checked BEFORE insertion so an oversized * value is never materialized into the store at all. * * 512 KiB is well above the source and asset files whose per-request round trip * this tier is meant to remove, and far below the size at which one entry would * dominate the process. */ export declare const IMMUTABLE_L1_DEFAULT_MAX_VALUE_BYTES: number; /** * Total-bytes ceiling across every scope, enforced by evicting in LRU order * until the store is back under it. * * This is the half of the bound that caps the tier's worst case outright, so a * project with many mid-sized release assets cannot exhaust the process by * staying under the per-value ceiling 2000 times over. 64 MiB is the retained * worst case with these defaults. */ export declare const IMMUTABLE_L1_DEFAULT_MAX_TOTAL_BYTES: number; /** * Overrides the TTL above. Set to `0` to disable the tier outright. Values * above `IMMUTABLE_L1_MAX_TTL_MS` are clamped to it, with a warning. */ export declare const IMMUTABLE_L1_TTL_ENV_VAR = "VERYFRONT_FILE_CACHE_L1_TTL_MS"; /** Overrides the entry-count ceiling above. Set to `0` to admit nothing. */ export declare const IMMUTABLE_L1_MAX_ENTRIES_ENV_VAR = "VERYFRONT_FILE_CACHE_L1_MAX_ENTRIES"; /** Overrides the per-value ceiling above. Set to `0` to admit nothing. */ export declare const IMMUTABLE_L1_MAX_VALUE_BYTES_ENV_VAR = "VERYFRONT_FILE_CACHE_L1_MAX_VALUE_BYTES"; /** Overrides the total-bytes ceiling above. Set to `0` to admit nothing. */ export declare const IMMUTABLE_L1_MAX_TOTAL_BYTES_ENV_VAR = "VERYFRONT_FILE_CACHE_L1_MAX_TOTAL_BYTES"; /** * True only for a concrete immutable release-scoped file cache key. * * Anchored on the literal prefix rather than scanning for a `release` segment * anywhere, because a project slug or a file path containing `release` must not * be able to qualify a mutable key. Every other shape is refused: branch keys, * `env` keys, `stat`/`dir`/`files` keys, the `file:unknown` no-context * fallback, prefixes with no path, and anything unrecognized. * * Known imprecision, deliberately left as is. `buildFileOperationPrefix` * (`cache/keys/builders/file.ts`) interpolates `projectSlug` raw while it URI * encodes the qualifier, so a slug containing a colon shifts the segment * boundaries: slug `a:b` makes the path-less prefix `file:release:a:b:rel_1` * read as slug `a`, release `b`, path `rel_1` and pass. That is a false * positive on a PREFIX, and prefixes only ever reach `deleteByPrefix`, never * `getAsync`, so nothing is served from it. Encoding the slug would change the * shape of every live file cache key and every invalidation prefix, which is a * far larger change than the imprecision warrants; it is recorded here instead. */ export declare function isImmutableReleaseFileCacheKey(key: string): boolean; /** * The authority an entry may be held under, or `null` when the store must not * be used for this read. * * A project reference is always required, so an entry can never be handed to * another project. For the API backend a token is required as well, mirroring * `ApiCacheBackend`'s gate: a read the backend would refuse for want of a * credential must not be answered from process memory instead. The credential * identity is folded into the scope so two credentials never share an entry. */ export declare function buildImmutableL1Scope(backendType: string, authority: ResolvedCacheAuthority): string | null; /** * `buildImmutableL1Scope` for the authority the current request resolves to. * * `authority` must be the backend's OWN resolution when it has one, because a * backend constructed with an explicit endpoint credential reads under that * credential rather than under the ambient one. Re-deriving it here without * that credential would scope entries on a token the read never used, which is * exactly the drift `cache/request-authority.ts` exists to prevent. */ export declare function resolveImmutableL1Scope(backendType: string, authority?: ResolvedCacheAuthority): string | null; /** Resolve an optional tier scope without making its context a backend dependency. */ export declare function resolveOptionalImmutableL1Scope(backendType: string, resolveAuthority: () => ResolvedCacheAuthority | undefined): string | null; /** * Taken before a backend read starts and handed back to `admit`. Any * invalidation touching that key in between makes the fetched value * unadmissible, so a read already in flight cannot reinstate what was just * invalidated. * * `startedAtElapsedMs` records when the backend read began on a monotonic * elapsed-time clock, and it is the moment every lifetime comparison is * measured from. The TTL is a bound on how stale a served value can be * relative to a revocation or a publish, and both can land while the read is * still in flight, so the entry's lifetime is measured from this moment rather * than from when the response arrived. A read slow enough to consume the whole * TTL admits nothing. The TTL is a security bound, and a wall clock stepped * backward by NTP or a manual adjustment must not widen it, so * `startedAtWallClockMs` is retained separately, only for comparing the * reader's time with a backend entry's Unix timestamp. */ export interface ImmutableL1ReadToken { readonly key: number; readonly sweep: number; readonly startedAtElapsedMs: number; readonly startedAtWallClockMs: number; } export interface ImmutableFileCacheL1 { readonly size: number; /** Entry-count ceiling in force, so profiler stats can report the bound. */ readonly maxEntries: number; /** Bytes currently charged against the total-bytes ceiling. */ readonly retainedBytes: number; beginRead(cacheKey: string): ImmutableL1ReadToken; /** * `maxAgeMs` is the CALLER's entry lifetime. The store is process-global * while lifetimes are configured per `FileCache` instance, so an entry is * served only while it is younger than both the lifetime it was admitted * with and the lifetime of the instance reading it, each measured on the * store's monotonic clock from the backend read start the entry's token * recorded. A non-finite `maxAgeMs` is ignored and the admission-time * expiry alone governs. */ lookup(scope: string, cacheKey: string, maxAgeMs?: number): string | null; /** * A `ttlMs` at or below zero, or not finite, admits nothing. Expiry is * anchored to the token's read start, so a read that was in flight long * enough to consume the whole TTL admits nothing either. */ admit(scope: string, cacheKey: string, value: string, token: ImmutableL1ReadToken, ttlMs: number): void; /** Drops the key under every scope holding it, touching only those entries. */ dropKey(cacheKey: string): void; /** * Drops every entry whose cache key starts with `prefix`. Cost is bounded by * the number of distinct prefix buckets plus the entries actually dropped, * not by the size of the store. */ dropPrefix(prefix: string): void; /** Reclaims every expired entry now rather than when it is next touched. */ evictExpired(): number; clear(): void; } /** * Configured entry lifetime; `0` disables the tier. * * Clamped to `IMMUTABLE_L1_MAX_TTL_MS`, because this value is the width of the * credential-revocation window and of the cross-pod publish-visibility window, * not a staleness preference. See that constant for both. */ export declare function resolveImmutableL1TtlMs(readEnv?: (name: string) => string | undefined): number; /** Configured entry-count ceiling. */ export declare function resolveImmutableL1MaxEntries(readEnv?: (name: string) => string | undefined): number; /** Configured per-value ceiling in bytes. */ export declare function resolveImmutableL1MaxValueBytes(readEnv?: (name: string) => string | undefined): number; /** Configured total-bytes ceiling across every scope. */ export declare function resolveImmutableL1MaxTotalBytes(readEnv?: (name: string) => string | undefined): number; export interface ImmutableFileCacheL1Options { maxEntries?: number; maxValueBytes?: number; maxTotalBytes?: number; /** Monotonic elapsed-time source, injectable for deterministic tests. */ elapsedNow?: () => number; /** Unix millisecond source used only for backend timestamp comparison. */ wallClockNow?: () => number; } /** * Create the store. One instance is shared by every `FileCache` in the process; * entries are separated by scope rather than by instance. */ export declare function createImmutableFileCacheL1(options?: ImmutableFileCacheL1Options): ImmutableFileCacheL1; //# sourceMappingURL=immutable-l1.d.ts.map