271 lines
11 KiB
Markdown
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.
|