diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f90dfc..22ffa6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: ["main", "dev"] pull_request: branches: ["main", "dev"] + # Callable so the release workflow gates on the *same* checks rather than a + # re-declared subset that can drift out of step with this file. + workflow_call: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index d839f58..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Publish Docker Image - -on: - push: - tags: - - "v*" - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-and-push-image: - name: Build and push multi-arch image - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - # Required for multi-platform builds (linux/amd64 + linux/arm64) - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml deleted file mode 100644 index 8fa0237..0000000 --- a/.github/workflows/npm.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Publish Packages to NPM - -on: - push: - tags: - - "v*" - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - publish-wasm: - name: Build and publish recached-edge to NPM - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - shared-key: "recached-wasm-cache" - - - name: Install wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20.x" - registry-url: "https://registry.npmjs.org" - - - name: Build Wasm package - run: wasm-pack build wasm-edge --target web --release --out-name recached-edge - - - name: Copy LICENSE + NOTICE into package - run: cp LICENSE.md NOTICE wasm-edge/pkg/ - - - name: Set package name and version - # wasm-pack derives the npm name and version from the crate — patch both so the - # published package always matches the git tag (e.g. v0.1.5 → 0.1.5). - run: | - node -e " - const fs = require('fs'); - const path = 'wasm-edge/pkg/package.json'; - const pkg = JSON.parse(fs.readFileSync(path, 'utf8')); - pkg.name = 'recached-edge'; - pkg.version = process.env.TAG_VERSION.replace(/^v/, ''); - fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); - console.log('Package:', pkg.name, pkg.version); - " - env: - TAG_VERSION: ${{ github.ref_name }} - - - name: Publish to NPM - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: cd wasm-edge/pkg && npm publish --access public - - publish-react: - name: Build and publish @recached/react to NPM - runs-on: ubuntu-latest - needs: publish-wasm - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20.x" - registry-url: "https://registry.npmjs.org" - - - name: Install dependencies - run: cd sdks/recached-react && npm install --legacy-peer-deps - - - name: Typecheck - run: cd sdks/recached-react && npm run typecheck - - - name: Build - run: cd sdks/recached-react && npm run build - - - name: Copy LICENSE + NOTICE into package - run: cp LICENSE.md NOTICE sdks/recached-react/ - - - name: Publish to NPM - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: cd sdks/recached-react && npm publish --access public - - publish-vue: - name: Build and publish @recached/vue to NPM - runs-on: ubuntu-latest - needs: publish-wasm - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20.x" - registry-url: "https://registry.npmjs.org" - - - name: Install dependencies - run: cd sdks/recached-vue && npm install --legacy-peer-deps - - - name: Typecheck - run: cd sdks/recached-vue && npm run typecheck - - - name: Build - run: cd sdks/recached-vue && npm run build - - - name: Copy LICENSE + NOTICE into package - run: cp LICENSE.md NOTICE sdks/recached-vue/ - - - name: Publish to NPM - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: cd sdks/recached-vue && npm publish --access public diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33c1343..eb7a690 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,13 @@ -name: Release Binaries +name: Release + +# One workflow for everything a tag ships: GitHub Release binaries, the +# multi-arch container image, and the three npm packages. +# +# These were three separate workflows on the same `v*` trigger, which produced +# three entries per tag in the Actions list and, more importantly, three +# independent runs — npm could publish while the binaries failed, leaving a +# half-released version. An npm publish cannot be undone after 72 hours, so +# nothing publishes until the full CI suite and the tag checks have passed. on: push: @@ -7,10 +16,62 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +# Two tags pushed close together must not publish over each other. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - build-and-upload: + # ── Gates ─────────────────────────────────────────────────────────────────── + # + # The full CI suite: fmt, clippy, tests, both coverage floors, browser tests, + # and the React/Vue typechecks. Reused rather than restated so the release + # gate cannot drift from what CI actually enforces on the branch. + ci: + name: CI + uses: ./.github/workflows/ci.yml + + tag-checks: + name: Verify tag + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # The npm job rewrites each package version from the tag, so a tag that + # disagrees with Cargo.toml would publish artifacts labelled with a + # version the source never claimed. Catch it before anything ships. + - name: Check tag matches workspace version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + CARGO_VERSION="$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)" + echo "tag=$TAG_VERSION cargo=$CARGO_VERSION" + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match Cargo.toml version $CARGO_VERSION" + exit 1 + fi + + # A release whose notes were never written is a release nobody can read. + - name: Check CHANGELOG has an entry for this version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + if ! grep -qE "^## \[?${TAG_VERSION}\]?" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## [$TAG_VERSION]' section" + exit 1 + fi + if grep -qE "^## \[?${TAG_VERSION}\]?.*Unreleased" CHANGELOG.md; then + echo "::error::CHANGELOG.md still marks $TAG_VERSION as Unreleased" + exit 1 + fi + + # ── Binaries ──────────────────────────────────────────────────────────────── + binaries: name: Build (${{ matrix.target }}) + needs: [ci, tag-checks] runs-on: ${{ matrix.runner }} permissions: contents: write @@ -65,3 +126,142 @@ jobs: asset_name: ${{ matrix.artifact }} tag: ${{ github.ref }} overwrite: true + + # ── Container image ───────────────────────────────────────────────────────── + docker: + name: Build and push multi-arch image + needs: [ci, tag-checks] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Required for multi-platform builds (linux/amd64 + linux/arm64) + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ── npm packages ──────────────────────────────────────────────────────────── + # + # The framework SDKs depend on recached-edge, so it publishes first. + npm-wasm: + name: Publish recached-edge to NPM + needs: [ci, tag-checks] + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: "recached-wasm-cache" + + - name: Install wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + registry-url: "https://registry.npmjs.org" + + - name: Build Wasm package + run: wasm-pack build wasm-edge --target web --release --out-name recached-edge + + - name: Copy LICENSE + NOTICE into package + run: cp LICENSE.md NOTICE wasm-edge/pkg/ + + - name: Set package name and version + # wasm-pack derives the npm name and version from the crate — patch both so the + # published package always matches the git tag (e.g. v0.1.5 → 0.1.5). + run: | + node -e " + const fs = require('fs'); + const path = 'wasm-edge/pkg/package.json'; + const pkg = JSON.parse(fs.readFileSync(path, 'utf8')); + pkg.name = 'recached-edge'; + pkg.version = process.env.TAG_VERSION.replace(/^v/, ''); + fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); + console.log('Package:', pkg.name, pkg.version); + " + env: + TAG_VERSION: ${{ github.ref_name }} + + - name: Publish to NPM + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: cd wasm-edge/pkg && npm publish --access public + + npm-sdks: + name: Publish @recached/${{ matrix.package }} to NPM + needs: npm-wasm + runs-on: ubuntu-latest + + strategy: + # One SDK failing must not stop the other from publishing: they are + # independent packages, and finishing a partial release is easier than + # unpicking one. + fail-fast: false + matrix: + package: [react, vue] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: cd sdks/recached-${{ matrix.package }} && npm install --legacy-peer-deps + + - name: Typecheck + run: cd sdks/recached-${{ matrix.package }} && npm run typecheck + + - name: Build + run: cd sdks/recached-${{ matrix.package }} && npm run build + + - name: Copy LICENSE + NOTICE into package + run: cp LICENSE.md NOTICE sdks/recached-${{ matrix.package }}/ + + - name: Publish to NPM + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: cd sdks/recached-${{ matrix.package }} && npm publish --access public diff --git a/CHANGELOG.md b/CHANGELOG.md index 55753c8..7224bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,241 @@ All notable changes to Recached are documented here. --- +## [0.2.2] — 2026-07-20 + +### Added + +- **`ESET` — connection-scoped keys for presence.** A key written with `ESET` lives exactly as long + as the connection that wrote it; when that connection closes the server deletes it and pushes the + deletion to live queries. Presence, cursors and "who is online" previously had to be hand-rolled + with `SETEX` plus a heartbeat, which leaves ghost entries for the length of the TTL whenever a tab + closes. + + Ownership transfers on each write, which is what makes multiple tabs behave: two tabs both setting + `presence:user:42` leave the **later** one as owner, so closing the first does not mark the user + offline. Replicas receive the write as a plain `SET` — they have no connection to scope a lifetime + to, and the owning server broadcasts the deletion. + +- **`onOutboxFull()` and `pendingWrites()` on the browser SDK.** The offline queue holds 10 000 + writes and evicts the oldest past that — previously in silence, with no error and no signal, so an + application could not tell that a user's write had been discarded. `onOutboxFull(cb)` reports the + dropped row id and the remaining depth; `pendingWrites()` exposes the depth for a "syncing…" + indicator or to apply back-pressure before the cap is reached. + +- **Capacity and sync metrics.** Seven new series, sampled every 5 seconds because capacity is a + level rather than an event: `recached_memory_bytes`, `recached_keys`, `recached_evictions_total`, + `recached_replicas_connected`, `recached_live_queries`, `recached_watched_keys`, and + `recached_dedup_clients_tracked`, and `recached_replication_queue_depth`. Previously only traffic + was exported, so an operator could not + answer "am I near the cap?" or "is eviction thrashing?" from a dashboard. Replication lag landed + separately in this release; browser outbox depth remains unexported because it lives in the client + — see [Operations](docs/server/operations.md). + +- **`HELLO` and RESP3 negotiation on the TCP port.** A connection starts in RESP2 and `HELLO 3` + switches it to RESP3; `HELLO 2` switches back, a bare `HELLO` reports without changing, and an + unsupported version returns `-NOPROTO` while leaving the connection on what it had. The reply is a + RESP3 map on a RESP3 connection and a flat array on a RESP2 one, matching Redis. `HELLO` requires + authentication — the pre-auth reply carries no server details, so it cannot be used to fingerprint + a deployment. + + This also adds a `Map` type (`%N`) to the RESP codec, with the header counting *pairs* rather than + elements. + +- **Replication offset acknowledgement, and a true lag metric.** Replicas now acknowledge each frame + they apply on the existing replication socket, and the primary exports + `recached_replication_lag_frames` — frames sent to the furthest-behind replica but not yet + acknowledged. + + `recached_replication_queue_depth` only ever showed work stuck in the primary's send queue, so the + case that matters most read as healthy: a replica that has received everything and is not applying + it shows an empty queue and unbounded lag. Acknowledgements are monotonic, so a reordered or + replayed ack cannot walk the high-water mark backwards. + + A replica older than 0.2.2 never acknowledges, so its lag climbs while replication works normally — + upgrade both ends together. + +### Changed + +- **Command arguments are moved rather than copied.** `extract_string` built every argument with + `from_utf8_lossy(..).into_owned()`, which allocates a fresh `String` and memcpys the payload even + when the bytes are already valid UTF-8 — which they nearly always are. Parsing now moves the byte + buffer the RESP parser already allocated, so a 1 MB `SET` value costs no copy at all. + + Applied to the commands that actually carry payloads: `SET`, `MSET`, `HSET`/`HMSET`, `APPEND`, + `GETSET`, `SETNX`, `JSET`, `JMERGE`, and all 22 bulk argument lists (`RPUSH`, `SADD`, `ZADD`, …). + The long tail of small-argument commands still copies; the remaining win there is negligible and + the risk of touching 125 parse arms is not. + + Argument slots are consumed, so an arm must read each index once — new tests cover the shapes + where that matters (`SET`'s option scan after moving key and value, `MSET`/`HSET` pair splitting, + a 1 MB round-trip, and invalid UTF-8 falling back to a lossy re-encode). + + **Not benchmarked.** The gain is a removed allocation and memcpy per argument, which is + structural, but the machine available could not produce trustworthy figures — see the + [benchmarks](docs/guide/benchmarks) caveat. Re-measure on a quiet host before quoting numbers. + +- **Rate-limiter memory is bounded.** A limiter stored one timestamp per attempt, so + `RLSET key 100000 3600` held 100 000 `u64`s — roughly 800 KB for a single key, and token-cost + limiting (roadmap #9) makes six-figure limits ordinary. Attempts are now counted into 64 buckets, + capping a limiter at about 1 KB whatever the limit. + + The trade-off is granularity: the window advances one bucket at a time, so a limiter is exact to + within `window / 64`. Attempts are never under-counted — a bucket leaves the window only once it + is entirely outside it — so the limiter errs toward rejecting slightly early rather than admitting + over the limit. `retry_after_ms` is clamped to the window. + + Limiter *attempt* state is no longer persisted in snapshots (configuration still is). It ages out + within a single window and a restart has already interrupted that window, so a restored limiter + enforces the same policy from a clean slate rather than a stale partial count. + +- **Per-connection limits are configurable** rather than compiled in, because the right value is + workload-dependent: `RECACHED_MAX_MULTI_QUEUE`, `RECACHED_MAX_WATCHES_PER_CONN`, + `RECACHED_MAX_LIVE_QUERIES`, `RECACHED_MAX_QSUB_INITIAL_KEYS`, and `RECACHED_EVICTION_SAMPLE` (the + knob Redis exposes as `maxmemory-samples`). Defaults are unchanged. The browser outbox cap is now + settable through `sync-client` rather than fixed at 10 000. + +- **Live queries now carry collection values.** `qstate` and `keychange` previously delivered only a + *type name* for hashes, lists, sets, sorted sets and JSON, so every subscriber had to follow up with + `HGETALL`/`LRANGE`/`JGET` — a network round-trip in a system whose premise is that reads are local. + Collections now arrive **type-tagged and complete**: + + ```text + hash → ["hash", field, value, ...] fields sorted + list → ["list", element, ...] head to tail + set → ["set", member, ...] + zset → ["zset", member, score, ...] ascending score + json → ["json", document] + ``` + + The tag is required for the payload to be unambiguous — a four-element array would otherwise be + indistinguishable between a list of four items and a hash of two pairs. Ordering is deterministic + so two clients build identical local state, and each notification carries the complete value, which + is what allows a removed member to propagate. + + ::: warning Wire-format change + A client older than 0.2.2 does not understand the tagged shape and will ignore collection values + from live queries. Server and SDKs are released in lockstep at the same version — run matching + versions. + ::: + +- **`FLUSHDB` now reaches live queries.** `primary_keys()` is empty for `FLUSHDB`, so the generic + notifier had nothing to announce and subscribers silently kept serving data the server had already + wiped. + + Announcing per deleted key would mean one frame per key in the keyspace for a single command, so + the server emits **one sentinel per registered pattern** instead — a `keychange` whose key is the + pattern and whose value is nil. Clients expand it locally to "every key matching this pattern is + gone", which is O(patterns) rather than O(keys). Explicitly `WATCH`ed keys are still notified + individually, since that set is bounded and callers expect per-key precision there. + +- **Reconnect backoff is jittered.** Delays now land in `[nominal/2, nominal]` instead of an exact + `500ms × 2^attempts`. Without jitter every client disconnected by the same event computes an + identical schedule and reconnects in lockstep — a thundering herd that can keep a recovering server + down. The jitter source is seeded from the client id rather than a system RNG, so `sync-client` + stays dependency-free and I/O-free and the sequence remains reproducible in tests. + +### Fixed + +- **Values were silently corrupted unless they were valid UTF-8; they are now byte-transparent.** + Values were stored as `String`, so a value containing invalid UTF-8 was converted to U+FFFD + replacement characters on the way in. `SET` returned `OK`, `GET` returned bytes that differed from + what was written, and nothing anywhere reported a problem — the original bytes were destroyed at + parse time, before storage, so there was nothing to recover. + + This affected **every transport, TCP included** — not only WebSocket, as the roadmap previously + recorded. `SET k <0xFF 0xFE 0x41>` over plain RESP came back as `EF BF BD EF BF BD 41`. + + Values are now stored and returned as the exact bytes sent, matching Redis. The change runs the + full depth of the stack: the store's string, list and hash types; command parsing; the RESP + encoder used for replication, AOF and browser sync; pub/sub payloads; and the browser's IndexedDB + write-ahead log. + + **Identifiers stay text.** Keys, hash fields, set and sorted-set members, glob patterns and channel + names must be valid UTF-8, and a command carrying a binary one is refused before anything is + written: + + ``` + ERR argument 1 is not valid UTF-8. Keys, fields, members and patterns must be text; + only values may be binary + ``` + + Redis permits binary there too, but those positions are looked up, glob-matched and checked against + sync scopes as text, so a binary identifier would be unreachable through its own access paths. It + is recorded on the [roadmap](docs/roadmap.md) rather than scheduled. + + **Snapshots remain compatible.** A snapshot written by 0.2.1 or earlier still loads: values were + msgpack strings then and are msgpack binary now, and the decoder accepts either. Binary values + encode as msgpack `bin` rather than an array of integers, so snapshot size is unchanged for text + and roughly halved versus the naive encoding for binary. + + **The browser SDK handles binary end to end.** `setBytes()` / `getBytes()` and `publishBytes()` + are new, and binary survives the offline outbox, the exactly-once `DEDUP` envelope, cross-tab + `BroadcastChannel` sync and IndexedDB persistence unchanged. Frames that carry binary now travel + in WebSocket *binary* frames in both directions — the socket's `binaryType` is `arraybuffer`, so + an inbound binary frame is no longer silently dropped by the message handler. + + `cache.get()` now **throws** on a binary value instead of returning mangled text, `getJSON()` + treats one as a miss, and an `onMessage` listener receives a `Uint8Array` for a binary pub/sub + payload — so the listener signature widened to `string | Uint8Array`. `getMatching()` — the read + behind every live query — returns a `Uint8Array` for a binary value rather than a lossy string, + which was the last silent conversion left on the browser read path. + + **`@recached/react` and `@recached/vue`** follow: `useKeyBytes()` is new, `usePubSub` handlers and + the `KeyValuePair` value type widened to `string | Uint8Array`, and `useKey` returns `null` for a + binary value rather than letting `get()` throw out of a React `getSnapshot` or a Vue reactive + update — which would have taken down the render tree over a value the hook cannot represent. + + These are **compile-time breaking changes for TypeScript users** who annotated a `usePubSub` + handler or an `onMessage` listener as `(msg: string) => void`, or who destructured a + `KeyValuePair` value as `string | null`. Widen the annotation, or narrow with `typeof v === 'string'`. + + Data corrupted by an earlier version cannot be recovered and must be re-populated. + +- **Pub/sub deliveries never reached a TCP subscriber that only listened.** Deliveries were written + into a 32 KB buffered writer and flushed only at the end of a client-command batch, so a + connection that subscribed and then waited received nothing until it happened to send another + command or 32 KB of messages accumulated. A subscriber that also polled looked fine, which is how + this survived. Deliveries are now flushed on write. + +- **Pub/sub frames were RESP3 Push on RESP2 connections.** Every delivery was a `>` frame regardless + of protocol. RESP2 has no push type, so a standard Redis client that subscribed without sending + `HELLO 3` could not parse what it was sent. Frame type now follows the negotiated version. The + WebSocket transport is unchanged — it is RESP3 by definition, and `HELLO 2` on it is refused + rather than silently ignored. + +- **WebSocket command frames must now be accepted in binary as well as text.** The handler matched + text frames only, so a binary frame was dropped without a reply — and the WebSocket spec requires + text frames to be well-formed UTF-8, which left no way to send bytes at all. Replies are sent as + text when the RESP bytes are valid UTF-8 and binary otherwise, so existing clients see no change. + + This makes the transport byte-clean; the values travelling over it became byte-transparent in the + same release — see below. + +- **Exactly-once delivery now survives a server restart.** Dedup high-water marks were held only in + memory, so a restart inside the acknowledgement window let a client's replayed write apply twice — + the last standing caveat on the guarantee. Marks are now persisted to a `.dedup` sidecar beside the + snapshot, written atomically and only when a mark advances, and restored before the server accepts + connections. + + The map is one `u64` per client, so it is flushed on a 1-second timer as well as with each + snapshot: the residual window on an unclean shutdown is bounded by that interval rather than by the + snapshot cadence. A missing or corrupt sidecar is logged and ignored rather than being fatal — + losing the bookkeeping is bad, refusing to boot is worse. + +- **WAL compaction could destroy the browser's persisted cache.** Compaction cleared the write-ahead + log in one IndexedDB transaction and wrote the replacement snapshot in later ones, so an + interruption between them left an empty WAL and no snapshot. The existing code comment anticipated + this window; it is now closed by doing the clear and the rewrite in a **single transaction**, which + IndexedDB commits or rolls back as a unit. + +- **`ESET` would not have replicated.** `is_write_command` is a `matches!` list, which — unlike a + `match` — has no exhaustiveness check, so a newly added command silently defaults to "not a write" + and never reaches replicas, the AOF, or live queries. Caught while wiring `ESET`; a cross-check test + now asserts that every command reporting written keys is also classified as a write, so the next + addition cannot repeat it. + +--- + ## [0.2.1] — 2026-07-19 ### Fixed — Browser SDK (critical) diff --git a/Cargo.lock b/Cargo.lock index b815a11..12a95a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,12 +124,13 @@ dependencies = [ [[package]] name = "core-engine" -version = "0.2.1" +version = "0.2.2" dependencies = [ "dashmap", "indexmap", "js-sys", "rand", + "rmp-serde", "serde", "serde_json", ] @@ -161,9 +162,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -600,9 +601,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "metrics" -version = "0.24.5" +version = "0.24.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" dependencies = [ "portable-atomic", "rapidhash", @@ -938,15 +939,6 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1066,7 +1058,7 @@ dependencies = [ [[package]] name = "server-native" -version = "0.2.1" +version = "0.2.2" dependencies = [ "base64", "core-engine", @@ -1076,7 +1068,6 @@ dependencies = [ "metrics-exporter-prometheus", "num_cpus", "rmp-serde", - "rustls-pemfile", "serde", "sha2", "socket2 0.5.10", @@ -1203,7 +1194,7 @@ dependencies = [ [[package]] name = "sync-client" -version = "0.2.1" +version = "0.2.2" dependencies = [ "core-engine", ] @@ -1589,7 +1580,7 @@ checksum = "60238e5b4b1b295701d6f9a66d2a126fe19990348f5fb9dae3b623a370119d94" [[package]] name = "wasm-edge" -version = "0.2.1" +version = "0.2.2" dependencies = [ "core-engine", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index 04a5135..148f012 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "2" # ── Single source of truth for all crate versions ──────────────────────────── # Members inherit with: version.workspace = true / edition.workspace = true [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2024" license = "Apache-2.0" authors = ["ThinkGrid Labs"] @@ -37,7 +37,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } metrics = "0.24" metrics-exporter-prometheus = { version = "0.16", features = ["http-listener"] } tokio-rustls = "0.26" -rustls-pemfile = "2" # serialization serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index a3e2ee7..b3d40a2 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ See [recached.dev/roadmap](https://recached.dev/roadmap) for what's planned. Reach out: [dennis@thinkgrid.dev](mailto:dennis@thinkgrid.dev) + ## Support Recached Recached is free and open-source, maintained by one person. If it saves you infrastructure cost or development time, [sponsoring on GitHub](https://github.com/sponsors/thinkgrid-labs) directly funds continued development: more Redis commands, RESP3, cluster support, and performance work. diff --git a/core-engine/Cargo.toml b/core-engine/Cargo.toml index 990112c..2fbc2d8 100644 --- a/core-engine/Cargo.toml +++ b/core-engine/Cargo.toml @@ -15,3 +15,8 @@ serde_json.workspace = true # crate is compiled into the browser SDK, so it needs the platform clock there. [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys.workspace = true + +[dev-dependencies] +# Snapshot back-compatibility tests need to write a pre-0.2.2 snapshot, where +# values were msgpack strings rather than binary. +rmp-serde.workspace = true diff --git a/core-engine/src/cmd.rs b/core-engine/src/cmd.rs index 0877954..73871ae 100644 --- a/core-engine/src/cmd.rs +++ b/core-engine/src/cmd.rs @@ -47,19 +47,26 @@ pub struct ZAddOptions { pub enum Command { Ping(Option), Auth(String), + /// `HELLO [protover]` — protocol negotiation. Connection-level like AUTH: + /// the store never sees it, because the answer depends on the connection. + Hello(Option), // ── Strings ────────────────────────────────────────────────────────────── - Set(String, String, SetOptions), + Set(String, Vec, SetOptions), Get(String), + /// ESET — a SET whose key is owned by the connection that wrote it. + /// The engine stores it like any string; the *server* deletes it when that + /// connection closes. Presence, cursors, "who is online". + ESet(String, Vec), Del(Vec), Unlink(Vec), - Append(String, String), + Append(String, Vec), Strlen(String), - GetSet(String, String), + GetSet(String, Vec), MGet(Vec), - MSet(Vec<(String, String)>), - SetNx(String, String), - SetEx(String, u64, String), - PSetEx(String, u64, String), + MSet(Vec<(String, Vec)>), + SetNx(String, Vec), + SetEx(String, u64, Vec), + PSetEx(String, u64, Vec), Incr(String), Decr(String), IncrBy(String, i64), @@ -81,7 +88,7 @@ pub enum Command { Rename(String, String), Type(String), // ── Hash ───────────────────────────────────────────────────────────────── - HSet(String, Vec<(String, String)>), + HSet(String, Vec<(String, Vec)>), HGet(String, String), HGetAll(String), HDel(String, Vec), @@ -91,20 +98,20 @@ pub enum Command { HIncrBy(String, String, i64), HIncrByFloat(String, String, f64), HExists(String, String), - HSetNx(String, String, String), + HSetNx(String, String, Vec), HMGet(String, Vec), // ── List ───────────────────────────────────────────────────────────────── - LPush(String, Vec), - RPush(String, Vec), - LPushX(String, Vec), - RPushX(String, Vec), + LPush(String, Vec>), + RPush(String, Vec>), + LPushX(String, Vec>), + RPushX(String, Vec>), LPop(String, Option), RPop(String, Option), LRange(String, i64, i64), LLen(String), LIndex(String, i64), - LSet(String, i64, String), - LRem(String, i64, String), + LSet(String, i64, Vec), + LRem(String, i64, Vec), LTrim(String, i64, i64), // ── Set ────────────────────────────────────────────────────────────────── SAdd(String, Vec), @@ -160,7 +167,7 @@ pub enum Command { Unsubscribe(Vec), PSubscribe(Vec), PUnsubscribe(Vec), - Publish(String, String), + Publish(String, Vec), // ── Observable keys ─────────────────────────────────────────────────────── Watch(Vec), Unwatch(Vec), @@ -194,7 +201,7 @@ pub enum Command { impl Command { pub fn from_value(value: Value) -> Result { match value { - Value::Array(Some(arr)) => { + Value::Array(Some(mut arr)) => { if arr.is_empty() { return Err("Empty command".to_string()); } @@ -204,6 +211,26 @@ impl Command { _ => return Err("Invalid command name type".to_string()), }; + // Payload arguments may be arbitrary bytes; identifier + // arguments may not. + // + // Keys, hash fields, set and sorted-set members, glob patterns + // and channel names are looked up, matched and routed as text, + // so a non-UTF-8 one cannot be handled faithfully. Rather than + // lossily converting it — which returns OK and stores something + // different from what was sent — the command is refused here, + // before anything is written. Values are exempt: they are + // stored verbatim. See `is_payload_index`. + if let Some(pos) = (1..arr.len()).find(|&i| { + !is_payload_index(&cmd_name, i) + && matches!(&arr[i], Value::BulkString(Some(b)) if std::str::from_utf8(b).is_err()) + }) { + return Err(format!( + "ERR argument {pos} is not valid UTF-8. Keys, fields, members and \ + patterns must be text; only values may be binary" + )); + } + macro_rules! need { ($n:expr) => { if arr.len() < $n { @@ -229,12 +256,20 @@ impl Command { need!(2); Ok(Command::Auth(extract_string(&arr[1]).unwrap_or_default())) } + // Bare HELLO reports the current version without changing it. + // Trailing AUTH/SETNAME arguments are not supported and are + // rejected by the connection layer rather than ignored. + "HELLO" => Ok(Command::Hello(if arr.len() > 1 { + Some(extract_string(&arr[1]).unwrap_or_default()) + } else { + None + })), // ── Strings ─────────────────────────────────────────────── "SET" => { need!(3); - let key = extract_key(&arr[1])?; - let val = extract_string(&arr[2]).unwrap_or_default(); + let key = take_key(&mut arr[1])?; + let val = take_bytes(&mut arr[2]).unwrap_or_default(); let mut opts = SetOptions::default(); let mut i = 3usize; while i < arr.len() { @@ -308,6 +343,13 @@ impl Command { need!(2); Ok(Command::Get(extract_key(&arr[1])?)) } + "ESET" => { + need!(3); + Ok(Command::ESet( + extract_key(&arr[1])?, + extract_bytes(&arr[2]).unwrap_or_default(), + )) + } "DEL" => { need!(2); Ok(Command::Del(extract_keys(&arr[1..])?)) @@ -318,10 +360,9 @@ impl Command { } "APPEND" => { need!(3); - Ok(Command::Append( - extract_key(&arr[1])?, - extract_string(&arr[2]).unwrap_or_default(), - )) + let key = take_key(&mut arr[1])?; + let val = take_bytes(&mut arr[2]).unwrap_or_default(); + Ok(Command::Append(key, val)) } "STRLEN" => { need!(2); @@ -329,10 +370,9 @@ impl Command { } "GETSET" => { need!(3); - Ok(Command::GetSet( - extract_key(&arr[1])?, - extract_string(&arr[2]).unwrap_or_default(), - )) + let key = take_key(&mut arr[1])?; + let val = take_bytes(&mut arr[2]).unwrap_or_default(); + Ok(Command::GetSet(key, val)) } "MGET" => { need!(2); @@ -345,11 +385,12 @@ impl Command { ); } let pairs = arr[1..] - .chunks(2) + .chunks_mut(2) .map(|c| { + let (k, v) = c.split_at_mut(1); Ok(( - extract_key(&c[0])?, - extract_string(&c[1]).unwrap_or_default(), + take_key(&mut k[0])?, + take_bytes(&mut v[0]).unwrap_or_default(), )) }) .collect::, String>>()?; @@ -357,10 +398,9 @@ impl Command { } "SETNX" => { need!(3); - Ok(Command::SetNx( - extract_key(&arr[1])?, - extract_string(&arr[2]).unwrap_or_default(), - )) + let key = take_key(&mut arr[1])?; + let val = take_bytes(&mut arr[2]).unwrap_or_default(); + Ok(Command::SetNx(key, val)) } "SETEX" => { need!(4); @@ -371,7 +411,7 @@ impl Command { Ok(Command::SetEx( extract_key(&arr[1])?, secs as u64, - extract_string(&arr[3]).unwrap_or_default(), + extract_bytes(&arr[3]).unwrap_or_default(), )) } "PSETEX" => { @@ -383,7 +423,7 @@ impl Command { Ok(Command::PSetEx( extract_key(&arr[1])?, ms as u64, - extract_string(&arr[3]).unwrap_or_default(), + extract_bytes(&arr[3]).unwrap_or_default(), )) } "INCR" => { @@ -575,11 +615,10 @@ impl Command { // ── JSON ─────────────────────────────────────────────────── "JSET" => { need!(4); - Ok(Command::JSet( - extract_key(&arr[1])?, - extract_string(&arr[2]).unwrap_or_default(), - extract_string(&arr[3]).unwrap_or_default(), - )) + let key = take_key(&mut arr[1])?; + let path = take_string(&mut arr[2]).unwrap_or_default(); + let doc = take_string(&mut arr[3]).unwrap_or_default(); + Ok(Command::JSet(key, path, doc)) } "JGET" => { need!(2); @@ -592,10 +631,9 @@ impl Command { } "JMERGE" => { need!(3); - Ok(Command::JMerge( - extract_key(&arr[1])?, - extract_string(&arr[2]).unwrap_or_default(), - )) + let key = take_key(&mut arr[1])?; + let patch = take_string(&mut arr[2]).unwrap_or_default(); + Ok(Command::JMerge(key, patch)) } // ── Rate limiting ────────────────────────────────────────── @@ -629,11 +667,12 @@ impl Command { } let key = extract_key(&arr[1])?; let pairs = arr[2..] - .chunks(2) + .chunks_mut(2) .map(|c| { + let (f, v) = c.split_at_mut(1); ( - extract_string(&c[0]).unwrap_or_default(), - extract_string(&c[1]).unwrap_or_default(), + take_string(&mut f[0]).unwrap_or_default(), + take_bytes(&mut v[0]).unwrap_or_default(), ) }) .collect(); @@ -655,7 +694,7 @@ impl Command { "HDEL" => { need!(3); let key = extract_key(&arr[1])?; - let fields = arr[2..].iter().filter_map(extract_string).collect(); + let fields = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::HDel(key, fields)) } "HKEYS" => { @@ -699,13 +738,13 @@ impl Command { Ok(Command::HSetNx( extract_string(&arr[1]).unwrap_or_default(), extract_string(&arr[2]).unwrap_or_default(), - extract_string(&arr[3]).unwrap_or_default(), + extract_bytes(&arr[3]).unwrap_or_default(), )) } "HMGET" => { need!(3); let key = extract_key(&arr[1])?; - let fields = arr[2..].iter().filter_map(extract_string).collect(); + let fields = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::HMGet(key, fields)) } @@ -713,25 +752,25 @@ impl Command { "LPUSH" => { need!(3); let key = extract_key(&arr[1])?; - let vals = arr[2..].iter().filter_map(extract_string).collect(); + let vals = arr[2..].iter_mut().filter_map(take_bytes).collect(); Ok(Command::LPush(key, vals)) } "RPUSH" => { need!(3); let key = extract_key(&arr[1])?; - let vals = arr[2..].iter().filter_map(extract_string).collect(); + let vals = arr[2..].iter_mut().filter_map(take_bytes).collect(); Ok(Command::RPush(key, vals)) } "LPUSHX" => { need!(3); let key = extract_key(&arr[1])?; - let vals = arr[2..].iter().filter_map(extract_string).collect(); + let vals = arr[2..].iter_mut().filter_map(take_bytes).collect(); Ok(Command::LPushX(key, vals)) } "RPUSHX" => { need!(3); let key = extract_key(&arr[1])?; - let vals = arr[2..].iter().filter_map(extract_string).collect(); + let vals = arr[2..].iter_mut().filter_map(take_bytes).collect(); Ok(Command::RPushX(key, vals)) } "LPOP" => { @@ -778,7 +817,7 @@ impl Command { Ok(Command::LSet( extract_string(&arr[1]).unwrap_or_default(), extract_int(&arr[2])?, - extract_string(&arr[3]).unwrap_or_default(), + extract_bytes(&arr[3]).unwrap_or_default(), )) } "LREM" => { @@ -786,7 +825,7 @@ impl Command { Ok(Command::LRem( extract_string(&arr[1]).unwrap_or_default(), extract_int(&arr[2])?, - extract_string(&arr[3]).unwrap_or_default(), + extract_bytes(&arr[3]).unwrap_or_default(), )) } "LTRIM" => { @@ -801,8 +840,8 @@ impl Command { // ── Set ──────────────────────────────────────────────────── "SADD" => { need!(3); - let key = extract_key(&arr[1])?; - let members = arr[2..].iter().filter_map(extract_string).collect(); + let key = take_key(&mut arr[1])?; + let members = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::SAdd(key, members)) } "SMEMBERS" => { @@ -814,7 +853,7 @@ impl Command { "SREM" => { need!(3); let key = extract_key(&arr[1])?; - let members = arr[2..].iter().filter_map(extract_string).collect(); + let members = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::SRem(key, members)) } "SCARD" => { @@ -831,43 +870,43 @@ impl Command { "SMISMEMBER" => { need!(3); let key = extract_key(&arr[1])?; - let members = arr[2..].iter().filter_map(extract_string).collect(); + let members = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::SMIsMember(key, members)) } "SINTER" => { need!(2); Ok(Command::SInter( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )) } "SINTERSTORE" => { need!(3); let dst = extract_string(&arr[1]).unwrap_or_default(); - let keys = arr[2..].iter().filter_map(extract_string).collect(); + let keys = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::SInterStore(dst, keys)) } "SUNION" => { need!(2); Ok(Command::SUnion( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )) } "SUNIONSTORE" => { need!(3); let dst = extract_string(&arr[1]).unwrap_or_default(); - let keys = arr[2..].iter().filter_map(extract_string).collect(); + let keys = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::SUnionStore(dst, keys)) } "SDIFF" => { need!(2); Ok(Command::SDiff( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )) } "SDIFFSTORE" => { need!(3); let dst = extract_string(&arr[1]).unwrap_or_default(); - let keys = arr[2..].iter().filter_map(extract_string).collect(); + let keys = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::SDiffStore(dst, keys)) } "SPOP" => { @@ -1020,7 +1059,7 @@ impl Command { "ZMSCORE" => { need!(3); let key = extract_key(&arr[1])?; - let members = arr[2..].iter().filter_map(extract_string).collect(); + let members = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::ZMScore(key, members)) } "ZRANK" => { @@ -1040,7 +1079,7 @@ impl Command { "ZREM" => { need!(3); let key = extract_key(&arr[1])?; - let members = arr[2..].iter().filter_map(extract_string).collect(); + let members = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::ZRem(key, members)) } "ZCARD" => { @@ -1074,26 +1113,26 @@ impl Command { "SUBSCRIBE" => { need!(2); Ok(Command::Subscribe( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )) } "UNSUBSCRIBE" => Ok(Command::Unsubscribe( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )), "PSUBSCRIBE" => { need!(2); Ok(Command::PSubscribe( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )) } "PUNSUBSCRIBE" => Ok(Command::PUnsubscribe( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )), "PUBLISH" => { need!(3); Ok(Command::Publish( extract_string(&arr[1]).unwrap_or_default(), - extract_string(&arr[2]).unwrap_or_default(), + extract_bytes(&arr[2]).unwrap_or_default(), )) } @@ -1101,11 +1140,11 @@ impl Command { "WATCH" => { need!(2); Ok(Command::Watch( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )) } "UNWATCH" => Ok(Command::Unwatch( - arr[1..].iter().filter_map(extract_string).collect(), + arr[1..].iter_mut().filter_map(take_string).collect(), )), // ── Persistence ─────────────────────────────────────────── @@ -1175,6 +1214,84 @@ fn extract_keys(vals: &[Value]) -> Result, String> { vals.iter().map(extract_key).collect() } +/// Take a string argument by **moving** its bytes out of the parsed frame. +/// +/// `extract_string` borrows and therefore copies: `from_utf8_lossy(..).into_owned()` +/// allocates a fresh `String` and memcpys the payload even when the bytes are +/// already valid UTF-8, which they almost always are. Moving reuses the `Vec` +/// the parser already allocated, so a 1 MB `SET` value costs no copy at all. +/// +/// The slot is left as a nil array, so an arm must not read the same index +/// twice — call this last, once per argument. +/// Is argument `i` of `cmd_name` a value payload rather than an identifier? +/// +/// Payloads are stored verbatim and may be any bytes. Everything else is used +/// as a lookup key, a glob pattern or a routing name, and must be text. Index 0 +/// is the command name and is never a payload. +fn is_payload_index(cmd_name: &str, i: usize) -> bool { + match cmd_name { + // key value + "SET" | "ESET" | "APPEND" | "GETSET" | "SETNX" => i == 2, + // key ttl value + "SETEX" | "PSETEX" => i == 3, + // key field value + "HSETNX" => i == 3, + // key index value / key count value + "LSET" | "LREM" => i == 3, + // channel message + "PUBLISH" => i == 2, + // key v1 v2 … + "LPUSH" | "RPUSH" | "LPUSHX" | "RPUSHX" => i >= 2, + // key f1 v1 f2 v2 … — values are the odd positions from 3 + "HSET" | "HMSET" => i >= 3 && !i.is_multiple_of(2), + // k1 v1 k2 v2 … — values are the even positions from 2 + "MSET" => i >= 2 && i.is_multiple_of(2), + _ => false, + } +} + +/// Moving counterpart of `take_string` for payload arguments: takes the bytes +/// as they arrived, with no UTF-8 conversion of any kind. +fn take_bytes(val: &mut Value) -> Option> { + match std::mem::replace(val, Value::Array(None)) { + Value::BulkString(Some(data)) => Some(data), + Value::SimpleString(s) => Some(s.into_bytes()), + _ => None, + } +} + +fn take_string(val: &mut Value) -> Option { + match std::mem::replace(val, Value::Array(None)) { + // `from_utf8` reuses the buffer, so this is a move rather than a copy. + // `from_value` has already rejected non-UTF-8 arguments, so the lossy + // branch is unreachable in practice — it is kept only so a future + // caller that bypasses that check degrades rather than panics. + Value::BulkString(Some(data)) => Some( + String::from_utf8(data) + .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()), + ), + Value::SimpleString(s) => Some(s), + _ => None, + } +} + +/// Moving counterpart of `extract_key`, with the same validation. +fn take_key(val: &mut Value) -> Result { + let key = take_string(val).unwrap_or_default(); + validate_key(&key)?; + Ok(key) +} + +/// Borrowing counterpart of `take_bytes`, for payload arguments read without +/// consuming the frame. +fn extract_bytes(val: &Value) -> Option> { + match val { + Value::BulkString(Some(data)) => Some(data.clone()), + Value::SimpleString(s) => Some(s.as_bytes().to_vec()), + _ => None, + } +} + fn extract_string(val: &Value) -> Option { match val { Value::BulkString(Some(data)) => Some(String::from_utf8_lossy(data).into_owned()), @@ -2593,3 +2710,165 @@ mod arity_and_error_tests { assert!(err.contains("key cannot be empty"), "got {err}"); } } + +#[cfg(test)] +mod zero_copy_parse_tests { + use super::*; + + fn bulk(s: &str) -> Value { + Value::BulkString(Some(s.as_bytes().to_vec())) + } + + fn parse(parts: &[&str]) -> Result { + Command::from_value(Value::Array(Some(parts.iter().map(|s| bulk(s)).collect()))) + } + + /// Arguments are now *moved* out of the parsed frame rather than copied, so + /// the failure mode is an arm reading the same index twice and getting an + /// empty string the second time. These assert full round-trips through the + /// converted commands. + + #[test] + fn set_preserves_key_value_and_options_together() { + // SET reads index 1 and 2 by move, then scans 3.. for options — the + // exact shape that breaks if a moved slot were re-read. + match parse(&["SET", "k", "v", "EX", "60", "NX"]).unwrap() { + Command::Set(key, val, opts) => { + assert_eq!(key, "k"); + assert_eq!(val, b"v"); + assert_eq!(opts.expiry, Some(SetExpiry::Ex(60))); + assert_eq!(opts.condition, Some(SetCondition::Nx)); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn a_large_value_survives_the_move_intact() { + // The whole point of moving: a 1 MB payload is no longer memcpy'd. + let big = "x".repeat(1024 * 1024); + match parse(&["SET", "k", &big]).unwrap() { + Command::Set(_, val, _) => { + assert_eq!(val.len(), big.len()); + assert_eq!( + val, + big.as_bytes(), + "payload must be byte-identical after the move" + ); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn a_binary_value_reaches_the_command_untouched() { + // The move-based parse must not reintroduce a UTF-8 conversion on the + // value path: the bytes have to arrive exactly as they were sent. + let raw = vec![0xff, 0xfe, b'o', b'k']; + let frame = Value::Array(Some(vec![ + bulk("SET"), + bulk("k"), + Value::BulkString(Some(raw.clone())), + ])); + match Command::from_value(frame).expect("a binary value is valid") { + Command::Set(key, val, _) => { + assert_eq!(key, "k"); + assert_eq!(val, raw); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn a_binary_key_is_still_refused() { + // Keys are matched and routed as text, so this position stays strict + // even though the value beside it does not. + let frame = Value::Array(Some(vec![ + bulk("SET"), + Value::BulkString(Some(vec![0xff, 0xfe])), + bulk("v"), + ])); + let err = Command::from_value(frame).expect_err("binary key must be refused"); + assert!(err.contains("must be text"), "got {err:?}"); + } + + #[test] + fn utf8_multibyte_arguments_are_not_mistaken_for_binary() { + // The validation runs over every argument, so an over-eager check would + // break ordinary non-ASCII payloads. + match parse(&["SET", "ключ", "日本語 ✓"]).unwrap() { + Command::Set(key, val, _) => { + assert_eq!(key, "ключ"); + assert_eq!(val, "日本語 ✓".as_bytes()); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn paired_arguments_keep_their_pairing() { + // MSET and HSET split each chunk into two mutable halves; a mistake + // there would swap or blank alternate fields. + match parse(&["MSET", "a", "1", "b", "2"]).unwrap() { + Command::MSet(pairs) => assert_eq!( + pairs, + vec![ + ("a".to_string(), b"1".to_vec()), + ("b".to_string(), b"2".to_vec()) + ] + ), + other => panic!("{other:?}"), + } + match parse(&["HSET", "h", "f1", "v1", "f2", "v2"]).unwrap() { + Command::HSet(key, pairs) => { + assert_eq!(key, "h"); + assert_eq!( + pairs, + vec![ + ("f1".to_string(), b"v1".to_vec()), + ("f2".to_string(), b"v2".to_vec()) + ] + ); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn bulk_member_lists_keep_every_element_in_order() { + match parse(&["RPUSH", "l", "a", "b", "c"]).unwrap() { + Command::RPush(key, vals) => { + assert_eq!(key, "l"); + assert_eq!(vals, vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]); + } + other => panic!("{other:?}"), + } + match parse(&["SADD", "s", "m1", "m2"]).unwrap() { + Command::SAdd(key, members) => { + assert_eq!(key, "s"); + assert_eq!(members, vec!["m1", "m2"]); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn key_validation_still_applies_to_moved_keys() { + // take_key must validate exactly as extract_key did. + assert!(parse(&["SET", "", "v"]).is_err(), "empty key rejected"); + assert!(parse(&["APPEND", "", "v"]).is_err()); + assert!(parse(&["MSET", "", "1"]).is_err()); + } + + #[test] + fn json_commands_carry_path_and_document_separately() { + match parse(&["JSET", "doc", "$.a", "{\"x\":1}"]).unwrap() { + Command::JSet(key, path, val) => { + assert_eq!(key, "doc"); + assert_eq!(path, "$.a"); + assert_eq!(val, "{\"x\":1}"); + } + other => panic!("{other:?}"), + } + } +} diff --git a/core-engine/src/resp.rs b/core-engine/src/resp.rs index 027c162..09af0b6 100644 --- a/core-engine/src/resp.rs +++ b/core-engine/src/resp.rs @@ -13,6 +13,12 @@ pub enum Value { /// RESP3 Push frame (`>N\r\n...`). Used for server-initiated out-of-band messages /// (mutation fan-out, pub/sub) on the WebSocket channel. Never sent as a command response. Push(Vec), + /// RESP3 Map (`%N\r\n` followed by `N` key/value pairs). + /// + /// Only `HELLO 3` replies with one today. A RESP2 connection must never be + /// sent a map — the type does not exist in RESP2 and the client will fail + /// to parse it — so the caller picks the shape from the negotiated version. + Map(Vec<(Value, Value)>), } impl Value { @@ -65,6 +71,15 @@ impl Value { v.serialize_into(out); } } + Value::Map(pairs) => { + // The header counts *pairs*, not elements, so a 3-entry map is + // `%3` followed by six values. + let _ = write!(out, "%{}\r\n", pairs.len()); + for (k, v) in pairs { + k.serialize_into(out); + v.serialize_into(out); + } + } } } @@ -84,6 +99,7 @@ impl Value { b'$' => Self::parse_bulk_string(buffer), b'*' => Self::parse_array(buffer, depth), b'>' => Self::parse_push(buffer, depth), + b'%' => Self::parse_map(buffer, depth), _ => Err("Invalid RESP type".to_string()), } } @@ -195,6 +211,39 @@ impl Value { } } + fn parse_map(buffer: &[u8], depth: usize) -> Result<(Value, usize), String> { + if depth >= MAX_ARRAY_DEPTH { + return Err("ERR max nesting depth exceeded".to_string()); + } + match Self::read_until_crlf(buffer) { + Some((data, mut offset)) => { + let s = String::from_utf8_lossy(data); + let count: u64 = s.parse().map_err(|_| "Invalid map length".to_string())?; + // Each pair is two values, so the element budget is halved. + if count as usize > MAX_ARRAY_ELEMENTS / 2 { + return Err(format!( + "ERR map too large ({} > {} pairs)", + count, + MAX_ARRAY_ELEMENTS / 2 + )); + } + let mut pairs = Vec::with_capacity(count as usize); + for _ in 0..count { + let (k, klen) = Self::parse_inner(&buffer[offset..], depth + 1)?; + offset += klen; + let (v, vlen) = Self::parse_inner(&buffer[offset..], depth + 1)?; + offset += vlen; + pairs.push((k, v)); + if offset > MAX_TOTAL_MESSAGE_BYTES { + return Err("ERR message too large".to_string()); + } + } + Ok((Value::Map(pairs), offset)) + } + None => Err("Incomplete".to_string()), + } + } + fn parse_array(buffer: &[u8], depth: usize) -> Result<(Value, usize), String> { if depth >= MAX_ARRAY_DEPTH { return Err("ERR max nesting depth exceeded".to_string()); @@ -341,6 +390,21 @@ mod tests { #[test] fn push_round_trip() { + round_trip(&Value::Map(vec![])); + round_trip(&Value::Map(vec![( + Value::BulkString(Some(b"proto".to_vec())), + Value::Integer(3), + )])); + round_trip(&Value::Map(vec![ + ( + Value::BulkString(Some(b"server".to_vec())), + Value::BulkString(Some(b"recached".to_vec())), + ), + ( + Value::BulkString(Some(b"modules".to_vec())), + Value::Array(Some(vec![])), + ), + ])); round_trip(&Value::Push(vec![])); round_trip(&Value::Push(vec![ Value::BulkString(Some(b"SET".to_vec())), @@ -354,6 +418,48 @@ mod tests { ])); } + #[test] + fn map_header_counts_pairs_not_elements() { + // `%N` means N key/value *pairs* — 2N values follow. Emitting the + // element count instead would desynchronise every downstream parser. + let m = Value::Map(vec![ + (Value::BulkString(Some(b"a".to_vec())), Value::Integer(1)), + (Value::BulkString(Some(b"b".to_vec())), Value::Integer(2)), + ]); + let bytes = m.serialize(); + assert!( + bytes.starts_with(b"%2\r\n"), + "got {:?}", + String::from_utf8_lossy(&bytes) + ); + let (parsed, n) = Value::parse(&bytes).unwrap(); + assert_eq!(parsed, m); + assert_eq!(n, bytes.len(), "must consume exactly the frame"); + } + + #[test] + fn map_rejects_a_malformed_length() { + assert!(Value::parse(b"%x\r\n").is_err()); + assert!(Value::parse(b"%-1\r\n").is_err()); + } + + #[test] + fn truncated_map_is_incomplete_not_an_error() { + // A partial frame must be retried once more bytes arrive, not rejected. + let full = Value::Map(vec![( + Value::BulkString(Some(b"k".to_vec())), + Value::Integer(7), + )]) + .serialize(); + for cut in 1..full.len() { + assert_eq!( + Value::parse(&full[..cut]), + Err("Incomplete".to_string()), + "prefix of length {cut} should be incomplete" + ); + } + } + #[test] fn push_prefix_distinct_from_array() { let push = Value::Push(vec![Value::BulkString(Some(b"x".to_vec()))]).serialize(); diff --git a/core-engine/src/store.rs b/core-engine/src/store.rs index 9902865..20a4067 100644 --- a/core-engine/src/store.rs +++ b/core-engine/src/store.rs @@ -103,13 +103,136 @@ fn in_score_range(score: f64, min: &ScoreBound, max: &ScoreBound) -> bool { above && below } +// ── Byte payloads ───────────────────────────────────────────────────────────── + +/// A stored value's bytes. +/// +/// Values are payloads and may be arbitrary bytes — compressed blobs, protobuf, +/// images. *Identifiers* (keys, hash fields, set and sorted-set members) stay +/// `String`: they are looked up, pattern-matched and scope-checked as text, and +/// making them bytes would spread through the glob matcher, sync scopes and +/// pub/sub routing for no practical gain. +/// +/// The serde impls are hand-written for two reasons. `Vec` serializes as an +/// array of integers under rmp-serde, which would roughly double snapshot size; +/// `serialize_bytes` emits a compact msgpack `bin`. And deserialization accepts +/// *either* a string or bytes, so snapshots written by 0.2.1 and earlier — where +/// values were `String` — still load. +#[derive(Clone, PartialEq, Eq, Hash, Default)] +pub struct Blob(pub Vec); + +impl Blob { + pub fn as_slice(&self) -> &[u8] { + &self.0 + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn into_vec(self) -> Vec { + self.0 + } + /// Alias of `into_vec`, mirroring `String::into_bytes` at the many call + /// sites that build a RESP `BulkString` straight from a stored value. + pub fn into_bytes(self) -> Vec { + self.0 + } + /// Append bytes, for `APPEND`. + pub fn extend(&mut self, other: &[u8]) { + self.0.extend_from_slice(other); + } + /// Parse the payload as text, for the commands that require a number. + /// Non-UTF-8 fails the same way non-numeric text does. + pub fn parse_as(&self) -> Option { + self.as_str()?.parse().ok() + } + /// Interpret the payload as text. Commands that need a number or a JSON + /// document (`INCR`, `JSET`, …) go through this and error when it fails, + /// rather than the storage layer refusing the write in the first place. + pub fn as_str(&self) -> Option<&str> { + std::str::from_utf8(&self.0).ok() + } +} + +impl From> for Blob { + fn from(v: Vec) -> Self { + Blob(v) + } +} +impl From<&[u8]> for Blob { + fn from(v: &[u8]) -> Self { + Blob(v.to_vec()) + } +} +impl From for Blob { + fn from(v: String) -> Self { + Blob(v.into_bytes()) + } +} +impl From<&str> for Blob { + fn from(v: &str) -> Self { + Blob(v.as_bytes().to_vec()) + } +} + +impl std::fmt::Debug for Blob { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Text payloads dominate, so print them readably and fall back to hex. + match self.as_str() { + Some(t) => write!(f, "{t:?}"), + None => write!(f, "<{} bytes>", self.0.len()), + } + } +} + +impl Serialize for Blob { + fn serialize(&self, ser: S) -> Result { + ser.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for Blob { + fn deserialize>(de: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Blob; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("bytes or a string") + } + // Pre-0.2.2 snapshots stored values as msgpack strings. + fn visit_str(self, v: &str) -> Result { + Ok(Blob(v.as_bytes().to_vec())) + } + fn visit_string(self, v: String) -> Result { + Ok(Blob(v.into_bytes())) + } + fn visit_bytes(self, v: &[u8]) -> Result { + Ok(Blob(v.to_vec())) + } + fn visit_byte_buf(self, v: Vec) -> Result { + Ok(Blob(v)) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(b) = seq.next_element::()? { + out.push(b); + } + Ok(Blob(out)) + } + } + de.deserialize_any(V) + } +} + // ── Entry value type ────────────────────────────────────────────────────────── #[derive(Clone)] enum EntryValue { - Str(String), - Hash(HashMap), - List(VecDeque), + Str(Blob), + Hash(HashMap), + List(VecDeque), // IndexSet rather than HashSet: SPOP / SRANDMEMBER need O(1) access to a // random member by index, which a hash table cannot provide. Set(IndexSet), @@ -267,15 +390,29 @@ fn json_approx_size(v: &serde_json::Value) -> usize { // ── Sliding-window rate limiter (RLSET / RLCHECK) ───────────────────────────── +/// Buckets the sliding window is divided into. Memory per limiter is fixed at +/// this many `(start, count)` pairs regardless of the configured limit. +const RL_BUCKETS: u64 = 64; + #[derive(Clone)] struct RateLimiterInner { limit: u64, window_ms: u64, - /// Timestamps (ms) of recorded attempts, oldest first. Attempts arrive in - /// monotonically non-decreasing time, so the deque stays sorted and window - /// pruning is O(pruned) pops from the front — no sorted-set machinery - /// needed. Length is bounded by `limit` (denied attempts are not recorded). - events: VecDeque, + /// `(bucket_start_ms, attempts)` for buckets inside the window, oldest + /// first. + /// + /// Storing one timestamp per attempt was exact but unbounded in practice: + /// a `RLSET key 100000 3600` limiter held 100 000 `u64`s — roughly 800 KB + /// for a single key, and token-cost limiting (roadmap #9) makes six-figure + /// limits ordinary. Counting into a fixed number of buckets caps a limiter + /// at ~1 KB whatever the limit. + /// + /// The cost is granularity: the window advances one bucket at a time, so a + /// limiter is exact to within `window_ms / RL_BUCKETS`. Attempts are never + /// under-counted — a bucket only leaves the window once it is entirely + /// outside it — so the limiter errs toward rejecting slightly early rather + /// than admitting over the limit. + buckets: VecDeque<(u64, u64)>, } impl RateLimiterInner { @@ -283,28 +420,50 @@ impl RateLimiterInner { Self { limit, window_ms, - events: VecDeque::new(), + buckets: VecDeque::new(), } } + /// Width of one bucket, at least 1 ms. + fn bucket_ms(&self) -> u64 { + (self.window_ms / RL_BUCKETS).max(1) + } + /// Record an attempt at `now`, returning `(allowed, remaining, retry_after_ms)`. /// Denied attempts are not recorded — a client hammering a full limiter /// does not push its own recovery further away. fn check(&mut self, now: u64) -> (i64, u64, u64) { + let width = self.bucket_ms(); let cutoff = now.saturating_sub(self.window_ms); - while self.events.front().is_some_and(|&t| t <= cutoff) { - self.events.pop_front(); + // A bucket leaves the window only once its whole span is behind the + // cutoff, so attempts are never dropped early. + while self + .buckets + .front() + .is_some_and(|&(start, _)| start + width <= cutoff) + { + self.buckets.pop_front(); } - if (self.events.len() as u64) < self.limit { - self.events.push_back(now); - let remaining = self.limit - self.events.len() as u64; - (1, remaining, 0) + + let used: u64 = self.buckets.iter().map(|&(_, c)| c).sum(); + if used < self.limit { + let current = now - (now % width); + match self.buckets.back_mut() { + Some((start, count)) if *start == current => *count += 1, + _ => self.buckets.push_back((current, 1)), + } + (1, self.limit - used - 1, 0) } else { + // Recovery arrives when the oldest bucket falls out of the window. let retry_after = self - .events + .buckets .front() - .map(|&t| (t + self.window_ms).saturating_sub(now)) - .unwrap_or(0); + .map(|&(start, _)| (start + width + self.window_ms).saturating_sub(now)) + .unwrap_or(0) + // A bucket spans forward from its start, so the raw figure can + // land a fraction past the window. The wait never legitimately + // exceeds one window. + .min(self.window_ms); (0, 0, retry_after) } } @@ -332,17 +491,17 @@ impl Clone for Entry { } impl Entry { - fn new_str(value: String) -> Self { + fn new_str(value: impl Into) -> Self { Self { - value: EntryValue::Str(value), + value: EntryValue::Str(value.into()), expires_at_ms: None, last_access_ms: AtomicU64::new(now_ms()), } } - fn new_str_ex(value: String, expires_at_ms: u64) -> Self { + fn new_str_ex(value: impl Into, expires_at_ms: u64) -> Self { Self { - value: EntryValue::Str(value), + value: EntryValue::Str(value.into()), expires_at_ms: Some(expires_at_ms), last_access_ms: AtomicU64::new(now_ms()), } @@ -480,9 +639,9 @@ pub enum EvictionPolicy { #[derive(Serialize, Deserialize)] pub enum SnapshotValue { - Str(String), - Hash(HashMap), - List(Vec), + Str(Blob), + Hash(HashMap), + List(Vec), Set(Vec), ZSet(Vec<(String, f64)>), // Appended after the original variants: rmp-serde encodes variants by @@ -510,6 +669,15 @@ pub struct KeyValueStore { max_memory_bytes: Option, eviction_policy: EvictionPolicy, dirty: Arc, + /// Keys sampled per eviction pass. Approximate-LRU quality rises with the + /// sample and so does the cost, so the right value is workload-dependent — + /// Redis exposes the same knob as `maxmemory-samples`. Configured rather + /// than read from the environment because this crate also runs in the + /// browser, where there is no environment to read. + eviction_sample: usize, + /// Total keys evicted since start. Exported as a metric: without it an + /// operator cannot tell a healthy cache from one thrashing at its cap. + evicted: Arc, } impl Default for KeyValueStore { @@ -526,6 +694,8 @@ impl KeyValueStore { max_memory_bytes: None, eviction_policy: EvictionPolicy::NoEviction, dirty: Arc::new(AtomicU64::new(0)), + eviction_sample: 10, + evicted: Arc::new(AtomicU64::new(0)), } } @@ -536,6 +706,8 @@ impl KeyValueStore { max_memory_bytes: None, eviction_policy: EvictionPolicy::NoEviction, dirty: Arc::new(AtomicU64::new(0)), + eviction_sample: 10, + evicted: Arc::new(AtomicU64::new(0)), } } @@ -550,6 +722,8 @@ impl KeyValueStore { max_memory_bytes, eviction_policy, dirty: Arc::new(AtomicU64::new(0)), + eviction_sample: 10, + evicted: Arc::new(AtomicU64::new(0)), } } @@ -614,19 +788,73 @@ impl KeyValueStore { /// Strings are returned as bulk strings. Complex types return nil — the /// watcher must use a type-specific command (HGETALL, LRANGE, etc.) to /// fetch the full value. Deleted or expired keys also return nil. + /// The current value of `key`, as delivered to live-query subscribers. + /// + /// Strings come back as a bulk string and a missing or expired key as nil. + /// Collections come back **type-tagged**: an array whose first element names + /// the type, followed by its contents. + /// + /// ```text + /// hash → ["hash", field, value, ...] (HGETALL order) + /// list → ["list", element, ...] (head to tail) + /// set → ["set", member, ...] + /// zset → ["zset", member, score, ...] (ascending score) + /// json → ["json", serialized-document] + /// ``` + /// + /// The tag is what makes the payload unambiguous — a four-element array is + /// otherwise indistinguishable between a list of four items and a hash of + /// two pairs. Earlier versions sent only the type name, which forced every + /// subscriber into a follow-up `HGETALL`/`LRANGE` round-trip: exactly the + /// network hop local reads exist to avoid. pub fn get_current(&self, key: &str) -> Value { + fn tagged(tag: &str, mut items: Vec) -> Value { + let mut out = Vec::with_capacity(items.len() + 1); + out.push(Value::BulkString(Some(tag.as_bytes().to_vec()))); + out.append(&mut items); + Value::Array(Some(out)) + } + fn bulk(s: &str) -> Value { + Value::BulkString(Some(s.as_bytes().to_vec())) + } + fn blob(b: &Blob) -> Value { + Value::BulkString(Some(b.as_slice().to_vec())) + } + let now = now_ms(); match self.data.get(key) { None => Value::BulkString(None), Some(e) if e.is_expired(now) => Value::BulkString(None), Some(e) => match &e.value { EntryValue::Str(s) => Value::BulkString(Some(s.clone().into_bytes())), - EntryValue::Hash(_) => Value::SimpleString("hash".to_string()), - EntryValue::List(_) => Value::SimpleString("list".to_string()), - EntryValue::Set(_) => Value::SimpleString("set".to_string()), - EntryValue::ZSet(_) => Value::SimpleString("zset".to_string()), + EntryValue::Hash(m) => { + let mut fields: Vec<(&String, &Blob)> = m.iter().collect(); + fields.sort_by(|a, b| a.0.cmp(b.0)); + let items = fields + .into_iter() + .flat_map(|(f, v)| [bulk(f), blob(v)]) + .collect(); + tagged("hash", items) + } + EntryValue::List(l) => tagged("list", l.iter().map(blob).collect()), + EntryValue::Set(st) => tagged("set", st.iter().map(|m| bulk(m)).collect()), + EntryValue::ZSet(z) => { + let mut pairs: Vec<(&String, &f64)> = z.scores.iter().collect(); + pairs.sort_by(|a, b| { + a.1.partial_cmp(b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(b.0)) + }); + let items = pairs + .into_iter() + .flat_map(|(m, sc)| [bulk(m), bulk(&format_score(*sc))]) + .collect(); + tagged("zset", items) + } + // Attempt state is transient and server-side; clients have no + // use for it and it must not leak into a browser replica. EntryValue::RateLimiter(_) => Value::SimpleString("ratelimit".to_string()), - EntryValue::Json(_) => Value::SimpleString("json".to_string()), + EntryValue::Json(doc) => tagged("json", vec![bulk(&doc.to_string())]), }, } } @@ -663,8 +891,30 @@ impl KeyValueStore { /// Evict a single entry per the configured policy. Returns the number of /// bytes freed (`Some`), or `None` if nothing could be evicted. + /// Set how many keys each eviction pass samples (default 10, minimum 1). + /// + /// A larger sample approximates true LRU/TTL ordering more closely at the + /// cost of more work per eviction. + pub fn set_eviction_sample(&mut self, sample: usize) { + self.eviction_sample = sample.max(1); + } + + /// Keys evicted since start. + pub fn evicted_count(&self) -> u64 { + self.evicted.load(Ordering::Relaxed) + } + + /// Live key count, excluding expired entries awaiting sweep. + pub fn key_count(&self) -> usize { + let now = now_ms(); + self.data + .iter() + .filter(|r| !r.value().is_expired(now)) + .count() + } + fn evict_one(&self, now: u64) -> Option { - const SAMPLE: usize = 10; + let sample = self.eviction_sample; let mut rng = rand::rng(); let chosen: Option = match self.eviction_policy { EvictionPolicy::NoEviction => None, @@ -678,7 +928,7 @@ impl KeyValueStore { r.value().last_access_ms.load(Ordering::Relaxed), ) }) - .choose_multiple(&mut rng, SAMPLE); + .choose_multiple(&mut rng, sample); sample.into_iter().min_by_key(|(_, w)| *w).map(|(k, _)| k) } EvictionPolicy::AllKeysRandom => { @@ -695,7 +945,7 @@ impl KeyValueStore { r.value().last_access_ms.load(Ordering::Relaxed), ) }) - .choose_multiple(&mut rng, SAMPLE); + .choose_multiple(&mut rng, sample); sample.into_iter().min_by_key(|(_, w)| *w).map(|(k, _)| k) } EvictionPolicy::VolatileTtl => { @@ -710,7 +960,7 @@ impl KeyValueStore { Some((r.key().clone(), exp)) } }) - .choose_multiple(&mut rng, SAMPLE); + .choose_multiple(&mut rng, sample); sample .into_iter() .min_by_key(|(_, exp)| *exp) @@ -720,11 +970,12 @@ impl KeyValueStore { let key = chosen?; // Treat a lost race (key already gone) as a successful eviction that // freed nothing, so callers don't spin. - Some( - self.data - .remove(&key) - .map_or(0, |(k, e)| entry_size(&k, &e)), - ) + let freed = self + .data + .remove(&key) + .map_or(0, |(k, e)| entry_size(&k, &e)); + self.evicted.fetch_add(1, Ordering::Relaxed); + Some(freed) } pub fn snapshot(&self) -> Vec { @@ -741,10 +992,14 @@ impl KeyValueStore { EntryValue::ZSet(z) => { SnapshotValue::ZSet(z.scores.iter().map(|(k, &v)| (k.clone(), v)).collect()) } + // Attempt counts are deliberately not persisted: they age + // out within a single window, and a restart has already + // interrupted that window. Only the configuration is + // restored. The field remains for snapshot compatibility. EntryValue::RateLimiter(rl) => SnapshotValue::RateLimiter { limit: rl.limit, window_ms: rl.window_ms, - events: rl.events.iter().copied().collect(), + events: Vec::new(), }, EntryValue::Json(doc) => { SnapshotValue::Json(serde_json::to_string(doc).unwrap_or_default()) @@ -779,11 +1034,14 @@ impl KeyValueStore { limit, window_ms, events, - } => EntryValue::RateLimiter(RateLimiterInner { - limit, - window_ms, - events: events.into(), - }), + } => { + let _ = events; // older snapshots carried attempt timestamps + EntryValue::RateLimiter(RateLimiterInner { + limit, + window_ms, + buckets: VecDeque::new(), + }) + } SnapshotValue::Json(s) => { EntryValue::Json(serde_json::from_str(&s).unwrap_or(serde_json::Value::Null)) } @@ -801,6 +1059,13 @@ impl KeyValueStore { pub fn execute(&self, cmd: Command) -> Value { match cmd { + // Ephemeral keys are ordinary strings to the engine — their lifetime + // is enforced by the server, which is the layer that knows about + // connections. Keeping the engine unaware keeps it I/O-free. + Command::ESet(key, value) => { + self.execute(Command::Set(key, value, crate::cmd::SetOptions::default())) + } + // ── Core ───────────────────────────────────────────────────────── Command::Ping(msg) => match msg { Some(m) => Value::BulkString(Some(m.into_bytes())), @@ -809,6 +1074,9 @@ impl KeyValueStore { Command::Auth(_) => Value::Error( "ERR AUTH is handled by the connection layer, not the store".to_string(), ), + Command::Hello(_) => Value::Error( + "ERR HELLO is handled by the connection layer, not the store".to_string(), + ), // ── Strings ─────────────────────────────────────────────────────── Command::Set(key, val, opts) => { @@ -870,7 +1138,7 @@ impl KeyValueStore { self.data.insert( key, Entry { - value: EntryValue::Str(val), + value: EntryValue::Str(val.into()), expires_at_ms, last_access_ms: AtomicU64::new(now), }, @@ -916,12 +1184,12 @@ impl KeyValueStore { .entry(key) .or_insert_with(|| Entry::new_str(String::new())); if was_expired { - entry.value = EntryValue::Str(String::new()); + entry.value = EntryValue::Str(Blob::default()); entry.expires_at_ms = None; } match &mut entry.value { EntryValue::Str(s) => { - s.push_str(&suffix); + s.extend(&suffix); Value::Integer(s.len() as i64) } _ => unreachable!(), @@ -1219,7 +1487,7 @@ impl KeyValueStore { .filter(|(f, _)| !h.contains_key(f.as_str())) .count(); for (field, val) in pairs { - h.insert(field, val); + h.insert(field, val.into()); } Value::Integer(new_count as i64) } @@ -1249,15 +1517,15 @@ impl KeyValueStore { Some(e) => match &e.value { EntryValue::Hash(h) => { e.touch(now); - let mut pairs: Vec<(&str, &str)> = - h.iter().map(|(f, v)| (f.as_str(), v.as_str())).collect(); + let mut pairs: Vec<(&str, &Blob)> = + h.iter().map(|(f, v)| (f.as_str(), v)).collect(); pairs.sort_unstable_by_key(|(f, _)| *f); let out = pairs .into_iter() .flat_map(|(f, v)| { [ Value::BulkString(Some(f.as_bytes().to_vec())), - Value::BulkString(Some(v.as_bytes().to_vec())), + Value::BulkString(Some(v.as_slice().to_vec())), ] }) .collect(); @@ -1311,13 +1579,13 @@ impl KeyValueStore { Some(e) if e.is_expired(now) => Value::Array(Some(vec![])), Some(e) => match &e.value { EntryValue::Hash(h) => { - let mut pairs: Vec<(&str, &str)> = - h.iter().map(|(f, v)| (f.as_str(), v.as_str())).collect(); + let mut pairs: Vec<(&str, &Blob)> = + h.iter().map(|(f, v)| (f.as_str(), v)).collect(); pairs.sort_unstable_by_key(|(f, _)| *f); Value::Array(Some( pairs .into_iter() - .map(|(_, v)| Value::BulkString(Some(v.as_bytes().to_vec()))) + .map(|(_, v)| Value::BulkString(Some(v.as_slice().to_vec()))) .collect(), )) } @@ -1375,7 +1643,7 @@ impl KeyValueStore { _ => unreachable!(), }; if let std::collections::hash_map::Entry::Vacant(e) = h.entry(field) { - e.insert(val); + e.insert(val.into()); Value::Integer(1) } else { Value::Integer(0) @@ -1425,7 +1693,7 @@ impl KeyValueStore { _ => unreachable!(), }; for v in vals { - list.push_front(v); + list.push_front(v.into()); } Value::Integer(list.len() as i64) } @@ -1447,7 +1715,7 @@ impl KeyValueStore { _ => unreachable!(), }; for v in vals { - list.push_back(v); + list.push_back(v.into()); } Value::Integer(list.len() as i64) } @@ -1460,7 +1728,7 @@ impl KeyValueStore { Some(mut e) => match &mut e.value { EntryValue::List(list) => { for v in vals { - list.push_front(v); + list.push_front(v.into()); } Value::Integer(list.len() as i64) } @@ -1477,7 +1745,7 @@ impl KeyValueStore { Some(mut e) => match &mut e.value { EntryValue::List(list) => { for v in vals { - list.push_back(v); + list.push_back(v.into()); } Value::Integer(list.len() as i64) } @@ -1546,13 +1814,13 @@ impl KeyValueStore { Some(e) => match &e.value { EntryValue::List(list) => { e.touch(now); - let slice: Vec<&String> = list.iter().collect(); + let slice: Vec<&Blob> = list.iter().collect(); match resolve_range(start, stop, slice.len()) { None => Value::Array(Some(vec![])), Some((s, e)) => Value::Array(Some( slice[s..=e] .iter() - .map(|v| Value::BulkString(Some(v.as_bytes().to_vec()))) + .map(|v| Value::BulkString(Some(v.as_slice().to_vec()))) .collect(), )), } @@ -1581,9 +1849,9 @@ impl KeyValueStore { Some(e) if e.is_expired(now) => Value::BulkString(None), Some(e) => match &e.value { EntryValue::List(list) => { - let slice: Vec<&String> = list.iter().collect(); + let slice: Vec<&Blob> = list.iter().collect(); resolve_idx(idx, slice.len()) - .map(|i| Value::BulkString(Some(slice[i].as_bytes().to_vec()))) + .map(|i| Value::BulkString(Some(slice[i].as_slice().to_vec()))) .unwrap_or(Value::BulkString(None)) } _ => Value::Error(WRONGTYPE.to_string()), @@ -1602,7 +1870,7 @@ impl KeyValueStore { match resolve_idx(idx, len) { None => Value::Error("ERR index out of range".to_string()), Some(i) => { - list[i] = val; + list[i] = val.into(); Value::SimpleString("OK".to_string()) } } @@ -1624,7 +1892,7 @@ impl KeyValueStore { if count >= 0 { let mut i = 0; while i < list.len() && (count == 0 || removed < abs as i64) { - if list[i] == element { + if list[i].as_slice() == element.as_slice() { list.remove(i); removed += 1; } else { @@ -1635,7 +1903,7 @@ impl KeyValueStore { let mut i = list.len(); while i > 0 && removed < abs as i64 { i -= 1; - if list[i] == element { + if list[i].as_slice() == element.as_slice() { list.remove(i); removed += 1; } @@ -1659,7 +1927,7 @@ impl KeyValueStore { match resolve_range(start, stop, len) { None => list.clear(), Some((s, e)) => { - let trimmed: VecDeque = list.drain(s..=e).collect(); + let trimmed: VecDeque = list.drain(s..=e).collect(); *list = trimmed; } } @@ -2453,7 +2721,7 @@ fn entry_size(key: &str, e: &Entry) -> usize { EntryValue::List(l) => l.iter().map(|s| s.len()).sum(), EntryValue::Set(s) => s.iter().map(|m| m.len()).sum::(), EntryValue::ZSet(z) => z.scores.keys().map(|m| m.len() + 8).sum(), - EntryValue::RateLimiter(rl) => rl.events.len() * 8 + 16, + EntryValue::RateLimiter(rl) => rl.buckets.len() * 16 + 16, EntryValue::Json(doc) => json_approx_size(doc), }; key.len() + val_size + 64 @@ -2473,16 +2741,18 @@ fn incr_by(data: &DashMap, key: String, delta: i64) -> Value { .entry(key) .or_insert_with(|| Entry::new_str("0".to_string())); if was_expired { - entry.value = EntryValue::Str("0".to_string()); + entry.value = EntryValue::Str("0".into()); entry.expires_at_ms = None; } match &mut entry.value { - EntryValue::Str(s) => match s.parse::() { - Err(_) => Value::Error("ERR value is not an integer or out of range".to_string()), - Ok(n) => match n.checked_add(delta) { + // A non-UTF-8 value fails here exactly as non-numeric text does: the + // bytes are stored faithfully, they are simply not a number. + EntryValue::Str(s) => match s.parse_as::() { + None => Value::Error("ERR value is not an integer or out of range".to_string()), + Some(n) => match n.checked_add(delta) { None => Value::Error("ERR increment or decrement would overflow".to_string()), Some(new) => { - *s = new.to_string(); + *s = new.to_string().into(); Value::Integer(new) } }, @@ -2671,11 +2941,11 @@ fn hash_incr_int(data: &DashMap, key: String, field: String, delt EntryValue::Hash(h) => h, _ => unreachable!(), }; - let cur: i64 = h.get(&field).and_then(|s| s.parse().ok()).unwrap_or(0); + let cur: i64 = h.get(&field).and_then(|s| s.parse_as()).unwrap_or(0); match cur.checked_add(delta) { None => Value::Error("ERR increment or decrement would overflow".to_string()), Some(new) => { - h.insert(field, new.to_string()); + h.insert(field, new.to_string().into()); Value::Integer(new) } } @@ -2704,13 +2974,13 @@ fn hash_incr_float(data: &DashMap, key: String, field: String, de EntryValue::Hash(h) => h, _ => unreachable!(), }; - let cur: f64 = h.get(&field).and_then(|s| s.parse().ok()).unwrap_or(0.0); + let cur: f64 = h.get(&field).and_then(|s| s.parse_as()).unwrap_or(0.0); let new = cur + delta; if new.is_nan() || new.is_infinite() { return Value::Error("ERR increment would produce NaN or Infinity".to_string()); } let new_str = format_score(new); - h.insert(field, new_str.clone()); + h.insert(field, new_str.clone().into()); Value::BulkString(Some(new_str.into_bytes())) } @@ -4146,17 +4416,26 @@ mod tests { } #[test] - fn rl_snapshot_roundtrip_preserves_state() { + fn rl_snapshot_preserves_config_but_not_attempt_state() { + // Attempt counts are transient: they age out within one window, and a + // restart has already interrupted that window. Only the configuration + // is restored, so a limiter comes back enforcing the same policy with a + // clean slate rather than a stale partial count. let s = store(); - s.execute(Command::RlSet("api".into(), 2, 60)); - rl(s.execute(Command::RlCheck("api".into(), None))); - rl(s.execute(Command::RlCheck("api".into(), None))); + s.execute(Command::RlSet("api".into(), 3, 60)); + s.execute(Command::RlCheck("api".into(), None)); + s.execute(Command::RlCheck("api".into(), None)); - let s2 = store(); - s2.restore(s.snapshot()); - let (allowed, remaining, retry) = rl(s2.execute(Command::RlCheck("api".into(), None))); - assert_eq!((allowed, remaining), (0, 0)); - assert!(retry > 0); + let restored = store(); + restored.restore(s.snapshot()); + + // Config survived: still a 3-per-60s limiter. + let (allowed, remaining, _) = rl(restored.execute(Command::RlCheck("api".into(), None))); + assert_eq!(allowed, 1); + assert_eq!( + remaining, 2, + "attempts reset on restore — the limiter enforces the same policy afresh" + ); } // ── JSON (JSET / JGET / JMERGE) ─────────────────────────────────────────── @@ -4432,23 +4711,72 @@ mod capacity_tests { } #[test] - fn get_current_returns_type_markers_for_collections() { + fn get_current_returns_type_tagged_collection_values() { + // Live-query subscribers get the actual contents, tagged with the type + // so the payload is unambiguous. Previously only the type name was sent, + // which forced a follow-up HGETALL/LRANGE — a network round-trip in a + // system whose whole premise is local reads. let s = KeyValueStore::new(); s.execute(Command::HSet("h".into(), vec![("f".into(), "v".into())])); - s.execute(Command::LPush("l".into(), vec!["a".into()])); - s.execute(Command::SAdd("st".into(), vec!["a".into()])); + s.execute(Command::RPush("l".into(), vec!["a".into(), "b".into()])); + s.execute(Command::SAdd("st".into(), vec!["m".into()])); s.execute(Command::ZAdd( "z".into(), Default::default(), - vec![(1.0, "a".into())], + vec![(1.5, "alice".into())], )); s.execute(Command::JSet("j".into(), "$".into(), "{\"a\":1}".into())); - assert_eq!(s.get_current("h"), Value::SimpleString("hash".into())); - assert_eq!(s.get_current("l"), Value::SimpleString("list".into())); - assert_eq!(s.get_current("st"), Value::SimpleString("set".into())); - assert_eq!(s.get_current("z"), Value::SimpleString("zset".into())); - assert_eq!(s.get_current("j"), Value::SimpleString("json".into())); + fn parts(v: Value) -> Vec { + match v { + Value::Array(Some(items)) => items + .iter() + .map(|i| match i { + Value::BulkString(Some(b)) => String::from_utf8_lossy(b).into_owned(), + other => format!("{other:?}"), + }) + .collect(), + other => panic!("expected a tagged array, got {other:?}"), + } + } + + assert_eq!(parts(s.get_current("h")), vec!["hash", "f", "v"]); + assert_eq!(parts(s.get_current("l")), vec!["list", "a", "b"]); + assert_eq!(parts(s.get_current("st")), vec!["set", "m"]); + assert_eq!(parts(s.get_current("z")), vec!["zset", "alice", "1.5"]); + assert_eq!(parts(s.get_current("j")), vec!["json", "{\"a\":1}"]); + } + + #[test] + fn get_current_orders_collections_deterministically() { + // Two clients receiving the same key must build identical local state, + // so ordering cannot depend on hash iteration order. + let s = KeyValueStore::new(); + s.execute(Command::HSet( + "h".into(), + vec![("b".into(), "2".into()), ("a".into(), "1".into())], + )); + s.execute(Command::ZAdd( + "z".into(), + Default::default(), + vec![(9.0, "high".into()), (1.0, "low".into())], + )); + for _ in 0..5 { + match s.get_current("h") { + Value::Array(Some(items)) => { + // Fields sorted: a before b. + assert_eq!(items[1], Value::BulkString(Some(b"a".to_vec()))); + } + other => panic!("{other:?}"), + } + match s.get_current("z") { + Value::Array(Some(items)) => { + // Ascending score: low before high. + assert_eq!(items[1], Value::BulkString(Some(b"low".to_vec()))); + } + other => panic!("{other:?}"), + } + } } // ── sweep_expired ───────────────────────────────────────────────────────── @@ -4496,9 +4824,9 @@ mod capacity_tests { let base = s.approximate_memory_bytes(); s.execute(Command::HSet( "h".into(), - vec![("f".into(), "v".repeat(500))], + vec![("f".into(), "v".repeat(500).into())], )); - s.execute(Command::LPush("l".into(), vec!["v".repeat(500)])); + s.execute(Command::LPush("l".into(), vec!["v".repeat(500).into()])); s.execute(Command::SAdd("st".into(), vec!["v".repeat(500)])); s.execute(Command::ZAdd( "z".into(), @@ -4626,7 +4954,7 @@ mod capacity_tests { for (k, ttl) in [("keep", 600_000u64), ("drop", 1_000)] { s.execute(Command::Set( k.into(), - "x".repeat(400), + "x".repeat(400).into(), SetOptions { expiry: Some(crate::cmd::SetExpiry::Px(ttl)), ..Default::default() @@ -5079,7 +5407,7 @@ mod critical_path_tests { let s = KeyValueStore::new(); s.execute(Command::Set( "n".into(), - i64::MAX.to_string(), + i64::MAX.to_string().into(), SetOptions::default(), )); let r = s.execute(Command::Incr("n".into())); @@ -5099,7 +5427,7 @@ mod critical_path_tests { let s = KeyValueStore::new(); s.execute(Command::Set( "n".into(), - i64::MIN.to_string(), + i64::MIN.to_string().into(), SetOptions::default(), )); assert!(matches!( @@ -5214,3 +5542,502 @@ mod critical_path_tests { ); } } + +#[cfg(test)] +mod ephemeral_tests { + use super::*; + use crate::cmd::Command; + + #[test] + fn eset_stores_a_value_like_set() { + // To the engine an ephemeral key is an ordinary string — lifetime is + // enforced by the server, which is the layer that knows about + // connections. Keeping the engine unaware is what keeps it I/O-free + // and identical between native and wasm builds. + let s = KeyValueStore::new(); + assert_eq!( + s.execute(Command::ESet("presence:1".into(), "online".into())), + Value::SimpleString("OK".into()) + ); + assert_eq!( + s.execute(Command::Get("presence:1".into())), + Value::BulkString(Some(b"online".to_vec())) + ); + assert_eq!( + s.execute(Command::Type("presence:1".into())), + Value::SimpleString("string".into()) + ); + // No TTL — the engine must not invent one. + assert_eq!( + s.execute(Command::Ttl("presence:1".into())), + Value::Integer(-1) + ); + } + + #[test] + fn eset_overwrites_and_is_visible_to_reads_and_live_queries() { + let s = KeyValueStore::new(); + s.execute(Command::ESet("presence:1".into(), "first".into())); + s.execute(Command::ESet("presence:1".into(), "second".into())); + assert_eq!( + s.get_current("presence:1"), + Value::BulkString(Some(b"second".to_vec())) + ); + // Pattern matching picks it up like any other key. + let matched = s.matching_key_values("presence:*", 10); + assert_eq!(matched.len(), 1); + } +} + +#[cfg(test)] +mod metrics_tests { + use super::*; + use crate::cmd::{Command, SetOptions}; + + #[test] + fn key_count_excludes_expired_entries() { + // The metric must report what a client can actually read, not what is + // still sitting in the map awaiting sweep — otherwise a dashboard shows + // a keyspace that is not there. + let s = KeyValueStore::new(); + s.execute(Command::Set( + "live".into(), + "v".into(), + SetOptions::default(), + )); + s.execute(Command::Set( + "dead".into(), + "v".into(), + SetOptions { + expiry: Some(crate::cmd::SetExpiry::Px(1)), + ..Default::default() + }, + )); + std::thread::sleep(std::time::Duration::from_millis(15)); + assert_eq!(s.key_count(), 1); + } + + #[test] + fn eviction_counter_starts_at_zero_and_counts_each_eviction() { + let s = KeyValueStore::with_config(Some(2), None, EvictionPolicy::AllKeysRandom); + assert_eq!(s.evicted_count(), 0); + + for i in 0..5 { + s.execute(Command::Set( + format!("k{i}"), + "v".into(), + SetOptions::default(), + )); + } + // Cap of 2 with 5 inserts means 3 evictions were required. + assert_eq!(s.key_count(), 2); + assert_eq!( + s.evicted_count(), + 3, + "eviction rate is the signal that a cache is thrashing at its cap" + ); + } + + #[test] + fn eviction_counter_stays_zero_without_pressure() { + let s = KeyValueStore::new(); + for i in 0..10 { + s.execute(Command::Set( + format!("k{i}"), + "v".into(), + SetOptions::default(), + )); + } + assert_eq!(s.evicted_count(), 0, "no cap configured, nothing to evict"); + } + + #[test] + fn memory_estimate_tracks_the_stored_data() { + let s = KeyValueStore::new(); + let empty = s.approximate_memory_bytes(); + s.execute(Command::Set( + "k".into(), + "x".repeat(4096).into(), + SetOptions::default(), + )); + assert!(s.approximate_memory_bytes() >= empty + 4096); + } +} + +#[cfg(test)] +mod rate_limiter_memory_tests { + use super::*; + use crate::cmd::Command; + + fn rl(v: Value) -> (i64, u64, u64) { + match v { + Value::Array(Some(items)) => match (&items[0], &items[1], &items[2]) { + (Value::Integer(a), Value::Integer(r), Value::Integer(w)) => { + (*a, *r as u64, *w as u64) + } + _ => panic!("unexpected RLCHECK reply shape"), + }, + other => panic!("expected array, got {other:?}"), + } + } + + #[test] + fn memory_is_bounded_regardless_of_limit() { + // The reason for bucketing: one timestamp per attempt meant ~800 KB for + // a single `RLSET key 100000 3600` limiter. Buckets cap it at ~1 KB. + let s = KeyValueStore::new(); + s.execute(Command::RlSet("big".into(), 100_000, 3600)); + for _ in 0..5_000 { + s.execute(Command::RlCheck("big".into(), None)); + } + let bytes = s.approximate_memory_bytes(); + assert!( + bytes < 4_096, + "5000 attempts against a 100k limiter should stay small, got {bytes} bytes" + ); + } + + #[test] + fn a_high_limit_still_admits_every_attempt_under_it() { + // Bounding memory must not bound throughput. + let s = KeyValueStore::new(); + s.execute(Command::RlSet("api".into(), 10_000, 3600)); + for i in 0..2_000 { + let (allowed, _, _) = rl(s.execute(Command::RlCheck("api".into(), None))); + assert_eq!(allowed, 1, "attempt {i} should be allowed"); + } + } + + #[test] + fn the_limit_is_still_enforced_exactly_at_the_boundary() { + // Bucketing approximates *when* attempts age out, never *how many* are + // counted inside the window. + let s = KeyValueStore::new(); + s.execute(Command::RlSet("api".into(), 5, 60)); + for expected in [4, 3, 2, 1, 0] { + let (allowed, remaining, retry) = rl(s.execute(Command::RlCheck("api".into(), None))); + assert_eq!(allowed, 1); + assert_eq!(remaining, expected); + assert_eq!(retry, 0); + } + let (allowed, remaining, retry) = rl(s.execute(Command::RlCheck("api".into(), None))); + assert_eq!(allowed, 0, "the 6th attempt against a limit of 5 is denied"); + assert_eq!(remaining, 0); + assert!(retry > 0, "a denied attempt must say when to retry"); + } + + #[test] + fn retry_after_never_exceeds_the_window() { + // Retry-After is handed straight to HTTP clients; a value past the + // window would park them longer than the policy requires. + let s = KeyValueStore::new(); + s.execute(Command::RlSet("api".into(), 1, 60)); + s.execute(Command::RlCheck("api".into(), None)); + let (_, _, retry) = rl(s.execute(Command::RlCheck("api".into(), None))); + assert!( + retry > 0 && retry <= 60_000, + "retry_after_ms = {retry}, window is 60000" + ); + } + + #[test] + fn the_shortest_window_recovers_after_it_elapses() { + // RLSET takes the window in *seconds*, so one second is the floor. At + // that width each bucket is ~15 ms; the bucket-width floor of 1 ms + // exists so an even shorter window could never divide to zero. + let s = KeyValueStore::new(); + s.execute(Command::RlSet("fast".into(), 2, 1)); + assert_eq!(rl(s.execute(Command::RlCheck("fast".into(), None))).0, 1); + assert_eq!(rl(s.execute(Command::RlCheck("fast".into(), None))).0, 1); + assert_eq!( + rl(s.execute(Command::RlCheck("fast".into(), None))).0, + 0, + "third attempt against a limit of 2 is denied" + ); + + // Past the window the limiter admits traffic again. + std::thread::sleep(std::time::Duration::from_millis(1_100)); + assert_eq!( + rl(s.execute(Command::RlCheck("fast".into(), None))).0, + 1, + "buckets older than the window must age out" + ); + } +} + +// ── Byte transparency ───────────────────────────────────────────────────────── +// +// Values are byte-transparent; identifiers are text. A stored value may be +// arbitrary bytes — compressed blobs, protobuf, images — and must come back +// exactly as it went in. Keys, hash fields, set and sorted-set members and glob +// patterns are looked up and matched as text, so a non-UTF-8 one is refused +// rather than lossily converted. +// +// These live in-crate rather than in `tests/`: a separate integration binary +// links its own copy of every function into the coverage map, and the copies it +// does not exercise drag the measured figure down without changing what is +// actually tested. +#[cfg(test)] +mod byte_transparency_tests { + use super::*; + use crate::cmd::Command; + use crate::resp::Value; + + /// Invalid UTF-8 in any position: a lone continuation byte and a truncated + /// sequence, plus an embedded NUL and a byte that is legal only inside one. + const BINARY: &[u8] = &[0xff, 0xfe, 0x00, 0x41, 0x80, 0xc3]; + + fn parse(raw: &[u8]) -> Result { + let (v, _) = Value::parse(raw).unwrap(); + Command::from_value(v) + } + + /// Build a RESP array frame from raw byte arguments. + fn frame(args: &[&[u8]]) -> Vec { + let mut out = format!("*{}\r\n", args.len()).into_bytes(); + for a in args { + out.extend_from_slice(format!("${}\r\n", a.len()).as_bytes()); + out.extend_from_slice(a); + out.extend_from_slice(b"\r\n"); + } + out + } + + #[test] + fn a_binary_value_round_trips_byte_for_byte() { + let store = KeyValueStore::new(); + store.execute(parse(&frame(&[b"SET", b"k", BINARY])).unwrap()); + + let Value::BulkString(Some(got)) = store.execute(Command::Get("k".into())) else { + panic!("key missing after SET"); + }; + assert_eq!(got, BINARY, "value must survive unchanged"); + } + + #[test] + fn binary_values_work_in_lists_and_hashes() { + let store = KeyValueStore::new(); + + store.execute(parse(&frame(&[b"RPUSH", b"l", BINARY, b"plain"])).unwrap()); + let Value::Array(Some(items)) = store.execute(Command::LRange("l".into(), 0, -1)) else { + panic!("list missing"); + }; + assert_eq!(items[0], Value::BulkString(Some(BINARY.to_vec()))); + + store.execute(parse(&frame(&[b"HSET", b"h", b"f", BINARY])).unwrap()); + assert_eq!( + store.execute(Command::HGet("h".into(), "f".into())), + Value::BulkString(Some(BINARY.to_vec())) + ); + } + + #[test] + fn append_concatenates_bytes_rather_than_text() { + let store = KeyValueStore::new(); + store.execute(parse(&frame(&[b"SET", b"k", BINARY])).unwrap()); + store.execute(parse(&frame(&[b"APPEND", b"k", BINARY])).unwrap()); + + let Value::BulkString(Some(got)) = store.execute(Command::Get("k".into())) else { + panic!("key missing"); + }; + assert_eq!(got.len(), BINARY.len() * 2); + assert_eq!(&got[..BINARY.len()], BINARY); + assert_eq!(&got[BINARY.len()..], BINARY); + } + + #[test] + fn strlen_counts_bytes_not_characters() { + let store = KeyValueStore::new(); + store.execute(parse(&frame(&[b"SET", b"k", BINARY])).unwrap()); + assert_eq!( + store.execute(Command::Strlen("k".into())), + Value::Integer(BINARY.len() as i64) + ); + } + + #[test] + fn incr_on_a_binary_value_errors_like_any_non_numeric_value() { + // The bytes are stored faithfully; they are simply not a number. This must + // read as a type error, not as corruption. + let store = KeyValueStore::new(); + store.execute(parse(&frame(&[b"SET", b"k", BINARY])).unwrap()); + let Value::Error(e) = store.execute(Command::Incr("k".into())) else { + panic!("INCR on binary must error"); + }; + assert!(e.contains("not an integer"), "got {e:?}"); + } + + #[test] + fn a_binary_key_is_rejected() { + // Keys are matched by glob and checked against sync scopes as text, so a + // corrupted key would be silently unretrievable. + let err = parse(&frame(&[b"SET", BINARY, b"v"])).expect_err("binary key must be refused"); + assert!(err.starts_with("ERR "), "{err:?}"); + assert!(err.contains("must be text"), "{err:?}"); + } + + #[test] + fn binary_fields_and_members_are_rejected() { + for args in [ + vec![b"HSET".as_slice(), b"h", BINARY, b"v"], // hash field + vec![b"SADD".as_slice(), b"s", BINARY], // set member + vec![b"ZADD".as_slice(), b"z", b"1", BINARY], // zset member + vec![b"KEYS".as_slice(), BINARY], // glob pattern + ] { + let err = parse(&frame(&args)).expect_err("identifier must be refused"); + assert!(err.contains("must be text"), "{:?} -> {err:?}", args[0]); + } + } + + #[test] + fn the_error_names_which_argument_was_bad() { + // MSET k1 v1 v2 — index 3 is a key, so it is refused. + let err = + parse(&frame(&[b"MSET", b"k1", b"v1", BINARY, b"v2"])).expect_err("must be refused"); + assert!(err.contains("argument 3"), "got {err:?}"); + } + + #[test] + fn a_rejected_command_stores_nothing() { + let store = KeyValueStore::new(); + assert!(parse(&frame(&[b"SET", BINARY, b"v"])).is_err()); + assert_eq!(store.execute(Command::DbSize), Value::Integer(0)); + } + + #[test] + fn utf8_values_survive_unchanged() { + let store = KeyValueStore::new(); + for value in ["héllo ✓", "日本語", "\u{1F600}", "", "plain"] { + store.execute(Command::Set("k".into(), value.into(), Default::default())); + let Value::BulkString(Some(got)) = store.execute(Command::Get("k".into())) else { + panic!("key missing after SET of {value:?}"); + }; + assert_eq!(String::from_utf8(got).unwrap(), value); + } + } +} + +// ── Snapshot compatibility ──────────────────────────────────────────────────── +// +// `SnapshotValue` held `String` up to 0.2.1 and holds `Blob` from 0.2.2. +// rmp-serde encodes those differently — msgpack `str` versus `bin` — so `Blob`'s +// deserializer accepts either. Without that, upgrading a server would silently +// start from an empty cache, or fail to boot. +#[cfg(test)] +mod snapshot_compat_tests { + // `super::*` already brings Command, Value and the snapshot types into + // scope from the store module's own imports. + use super::*; + use std::collections::HashMap; + + /// Mirror of the pre-0.2.2 `SnapshotValue`, used to produce a genuine old-format + /// payload rather than a hand-rolled byte string. Variant order matters: + /// rmp-serde encodes variants by index. + #[derive(Serialize)] + #[allow(dead_code)] // variants exist to fix the discriminant order, not to be built + enum LegacySnapshotValue { + Str(String), + Hash(HashMap), + List(Vec), + Set(Vec), + ZSet(Vec<(String, f64)>), + RateLimiter { + limit: u64, + window_ms: u64, + events: Vec, + }, + Json(String), + } + + #[derive(Serialize)] + struct LegacyEntry { + key: String, + value: LegacySnapshotValue, + expires_at_ms: Option, + } + + #[test] + fn a_pre_0_2_2_snapshot_still_restores() { + let legacy = vec![ + LegacyEntry { + key: "s".into(), + value: LegacySnapshotValue::Str("hello".into()), + expires_at_ms: None, + }, + LegacyEntry { + key: "l".into(), + value: LegacySnapshotValue::List(vec!["a".into(), "b".into()]), + expires_at_ms: None, + }, + LegacyEntry { + key: "h".into(), + value: LegacySnapshotValue::Hash(HashMap::from([( + "f".to_string(), + "v".to_string(), + )])), + expires_at_ms: None, + }, + ]; + let bytes = rmp_serde::to_vec(&legacy).expect("legacy snapshot must encode"); + + // Decode with the *current* types — this is what a restarted server does. + let entries: Vec = + rmp_serde::from_slice(&bytes).expect("a pre-0.2.2 snapshot must still decode"); + + let store = KeyValueStore::new(); + store.restore(entries); + + use crate::{cmd::Command, resp::Value}; + assert_eq!( + store.execute(Command::Get("s".into())), + Value::BulkString(Some(b"hello".to_vec())) + ); + assert_eq!( + store.execute(Command::HGet("h".into(), "f".into())), + Value::BulkString(Some(b"v".to_vec())) + ); + assert_eq!(store.execute(Command::LLen("l".into())), Value::Integer(2)); + } + + #[test] + fn binary_values_survive_a_snapshot_round_trip() { + let binary = vec![0xff, 0xfe, 0x00, 0x41, 0x80]; + let store = KeyValueStore::new(); + store.restore(vec![SnapshotEntry { + key: "b".into(), + value: SnapshotValue::Str(binary.clone().into()), + expires_at_ms: None, + }]); + + let bytes = rmp_serde::to_vec(&store.snapshot()).unwrap(); + let entries: Vec = rmp_serde::from_slice(&bytes).unwrap(); + + let restored = KeyValueStore::new(); + restored.restore(entries); + + use crate::{cmd::Command, resp::Value}; + assert_eq!( + restored.execute(Command::Get("b".into())), + Value::BulkString(Some(binary)), + "binary must survive snapshot and restore" + ); + } + + #[test] + fn a_binary_value_encodes_as_msgpack_bin_not_an_int_array() { + // Vec serializes as an array of integers by default, which would roughly + // double snapshot size for binary payloads. Blob emits a compact `bin`. + let store = KeyValueStore::new(); + store.restore(vec![SnapshotEntry { + key: "b".into(), + value: SnapshotValue::Str(vec![0xffu8; 1000].into()), + expires_at_ms: None, + }]); + let bytes = rmp_serde::to_vec(&store.snapshot()).unwrap(); + assert!( + bytes.len() < 1200, + "1000 bytes encoded to {} — likely an int array, not msgpack bin", + bytes.len() + ); + } +} diff --git a/docs/browser/api-reference.md b/docs/browser/api-reference.md index 4a9267d..4895968 100644 --- a/docs/browser/api-reference.md +++ b/docs/browser/api-reference.md @@ -109,6 +109,26 @@ cache.get('name') // 'Alice' cache.get('missing') // null ``` +::: warning Throws on binary values +Values are byte-transparent, so a backend can write bytes that are not valid UTF-8 and they will sync +into this cache. `get()` throws rather than returning a mangled string — use +[`getBytes()`](#getbytes-key) when a value may not be text. +::: + +#### `getBytes(key)` + +Returns the value for a key as raw bytes, or `null` if it does not exist or has expired. Works for +any value; text values come back as their UTF-8 bytes. + +```typescript +getBytes(key: string): Uint8Array | null +``` + +```typescript +const bytes = cache.getBytes('thumb:42') +if (bytes) img.src = URL.createObjectURL(new Blob([bytes])) +``` + #### `getJSON(key)` Returns a JSON-parsed value, or `null` if the key is missing, expired, or not valid JSON. @@ -152,6 +172,19 @@ Sets a key to a string value. Overwrites any existing value and removes any exis set(key: string, value: string): void ``` +#### `setBytes(key, value)` + +Sets a key to raw bytes. Values are byte-transparent: the exact bytes are stored, synced to the +server, replicated, and persisted — through the offline outbox and IndexedDB unchanged. + +```typescript +setBytes(key: string, value: Uint8Array): void +``` + +```typescript +cache.setBytes('thumb:42', new Uint8Array(await blob.arrayBuffer())) +``` + #### `setEx(key, value, seconds)` Sets a key with a TTL in seconds. The key is deleted automatically when the TTL elapses. @@ -349,7 +382,7 @@ handler only** — it does not leave the channel; call `unsubscribe(channel)` fo handlers can be registered on the same channel. ```typescript -onMessage(channel: string, cb: (msg: string) => void): () => void +onMessage(channel: string, cb: (msg: string | Uint8Array) => void): () => void ``` ```typescript @@ -380,6 +413,15 @@ Publish a message to a pub/sub channel. All server-side and browser-side subscri publish(channel: string, message: string): void ``` +#### `publishBytes(channel, message)` + +Publish raw bytes to a pub/sub channel. Subscribers receive a `Uint8Array` rather than a string when +the payload is not valid UTF-8. + +```typescript +publishBytes(channel: string, message: Uint8Array): void +``` + --- ### Persistence @@ -413,6 +455,6 @@ Direct access to the underlying WASM instance (`RecachedCache` from wasm-bindgen get raw(): RawCache ``` -Available methods on `raw`: `set()`, `set_ex()`, `get()`, `del()`, `ttl()`, `exists()`, `subscribe()`, `unsubscribe()`, `publish()`, `connect()`, `auth()`, `broadcast()`, `enable_persistence()`, `clear_persistence()`, `set_mutation_callback()`, `free()`. +Available methods on `raw`: `set()`, `setBytes()`, `set_ex()`, `get()`, `getBytes()`, `del()`, `ttl()`, `exists()`, `subscribe()`, `unsubscribe()`, `publish()`, `publishBytes()`, `connect()`, `auth()`, `broadcast()`, `enable_persistence()`, `clear_persistence()`, `set_mutation_callback()`, `free()`. > Writes through `cache.raw` bypass the `onMutation` notification bus. Use the typed `Cache` methods when possible. diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 5951e30..700c107 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -4,6 +4,9 @@ Recached is an in-memory cache server written in Rust. It speaks RESP (the Redis Serialization Protocol) on port 6379, so any Redis client — `ioredis`, `node-redis`, `redis-py`, `Jedis` — works against it today with no code changes. +Values are binary-safe, as they are in Redis: a value is stored and returned as the exact bytes you +sent. *Keys* and other identifiers must be text — see [Binary values](#binary-values). + That is where the similarity with Redis ends. The distinguishing feature is the `core-engine` crate: a pure Rust state machine with no network dependencies, no file I/O, and no OS-specific code. It compiles to native x86-64/ARM64 for the server **and** to `wasm32-unknown-unknown` for the browser. Both targets run the same cache logic from the same source. The WebSocket sync layer (port 6380) keeps the two sides consistent in real time. @@ -48,9 +51,44 @@ Recached is a good fit when: - **You need very high-durability persistence.** Recached supports snapshots (RDB-style) and AOF, but it is still primarily an in-memory cache. If you cannot tolerate any data loss between fsync intervals, a purpose-built database is the right tool. - **You need multi-replica consensus failover.** Recached supports leader–follower replication with automatic single-replica failover (`RECACHED_FAILOVER_TIMEOUT`). If the primary is unreachable for the configured duration, the designated replica promotes itself. What it does not include is multi-replica quorum election: in a setup with several replicas, split-brain prevention requires you to designate one replica for auto-failover and keep the others as passive standbys. -- **You depend on uncommon Redis commands.** Recached implements the commands most applications use, not all 250+. Server introspection (`INFO`, `SLOWLOG`, `COMMAND`), Lua scripting, RESP3, and cluster mode are out of scope. +- **You depend on uncommon Redis commands.** Recached implements the commands most applications use, not all 250+. Server introspection (`INFO`, `SLOWLOG`, `COMMAND`), Lua scripting, and cluster mode are out of scope. RESP3 is supported for protocol negotiation and pub/sub delivery (`HELLO 3`), not for the full RESP3 type surface. - **You need very large datasets.** Recached is an in-memory cache — it is not a database. If your working set does not fit in RAM, Redis with RDB persistence or a proper database is the right tool. +## Binary values + +**Values are binary-safe.** A value is stored and returned as the exact bytes you sent — compressed +payloads, protobuf, images, serialized objects — with no encoding step and no size penalty. + +**Identifiers must be text.** Keys, hash fields, set and sorted-set members, glob patterns and +pub/sub channel names must be valid UTF-8, and a command carrying a binary one is rejected: + +``` +ERR argument 1 is not valid UTF-8. Keys, fields, members and patterns must be text; + only values may be binary +``` + +Nothing is stored when this happens and the connection stays usable. This is narrower than Redis, +where keys are binary-safe too — but keys are looked up, glob-matched and checked against sync scopes +as text, and a binary key would be unreachable through those paths. Keys are identifiers in practice, +so this is rarely felt. + +Commands that interpret a value still require the right shape: `INCR` on a binary value returns +`ERR value is not an integer`, and JSON documents must be UTF-8 because JSON is defined that way. +Those are type errors, not encoding losses — the stored bytes are unchanged either way. + +**The browser SDK handles binary too.** `cache.setBytes(key, uint8array)` writes it, +`cache.getBytes(key)` reads it back, and `cache.publishBytes(channel, uint8array)` publishes it. +Binary values survive the offline outbox, cross-tab sync and IndexedDB persistence unchanged. + +`cache.get()` **throws** on a binary value rather than returning mangled text, and `getJSON()` +treats one as a miss — reach for `getBytes()` when a value may not be text. A binary pub/sub payload +arrives at an `onMessage` listener as a `Uint8Array` instead of a string. + +Before 0.2.2 values were stored as UTF-8 strings and binary was silently replaced with U+FFFD: `SET` +returned `OK` and `GET` returned different bytes than were written, on every transport. If you are +upgrading from an earlier version, data already corrupted that way cannot be recovered — the bytes +were destroyed on the way in. + ## Maturity Honest status, per layer: diff --git a/docs/guide/use-cases.md b/docs/guide/use-cases.md index 7449c92..dae0856 100644 --- a/docs/guide/use-cases.md +++ b/docs/guide/use-cases.md @@ -88,8 +88,9 @@ It is not durable storage. See the persistence caveats in **Working set larger than RAM → a database, or Redis with eviction tuned.** There is no disk-backed tier. -**You need Lua scripting, cluster mode, RESP3, or `INFO`/`SLOWLOG` introspection → Redis.** These are -explicitly out of scope; see [Commands](/server/commands). +**You need Lua scripting, cluster mode, or `INFO`/`SLOWLOG` introspection → Redis.** These are +explicitly out of scope; see [Commands](/server/commands). RESP3 exists only for protocol +negotiation and pub/sub framing (`HELLO 3`), not the full type surface. ## Compared directly @@ -139,9 +140,12 @@ Before switching a workload over, check: 1. **Command coverage.** Diff your actual command usage against [Commands](/server/commands). `MONITOR` on your existing Redis for a representative window is the fastest way to get that list. Lua scripts, cluster commands, streams, and `INFO`-based tooling will not carry over. -2. **Persistence expectations.** Confirm snapshot + AOF semantics match what you assume today. -3. **Replication topology.** Single-replica auto-failover only; no Sentinel or quorum election. -4. **Eviction.** Review the key cap and TTL behaviour in +2. **Key encoding.** Values are binary-safe, but keys, hash fields, set members and glob patterns + must be valid UTF-8 — Redis allows binary there. Applications that use binary keys are rare; if + yours does, that is a blocker. See [Binary values](/guide/introduction#binary-values). +3. **Persistence expectations.** Confirm snapshot + AOF semantics match what you assume today. +4. **Replication topology.** Single-replica auto-failover only; no Sentinel or quorum election. +5. **Eviction.** Review the key cap and TTL behaviour in [Configuration](/server/configuration) against your memory budget. There is no data migration path from an RDB file — treat it as a cold cache and let it fill. diff --git a/docs/package.json b/docs/package.json index 9557e7d..1708364 100644 --- a/docs/package.json +++ b/docs/package.json @@ -9,5 +9,11 @@ }, "devDependencies": { "vitepress": "^1.6.3" + }, + "pnpm": { + "overrides": { + "vite": "^6.4.3", + "esbuild": "^0.25.0" + } } } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 1b5bd99..0135d54 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + vite: ^6.4.3 + esbuild: ^0.25.0 + importers: .: @@ -130,141 +134,159 @@ packages: search-insights: optional: true - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -457,7 +479,7 @@ packages: resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} peerDependencies: - vite: ^5.0.0 || ^6.0.0 + vite: ^6.4.3 vue: ^3.2.25 '@vue/compiler-core@3.5.34': @@ -588,14 +610,23 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} hasBin: true estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} @@ -664,6 +695,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -718,6 +753,10 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -742,22 +781,27 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' less: '*' lightningcss: ^1.21.0 sass: '*' sass-embedded: '*' stylus: '*' sugarss: '*' - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -772,6 +816,10 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true vitepress@1.6.4: resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} @@ -947,73 +995,82 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-x64@0.25.12': optional: true '@iconify-json/simple-icons@1.2.81': @@ -1164,9 +1221,9 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.34)': + '@vitejs/plugin-vue@5.2.4(vite@6.4.3)(vue@3.5.34)': dependencies: - vite: 5.4.21 + vite: 6.4.3 vue: 3.5.34 '@vue/compiler-core@3.5.34': @@ -1311,34 +1368,41 @@ snapshots: entities@7.0.1: {} - esbuild@0.21.5: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 estree-walker@2.0.2: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + focus-trap@7.8.0: dependencies: tabbable: 6.4.0 @@ -1421,6 +1485,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@4.0.5: {} + postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -1504,6 +1570,11 @@ snapshots: tabbable@6.4.0: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + trim-lines@3.0.1: {} unist-util-is@6.0.1: @@ -1539,11 +1610,14 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@5.4.21: + vite@6.4.3: dependencies: - esbuild: 0.21.5 + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.14 rollup: 4.60.3 + tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 @@ -1556,7 +1630,7 @@ snapshots: '@shikijs/transformers': 2.5.0 '@shikijs/types': 2.5.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.4(vite@5.4.21)(vue@3.5.34) + '@vitejs/plugin-vue': 5.2.4(vite@6.4.3)(vue@3.5.34) '@vue/devtools-api': 7.7.9 '@vue/shared': 3.5.34 '@vueuse/core': 12.8.2 @@ -1565,7 +1639,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.2.0 shiki: 2.5.0 - vite: 5.4.21 + vite: 6.4.3 vue: 3.5.34 optionalDependencies: postcss: 8.5.14 @@ -1579,6 +1653,7 @@ snapshots: - drauu - fuse.js - idb-keyval + - jiti - jwt-decode - less - lightningcss @@ -1593,8 +1668,10 @@ snapshots: - stylus - sugarss - terser + - tsx - typescript - universal-cookie + - yaml vue@3.5.34: dependencies: diff --git a/docs/roadmap.md b/docs/roadmap.md index 1de72e5..11b4672 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,67 +2,33 @@ Recached competes on **where the data can live** — the same engine on the server and in the browser, with sync in between. The [benchmarks](/guide/benchmarks) show this costs nothing in raw speed. -Numbered items are stable identifiers, not an ordering — code and changelog entries reference them -(e.g. "roadmap #6"), so they are never renumbered. **Near-term priorities** are called out below. - -Items 1–5 have shipped: rate-limiting commands, scoped sync with per-client auth, live queries, the native JSON type, and offline-first writes with merge semantics. Their numbering is retained below so existing references stay valid — see the [changelog](https://github.com/thinkgrid-labs/recached/blob/main/CHANGELOG.md) for what landed in each. - -## Near-term - -The next three things worth doing, in order: - -1. **[Reconnect backoff jitter](#reliability)** — a few lines; prevents a thundering - herd every time the server restarts. -2. **[Presence — connection-scoped keys](#_13-presence-—-connection-scoped-keys)** — the docs already - sell presence as a use case; the primitive does not exist yet. -3. **[Live queries carry collection values](#live-queries)** — removes a - round-trip that currently contradicts the local-read premise. --- -## 6. Mobile SDKs — React Native, Flutter, Kotlin, Swift +## Mobile SDKs — React Native, Flutter, Kotlin, Swift - **Kotlin + Swift first**, via a single `uniffi`-annotated Rust crate that generates bindings for both. The platform WebSocket (OkHttp / URLSession) feeds frames into `sync-client` — no embedded async runtime. Persistence: a file/SQLite adapter over the same outbox/meta effects the browser maps to IndexedDB. Reactivity: Kotlin `Flow` / Swift `Observation` over keychange pushes. - **Flutter** via `flutter_rust_bridge`: synchronous local reads into Rust memory, `watchKey()` → `Stream` for rebuilds. - **React Native** last (Hermes has no WASM): `uniffi-bindgen-react-native` reuses the same binding layer, and the existing React hooks API carries over — same `useKey` in React DOM and React Native. -## 7. WASM server-side scripting +## WASM server-side scripting Run `.wasm` stored procedures in place of Lua scripts. The scripting VM would be sandboxed (no network, no file I/O, bounded execution time), accept any WASM module that exports a specific entry function, and execute it against the cache store. Supports any language that compiles to WASM: Rust, Go (TinyGo), AssemblyScript, Python. -## 8. WASI target +## WASI target A `wasm32-wasip1` build of `wasm-edge` for Cloudflare Workers and Deno Deploy, running Recached as a cache layer at the edge with the same API as the browser client. `core-engine` is already `wasm32`-compatible; the work is adapting the WebSocket and persistence layers to WASI. Last on the list because the platform fights the model — Workers cannot hold persistent WebSockets outside Durable Objects — and edge platforms ship native KV stores. -## 13. Presence — connection-scoped keys - -Keys whose lifetime is tied to the connection that set them. The server drops them when the socket -closes, and the deletion fans out through live queries like any other change. - -```bash -ESET presence:room:7:user:42 "typing" # lives exactly as long as this connection -``` - -Today presence has to be hand-rolled with `SETEX` plus a heartbeat, which leaves ghost entries for -the length of the TTL whenever a tab closes — the classic "still shows as online" bug. Every piece -needed already exists: the WebSocket handler knows when a connection ends, and `QSUB` already -broadcasts deletions. - -Worth doing early because presence, live dashboards, and collaborative cursors are the use cases the -architecture is uniquely suited to, and they are already named in the -[use cases](/guide/use-cases) page. - ---- ## AI-era features Recached's unfair advantage is *where the data lives* — so the winning AI features put the intelligence layer **next to the user** instead of behind another network hop. Ordered by intended sequence. -### 9. Token-cost rate limiting +### Token-cost rate limiting AI providers meter tokens, not requests. One optional argument extends the existing limiter to weighted budgets: @@ -71,7 +37,7 @@ RLCHECK user:42 100000 3600 COST 1850 # consume 1,850 tokens of a 100k/hour bu ``` -### 10. Semantic caching (`SEMSET` / `SEMGET`) +### Semantic caching (`SEMSET` / `SEMGET`) LLM calls are expensive and repeats are *paraphrases*, so exact-key caching misses them. A semantic cache returns a hit when a query's embedding is close enough to a cached one: @@ -80,12 +46,12 @@ SEMSET prompts "" EX 3600 SEMGET prompts 0.92 # → cached response or nil ``` -### 11. Streaming values — "watch the agent think" +### Streaming values — "watch the agent think" An agent streams tokens into a key with `APPEND`; every subscribed browser renders it live. Live queries already deliver the subscription — the missing piece is an append *delta* frame (keychange currently re-sends the whole value) plus catch-up-then-follow on reconnect. `useKey('agent:run:42:output')` becomes a live-typing agent visible to any number of viewers. Redis Streams end at the backend; this reaches the UI. -### 12. Computed keys — the reactive cache +### Computed keys — the reactive cache Declare a key as a function of other keys; the server recomputes on change and the diff flows through live queries — cache becomes spreadsheet. `cart:42:total` recomputes when any `cart:42:item:*` changes, and every subscribed UI updates. Uses WASM scripting (#7) as the function runtime. Biggest lift, biggest ceiling. @@ -93,88 +59,4 @@ Under consideration behind these: a CRDT text type for collaborative editing (li --- -## Hardening & enhancements - -Improvements to shipped functionality rather than new surface. Unnumbered because they are small and -independent — pick them off in any order. - -### Reliability - -**Reconnect backoff jitter.** `on_close()` computes `500ms × 2^attempts` with no randomisation, so -every client disconnected by a server restart reconnects on an identical schedule. At scale that is a -thundering herd that can keep knocking the server over as it comes back up. Full or decorrelated -jitter is a few lines, and the existing backoff test only needs to assert a range instead of an exact -value. - -**Exactly-once across a server restart.** `DEDUP` high-water marks live in server memory and are -swept after 24 h idle, so a restart inside the acknowledgement window can admit one duplicate — a -caveat currently documented rather than fixed. Persisting the marks alongside the snapshot would make -the exactly-once guarantee unconditional. - -**Atomic WAL compaction.** Browser-side compaction clears the write-ahead log and then writes the -replacement snapshot. An interruption between the two loses the persisted cache. Writing to a new key -range and swapping removes the window entirely. - -**Surface outbox overflow.** The client outbox holds 10 000 writes and silently evicts the oldest -past that. An `onOutboxFull` callback, or a `pendingWrites()` accessor, lets an application degrade -deliberately instead of losing writes without a signal. - -### Live queries - -**Live queries carry collection values.** Collection types currently arrive as *type markers*, so a -subscriber must follow every change with a typed re-read (`HGETALL`, `LRANGE`). That round-trip is -precisely what the local-read model exists to remove, so the promise only fully holds for strings -today. Sending the value — or a delta — inline closes the gap. - -**`FLUSHDB` should emit per-key diffs.** Subscribers currently miss a mass deletion entirely. - -### Performance - -Both of these are diagnosed on the [benchmarks](/guide/benchmarks) page; the analysis is done, the -work is not. - -**Byte-slice command arguments.** RESP parsing allocates a `String` per argument. This is the main -remaining lever on unpipelined latency and the likeliest explanation for `HSET` sitting at ~46 % of -Redis while *beating* it pipelined. - -**Serialize `LRANGE` straight from the store**, instead of building the full reply `Value` first. - -### Operability - -**Capacity and sync metrics.** Six series are exported today, all traffic. Nothing reports memory -use, key count, eviction rate, replication lag, or outbox depth — so an operator cannot answer "am I -near the cap?" or "is my replica behind?" from a dashboard. See -[Operations](/server/operations#what-is-not-exported-yet). - -**Make the compiled-in limits configurable.** The 10 000-write outbox, 64 live queries per -connection, and eviction's fixed 10-key sample are all constants. Redis exposes `maxmemory-samples` -for the same reason: the right value is workload-dependent. - -**Bound rate-limiter memory.** The limiter stores one timestamp per attempt, so `RLSET key 100000 -3600` holds 100 000 `u64`s — roughly 800 KB for a single key. The cost-weighted rework in -[#9](#_9-token-cost-rate-limiting) is the natural moment to move to bucketed counts. - -### Security - -**Warn on sync tokens minted without an expiry.** Expiry is optional in the token payload, so a token -issued without one is valid forever. Refusing to mint it — or at minimum logging loudly — would match -how half-configured TLS is now handled. - -**Per-command ACLs and an audit log.** Both are named as gaps in the -[threat model](/server/security#threat-model-stated-plainly): authentication on the RESP port is -all-or-nothing, and there is no record of who read or wrote what. Neither is interesting engineering; -both are procurement checkboxes worth building when someone actually asks. - ---- - -## Ongoing: drop-in credibility - -Not features, but continuous work that keeps "any Redis client works today" honest: - -- **Binary-safe WebSocket values** — values over WS are currently UTF-8 (lossy for raw bytes); binary frames would make the two transports equivalent. -- **RESP3** — push protocol support on the TCP port. -- **Command coverage** — closing gaps in the supported command set as real workloads surface them (see [Commands](/server/commands)). - ---- - Feedback on priorities is welcome — [open an issue](https://github.com/thinkgrid-labs/recached/issues) or write to [dennis@thinkgrid.dev](mailto:dennis@thinkgrid.dev). diff --git a/docs/server/commands.md b/docs/server/commands.md index 6cfbcdc..3a05fd0 100644 --- a/docs/server/commands.md +++ b/docs/server/commands.md @@ -8,6 +8,7 @@ Recached implements the subset of RESP commands that most applications use. Comm |---|---| | `PING [message]` | Returns `PONG`, or echoes `message` if provided. Used to test connectivity and measure latency. | | `AUTH password` | Authenticates the connection. Required on the first command if `RECACHED_PASSWORD` is set. 5 consecutive failures close the connection. | +| `HELLO [protover]` | Reports server info and negotiates the protocol version. `3` switches the connection to RESP3, `2` back to RESP2, no argument reports without changing. Unsupported versions return `-NOPROTO` and leave the connection unchanged. Requires authentication. See [Wire Protocol](/server/protocol#protocol-version-tcp). | --- @@ -22,6 +23,7 @@ The most common data type. Values are always stored as byte strings; numeric ope | `GETSET key value` | Sets the key to a new value and returns the old value atomically. Deprecated in Redis 6.2 — prefer `SET key value GET`. | | `MGET key [key ...]` | Returns the values of multiple keys. Keys that do not exist return nil. | | `MSET key value [key value ...]` | Sets multiple keys to their respective values in a single atomic operation. | +| `ESET key value` | **Ephemeral set.** Stores a string like `SET`, but the key's lifetime is bound to the connection that wrote it — when that connection closes, the server deletes the key and the deletion is pushed to live queries. Writing the same key again transfers ownership to the newest connection, so a second browser tab keeps presence alive when the first closes. Intended for presence, cursors, and "who is online"; use `SET` for anything that should outlive a connection. | | `SETNX key value` | Set a key only if it does not exist. Returns 1 if set, 0 if the key already existed. | | `SETEX key seconds value` | Set a key with an integer-second expiry. Equivalent to `SET key value EX seconds`. | | `PSETEX key milliseconds value` | Set a key with a millisecond-precision expiry. | @@ -197,7 +199,7 @@ JGET doc:42 # {"meta":{"views":17},"title":"Final"} The browser SDK exposes the same commands as [`jset` / `jget` / `jmerge`](/browser/api-reference#json-documents) with `JSON.stringify`/`parse` handled for you — a `JMERGE` from any client updates every connected browser's local document. -In live queries (`QSUB` / `useKeys`), JSON keys appear with a `json` type marker rather than the document — subscribe for change signals and read the document with `JGET`/`jget`. +In live queries (`QSUB` / `useKeys`), JSON keys arrive as `["json", document]` — the document travels with the notification, so no follow-up `JGET` is needed. --- @@ -265,7 +267,7 @@ A live query delivers the current state of every key matching a glob pattern, th | Command | Description | |---|---| -| `QSUB pattern` | Subscribe. The reply is `["qstate", pattern, key, value, ...]` — the current state of every live key matching the pattern as flat pairs (strings in full; collection types as their type name, fetch them with a typed read). Afterwards, every mutation to a matching key — including keys created later — arrives as a `["keychange", key, value]` push; deletions arrive with a nil value. Initial state is capped at 10 000 keys. Up to 64 live queries per connection. | +| `QSUB pattern` | Subscribe. The reply is `["qstate", pattern, key, value, ...]` — the current state of every live key matching the pattern as flat pairs. Afterwards, every mutation to a matching key — including keys created later — arrives as a `["keychange", key, value]` push; deletions arrive with a nil value. Initial state is capped at 10 000 keys. Up to 64 live queries per connection. | | `QUNSUB [pattern]` | Drop one live query, or all of them without an argument. | ```bash @@ -280,7 +282,36 @@ QSUB cart:42:* Under strict sync scoping, `QSUB` patterns must sit inside the connection's granted scopes — a grant of `cart:42:*` covers `QSUB cart:42:*` and narrower prefix patterns. Live-query pushes never interfere with `WATCH` transactions (they travel on a separate internal channel). -Two current limitations: `FLUSHDB` does not emit per-key diffs to live queries, and collection-type values arrive as type markers rather than full values — subscribe plus a typed re-read (`HGETALL`, `LRANGE`) on change. +### Value shapes + +Strings arrive as a bulk string and deletions as nil. Collections arrive **type-tagged** — an array +whose first element names the type — so a subscriber can rebuild the value without a follow-up read: + +```text +hash → ["hash", field, value, ...] fields sorted +list → ["list", element, ...] head to tail +set → ["set", member, ...] +zset → ["zset", member, score, ...] ascending score +json → ["json", document] +``` + +The tag is what makes the payload unambiguous: a four-element array would otherwise be +indistinguishable between a list of four items and a hash of two pairs. Ordering is deterministic, so +two clients receiving the same notification build identical local state. + +Each notification carries the **complete** current value, so the receiver replaces the key rather than +merging — which is what allows a removed member to propagate. + +`FLUSHDB` is announced as a single sentinel per subscribed pattern — a `keychange` whose key is the +pattern and whose value is nil — meaning "every key matching this pattern is gone". Announcing each +deleted key would mean one frame per key in the keyspace for one command. The browser SDK expands the +sentinel locally; a hand-written client should do the same. + +::: warning Changed in 0.2.2 +Before 0.2.2 collections arrived as a bare type name (`"hash"`), and subscribers had to follow up with +`HGETALL`/`LRANGE`. Server and SDK are released in lockstep — run matching versions, since a 0.2.1 +client ignores the new shape. +::: --- diff --git a/docs/server/configuration.md b/docs/server/configuration.md index ffccd42..cbfc1e6 100644 --- a/docs/server/configuration.md +++ b/docs/server/configuration.md @@ -19,6 +19,11 @@ Recached is configured entirely through environment variables. There is no confi | `RECACHED_AOF_PATH` | _(disabled)_ | Path to the append-only file. When set, every write command is appended to this file in addition to snapshot saves. On startup the snapshot is loaded first, then AOF commands are replayed for the delta. The AOF is truncated after each successful snapshot save. | | `RECACHED_AOF_SYNC` | `everysec` | AOF fsync policy. `always`: fsync after every write (safest, slowest). `everysec`: fsync once per second (default, good balance). `no`: let the OS decide (fastest, least safe). | | `RECACHED_MAX_CONNECTIONS` | `1024` | Maximum number of concurrent client connections (TCP + WebSocket combined). New connections are dropped when the limit is reached. | +| `RECACHED_EVICTION_SAMPLE` | `10` | Keys sampled per eviction pass. A larger sample approximates true LRU/TTL ordering more closely at the cost of more work per eviction — the knob Redis exposes as `maxmemory-samples`. | +| `RECACHED_MAX_MULTI_QUEUE` | `10000` | Commands that may be queued inside one `MULTI`. | +| `RECACHED_MAX_WATCHES_PER_CONN` | `1024` | Keys a single connection may `WATCH`. | +| `RECACHED_MAX_LIVE_QUERIES` | `64` | Live queries (`QSUB`) a single connection may hold. | +| `RECACHED_MAX_QSUB_INITIAL_KEYS` | `10000` | Keys returned in a live query's initial `qstate` reply. Beyond this the snapshot is truncated — narrow the pattern instead of raising it. | | `RECACHED_REPL_PORT` | `6381` | TCP port the primary listens on for incoming replica connections. Only active when `RECACHED_REPLICAOF` is not set (i.e. this server is a primary). | | `RECACHED_REPLICAOF` | _(none)_ | Set to `host:port` to run this server as a read-only replica. On startup it connects to the primary, receives a full snapshot, and then streams all subsequent writes. Reconnects automatically with exponential backoff on disconnect. | | `RECACHED_REPL_PASSWORD` | _(none)_ | Shared secret for the replication channel. When set, replicas must send this password during the handshake before receiving any data. Must match on both primary and replica. Strongly recommended for any network-exposed replication port. | diff --git a/docs/server/operations.md b/docs/server/operations.md index b1a0365..5b24de6 100644 --- a/docs/server/operations.md +++ b/docs/server/operations.md @@ -35,22 +35,42 @@ real. | `recached_keyspace_hits_total` | counter | — | Reads that found a live key. | | `recached_keyspace_misses_total` | counter | — | Reads that found nothing or an expired key. | -### What is not exported yet +### Capacity and sync -Be aware of these before you build a dashboard expecting them: +Sampled every 5 seconds, because these are levels rather than events. -- **No memory metric.** Nothing reports bytes used or how close you are to `RECACHED_MAX_MEMORY`. - Monitor process RSS from your container runtime or node exporter instead. -- **No key-count metric.** Nothing reports keyspace size against `RECACHED_MAX_KEYS`. `DBSIZE` gives - it on demand, but no scrape collects it. -- **No eviction counter.** You cannot currently see whether eviction is running or how hard. -- **No replication metrics.** No lag, no replica connection state, no failover events. -- **No sync-layer metrics.** Live-query counts, outbox depth, and `DEDUP` duplicate rates are not - exported. +| Metric | Type | Meaning | +|---|---|---| +| `recached_memory_bytes` | gauge | Approximate heap used by stored data. Compare against `RECACHED_MAX_MEMORY`. | +| `recached_keys` | gauge | Live keys, excluding expired entries awaiting sweep. Compare against `RECACHED_MAX_KEYS`. | +| `recached_evictions_total` | counter | Keys evicted since start. A rising rate means the cache is working at its cap. | +| `recached_replicas_connected` | gauge | Replicas currently attached to this primary. | +| `recached_live_queries` | gauge | Registered `QSUB` patterns across all connections. | +| `recached_watched_keys` | gauge | Keys under `WATCH`. | +| `recached_dedup_clients_tracked` | gauge | Clients with exactly-once bookkeeping in memory. | +| `recached_replication_queue_depth` | gauge | Deepest replica send queue, in frames — work the primary has not yet put on the wire. | +| `recached_replication_lag_frames` | gauge | Frames the furthest-behind replica has been sent but has not acknowledged applying. Zero means every replica is caught up. | + +### Reading the two replication gauges + +They fail differently, which is why both exist: + +- **Queue depth high, lag high** — the primary cannot hand frames off fast enough. The replica's + channel is backing up, usually a slow or saturated network link. A replica whose queue fills is + disconnected outright so it resyncs from a snapshot rather than falling further behind. +- **Queue depth zero, lag high** — everything was written to the socket and the replica is not + acknowledging it. The frames are in flight, or the replica is applying them slowly, or it is + wedged. This is the case queue depth alone cannot see, and it is the one worth alerting on. -The practical consequence: **Recached tells you about traffic, not about capacity or sync health.** -Until those land, back the traffic metrics with process-level monitoring (RSS, CPU, FD count) and -treat replication and sync as things you verify by probing, not by scraping. +Lag is measured in frames, not bytes or seconds: one frame is one replicated write command. + +A replica running a build older than 0.2.2 never acknowledges, so its lag climbs without bound while +replication works normally. Upgrade both ends together. + +### What is still not exported + +- **Client outbox depth.** That state lives in the browser — read it there with + `cache.pendingWrites()`. ## Useful queries @@ -83,7 +103,10 @@ Thresholds are starting points — tune to your traffic. | Connection saturation | `recached_connections_active` > 80% of `RECACHED_MAX_CONNECTIONS` | New connections are rejected once the semaphore is exhausted — this fails hard, not gracefully. | | Hit ratio collapse | hit ratio drops sharply vs baseline | Keys expiring faster than expected, an eviction storm, or a cold restart. | | Traffic flatline | `rate(recached_commands_total[5m]) == 0` while clients are up | The process is alive enough to scrape but not serving. | -| Process memory | RSS > 80% of the container limit | There is no built-in memory metric; this is your only capacity signal. | +| Memory pressure | `recached_memory_bytes` > 80% of `RECACHED_MAX_MEMORY` | Eviction is about to start, or already has. | +| Eviction churn | `rate(recached_evictions_total[5m])` climbing | The working set no longer fits; results will start missing. | +| Replica lost | `recached_replicas_connected` drops | Failover risk — the standby is no longer following. | +| Replica falling behind | `recached_replication_lag_frames` > 1000 for 5m | The standby is not keeping up; a failover now would lose those writes. | ## Health checking @@ -115,17 +138,18 @@ healthy — they are separate listeners. Probe the cache port. Hard limits compiled into the server. Exceeding them produces errors rather than degradation, so it is worth knowing where the walls are: -| Limit | Value | Configurable | +| Limit | Default | Configurable | |---|---|---| | Max connections | 1024 | `RECACHED_MAX_CONNECTIONS` | | Consecutive auth failures before disconnect | 5 | No | | Read buffer per TCP connection | 64 MB | No | -| Queued commands per `MULTI` | 10,000 | No | -| `WATCH`ed keys per connection | 1,024 | No | -| Live queries (`QSUB`) per connection | 64 | No | -| Keys returned in a live query's initial state | 10,000 | No | +| Queued commands per `MULTI` | 10,000 | `RECACHED_MAX_MULTI_QUEUE` | +| `WATCH`ed keys per connection | 1,024 | `RECACHED_MAX_WATCHES_PER_CONN` | +| Live queries (`QSUB`) per connection | 64 | `RECACHED_MAX_LIVE_QUERIES` | +| Keys returned in a live query's initial state | 10,000 | `RECACHED_MAX_QSUB_INITIAL_KEYS` | +| Keys sampled per eviction pass | 10 | `RECACHED_EVICTION_SAMPLE` | | Replication frame | 512 MB | No | -| Client outbox (browser, offline writes) | 10,000 writes | No | +| Client outbox (browser, offline writes) | 10,000 writes | via `sync-client` | The keyspace cap (`RECACHED_MAX_KEYS`) and memory cap (`RECACHED_MAX_MEMORY`) are configured rather than compiled — see [Configuration](/server/configuration#environment-variable-reference). @@ -144,8 +168,12 @@ redis-cli -p 6379 LASTSAVE # timestamp advances when the save lands cp /var/lib/recached/dump.msgpack /backups/dump-$(date +%F).msgpack ``` -To restore, stop the server, put the snapshot at `RECACHED_SAVE_PATH`, and start it — the snapshot -loads at boot. There is **no import path from a Redis RDB file**; the formats are unrelated. +A sidecar file sits next to the snapshot with a `.dedup` extension, holding exactly-once high-water +marks. Back it up with the snapshot: without it a restarted server can re-apply a write a client +replays. Losing it is not fatal — the server starts normally and rebuilds the marks. + +To restore, stop the server, put the snapshot (and its `.dedup` sidecar) at `RECACHED_SAVE_PATH`, and +start it — both load at boot. There is **no import path from a Redis RDB file**; the formats are unrelated. If AOF is enabled, the AOF replays on top of the snapshot. Losing the AOF while keeping the snapshot costs you every write since the last save. diff --git a/docs/server/protocol.md b/docs/server/protocol.md index 95ea837..7472145 100644 --- a/docs/server/protocol.md +++ b/docs/server/protocol.md @@ -6,10 +6,39 @@ This page is **normative**: client SDKs (browser `recached-edge`, the planned mo | Port | Transport | Framing | Audience | |---|---|---|---| -| 6379 | TCP | RESP2, pipelined | Trusted backends (any Redis client) | -| 6380 | WebSocket | One RESP value per **text** frame | Untrusted browsers / apps | +| 6379 | TCP | RESP2 by default, RESP3 after `HELLO 3`, pipelined | Trusted backends (any Redis client) | +| 6380 | WebSocket | One RESP value per frame — **text**, or **binary** for bytes that are not valid UTF-8 | Untrusted browsers / apps | -WebSocket text frames imply UTF-8: raw binary values are only fully round-trippable over TCP. +### Protocol version (TCP) + +A TCP connection starts in **RESP2**. `HELLO 3` switches it to RESP3; `HELLO 2` switches back; a +bare `HELLO` reports without changing anything. An unsupported version is refused with `-NOPROTO` +and leaves the connection on the protocol it already had, so a client can probe and fall back. + +The version changes exactly one thing on the wire today: **pub/sub deliveries are RESP3 Push (`>`) +frames on a RESP3 connection and plain arrays (`*`) on a RESP2 one.** RESP2 has no push type, so +sending `>` to a RESP2 client is unparseable — before 0.2.2 the server did exactly that, which broke +standard Redis clients that subscribed without negotiating. + +`HELLO` requires authentication when a password is set; the pre-auth reply is `-NOAUTH` and carries +no server details. + +### Binary frames (WebSocket) + +The WebSocket spec requires text frames to be well-formed UTF-8. A command or reply carrying bytes +that are not valid UTF-8 therefore travels in a **binary** frame instead; everything else stays in +text frames, so existing clients are unaffected. A client must accept both. + +::: tip Values are binary-safe; identifiers are not +A value is stored and returned as the exact bytes sent. Keys, hash fields, set and sorted-set +members, glob patterns and channel names must be valid UTF-8 — they are looked up, matched and routed +as text — and a command carrying a binary one is rejected with +`ERR argument is not valid UTF-8. Keys, fields, members and patterns must be text; only values +may be binary`. Nothing is stored, and the connection stays usable. + +Before 0.2.2 values were stored as UTF-8 strings and binary was silently replaced with U+FFFD on +every transport. +::: ## Frame taxonomy (WebSocket) @@ -19,7 +48,7 @@ Every frame a client receives is exactly one of: |---|---|---| | **Reply** | any RESP value not matching the rows below | Response to one command this connection sent | | **Mutation push** | RESP3 Push `>N` whose elements form a replayable command (`SET`, `HSET`, `JSET`, `JMERGE`, …) | Another client/backend mutated a key in scope — apply to the local store | -| **Pub/sub push** | RESP3 Push `>3` = `["message", channel, payload]` | Pub/sub delivery | +| **Pub/sub push** | RESP3 Push `>3` = `["message", channel, payload]` | Pub/sub delivery. The WebSocket transport is always RESP3 — `HELLO 2` on it is refused, because the frame taxonomy below depends on the push type existing | | **Keychange push** | Array `["keychange", key, value]` | A watched / live-queried key changed. `value`: full string, nil (deleted), or a type-name marker (`hash`, `list`, `set`, `zset`, `json`, `ratelimit`) whose content travels via mutation pushes instead | | **Query state** | Array `["qstate", pattern, k1, v1, …]` | **Both** the reply to a `QSUB` **and** initial state to apply (same value encoding as keychange) | diff --git a/docs/server/troubleshooting.md b/docs/server/troubleshooting.md index 339a4a8..044a9ec 100644 --- a/docs/server/troubleshooting.md +++ b/docs/server/troubleshooting.md @@ -78,13 +78,54 @@ traffic you believed was encrypted was not. If you are on an older version, veri assume: a `rediss://` client should connect and a plaintext client should be refused. See [Security → Transport encryption](/server/security#transport-encryption). +### A replica is connected but falling behind + +`recached_replication_lag_frames` counts frames sent but not acknowledged by the furthest-behind +replica. Unlike `recached_replication_queue_depth`, it stays high when the primary has written +everything to the socket and the replica is not keeping up — see +[Operations → Reading the two replication gauges](/server/operations#reading-the-two-replication-gauges). + +Lag that climbs without bound while replication otherwise works usually means the replica predates +0.2.2 and never acknowledges. Upgrade both ends together. + ### Memory keeps growing There is no built-in memory metric — monitor process RSS. Set `RECACHED_MAX_MEMORY` and `RECACHED_EVICTION` so the cache bounds itself, and `RECACHED_MAX_KEYS` if key count rather than value size is the driver. `DBSIZE` reports the current key count on demand. -### `ERR key too large` +### A subscriber receives nothing, or cannot parse what it receives + +Two separate faults, both fixed in **0.2.2**: + +- **Nothing arrives until the subscriber sends another command.** Deliveries were written into a + buffered writer that only flushed when handling a client command, so a connection that purely + listened saw nothing. A subscriber that also polls appeared to work, which is why this went + unnoticed. +- **Frames arrive but the client errors on them.** Pub/sub was delivered as RESP3 Push (`>`) frames + on every connection. RESP2 has no push type, so a standard Redis client that subscribed without + sending `HELLO 3` could not parse the frame. Deliveries now follow the negotiated version. + +On an older server, neither has a client-side workaround — upgrade. + +### `ERR argument N is not valid UTF-8` + +A key, hash field, set or sorted-set member, or glob pattern contained bytes that are not valid +UTF-8. **Values are binary-safe** — this error only ever refers to an identifier position. Nothing +was written and the connection is still usable. + +Identifiers are looked up, glob-matched and checked against sync scopes as text, so a binary one +would be unreachable through those paths. Hex- or base64-encode the identifier: + +```js +await cache.set(`blob:${id.toString('hex')}`, binaryPayload); // value stays raw +``` + +Before 0.2.2 *values* were also required to be text and binary was silently corrupted. If you are +reading back mangled binary written by an older server, that data cannot be recovered — the bytes +were destroyed on the way in. Re-populate the affected keys. + +### `ERR key too large`### `ERR key too large` A key exceeded the maximum key length. Keys are identifiers, not payloads — put the data in the value. @@ -122,6 +163,10 @@ connections — by design, since they would leak or destroy data outside the con The client outbox holds **10,000 pending writes**. Past that, each new write **evicts the oldest one** — silently, with no error surfaced to your code. +Register `cache.onOutboxFull((droppedId, pending) => …)` to be told when this happens — without it +the loss is invisible. `cache.pendingWrites()` reports the current depth, so an application can apply +back-pressure before the cap is reached. + If a client can be offline long enough to exceed 10,000 writes, do not rely on the outbox as the system of record for those mutations. Batch them, or persist them yourself and reconcile on reconnect. @@ -151,9 +196,12 @@ See [Offline & Reconnection](/browser/offline). A live query's initial state is capped at **10,000 keys**. Beyond that the snapshot is truncated. Narrow the pattern. -Also note two documented limits of live queries: `FLUSHDB` does not emit per-key diffs, and -collection values arrive as type markers rather than full values — subscribe, then re-read with -`HGETALL` / `LRANGE` on change. +`FLUSHDB` arrives as one sentinel per subscribed pattern rather than one frame per key — if your +client is hand-written, expand it locally. + +If collection values arrive as a bare type name (`"hash"`) rather than their contents, the server and +SDK are on different versions — collection values ship complete from 0.2.2 onward, and the two are +released in lockstep. ### Too many live queries diff --git a/sdks/recached-react/package-lock.json b/sdks/recached-react/package-lock.json index 0e6ba7f..c025e16 100644 --- a/sdks/recached-react/package-lock.json +++ b/sdks/recached-react/package-lock.json @@ -1,13 +1,13 @@ { "name": "@recached/react", - "version": "0.1.4", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@recached/react", - "version": "0.1.4", - "license": "MIT", + "version": "0.2.2", + "license": "Apache-2.0", "devDependencies": { "@types/react": "^18", "typescript": "^5.9.3" diff --git a/sdks/recached-react/package.json b/sdks/recached-react/package.json index 74fbcf3..ed5b6ff 100644 --- a/sdks/recached-react/package.json +++ b/sdks/recached-react/package.json @@ -1,6 +1,6 @@ { "name": "@recached/react", - "version": "0.2.1", + "version": "0.2.2", "description": "Official React hooks for Recached \u2014 zero-latency reactive cache", "type": "module", "main": "./dist/index.js", diff --git a/sdks/recached-react/src/index.ts b/sdks/recached-react/src/index.ts index 8cf35ff..8b50b09 100644 --- a/sdks/recached-react/src/index.ts +++ b/sdks/recached-react/src/index.ts @@ -1,4 +1,4 @@ export { RecachedProvider, useRecached } from './context'; -export { useKey, useKeyJSON } from './useKey'; +export { useKey, useKeyBytes, useKeyJSON } from './useKey'; export { useKeys, type KeyValuePair } from './useKeys'; export { usePubSub } from './usePubSub'; diff --git a/sdks/recached-react/src/useKey.ts b/sdks/recached-react/src/useKey.ts index 7bc9a4b..f4bd644 100644 --- a/sdks/recached-react/src/useKey.ts +++ b/sdks/recached-react/src/useKey.ts @@ -8,7 +8,9 @@ import { useRecached } from './context'; * deleted — whether the mutation originated locally, from another tab via * BroadcastChannel, or from another client via the server WebSocket. * - * Returns `null` when the key does not exist or has expired. + * Returns `null` when the key does not exist, has expired, or holds a value that + * is not valid UTF-8 — a binary value has no string form, so read it with + * {@link useKeyBytes} instead. * * Built on React 18's `useSyncExternalStore` — safe with concurrent features. * @@ -28,7 +30,43 @@ export function useKey(key: string): string | null { const cache = useRecached(); return useSyncExternalStore( (cb) => cache.onMutation(cb), - () => cache.get(key), + // `get` throws on a binary value. This runs as a `getSnapshot`, so letting + // it propagate would take down the render tree over a value the component + // simply cannot display — report it as absent and let `useKeyBytes` read it. + () => { + try { + return cache.get(key); + } catch { + return null; + } + }, + () => null, + ); +} + +/** + * Reactively read a key as raw bytes. + * + * Behaves identically to {@link useKey} but returns the value's bytes, so it + * works for binary values a backend wrote — compressed payloads, protobuf, + * images. Text values come back as their UTF-8 bytes. + * + * ```tsx + * function Thumbnail() { + * const bytes = useKeyBytes('thumb:42'); + * const src = useMemo( + * () => (bytes ? URL.createObjectURL(new Blob([bytes])) : null), + * [bytes], + * ); + * return src ? : null; + * } + * ``` + */ +export function useKeyBytes(key: string): Uint8Array | null { + const cache = useRecached(); + return useSyncExternalStore( + (cb) => cache.onMutation(cb), + () => cache.getBytes(key), () => null, ); } diff --git a/sdks/recached-react/src/useKeys.ts b/sdks/recached-react/src/useKeys.ts index a09409a..fd29ebe 100644 --- a/sdks/recached-react/src/useKeys.ts +++ b/sdks/recached-react/src/useKeys.ts @@ -2,8 +2,9 @@ import { useEffect, useRef, useSyncExternalStore } from 'react'; import { useRecached } from './context'; /** A key/value pair from the local store. Collection-typed keys have `null` - * values — read those with typed accessors. */ -export type KeyValuePair = [key: string, value: string | null]; + * values — read those with typed accessors. A value that is not valid UTF-8 + * arrives as a `Uint8Array` rather than a mangled string. */ +export type KeyValuePair = [key: string, value: string | Uint8Array | null]; const EMPTY: KeyValuePair[] = []; diff --git a/sdks/recached-react/src/usePubSub.ts b/sdks/recached-react/src/usePubSub.ts index dbe8473..f1c26da 100644 --- a/sdks/recached-react/src/usePubSub.ts +++ b/sdks/recached-react/src/usePubSub.ts @@ -5,7 +5,9 @@ import { useRecached } from './context'; * Subscribe to a Recached pub/sub channel for the lifetime of the component. * * Sends `SUBSCRIBE` to the server on mount and `UNSUBSCRIBE` on unmount. - * The `handler` is called with each incoming message string. + * The `handler` is called with each incoming message. A publisher may send + * binary, in which case the payload arrives as a `Uint8Array` rather than a + * string — narrow the type before treating it as text. * * ```tsx * function Notifications() { @@ -20,7 +22,10 @@ import { useRecached } from './context'; * @param handler Called with each message payload. Identity need not be stable * across renders — the hook captures the latest ref internally. */ -export function usePubSub(channel: string, handler: (msg: string) => void): void { +export function usePubSub( + channel: string, + handler: (msg: string | Uint8Array) => void, +): void { const cache = useRecached(); const handlerRef = useRef(handler); handlerRef.current = handler; diff --git a/sdks/recached-vue/package-lock.json b/sdks/recached-vue/package-lock.json index b03d473..1eef94d 100644 --- a/sdks/recached-vue/package-lock.json +++ b/sdks/recached-vue/package-lock.json @@ -1,13 +1,13 @@ { "name": "@recached/vue", - "version": "0.1.4", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@recached/vue", - "version": "0.1.4", - "license": "MIT", + "version": "0.2.2", + "license": "Apache-2.0", "devDependencies": { "@vue/runtime-core": "^3", "typescript": "^5.9.3", diff --git a/sdks/recached-vue/package.json b/sdks/recached-vue/package.json index 4f4ffa8..3a71e22 100644 --- a/sdks/recached-vue/package.json +++ b/sdks/recached-vue/package.json @@ -1,6 +1,6 @@ { "name": "@recached/vue", - "version": "0.2.1", + "version": "0.2.2", "description": "Official Vue 3 composables for Recached \u2014 zero-latency reactive cache", "type": "module", "main": "./dist/index.js", diff --git a/sdks/recached-vue/src/index.ts b/sdks/recached-vue/src/index.ts index 0bcad01..835f1e0 100644 --- a/sdks/recached-vue/src/index.ts +++ b/sdks/recached-vue/src/index.ts @@ -1,4 +1,4 @@ export { RecachedPlugin, useRecached, CACHE_KEY } from './plugin'; -export { useKey, useKeyJSON } from './useKey'; +export { useKey, useKeyBytes, useKeyJSON } from './useKey'; export { useKeys, type KeyValuePair } from './useKeys'; export { usePubSub } from './usePubSub'; diff --git a/sdks/recached-vue/src/useKey.ts b/sdks/recached-vue/src/useKey.ts index e582b1c..d6ab9f6 100644 --- a/sdks/recached-vue/src/useKey.ts +++ b/sdks/recached-vue/src/useKey.ts @@ -30,14 +30,42 @@ import { useRecached } from './plugin'; export function useKey(key: string): Ref { const cache = useRecached(); const value = ref(null); + // `get` throws on a binary value, which has no string form. Report it as + // absent rather than propagating out of a reactive update; read those with + // {@link useKeyBytes}. + const read = (): string | null => { + try { + return cache.get(key); + } catch { + return null; + } + }; const unsub = cache.onMutation(() => { - value.value = cache.get(key); + value.value = read(); }); - value.value = cache.get(key); + value.value = read(); onUnmounted(unsub); return value; } +/** + * Reactively read a key as raw bytes. + * + * Behaves identically to {@link useKey} but returns the value's bytes, so it + * works for binary values a backend wrote. Text values come back as their + * UTF-8 bytes. + */ +export function useKeyBytes(key: string): Ref { + const cache = useRecached(); + const value = ref(null); + const unsub = cache.onMutation(() => { + value.value = cache.getBytes(key); + }); + value.value = cache.getBytes(key); + onUnmounted(unsub); + return value as Ref; +} + /** * Reactively read a JSON-parsed value from the Recached store. * diff --git a/sdks/recached-vue/src/useKeys.ts b/sdks/recached-vue/src/useKeys.ts index 634a48e..70767d7 100644 --- a/sdks/recached-vue/src/useKeys.ts +++ b/sdks/recached-vue/src/useKeys.ts @@ -2,8 +2,9 @@ import { ref, onUnmounted, type Ref } from 'vue'; import { useRecached } from './plugin'; /** A key/value pair from the local store. Collection-typed keys have `null` - * values — read those with typed accessors. */ -export type KeyValuePair = [key: string, value: string | null]; + * values — read those with typed accessors. A value that is not valid UTF-8 + * arrives as a `Uint8Array` rather than a mangled string. */ +export type KeyValuePair = [key: string, value: string | Uint8Array | null]; /** * Live query: reactively read every key matching a glob pattern. diff --git a/sdks/recached-vue/src/usePubSub.ts b/sdks/recached-vue/src/usePubSub.ts index a5438f1..5514ef0 100644 --- a/sdks/recached-vue/src/usePubSub.ts +++ b/sdks/recached-vue/src/usePubSub.ts @@ -5,7 +5,9 @@ import { useRecached } from './plugin'; * Subscribe to a Recached pub/sub channel for the lifetime of the component. * * Sends `SUBSCRIBE` to the server on setup and `UNSUBSCRIBE` on `onUnmounted`. - * The `handler` is called with each incoming message string. + * The `handler` is called with each incoming message. A publisher may send + * binary, in which case the payload arrives as a `Uint8Array` rather than a + * string — narrow the type before treating it as text. * * ```vue *