Initial Commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
type BuildInvalidationArgs,
|
||||
buildInvalidationMiddleware,
|
||||
type InvalidationHooks,
|
||||
} from "./middleware.invalidation.js";
|
||||
export {
|
||||
type BuildMiddlewareArgs,
|
||||
buildMiddleware,
|
||||
} from "./middleware.js";
|
||||
export { getSignal, isPreAborted } from "./middleware.signal.js";
|
||||
@@ -0,0 +1,53 @@
|
||||
# `middleware.invalidation`
|
||||
|
||||
## Purpose
|
||||
|
||||
`middleware.invalidation.ts` provides the `write` and `delete` hooks of the `DriveMiddleware` built in `middleware.ts`. Each hook runs `next()` first; on successful resolution it deletes the corresponding cache entry. When `next()` throws, the throw propagates and the cache is left untouched — invariably the bytes in the cache are still the bytes the consumer last wrote.
|
||||
|
||||
This split keeps `middleware.ts` focused on the read path (the hot path) and makes the invalidation logic independently testable. The two hooks share a single `invalidate(path)` closure that wraps `cache.delete` with stats accounting, OTel recording, and error swallowing.
|
||||
|
||||
## Features
|
||||
|
||||
- Post-success invalidation — no race window where a concurrent read can repopulate stale bytes between the cache delete and the storage write.
|
||||
- `invalidateOnWrite` / `invalidateOnDelete` per-side toggles default to `true` but accept `false` for callers wanting append-only or write-through behaviour.
|
||||
- Underlying `cache.delete` rejection is swallowed, counted on `stats.errors`, and recorded via OTel as `errors_total{namespace, op: "delete"}`.
|
||||
- The `delete` hook returns the original `next()` boolean unchanged — semantics match `Drive.delete`'s "did the file exist" contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildInvalidationMiddleware } from "@suaveplan/drive-plugin-cache/middleware";
|
||||
|
||||
const hooks = buildInvalidationMiddleware({
|
||||
cache,
|
||||
stats,
|
||||
namespace: "drive",
|
||||
invalidateOnWrite: true,
|
||||
invalidateOnDelete: true,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const hooks = buildInvalidationMiddleware({
|
||||
cache,
|
||||
stats,
|
||||
namespace: "audit",
|
||||
invalidateOnWrite: false, // append-only: writes never displace cached bytes
|
||||
invalidateOnDelete: true,
|
||||
logger,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Description |
|
||||
|---|---|---|
|
||||
| `BuildInvalidationArgs` | `{ cache; stats; namespace; invalidateOnWrite; invalidateOnDelete; logger? }` | Constructor bag. |
|
||||
| `InvalidationHooks` | `{ write(ctx, next); delete(ctx, next) }` | Hook pair compatible with `DriveMiddleware`. |
|
||||
| `buildInvalidationMiddleware(args)` | `(BuildInvalidationArgs) => InvalidationHooks` | Returns the two hooks. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The shared `invalidate(path)` helper is a closure over the per-build state — `cache`, `stats`, `namespace`, optional `logger`. This avoids passing those four parameters through every invocation while keeping the function pure modulo the explicit mutable state. The `delete` hook still calls `invalidate` when `next()` returns `false` (the file did not exist on disk). This is safe — `cache.delete` is idempotent on missing keys — and it cleans any stale cache entry produced by an earlier reader that observed the file before a concurrent removal.
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { MiddlewareContext } from "@suaveplan/drive";
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeCache } from "../../tests/setup/fake-cache.js";
|
||||
import { createMutableStats, type MutableStats } from "../handle/handle.js";
|
||||
import { buildMiddleware } from "./middleware.js";
|
||||
|
||||
function makeCtx(path: string, op = "write"): MiddlewareContext {
|
||||
return {
|
||||
operation: op,
|
||||
path,
|
||||
drive: {} as MiddlewareContext["drive"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("middleware: invalidation", () => {
|
||||
let cache: FakeCache;
|
||||
let stats: MutableStats;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new FakeCache();
|
||||
stats = createMutableStats();
|
||||
});
|
||||
|
||||
it("successful write invalidates the cache entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
cache.store.set("drive::x.bin", new Uint8Array([1]));
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
await mw.write?.(makeCtx("x.bin"), async () => {});
|
||||
expect(cache.deleteCalls).toEqual(["drive::x.bin"]);
|
||||
expect(stats.invalidations).toBe(1);
|
||||
});
|
||||
|
||||
it("failed write does not invalidate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
let threw = false;
|
||||
try {
|
||||
await mw.write?.(makeCtx("x.bin"), async () => {
|
||||
throw new Error("storage failed");
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
expect(cache.deleteCalls).toEqual([]);
|
||||
expect(stats.invalidations).toBe(0);
|
||||
});
|
||||
|
||||
it("invalidateOnWrite:false skips invalidation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = buildMiddleware({
|
||||
cache,
|
||||
stats,
|
||||
invalidateOnWrite: false,
|
||||
});
|
||||
await mw.write?.(makeCtx("x.bin"), async () => {});
|
||||
expect(cache.deleteCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("successful delete invalidates the cache entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
cache.store.set("drive::x.bin", new Uint8Array([1]));
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const removed = await mw.delete?.(
|
||||
makeCtx("x.bin", "delete"),
|
||||
async () => true
|
||||
);
|
||||
expect(removed).toBe(true);
|
||||
expect(cache.deleteCalls).toEqual(["drive::x.bin"]);
|
||||
expect(stats.invalidations).toBe(1);
|
||||
});
|
||||
|
||||
it("delete invalidates even when next() returned false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const removed = await mw.delete?.(
|
||||
makeCtx("x.bin", "delete"),
|
||||
async () => false
|
||||
);
|
||||
expect(removed).toBe(false);
|
||||
// Per spec: "MAY still call cache.delete(key) to clean any stale entry"
|
||||
expect(cache.deleteCalls).toEqual(["drive::x.bin"]);
|
||||
});
|
||||
|
||||
it("invalidateOnDelete:false skips invalidation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = buildMiddleware({
|
||||
cache,
|
||||
stats,
|
||||
invalidateOnDelete: false,
|
||||
});
|
||||
await mw.delete?.(makeCtx("x.bin", "delete"), async () => true);
|
||||
expect(cache.deleteCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("failed delete does not invalidate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
let threw = false;
|
||||
try {
|
||||
await mw.delete?.(makeCtx("x.bin", "delete"), async () => {
|
||||
throw new Error("storage gone");
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
expect(cache.deleteCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("cache.delete rejection on invalidation does not propagate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
cache.throwOnDelete = new Error("redis down");
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
// Should NOT throw
|
||||
await mw.write?.(makeCtx("x.bin"), async () => {});
|
||||
expect(stats.errors).toBe(1);
|
||||
expect(stats.invalidations).toBe(0);
|
||||
});
|
||||
|
||||
it("invalidation logger captures suppressed cache.delete errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
cache.throwOnDelete = new Error("redis down");
|
||||
const logs: string[] = [];
|
||||
const logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: (msg: string) => {
|
||||
logs.push(msg);
|
||||
},
|
||||
};
|
||||
const mw = buildMiddleware({ cache, stats, logger });
|
||||
await mw.write?.(makeCtx("x.bin"), async () => {});
|
||||
expect(logs.some((m) => m.includes("cache.delete failed"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AbstractCache } from "@suaveplan/cache";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { MutableStats } from "../handle/handle.js";
|
||||
import { recordError, recordInvalidate } from "../otel/otel.js";
|
||||
|
||||
interface MiddlewareContext {
|
||||
readonly operation: string;
|
||||
readonly path: string;
|
||||
readonly drive: unknown;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BuildInvalidationArgs {
|
||||
readonly cache: AbstractCache<Uint8Array>;
|
||||
readonly stats: MutableStats;
|
||||
readonly namespace: string;
|
||||
readonly invalidateOnWrite: boolean;
|
||||
readonly invalidateOnDelete: boolean;
|
||||
readonly logger?: LoggerLike | undefined;
|
||||
}
|
||||
|
||||
export interface InvalidationHooks {
|
||||
write(ctx: MiddlewareContext, next: () => Promise<void>): Promise<void>;
|
||||
delete(
|
||||
ctx: MiddlewareContext,
|
||||
next: () => Promise<boolean>
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `write` and `delete` `DriveMiddleware` hooks responsible for
|
||||
* post-success cache invalidation. Each hook runs `next()` first; on
|
||||
* successful resolution it deletes the corresponding cache entry. When
|
||||
* `next()` throws, the throw propagates and the cache is left untouched.
|
||||
*/
|
||||
export function buildInvalidationMiddleware(
|
||||
args: BuildInvalidationArgs
|
||||
): InvalidationHooks {
|
||||
const {
|
||||
cache,
|
||||
stats,
|
||||
namespace,
|
||||
invalidateOnWrite,
|
||||
invalidateOnDelete,
|
||||
logger,
|
||||
} = args;
|
||||
|
||||
async function invalidate(path: string): Promise<void> {
|
||||
const key = `${namespace}::${path}`;
|
||||
try {
|
||||
await cache.delete(key);
|
||||
stats.invalidations += 1;
|
||||
recordInvalidate(namespace, path);
|
||||
} catch (cause) {
|
||||
stats.errors += 1;
|
||||
recordError(namespace, "delete", path);
|
||||
if (logger !== undefined) {
|
||||
logger.warn("drive-plugin-cache: cache.delete failed", {
|
||||
path,
|
||||
namespace,
|
||||
cause: String(cause),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async write(
|
||||
ctx: MiddlewareContext,
|
||||
next: () => Promise<void>
|
||||
): Promise<void> {
|
||||
await next();
|
||||
if (invalidateOnWrite) {
|
||||
await invalidate(ctx.path);
|
||||
}
|
||||
},
|
||||
async delete(
|
||||
ctx: MiddlewareContext,
|
||||
next: () => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const removed = await next();
|
||||
if (invalidateOnDelete) {
|
||||
await invalidate(ctx.path);
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# `middleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`middleware.ts` builds the `DriveMiddleware` registered on a `Drive` by `driveCachePlugin`. The middleware intercepts `read(ctx, next)` to short-circuit hot paths from the cache and to populate fresh entries on a miss. `write` and `delete` interceptions are delegated to `middleware.invalidation`. `stat` and `list` are pure passthroughs in this iteration.
|
||||
|
||||
A cache is a perf optimisation, not a correctness boundary. Every underlying cache failure (`get` rejection, `set` rejection, `delete` rejection) is captured into `stats.errors` and the OTel `errors_total{namespace, op}` counter — and is never propagated to the consumer.
|
||||
|
||||
## Features
|
||||
|
||||
- Pre-aborted `AbortSignal` short-circuits the cache work and defers to `next()` so the inner storage surfaces canonical `DRIVE.ABORTED`.
|
||||
- Live `AbortSignal` is forwarded to `cache.get` for caches whose `get` accepts a signal.
|
||||
- `shouldCache(path, bytes)` predicate gates population — defaults to a 16-MiB byte ceiling matching `drive-mesh-protocol`'s `MAX_FRAME_BYTES`.
|
||||
- Population enforces the byte ceiling defensively; an oversize entry raises a structured `DRIVE_CACHE.CACHE_TOO_LARGE` internally and is suppressed (counted on `stats.errors`).
|
||||
- `stat` and `list` pass straight to `next()` without touching the cache.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildMiddleware } from "@suaveplan/drive-plugin-cache/middleware";
|
||||
import { createMutableStats } from "@suaveplan/drive-plugin-cache/handle";
|
||||
|
||||
const stats = createMutableStats();
|
||||
const mw = buildMiddleware({ cache, stats, namespace: "drive" });
|
||||
drive.useMiddleware(mw);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const mw = buildMiddleware({
|
||||
cache,
|
||||
stats,
|
||||
namespace: "media",
|
||||
invalidateOnWrite: true,
|
||||
invalidateOnDelete: false,
|
||||
shouldCache: (path, bytes) =>
|
||||
path.endsWith(".mov") && bytes.byteLength <= 8 * 1024 * 1024,
|
||||
maxEntryBytes: 8 * 1024 * 1024,
|
||||
logger,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Description |
|
||||
|---|---|---|
|
||||
| `BuildMiddlewareArgs` | `{ cache; stats; namespace?; invalidateOnWrite?; invalidateOnDelete?; shouldCache?; maxEntryBytes?; logger? }` | Constructor bag. |
|
||||
| `buildMiddleware(args)` | `(BuildMiddlewareArgs) => DriveMiddleware` | Returns the full `DriveMiddleware` (read + write + delete + stat + list). |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cache key is computed as `${namespace}::${ctx.path}`. The `::` separator avoids accidental collision with paths that already contain `/`. Population happens **after** `next()` resolves — i.e. only when the inner storage actually produced bytes — so a thrown `next()` cannot poison the cache. Symmetrically, invalidation happens **after** a successful `write`/`delete` `next()`. The optimistic alternative (delete before next()) opens a race window where a concurrent read could repopulate stale bytes between the cache delete and the storage write.
|
||||
@@ -0,0 +1,46 @@
|
||||
# `middleware.signal`
|
||||
|
||||
## Purpose
|
||||
|
||||
`middleware.signal.ts` provides two tiny `AbortSignal` helpers used by the drive-cache middleware: `getSignal(ctx)` extracts a signal from the open-shaped `MiddlewareContext`, and `isPreAborted(ctx)` returns true when the extracted signal is already aborted at the moment the middleware runs.
|
||||
|
||||
Drive's core does not currently thread a signal into `ctx`. The `MiddlewareContext` shape is an open `[key: string]: unknown` carrier, so downstream extensions and future Drive versions can. Centralising the lookup here means the read/write/delete middleware code stays a single line, and updating to a future canonical `ctx.signal` field is a one-file change.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-safe extraction — returns `AbortSignal | undefined`, never throws.
|
||||
- `isPreAborted` collapses the "have a signal and it is aborted" check into a single boolean.
|
||||
- No runtime dependency — purely structural over the `MiddlewareContext` open shape.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { isPreAborted, getSignal } from "@suaveplan/drive-plugin-cache/middleware";
|
||||
|
||||
if (isPreAborted(ctx)) return next();
|
||||
const signal = getSignal(ctx);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { getSignal } from "@suaveplan/drive-plugin-cache/middleware";
|
||||
|
||||
const signal = getSignal(ctx);
|
||||
if (signal !== undefined) {
|
||||
// Pass the signal into the underlying cache.get when supported.
|
||||
await (cache as { get(k: string, s?: AbortSignal): Promise<Uint8Array | undefined> })
|
||||
.get(key, signal);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Description |
|
||||
|---|---|---|
|
||||
| `getSignal(ctx)` | `(MiddlewareContext) => AbortSignal \| undefined` | Returns the signal carried on `ctx.signal` when it is an `AbortSignal`, else `undefined`. |
|
||||
| `isPreAborted(ctx)` | `(MiddlewareContext) => boolean` | True when `getSignal(ctx)?.aborted === true`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `instanceof AbortSignal` guard rejects loose duck-typed shapes that happen to carry an `aborted` boolean but lack the full signal contract (no `addEventListener`, no `reason`). This matters because forwarding such a shape into `cache.get` could cause the cache to call `signal.addEventListener` on a non-EventTarget object. The instanceof check is cheap and correct for every standards-conformant runtime; consumers building their own signal-shaped object should construct a real `AbortController` and surface its `signal`.
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeCache } from "../../tests/setup/fake-cache.js";
|
||||
import { createMutableStats } from "../handle/handle.js";
|
||||
import { buildMiddleware } from "./middleware.js";
|
||||
import { getSignal, isPreAborted } from "./middleware.signal.js";
|
||||
|
||||
interface TestCtx {
|
||||
operation: string;
|
||||
path: string;
|
||||
drive: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function makeCtx(path: string, signal?: AbortSignal): TestCtx {
|
||||
const base: TestCtx = {
|
||||
operation: "read",
|
||||
path,
|
||||
drive: {},
|
||||
};
|
||||
if (signal !== undefined) {
|
||||
(base as { signal?: AbortSignal }).signal = signal;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
describe("middleware.signal helpers", () => {
|
||||
it("getSignal returns undefined when ctx has no signal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(getSignal(makeCtx("x"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getSignal returns the signal when ctx carries a real AbortSignal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctrl = new AbortController();
|
||||
expect(getSignal(makeCtx("x", ctrl.signal))).toBe(ctrl.signal);
|
||||
});
|
||||
|
||||
it("getSignal rejects non-AbortSignal duck shapes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx: TestCtx = {
|
||||
operation: "read",
|
||||
path: "x",
|
||||
drive: {},
|
||||
signal: { aborted: true },
|
||||
};
|
||||
expect(getSignal(ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isPreAborted reflects the signal state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctrl = new AbortController();
|
||||
expect(isPreAborted(makeCtx("x", ctrl.signal))).toBe(false);
|
||||
ctrl.abort();
|
||||
expect(isPreAborted(makeCtx("x", ctrl.signal))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("middleware: AbortSignal threading", () => {
|
||||
it("pre-aborted signal skips cache work", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cache = new FakeCache();
|
||||
const stats = createMutableStats();
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
let nextCalls = 0;
|
||||
await mw.read?.(makeCtx("x", ctrl.signal), async () => {
|
||||
nextCalls += 1;
|
||||
return new Uint8Array();
|
||||
});
|
||||
expect(cache.getCalls).toEqual([]);
|
||||
expect(cache.setCalls).toEqual([]);
|
||||
expect(nextCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("live signal is forwarded to cache operations", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cache = new FakeCache();
|
||||
const stats = createMutableStats();
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const ctrl = new AbortController();
|
||||
await mw.read?.(
|
||||
makeCtx("x", ctrl.signal),
|
||||
async () => new Uint8Array([1])
|
||||
);
|
||||
expect(cache.getCalls).toHaveLength(1);
|
||||
expect(cache.getCalls[0].signal).toBe(ctrl.signal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Structural view of the middleware-context fields this helper reads. Mirrors
|
||||
* `MiddlewareContext` from `@suaveplan/drive` (which is not re-exported from
|
||||
* the package root) without binding to the concrete shape — `path`/`drive`/
|
||||
* `operation` are irrelevant for signal extraction.
|
||||
*/
|
||||
export interface SignalContext {
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the `AbortSignal` (if any) attached to a middleware context.
|
||||
*
|
||||
* Drive's core does not currently thread a signal into `ctx`, but the
|
||||
* underlying `MiddlewareContext` shape is an open `[key: string]: unknown`
|
||||
* carrier so downstream extensions / future Drive versions can. This helper
|
||||
* centralises the lookup so the read/write/delete middleware code stays a
|
||||
* single line.
|
||||
*/
|
||||
export function getSignal(ctx: SignalContext): AbortSignal | undefined {
|
||||
const candidate = ctx["signal"];
|
||||
if (candidate instanceof AbortSignal) return candidate;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` when the given context carries a signal that is already aborted at
|
||||
* the moment the middleware runs. Used to short-circuit the cache work and
|
||||
* defer to `next()` so the inner storage layer surfaces `DRIVE.ABORTED`
|
||||
* canonically.
|
||||
*/
|
||||
export function isPreAborted(ctx: SignalContext): boolean {
|
||||
const signal = getSignal(ctx);
|
||||
return signal?.aborted ?? false;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeCache } from "../../tests/setup/fake-cache.js";
|
||||
import { createMutableStats, type MutableStats } from "../handle/handle.js";
|
||||
import { buildMiddleware } from "./middleware.js";
|
||||
|
||||
interface TestCtx {
|
||||
operation: string;
|
||||
path: string;
|
||||
drive: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function makeCtx(path: string, extra?: Record<string, unknown>): TestCtx {
|
||||
return {
|
||||
operation: "read",
|
||||
path,
|
||||
drive: {},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("middleware: read", () => {
|
||||
let cache: FakeCache;
|
||||
let stats: MutableStats;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new FakeCache();
|
||||
stats = createMutableStats();
|
||||
});
|
||||
|
||||
it("hit short-circuits next() and returns cached bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cached = new Uint8Array([10, 20, 30]);
|
||||
cache.store.set("drive::clip.mov", cached);
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
let nextCalls = 0;
|
||||
const out = await mw.read?.(makeCtx("clip.mov"), async () => {
|
||||
nextCalls += 1;
|
||||
return new Uint8Array([99]);
|
||||
});
|
||||
expect(out).toBe(cached);
|
||||
expect(nextCalls).toBe(0);
|
||||
expect(stats.hits).toBe(1);
|
||||
expect(stats.misses).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the configured namespace in the cache key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cached = new Uint8Array([1]);
|
||||
cache.store.set("media::clip.mov", cached);
|
||||
const mw = buildMiddleware({ cache, stats, namespace: "media" });
|
||||
await mw.read?.(makeCtx("clip.mov"), async () => new Uint8Array());
|
||||
expect(cache.getCalls).toEqual([
|
||||
{ key: "media::clip.mov", signal: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("miss invokes next() and populates the cache", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array([5, 6, 7]);
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const out = await mw.read?.(makeCtx("x.bin"), async () => bytes);
|
||||
expect(out).toBe(bytes);
|
||||
expect(stats.misses).toBe(1);
|
||||
expect(stats.populations).toBe(1);
|
||||
expect(cache.setCalls).toEqual([{ key: "drive::x.bin", value: bytes }]);
|
||||
});
|
||||
|
||||
it("shouldCache predicate gates population", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = buildMiddleware({
|
||||
cache,
|
||||
stats,
|
||||
shouldCache: () => false,
|
||||
});
|
||||
await mw.read?.(makeCtx("x.bin"), async () => new Uint8Array([1, 2]));
|
||||
expect(cache.setCalls).toEqual([]);
|
||||
expect(stats.populations).toBe(0);
|
||||
expect(stats.misses).toBe(1);
|
||||
});
|
||||
|
||||
it("default maxEntryBytes is 16 MiB", { timeout: 30000 }, async () => {
|
||||
const big = new Uint8Array(16 * 1024 * 1024 + 1);
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
await mw.read?.(makeCtx("big.bin"), async () => big);
|
||||
expect(cache.setCalls).toEqual([]);
|
||||
expect(stats.populations).toBe(0);
|
||||
// Default shouldCache short-circuits before the defensive
|
||||
// CACHE_TOO_LARGE branch — so stats.errors stays 0.
|
||||
expect(stats.errors).toBe(0);
|
||||
});
|
||||
|
||||
it("populates entries exactly at the 16 MiB boundary", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const boundary = new Uint8Array(16 * 1024 * 1024);
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
await mw.read?.(makeCtx("boundary.bin"), async () => boundary);
|
||||
expect(stats.populations).toBe(1);
|
||||
});
|
||||
|
||||
it("custom shouldCache enforcing maxEntryBytes triggers CACHE_TOO_LARGE swallow", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// shouldCache returns true even though bytes exceed maxEntryBytes —
|
||||
// middleware defensively suppresses and counts an error.
|
||||
const mw = buildMiddleware({
|
||||
cache,
|
||||
stats,
|
||||
shouldCache: () => true,
|
||||
maxEntryBytes: 4,
|
||||
});
|
||||
await mw.read?.(
|
||||
makeCtx("x.bin"),
|
||||
async () => new Uint8Array([1, 2, 3, 4, 5])
|
||||
);
|
||||
expect(cache.setCalls).toEqual([]);
|
||||
expect(stats.errors).toBe(1);
|
||||
});
|
||||
|
||||
it("cache.get rejection falls through to next()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
cache.throwOnGet = new Error("redis down");
|
||||
const bytes = new Uint8Array([42]);
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const out = await mw.read?.(makeCtx("x"), async () => bytes);
|
||||
expect(out).toBe(bytes);
|
||||
expect(stats.errors).toBe(1);
|
||||
expect(stats.misses).toBe(1);
|
||||
});
|
||||
|
||||
it("cache.set rejection does not propagate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
cache.throwOnSet = new Error("disk full");
|
||||
const bytes = new Uint8Array([1, 2]);
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const out = await mw.read?.(makeCtx("x"), async () => bytes);
|
||||
expect(out).toBe(bytes);
|
||||
expect(stats.errors).toBe(1);
|
||||
expect(stats.populations).toBe(0);
|
||||
});
|
||||
|
||||
it("logger receives suppressed cache.get error context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
cache.throwOnGet = new Error("redis down");
|
||||
const logs: Array<{
|
||||
msg: string;
|
||||
ctx: Record<string, unknown> | undefined;
|
||||
}> = [];
|
||||
const logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: (msg: string, ctx?: Record<string, unknown>) => {
|
||||
logs.push({ msg, ctx });
|
||||
},
|
||||
};
|
||||
const mw = buildMiddleware({ cache, stats, logger });
|
||||
await mw.read?.(makeCtx("x"), async () => new Uint8Array());
|
||||
expect(logs.length).toBeGreaterThanOrEqual(1);
|
||||
expect(logs[0].msg).toContain("cache.get failed");
|
||||
});
|
||||
|
||||
it("logger receives suppressed cache.set error context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
cache.throwOnSet = new Error("disk full");
|
||||
const logs: Array<{ msg: string }> = [];
|
||||
const logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: (msg: string) => {
|
||||
logs.push({ msg });
|
||||
},
|
||||
};
|
||||
const mw = buildMiddleware({ cache, stats, logger });
|
||||
await mw.read?.(makeCtx("x"), async () => new Uint8Array([1]));
|
||||
expect(logs.some((l) => l.msg.includes("cache.set failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("stat passes through without touching the cache", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const stat = { size: 1 };
|
||||
const out = await mw.stat?.(makeCtx("x"), async () => stat);
|
||||
expect(out).toBe(stat);
|
||||
expect(cache.getCalls).toEqual([]);
|
||||
expect(cache.setCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("list passes through without touching the cache", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = buildMiddleware({ cache, stats });
|
||||
const listing = [{ path: "a" }];
|
||||
const out = await mw.list?.(makeCtx("folder/"), async () => listing);
|
||||
expect(out).toBe(listing);
|
||||
expect(cache.getCalls).toEqual([]);
|
||||
expect(cache.setCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { AbstractCache } from "@suaveplan/cache";
|
||||
import type { DriveMiddleware } from "@suaveplan/drive";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
DRIVE_CACHE_CODES,
|
||||
DriveCacheError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { MutableStats } from "../handle/handle.js";
|
||||
import {
|
||||
recordError,
|
||||
recordHit,
|
||||
recordMiss,
|
||||
recordPopulate,
|
||||
} from "../otel/otel.js";
|
||||
import { buildInvalidationMiddleware } from "./middleware.invalidation.js";
|
||||
import { getSignal, isPreAborted } from "./middleware.signal.js";
|
||||
|
||||
/**
|
||||
* Structural copy of `MiddlewareContext` from `@suaveplan/drive`. The drive
|
||||
* package does not currently re-export this type from its root barrel, so we
|
||||
* mirror the shape here. It is a structural match — any object compatible
|
||||
* with drive's `MiddlewareContext` is compatible with this one.
|
||||
*/
|
||||
interface MiddlewareContext {
|
||||
readonly operation: string;
|
||||
readonly path: string;
|
||||
readonly drive: unknown;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
const DEFAULT_NAMESPACE = "drive";
|
||||
const DEFAULT_MAX_ENTRY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export interface BuildMiddlewareArgs {
|
||||
readonly cache: AbstractCache<Uint8Array>;
|
||||
readonly stats: MutableStats;
|
||||
readonly namespace?: string | undefined;
|
||||
readonly invalidateOnWrite?: boolean | undefined;
|
||||
readonly invalidateOnDelete?: boolean | undefined;
|
||||
readonly shouldCache?:
|
||||
| ((path: string, bytes: Uint8Array) => boolean)
|
||||
| undefined;
|
||||
readonly maxEntryBytes?: number | undefined;
|
||||
readonly logger?: LoggerLike | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `DriveMiddleware` registered on a `Drive` by `driveCachePlugin`.
|
||||
*
|
||||
* Wires the read short-circuit / populate-on-miss behaviour with the
|
||||
* invalidation hooks for write and delete. `stat` and `list` are pure
|
||||
* passthroughs in this iteration.
|
||||
*/
|
||||
export function buildMiddleware(args: BuildMiddlewareArgs): DriveMiddleware {
|
||||
const namespace = args.namespace ?? DEFAULT_NAMESPACE;
|
||||
const maxEntryBytes = args.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
|
||||
const shouldCachePredicate =
|
||||
args.shouldCache ??
|
||||
((_path: string, bytes: Uint8Array): boolean =>
|
||||
bytes.byteLength <= maxEntryBytes);
|
||||
|
||||
const cache = args.cache;
|
||||
const stats = args.stats;
|
||||
const logger = args.logger;
|
||||
|
||||
const invalidation = buildInvalidationMiddleware({
|
||||
cache,
|
||||
stats,
|
||||
namespace,
|
||||
invalidateOnWrite: args.invalidateOnWrite ?? true,
|
||||
invalidateOnDelete: args.invalidateOnDelete ?? true,
|
||||
logger,
|
||||
});
|
||||
|
||||
return {
|
||||
async read(
|
||||
ctx: MiddlewareContext,
|
||||
next: () => Promise<Uint8Array>
|
||||
): Promise<Uint8Array> {
|
||||
if (isPreAborted(ctx)) {
|
||||
return next();
|
||||
}
|
||||
const key = `${namespace}::${ctx.path}`;
|
||||
const signal = getSignal(ctx);
|
||||
|
||||
let cached: Uint8Array | undefined;
|
||||
try {
|
||||
cached = await invokeCacheGet(cache, key, signal);
|
||||
} catch (cause) {
|
||||
stats.errors += 1;
|
||||
recordError(namespace, "get", ctx.path);
|
||||
logCacheError(logger, "get", ctx.path, namespace, cause);
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
if (cached !== undefined) {
|
||||
stats.hits += 1;
|
||||
recordHit(namespace, ctx.path);
|
||||
return cached;
|
||||
}
|
||||
|
||||
stats.misses += 1;
|
||||
recordMiss(namespace, ctx.path);
|
||||
const bytes = await next();
|
||||
|
||||
if (shouldCachePredicate(ctx.path, bytes)) {
|
||||
try {
|
||||
if (bytes.byteLength > maxEntryBytes) {
|
||||
throw new DriveCacheError(
|
||||
DRIVE_CACHE_CODES.CACHE_TOO_LARGE,
|
||||
"cached entry exceeds configured maxEntryBytes",
|
||||
{
|
||||
path: ctx.path,
|
||||
namespace,
|
||||
byteLength: bytes.byteLength,
|
||||
maxEntryBytes,
|
||||
}
|
||||
);
|
||||
}
|
||||
await cache.set(key, bytes);
|
||||
stats.populations += 1;
|
||||
recordPopulate(namespace, ctx.path, bytes.byteLength);
|
||||
} catch (cause) {
|
||||
stats.errors += 1;
|
||||
recordError(namespace, "set", ctx.path);
|
||||
logCacheError(logger, "set", ctx.path, namespace, cause);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
},
|
||||
write: invalidation.write,
|
||||
delete: invalidation.delete,
|
||||
async stat(
|
||||
_ctx: MiddlewareContext,
|
||||
next: () => Promise<unknown | null>
|
||||
): Promise<unknown | null> {
|
||||
return next();
|
||||
},
|
||||
async list(
|
||||
_ctx: MiddlewareContext,
|
||||
next: () => Promise<unknown>
|
||||
): Promise<unknown> {
|
||||
return next();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `cache.get(key)`, optionally forwarding an `AbortSignal` to caches
|
||||
* whose `get` accepts a second argument. The `AbstractCache` base contract
|
||||
* is single-arg, but concrete implementations may extend it; the cast keeps
|
||||
* the call site type-safe while still threading the signal where supported.
|
||||
*/
|
||||
async function invokeCacheGet(
|
||||
cache: AbstractCache<Uint8Array>,
|
||||
key: string,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<Uint8Array | undefined> {
|
||||
if (signal === undefined) return cache.get(key);
|
||||
const extended = cache as unknown as {
|
||||
get(k: string, s?: AbortSignal): Promise<Uint8Array | undefined>;
|
||||
};
|
||||
return extended.get(key, signal);
|
||||
}
|
||||
|
||||
function logCacheError(
|
||||
logger: LoggerLike | undefined,
|
||||
op: "get" | "set",
|
||||
path: string,
|
||||
namespace: string,
|
||||
cause: unknown
|
||||
): void {
|
||||
if (logger === undefined) return;
|
||||
logger.warn(`drive-plugin-cache: cache.${op} failed`, {
|
||||
path,
|
||||
namespace,
|
||||
cause: String(cause),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user