Files
suaveplan/openspec/archive/2026-05-16-add-drive-plugin-cache/design.md
2026-05-27 23:29:53 +01:00

271 lines
11 KiB
Markdown

# Design — @suaveplan/drive-plugin-cache
## Tier and dependency posture
Universal tier. Zero `node:` / `bun:` imports in `src/`. Runtime deps:
- `@suaveplan/error` — `defineCodes`, `SuaveplanError`
- `@suaveplan/drive` — `DrivePlugin`, `DriveMiddleware`, `MiddlewareContext`, `MiddlewareNext`, `DriveLike` (type-only)
- `@suaveplan/cache` — `AbstractCache<T>` (type-only consumption — the consumer passes a concrete instance)
- `@suaveplan/types` — `LoggerLike` (optional injectable)
- `@opentelemetry/api` — meter + tracer
- `tslib`
`@suaveplan/cache-storage`, `cache-redis`, `storage-file`, `storage-browser-*` are **NOT** runtime deps. The consumer wires their preferred cache backend before passing the `AbstractCache<Uint8Array>` instance into `driveCachePlugin(options)`. Same posture as `drive-uri` — the plugin sees an interface, not a concrete provider.
DevDeps: `@suaveplan/testing`, `@suaveplan/storage`, `@suaveplan/storage-file`, `@suaveplan/cache-storage`, `@vitest/coverage-istanbul`, `fast-check`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`. The cache-storage + storage-file pair is needed only for the integration test that exercises a real disk-backed cache surviving plugin reinstall.
## File layout
```text
packages/drive/drive-plugin-cache/
package.json
README.md
src/
index.ts # explicit named re-exports (rule 10)
error-codes/
error-codes.ts # defineCodes("DRIVE_CACHE", [...]), DriveCacheError
error-codes.test.ts
error-codes.md
index.ts
types/
options.types.ts # DriveCacheOptions, DriveCacheHandle, DriveCacheStats
options.types.md
index.ts
handle/
handle.ts # DriveCacheHandle implementation backing the factory's install()
handle.test.ts
handle.md
index.ts
middleware/
middleware.ts # The DriveMiddleware (read intercept)
middleware.invalidation.ts # write/delete invalidation hooks
middleware.signal.ts # AbortSignal short-circuit helpers
middleware.test.ts
middleware.invalidation.test.ts
middleware.signal.test.ts
middleware.md
middleware.invalidation.md
middleware.signal.md
index.ts
plugin/
plugin.ts # driveCachePlugin(options): DrivePlugin<DriveCacheHandle>
plugin.test.ts
plugin.md
index.ts
otel/
otel.ts # meter + tracer wiring, recordOp(...)
otel.test.ts
otel.md
index.ts
tests/
setup/polyfills.ts
integration/
with-memory-cache.test.ts # real Drive + real MemoryCache
with-storage-cache.test.ts # real Drive + real StorageCache over storage-file
with-namespaced-caches.test.ts # two plugin instances, distinct namespaces, one shared cache
tsconfig.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
```
Per AGENTS §10: `handle/`, `middleware/`, `plugin/` ancestors with un-suffixed children (`middleware.ts`, not `middleware-impl.ts`). One feature per folder. Every implementation `.ts` (excluding `*.test.ts`, `*.types.ts` inside feature dirs, `index.ts` barrels) has a sibling `.md`.
## Plugin shape
```ts
export interface DriveCacheOptions {
cache: AbstractCache<Uint8Array>;
invalidateOnWrite?: boolean; // default true
invalidateOnDelete?: boolean; // default true
namespace?: string; // default "drive"
shouldCache?: (path: string, bytes: Uint8Array) => boolean;
maxEntryBytes?: number; // default 16 * 1024 * 1024
logger?: LoggerLike;
}
export interface DriveCacheHandle {
readonly cache: AbstractCache<Uint8Array>;
getStats(): DriveCacheStats;
invalidate(path: string): Promise<void>;
clear(): Promise<void>;
}
export interface DriveCacheStats {
readonly hits: number;
readonly misses: number;
readonly populations: number;
readonly invalidations: number;
readonly errors: number;
}
export function driveCachePlugin(
options: DriveCacheOptions
): DrivePlugin<DriveCacheHandle>;
```
```ts
// usage
const cache = createCache("memory", { policy: "lru", maxItems: 10_000 });
const drive = new Drive({ /* … */ });
await drive.usePlugin(driveCachePlugin({ cache, namespace: "drive-a" }));
const bytes1 = await drive.read("clip.mov"); // miss → next() → populate
const bytes2 = await drive.read("clip.mov"); // hit → return cached
```
## Read middleware
```ts
async read(ctx, next) {
const key = `${namespace}::${ctx.path}`;
if (ctx.signal?.aborted) return next(); // short-circuit; let next() raise DRIVE.ABORTED
let cached: Uint8Array | undefined;
try {
cached = await cache.get(key);
} catch (err) {
stats.errors += 1;
recordError("get", err);
// Fall through — a get failure is not a correctness boundary.
}
if (cached !== undefined) {
stats.hits += 1;
recordHit(key);
return cached;
}
stats.misses += 1;
recordMiss(key);
const bytes = await next();
if (shouldCachePredicate(ctx.path, bytes)) {
try {
await cache.set(key, bytes);
stats.populations += 1;
recordPopulate(key, bytes.byteLength);
} catch (err) {
stats.errors += 1;
recordError("set", err);
// Swallow — cache write failure must not propagate.
}
}
return bytes;
}
```
The error-swallowing on `cache.get` / `cache.set` / `cache.delete` is the core correctness rule: **a cache is a perf optimisation, not a correctness boundary.** A failed cache call must not turn a successful drive read into a thrown error.
## Invalidation middleware
```ts
async write(ctx, next) {
await next(); // write succeeds first
if (invalidateOnWrite) await invalidate(ctx.path);
}
async delete(ctx, next) {
const removed = await next();
if (removed && invalidateOnDelete) await invalidate(ctx.path);
return removed;
}
async invalidate(path: string) {
try {
await cache.delete(`${namespace}::${path}`);
stats.invalidations += 1;
recordInvalidate(path);
} catch (err) {
stats.errors += 1;
recordError("delete", err);
}
}
```
Post-success invalidation is deliberate. Optimistic (pre-`next()`) invalidation opens a race window where a concurrent read between the `cache.delete` and the storage write repopulates the cache with the *old* bytes that the write was about to replace.
## `shouldCache` default
```ts
const DEFAULT_MAX_ENTRY_BYTES = 16 * 1024 * 1024; // 16 MiB
function defaultShouldCache(path: string, bytes: Uint8Array): boolean {
return bytes.byteLength <= DEFAULT_MAX_ENTRY_BYTES;
}
```
16 MiB matches `drive-mesh-protocol`'s `MAX_FRAME_BYTES`. Empirically safe in every consumer already running drive. Bypass by passing a custom `shouldCache` for use cases that intentionally cache larger blobs.
## OTel surface
Tracer + meter: `@suaveplan/drive-cache`.
Histograms:
- `drive_cache.entry_bytes{namespace}` — distribution of cached entry sizes. Sampled on `populations_total` increments.
Counters:
- `drive_cache.hits_total{namespace}`
- `drive_cache.misses_total{namespace}`
- `drive_cache.populations_total{namespace}`
- `drive_cache.invalidations_total{namespace}`
- `drive_cache.errors_total{namespace, op}` — `op ∈ get | set | delete`
Spans: one per middleware-intercept, attributes `drive.cache.namespace`, `drive.cache.path`, `drive.cache.outcome ∈ hit | miss | populated | invalidated | error`. Paths are not URLs so no query-string redaction is needed; the path *is* the discriminant operators want to see.
## Cache key shape
`${namespace}::${path}` — the `::` separator avoids accidental collision with paths that contain `/`. Namespace defaults to `"drive"`; multi-drive consumers pass distinct namespaces and share one `AbstractCache` instance with no cross-talk.
A future iteration may switch to a typed key (`{ namespace, path }`) once we have a use case that needs to scan by namespace; today the flat string is simplest and matches how `AbstractCache.delete` / `wrap` work.
## Trade-offs accepted
- **No `stat` / `list` caching.** See proposal Out of Scope. The list-invalidation problem is genuinely hard and orthogonal; this iteration owns reads only.
- **No streaming-read interception.** `Drive.createReadStream(path)` bypasses middleware entirely (drive's design choice). Cached streaming belongs at the URI layer where the resolver owns the streaming surface; the planned `cacheUriPlugin` can wrap `drive://` URIs and cache stream output.
- **One `AbstractCache` per plugin instance.** Two-tier caches (e.g. memory in front of OPFS in front of storage) are out of scope. Consumers can compose them by passing a `NamespacedCache` over a layered cache they construct themselves.
- **Cache write failures swallow without retry.** A retrying cache layer belongs inside the `AbstractCache` implementation (e.g. via `retry` middleware); the plugin trusts the cache to honour its own contract.
- **Synchronous `getStats()`.** Stats are kept in a per-handle counter object updated inline. Reads of `getStats()` are O(1) and lock-free; perfectly adequate for occasional operator inspection.
## Composition recipes (in README)
```ts
// 1. Memory cache (zero deps, ephemeral, fastest)
const cache = createCache("memory", { policy: "lru", maxItems: 10_000 });
await drive.usePlugin(driveCachePlugin({ cache }));
// 2. OPFS-backed cache (browser, survives reloads)
import { createOpfsStorage } from "@suaveplan/storage-browser-opfs";
const cache = createCache("storage", {
storage: await createOpfsStorage("drive-cache"),
policy: "lru",
maxBytes: 256 * 1024 * 1024,
});
await drive.usePlugin(driveCachePlugin({ cache, namespace: "media" }));
// 3. File-backed cache (server, survives process restart)
import { createFileStorage } from "@suaveplan/storage-file";
const cache = createCache("storage", {
storage: createFileStorage({ rootDir: "/var/cache/drive" }),
policy: "ttl",
ttlMs: 3600_000,
});
await drive.usePlugin(driveCachePlugin({ cache }));
// 4. Redis-backed cache (server, cross-process shared)
import { createRedisCache } from "@suaveplan/cache-redis";
const cache = createRedisCache({ client, prefix: "drive-cache:" });
await drive.usePlugin(driveCachePlugin({ cache, namespace: "media" }));
// 5. Stale-while-revalidate via cache.wrap()
const handle = await drive.usePlugin(driveCachePlugin({ cache }));
const bytes = await handle.cache.wrap(`drive::clip.mov`, () => drive.read("clip.mov"), {
staleAge: 60_000,
refreshAhead: true,
});
```
Recipe 5 illustrates why the plugin exposes `handle.cache`: stale-while-revalidate, lock-and-load, refresh-ahead, and every other policy that `wrap()` already supports stays one composition step away — without the plugin having to grow options for each.