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

11 KiB

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

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

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>;
// 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

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

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

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)

// 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.