Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Six harnesses measure the client.

**Call shape (runs in `pnpm test`).** `packages/cachekit/test/transport/call-shape.test.ts` runs the CacheKit Cloud backend against a local TLS fake of CacheKit Cloud. It asserts the exact requests each op sends (a wrap miss is `GET` then `PUT`, and with `writeBehind` the caller waits for the `GET` only; a locked miss is `GET`, lock, `PUT`, unlock) and the connections 100 ops use. Each extra request is a round trip, and each extra connection is a TCP and TLS handshake. So if you change one of these numbers, change the expectation in the same PR and say why. The fake mints a throwaway certificate with the `openssl` CLI, and it needs Node 22.19+ or 24.5+ for `tls.setDefaultCACertificates`. Where either is missing, the tests fail with a message that names the requirement.

**Instructions per op (`pnpm --filter @cachekit-io/cachekit bench:ir`).** This counts the main-thread instructions of each hot path under callgrind: key generation through the NAPI hash (`keygen`, `keygen-1kb`) and the wasm hash (`keygen-wasm`, `keygen-wasm-1kb`), at 45 B and 1 KB of arguments, the serializer, the NAPI and wasm envelopes (1 KB, plus 40 B under the core's 64 B literals-only cutover and 64 KiB), both encrypted paths (1 KB, plus 64 KiB of incompressible bytes), and the L1 hit through `get()` (`get-l1-hit`) and through `wrap()`, once with an object among the arguments and once with the single integer argument the README benchmark passes, the encrypted `wrap()` L1 hit at 100 B, 10 KB and 1 MB, and the encrypted `get()` L1 hit at 100 B (`secure-get-l1-hit`). Unlike wall-clock timing, instruction counts repeat on a busy machine. The suite pins V8's flags and counts only the measured ops. It also lowers V8's TurboFan threshold, so no optimizing compile lands in any workload's window. For all but the two 1 MB workloads, the window counts the code V8 runs in steady state. Those run so few ops that the `set()` and `wrap()` code around each 1 MB encode or decode stays in V8's interpreter for the whole run, which costs too little to show beside the encode or decode. A workload that tiers up later sets its own `warmup`; `bench/ir-workload.mjs` says how to check one with `--trace-opt`. The 100,000-entry L1 at-capacity workload collects garbage just before its window, so V8 does not finish collecting the entries its setup made inside it: that had been two thirds of its count, and it moved with whatever the build allocated at load. The 1,000- and 10,000-entry ones do not collect: at 10,000 a collection made the count move 2.4% with heap layout. On Node 22 and 24, every path repeats within 0.01%. On Node 26, paths through the native cores repeat within about 0.3%, but `napi-encrypted` and the encrypted `wrap()` hits at 100 B and 1 MB sometimes have a run up to 1.7% off the rest, which makes them read inconclusive. Gate on Node 22 or 24. It needs Linux and valgrind. It imports `dist/` and the wasm build, so run `pnpm build` and `pnpm --filter @cachekit-io/cachekit-core-wasm build:wasm` first; the second needs the `wasm32-unknown-unknown` target, `wasm-bindgen` and `wasm-opt`, and without it pass `--only` to skip the seven wasm workloads (`wasm-envelope`, `wasm-envelope-40b`, `wasm-envelope-64kb`, `wasm-encrypted`, `wasm-encrypted-64kb`, `keygen-wasm`, `keygen-wasm-1kb`). To A/B a change, run it on both builds on the same machine:
**Instructions per op (`pnpm --filter @cachekit-io/cachekit bench:ir`).** This counts the main-thread instructions of each hot path under callgrind: key generation through the NAPI hash (`keygen`, `keygen-1kb`) and the wasm hash (`keygen-wasm`, `keygen-wasm-1kb`), at 45 B and 1 KB of arguments, the serializer, the NAPI and wasm envelopes (1 KB, plus 40 B under the core's 64 B literals-only cutover and 64 KiB), both encrypted paths (1 KB, plus 64 KiB of incompressible bytes), and the L1 hit through `get()` (`get-l1-hit`) and through `wrap()`, once with an object among the arguments and once with the single integer argument the README benchmark passes, the encrypted `wrap()` L1 hit at 100 B, 10 KB and 1 MB, the encrypted `get()` L1 hit at 100 B (`secure-get-l1-hit`), and the encrypted `set()` at 100 B (`secure-set-100b`). Unlike wall-clock timing, instruction counts repeat on a busy machine. The suite pins V8's flags and counts only the measured ops. It also lowers V8's TurboFan threshold, so no optimizing compile lands in any workload's window. For all but the two 1 MB workloads, the window counts the code V8 runs in steady state. Those run so few ops that the `set()` and `wrap()` code around each 1 MB encode or decode stays in V8's interpreter for the whole run, which costs too little to show beside the encode or decode. A workload that tiers up later sets its own `warmup`; `bench/ir-workload.mjs` says how to check one with `--trace-opt`. The 100,000-entry L1 at-capacity workload collects garbage just before its window, so V8 does not finish collecting the entries its setup made inside it: that had been two thirds of its count, and it moved with whatever the build allocated at load. The 1,000- and 10,000-entry ones do not collect: at 10,000 a collection made the count move 2.4% with heap layout. On Node 22 and 24, every path repeats within 0.01%. On Node 26, paths through the native cores repeat within about 0.3%, but `napi-encrypted` and the encrypted `wrap()` hits at 100 B and 1 MB sometimes have a run up to 1.7% off the rest, which makes them read inconclusive. Gate on Node 22 or 24. It needs Linux and valgrind. It imports `dist/` and the wasm build, so run `pnpm build` and `pnpm --filter @cachekit-io/cachekit-core-wasm build:wasm` first; the second needs the `wasm32-unknown-unknown` target, `wasm-bindgen` and `wasm-opt`, and without it pass `--only` to skip the seven wasm workloads (`wasm-envelope`, `wasm-envelope-40b`, `wasm-envelope-64kb`, `wasm-encrypted`, `wasm-encrypted-64kb`, `keygen-wasm`, `keygen-wasm-1kb`). To A/B a change, run it on both builds on the same machine:

```bash
git checkout <your-branch> -- packages/cachekit/bench # in the base checkout: your bench/, as CI does
Expand Down
2 changes: 1 addition & 1 deletion packages/cachekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1189,7 +1189,7 @@ What one call costs in the client, on an in-memory backend, so no figure include

- **`wrap()` L1 hit**: the hit returns the stored object as it is, so the value's size barely matters. A wrapped function remembers the key of each call whose arguments are all strings, numbers, booleans, `null` or `undefined`, and the L1 entry that call was last served from. A repeat call is served from that entry on one check, with no key generation and no lookup by key, while L1 still holds the entry and it is short of its stale-while-revalidate point (the top of that point's ±10% jitter band); once L1 evicts or expires it, or it reaches that band, the call looks its key up in L1 again. `delete()`, `invalidate()` and an invalidation event empty the memo too, so the next call generates its key first, and after `close()` every call is rejected. It remembers a call only if it has at most 8 arguments, no `NaN`, and strings whose `length` adds up to 256 or less. Every other call generates its key, which adds about the cost of the key generation row: an object, array, `Date`, `Map`, `Set` or binary argument, a call past those limits, a call with `skipL1` or under `ttl: 0`, every `interop` call, and every call on a cache that encrypts or has L1 off, or on Cloudflare Workers.
- **Key memo**: a wrapped function remembers at most 1,024 keys and starts again when full: about 160 KiB when each call passes one number, and at most about 1 MiB. It holds copies of the arguments, so a cache that encrypts keeps no memo, and `delete()`, `invalidate()`, `close()` and, with `invalidation` configured, an invalidation event from another instance empty every memo of the cache. A timer also empties a memo one second after its first key, or one `ttl` after it if that is sooner, even if the function is not called again, so it is set to drop an argument no later than L1 stops serving a value stored with that `ttl`. The timer can run late, for example when the event loop is busy or the process is suspended, and the memo keeps its arguments until it runs. A call that comes one `ttl` or more after the first key, before the timer has run, empties the memo first. The call that stores the first key sets that timer, which keeps the call's async context, `AsyncLocalStorage` stores included, until it runs: one second or less after it was set, unless it runs late. So a function called again and again with the same arguments generates their key about once a second, or once per `ttl` if that is shorter. Wrap a function once, at module scope: a function wrapped inside each request keeps that request's async context and its memo until the memo's timer runs, one second later or one `ttl` later if that is sooner. With a `ttl` of a second or more, at 1,000 requests a second with a 16 KiB store, that is about 16 MB. A wrap under `ttl: 0`, which never expires, keeps no memo, and nor does a Worker, where a timer can be cancelled when its request ends.
- **Encrypted L1 hit**: L1 holds the same ciphertext as L2, so every hit also generates the key, decrypts, verifies the AAD and decodes the value. That cost grows with the value. A read from L2 also walks the document to check its nesting depth, before the decode and again after it. So does the first hit on an entry, which then keeps its own copy of the ciphertext in L1; later hits on that copy skip the walks (an `interop` entry keeps the check after the decode). On Node.js the decrypt is a synchronous call into the native core, so once the cache has derived its keys, at its first encrypt or decrypt, a hit through `get()`, `exists()` or `wrap()` awaits no promise. On Cloudflare Workers, WebCrypto decrypts asynchronously, so the hit waits for it.
- **Encrypted L1 hit**: L1 holds the same ciphertext as L2, so every hit also generates the key, decrypts, verifies the AAD and decodes the value. That cost grows with the value. A read from L2 also walks the document to check its nesting depth, before the decode and again after it. So does the first hit on an entry, which then keeps its own copy of the ciphertext in L1; later hits on that copy skip the walks (an `interop` entry keeps the check after the decode). On Node.js the decrypt and the encrypt are synchronous calls into the native core, so once the cache has derived its keys, at its first encrypt or decrypt, a hit through `get()`, `exists()` or `wrap()` awaits no promise, and a `set()` awaits none around its encrypt. On Cloudflare Workers, WebCrypto decrypts and encrypts asynchronously, so the hit and the set wait for it.
- **L2 hit and `set`**: L1 is off, so each call reads or writes the backend and decodes or encodes the value. A real backend adds its round trip, which for Redis or CacheKit Cloud is a network call.

To print this table for your own machine, run `pnpm install` and `pnpm build`, then `node packages/cachekit/bench/readme.mjs`, from a clone of the repository, or run the **Bench** workflow from its Actions tab. Across 11 runs on this CPU model, the 100 B hit took 290-360 ns, 320 ns at the median, and every other cell agreed within 10%. The encrypted L1 hit row is the median of four later runs on this CPU model, which read 9.0-9.7 µs and 119-125 µs. Hosted runners also come with other CPU models, on which the 100 B hit took from 160 ns (an EPYC 9V45) to 330 ns (an Intel Xeon Platinum 8370C or 8573C) in the same batch of runs, and other machines give other figures.
Expand Down
19 changes: 19 additions & 0 deletions packages/cachekit/bench/ir-workload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,25 @@ export const WORKLOADS = {
};
},
},
// an encrypted set() on Node, L1 on: encode 100 B of msgpack, the AAD-size
// pre-flight, encrypt through the NAPI core, the L1 fill with the
// ciphertext and the backend write. Setup's set derives the keys.
'secure-set-100b': {
n: 20_000,
setup: async () => {
const { createCache } = await load('index.js');
const cache = createCache({
backend: new MemoryBackend(),
encryption: { masterKey: MASTER_KEY },
metrics: false,
});
const value = rowsOf(3);
await cache.set(KEY, value);
return async () => {
await cache.set(KEY, value);
};
},
},
// get() served from a plaintext L1, which holds the decoded value: the
// lookup and the promises around it (l1-get-hit times L1Cache.get alone).
// 4,000 warm-up ops: V8 compiles getEntry with TurboFan at ~2,200.
Expand Down
15 changes: 12 additions & 3 deletions packages/cachekit/src/cache-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,12 @@ export interface ByteStorageLike {

/** Encryption surface CacheImpl drives (see EncryptionManagerCore). */
export interface EncryptionLike {
encrypt(data: Uint8Array, cacheKey: string, compressed?: boolean): Promise<Uint8Array>;
/** The ciphertext when the encrypt need not suspend (NAPI), else a Promise of it. */
encryptNow(
data: Uint8Array,
cacheKey: string,
compressed?: boolean
): Uint8Array | Promise<Uint8Array>;
/** The plaintext when the decrypt need not suspend (NAPI), else a Promise of it. */
decryptNow(
ciphertext: Uint8Array,
Expand Down Expand Up @@ -1502,8 +1507,12 @@ export class CacheImpl implements SecureCache {
let data: Uint8Array;
try {
data = useEnvelope ? this.withEnvelopeCodec((codec) => codec.pack(serialized)) : serialized;
if (this.encryption)
data = await this.encryption.encrypt(data, this.aadKey(key), useEnvelope);
if (this.encryption) {
// On Node.js, once the keys are derived, the encrypt does not suspend,
// and awaiting its plain value would cost the set a microtask turn.
const encrypted = this.encryption.encryptNow(data, this.aadKey(key), useEnvelope);
data = isThenable(encrypted) ? await encrypted : encrypted;
}
} catch (error) {
this.recordFailure('set', error);
if (!this.degradationEnabled) throw error;
Expand Down
65 changes: 58 additions & 7 deletions packages/cachekit/src/cache.encryption-l1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,12 +374,13 @@ describe('L1 zero-knowledge for encrypted caches', () => {
});
});

// On Node.js the decrypt is a synchronous NAPI call, so once the keys are
// derived an encrypted L1 hit has nothing to await, and awaiting even a
// settled value costs the hit a microtask turn: the promise it hands back
// must already be settled. On Workers WebCrypto decrypts asynchronously, so
// there the hit awaits the decrypt.
describe('an encrypted L1 hit awaits only a decrypt that suspends', () => {
// On Node.js the decrypt and the encrypt are synchronous NAPI calls, so once
// the keys are derived an encrypted L1 hit has nothing to await, and awaiting
// even a settled value costs the hit a microtask turn: the promise it hands
// back must already be settled. A set still awaits its backend write, so it
// must take as many turns as a plaintext set. On Workers WebCrypto decrypts
// and encrypts asynchronously, so there the hit and the set await it.
describe('an encrypted L1 hit or set awaits only a decrypt or encrypt that suspends', () => {
const caches: SecureCache[] = [];

afterEach(async () => {
Expand Down Expand Up @@ -501,7 +502,7 @@ describe('an encrypted L1 hit awaits only a decrypt that suspends', () => {
expect(backend.gets).toBe(reads);
});

// Before its keys are derived a cache decrypts through an async function,
// Before its keys are derived a cache decrypts and encrypts through an async function,
// whose Promise is the engine's own: `instanceof` against a replaced global
// Promise misses it, and a Promise taken for a value is never awaited.
async function withReplacedPromise<T>(op: () => Promise<T>): Promise<T> {
Expand Down Expand Up @@ -603,4 +604,54 @@ describe('an encrypted L1 hit awaits only a decrypt that suspends', () => {
expect(fn).toHaveBeenCalledTimes(2);
}
);

/** Microtask turns a plaintext set() of VALUE takes, on a cache configured as makeCache's. */
async function plaintextSetTurns(): Promise<number> {
const plaintext = createCache({
backend: new InMemoryBackend(),
defaultTtl: 3600,
l1: { enabled: true, maxEntries: 100 },
metrics: false,
});
try {
return await turnsToSettle(plaintext.set(KEY, VALUE));
} finally {
await plaintext.close();
}
}

it('on Node.js, an encrypted set() takes as many turns as a plaintext one once the keys are derived', async () => {
const backend = new InMemoryBackend();
const cache = makeCache(backend);
const plaintextTurns = await plaintextSetTurns();

// The first set derives the keys through encrypt(), which suspends.
expect(await turnsToSettle(cache.set('users:1', VALUE))).toBeGreaterThan(plaintextTurns);
for (const key of ['users:1', 'users:2']) {
expect(await turnsToSettle(cache.set(key, VALUE))).toBe(plaintextTurns);
expectCiphertext(l1Entry(cache, key), backend.store.get(key));
}
expect(await makeCache(backend).get('users:2')).toEqual(VALUE);
});

it('an encrypt that suspends, as on Workers, is awaited, and its ciphertext stored', async () => {
const backend = new InMemoryBackend();
const cache = makeCache(backend, { suspends: true });
await cache.set('users:1', VALUE); // derives the keys

expect(await turnsToSettle(cache.set('users:2', VALUE))).toBeGreaterThan(
await plaintextSetTurns()
);
expectCiphertext(l1Entry(cache, 'users:2'), backend.store.get('users:2'));
expect(await makeCache(backend).get('users:2')).toEqual(VALUE);
});

it('a fresh cache’s first set() stores ciphertext when a library has replaced the global Promise', async () => {
const backend = new InMemoryBackend();
const cache = makeCache(backend);

await withReplacedPromise(() => cache.set(KEY, VALUE));
expectCiphertext(l1Entry(cache, KEY), backend.store.get(KEY));
expect(await makeCache(backend).get(KEY)).toEqual(VALUE);
});
});
24 changes: 15 additions & 9 deletions packages/cachekit/src/cache.l2-write-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,22 @@ function breakerOf(cache: AnyCache) {
return reliabilityExecutor.getCircuitBreakerState();
}

/** Make the cache's envelope pack or its encrypt step fail with `error` on every call. */
/**
* Make the cache's envelope pack or its encrypt step fail with `error` on every
* call. Both throw in the call, as the NAPI encrypt does on Node.js once the
* keys are derived.
*/
function failStep(cache: AnyCache, step: 'pack' | 'encrypt', error: Error) {
const internals = cache as unknown as {
byteStorage: { pack: (data: Uint8Array) => Uint8Array };
encryption: { encrypt: (data: Uint8Array) => Promise<Uint8Array> };
encryption: { encryptNow: (data: Uint8Array) => Uint8Array | Promise<Uint8Array> };
};
const fail = () => {
throw error;
};
return step === 'pack'
? vi.spyOn(internals.byteStorage, 'pack').mockImplementation(() => {
throw error;
})
: vi.spyOn(internals.encryption, 'encrypt').mockRejectedValue(error);
? vi.spyOn(internals.byteStorage, 'pack').mockImplementation(fail)
: vi.spyOn(internals.encryption, 'encryptNow').mockImplementation(fail);
}

const caches: AnyCache[] = [];
Expand Down Expand Up @@ -159,13 +164,14 @@ describe('a failed L2 write still fills L1', () => {
expect(await cache.get('users:1')).toEqual({ ssn: CANARY });
});

it('on an encrypted cache L1 holds nothing when encryption itself threw', async () => {
// A rejected Promise, as from the Workers bindings; failStep covers a throw.
it('on an encrypted cache L1 holds nothing when encryption itself rejected', async () => {
setLogger(() => {});
const { backend, calls } = rejectingBackend('authentication');
const cache = makeCache(backend, { encrypted: true });
const encryption = (cache as unknown as { encryption: { encrypt: () => Promise<never> } })
const encryption = (cache as unknown as { encryption: { encryptNow: () => Promise<never> } })
.encryption;
vi.spyOn(encryption, 'encrypt').mockRejectedValue(new Error('encrypt failed'));
vi.spyOn(encryption, 'encryptNow').mockRejectedValue(new Error('encrypt failed'));

await cache.set('users:1', { ssn: CANARY });

Expand Down
Loading
Loading