Initial Commit
This commit is contained in:
78
packages/persistence/storage/storage-uri-plugin-bundle/src/cache/cache.md
vendored
Normal file
78
packages/persistence/storage/storage-uri-plugin-bundle/src/cache/cache.md
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
# UriLruCache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`cache.ts` declares the optional in-memory LRU used by the storage shim
|
||||
when a caller passes `options.cache = { maxBytes }`. The cache is keyed
|
||||
by the full URI string, holds raw `Uint8Array` bodies, and evicts oldest
|
||||
entries first until the cumulative byte total is at or below the configured
|
||||
ceiling. The implementation is intentionally minimal — just enough surface
|
||||
for the `get` / `set` / `has` triad the shim needs.
|
||||
|
||||
The shim defaults to OFF. When the LRU is enabled, every successful `get`
|
||||
result is inserted; subsequent `get`s of the same URI return the cached
|
||||
bytes without invoking the injected `FetchLike`. Writes are not relevant
|
||||
(bundles are immutable, every write throws `READ_ONLY` upstream of the
|
||||
cache).
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `get`/`set`/`has` via the underlying `Map`.
|
||||
- LRU recency refresh on `get` (delete + re-insert at tail).
|
||||
- Byte-bounded eviction policy — oldest-first until
|
||||
`currentBytes <= maxBytes`.
|
||||
- Single-entry oversize is rejected silently (the entry is not inserted
|
||||
and no eviction loop fires) so a runaway 500 MB payload cannot flush
|
||||
the entire cache.
|
||||
- `bytes` / `size` accessors for diagnostic and test inspection.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { UriLruCache } from "@suaveplan/storage-uri-plugin-bundle";
|
||||
|
||||
const cache = new UriLruCache(1024);
|
||||
cache.set("bundle://x/a", new Uint8Array(512));
|
||||
cache.set("bundle://x/b", new Uint8Array(512));
|
||||
cache.get("bundle://x/a"); // → Uint8Array (and refresh recency)
|
||||
cache.set("bundle://x/c", new Uint8Array(512));
|
||||
cache.has("bundle://x/b"); // → false (evicted as oldest)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { UriLruCache } from "@suaveplan/storage-uri-plugin-bundle";
|
||||
|
||||
const cache = new UriLruCache(100);
|
||||
cache.set("k1", new Uint8Array(50));
|
||||
cache.set("k2", new Uint8Array(50));
|
||||
cache.set("k3", new Uint8Array(50));
|
||||
console.log(cache.size, cache.bytes); // → 2 100
|
||||
console.log(cache.has("k1")); // → false (oldest evicted)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new UriLruCache(maxBytes)` — construct an empty cache with the
|
||||
byte ceiling.
|
||||
- `get(key): Uint8Array | undefined` — refresh recency and return.
|
||||
- `set(key, value): void` — insert and evict to satisfy the ceiling.
|
||||
- `has(key): boolean` — non-mutating presence check.
|
||||
- `size: number` — entry count.
|
||||
- `bytes: number` — cumulative byte total.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class delegates everything to a plain `Map`. JavaScript `Map`
|
||||
preserves insertion order, so `map.keys().next().value` returns the
|
||||
oldest key — exactly the LRU candidate. The recency refresh on `get`
|
||||
is a delete + re-insert; the cost is O(1). Cached buffers are shared
|
||||
references — callers must NOT mutate a returned `Uint8Array` in place
|
||||
or future cache hits will surface the mutation. The shim guarantees
|
||||
immutability by treating cache results as read-only, which is the same
|
||||
invariant the entire `bundle://` contract enforces upstream.
|
||||
167
packages/persistence/storage/storage-uri-plugin-bundle/src/cache/cache.test.ts
vendored
Normal file
167
packages/persistence/storage/storage-uri-plugin-bundle/src/cache/cache.test.ts
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { bundleUriPlugin } from "../factory/factory.js";
|
||||
import {
|
||||
getInstruments,
|
||||
resetInstruments,
|
||||
setBundleUriMeterProvider,
|
||||
} from "../otel/otel.js";
|
||||
import { UriLruCache } from "./cache.js";
|
||||
|
||||
describe("UriLruCache (unit)", () => {
|
||||
it("returns undefined for missing keys", { timeout: 5000 }, () => {
|
||||
const cache = new UriLruCache(1024);
|
||||
expect(cache.get("missing")).toBeUndefined();
|
||||
expect(cache.has("missing")).toBe(false);
|
||||
expect(cache.size).toBe(0);
|
||||
expect(cache.bytes).toBe(0);
|
||||
});
|
||||
|
||||
it("stores and retrieves bytes", { timeout: 5000 }, () => {
|
||||
const cache = new UriLruCache(1024);
|
||||
const v = new Uint8Array([1, 2, 3]);
|
||||
cache.set("k", v);
|
||||
expect(cache.get("k")).toBe(v);
|
||||
expect(cache.bytes).toBe(3);
|
||||
expect(cache.size).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes recency on get (delete-then-reinsert)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new UriLruCache(100);
|
||||
cache.set("a", new Uint8Array(30));
|
||||
cache.set("b", new Uint8Array(30));
|
||||
// a is older, b is newer; reading a makes it newest.
|
||||
cache.get("a");
|
||||
cache.set("c", new Uint8Array(50));
|
||||
// Total = 30+30+50 = 110 > 100, so oldest (b) evicted.
|
||||
expect(cache.has("a")).toBe(true);
|
||||
expect(cache.has("b")).toBe(false);
|
||||
expect(cache.has("c")).toBe(true);
|
||||
});
|
||||
|
||||
it("LRU evicts oldest when maxBytes exceeded", { timeout: 5000 }, () => {
|
||||
const cache = new UriLruCache(100);
|
||||
cache.set("a", new Uint8Array(50));
|
||||
cache.set("b", new Uint8Array(50));
|
||||
cache.set("c", new Uint8Array(50));
|
||||
expect(cache.has("a")).toBe(false);
|
||||
expect(cache.has("b")).toBe(true);
|
||||
expect(cache.has("c")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects single-entry oversize without evicting world", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new UriLruCache(100);
|
||||
cache.set("small", new Uint8Array(50));
|
||||
cache.set("huge", new Uint8Array(500));
|
||||
expect(cache.has("small")).toBe(true);
|
||||
expect(cache.has("huge")).toBe(false);
|
||||
});
|
||||
|
||||
it("re-inserting same key updates bytes correctly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new UriLruCache(100);
|
||||
cache.set("a", new Uint8Array(40));
|
||||
cache.set("a", new Uint8Array(60));
|
||||
expect(cache.bytes).toBe(60);
|
||||
expect(cache.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache integration with shim", () => {
|
||||
it("cache off by default does not hit", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let invoked = 0;
|
||||
const fetchFn: typeof fetch = async () =>
|
||||
new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
||||
const plugin = bundleUriPlugin({
|
||||
packageSlug: "x",
|
||||
rootUrl: "https://example/x/",
|
||||
fetch: () => {
|
||||
invoked++;
|
||||
return fetchFn("x");
|
||||
},
|
||||
});
|
||||
await plugin.adapter.get("k");
|
||||
await plugin.adapter.get("k");
|
||||
expect(invoked).toBe(2);
|
||||
});
|
||||
|
||||
it("cache hit returns cached bytes without fetch", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const hits: Array<Record<string, unknown> | undefined> = [];
|
||||
setBundleUriMeterProvider({
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) => ({
|
||||
add: (_: number, attrs?: Record<string, unknown>) => {
|
||||
if (name === "bundle_uri.cache_hits_total") {
|
||||
hits.push(attrs);
|
||||
}
|
||||
},
|
||||
}),
|
||||
createHistogram: () => ({ record: () => undefined }),
|
||||
}),
|
||||
} as unknown as Parameters<typeof setBundleUriMeterProvider>[0]);
|
||||
resetInstruments();
|
||||
getInstruments(true);
|
||||
|
||||
let invoked = 0;
|
||||
const fetchFn: typeof fetch = async () => {
|
||||
invoked++;
|
||||
return new Response(new Uint8Array([7, 8, 9]), { status: 200 });
|
||||
};
|
||||
const plugin = bundleUriPlugin({
|
||||
packageSlug: "x",
|
||||
rootUrl: "https://example/x/",
|
||||
fetch: fetchFn,
|
||||
cache: { maxBytes: 1024 },
|
||||
});
|
||||
const a = await plugin.adapter.get("k");
|
||||
const b = await plugin.adapter.get("k");
|
||||
expect(invoked).toBe(1);
|
||||
expect(Array.from(a ?? [])).toEqual([7, 8, 9]);
|
||||
expect(Array.from(b ?? [])).toEqual([7, 8, 9]);
|
||||
expect(hits.length).toBeGreaterThanOrEqual(1);
|
||||
expect(hits[0]?.["root_kind"]).toBe("http");
|
||||
|
||||
setBundleUriMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("LRU evicts older entries when maxBytes exceeded", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let invoked = 0;
|
||||
const responses = new Map<string, Uint8Array>([
|
||||
["k1", new Uint8Array(50)],
|
||||
["k2", new Uint8Array(50)],
|
||||
["k3", new Uint8Array(50)],
|
||||
]);
|
||||
const fetchFn: typeof fetch = async (input) => {
|
||||
invoked++;
|
||||
const u = input instanceof URL ? input : new URL(String(input));
|
||||
const key = u.pathname.slice(1);
|
||||
const body = responses.get(key);
|
||||
return new Response(body, { status: 200 });
|
||||
};
|
||||
const plugin = bundleUriPlugin({
|
||||
packageSlug: "x",
|
||||
rootUrl: "https://example/",
|
||||
fetch: fetchFn,
|
||||
cache: { maxBytes: 100 },
|
||||
});
|
||||
await plugin.adapter.get("k1");
|
||||
await plugin.adapter.get("k2");
|
||||
await plugin.adapter.get("k3"); // evicts k1
|
||||
expect(invoked).toBe(3);
|
||||
await plugin.adapter.get("k1"); // miss again
|
||||
expect(invoked).toBe(4);
|
||||
await plugin.adapter.get("k3"); // hit
|
||||
expect(invoked).toBe(4);
|
||||
});
|
||||
});
|
||||
61
packages/persistence/storage/storage-uri-plugin-bundle/src/cache/cache.ts
vendored
Normal file
61
packages/persistence/storage/storage-uri-plugin-bundle/src/cache/cache.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* In-memory LRU cache keyed by full URI. Off by default; instantiated
|
||||
* by the storage shim when `options.cache.maxBytes` is supplied.
|
||||
*
|
||||
* The implementation is a plain `Map`-backed LRU: insertion order is
|
||||
* the LRU order, `get` deletes-and-reinserts to refresh recency, and
|
||||
* `set` evicts oldest entries until `totalBytes <= maxBytes`. Single-
|
||||
* entry oversize is rejected (the entry is not inserted, no eviction
|
||||
* loop runs forever).
|
||||
*/
|
||||
export class UriLruCache {
|
||||
private readonly map = new Map<string, Uint8Array>();
|
||||
private currentBytes = 0;
|
||||
|
||||
public constructor(private readonly maxBytes: number) {}
|
||||
|
||||
public get(key: string): Uint8Array | undefined {
|
||||
const value = this.map.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
// Refresh recency: re-insert at the tail.
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
public set(key: string, value: Uint8Array): void {
|
||||
const size = value.byteLength;
|
||||
// Single-entry oversize — do not insert, do not evict the world.
|
||||
if (size > this.maxBytes) return;
|
||||
const prev = this.map.get(key);
|
||||
if (prev !== undefined) {
|
||||
this.currentBytes -= prev.byteLength;
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
this.currentBytes += size;
|
||||
while (this.currentBytes > this.maxBytes) {
|
||||
// Invariant: currentBytes > 0 implies map.size > 0, so the
|
||||
// entries iterator yields a defined entry. Cast is therefore
|
||||
// safe and matches the runtime invariant.
|
||||
const [oldestKey, oldestVal] = this.map.entries().next().value as [
|
||||
string,
|
||||
Uint8Array,
|
||||
];
|
||||
this.map.delete(oldestKey);
|
||||
this.currentBytes -= oldestVal.byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
public has(key: string): boolean {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
public get bytes(): number {
|
||||
return this.currentBytes;
|
||||
}
|
||||
}
|
||||
1
packages/persistence/storage/storage-uri-plugin-bundle/src/cache/index.ts
vendored
Normal file
1
packages/persistence/storage/storage-uri-plugin-bundle/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { UriLruCache } from "./cache.js";
|
||||
Reference in New Issue
Block a user