# kevy > Pure-Rust, zero-dependency, Redis-compatible serving engine: the primary > store for applications (declared indexes, views, write-time aggregates, > CJK full-text search, vector KNN, CDC feeds, replication) speaking RESP. Machine notes: wire protocol is RESP2 (RESP3 via HELLO 3). Every verb below is discoverable live via `COMMAND DOCS `, which answers with the same `complexity` and `compat` fields printed here; errors are self-explaining and their prefixes are a stable contract (docs/error-replies.md). This file is GENERATED from the server's verb metadata table — do not edit by hand. The full text of every document below is inlined at https://kevy.golia.jp/llms-full.txt, which is one fetch and needs no crawling. Read `compat` before you assume a verb behaves the way Redis's docs say. kevy is wire-compatible, not behaviour-identical, and the differences that matter are stated per verb rather than buried in a migration guide. Three that catch people: SCAN is not a cursor iterator (one call sweeps the whole keyspace and returns cursor 0); RANDOMKEY, SPOP and SRANDMEMBER are NOT random (they return the same members every time); and multi-key writes are atomic only within one shard — co-locate keys with a {hashtag} when you need them to move together. Read `complexity` before you assume a cost. It was derived from THIS engine's code, not copied from Redis: our sorted set is a hash plus a plain BTreeSet with no rank augmentation, so ZRANK is O(N) here and score-range queries scan rather than seek. ## Docs - [Verb reference](https://kevy.golia.jp/docs/commands/): every verb, arity, flags, syntax - [Designing on kevy](https://kevy.golia.jp/docs/designing-on-kevy/): the serving-engine model - [Cookbook](https://kevy.golia.jp/docs/cookbook/): RDS-to-kevy modeling recipes - [RDS workloads](https://kevy.golia.jp/docs/rds-workloads/): SQL-to-kevy reference matrix (types, SELECT, JOIN, transactions, DDL) - [Indexes](https://kevy.golia.jp/docs/indexes/) · [Views](https://kevy.golia.jp/docs/views/) · [Text search](https://kevy.golia.jp/docs/text-search/) · [Vector search](https://kevy.golia.jp/docs/vector-search/) - [CDC feeds](https://kevy.golia.jp/docs/cdc/) · [Replication](https://kevy.golia.jp/docs/replication/) · [Availability & failover](https://kevy.golia.jp/docs/availability/) · [Persistence](https://kevy.golia.jp/docs/persistence/) - [Migration](https://kevy.golia.jp/docs/migration/) · [Upgrading between majors](https://kevy.golia.jp/docs/UPGRADING/) - [WASM / browser](https://kevy.golia.jp/docs/wasm/) · [IoT / embedded tiers](https://kevy.golia.jp/docs/iot/) - [Error contract](https://kevy.golia.jp/docs/error-replies/) · [Tuning](https://kevy.golia.jp/docs/tuning/) ## Verbs (184 total) ### scan - `KEYS pattern` [readonly] — Return every key matching the glob pattern (cross-shard gather). - complexity: O(N) keys in the whole keyspace — every shard walks its entire map, plus an O(P*K) glob match per key - compat: full - `RANDOMKEY` [readonly] — Return a random key from the keyspace. - complexity: O(1) expected - compat: differs: the result is NOT random — each shard returns the first live key in hash-bucket order and the reducer takes the first shard's, so a fixed keyspace returns the same key every time; do not use it for sampling - `SCAN cursor [MATCH pattern] [COUNT count]` [readonly] — Sweep the keyspace and return every match at once; NOT a cursor iterator (COUNT and TYPE are accepted and ignored, and the cursor is always 0). - complexity: O(N) keys in the WHOLE keyspace, on every call — there is no incremental walk - compat: differs: SCAN is not a cursor iterator. Every call sweeps every shard's entire keyspace and replies with cursor 0 and all matches in one batch, so the standard SCAN loop terminates after one round trip. COUNT and TYPE are accepted and ignored; MATCH is honoured. Redis's incremental, bounded-work, rehash-tolerant guarantees do not exist here ### generic - `DEL key [key ...]` [write] — Delete one or more keys. - complexity: O(N) keys, plus O(M) per key holding a collection of M elements (the drop walks the container) - compat: differs: the multi-key form is split by shard and summed with no barrier, so it is not atomic across shards; the single-key form is - `EXISTS key [key ...]` [readonly] — Count how many of the given keys exist. - complexity: O(N) keys, one O(1) probe each - compat: differs: the multi-key form is gathered per shard, so the count is not a point-in-time snapshot across shards - `EXPIRE key seconds` [write] — Set a key's TTL in seconds (non-positive TTL deletes the key). - complexity: O(1) - compat: differs: the NX / XX / GT / LT condition flags are not parsed — arity is fixed at 3, so EXPIRE key seconds NX is a wrong-arity error - `EXPIREAT key unix-time-seconds` [write] — Set a key's expiry as an absolute Unix timestamp in seconds. - complexity: O(1) - compat: differs: the NX / XX / GT / LT condition flags are not parsed — arity is fixed at 3 - `PERSIST key` [write] — Remove a key's TTL. - complexity: O(1) - compat: full - `PEXPIRE key milliseconds` [write] — Set a key's TTL in milliseconds. - complexity: O(1) - compat: differs: the NX / XX / GT / LT condition flags are not parsed — arity is fixed at 3 - `PEXPIREAT key unix-time-milliseconds` [write] — Set a key's expiry as an absolute Unix timestamp in milliseconds. - complexity: O(1) - compat: differs: the NX / XX / GT / LT condition flags are not parsed — arity is fixed at 3 - `PTTL key` [readonly] — Return a key's remaining TTL in milliseconds (-1 no TTL, -2 missing). - complexity: O(1) - compat: full - `RENAME key newkey` [write] — Rename a key, overwriting the destination (write routed at the runtime Op level). - complexity: O(1) for a string value; O(M) for a collection of M elements (the entry's weight is re-derived by walking the value) - compat: differs: a cross-shard rename is a non-atomic Take then Put — there is a window in which neither key is visible; co-locate the two keys with a {hashtag} for the atomic path - `RENAMENX key newkey` [write] — Rename a key only if the destination does not exist (write routed at the runtime Op level). - complexity: O(1) for a string value; O(M) for a collection of M elements - compat: differs: cross-shard RENAMENX is a non-atomic Take, Put, restore-on-refusal sequence — there is a window in which the source is absent from the keyspace - `TTL key` [readonly] — Return a key's remaining TTL in seconds (-1 no TTL, -2 missing). - complexity: O(1) - compat: full - `TYPE key` [readonly] — Return the type of the value stored at key. - complexity: O(1) - compat: full - `UNLINK key [key ...]` [write] — Delete one or more keys (alias of DEL in kevy's shard model). - complexity: O(N) keys, plus O(M) per collection-typed key — identical to DEL - compat: differs: UNLINK is an alias of DEL — the value is freed inline on the owning shard's reactor thread, not by a background thread, so it gives no latency benefit ### string - `APPEND key value` [write] — Append bytes to a string value; returns the new length. - complexity: O(1) amortised while the value is <= 64 B; O(N) per call once it exceeds that (the Arc payload is copied out and re-boxed on every append), so building a large string by repeated APPEND is O(N^2) - compat: full - `DECR key` [write] — Decrement the integer value of a key by one. - complexity: O(1) - compat: full - `DECRBY key decrement` [write] — Decrement the integer value of a key by the given amount. - complexity: O(1) - compat: full - `GET key` [readonly] — Return the string value of a key. - complexity: O(1) - compat: full - `GETDEL key` [write] — Return the string value of a key and delete it. - complexity: O(1) - compat: full - `GETSET key value` [write] — Set a key's string value and return its previous value. - complexity: O(1) - compat: full - `INCR key` [write] — Increment the integer value of a key by one. - complexity: O(1) - compat: full - `INCRBY key increment` [write] — Increment the integer value of a key by the given amount. - complexity: O(1) - compat: full - `INCRBYFLOAT key increment` [write] — Increment the float value of a key by the given amount. - complexity: O(1) - compat: differs: arithmetic is f64 and the reply uses Rust's shortest-round-trip float printer, not a long double accumulator, so trailing digits can diverge from Redis - `MGET key [key ...]` [readonly] — Return the values of multiple keys (cross-shard gather). - complexity: O(N) keys, one O(1) probe each on the owning shard - compat: differs: keys are grouped by shard and each shard is probed independently, so the reply is not a point-in-time snapshot across shards - `MSET key value [key value ...]` [write] — Set multiple key/value pairs atomically per shard. - complexity: O(N) pairs, one O(1) SET each - compat: differs: atomic only per shard — pairs are split by key and applied shard-by-shard with no cross-shard barrier, so a reader can observe some pairs written and others not; Redis MSET is globally atomic - `PSETEX key milliseconds value` [write] — Set a key's value with a TTL in milliseconds. - complexity: O(1) - compat: full - `SET key value [EX seconds|PX milliseconds] [NX|XX]` [write] — Set a key's string value with optional TTL and existence conditions. - complexity: O(1) - compat: differs: only NX / XX / EX / PX are parsed — KEEPTTL, EXAT, PXAT, GET and the IFEQ family are a syntax error; a repeated expire option is silently accepted last-one-wins instead of erroring - `SETEX key seconds value` [write] — Set a key's value with a TTL in seconds. - complexity: O(1) - compat: full - `SETNX key value` [write] — Set a key's value only if it does not exist. - complexity: O(1) - compat: full - `STRLEN key` [readonly] — Return the length of a key's string value. - complexity: O(1) - compat: full ### hash - `HDEL key field [field ...]` [write] — Delete one or more hash fields. - complexity: O(M) fields - compat: full - `HEXISTS key field` [readonly] — Check whether a hash field exists. - complexity: O(1) - compat: full - `HEXPIRE key seconds [NX|XX|GT|LT] FIELDS numfields field [field ...]` [write] — Set per-field TTLs on a hash, in seconds. - complexity: O(F) fields in the FIELDS clause - compat: differs: the FIELDS parse error reads 'Mandatory keyword FIELDS is missing' where Redis says 'Mandatory argument FIELDS' - `HGET key field` [readonly] — Return the value of a hash field. - complexity: O(1) - compat: full - `HGETALL key` [readonly] — Return all fields and values of a hash. - complexity: O(N) fields, with a full copy of every name and value - compat: full - `HINCRBY key field increment` [write] — Increment the integer value of a hash field. - complexity: O(1) - compat: full - `HKEYS key` [readonly] — Return all field names of a hash. - complexity: O(N) fields - compat: full - `HLEN key` [readonly] — Return the number of fields in a hash. - complexity: O(1) - compat: full - `HMGET key field [field ...]` [readonly] — Return the values of multiple hash fields. - complexity: O(M) fields requested - compat: full - `HMSET key field value [field value ...]` [write] — Deprecated alias of HSET that replies OK. - complexity: O(M) pairs - compat: full - `HPERSIST key FIELDS numfields field [field ...]` [write] — Remove per-field TTLs from a hash. - complexity: O(F) fields - compat: differs: silently accepts a stray NX|XX|GT|LT token before FIELDS (it shares HEXPIRE's parser) where Redis rejects it - `HPEXPIRE key milliseconds [NX|XX|GT|LT] FIELDS numfields field [field ...]` [write] — Set per-field TTLs on a hash, in milliseconds. - complexity: O(F) fields - compat: differs: same FIELDS error-text deviation as HEXPIRE - `HPEXPIREAT key unix-time-milliseconds [NX|XX|GT|LT] FIELDS numfields field [field ...]` [write] — Set per-field expiries as absolute Unix-millisecond deadlines. - complexity: O(F) fields - compat: differs: same FIELDS error-text deviation as HEXPIRE - `HSCAN key cursor [MATCH pattern] [COUNT count]` [readonly] — Iterate a hash's fields and values (single-batch cursor). - complexity: O(N) — the whole hash is copied and returned in one batch - compat: differs: not a cursor iterator — the cursor is always 0 and the entire hash comes back in a single reply; NOVALUES is not parsed and COUNT is validated then ignored - `HSET key field value [field value ...]` [write] — Set one or more hash fields; returns the number of new fields. - complexity: O(M) pairs - compat: full - `HSETNX key field value` [write] — Set a hash field only if it does not exist. - complexity: O(1) - compat: full - `HPTTL key FIELDS numfields field [field ...]` [readonly] — Return per-field remaining TTLs in milliseconds (-1 no TTL, -2 missing). - complexity: O(F) fields - compat: differs: silently accepts a stray NX|XX|GT|LT token before FIELDS where Redis rejects it - `HTTL key FIELDS numfields field [field ...]` [readonly] — Return per-field remaining TTLs in seconds (-1 no TTL, -2 missing). - complexity: O(F) fields - compat: differs: silently accepts a stray NX|XX|GT|LT token before FIELDS where Redis rejects it - `HVALS key` [readonly] — Return all values of a hash. - complexity: O(N) fields - compat: full ### list - `BLPOP key [key ...] timeout` [write,blocking] — Blocking left pop across one or more lists (effect logged as LPOP). - complexity: O(1) to serve; the park is O(1) and waiters are woken FIFO per key - compat: differs: on a multi-shard server the multi-key form does not honour Redis's left-to-right key priority — keys on the connection's own shard are served before remote ones - `BRPOP key [key ...] timeout` [write,blocking] — Blocking right pop across one or more lists (effect logged as RPOP). - complexity: O(1) to serve; FIFO park - compat: differs: same multi-key priority deviation as BLPOP - `BRPOPLPUSH source destination timeout` [write,blocking] — Blocking RPOPLPUSH: pop the source tail and push it onto the destination head. - complexity: O(1) to serve - compat: differs: a cross-shard move is not atomic — the element is popped on the source's shard and pushed on the destination's, and between those two steps it is in neither list. Co-locate the keys with a {hashtag} to get Redis's atomic path - `LINDEX key index` [readonly] — Return the element at the given list index. - complexity: O(1) — the list is a VecDeque, so an index is a direct offset (Redis's quicklist is O(N)) - compat: full - `LLEN key` [readonly] — Return the length of a list. - complexity: O(1) - compat: full - `LMOVE source destination LEFT|RIGHT LEFT|RIGHT` [write] — Atomically move an element between two lists. - complexity: O(1) - compat: differs: a cross-shard move is not atomic — see BRPOPLPUSH - `LPOP key [count]` [write] — Pop one or more elements from the head of a list. - complexity: O(C) elements popped - compat: differs: LPOP key 0 on an existing key replies a nil array where Redis replies an empty array - `LPOS key element [RANK rank] [COUNT num-matches] [MAXLEN len]` [readonly] — Return the index of matching elements in a list. - complexity: O(N) always — the whole list is copied before RANK / COUNT / MAXLEN are applied, so MAXLEN bounds the comparisons but not the copy - compat: full - `LPUSH key element [element ...]` [write] — Prepend one or more elements to a list. - complexity: O(M) values, O(1) amortised each - compat: full - `LRANGE key start stop` [readonly] — Return a range of list elements. - complexity: O(M) elements returned — the start offset is skipped in O(1) (Redis is O(S+M)) - compat: full - `LREM key count element` [write] — Remove elements equal to the given value from a list. - complexity: O(N*R) worst case — the scan is O(N) but each removal shifts up to half the deque (Redis is O(N+R)) - compat: full - `LSET key index element` [write] — Set the list element at the given index. - complexity: O(1) — a direct index write (Redis is O(N) for a mid-list index) - compat: full - `LTRIM key start stop` [write] — Trim a list to the given range. - complexity: O(R) elements removed - compat: full - `RPOP key [count]` [write] — Pop one or more elements from the tail of a list. - complexity: O(C) elements popped - compat: differs: RPOP key 0 on an existing key replies a nil array where Redis replies an empty array - `RPOPLPUSH source destination` [write] — Pop the source tail and push it onto the destination head. - complexity: O(1) - compat: differs: a cross-shard move is not atomic — see BRPOPLPUSH - `RPUSH key element [element ...]` [write] — Append one or more elements to a list. - complexity: O(M) values, O(1) amortised each - compat: full ### set - `SADD key member [member ...]` [write] — Add one or more members to a set. - complexity: O(M) members - compat: full - `SCARD key` [readonly] — Return the number of members in a set. - complexity: O(1) - compat: full - `SDIFF key [key ...]` [readonly] — Return the difference of the given sets (cross-shard gather). - complexity: O(sum of the source cardinalities) - compat: full - `SDIFFSTORE destination key [key ...]` [write] — Store the difference of the given sets into destination. - complexity: O(sum of the source cardinalities) + O(result) to write the destination on its own shard - compat: full - `SINTER key [key ...]` [readonly] — Return the intersection of the given sets (cross-shard gather). - complexity: O(sum of the source cardinalities) — there is NO smallest-set-first ordering, so SINTER huge tiny costs O(huge) where Redis costs O(tiny * k) - compat: full - `SINTERSTORE destination key [key ...]` [write] — Store the intersection of the given sets into destination. - complexity: O(sum of the source cardinalities) + O(result); same no-smallest-first algorithm - compat: full - `SISMEMBER key member` [readonly] — Check whether a value is a member of a set. - complexity: O(1) - compat: full - `SMEMBERS key` [readonly] — Return all members of a set. - complexity: O(N) members - compat: full - `SPOP key [count]` [write] — Remove and return one or more random members of a set. - complexity: O(count) expected; draining an N-member set with N calls is O(N^2) because the bucket scan restarts each time - compat: differs: members are NOT random — SPOP returns the first members in hash-bucket order and the seed is a fixed constant, so repeated SPOP on the same set is fully deterministic - `SRANDMEMBER key [count]` [readonly] — Return one or more random members of a set without removing them. - complexity: O(count) expected - compat: differs: NOT random — it returns the same first-bucket member every time for a given set; a negative count is rejected instead of returning |count| members with repetitions - `SREM key member [member ...]` [write] — Remove one or more members from a set. - complexity: O(M) members - compat: full - `SSCAN key cursor [MATCH pattern] [COUNT count]` [readonly] — Iterate a set's members (single-batch cursor). - complexity: O(N) — the whole set is copied and returned in one batch - compat: differs: not a cursor iterator — the cursor is always 0 and the entire set comes back in one reply; COUNT is validated then ignored - `SUNION key [key ...]` [readonly] — Return the union of the given sets (cross-shard gather). - complexity: O(sum of the source cardinalities) - compat: full - `SUNIONSTORE destination key [key ...]` [write] — Store the union of the given sets into destination. - complexity: O(sum of the source cardinalities) + O(result) - compat: full ### zset - `BZPOPMIN key [key ...] timeout` [write,blocking] — Blocking pop of the lowest-scored member across one or more sorted sets. - complexity: O(log N) per pop; the park is O(1) - compat: full - `ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]` [write] — Add members with scores to a sorted set, with conditional flags. - complexity: O(M log N) — O(1) hash insert plus O(log N) tree insert per member - compat: full - `ZCARD key` [readonly] — Return the number of members in a sorted set. - complexity: O(1) - compat: full - `ZCOUNT key min max` [readonly] — Count members with scores within the given bounds. - complexity: O(N) — a full scan and filter, NOT a range seek; Redis's O(log N) does not apply - compat: full - `ZDIFFSTORE destination numkeys key [key ...]` [write] — Store the difference of the given sorted sets into destination. - complexity: O(sum of the source cardinalities + R log R); each source is extracted whole on its own shard - compat: full - `ZINCRBY key increment member` [write] — Increment a member's score in a sorted set. - complexity: O(log N) - compat: full - `ZINTERCARD numkeys key [key ...] [LIMIT limit]` [readonly] — Return the cardinality of the intersection of the given sorted sets. - complexity: O(sum of the source cardinalities) — LIMIT does not short-circuit the gather, only the combine - compat: full - `ZINTERSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX]` [write] — Store the intersection of the given sorted sets into destination. - complexity: O(sum of the source cardinalities + R log R) - compat: full - `ZPOPMIN key [count]` [write] — Pop up to count members with the lowest scores. - complexity: O(M log N) - compat: full - `ZPOPMIN.BELOW key below [count]` [write,extension] — kevy extension: pop up to count lowest members with score strictly below the threshold (delayed-job primitive). - complexity: O(M log N) — take_while stops at the first score at or above the threshold, so it never walks past the due prefix - compat: kevy-only: a delayed-job primitive (score = due time); no Redis analogue - `ZRANGE key start stop [WITHSCORES]` [readonly] — Return members by rank range. - complexity: O(start + M) — the tree iterator advances one node at a time; there is no rank index to jump with - compat: differs: only the legacy 'key start stop [WITHSCORES]' form is accepted — the Redis 6.2 BYSCORE / BYLEX / REV / LIMIT arguments are a syntax error - `ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]` [readonly] — Return members with scores within the given bounds. - complexity: O(N + M) — a full scan and filter; LIMIT is applied after the whole match set is materialised, so it does not reduce the work - compat: full - `ZRANK key member` [readonly] — Return a member's rank, ordered from the lowest score. - complexity: O(N) — a linear position scan. NOT O(log N): our zset has no rank-augmented structure - compat: differs: the Redis 7.2 WITHSCORE option is not supported - `ZREM key member [member ...]` [write] — Remove one or more members from a sorted set. - complexity: O(M log N) - compat: full - `ZREMRANGEBYRANK key start stop` [write] — Remove members within the given rank range. - complexity: O(start + M log N) - compat: full - `ZREMRANGEBYSCORE key min max` [write] — Remove members with scores within the given bounds. - complexity: O(N + M log N) — a full scan to find the hit set, then M deletes - compat: full - `ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]` [readonly] — Return members with scores within the given bounds, highest first. - complexity: O(N + M) — a forward full scan and filter, then the result is reversed - compat: full - `ZSCAN key cursor [MATCH pattern] [COUNT count]` [readonly] — Iterate a sorted set's members and scores (single-batch cursor). - complexity: O(N) — the whole set is materialised - compat: differs: not a cursor iterator — every call returns the whole set with cursor 0, and COUNT is parsed then ignored - `ZSCORE key member` [readonly] — Return a member's score. - complexity: O(1) — a hash lookup - compat: full - `ZUNIONSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX]` [write] — Store the union of the given sorted sets into destination. - complexity: O(sum of the source cardinalities + R log R) - compat: full ### stream - `XACK key group id [id ...]` [write] — Acknowledge pending entries for a consumer group. - complexity: O(A log P) - compat: full - `XADD key [NOMKSTREAM] [MAXLEN [=|~] threshold | MINID [=|~] id] id|* field value [field value ...]` [write] — Append an entry to a stream, with optional trim. - complexity: O(log N) to insert; O(N*F) whenever a MAXLEN / MINID clause actually evicts, because the trim triggers a full weight recompute - compat: differs: the approximate-trim '~' is accepted for wire compatibility but always trims exactly, and the LIMIT clause is not parsed - `XAUTOCLAIM key group consumer min-idle-time start [COUNT count] [JUSTID]` [write] — Scan and claim idle pending entries starting from a cursor. - complexity: O(log P + K + COUNT log N); the idle filter runs before the take, so a PEL of fresh entries is walked in full - compat: differs: the next-cursor is last-claimed+1 rather than 0-0 when the PEL scan completes, so a client looping until 0-0 needs one extra round trip - `XCLAIM key group consumer min-idle-time id [id ...] [IDLE ms] [TIME unix-time-milliseconds] [RETRYCOUNT count] [FORCE] [JUSTID]` [write] — Claim specific idle pending entries for a consumer. - complexity: O(A * (log P + log N)) - compat: differs: IDLE / TIME / RETRYCOUNT / FORCE / JUSTID are parsed but LASTID is not, and a missing key or group returns 'no such key' instead of NOGROUP - `XDEL key id [id ...]` [write] — Delete entries from a stream by ID. - complexity: O(D log N + N*F) for D ids — NOT O(1): the trailing weight recompute is linear in the whole stream - compat: full - `XGROUP CREATE key group id|$ [MKSTREAM] | DESTROY key group | SETID key group id|$ | CREATECONSUMER key group consumer | DELCONSUMER key group consumer` [write] — Manage stream consumer groups. - complexity: CREATE / DESTROY: O(1) plus an O(N*F) reweigh. SETID / CREATECONSUMER: O(1). DELCONSUMER: O(P) - compat: differs: CREATE / SETID / DESTROY / CREATECONSUMER / DELCONSUMER / MKSTREAM are supported, but on a missing key the non-CREATE subcommands return 0 or NOGROUP instead of Redis's error, and XGROUP HELP is absent - `XINFO STREAM key | GROUPS key | CONSUMERS key group | HELP` [readonly] — Introspect a stream, its groups, or its consumers. - complexity: STREAM: O(1). GROUPS: O(G*N) — 'lag' is a linear walk of every entry, once per group. CONSUMERS: O(C) - compat: differs: STREAM / GROUPS / CONSUMERS / HELP only — XINFO STREAM FULL is absent; radix-tree-keys and nodes are synthetic (we have no radix tree), entries-read is approximated, and CONSUMERS omits 'inactive' - `XLEN key` [readonly] — Return the number of entries in a stream. - complexity: O(1) - compat: full - `XPENDING key group [[IDLE min-idle-time] start end count [consumer]]` [readonly] — Inspect pending entries of a consumer group (summary or extended form). - complexity: summary: O(P*C) — the per-consumer tally is a linear find per PEL row. extended: O(log P + K) - compat: full - `XRANGE key start end [COUNT count]` [readonly] — Return stream entries within an ID range. - complexity: O(log N + M) — a B-tree range descent, COUNT applied lazily - compat: full - `XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]` [readonly,blocking] — Read new entries from one or more streams, optionally blocking. - complexity: O(S * (log N + M)) over S streams; '$' is O(1); the park is O(1) - compat: differs: the '+' last-id form is not parsed — only explicit IDs and '$' are accepted - `XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] id [id ...]` [write,blocking] — Read stream entries as a consumer-group member, optionally blocking. - complexity: '>' form: O(log N + U + M) — COUNT does NOT bound the scan, the entire undelivered tail is materialised first. Replay form: O(log P + K) - compat: differs: COUNT is parsed but does not bound the '>' scan - `XREVRANGE key end start [COUNT count]` [readonly] — Return stream entries within an ID range, in reverse order. - complexity: O(log N + M) - compat: full - `XSETID key last-id [ENTRIESADDED entries-added] [MAXDELETEDID max-deleted-id]` [write] — Overwrite a stream's last-generated ID and bookkeeping counters. - complexity: O(log N) - compat: full - `XTRIM key MAXLEN|MINID [=|~] threshold` [write] — Trim a stream by length or minimum ID. - complexity: O(R log N + N*F) for R evicted entries - compat: differs: '~' is accepted but trimming is always exact, LIMIT is not parsed, and trailing tokens after the threshold are silently ignored instead of erroring ### geo - `GEOADD key [NX|XX] [CH] longitude latitude member [longitude latitude member ...]` [write] — Add geospatial members (longitude/latitude) to a geo set. - complexity: O(M log N); CH adds an O(M^2) term - compat: full - `GEODIST key member1 member2 [m|km|mi|ft]` [readonly] — Return the distance between two geo members. - complexity: O(1) - compat: full - `GEOHASH key member [member ...]` [readonly] — Return the base32 geohash string of each member. - complexity: O(M) - compat: full - `GEOPOS key member [member ...]` [readonly] — Return the longitude/latitude of each member. - complexity: O(M) - compat: full - `GEORADIUS key longitude latitude radius m|km|mi|ft [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC|DESC] [STORE key] [STOREDIST key]` [write] — Legacy radius search around a coordinate, with optional STORE writes. - complexity: O(N) per call; STORE / STOREDIST add O(K log K) to write plus O(D) to clear the destination - compat: differs: the destination is written on its own shard, so a STORE across shards is not atomic with the search - `GEORADIUSBYMEMBER key member radius m|km|mi|ft [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC|DESC] [STORE key] [STOREDIST key]` [write] — Legacy radius search around an existing member, with optional STORE writes. - complexity: O(N) per call; the anchor lookup is O(1) - compat: differs: the destination is written on its own shard, so a STORE across shards is not atomic with the search - `GEOSEARCH key FROMMEMBER member|FROMLONLAT longitude latitude BYRADIUS radius m|km|mi|ft|BYBOX width height m|km|mi|ft [ASC|DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]` [readonly] — Search members within a radius or box from a member or coordinate. - complexity: O(N) per call — up to nine neighbour ranges, each fetched with a full zset scan; the geohash block prunes distance maths, not iteration - compat: differs: STOREDIST is accepted and ignored on the read-only form; COUNT 0 is an error - `GEOSEARCHSTORE destination source FROMMEMBER member|FROMLONLAT longitude latitude BYRADIUS radius m|km|mi|ft|BYBOX width height m|km|mi|ft [ASC|DESC] [COUNT count [ANY]] [STOREDIST]` [write] — Run GEOSEARCH on source and store the hits into destination. - complexity: O(N) to search, plus O(D + K log K) to rewrite the destination on its own shard - compat: differs: STOREDIST stores the distance in the unit the query asked for (Redis does the same); the destination is written on its own shard, so the source and destination may live on different cores and the write is not atomic with the read ### connection - `ECHO message` [readonly] — Return the given message. - complexity: O(1) - compat: full - `HELLO [protover]` [readonly] — Handshake: report server info and switch RESP protocol version (2 or 3). - complexity: O(1) - compat: differs: only HELLO [2|3] is honoured — the AUTH and SETNAME tails are parsed off and ignored, because kevy has no AUTH and this form does not set the connection name - `PING [message]` [readonly] — Ping the server; returns PONG or echoes the optional message. - complexity: O(1) - compat: differs: kevy has no RESP2 subscriber mode, so PING while subscribed does not switch to the array form and non-pubsub commands stay legal on a subscribed connection - `QUIT` [readonly] — Close the connection after replying OK. - complexity: O(1) - compat: full - `SELECT index` [readonly] — Select the logical database; kevy is single-DB and only accepts index 0. - complexity: O(1) - compat: differs: kevy is single-DB — SELECT 0 replies OK and any other index is an error ### server - `BGREWRITEAOF` [write,admin] — Start an append-only-file rewrite in the background. - complexity: O(S) fan-out; each shard freezes a copy-on-write view and hands serialise plus fsync to its own worker - compat: differs: replies +OK rather than Redis's status line, rewrites one AOF per shard, and replies +OK even when AOF is disabled - `BGSAVE` [write,admin] — Start a snapshot save in the background. - complexity: O(S) fan-out; O(N/S) copy-on-write freeze per shard on the reactor, then serialise off-thread - compat: differs: replies +OK rather than Redis's status line, and writes one dump per shard - `COMMAND [COUNT | LIST | INFO command [command ...] | DOCS [command [command ...]]]` [readonly] — Introspect the command table (COUNT, LIST, INFO, DOCS subcommands). - complexity: bare / LIST / DOCS: O(V) verbs. COUNT: O(1). INFO / DOCS with k names: O(k*V) — the lookup is a linear scan - compat: differs: COUNT / LIST / INFO / DOCS only — GETKEYS and GETKEYSANDFLAGS are absent, and the info rows hard-code first/last/step and leave ACL categories, tips and key-specs empty - `CONFIG GET parameter | SET parameter value | REWRITE | RESETSTAT` [write,admin] — Read or change server configuration parameters. - complexity: GET: O(patterns * 16). SET: O(1) plus one config swap that shards pick up on their next tick. REWRITE: O(config file size) - compat: differs: 16 Redis-named parameters exist; only maxmemory, maxmemory-policy, appendfsync, the auto-rewrite pair, hz, maxmemory-samples, loglevel and logfile are hot-settable — bind, port, io-threads, dir and appendonly error at runtime; RESETSTAT is a no-op - `CLIENT ID | GETNAME | SETNAME name | LIST | KILL filter | INFO | NO-EVICT ON|OFF` [readonly,admin] — Connection introspection and control subcommands. - complexity: ID / GETNAME / SETNAME / INFO: O(1). LIST: O(S) fan-out + O(C) rows. KILL: O(S) + O(C) scan - compat: differs: ID / GETNAME / SETNAME / INFO / LIST (bare) / KILL (ID, ADDR) / NO-EVICT only; CLIENT LIST with a filter is a syntax error and every other subcommand (PAUSE, REPLY, TRACKING, UNBLOCK, NO-TOUCH) is unknown - `CLUSTER INFO | NODES | SLOTS | SHARDS | KEYSLOT key | COUNTKEYSINSLOT slot` [readonly,admin] — Cluster topology introspection (virtual-node model over shards). - complexity: INFO / NODES / SLOTS / SHARDS / MYID: O(S). KEYSLOT: O(len(key)). COUNTKEYSINSLOT: O(N/S) - compat: differs: a read-only virtual-node topology derived from config (one virtual master per shard) — no gossip, no MEET / FORGET / SETSLOT effect (they reply OK as no-ops), no MIGRATE / ASK, no failover; COUNTKEYSINSLOT counts only the answering shard's keys - `DBSIZE` [readonly] — Return the number of keys across all shards. - complexity: O(S) — one message per shard, each answering O(1) from its map length; NOT O(N) - compat: full - `DEBUG SLEEP seconds | subcommand [args ...]` [readonly,admin] — Debug subcommands; SLEEP blocks the shard, other subcommands are tolerated as OK. - complexity: SLEEP: parks the ANSWERING shard's reactor thread for the duration (other shards keep serving). Everything else: O(1) - compat: differs: SLEEP is the only subcommand with an effect and it blocks one shard, not the server; every other subcommand is accepted and answered OK without doing anything - `FLUSHALL` [write] — Delete every key on every shard. - complexity: O(S) fan-out; per shard O(N/S) clear plus WATCH invalidation plus a feed-generation bump - compat: differs: ASYNC / SYNC are accepted and ignored (the flush is always synchronous per shard), and it bumps the CDC feed generation, so live FEED cursors get a resync error - `FLUSHDB` [write] — Delete every key of the current database (kevy is single-DB: same as FLUSHALL). - complexity: O(N + S) — identical to FLUSHALL - compat: differs: kevy is single-DB, so FLUSHDB is an alias of FLUSHALL; ASYNC / SYNC are ignored - `INFO [section]` [readonly] — Report server statistics and status sections as text. - complexity: O(1) in the keyspace — it never walks it; O(S) to sum the per-shard gauges - compat: differs: 8 sections only (server, clients, memory, persistence, stats, replication, cluster, keyspace) — no commandstats / latencystats / errorstats / cpu; reports redis_version 7.4.0 for client sniffing plus a kevy_version line, and the values are up to one tick stale - `MEMORY USAGE key [SAMPLES count] | STATS | DOCTOR | PURGE | MALLOC-STATS` [readonly] — Memory introspection subcommands. - complexity: USAGE: O(1) — the accounting is exact, not sampled. STATS: O(1) from the instance-wide gauges - compat: differs: USAGE's SAMPLES count is parsed and ignored (our accounting is exact); PURGE is a no-op (system allocator, no arena); DOCTOR and MALLOC-STATS return canned strings; STATS is a trimmed 8-field set - `SAVE` [write,admin] — Synchronously snapshot the dataset to disk. - complexity: O(S) fan-out; O(N/S) copy-on-write freeze per shard - compat: differs: SAVE is no longer synchronous-until-durable — it freezes a copy-on-write view and replies OK immediately, so 'SAVE returned, therefore the dump is safe to copy' no longer holds - `SHUTDOWN [NOSAVE|SAVE]` [write,admin] — Stop the server process (connection drops without a reply). - complexity: O(1) to trip the stop flag; the drain that follows is O(N) - compat: differs: only SHUTDOWN [NOSAVE|SAVE] parses — NOW, FORCE and ABORT are a syntax error - `SLOWLOG GET [count] | LEN | RESET | HELP` [readonly,admin] — Inspect or reset the slow-command log. - complexity: GET: O(S) fan-out plus O(L log L) to merge. LEN: O(S). RESET: O(S) - compat: differs: the log is per-shard and GET merges across shards by timestamp, so ids are not globally monotonic; every entry's client-addr and client-name are empty strings ### replication - `FAILOVER host port [TIMEOUT ms] | ABORT` [write,admin] — Planned zero-loss handover: quiesce writes, wait for the target replica to drain, promote it, and follow it. - complexity: O(1) at the command — the handover runs in the background, polling the target until it is caught up - compat: differs: Redis's syntax is FAILOVER [TO host port [FORCE]] [ABORT] [TIMEOUT ms] with an optional target; kevy's is FAILOVER host port [TIMEOUT ms] | ABORT — the target is mandatory and positional, and the port given is the target's client port - `REPL.TOKEN` [readonly,extension] — kevy extension: mint a read-your-writes token — per-shard [generation, offset] pairs (a primary reports its live feed tail; a replica its applied positions). - complexity: O(S) — one (generation, offset) pair per shard - compat: kevy-only: the read-your-writes token; the nearest Redis concept is master_repl_offset, which is not a resumable per-shard cursor - `REPL.WAIT gen offset [gen offset ...] [TIMEOUT milliseconds]` [readonly,blocking,extension] — kevy extension: on a replica, block until every shard has applied the given REPL.TOKEN (then reads are read-your-writes); +OK on success, -MISDIRECTED writer is on timeout or generation mismatch. Default TIMEOUT 1000 ms, hard cap 60 s. On a primary: immediate +OK. - complexity: O(S) — a barrier, not a keyspace op; the wall-clock cost is the replication lag, bounded by TIMEOUT (default 1s, hard-capped at 60s) - compat: kevy-only: waits until a (generation, offset) token has been applied, which is what makes read-your-writes possible across a failover - `REPLICAOF host port | NO ONE` [write,admin] — Make this server a replica of another, or promote it with NO ONE. - complexity: O(1) at the command; the full resync that follows is O(N) in the background - compat: differs: the port argument is the primary's REPLICATION port base, not its client port (the default base is client port + 10000); chain replication is rejected - `ROLE` [readonly] — Report the replication role and state of this server. - complexity: O(1) on a replica; O(R) on a primary - compat: full - `SLAVEOF host port | NO ONE` [write,admin] — Legacy alias of REPLICAOF. - complexity: O(1) plus a background resync - compat: differs: a deprecated alias of REPLICAOF, including the replication-port-base argument - `WAIT numreplicas timeout` [readonly,blocking] — Block until every shard's master_repl_offset is acknowledged by at least numreplicas replicas (or timeout ms pass); returns the minimum acked-replica count across shards. timeout 0 = wait forever, hard-capped at 60 s. NOTE: a replica ACK is not fsync durability. - complexity: O(S) — an all-shard barrier returning the MINIMUM acked-replica count; the wall-clock cost is the replication round trip - compat: differs: it is an all-shard barrier returning the minimum across shards (Redis has one offset to wait on); timeout 0 is capped at 60s rather than blocking forever, and an ACK means the frame reached the replica's apply pipeline, not fsync durability ### tx - `DISCARD` [readonly,transaction] — Abort the open MULTI transaction. - complexity: O(1) — drops the queued commands and the WATCH set - compat: full - `EXEC` [readonly,transaction] — Execute every command queued since MULTI. - complexity: O(sum of the queued commands' costs); with W watched keys, one extra O(W) pre-check fanned into O(min(W,S)) messages - compat: differs: a MULTI batch spanning several shards is per-shard-atomic, not globally isolated (there is no cross-shard snapshot); a queue-time error aborts the whole batch with EXECABORT, but a runtime error inside EXEC leaves the other commands applied, as in Redis - `MULTI` [readonly,transaction] — Start a transaction; subsequent commands are queued until EXEC. - complexity: O(1) - compat: full - `UNWATCH` [readonly,transaction] — Forget all watched keys. - complexity: O(1) - compat: full - `WATCH key [key ...]` [readonly,transaction] — Watch keys for optimistic locking of the next EXEC. - complexity: O(W) keys, grouped into O(min(W,S)) cross-shard messages - compat: full ### pubsub - `PSUBSCRIBE pattern [pattern ...]` [readonly,pubsub] — Subscribe to channels matching the given glob patterns. - complexity: O(p * P) — each pattern costs a linear scan of the shared pattern registry - compat: full - `PUBLISH channel message` [readonly,pubsub] — Post a message to a channel; returns the receiver count. - complexity: O(1) channel lookup + O(P) — a linear walk of the registered patterns with one glob match each, even when none match. Delivery fans out only to the shards holding a subscriber - compat: full - `PUNSUBSCRIBE [pattern [pattern ...]]` [readonly,pubsub] — Unsubscribe from patterns (all patterns when none given). - complexity: O(p * P) - compat: full - `SUBSCRIBE channel [channel ...]` [readonly,pubsub] — Subscribe to the given channels. - complexity: O(c) channels; never fans out to other shards - compat: differs: kevy has no RESP2 subscriber mode — after SUBSCRIBE the connection may still issue any command, where Redis restricts it to the pubsub verbs plus PING / QUIT / RESET - `UNSUBSCRIBE [channel [channel ...]]` [readonly,pubsub] — Unsubscribe from channels (all channels when none given). - complexity: O(c) channels - compat: full ### script - `EVAL script numkeys [key [key ...]] [arg [arg ...]]` [write] — Run a Lua script server-side; routed to KEYS[1]'s shard when numkeys >= 1. - complexity: O(compile + the script's own work). The whole script is one atomic unit on KEYS[1]'s shard and blocks that shard for its duration. The runaway guard is an INSTRUCTION budget (lua time_limit_ms x 40000 instructions), not a wall clock - compat: differs: there is no -BUSY state and no SCRIPT KILL — an over-budget script is aborted in place and answered with an ordinary error; nested EVAL is rejected, a redis.call touching another shard's key returns CROSSSLOT, and EVAL auto-caches its source by SHA1 so EVALSHA works without SCRIPT LOAD - `EVALSHA sha1 numkeys [key [key ...]] [arg [arg ...]]` [write] — Run a cached Lua script by its SHA1 digest. - complexity: O(1) cache lookup, then identical to EVAL - compat: differs: same instruction-budget model as EVAL (no -BUSY, no SCRIPT KILL); the cache is process-wide and EVAL populates it - `SCRIPT LOAD script | EXISTS sha1 [sha1 ...] | FLUSH [ASYNC|SYNC]` [readonly] — Manage the process-wide Lua script cache. - complexity: LOAD: O(len(script)). EXISTS: O(k). FLUSH: O(cached scripts) - compat: differs: LOAD / EXISTS / FLUSH only — SCRIPT KILL does not exist, by design: there is no -BUSY state to interrupt because an over-budget script self-aborts ### index - `IDX.COUNT name RANGE min max | EQ value` [readonly,extension] — Count index entries matching a range or equality predicate. - complexity: O(log N + matched) per shard — NOT an O(1) counter and not a B-tree rank query; it counts the matched range - compat: kevy-only: nearest is FT.SEARCH with LIMIT 0 0 - `IDX.CREATE name ON PREFIX prefix FIELD field TYPE i64|f64|str|vector KIND range|unique|text|ann|agg [MAXMEM bytes] [DIM dim] [DISTANCE cosine|l2|ip] [M m] [EF ef] [GROUPBY field]` [write,extension] — Declare a secondary index over a key prefix (catalog mutation, sidecar-persisted). - complexity: O(1) at the call; the build is asynchronous — each shard snapshots its matching keys (O(keys on that shard)) then backfills 2048 rows per tick. Per-row build cost: range/unique O(log N), text O(tokens), vector O(ef_construction * M * dim) - compat: kevy-only: nearest is RediSearch FT.CREATE — comparable declarative index-on-prefix, but RediSearch is a separate module with its own index server; kevy's catalog is in-process and the index is maintained in the write hook - `IDX.DROP name` [write,extension] — Drop a declared index (catalog mutation, sidecar-persisted). - complexity: O(catalog size) - compat: kevy-only: nearest is FT.DROPINDEX - `IDX.LIST` [readonly,extension] — List declared indexes with per-index build state and stats. - complexity: Not O(1) — per shard, per index it computes live stats: range/unique O(1), text O(distinct tokens + docs), vector O(nodes + links), agg O(groups + rows) - compat: kevy-only: nearest is FT._LIST plus FT.INFO combined - `IDX.EXPLAIN name RANGE min max|EQ value|MATCH text|KNN vector|GROUPS [args ...]` [readonly,extension] — The IDX.QUERY parse without the execution: kind, state, est_rows, and the plan line. - complexity: The plan is parse-only, but est_rows costs the same live stats walk as IDX.LIST - compat: kevy-only: nearest is FT.EXPLAIN, which returns a parsed query tree; ours returns kind, build state, estimated rows and a one-line plan - `IDX.QUERY name RANGE min max [LIMIT n] [CURSOR c] [FIELDS field ...] | name EQ value [LIMIT n] [CURSOR c] [FIELDS field ...] | name MATCH text [LIMIT n] [FIELDS field ...] | name KNN vector [LIMIT k] [EF ef] [FIELDS field ...] | name GROUP group | name GROUPS [BY count|sum|min|max] [LIMIT n] | HYBRID text_idx MATCH text ann_idx KNN vector [LIMIT n] [RRFK k] [EF ef] [FIELDS field ...] | COMPOSE AND|OR nameA RANGE min max|EQ value nameB RANGE min max|EQ value [LIMIT n] [CURSOR c] [FIELDS field ...]` [readonly,extension] — Query an index: range/equality, text MATCH, vector KNN, aggregation groups, or a two-index COMPOSE. - complexity: Depends on the index kind. range: O(log N + limit). unique: O(log N + hits). full-text: O(sum of the posting lists), MaxScore-pruned. vector ANN: O(dim * (M log N + ef * M)) distance evaluations (HNSW). agg GROUPS: a bounded top-K with 1 to 3 fan-out rounds. HYBRID: both sub-queries at 4x depth, fused by RRF at the origin. COMPOSE: NOT limit-bounded — both leaves are materialised in full, because the result is key-ordered while a segment is value-ordered - compat: kevy-only: nearest is RediSearch FT.SEARCH — genuinely comparable on two axes (our text index is BM25 over inverted segments, our vector index is HNSW, and RediSearch offers both). Differences: no separate module or index server; our BM25 statistics are per-shard, not global; and our query surface is a fixed grammar, not a DSL — no field boosting, no phrase or proximity search, no stemming, no aggregation pipeline - `IDX.REBUILD name` [write,extension] — Rebuild an ANN index (tombstone compaction). - complexity: Vector indexes only (tombstone compaction). O(V_live * ef_construction * M * dim) distance evaluations, on every shard, synchronously — the most expensive verb in the extension surface - compat: kevy-only: no Redis analogue — RediSearch handles HNSW deletes internally and exposes no rebuild verb - `IDX.VERIFY name` [readonly,extension] — Verify an index: re-read every held entry against its row and report entries/bytes/coerce_failures/duplicates/drift/checked. - complexity: O(N) per shard — every held entry is re-read against its row and re-coerced, which is the point: it makes index drift falsifiable rather than merely asserted - compat: kevy-only: no Redis analogue — RediSearch's index is asynchronously maintained and offers no consistency-verify verb ### view - `VIEW.CREATE name QUERY tree ORDER BY index [DESC] [MODE virtual|materialized] [TOPK k] [VIA template] (tree = '( AND|OR|DIFF sub sub )' | 'index RANGE min max' | 'index EQ value')` [write,extension] — Declare a composed view over indexes with an ORDER BY (catalog mutation, sidecar-persisted). - complexity: O(tree size) at the call. A materialized view builds lazily on the next tick (a full eval_tree plus an O(M log M) sort); a virtual view builds nothing, ever - compat: kevy-only: no Redis analogue. A view is neither a stored query nor a join — it is a named composition tree over declared indexes that stores membership and order only, never field values - `VIEW.DROP name` [write,extension] — Drop a declared view (catalog mutation, sidecar-persisted). - complexity: O(catalog size) - compat: kevy-only - `VIEW.EXPLAIN name` [readonly,extension] — Explain a view's composition tree with per-leaf cardinalities. - complexity: O(sum over leaves of (log N + matched)) — each leaf's cardinality is a real count, so EXPLAIN on a broad leaf costs as much as counting it - compat: kevy-only: nearest is FT.EXPLAIN, which returns a query tree rather than per-leaf cardinalities - `VIEW.LIST` [readonly,extension] — List declared views with their mode and shape. - complexity: O(views) at the origin — the catalog is process-global - compat: kevy-only - `VIEW.QUERY name [LIMIT n] [CURSOR c] [FIELDS field ...]` [readonly,extension] — Page through a view's ordered members, optionally hydrating fields via the VIA template. - complexity: virtual: O(log N_order + candidates * leaves) — it streams the ORDER index and probes membership per candidate, stopping at the limit, so it never materialises the member set. materialized: O(log M + limit). Adding FIELDS with VIA costs a SECOND fan-out to hydrate the rows - compat: kevy-only: no Redis analogue. Absent by construction: no joins, no predicates at the view layer, no projection from the view itself, no HAVING, no aggregation - `VIEW.REBUILD name` [write,extension] — Force a rebuild of a materialized view. - complexity: Materialized only. A full eval_tree over the whole tree plus an O(M log M) sort and re-insert, on every shard, now (not deferred to a tick) - compat: kevy-only - `VIEW.VERIFY name` [readonly,extension] — Verify a view and report member/byte statistics. - complexity: materialized: O(M). virtual: O(sum over leaves of (log N + leaf range) + M) — a full fresh evaluation just to report cardinality, which is the one place a virtual view is the expensive one - compat: kevy-only ### feed - `FEED.READ shard generation offset [COUNT n] [PREFIX prefix ...]` [readonly,extension] — Read change-feed frames from one shard past a generation/offset cursor. - complexity: A single shard (the shard is an argument, not a fan-out). O(log B) to locate the start offset in the backlog plus O(count * frame size) to decode and re-encode. COUNT defaults to 256 and is hard-capped at 4096 - compat: kevy-only: no clean Redis analogue. Keyspace notifications are fire-and-forget with no cursor and no replay; Redis Streams have cursors but are a stream you must dual-write to, not a feed over your existing keyspace; the replication backlog is what this is built on, but Redis exposes it only to replicas. kevy is deliberately not a broker: no consumer groups, no server-side positions, no global cross-shard order - `FEED.SHARDS` [readonly,extension] — Return the number of change-feed shards. - complexity: O(1), answered locally - compat: kevy-only: Redis has no shard-partitioned change feed to enumerate - `FEED.TAIL shard` [readonly,extension] — Return a shard's current feed generation and next offset. - complexity: O(1) on a single shard - compat: kevy-only: nearest is INFO replication's master_repl_offset, which is not a resumable per-shard CDC cursor ### migration - `PREFIX.STATS prefix` [readonly,extension] — Report key-count and byte statistics for a key prefix. - complexity: O(keys on the shard) — a full keyspace walk on every shard; the prefix cannot be precomputed - compat: kevy-only: nearest is INFO keyspace's db0:keys=N,expires=M — the same two counters, but whole-DB and O(1) off a counter, where ours is per-prefix and therefore O(keyspace) - `PREFIX.DIGEST prefix` [readonly,extension] — Order-insensitive checksum of a prefix's rows for migration verification. - complexity: O(keys on the shard) to walk plus O(sum of row sizes) to digest, on every shard. Per-row digests XOR together, so the result is order-insensitive and safe across shard counts - compat: kevy-only: nearest is DEBUG DIGEST — comparable in kind (Redis also XORs per-key digests), but Redis's is whole-DB and lives behind DEBUG, while ours is per-prefix (the unit a migration actually cares about) and is a supported verb ============================================================================== # FULL DOCUMENTATION ============================================================================== Everything below is the complete text of kevy's documentation, in the order an assistant should read it. The first three documents matter most: they are what stops you from writing Redis code that compiles here and then behaves differently. ============================================================================== # designing-on-kevy ============================================================================== # Designing your application on kevy kevy is a **serving engine**: the primary data store for applications that would otherwise put their operational model on a relational database and cache in front of it. This page is the map — what the engine gives you, the laws that bound it, and where each capability lives. If you're migrating from an RDS, read this page first, then work through [the cookbook](https://kevy.golia.jp/docs/cookbook/) recipe by recipe. ## The seven planes | Plane | What it carries | Where | |---|---|---| | **P0 — Operations** | Every op on every surface: server (RESP), embedded (in-process), Lua, atomic blocks, pipelines. One OP_TABLE, CI-enforced parity. | docs/verb-reference.md | | **P1 — Atomicity & durability** | Single-shard atomic blocks, deterministic-order all-shard blocks, appendfsync × atomic-commit matrix, per-block fsync barriers. | docs/persistence.md | | **P2 — Indexes** | Declared secondary indexes, four kinds: `range`, `unique`, `text` (CJK bigram + BM25), `ann` (HNSW). Derived-by-construction (write-hook maintained, zero drift), one-hop hydration (`FIELDS`), backfill rebuild. | docs/indexes.md, docs/text-search.md, docs/vector-search.md | | **P3 — Views & algebra** | Named compositions over indexes (virtual / materialized top-K), zset/set algebra with full Redis semantics. | docs/views.md | | **P4 — Flow** | CDC feed with `(generation, offset)` cursors (the built-in outbox), blocking pops, hash-field TTLs, snapshot read views, the embedded read-only RESP listener. | docs/cdc.md, docs/embedded-listener.md | | **P5 — Evidence** | Every declared line measured and reconciled; crash-consistency chaos gates; 30M-key mixed-stack soak. | bench/VALIDATION-LEDGER.md | | **P6 — Availability** | Replication with acked-offset truth and heartbeats, planned handover (`FAILOVER`) and crash failover (quorum election, election-only write authority, fork discard), and the opt-in consistency ladder: `WAIT`, read-your-writes tokens (`REPL.TOKEN`/`REPL.WAIT`), bounded staleness (`-STALE`), quorum-lease write fencing. 13 executable clamps (availgate) run in CI. | docs/availability.md, docs/replication.md | ## The three laws The constitution that kept — and keeps — kevy from sliding into being a worse RDS: **Law 1 — The Redis contract is inviolable.** Every Redis op kevy implements behaves exactly like Redis. New-genre capability never changes the semantics of an existing verb, never leaks internal keyspace into `SCAN`/`KEYS`/`DBSIZE`, and lives in its own command namespaces (`IDX.*`, `VIEW.*`, `FEED.*`, `PREFIX.*`). **Law 2 — Superset only when it is engine-genre.** A capability gets in only if it is derived data with a lifecycle the engine can own: declare → maintain → verify → rebuild. Indexes, views, feeds and digests all pass that test. Stored app logic doesn't. **Law 3 — The RDS event horizon.** No query language, no planner, no joins, no server-side validation, no triggers. The moment the engine starts *deciding* how to answer a question instead of *executing a declared access path*, it's an RDS — a worse one. kevy stays on this side of the horizon permanently. ## Considered and refused Each of these has a covered answer — the need is real, the feature is the wrong shape: | You might ask for | Use instead | |---|---| | SQL / query DSL | explicit index APIs + [cookbook](https://kevy.golia.jp/docs/cookbook/) | | Query planner / auto index selection | `IDX.EXPLAIN` (diagnostic only) | | Joins | one-hop hydration (`FIELDS`, `VIA`) + app-side assembly | | Foreign keys / cascades | atomic blocks, prefix bulk ops, CDC consumers | | CHECK constraints | reads inside atomic blocks (app evaluates, engine commits atomically) | | Schema / typed columns | bytes are yours; types declared at index creation | | JSON-path queries | hash fields ARE the column model | | Triggers / write-path UDFs | CDC consumers — after commit, decoupled, replayable | | GROUP BY / aggregation pipelines | maintain aggregates at write time in your model | | Time travel | snapshots + CDC retention (recovery-point contract) | | Transactional outbox | unnecessary — the feed IS the outbox | | WHERE without an index | deliberately absent: model the access path or don't ship it | ## The serving charter (what's gated, forever) Numbers are ratchets — floors only rise. The standing lines (measured values in `bench/VALIDATION-LEDGER.md`): - Redis-parity throughput: 12-angle perfgate, floor = baseline×0.92. - Hydrated row-list page p99 < 1ms; view page < 1ms; write fan-out through index+view hooks p99 < 200µs — on one server carrying the full stack. - IDX.QUERY p99 < 2ms @ 1M rows; MATCH p95 < 20ms @ 1M docs; KNN p95 < 30ms with recall@10 ≥ 0.90 @ 1M×128d. - Crash honesty: kill -9 mid-write → replay → derived state identical to a fresh rebuild. Restore point = snapshot + `(gen, offset)`. - Empty catalogs cost nothing: every subsystem gates a zero-tax line. ## Where to start 1. Model your prefixes and access paths ([cookbook §1-2](https://kevy.golia.jp/docs/cookbook/)). 2. Bulk load, then declare indexes ([migration guide](https://kevy.golia.jp/docs/migration/)). 3. Compose views for your hot lists ([views](https://kevy.golia.jp/docs/views/)). 4. Wire your event consumers to the feed ([CDC](https://kevy.golia.jp/docs/cdc/)). 5. Verify with digests, watch the gates. 6. When one node stops being enough, add replicas and pick your rung on the consistency ladder ([availability](https://kevy.golia.jp/docs/availability/)). ============================================================================== # cookbook ============================================================================== # The RDS→kevy modeling cookbook You are moving a relational data model onto kevy. Every recipe below uses shipped primitives — no roadmap features, no "coming soon". Each one names the RDS concept it replaces and the kevy pattern that carries it. The design stance behind all of them: **model the access paths, not the schema**. An RDS lets you defer that decision to a query planner; kevy makes you state it — and pays you back with microsecond pages at serving time (`bench/VALIDATION-LEDGER.md` has the measured numbers). Every command block runs as-is against a fresh local kevy (`kevy --port 6004`; recipes 11–14, 16 and 20 additionally want `[feed] enabled = true` in `kevy.toml` — see docs/cdc.md). `bench/cookbook_smoke.sh` executes every `kevy-cli` line below against a throwaway server, so the blocks stay honest. ## 1. Tables and rows **SQL equivalent:** `CREATE TABLE` + `SELECT col FROM t WHERE id = ?` — [matrix: tables, rows, columns](https://kevy.golia.jp/docs/rds-workloads/#tables-rows-columns). A row is a hash under a typed prefix: ```console kevy-cli -p 6004 HSET user:42 name ada email ada@example.com age 36 kevy-cli -p 6004 HGET user:42 name kevy-cli -p 6004 HGET user:42 phone # NULL = absent field: already answers (nil) ``` - Table → key prefix (`user:`). Column → hash field. Primary key → the key itself. - **NULL = absent field.** Don't store sentinel strings; `HGET` of a missing field already answers nil, and index specs treat a missing field as "row excluded" (visible in `IDX.VERIFY` counts). - Column types are yours: kevy stores bytes. Declare types where they matter — at index creation (`TYPE i64|f64|str|vector`); coercion failures are counted, never silently indexed. ## 2. One-to-many, many-to-many **SQL equivalent:** foreign-key columns + junction tables; `SELECT … FROM orders WHERE user_id = ?` — [matrix: JOIN](https://kevy.golia.jp/docs/rds-workloads/#join). Link keys carry the relation, one set per side: ```console kevy-cli -p 6004 HSET order:1001 user_id 42 total 1999 status shipped kevy-cli -p 6004 HSET order:1002 user_id 42 total 550 status pending kevy-cli -p 6004 SADD user:42:orders 1001 1002 # 1-N: member = order id kevy-cli -p 6004 RPUSH order:1001:items sku-7 sku-9 kevy-cli -p 6004 SADD tag:urgent:orders 1001 # N-M: one set per side kevy-cli -p 6004 SADD order:1001:tags urgent ``` Or skip the link keys entirely: put the foreign key in the row (`user_id` above) and declare an index — `IDX.QUERY … EQ 42` is the `SELECT … WHERE user_id = 42` of this world, hydrated in one hop: ```console kevy-cli -p 6004 IDX.CREATE order_user ON PREFIX order: FIELD user_id TYPE i64 KIND range kevy-cli -p 6004 IDX.QUERY order_user EQ 42 FIELDS total status ``` ## 3. Sequences **SQL equivalent:** `AUTO_INCREMENT` / `CREATE SEQUENCE` + `nextval()` — [matrix: primary key, unique, auto_increment](https://kevy.golia.jp/docs/rds-workloads/#primary-key-unique-auto_increment). ```console kevy-cli -p 6004 INCR seq:order # one id kevy-cli -p 6004 INCRBY seq:order 100 # block allocation: hand out 100 ids # from app memory, refill when dry ``` Block allocation is the high-throughput form; gaps on crash are the same contract PostgreSQL sequences give you. ## 4. Optimistic locking (row versions) **SQL equivalent:** `UPDATE t SET …, version = v+1 WHERE id = ? AND version = v` (the version-column CAS) — [matrix: transactions](https://kevy.golia.jp/docs/rds-workloads/#transactions). Server: WATCH/MULTI — the CAS loop. The transaction is connection-scoped, so it runs in one REPL session (here fed by a heredoc): ```bash kevy-cli -p 6004 HSET user:42 balance 100 version 7 kevy-cli -p 6004 <<'TXN' WATCH user:42 HGET user:42 version MULTI HSET user:42 balance 90 version 8 EXEC TXN ``` `EXEC` answers nil when somebody else touched `user:42` after the `WATCH` — somebody won the race; re-read and retry. Embedded: run the read-decide-write inside one `atomic()` block — the shard lock makes the branch race-free without a retry loop. ## 5. CHECK constraints and multi-key invariants **SQL equivalent:** `CHECK (balance >= 0)` + a trigger-maintained audit row — [matrix: constraints and triggers](https://kevy.golia.jp/docs/rds-workloads/#constraints-and-triggers). The RDS runs `CHECK (balance >= 0)` in the engine. kevy's replacement is **reads inside the atomic block**: the app evaluates the invariant, the engine guarantees the decision and the write commit together. ```rust // embedded — debit that must not overdraw, plus an audit row: store.atomic(b"acct:7", |ctx| { let bal: i64 = parse(ctx.hget(b"acct:7", b"balance")?); if bal < amount { return Err(Overdraw); } ctx.hset(b"acct:7", &[(b"balance", &(bal - amount))])?; ctx.rpush(b"acct:7:ledger", &[entry])?; Ok(()) }) ``` Cross-shard invariants: `atomic_all_shards` (deterministic lock order, documented deadlock exemption). Use sparingly — it is the serializable-transaction hammer, and most invariants live under one key prefix by design. ## 6. Idempotency keys **SQL equivalent:** `UNIQUE INDEX` + `INSERT … ON CONFLICT DO NOTHING` — [matrix: primary key, unique, auto_increment](https://kevy.golia.jp/docs/rds-workloads/#primary-key-unique-auto_increment). ```console kevy-cli -p 6004 HSET req:9001 idem_key pay-2026-07-04-a77 amount 1999 kevy-cli -p 6004 IDX.CREATE req_idem ON PREFIX req: FIELD idem_key TYPE str KIND unique kevy-cli -p 6004 IDX.QUERY req_idem EQ pay-2026-07-04-a77 # duplicates are visible as multi-hit reads kevy-cli -p 6004 IDX.VERIFY req_idem # ...and counted here kevy-cli -p 6004 SET idem:pay-2026-07-04-a77 1 NX PX 86400000 ``` Write the row, then query — duplicates are *visible* (the unique kind counts them in VERIFY rather than rejecting writes; declarative fence, not a write gate). For a hard gate use the `SET … NX PX` form before processing: NX is the atomic claim, the TTL is the retention window. ## 7. Soft delete **SQL equivalent:** a `deleted` flag column + a partial index / view `WHERE deleted = 0` — [matrix: VIEW](https://kevy.golia.jp/docs/rds-workloads/#view). Flag, don't remove: ```console kevy-cli -p 6004 HSET user:42 deleted 0 age 36 kevy-cli -p 6004 HSET user:43 deleted 1 age 51 kevy-cli -p 6004 IDX.CREATE user_live ON PREFIX user: FIELD deleted TYPE i64 KIND range kevy-cli -p 6004 IDX.QUERY user_live EQ 0 LIMIT 100 # live rows only ``` Views compose it away permanently — callers never re-state the filter: ```console kevy-cli -p 6004 IDX.CREATE user_age ON PREFIX user: FIELD age TYPE i64 KIND range kevy-cli -p 6004 VIEW.CREATE live_users QUERY '(' AND user_live EQ 0 user_age RANGE 18 200 ')' ORDER BY user_age kevy-cli -p 6004 VIEW.QUERY live_users LIMIT 10 ``` ## 8. Composite ordering (ORDER BY a, b) **SQL equivalent:** `ORDER BY a, b` on a composite index — [matrix: ORDER BY / LIMIT / OFFSET](https://kevy.golia.jp/docs/rds-workloads/#order-by--limit--offset). Encode the composite into one indexed score field at write time: `score = a * 1_000_000 + b` for bounded integer `b`, or a zero-padded string field for lexicographic composites — one index, one ORDER BY; the write hook maintains it like any field: ```console kevy-cli -p 6004 HSET evt:1 ord '2026-07-04|000042' kevy-cli -p 6004 HSET evt:2 ord '2026-07-04|000007' kevy-cli -p 6004 HSET evt:3 ord '2026-07-05|000001' kevy-cli -p 6004 IDX.CREATE evt_ord ON PREFIX evt: FIELD ord TYPE str KIND range kevy-cli -p 6004 IDX.QUERY evt_ord RANGE '2026-07-04|000000' '2026-07-04|999999' LIMIT 100 ``` ## 9. JSONB **SQL equivalent:** a JSON/JSONB column with generated-column indexes — [matrix: type system](https://kevy.golia.jp/docs/rds-workloads/#type-system). Flatten to hash fields: `profile.city` → field `profile.city`. You keep per-field reads/writes, field TTLs (HEXPIRE), and indexability — everything JSONB gave you except JSON-path queries, which are **permanently out** (query-engine slope; see the REFUSED table in docs/designing-on-kevy.md). ```console kevy-cli -p 6004 HSET user:7 profile.city tokyo profile.plan pro kevy-cli -p 6004 HGET user:7 profile.city kevy-cli -p 6004 HEXPIRE user:7 3600 FIELDS 1 profile.plan # per-field TTL survives the flattening ``` A deeply nested blob nobody indexes can stay one serialized field; the moment a path matters, promote it to a field. ## 10. Cascade delete / foreign keys **SQL equivalent:** `FOREIGN KEY … ON DELETE CASCADE` — [matrix: constraints and triggers](https://kevy.golia.jp/docs/rds-workloads/#constraints-and-triggers). Cascades are app patterns, never engine magic: - Synchronous, small blast radius: delete inside one atomic block (`ctx.del(row)`, `ctx.srem(parent_link, id)`). - Bulk / prefix-shaped: `delete-prefix` — rate-limited, resumable. - Asynchronous: a CDC consumer (`FEED.READ` with `PREFIX`) reacts to parent deletes and cleans children — the trigger replacement, after commit, decoupled, replayable. ```console kevy-cli -p 6004 HSET order:1001 user_id 42 kevy-cli -p 6004 RPUSH order:1001:items sku-7 sku-9 kevy-cli -p 6004 SADD order:1001:tags urgent kevy-cli delete-prefix -p 6004 --rate 5000 order:1001: # children gone, parent row stays ``` ## 11. The outbox you don't need **SQL equivalent:** the transactional-outbox table + relay worker — [matrix: CDC](https://kevy.golia.jp/docs/rds-workloads/#cdc). The transactional-outbox pattern exists because an RDS commit and a message-bus publish can't be atomic. In kevy **the feed is the outbox**: every committed write is already a change frame at a `(generation, offset)` cursor, at-least-once, prefix-filterable (docs/cdc.md). Consume `FEED.READ`; don't build a second journal. ```console # needs [feed] enabled = true in kevy.toml (docs/cdc.md) kevy-cli -p 6004 HSET order:9001 status paid kevy-cli -p 6004 FEED.SHARDS kevy-cli -p 6004 FEED.TAIL 0 # a fresh consumer's starting cursor kevy-cli -p 6004 FEED.READ 0 1 0 COUNT 10 PREFIX order: # gen 1 = a fresh data dir's first generation ``` ## 12. Audit history **SQL equivalent:** a trigger-maintained audit/history table (or binlog archaeology) — [matrix: CDC](https://kevy.golia.jp/docs/rds-workloads/#cdc). CDC retention IS the audit log: frames carry the applied effect argv in commit order. Size the feed backlog for the window you owe compliance, export to cold storage with a cursor consumer. For point-in-time reconstruction: restore snapshot + replay to the `(gen, offset)` recovery point (docs/persistence.md). ```console kevy-cli -p 6004 HSET acct:7 balance 100 kevy-cli -p 6004 HSET acct:7 balance 90 kevy-cli -p 6004 FEED.READ 0 1 0 COUNT 100 PREFIX acct: # who set what, in commit order ``` ## 13. The rollback window (reverse mirror) **SQL equivalent:** reverse replication back to the old primary during a cutover — [migration playbook, phase 5](https://kevy.golia.jp/docs/migration/#phase-5--write-cutover--the-rollback-window). During cutover, run a CDC consumer that mirrors kevy writes BACK to the old RDS (`FEED.READ` → UPDATE statements). Your rollback plan is then "repoint the app", not "reverse-migrate data". Decommission the mirror when confidence hardens; `kevy-cli diff` (per-prefix digests) is the confidence meter. ```console kevy-cli -p 6004 HSET user:42 name ada kevy-cli -p 6004 FEED.READ 0 1 0 COUNT 10 PREFIX user: # the mirror consumer's read loop kevy-cli diff 127.0.0.1:6004 127.0.0.1:6004 user: # digests match: safe form of the check kevy-cli diff old-rds-mirror.internal:6379 127.0.0.1:6004 user: # needs-external ``` ## 14. Analytics export **SQL equivalent:** the ETL job / binlog tap feeding the warehouse — [matrix: CDC](https://kevy.golia.jp/docs/rds-workloads/#cdc). Serving and analytics don't share an engine. Export patterns: - `export` — logical, resumable, loadable anywhere RESP goes. - CDC → warehouse: a cursor consumer streaming inserts to your OLAP store, exactly the CDC-to-Kafka shape. - Read-only listener (`docs/embedded-listener.md`) for ad-hoc pulls from embedded apps. ```console kevy-cli -p 6004 HSET order:1001 user_id 42 total 1999 kevy-cli export -p 6004 --prefix order: /tmp/orders.resp kevy-cli -p 6004 FEED.READ 0 1 0 COUNT 100 PREFIX order: # the CDC-to-warehouse read loop ``` ## 15. Loading order (the deferred-index rule) **SQL equivalent:** `LOAD DATA` first, `CREATE INDEX` after (the bulk-load discipline) — [matrix: secondary index DDL](https://kevy.golia.jp/docs/rds-workloads/#secondary-index-ddl). Bulk load FIRST, declare indexes/views AFTER: backfill builds from existing rows at ~7s/million — orders of magnitude cheaper than paying the write hook per imported row (docs/migration.md). ```console kevy-cli -p 6004 HSET item:1 price 10 kevy-cli -p 6004 HSET item:2 price 25 kevy-cli -p 6004 HSET item:3 price 7 kevy-cli export -p 6004 --prefix item: /tmp/items.resp kevy-cli import -p 6004 /tmp/items.resp # bulk load FIRST: no index write hook to pay kevy-cli -p 6004 IDX.CREATE item_price ON PREFIX item: FIELD price TYPE i64 KIND range # declare AFTER: backfill kevy-cli -p 6004 IDX.QUERY item_price RANGE 0 100 LIMIT 10 ``` --- The last three recipes swap the workload: not an RDS being replaced but an AI agent's memory stack. Nothing new is needed — session state, episodic memory and RAG retrieval are the same access-path patterns wearing different key prefixes. ## 16. Session context with TTL **SQL equivalent:** a sessions table + the expiry cron job — [matrix: operational deltas](https://kevy.golia.jp/docs/rds-workloads/#sizing-and-operational-deltas). An agent's working context is a row with a lease: the compacted conversation lives in a hash, `EXPIRE` is the idle-eviction policy (renewed every turn — a sliding window), and the feed is the audit trail you replay when someone asks what the agent knew at turn 7. ```console # needs [feed] enabled = true in kevy.toml (docs/cdc.md) kevy-cli -p 6004 HSET session:a7 user 42 turns 6 messages 'wants refund for order 1001; tone calm' last_tool order_lookup kevy-cli -p 6004 EXPIRE session:a7 3600 kevy-cli -p 6004 HSET session:a7 turns 7 messages 'refund approved; awaiting confirmation' kevy-cli -p 6004 EXPIRE session:a7 3600 # renew the lease on every turn kevy-cli -p 6004 FEED.TAIL 0 # audit cursor: where the log ends now kevy-cli -p 6004 FEED.READ 0 1 0 COUNT 100 PREFIX session: # gen 1 = a fresh data dir's first generation ``` The `messages` field holds whatever summary your compaction step produces; rewriting it is one `HSET`, and every revision is already a change frame in commit order — the "conversation history" table most agent frameworks bolt on is recipe 12's audit log for free. ## 17. Episodic memory (time × semantic) **SQL equivalent:** `WHERE ts BETWEEN …` + pgvector `ORDER BY embedding <=> ? LIMIT k` — [matrix: SELECT](https://kevy.golia.jp/docs/rds-workloads/#select). Episodic memory answers two questions about the same rows: *what happened recently* (time) and *what resembles this* (meaning). One prefix, one index per question — `DIM 8` keeps the demo readable; real embeddings are 768+ dimensions shipped as f32-LE blobs, and the `csv:` debug form below is accepted everywhere a vector is (stored fields and query vectors go through the same parser — docs/vector-search.md). ```console kevy-cli -p 6004 HSET mem:1 ts 1783200000 kind obs what 'user prefers dark roast' v csv:0.9,0.1,0,0,0,0,0,0 kevy-cli -p 6004 HSET mem:2 ts 1783203600 kind obs what 'user asked about decaf' v csv:0.8,0.3,0.1,0,0,0,0,0 kevy-cli -p 6004 HSET mem:3 ts 1783207200 kind reflection what 'coffee questions cluster in the morning' v csv:0,0.2,0.9,0.1,0,0,0,0 kevy-cli -p 6004 IDX.CREATE mem_ts ON PREFIX mem: FIELD ts TYPE i64 KIND range kevy-cli -p 6004 IDX.CREATE mem_kind ON PREFIX mem: FIELD kind TYPE str KIND range kevy-cli -p 6004 IDX.CREATE mem_ann ON PREFIX mem: FIELD v TYPE vector KIND ann DIM 8 kevy-cli -p 6004 IDX.QUERY mem_ts RANGE 1783203000 1783210000 LIMIT 10 FIELDS what # recent memories kevy-cli -p 6004 IDX.QUERY mem_ann KNN csv:0.85,0.2,0,0,0,0,0,0 LIMIT 2 FIELDS what ts # similar memories kevy-cli -p 6004 IDX.QUERY COMPOSE AND mem_ts RANGE 1783203000 1783210000 mem_kind EQ reflection LIMIT 10 FIELDS what ``` `COMPOSE AND` conjoins scalar legs (`RANGE`/`EQ`) — here "in this time window AND a reflection". For *similar within a window* there is deliberately no KNN leg (filtering inside the graph walk is the query-engine slope, REFUSED): run the KNN with headroom on `LIMIT`, hydrate `ts` via `FIELDS` as above, and drop out-of-window hits client-side. ## 18. RAG chunks with hybrid retrieval **SQL equivalent:** tsvector full-text + pgvector KNN, fused app-side — [matrix: SELECT](https://kevy.golia.jp/docs/rds-workloads/#select). Chunks are rows carrying both retrieval surfaces — the text and its embedding — so one write maintains both indexes: ```console kevy-cli -p 6004 HSET chunk:1 doc kevy-guide seq 1 body 'rows are hashes under a typed key prefix' v csv:1,0,0,0,0,0,0,0 kevy-cli -p 6004 HSET chunk:2 doc kevy-guide seq 2 body 'indexes are declared once and maintained by the write hook' v csv:0,1,0,0,0,0,0,0 kevy-cli -p 6004 HSET chunk:3 doc kevy-guide seq 3 body 'the feed streams every committed write as a change frame' v csv:0,0,1,0,0,0,0,0 kevy-cli -p 6004 IDX.CREATE chunk_text ON PREFIX chunk: FIELD body TYPE str KIND text kevy-cli -p 6004 IDX.CREATE chunk_ann ON PREFIX chunk: FIELD v TYPE vector KIND ann DIM 8 kevy-cli -p 6004 IDX.QUERY HYBRID chunk_text MATCH 'typed key prefix' chunk_ann KNN csv:0.9,0.1,0.1,0,0,0,0,0 LIMIT 2 FIELDS body kevy-cli -p 6004 IDX.QUERY HYBRID chunk_text MATCH 'change frame' chunk_ann KNN csv:0,0.1,0.9,0,0,0,0,0 LIMIT 2 RRFK 20 FIELDS body ``` `HYBRID` runs both legs server-side and fuses by **reciprocal-rank fusion**: each key scores `Σ 1/(k + rank)` across the BM25 list and the KNN list — rank-only, so the two heterogeneous score scales never need normalizing, and a chunk near the top of *both* legs beats a chunk that tops only one. `RRFK` is the k (default 60): lower it when you trust each leg's top hits and want agreement there to dominate; raise it to flatten the fusion toward consensus deeper in both lists. --- The last two recipes leave the rack entirely: a kevy on an edge node — the same server binary, or `kevy-embedded` compiled down to its `core` tier at 655 KB ([docs/iot.md](https://kevy.golia.jp/docs/iot/)) — speaks the same verbs, so the patterns transfer verbatim from datacenter to sensor gateway. ## 19. Sensor cache (latest value + liveness lease) **SQL equivalent:** the `readings_latest` upsert table plus the staleness cron — [matrix: operational deltas](https://kevy.golia.jp/docs/rds-workloads/#sizing-and-operational-deltas). The current value of every sensor is a row; the TTL is the liveness contract. A sensor that stops reporting expires out of the cache — **absence IS the offline signal**, no reaper job to write: ```console kevy-cli -p 6004 HSET sensor:t1 val 21.5 unit C ts 1783200000 kevy-cli -p 6004 EXPIRE sensor:t1 90 kevy-cli -p 6004 HSET sensor:t1 val 21.7 unit C ts 1783200030 kevy-cli -p 6004 EXPIRE sensor:t1 90 # every report renews the lease kevy-cli -p 6004 EXISTS sensor:t1 # 1 = reporting, 0 = gone dark ``` Size the lease to your alarm tolerance (here 90 s = three missed 30-second reports). To *react* to a sensor going dark instead of polling, enable keyspace notifications with the `x` (expired) class and subscribe to the expiry events — the push form of the same contract (docs/pubsub.md). The recent window is a stream with a hard cap — `MAXLEN ~` keeps the node's memory bounded no matter how long it runs, which on a months-uptime edge box is the invariant that matters: ```console kevy-cli -p 6004 XADD sensor:t1:log MAXLEN '~' 1000 '*' val 21.5 kevy-cli -p 6004 XADD sensor:t1:log MAXLEN '~' 1000 '*' val 21.7 kevy-cli -p 6004 XLEN sensor:t1:log kevy-cli -p 6004 XRANGE sensor:t1:log - + COUNT 10 ``` Embedded form: same verbs through the typed API inside your gateway process — `store.hset(…)` / `store.expire(…)` / `store.xadd(…)` — with no socket at all; the `core` feature tier carries everything this recipe uses (docs/iot.md). ## 20. Edge aggregation (write-time GROUP BY + uplink) **SQL equivalent:** `SELECT zone, COUNT(*), SUM(w) … GROUP BY zone` re-run per dashboard refresh — [matrix: GROUP BY and aggregates](https://kevy.golia.jp/docs/rds-workloads/#group-by-and-aggregates). An edge node summarizes locally and ships summaries — raw readings are too many to uplink. Declare the aggregate once; it is maintained in the write path, so the "aggregation job" simply stops existing: ```console kevy-cli -p 6004 HSET reading:1 zone floor1 w 120 kevy-cli -p 6004 HSET reading:2 zone floor1 w 180 kevy-cli -p 6004 HSET reading:3 zone floor2 w 95 kevy-cli -p 6004 IDX.CREATE zone_w ON PREFIX reading: FIELD w TYPE i64 KIND agg GROUPBY zone kevy-cli -p 6004 IDX.QUERY zone_w GROUP floor1 # [count, sum, min, max, avg] kevy-cli -p 6004 IDX.QUERY zone_w GROUPS BY sum LIMIT 10 # zones ranked by load ``` The uplink is recipe 11's outbox wearing overalls: the feed already journals every committed write, so the cloud-sync consumer is a cursor loop, resumable across links that drop for hours — at-least-once, in commit order, prefix-filtered to just what the cloud needs: ```console # needs [feed] enabled = true in kevy.toml (docs/cdc.md) kevy-cli -p 6004 FEED.TAIL 0 kevy-cli -p 6004 FEED.READ 0 1 0 COUNT 100 PREFIX reading: # the uplink loop ``` Pair it with recipe 19's `MAXLEN` cap and TTLs: raw readings stay bounded on the node, the aggregate rows stay tiny, and the feed cursor survives reboots — the whole edge story with zero moving parts beyond kevy itself. ## Recipe index Recipe ↔ the SQL construct it replaces ↔ the [rds-workloads.md](https://kevy.golia.jp/docs/rds-workloads/) matrix row that states the semantics and limits. | # | Recipe | SQL construct | Matrix row | |---|---|---|---| | 1 | Tables and rows | `CREATE TABLE`, point `SELECT` | [tables, rows, columns](https://kevy.golia.jp/docs/rds-workloads/#tables-rows-columns) | | 2 | One-to-many, many-to-many | FK columns, junction tables, `WHERE fk = ?` | [JOIN](https://kevy.golia.jp/docs/rds-workloads/#join) | | 3 | Sequences | `AUTO_INCREMENT` / `nextval()` | [PK, UNIQUE, AUTO_INCREMENT](https://kevy.golia.jp/docs/rds-workloads/#primary-key-unique-auto_increment) | | 4 | Optimistic locking | version-column CAS `UPDATE` | [transactions](https://kevy.golia.jp/docs/rds-workloads/#transactions) | | 5 | CHECK constraints | `CHECK (…)` + audit trigger | [constraints and triggers](https://kevy.golia.jp/docs/rds-workloads/#constraints-and-triggers) | | 6 | Idempotency keys | `UNIQUE INDEX` + `ON CONFLICT DO NOTHING` | [PK, UNIQUE, AUTO_INCREMENT](https://kevy.golia.jp/docs/rds-workloads/#primary-key-unique-auto_increment) | | 7 | Soft delete | flag column + filtered view | [VIEW](https://kevy.golia.jp/docs/rds-workloads/#view) | | 8 | Composite ordering | `ORDER BY a, b` | [ORDER BY / LIMIT / OFFSET](https://kevy.golia.jp/docs/rds-workloads/#order-by--limit--offset) | | 9 | JSONB | JSON column + generated-column indexes | [type system](https://kevy.golia.jp/docs/rds-workloads/#type-system) | | 10 | Cascade delete / FKs | `ON DELETE CASCADE` | [constraints and triggers](https://kevy.golia.jp/docs/rds-workloads/#constraints-and-triggers) | | 11 | The outbox you don't need | transactional-outbox table | [CDC](https://kevy.golia.jp/docs/rds-workloads/#cdc) | | 12 | Audit history | audit table / binlog archaeology | [CDC](https://kevy.golia.jp/docs/rds-workloads/#cdc) | | 13 | The rollback window | reverse replication at cutover | [migration playbook](https://kevy.golia.jp/docs/migration/#phase-5--write-cutover--the-rollback-window) | | 14 | Analytics export | ETL / binlog tap to warehouse | [CDC](https://kevy.golia.jp/docs/rds-workloads/#cdc) | | 15 | Loading order | bulk `LOAD DATA`, index after | [secondary index DDL](https://kevy.golia.jp/docs/rds-workloads/#secondary-index-ddl) | | 16 | Session context with TTL | sessions table + expiry cron | [operational deltas](https://kevy.golia.jp/docs/rds-workloads/#sizing-and-operational-deltas) | | 17 | Episodic memory | time `BETWEEN` + pgvector KNN | [SELECT](https://kevy.golia.jp/docs/rds-workloads/#select) | | 18 | RAG hybrid retrieval | tsvector + pgvector, fused | [SELECT](https://kevy.golia.jp/docs/rds-workloads/#select) | | 19 | Sensor cache | upsert table + staleness cron | [operational deltas](https://kevy.golia.jp/docs/rds-workloads/#sizing-and-operational-deltas) | | 20 | Edge aggregation | `GROUP BY` per refresh + ETL uplink | [GROUP BY and aggregates](https://kevy.golia.jp/docs/rds-workloads/#group-by-and-aggregates) | ============================================================================== # rds-workloads ============================================================================== # RDS workloads on kevy You run an operational workload on MySQL or PostgreSQL and are considering moving it — wholly or partially — onto kevy. This page is the **reference matrix**: every SQL-surface construct, what carries it in kevy, the exact verbs, and the semantic deltas. Where kevy refuses a construct, the refusal is stated plainly along with the covered alternative. The companion [cookbook](https://kevy.golia.jp/docs/cookbook/) turns these rows into runnable recipes; [designing-on-kevy](https://kevy.golia.jp/docs/designing-on-kevy/) is the one-page map of the engine itself. ## The division of labor An RDS lets you write data first and decide the access paths later — the query planner turns any `WHERE` clause into *some* plan at query time. kevy inverts that: **you declare the access paths at write time** (indexes, aggregates, views), the engine maintains them synchronously with every write (derived-by-construction, zero drift), and serving is a microsecond-scale lookup, never a plan search. The trade is explicit: - What you give up: ad-hoc queries over undeclared paths. A `WHERE` with no matching index is not slow on kevy — it is *absent by design* (model the access path or don't ship the query). - What you get: predictable serving latency (hydrated page p99 < 1ms, write fan-out through index+view hooks p99 < 200µs on one server carrying the full stack — the gated lines in [designing-on-kevy](https://kevy.golia.jp/docs/designing-on-kevy/)), and raw Redis-parity throughput far above what a B-tree-on-disk engine serves (see [Sizing](#sizing-and-operational-deltas)). The boundary is a charter, not a backlog: no query language, no planner, no joins, no server-side validation DSL, no triggers ("Law 3", [designing-on-kevy](https://kevy.golia.jp/docs/designing-on-kevy/)). Everything below maps SQL constructs onto that fixed surface. ## Tables, rows, columns | RDS | kevy | |---|---| | table | key prefix (`user:`) | | row | hash under the prefix (`user:42`) | | column | hash field | | PRIMARY KEY | the key itself | | NULL | absent field (never a sentinel string) | ``` HSET user:42 name ada email ada@example.com age 36 HGETALL user:42 # SELECT * WHERE id = 42 HGET user:42 email # SELECT email WHERE id = 42 HMGET user:42 name age # SELECT name, age WHERE id = 42 ``` `HGET` of a missing field answers nil — that IS the NULL semantics, and index specs treat a missing field as "row excluded" (counted, visible in `IDX.VERIFY`). ### Type system kevy stores bytes; types are declared **where they matter — at index creation** (`TYPE i64|f64|str|vector`). A row whose field fails the declared coercion is excluded from that index and counted (`coerce_failures` in `IDX.VERIFY` / `IDX.LIST`) — a declarative fence, never a write error. | RDS type | kevy form | notes | |---|---|---| | INT / BIGINT / SERIAL | field bytes, index `TYPE i64` | full i64 range | | DECIMAL / NUMERIC | **integer minor units** as `i64` (cents, micros) | kevy has no decimal type; `f64` is binary floating point — never store money in it. Sums in `KIND agg` accumulate in f64 (documented precision bound), so pick units that keep totals well under 2^53 | | FLOAT / DOUBLE | field bytes, index `TYPE f64` | IEEE 754 semantics | | VARCHAR / CHAR / TEXT | field bytes, index `TYPE str` | binary-safe; lexicographic byte order | | BLOB / BYTEA | field bytes (no index) or a dedicated string key | values are arbitrary bytes everywhere | | DATETIME / TIMESTAMP | Unix epoch (secs or millis) as `i64` | range index gives you `BETWEEN`; kevy has no date arithmetic — the app does calendar math | | BOOL | `0` / `1` as `i64` | indexable, composable (`EQ 0`) | | JSON / JSONB | flattened hash fields (`profile.city`) | per-field reads, TTLs (`HEXPIRE`), indexability; JSON-path queries are permanently out — cookbook recipe 9 | | embedding (pgvector) | `dim × 4` bytes f32-LE, index `TYPE vector` | [vector-search](https://kevy.golia.jp/docs/vector-search/) | ## PRIMARY KEY, UNIQUE, AUTO_INCREMENT **Primary key** — the key itself. Point lookups (`WHERE pk = ?`) are `GET`/`HGETALL`/`HMGET`: one shard, one hash probe, no index needed. **AUTO_INCREMENT / sequences** — `INCR seq:order` mints one id; `INCRBY seq:order 100` allocates a block to hand out from app memory (the high-throughput form). Gaps on crash are the same contract PostgreSQL sequences give you (cookbook recipe 3). **UNIQUE** — `KIND unique` is a **fence, not a lock**: it does not block writes (blocking would serialize cross-shard writes); duplicates are counted (`duplicates` in `IDX.VERIFY`) and visible as multi-hit `EQ` reads. For a hard uniqueness gate use one of: ``` SET uniq:email:ada@example.com 42 NX # atomic claim, NX = the gate WATCH + MULTI/EXEC check-then-write # CAS loop (cookbook recipe 4) {hashtag} prefix + single-shard domain # cluster mode ``` ## SELECT | SQL | kevy | verbs | |---|---|---| | `WHERE pk = ?` | direct key read | `GET` / `HGETALL` / `HMGET` | | `WHERE col = ?` | equality on a declared index | `IDX.QUERY idx EQ v [FIELDS f…]` | | `WHERE col BETWEEN a AND b` | range scan on a declared index | `IDX.QUERY idx RANGE a b` | | `WHERE col > a` | half-open range | `RANGE a ` | | `WHERE a = ? AND b BETWEEN …` | two-index composition | `IDX.QUERY COMPOSE AND idxA EQ v idxB RANGE a b` | | `WHERE col IN (v1, v2)` | two-leg OR, or N app-side EQ queries | `IDX.QUERY COMPOSE OR idx EQ v1 idx EQ v2` | | `LIKE 'abc%'` (prefix) | str range index, prefix bounds | `IDX.QUERY idx RANGE 'abc' 'abc\xff'` | | `LIKE '%abc%'` (infix) | **not supported as such** | full-text `MATCH` if token-shaped; else redesign the field | | full-text search | `KIND text`, BM25 | `IDX.QUERY idx MATCH "…"` | | `SELECT col1, col2` (projection) | field hydration | `… FIELDS col1 col2` | | `COUNT(*) WHERE …` | count without materializing | `IDX.COUNT idx RANGE a b` | | `EXPLAIN` | parse + plan line, zero execution | `IDX.EXPLAIN idx RANGE a b` | Semantics and limits, honestly: - **One field per index.** An index covers one declared field of one prefix. Multi-column predicates are `COMPOSE AND|OR` of exactly **two** indexes (key-ordered — the two value domains differ, so key order is the only shared order), or a [view](#view) for a named, reusable composition of up to 4 leaves. Wider ad-hoc conjunctions are the query-planner slope — refused. Filter residual predicates app-side after hydration. - **`RANGE`/`EQ`/`COMPOSE`**: default `LIMIT 100`, max `10000`, cursor pagination (`[next-cursor, rows]`, `"0"` = start/exhausted). Cursor contract is SCAN-class: rows stable across the traversal are seen exactly once; concurrent writes may or may not appear. No global snapshot — same envelope as `SCAN`/`DBSIZE`. - **Prefix `LIKE`**: a `TYPE str KIND range` index is ordered by lexicographic byte order, so `LIKE 'abc%'` is `RANGE 'abc' 'abc<0xff>'` — an index scan, not a keyspace walk. (Key-name prefix matching via `SCAN 0 MATCH 'user:*'` also exists but walks the whole keyspace incrementally — use it for ops chores, never as a serving path.) - **`MATCH`** is OR-semantics over query tokens, BM25-ranked, CJK bigrams built in; `LIMIT` caps at 1000, no cursor; no phrase queries, no boolean syntax, no highlighting ([text-search](https://kevy.golia.jp/docs/text-search/)). - **`FIELDS`** hydrates named hash fields on each row's owning shard in the same call — the one-hop replacement for the "index scan + primary lookup" double hop. This is also the JOIN-shaped hydration primitive (below). - **`IDX.EXPLAIN` is diagnostic only.** It reports kind, state, `est_rows`, and the plan line for the query you already wrote — there is no optimizer choosing among plans. ## ORDER BY / LIMIT / OFFSET - A `range` index **is** the order: `IDX.QUERY … RANGE` returns rows ascending by indexed value. `ORDER BY col ASC LIMIT n` = declare a range index on `col`, query with `LIMIT n`. - **Descending order** on the bare index surface: not an `IDX.QUERY` option. Either declare a view (`VIEW.CREATE … ORDER BY idx DESC`) or store a negated/complemented sort field at write time. - **`ORDER BY a, b`** (composite): encode the composite into one indexed field at write time — `a * 1_000_000 + b` for bounded integers, zero-padded strings for lexicographic composites (cookbook recipe 8). - **`OFFSET` does not exist.** There is deliberately no "skip N rows" — it is O(N) waste in any engine and an anti-pattern at depth. Paginate with the returned cursor (constant cost per page, position-stable under the SCAN-class contract). If your product needs "jump to page 47", precompute page anchors or rethink the UX; kevy will not hide the cost. ## GROUP BY and aggregates `KIND agg` maintains aggregates **in the write path** — the inversion of `GROUP BY`: an RDS scans rows at query time, kevy folds each write into its group as it lands, and reading a group is O(1). ``` IDX.CREATE ord_amt ON PREFIX ord: FIELD amount TYPE i64 KIND agg GROUPBY status IDX.QUERY ord_amt GROUP paid → [count, sum, min, max, avg] IDX.QUERY ord_amt GROUPS BY sum LIMIT 100 → ranked [group, count, sum, min, max] ``` - `GROUP g` = `SELECT COUNT(*), SUM(v), MIN(v), MAX(v), AVG(v) WHERE group = g`; `GROUPS BY count|sum|min|max` = the ranked `GROUP BY … ORDER BY agg LIMIT n` (LIMIT ≤ 1000). - min/max stay exact under deletion (per-group value multiset); sums accumulate in f64 — mind the precision bound for money (use minor units sized so totals stay well under 2^53). - **No `HAVING`, no aggregate expressions, no `GROUP BY` over arbitrary predicates** — filter the (bounded) `GROUPS` result in the app. One group-by field per index; declare several agg indexes for several groupings. ## JOIN **kevy does not join.** No verb joins two prefixes server-side, and none ever will (Law 3). The three replacements, in order of preference: 1. **Denormalize at write time.** The serving-model answer: if the page shows `order.total` next to `user.name`, write `user_name` into the order row when it's created. Storage is cheap; the write hook keeps any indexes on the copy fresh. Update fan-out on the parent (rare for display fields) is a CDC consumer. 2. **Application-side hydration (the two-hop).** Foreign key lives in the row; an index makes the reverse direction one hop: `IDX.QUERY order_user EQ 42 FIELDS total status` is `SELECT total, status FROM orders WHERE user_id = 42` — the index finds the keys and hydrates fields in the same call (cookbook recipe 2). The forward direction is `HMGET` per parent — batch the ids and pipeline. 3. **A view with `VIA` hydration.** `VIEW.QUERY v FIELDS name VIA user:{key.1}` dereferences each member key to a *target* key via a template and reads fields there — a declared, reusable member→parent dereference (one internal fan-out, no client round trips). Dereference, not query: targets take no predicates. What you lose vs SQL: arbitrary N-way joins, join-time filtering on the far table, planner-chosen join order. What you gain: no join ever shows up in a latency histogram. ## VIEW `VIEW.*` is a named AND/OR/DIFF composition over declared indexes with an ordering index — closer to a *materialized* view with an index than to a SQL view: | SQL view property | kevy view | |---|---| | arbitrary SELECT body | **no** — tree of index shapes only (depth ≤ 3, ≤ 4 leaves, shapes fixed at CREATE) | | always-fresh (virtual) | `MODE virtual` — evaluated per query, zero write cost | | materialized + REFRESH | `MODE materialized` — incrementally maintained in the write hook, **never stale**, no refresh job; optional `TOPK k` bound | | ORDER BY in the view | `ORDER BY [DESC]` — a declared range index supplies the order | | view over a view | no — one level, over indexes | ``` VIEW.CREATE live_adults QUERY '( AND user_live EQ 0 user_age RANGE 18 200 )' ORDER BY user_age MODE materialized TOPK 100 VIEW.QUERY live_adults LIMIT 10 [FIELDS name] [VIA …] ``` The killer app is the **hot list**: "top 100 ready jobs by priority" as a `TOPK` materialized view costs ~2% write tax and answers in microseconds, permanently fresh — the thing an RDS needs a covering index + a disciplined query + luck to approximate ([views](https://kevy.golia.jp/docs/views/)). ## Transactions kevy's transaction story is Redis's (Law 1), which maps to SQL like this: | SQL | kevy | delta | |---|---|---| | `BEGIN … COMMIT` (batch) | `MULTI` … `EXEC` | commands queue and apply as one unit; **no interactive reads inside** — reads you branch on happen before `MULTI` | | `SELECT … FOR UPDATE` | `WATCH key` + `MULTI`/`EXEC` | optimistic CAS, not a lock: `EXEC` answers nil if a watched key changed — re-read and retry (cookbook recipe 4) | | `ROLLBACK` | none | a queue-time error aborts the whole batch (`-EXECABORT`, nothing ran); a runtime error inside `EXEC` does **not** undo the other commands — Redis semantics | | stored procedure / one atomic RMW | `EVAL` (Lua) | the whole script is one atomic unit on `KEYS[1]`'s shard; read-decide-write with real branching ([lua](https://kevy.golia.jp/docs/lua/)) | | serializable single-entity txn | embedded `store.atomic(key, …)` | shard-locked closure: reads see your own writes, commit is atomic + one fsync (cookbook recipe 5) | | cross-entity serializable | embedded `atomic_all_shards` | deterministic lock order; the hammer — use sparingly | Isolation, honestly: kevy has no MVCC and no isolation-level knob. Within one shard, every command (and every `EVAL`, `MULTI` batch, or embedded atomic block) is serialized — you get *serializable* behavior for anything that fits one shard / one script. Across shards there is no global snapshot: multi-key reads (`MGET`, `IDX.QUERY` merges) are per-shard-atomic, SCAN-class overall. There is no read that observes a torn single command, and no dirty read of an uncommitted `MULTI` batch; there IS no repeatable-read across two separate commands — use `WATCH` (CAS) or Lua (one unit) where that matters. Durability of a "commit": see [persistence](https://kevy.golia.jp/docs/persistence/) — with `appendfsync always` an acknowledged write is on disk before the reply; the default `everysec` windows up to 1s (the Redis trade). `Store::fsync_aof()` (embedded) is the per-transaction `synchronous_commit` escape hatch. ## Constraints and triggers | SQL | kevy answer | |---|---| | `NOT NULL` / type checks | the **coercion fence**: rows failing an index's declared TYPE are excluded and counted (`coerce_failures`); the app watches `IDX.VERIFY` — declarative visibility, not write rejection | | `CHECK (expr)` | evaluate the invariant inside the atomic unit: Lua script (server) or `atomic` block (embedded) reads, decides, writes — the engine guarantees decision + commit are one unit (cookbook recipe 5) | | `UNIQUE` | fence + counted duplicates, or a hard `SET … NX` gate (above) | | `FOREIGN KEY` (existence) | not enforced; write parent-then-child inside one atomic unit if you need the invariant | | `ON DELETE CASCADE` | app pattern: atomic block (small), `kevy-cli delete-prefix` (bulk), or a CDC consumer reacting to parent deletes (async — cookbook recipe 10) | | triggers | **CDC consumers**: `FEED.READ` delivers every committed write as a change frame — after commit, decoupled, replayable, and it can't corrupt the write path (cookbook recipes 10–12) | There is deliberately no server-side constraint or trigger DSL: Lua scripts are the only server-side logic, and they run *as the write*, not attached to it. ## Secondary index DDL | SQL | kevy | |---|---| | `CREATE INDEX idx ON t (col)` | `IDX.CREATE idx ON PREFIX t: FIELD col TYPE i64\|f64\|str KIND range` | | `CREATE UNIQUE INDEX` | `KIND unique` (fence semantics, above) | | `CREATE INDEX … USING gin (tsvector)` | `KIND text` ([text-search](https://kevy.golia.jp/docs/text-search/)) | | pgvector `USING hnsw` | `KIND ann DIM d [DISTANCE cosine\|l2\|ip] [M m] [EF ef]` ([vector-search](https://kevy.golia.jp/docs/vector-search/)) | | `DROP INDEX` | `IDX.DROP idx` | | `\d` / information_schema | `IDX.LIST` / `VIEW.LIST` (state, entries, bytes) | - **Online build**: `IDX.CREATE` returns immediately and backfills in the background (~7s per million small rows; multi-KB text bodies ~85s/M). Queries answer `-INDEXBUILDING` until ready — poll `IDX.LIST` for `state=ready` ([migration](https://kevy.golia.jp/docs/migration/)). Data availability never waits on an index build. - Index content is **derived state**: never snapshotted or AOF-logged, rebuilt in the background after restart. The catalog (the declarations) persists in a data-dir sidecar. - Up to 64 indexes; `MAXMEM` caps a build declaratively (`-INDEXOVERBUDGET`) instead of growing unbounded. - **Bulk-load rule**: import first, declare indexes after — backfill at bulk speed beats paying the write hook per imported row (cookbook recipe 15). ## Pagination and optimistic locking patterns - **Keyset/cursor pagination** (the good kind of `OFFSET`): every paged surface (`IDX.QUERY RANGE/EQ/COMPOSE`, `VIEW.QUERY`, `SCAN`) returns `[next-cursor, rows]`; feed the cursor back until `"0"`. Treat cursors as opaque and single-traversal. - **Version-column optimistic locking**: keep a `version` field, `WATCH` the row, read, `MULTI` + `HSET … version n+1` + `EXEC`; nil reply = lost the race, retry (cookbook recipe 4). ## Backup and PITR | RDS | kevy | |---|---| | `mysqldump` / `pg_dump` | `kevy-cli export` (logical RESP stream, `redis-cli --pipe`-compatible) | | binary backup | snapshot files (`SAVE`/`BGSAVE` → `dump-.rdb`) | | WAL / binlog | the AOF (append-only command log, per shard) | | `synchronous_commit` | `appendfsync always` (or `everysec` + `fsync_aof` barriers) | | PITR (base + WAL replay) | **recovery-point contract**: snapshot + CDC frames from the snapshot's recorded cursor = exact state at any later cursor ([persistence](https://kevy.golia.jp/docs/persistence/)) | Scope note on PITR: the feed window is an in-memory backlog (`feed_buffer_size`, caps 1 GiB/shard) — take snapshots at least as often as the window turns over if you rely on exact-point recovery. Verification is first-class: `PREFIX.DIGEST` / `kevy-cli diff` prove two keyspaces equal, order- and topology-insensitively. ## Replication and read scaling Primary + N replicas, asynchronous by default — the read-replica shape. The differences from an RDS are all in the **consistency ladder** ([availability](https://kevy.golia.jp/docs/availability/)), which prices each guarantee per call instead of per instance: | RDS notion | kevy rung | |---|---| | async replica (MySQL default) | rung 0: primary acks after local apply, replicas trail | | session read-your-writes | rung 1: `REPL.TOKEN` on the primary → `REPL.WAIT token` on the replica → your next read observes your write | | semi-sync replication | rung 2: `WAIT n timeout` per write — on the primary **and** acked by ≥ n replicas (ACK ≠ fsync) | | max replica lag guard | rung 3: `replica_max_staleness_ms` — a stale replica answers `-STALE` instead of serving old reads | | refuse writes without standbys | rung 4: `min_replicas_to_write` (`-NOREPLICAS`) | | fencing / split-brain guard | rung 5: quorum lease — a partitioned primary fences its own writes | Failover: planned handover is the `FAILOVER` verb (quiesce → drain → promote — zero-loss); crash failover is the 3-node elect quorum (MTTR ≈ 5–8s, offset-best candidate wins so `WAIT`-acked writes survive). A rejoining ex-primary's un-replicated tail is **discarded** (the forked suffix was never acked by definition) — the same data-loss shape as losing un-shipped binlog in MySQL semi-sync, made explicit. Every degraded state is a named error (`-READONLY`, `-STALE`, `-NOREPLICAS`, `-QUIESCED`, `-MISDIRECTED`) that a routing client self-heals from ([error-replies](https://kevy.golia.jp/docs/error-replies/)). ## CDC The feed is kevy's binlog-as-API — what Debezium extracts from an RDS, served natively ([cdc](https://kevy.golia.jp/docs/cdc/)): | Debezium/binlog notion | kevy | |---|---| | binlog position / LSN | `(generation, offset)` cursor, per shard | | connector snapshot + stream | rebuild (SCAN your prefix) + resume from `FEED.TAIL` | | topic-per-table filtering | `PREFIX` filter on `FEED.READ` (fail-open for multi-key frames) | | at-least-once delivery | same — consumers must be idempotent | | transactional outbox pattern | **unnecessary**: every committed write already IS a change frame (cookbook recipe 11) | Not provided, deliberately: global cross-shard order (per-key order is guaranteed within a shard stream), server-side consumer positions, consumer groups. Retention is the in-memory backlog; falling behind answers `-FEEDRESYNC` with the cursor to rebuild from. ## What kevy will NOT do The refusals, in one place. Each is a charter decision ([designing-on-kevy](https://kevy.golia.jp/docs/designing-on-kevy/), the project's scope log) — not a roadmap gap: | Refused | Because | Use instead | |---|---|---| | SQL parser / query DSL | the planner slope starts here | explicit `IDX.*`/`VIEW.*` + this matrix | | query planner / auto index selection | engine must execute declared paths, not decide | `IDX.EXPLAIN` (diagnostic only) | | JOINs | no server-side plan search | denormalize / hydrate (`FIELDS`, `VIA`) / view | | `WHERE` without an index | accidental O(n) is a lie waiting to page you | declare the path or don't ship the query | | server-side constraint DSL / triggers | stored app logic isn't engine-genre | Lua `EVAL` (atomic unit) + CDC consumers | | DECIMAL type | no server-side arithmetic beyond i64/f64 | integer minor units | | JSON-path queries | hash fields ARE the column model | flatten to fields (cookbook recipe 9) | | `HAVING` / aggregate expressions | query-language slope | filter `GROUPS` output app-side | | `OFFSET` pagination | O(N) skip is an anti-pattern | cursors | | multi-database `SELECT n` | one keyspace per server | prefixes or separate instances | | AUTH / TLS | trust boundary is the network perimeter | loopback bind (default), sidecar proxy | | cross-DC active-active, CRDTs | single-DC charter | app-level federation | | dynamic membership / auto-replace / resharding | topology is operator-declared | config + rolling restart | | HTTP/REST API | RESP + MCP are the two access planes | any Redis client | ## Sizing and operational deltas **Everything is RAM-resident.** The working set that an RDS pages from disk lives in memory here; disk is the durability log + snapshot, not the serving tier. Capacity planning in one line: > RAM ≈ rows × (avg key len + avg row bytes + per-key overhead) > + Σ per-index formula + view members × entry size — then verify > against `MEMORY USAGE` / `IDX.LIST` bytes on a loaded sample. The per-subsystem formulas (each gated against measured RSS in CI): range index ≈ `rows × (value_width + avg_key_len + 48)`; text and ANN formulas in [text-search](https://kevy.golia.jp/docs/text-search/) / [vector-search](https://kevy.golia.jp/docs/vector-search/) (1M × 1024d vectors ≈ 4.1 GiB); agg ≈ groups-dominated ([indexes](https://kevy.golia.jp/docs/indexes/)); view members ≈ `order_value_width + key_len + 48` ([views](https://kevy.golia.jp/docs/views/)). Set `maxmemory` + an eviction policy or accept `-OOM` refusals (writes are rejected at admission — existing data is never corrupted). **Serving headroom.** The v3.18.0 release arena (kevy vs valkey 9.1, fair-fight protocol, median-of-5 — `bench/PERF-LEDGER.md`): GET 3.00×, SET 3.99×, INCR 3.00×, SADD 2.50×, HSET 2.25×, ZADD 1.73×, LPUSH 1.64× — 7/7 wins with the full replication/heartbeat pipeline landed. Against a disk-first RDS on point reads the gap is larger still; the honest comparison there is "kevy replaces the cache tier AND the operational queries", not a per-query benchmark. **Operational deltas vs an RDS**, in brief: single keyspace (no schemas/databases — prefixes are the namespace); TTLs are first-class (`EXPIRE`, per-field `HEXPIRE` — the cron-delete job disappears); `FLUSHALL` is one command away (perimeter-protect it); derived state (indexes, views, feeds) rebuilds after restart rather than loading (seconds per million rows — budget it into restart time); and the error surface is part of the contract — clients should match error prefixes, not parse messages ([error-replies](https://kevy.golia.jp/docs/error-replies/)). ## This page vs the cookbook This page is the **reference matrix** — SQL construct in, kevy shape out, deltas stated. The [cookbook](https://kevy.golia.jp/docs/cookbook/) is the **recipe book** — 20 runnable patterns (every command block CI-smoked), each tagged with the SQL construct it replaces and cross-linked back to the matrix rows above. Migrating for real? The phased playbook is in [migration](https://kevy.golia.jp/docs/migration/). ============================================================================== # persistence ============================================================================== # Persistence How kevy keeps data across restarts — the AOF, snapshots, fsync policies, rewrite/compaction, crash recovery, and the introspection that lets you watch all of it. ## When you need this Reach for this doc when you are: - Choosing a durability policy (zero-loss vs throughput) for a production deployment. - Sizing disk usage and replay-time budgets for a write-heavy workload. - Debugging an unexpected on-disk artifact — a quarantine file, a stale `.rewrite` temp, a `.premigration.*` backup. - Wiring an embedded `kevy_embedded::Store` into a host application and want to know what survives a process crash, what doesn't, and how to observe it from inside the host. - Looking at a key whose TTL behaves oddly across restarts. If you only want a quick "does it survive `kill -9`?" answer: yes, with at most one second of writes lost under the default policy. ## Core idea Every shard owns two files in the persistence directory: an append-only log of mutating commands (`aof-.aof`) and an optional binary snapshot (`dump-.rdb`). The AOF alone is a complete durable record; the snapshot exists only to bound replay time. On boot kevy loads the snapshot if present, then replays the AOF; on a successful snapshot the AOF is reset so the two files together cover the full history exactly once. The directory itself is created at boot if missing (v3.17); a path that cannot be created is one named startup error, not a bare ENOENT from whichever subsystem touches it first. ## Worked examples ### Server mode Drop this into `kevy.toml` and launch with `kevy --config kevy.toml`: ```toml # kevy.toml [server] data_dir = "/var/lib/kevy" port = 6379 threads = 4 [persistence] aof = true # AOF durability — see the knobs table below for the full set. appendfsync = "everysec" # always | everysec | no auto_aof_rewrite_percentage = 100 # rewrite when the AOF doubles since the last rewrite auto_aof_rewrite_min_size = "64mb" # …and is at least this big ``` Operate it with standard Redis-style commands over RESP: ```text $ redis-cli -p 6379 BGSAVE Background saving started $ redis-cli -p 6379 BGREWRITEAOF Background append only file rewriting started $ redis-cli -p 6379 INFO persistence aof_enabled:1 appendfsync:everysec aof_rewrite_in_progress:0 aof_rewrites_total:3 ``` `CONFIG SET appendfsync always` retunes the policy live without a restart. ### Embedded mode Add the crate to `Cargo.toml`: ```toml [dependencies] kevy-embedded = "*" ``` Then in `main.rs`: ```rust use std::time::Duration; use kevy_embedded::{AppendFsync, Config, KevyMetric, Store}; fn main() -> kevy_embedded::KevyResult<()> { let cfg = Config::default() .with_persist("/var/lib/myapp/kevy") .with_appendfsync(AppendFsync::EverySec) .with_auto_aof_rewrite(100, 64 * 1024 * 1024) .with_metric_sink(|m| match m { KevyMetric::Replay { commands, bytes, elapsed_ms } => { eprintln!("kevy replay: {commands} cmds / {bytes} B in {elapsed_ms} ms"); } KevyMetric::Rewrite { keys, before_bytes, after_bytes, elapsed_ms } => { eprintln!( "kevy rewrite: {keys} keys, {before_bytes} -> {after_bytes} B in {elapsed_ms} ms" ); } _ => {} }); let store = Store::open(cfg)?; store.set(b"hello", b"world")?; store.expire(b"hello", Duration::from_secs(300))?; // Point-in-time snapshot. Returns after the file is on disk; per-shard // locks are held only for the view freeze and the final rename. store.save_snapshot()?; // On-demand AOF compaction. Same lock discipline as save_snapshot. let _stats = store.rewrite_aof()?; // Live introspection. let info = store.info(); println!("{} keys, {} bytes AOF", info.keys, info.aof_bytes); Ok(()) } ``` A fresh embedded store with the default config writes only the AOF — no snapshot file appears until `save_snapshot` runs. That is expected; the AOF on its own is enough to rebuild the keyspace. ## Configuration knobs ### Durability and AOF growth | Knob | Server (TOML / `CONFIG SET`) | Embedded (`Config::…`) | Default | Notes | |---|---|---|---|---| | AOF fsync policy | `appendfsync` (`always` / `everysec` / `no`) | `with_appendfsync(AppendFsync::…)` | `EverySec` | Live-tunable on the server. | | AOF enabled | `aof` (`true` / `false`) | implied by `with_persist(...)` | `true` (server), off until `with_persist` | Disabling skips all on-disk persistence. | | Auto-rewrite percentage | `auto_aof_rewrite_percentage` | first arg of `with_auto_aof_rewrite(pct, min)` | `100` | `0` disables auto-rewrite. | | Auto-rewrite minimum size | `auto_aof_rewrite_min_size` | second arg of `with_auto_aof_rewrite(pct, min)` | `67108864` (64 MiB) | Auto-rewrite fires only when both thresholds are met. | | Persistence directory | `data_dir` / env `KEVY_DIR` | `with_persist(path)` | `./data` (server); none (embedded) | One directory per kevy instance. | | Reactor / reaper cadence | reactor tick, ~100 ms | background reaper, or your `Store::tick` calls | ~100 ms | Drives `EverySec` flush, auto-rewrite checks, TTL eviction. | ### Trigger surface | Action | Server | Embedded | Blocking shape | |---|---|---|---| | Synchronous snapshot | `SAVE` | `Store::save_snapshot()` | Returns after the file is on disk; locks held only for freeze + rename. | | Background snapshot | `BGSAVE` | call `save_snapshot` from a worker thread | Returns immediately; commit lands within one reactor tick of the disk write finishing. | | AOF rewrite | `BGREWRITEAOF` | `Store::rewrite_aof()` | Returns after the atomic rename; serialization runs with the keyspace live. | | Live-tune fsync | `CONFIG SET appendfsync everysec` | rebuild `Config` | n/a | | Graceful shutdown | `SHUTDOWN [SAVE\|NOSAVE]` (or SIGTERM) | drop the last `Store` clone | Drains every shard: in-flight persist jobs land, the AOF tail is force-fsynced, then the process exits. `SAVE` additionally takes one final snapshot per shard. No reply is sent — the client observes the connection closing (Redis behavior). | ### fsync policy semantics | Policy | Durability | Cost | |---|---|---| | `Always` | Zero-loss — every write fsynced before its reply | ~50% throughput | | `EverySec` (default) | At most ~1 second of writes lost on a crash | Cheap | | `No` | Defers to the OS pagecache flush | Cheapest | ## Trade-offs and limits **Per-policy throughput vs data loss.** `Always` blocks each reply on `fsync`; it is the only policy that survives `kill -9` with zero command loss, and it cuts SET-heavy throughput roughly in half on typical NVMe. `EverySec` runs a background flush every second and loses up to that window on a crash — the default precisely because it matches the Redis trade and the lost window is usually tolerable. `No` lets the kernel decide; throughput is highest but a crash can lose anything still in pagecache, potentially many seconds. **AOF replay cost vs snapshot load cost.** Without a snapshot, boot time grows linearly with the AOF byte count: a 4 GiB AOF replays in a few seconds on local NVMe, a 40 GiB one in a minute or more. A snapshot caps that — load is one streaming read plus a short tail of post-snapshot AOF — but costs a transient view freeze (O(keys), nanoseconds per key, because collection values are refcount-shared) plus a one-time copy of any collection first mutated while the snapshot is in flight. For write-heavy workloads, prefer leaning on auto-rewrite to keep the AOF bounded rather than running periodic `BGSAVE`s: rewrite gives you the same boot-time bound with no second file to manage. **Background-job concurrency.** Each shard runs at most one background save or rewrite at a time. A duplicate request that arrives mid-job is skipped with a log line, never queued. **TTL persistence.** TTLs are written as absolute Unix-millisecond deadlines (`PEXPIREAT` in the AOF, an absolute field in the snapshot format), so a key keeps its original expiry instant across any number of restarts and the time the process spent down is subtracted correctly. Older AOFs that recorded relative remaining time still load (treated as relative on entry); new writes are always absolute. `EXPIREAT` and `PEXPIREAT` are exposed as client commands. **Shard-layout changes are crash-idempotent.** Changing `--threads` / `shards` writes new snapshots under `.reshard` temp names, commits via a durable `reshard.journal`, and rolls an interrupted migration forward on the next start. Source files survive as `.premigration.` backups; the journal is the commit point and must never be deleted by hand. **What is not persisted.** Pub/sub channels, subscriptions, and undelivered messages live only in memory. Blocking-command waiters such as `BLPOP` and blocking `XREAD` are connection state, not data. Neither is written to the AOF or snapshot, and neither is replayed. ## FAQ ### My AOF file is growing — how do I compact it? Run `BGREWRITEAOF` on the server or `Store::rewrite_aof()` in embedded mode. Rewrite rebuilds the log as the minimal command set that reconstructs the current keyspace — one `SET` / `HSET` / etc. per key, plus a `PEXPIREAT` for TTL'd keys — and atomically swaps the new file in. Ten thousand overwrites of `hot` collapse to a single `SET hot `. For unattended ops, leave auto-rewrite at its defaults — 100% growth over the previous rewrite size, with a 64 MiB floor — and the reactor will fire compaction on its own. Set `auto_aof_rewrite_percentage = 0` to disable it and drive rewrite entirely by hand. Rewrite is non-blocking for the keyspace: serialization and `fsync` run with reads and writes flowing, and any writes that land during the rewrite are tee'd into a diff buffer that gets appended to the compacted image. If a rewrite crashes midway the original AOF is untouched (the swap is an atomic `rename`) and the leftover `aof-.aof.rewrite` temp is safe to delete. ### Can I disable persistence entirely? Yes, in two ways: - **Server:** set `appendonly = false` in `kevy.toml` (or omit `--dir`). The server runs as a pure in-memory cache; no `aof-*` or `dump-*` files are created. - **Embedded:** build a `Config` without calling `with_persist(...)`. `Store::open` runs the keyspace entirely in memory; `save_snapshot` and `rewrite_aof` become no-ops at the API surface (or surface an error indicating no persistence directory is configured). If you want persistence but no AOF growth at all between snapshots, that combination is not supported — kevy's durability model is AOF-first, and the snapshot exists to bound AOF replay, not to replace the AOF. ### What is the cost of a snapshot during high write load? The blocking portion is tiny. A per-shard freeze of the keyspace is O(keys), not O(bytes), because collection values are reference-counted and shared with the live store; on a million-key shard the freeze takes single-digit milliseconds. Serialization itself runs with the keyspace live — writes are not paused. The transient cost you pay is memory. Any collection (list, hash, set, sorted-set) that gets mutated while the snapshot is being written gets cloned once, so the live store can move on without disturbing the frozen view. For workloads dominated by `SET` on plain string keys the extra memory is negligible; for workloads dominated by `HSET` / `LPUSH` on a small number of huge collections it can briefly double the resident size of those specific collections. A successful snapshot also resets the AOF — the snapshot now carries everything the log used to, and the log restarts with only writes that landed after the freeze. A restart then loads snapshot + log without ever double-applying history. ### How is recovery sequenced on the next boot? For each shard, in order: 1. **Load the snapshot.** If `dump-.rdb` exists, stream it into the keyspace. Expired TTLs are dropped during load. 2. **Replay the AOF.** Read `aof-.aof` from the front and apply each frame. 3. **Handle the tail.** A clean file applies in full. A truncated tail (crash mid-append) drops the partial trailing frame and applies the prefix. A corrupt frame moves the bad bytes aside to `aof-.aof.panic-quarantine.` so they don't block future starts, then applies the prefix. The quarantined tail is never re-applied; inspect it by hand if you need to recover anything from it. 4. **Log a one-line summary** including wall-clock time: ```text kevy: AOF /data/kevy/aof-0.aof replayed 145313 commands from 418261733 bytes in 247 ms (clean) ``` 5. **Roll forward any interrupted shard-layout migration** by replaying `reshard.journal`. Watch the replay-time line and use auto-rewrite to keep it bounded — replay time grows linearly with the unrewritten AOF size. ### How do I monitor persistence from inside an embedded host process? Two surfaces. **Polling.** `store.info()` returns a `KevyInfo` struct with `keys`, `used_memory`, `aof_bytes`, `expire_pending`, `evictions`, `expired_keys`. Finer-grained helpers cover the same ground: ```rust store.dbsize(); // live key count store.ttl(key); // Option (None = no key / no TTL) store.ttl_ms(key); // Redis PTTL semantics: -2 no key, -1 no TTL, else ms store.expire_pending_count(); // live keys carrying a TTL store.used_memory(); // resident-bytes estimate store.expired_keys_total(); // total expired (lazy + reaper) store.evictions_total(); // total evicted by maxmemory ``` `expire_pending_count() == 0` when you expected TTLs is the classic tell that the TTL subsystem didn't register your keys. **Push.** Register `Config::with_metric_sink(...)` and receive `KevyMetric` events on AOF replay (startup) and each AOF rewrite (compaction). The sink runs synchronously on the emitting thread (the reaper for background rewrites), so keep the callback fast. `KevyMetric` is `#[non_exhaustive]` — always match a `_` arm to stay forward-compatible. ### What is every file in the persistence directory? | Pattern | Meaning | |---|---| | `aof-.aof` | Live AOF for shard ``. | | `dump-.rdb` | Binary snapshot for shard ``. | | `shards.meta` | Recorded shard count and routing scheme. | | `dump-.rdb.tmp` | In-progress snapshot write. Safe to delete if stale. | | `aof-.aof.rewrite` | In-progress AOF rewrite/reset. Safe to delete if stale. | | `dump-.rdb.reshard` + `reshard.journal` | In-progress shard-layout migration. Rolled forward on next start; never delete the journal by hand. | | `*.premigration.` | Pre-migration source backups, kept for rollback. | | `aof-.aof.panic-quarantine.` | Corrupt AOF tail set aside during recovery. Inspect by hand if you need to salvage anything; kevy will not re-apply it. | | `elect.meta` (+ transient `elect.meta.tmp`) | Election durability (v3.15): the elector's `(epoch, votedFor)` pair, persisted *before* any vote answer leaves the node so a crash-restart can never double-vote. Written tmp + fsync + rename — a crash mid-save leaves the old pair or the new pair, never a torn file. Only present with a `[cluster]` quorum configured. | ## Durability contract (v2.1) What "the call returned OK" guarantees, per `appendfsync` × write path. "Durable" = on stable storage (`fdatasync` completed); "windowed" = in the OS page cache, lost only if the *machine* (not just the process) dies inside the window. | Write path | `always` | `everysec` | `no` | |---|---|---|---| | Server command reply | durable before the reply leaves the shard (group-committed per batch) | windowed ≤ 1 s | OS-paced | | Embedded facade op (`set`, `zadd`, …) | durable on return | windowed ≤ 1 s | OS-paced | | Embedded `atomic` / `atomic_all_shards` block | durable on commit (one fsync per touched shard) | windowed ≤ 1 s | OS-paced | | Embedded `Pipeline::commit` | durable on return, fsyncs batched per shard | windowed ≤ 1 s | OS-paced | | …any of the above + **`Store::fsync_aof()`** | no-op | **durable at the barrier** | **durable at the barrier** | `Store::fsync_aof()` is the per-write durability escape hatch (Postgres `synchronous_commit`-per-transaction genre): run a deployment on `everysec` for throughput, and place the barrier after the few writes that must survive a machine crash the moment they are acknowledged. Cost: one `fdatasync` per dirty shard. Process crash (SIGKILL) never loses acknowledged writes under `always` and loses at most the fsync window otherwise; the AOF tail is replayed on the next open, and a torn final frame is quarantined (`panic-quarantine`), never silently applied. An **orderly stop** (`SHUTDOWN` or SIGTERM) loses nothing under any policy: the drain force-fsyncs the AOF tail before exit, so the `everysec` window that a crash can lose does not apply to a clean shutdown. ## Atomicity charter (embedded serving-store, v2.1) - **`Store::atomic(body)`** — single-shard transaction: takes that shard's write lock for the closure, reads inside see the closure's own writes, AOF appends are deferred and committed with **one fsync at commit** (under `always`). Every key touched must hash to the same shard — the blessed serving-store config is therefore **1 shard** when your write patterns span arbitrary keys: you keep full atomicity and pay no cross-shard coordination. The ceiling of the 1-shard config is single-core write throughput; measured numbers live in `bench/REPORT.md`. - **`Store::atomic_all_shards(body)`** — multi-shard transaction: acquires **every** shard's write lock in shard-index order (deterministic order = no deadlock), commits per-shard AOF batches on return. Cost: blocks all other readers + writers for the closure's duration — use it for cross-shard invariants, not as the default write path. - **`Store::pipeline()`** — NOT atomic: each op takes its own lock; other writers interleave. It batches fsyncs (N ops → ≤ shard-count fsyncs), nothing more. - Both atomic forms log the **effect** of conditional ops (`ZADD GT`, `SPOP`) as unconditional verbs, so replay and replica-apply are deterministic by construction. ## Recovery points (v2.3) With the change feed enabled (`[feed] enabled = true`, see [cdc.md](https://kevy.golia.jp/docs/cdc/)), every snapshot records the feed cursor it was taken at — frozen in the same no-append window as the snapshot data itself. That yields the recovery-point contract: > **snapshot S + the feed frames from S's recorded cursor = the exact > state at any later cursor.** `kevy_persist::read_snapshot_cursor(path)` reads the cursor back (`None` for pre-v2.3 snapshots — format v4 and older carry no cursor and remain fully loadable). The executable form of the contract is `bench/restore-drill.sh`, run as a `diskgate` line: write → SAVE → write more → kill → restore the dumps alone → replay the captured feed frames → byte-exact key-by-key verification. Scope note: the feed window is the in-memory backlog. Frames older than the window are gone — a snapshot older than the window's reach is a plain snapshot restore (state at S), not a PITR base. Take snapshots at least as often as the window turns over if you rely on exact-point recovery. ## The snapshot on the replication wire (v3.15) The same snapshot format is what a primary in-line-ships to a replica that has fallen past the backlog window (see [replication.md](https://kevy.golia.jp/docs/replication/)). One semantic worth knowing: a shipped snapshot **replaces** the replica's local state, it does not merge — the replica flushes its keyspace before loading. That is deliberate: when a rejoining ex-primary carries a forked suffix (writes that were never replicated), the resync must genuinely discard the fork rather than leave it as residue under an upsert-only load. ============================================================================== # tuning ============================================================================== # Tuning kevy A reference for the runtime knobs that change kevy's per-op cost — CPU layout, reactor choice, persistence, memory limits, network transport, and a few Linux-side levers. ## When you need this Reach for this doc when: - A benchmark is showing kevy under your throughput or latency target and you want to know which knob to turn next. - You are deploying kevy on a host where the defaults (TCP loopback, io_uring auto-detect, `appendfsync everysec`, no `maxmemory`) do not match the workload — e.g. sparse-conn services, NVMe-backed durability requirements, or memory-capped cache tiers. - You are profiling kevy with `perf` and need the build profile that keeps debug line tables on. If you are just starting kevy on a laptop and the numbers look fine, you do not need this page. Defaults are tuned to be reasonable across workloads. ## Core idea kevy is a thread-per-core server: one shard per OS thread, shared-nothing keyspace partitioned by CRC16 hashtag, busy-poll reactor on each shard. The defaults aim at "decent on every workload"; tuning means matching the shard count, the reactor, and the persistence policy to **what your perf data actually shows is the bottleneck**. Do not pre-emptively flip knobs. Measure, identify the cost, then change one variable at a time. ## Tuning playbook ### CPU and shards | Knob | Where | Default | Effect | |------|-------|---------|--------| | `--threads N` / `KEVY_THREADS` | CLI / env | number of online cores | shard count; one OS thread per shard | | `--accept-shards K` | CLI | all shards accept | only the first K shards bind a listener; the rest are compute-only | | CPU pinning | `taskset` / `numactl` | none | locks shards to a fixed core set | **Picking `--threads`.** Set this to the parallelism actually present in the workload. A single-client pipelined benchmark (`-c 1 -P 16`) saturates one shard; setting `--threads 10` here makes nine shards busy-poll for no work and steal cache lines from shard 0. For real multi-client workloads, start at `min(cores, expected concurrent clients / 4)` and measure. **Picking `--accept-shards`.** When the connection-to-shard ratio is low (sparse-conn workloads — say, 50 clients across 10 shards = 5 conns/shard), the per-iteration busy-poll overhead stops amortizing and throughput drops. The rule of thumb is `ceil(conns / 20)` — for 50 conns, set `--accept-shards 3` and let three listening shards each take roughly 17 connections while the remaining shards stay compute-only and still receive cross-shard work via the internal dispatcher. The empirical sweet spot is broader than the point estimate; see [docs/accept-shards.md](https://github.com/goliajp/kevy/blob/develop/docs/accept-shards.md) for the full sweep and a discussion of when the cross-shard hop tax outweighs the accept-concentration win. **CPU pinning.** On a benchmark or single-tenant host, pinning kevy to a fixed core set keeps the NIC IRQ → softirq → user-thread path on the same L1/L2: ```sh taskset -c 0-9 kevy --port 6004 --threads 10 ``` If the client runs on the same machine, pin server and client to **disjoint** core ranges (server `0-9`, client `10-15`). Shared cores reintroduce scheduler ping-pong that swamps any reactor gain. ### Reactor choice | Platform | Default | Override | |----------|---------|----------| | Linux ≥ 5.19 | io_uring (auto-detected) | `KEVY_IO_URING=0` forces epoll; `KEVY_IO_URING=1` requires io_uring and exits loudly if `io_uring_setup` is blocked by seccomp | | macOS / *BSD | kqueue | not configurable | | Older Linux | epoll | n/a | The Linux auto-detect runs `io_uring_setup` at startup; if the syscall is blocked (seccomp profile, locked-down container) kevy silently falls back to epoll. In a hardened deployment that you *want* to fail loudly rather than silently degrade, set `KEVY_IO_URING=1` so the server refuses to start unless io_uring is actually available. Conversely, when you need to take io_uring out of the picture for a reproducible epoll-vs-io_uring benchmark or to work around a kernel regression, set `KEVY_IO_URING=0`. ```sh KEVY_IO_URING=1 kevy --port 6004 # require io_uring, exit if blocked KEVY_IO_URING=0 kevy --port 6004 # force epoll ``` ### Persistence AOF policy is controlled by `appendfsync` (config file or `CONFIG SET`). The three values match Redis semantics: | `appendfsync` | Durability | Cost | |---------------|------------|------| | `always` | every write `fsync`-ed before reply | highest latency; bounded by NVMe sync latency | | `everysec` (default) | `fsync` once per second on a background thread | bounded data loss window of 1 s; near-zero hot-path cost | | `no` | never `fsync`; kernel flushes on its own schedule | fastest; data loss window = page-cache flush interval | The background `fsync` for `everysec` runs on a dedicated bio thread off the shard hot path, so shard tail latency is not coupled to disk latency. For a pure cache or a read-replica, also consider disabling AOF entirely with `--no-aof` (no AOF file is written at all, not even buffered). ### Memory | Knob | Default | What it does | |------|---------|--------------| | `maxmemory` | unlimited | hard memory cap in bytes; once reached, the eviction policy kicks in | | `maxmemory-policy` | `noeviction` | which keys to drop when the cap is hit | | `maxmemory-samples` | 5 | sample size for the approximate-LRU/LFU policies | Eviction policies mirror Redis: `noeviction`, `allkeys-lru`, `allkeys-lfu`, `allkeys-random`, `volatile-lru`, `volatile-lfu`, `volatile-random`, `volatile-ttl`. `noeviction` makes writes fail with OOM once the cap is hit and is the safe default for a primary store; the `allkeys-*` policies are correct for a cache tier where any key is disposable. `maxmemory-samples` is a quality-vs-cost dial for the approximate policies — sampling more keys produces a closer approximation to true LRU/LFU at a per-eviction CPU cost. The default of 5 is sufficient for most cache workloads; raise to 10 if you can see eviction picking poor victims in your access pattern, lower to 3 only if eviction itself is showing up in profiles. ### Network The default transport is TCP. When the client lives on the same host, switch to a Unix-domain socket and skip the loopback TCP stack entirely: ```sh KEVY_UNIX_SOCKET=/tmp/kevy.sock kevy --port 6004 redis-cli -s /tmp/kevy.sock SET foo bar ``` The server dual-binds: TCP stays available for remote clients, UDS handles local ones. Same RESP semantics, same shard runtime. The gain on local-client workloads is large (the loopback TCP path is the dominant cost at small payload sizes); see [docs/uds.md](https://github.com/goliajp/kevy/blob/develop/docs/uds.md) for the full numbers, the permissions model, and the cases where UDS does not apply. **Bind address warning.** kevy has no AUTH and no TLS today. Binding to a non-loopback address (`--bind 0.0.0.0` or any public interface) prints a startup warning, because anything on the network can then issue commands. Run kevy behind a private network boundary or behind a proxy that terminates auth. **Connection introspection.** `INFO clients` reports a live `connected_clients` gauge summed across all shards. `CLIENT LIST` / `CLIENT INFO` render one Redis-7.x-shaped row per real client connection — peer address, a globally unique `id`, `name`, subscription counts, MULTI queue depth, input/output buffer sizes (`cmd=NULL`: the last-command name is not tracked). `CLIENT SETNAME` labels the connection for LIST; `CLIENT KILL ID | ADDR | LADDR ` (or the legacy positional `CLIENT KILL `) closes every matching connection, including ones parked in blocking commands. Teardown waits for the victim's pending output to drain, so a connection that kills itself still receives its own reply. ### Replication and availability Only relevant when running a primary/replica topology ([docs/replication.md](https://github.com/goliajp/kevy/blob/develop/docs/replication.md), [docs/availability.md](https://github.com/goliajp/kevy/blob/develop/docs/availability.md)). **Port layout.** Each node uses three planes, all derived from the client port by default: | Plane | Port | Notes | |-------|------|-------| | client RESP | `port` (e.g. 6004) | what clients and `peers` client-ports refer to | | replication | `listen_port_base + shard_i`; default base = `port` + 10000 | `nshards` consecutive ports; replicas bind this range too (v3.15) | | election | `elect_port_base`; default = `port` + 200 | one control-plane listener per node | Co-hosting several instances on one machine: keep client ports at least `nshards` apart, or the default replication ranges collide. `FAILOVER` and automatic retarget assume the `port + 10000` convention — leave `listen_port_base` at its default in failover-enabled deployments. **Consistency knobs.** Two `[replication]` keys trade availability for stronger guarantees (the full ladder is [docs/availability.md](https://github.com/goliajp/kevy/blob/develop/docs/availability.md)): | Knob | Default | What it does | |------|---------|--------------| | `replica_max_staleness_ms` | `0` (off) | a replica whose last primary heartbeat is older than the bound refuses reads with `-STALE`; heartbeats ride the stream at 1 Hz, so bounds below ~2 s trip on healthy links | | `min_replicas_to_write` | `0` (off) | the primary refuses writes with `-NOREPLICAS` when fewer than N replicas are healthy | Per-call barriers cost one blocked call instead of a standing config: `WAIT n timeout` on the primary, `REPL.TOKEN` + `REPL.WAIT` for read-your-writes on a replica. Both interpret `timeout 0` as "wait forever", hard-capped at 60 s. ### Linux kernel knobs Two host-level levers move the kernel floor that sits underneath kevy. Both are benchmark / single-tenant-only — read the trade-offs before applying. **Spectre / BHB mitigations.** On Linux 6.x kernels with mitigations enabled (the default), every syscall pays for `clear_bhb_loop` and friends. On a small-payload `-c 1` workload this is the single largest CPU consumer in a kevy run. Disabling mitigations at the kernel cmdline: ```sh # Add `mitigations=off` to GRUB_CMDLINE_LINUX_DEFAULT, then: sudo update-grub && sudo reboot cat /proc/cmdline | grep mitigations ``` is only acceptable on single-tenant boxes where no untrusted code runs (no Lua-from-the-wire, no third-party plugins, no multi-tenant containers). Do not apply to multi-tenant hosts, shared CI runners, or anything processing untrusted user code. The gain is in the +10–15% range on `-c 1`, smaller as the workload pipelines more. **Hugepages for the `.text` segment.** kevy can call `madvise(MADV_HUGEPAGE)` on its own code segment, which lets the kernel back the kevy binary's instructions with 2 MiB pages instead of 4 KiB. The win is a smaller iTLB footprint on the hot dispatch loop. This costs effectively nothing at runtime and is worth enabling on Linux hosts where `/sys/kernel/mm/transparent_hugepage/enabled` is `always` or `madvise`. The trade-off is purely the small one-time cost of the `madvise` call at startup; there is no security trade-off, unlike `mitigations=off`. ## Profiling For a `perf record` flamegraph that resolves to actual symbols, build with the `release-perf` profile — same optimization level as `release` but with debug line tables retained: ```sh cargo build --profile release-perf ./target/release-perf/kevy --port 6004 --threads 1 & KEVY_PID=$! perf record -F 999 -p $KEVY_PID -g --call-graph=fp -- sleep 30 perf report --stdio | head -60 # Resolve raw addresses for inlined symbols: addr2line -e ./target/release-perf/kevy -f -i 0x ``` The standard `release` profile strips line tables, so `perf` reports raw addresses with no symbols and `addr2line` returns `??`. Don't profile a `release` binary; rebuild with `release-perf` first. For symbol-level attribution of `clear_bhb_loop` and other kernel-side cost, capture with `--call-graph=dwarf` instead of `fp` and use the same `addr2line` flow. The dwarf unwinder is slower but unwinds across the syscall boundary correctly. ## Trade-offs | Knob | Costs | Buys | |------|-------|------| | `--threads N` (raise) | wasted CPU on idle busy-poll shards if N > workload parallelism | more concurrent client capacity | | `--threads N` (lower) | one shard's worth of cross-shard hop tax avoided | less wasted CPU on sparse-conn workloads | | `--accept-shards K` | listener concentration; fewer entry points if clients connect via raw `connect` | per-iter overhead amortizes across more conns on each accepting shard | | `KEVY_IO_URING=1` (force) | server refuses to start when seccomp blocks io_uring | no silent degradation to epoll on hardened hosts | | `KEVY_IO_URING=0` (force epoll) | gives up io_uring's per-op saving | reproducible epoll baseline; works around kernel regressions | | `appendfsync always` | every write blocks on `fsync` | zero-data-loss durability | | `appendfsync no` | data loss window = page-cache flush interval | fastest write path | | `--no-aof` | no persistence at all | minimum disk I/O; useful for replicas / caches | | `maxmemory` set | writes can fail (`noeviction`) or evict (`allkeys-*`) | bounded memory footprint | | `maxmemory-samples` raise | per-eviction CPU cost | better approximate-LRU/LFU victim choice | | Unix-domain socket | local-only; filesystem-permission security model | skips the TCP loopback stack | | `replica_max_staleness_ms` set | reads on a lagging replica fail (`-STALE`) until it catches up | bounded read staleness | | `min_replicas_to_write` set | write availability coupled to replica health (`-NOREPLICAS`) | no writing into the void | | `mitigations=off` | Spectre / Meltdown / MDS / etc. mitigations all off | reclaims the syscall-path tax | | `MADV_HUGEPAGE` on `.text` | none meaningful | smaller iTLB footprint on the dispatch loop | | `release-perf` build | larger binary (debug line tables) | `perf` resolves to symbols | ## FAQ **Should I always set `--accept-shards`?** No. The knob exists for sparse-conn workloads where conns/shards is low and the busy-poll body fails to amortize. For dense-conn workloads (say, 1000 clients on 10 shards = 100 conns/shard), the default — every shard accepts — is correct, because spreading the listener evenly reduces accept-side contention. Apply `ceil(conns / 20)` only when you actually have a sparse-conn case. **Is io_uring always faster than epoll?** On Linux ≥ 5.19 with a workload that batches submissions, yes, materially. On older kernels, on kernels with seccomp filters that block `io_uring_setup`, or on workloads dominated by a single syscall per op with no batching opportunity, the difference shrinks. Auto-detect is the right default; override only when you have a measured reason or a hardened deployment that should fail loudly rather than silently fall back. **What's the production sweet spot for `appendfsync`?** `everysec` for almost everyone. It bounds data loss to one second, runs the `fsync` off the hot path, and has near-zero impact on tail latency. Use `always` only when your durability story actually requires zero data loss (and accept that NVMe `fsync` latency now bounds your tail latency). Use `no` only for pure caches where the AOF exists just for warm-restart speed. **When do I need `MADV_HUGEPAGE`?** When `perf` shows iTLB misses on the hot dispatch loop, or when the host's `/sys/kernel/mm/transparent_hugepage/enabled` is set to `madvise` (in which case nothing else opts kevy in). It's a no-cost knob on Linux hosts where THP is enabled at all, so the default position is "leave it on." There is no equivalent on macOS / BSD. **My `perf` report is full of raw addresses. What did I do wrong?** You profiled a `cargo build --release` binary. The standard release profile strips debug line tables, so `perf` and `addr2line` have nothing to resolve against. Rebuild with `cargo build --profile release-perf` and re-record. ============================================================================== # error-replies ============================================================================== # Error reply catalog A lookup table of every wire-level error reply kevy emits, what triggers it, and what the client should do next. ## How to read this kevy errors travel over RESP as a simple-error string: `- \r\n`. The first whitespace-separated token is the **prefix** (`ERR`, `WRONGTYPE`, `MOVED`, `CROSSSLOT`, ...). Client libraries typically surface this as an error variant or exception whose `.kind` / `.code` / class name matches the prefix; the rest of the line is a human-readable message. This catalog is grouped by prefix. If you handle errors structurally (recommended), match on the prefix; the trailing message is intended for logs and operators, not for parsing. Errors are part of kevy's user-facing contract. Adding, renaming, or repurposing a prefix is a breaking change for clients that pattern-match. ## Core reference | Prefix | Triggers when | Recovery / next step | |--------|---------------|----------------------| | `-ERR unknown command ''` | Command name is not implemented or not recognized. | Check README command coverage; verify spelling. | | `-ERR wrong number of arguments for '' command` | argc doesn't match the command's accepted shapes. | Re-issue with the documented argument count. | | `-ERR value is not an integer or out of range` | INCR/DECR-class command on a value that isn't a parsable i64, or numeric arg out of range. | Use `SET` to overwrite with a parsable integer; clamp the input. | | `-ERR no such key` | RENAME / RENAMENX / COPY / GETEX targets a key that does not exist. | Treat the key as absent (use `EXISTS` to pre-check if needed). | | `-ERR kevy only supports DB 0` | `SELECT N` issued with N ≠ 0. | kevy has no multi-DB; use separate instances or namespaced keys. | | `-ERR MULTI calls can not be nested` | `MULTI` sent while already inside a MULTI block. | Wait for `EXEC` / `DISCARD` before opening another transaction. | | `-ERR EXEC without MULTI` | `EXEC` sent with no open transaction. | Pair with `MULTI`, or drop the command. | | `-ERR DISCARD without MULTI` | `DISCARD` sent with no open transaction. | Pair with `MULTI`, or drop the command. | | `-ERR WATCH inside MULTI is not allowed` | `WATCH` sent inside a MULTI block. | Issue `WATCH` before `MULTI`. | | `-ERR not allowed inside MULTI` | A command that isn't queue-safe (pub/sub, `WATCH`, `HELLO`, `RENAME`) was queued inside MULTI. | Issue these outside the transaction. | | `-ERR Protocol error` | Inbound bytes are not valid RESP. | Reconnect and retry; if persistent, audit the client serializer. | | `-ERR CONFIG SET failed for '': ` | Unknown CONFIG field or value out of range. | `CONFIG GET *` to see supported fields and current values. | | `-ERR CONFIG REWRITE could not write : ` | Config TOML path is missing or not writable. | Check `--config` path and filesystem permissions. | | `-WRONGTYPE Operation against a key holding the wrong kind of value` | Command run against an existing key of a different Redis type. | See [Wrong-type rules](#wrong-type-rules). | | `-EXECABORT Transaction discarded because of previous errors.` | A command queued during MULTI was an unknown verb or had too few arguments; EXEC refuses the batch and runs nothing. | Fix the offending queued command, then `MULTI` / queue / `EXEC` again. | | `-MOVED ` | Key's hash slot is not owned by this node. | See [Cluster-routing replies](#cluster-routing-replies). | | `-CROSSSLOT Keys in request don't hash to the same slot` | Multi-key command spans more than one hash slot. | See [Cluster-routing replies](#cluster-routing-replies). | | `-MISDIRECTED writer is ` | Write landed on a node that doesn't own this key's scope, or `REPL.WAIT` could not serve read-your-writes on this replica (timeout or generation mismatch). | See [Cluster-routing replies](#cluster-routing-replies); for `REPL.WAIT`, read the primary at ``. | | `-QUIESCED migrating to ` | The slot or scope is mid-migration and frozen on this node, or the node is mid-`FAILOVER` handover to ``. | See [Cluster-routing replies](#cluster-routing-replies). | | `-OOM command not allowed when used memory > 'maxmemory'` | Write-class command with policy `noeviction` after the limit is exceeded. | Raise `maxmemory`, set an eviction policy (e.g. `allkeys-lru`), or `DEL` to free room. Existing data is intact. | | `-READONLY You can't write against a read only replica.` | Write command sent to a replica node. | Send to the primary, or use a routing client. | | `-NOREPLICAS Not enough good replicas to write.` | Primary with `min_replicas_to_write = N` sees fewer than N healthy replicas. | Back off and retry; restore the replicas (see [docs/availability.md](https://github.com/goliajp/kevy/blob/develop/docs/availability.md)). | | `-NOREPLICAS primary lost quorum; writes fenced` | An elect-quorum primary cannot reach a strict majority of its peers (partition minority side); writes self-fence for the lease window. | Back off and retry — the fence lifts when the partition heals, or the majority elects a new primary your routing client will find. | | `-STALE replica is stale; read the primary or raise replica_max_staleness_ms` | Read sent to a replica whose last primary heartbeat is older than its `replica_max_staleness_ms` bound. | Read the primary until the replica catches up, or raise/disable the bound. | | `-READONLY can't write against a read-only script` | Script was evaluated via `EVAL_RO` / `EVALSHA_RO` and attempted a write. | Use the writable `EVAL` / `EVALSHA` variant. | | `-NOSCRIPT No matching script. Please use EVAL.` | `EVALSHA ` requested a script not in the cache. | Call `EVAL` directly (kevy auto-caches) or `SCRIPT LOAD` first. | | `-LOADING kevy is loading the dataset in memory` | Read sent to a replica while it is receiving a full-resync snapshot from its primary. | Wait and retry (the window is bounded by the snapshot ship); `PING`, `INFO`, and `HELLO` are answered during loading, so health checks and monitoring keep working. | | `-ERR No such client address in the list` | Legacy-form `CLIENT KILL ` matched no connection. | List live connections with `CLIENT LIST` and re-issue with an existing `addr`; the filtered form (`CLIENT KILL ID\|ADDR\|LADDR …`) returns a count of 0 instead of erroring. | ### Prefixes kevy never emits By design, kevy does not authenticate or authorize at the protocol layer; these Redis-compatible prefixes are deliberately absent: - `-NOAUTH` — no `AUTH` command surface. - `-WRONGPASS` — no password check. - `-NOPERM` — no ACL system. - `-MISCONF` — a persistence failure (AOF append or background save) is logged to stderr and the node keeps serving from its consistent in-memory state; kevy does not turn a durability error into a client-visible reply. On restart it replays whatever reached disk. - `-BUSY` — kevy has no interactive `SCRIPT KILL`. A script is instead bounded by an instruction budget (`[lua] time_limit_ms`, `0` = unlimited); an over-budget script is aborted in place and its `EVAL` returns an ordinary `-ERR` Lua error, so a runaway script never wedges the shard for another client to interrupt. If your client expects these to be possible, treat them as unreachable on kevy. Access control is delegated to the deployment perimeter (kevy binds `127.0.0.1` by default). ## Wrong-type rules A `WRONGTYPE` reply means the key already exists with a different Redis data type than the command expects. The rules: - Type is decided at key creation and persists until the key is deleted (or expires). - `DEL ` followed by the original command will succeed (you've reset the type). - `EXPIRE` / `PERSIST` / `TYPE` / `OBJECT ENCODING` / `EXISTS` / `DEL` / `UNLINK` are type-agnostic and never raise `WRONGTYPE`. - `WRONGTYPE` is never returned for a missing key; missing-key semantics follow each command's documented behavior (`GET` returns nil, `LPUSH` creates the list, and so on). Recovery is always one of: pick a different key, or `DEL` the existing one and re-create with the intended type. ## Cluster-routing replies These prefixes only appear when kevy is running in a routed mode (cluster or scoped). A non-cluster client may never see them. - **`-MOVED `** — Fires when a key's hash slot is permanently owned by another node. The client should reconnect to `` and re-issue. Cluster-aware clients (e.g., `redis-cli -c`, `ioredis` in cluster mode) follow the redirect transparently and update their slot map. - **`-CROSSSLOT Keys in request don't hash to the same slot`** — Fires on multi-key commands (`MGET`, `MSET`, `DEL` with multiple keys, `EVAL` with multiple `KEYS`, `SUNIONSTORE`, etc.) when the keys do not all hash to the same slot. Co-locate the keys with a shared `{hashtag}` segment, or split the command into per-slot batches client-side. - **`-MISDIRECTED writer is `** — Fires on a write that landed on a node that does not own the key's scope. Routing clients follow the redirect; manual clients should reconnect to ``. - **`-QUIESCED migrating to `** — Fires while a slot or scope is being migrated and is frozen on this node. The client should treat it like `MOVED` and retry against ``. Once migration completes, that node will respond authoritatively. See the protocol notes in [docs/](https://github.com/goliajp/kevy/tree/develop/docs) for the full routing model. ## FAQ **My client treats `-MOVED` as a fatal error — how do I fix it?** The client isn't cluster-aware. Either switch to a cluster-aware client (e.g., `redis-cli -c`, `ioredis` with `Cluster`, `redis-py` with `RedisCluster`, the kevy routing client), or wrap your driver to catch `MOVED`, reconnect to the host in the message, and re-issue. **Single-key command came back with `-CROSSSLOT` — is that a bug?** No. `CROSSSLOT` only fires for multi-key commands. If you see it on what looks like a single-key call, the command is actually multi-key (e.g., `EVAL` with two `KEYS`, `SUNIONSTORE` with source + destination). Use `{tag}` notation to force shared slot placement, or split the call. **I got `-OOM` — is my data corrupt?** No. `-OOM` is rejected at command-admission time; the write never landed. The keyspace is in exactly the state it was before the command. Free room (`DEL` / set an eviction policy / raise `maxmemory`) and retry. **`-LOADING` keeps coming back — how long should I wait?** For as long as the full-resync snapshot ship takes (proportional to dataset size and link speed). `PING`, `INFO`, and `HELLO` are answered during loading (`INFO replication` reports `loading:1`), so health checks still work. A replica only enters this state when it reconnects too far behind the primary's backlog; if `-LOADING` recurs constantly, raise `[replication] replication_buffer_size` or investigate the link. **A queued MULTI command returned `-EXECABORT` — were any writes applied?** No. `EXECABORT` means the transaction was rejected as a batch; nothing in the queued sequence was executed. Fix the offending command and reopen with `MULTI`. ## Extension surfaces (IDX. / VIEW. / FEED.) The extension verbs follow the same prefix contract. Every error is self-explaining: it names the verb and the object and points at the discovery surface (an agent that hits one can recover in-band). | Prefix | Emitted when | Recovery | |---|---|---| | `ERR '': bad arguments — run COMMAND DOCS for the syntax` | argument parse failed on an IDX./VIEW. verb | `COMMAND DOCS ` returns the full syntax string | | `ERR no such index '' (IDX.LIST enumerates them)` | query names an index that doesn't exist | `IDX.LIST` / `VIEW.LIST` enumerate the catalog | | `INDEXBUILDING index '' is still building (poll IDX.LIST until state=ready)` | query raced the post-create backfill | poll `IDX.LIST` `state`; see docs/migration.md | | `INDEXOVERBUDGET index '' build exceeded MAXMEM (raise maxmemory or DROP the index)` | build hit the memory budget | raise `maxmemory` or `IDX.DROP` | | `FEEDRESYNC ` | a FEED cursor is no longer servable (generation bump or past-backlog) | restart consumption from a fresh snapshot + the returned cursor; see docs/cdc.md | ## Updating this catalog If you add or modify a code path that emits a `- ...` reply: 1. Update the row in [Core reference](#core-reference) (or add a new one). 2. Extend the wire-level chaos test at [crates/kevy/tests/wire_torture_chaos.rs](https://github.com/goliajp/kevy/blob/develop/crates/kevy/tests/wire_torture_chaos.rs) if a new prefix is introduced. 3. Note the change in [CHANGELOG.md](https://github.com/goliajp/kevy/blob/develop/CHANGELOG.md) for the release that ships it. Errors are part of the client contract. Silent message changes can break ecosystem libraries that pattern-match on them. ============================================================================== # indexes ============================================================================== # Secondary indexes (`IDX.*` / `idx_*`) kevy can maintain declarative secondary indexes over a **prefix domain** of the keyspace: every hash key under a prefix is a "row", one declared hash field is the indexed value. Indexes are maintained **synchronously with every write** (derived-by-construction — the index can never drift from the data, and `IDX.VERIFY` makes that falsifiable), and queried with cursor pagination, two-index composition, and optional field hydration. ``` IDX.CREATE idx_age ON PREFIX user: FIELD age TYPE i64 KIND range HSET user:42 age 31 name "……" IDX.QUERY idx_age RANGE 18 30 LIMIT 100 FIELDS name ``` ## Declaring `IDX.CREATE ON PREFIX

FIELD TYPE i64|f64|str KIND range|unique [MAXMEM ]` - **TYPE** is a scalar coercion: a row whose field is missing or fails to parse is **excluded** (counted per index — `IDX.VERIFY` / `IDX.LIST` report `coerce_failures`; this is the declarative fence, not a runtime error). - **KIND range** serves `RANGE min max` scans; **unique** serves the same plus the duplicate fence (below). - **MAXMEM** caps the index's memory: a build that crosses the budget fails declaratively (`-INDEXOVERBUDGET` on queries) instead of growing unbounded. - Up to 64 indexes. The catalog persists in a data-dir sidecar; the index CONTENT is derived state — it is never snapshotted or AOF-logged, and rebuilds in the background after a restart (`-INDEXBUILDING` until ready; data availability never waits). ## Querying - `IDX.QUERY RANGE | EQ [LIMIT n] [CURSOR c] [FIELDS f…]` → `[next-cursor, rows]`. Rows are `(value, key)` ordered across all shards; `FIELDS` hydrates the named hash fields on each row's owning shard (no second round-trip) and switches the rows to nested `[key, value, fname, fval…]` form. - `IDX.QUERY COMPOSE AND|OR …` — two-index composition, **key-ordered** (the two value domains differ), same LIMIT/CURSOR/FIELDS tail. AND/OR run per shard (a key lives on exactly one shard, so per-shard set algebra composes globally). - `IDX.COUNT RANGE|EQ …` — count without materializing keys. - `IDX.VERIFY ` — summed stats: entries, bytes, coerce_failures, duplicates. - `IDX.LIST` — catalog + per-index state/entries/bytes. - Cursor contract is SCAN-class: rows stable across the whole traversal are seen exactly once; concurrent insertions/deletions may or may not appear. `"0"` = start / exhausted. ## Uniqueness is a fence, not a lock A `unique` index **does not block writes** — enforcing global uniqueness at write time would serialize cross-shard writes. Instead: duplicates are counted (`duplicates` in VERIFY/LIST) and visible as multi-hit `EQ` reads. If you need hard uniqueness, pin the domain to one shard with a `{hashtag}` prefix in cluster mode, or check-then-write under `MULTI`/`WATCH`. ## Embedded Same engine, typed API: `idx_create / idx_drop / idx_query / idx_count / idx_stats / idx_list` (values as `IndexValue`, cursors as `IndexCursor`). No `FIELDS` hydration — you're in-process; read fields with `hget`. `idx_create` builds synchronously and returns when the index serves. ## Consistency + cost model - A write and its index update are atomic within the owning shard (single reactor thread / shard lock). Cross-shard queries are merged per shard with no global snapshot (SCAN-class, same as DBSIZE). - An **empty catalog costs one untaken branch per write** (a Relaxed atomic load). With indexes declared, a write in an indexed domain pays one hash-field read + one B-tree update per matching index. - Memory per index ≈ `rows × (value_width + avg_key_len + 48)` bytes (the constant is per-entry structure overhead). `IDX.LIST` reports measured bytes; `bench/idxgate.sh` gates the formula. ## Aggregate kind (`KIND agg`) — write-time GROUP BY ``` IDX.CREATE ord_amt ON PREFIX ord: FIELD amount TYPE i64 KIND agg GROUPBY status IDX.QUERY ord_amt GROUP paid → [count, sum, min, max, avg] IDX.QUERY ord_amt GROUPS BY sum LIMIT 100 → ranked [group, count, sum, min, max] ``` The engine answer to `SELECT g, COUNT(*), SUM(v) … GROUP BY g`: aggregates are maintained IN THE WRITE PATH (a declared access path — never a query-time row scan). min/max stay exact under deletion via a per-group value multiset; sums accumulate in f64 (documented precision bound); a row whose value fails coercion or whose group field is missing is excluded and counted (VERIFY). Cross-shard merging is exact: counts/sums add, extremes take. `GROUPS` ranks by count/sum/max descending or min ascending, LIMIT ≤ 1000. No HAVING, no aggregate expressions, no approximate sketches — the query-language slope. Filter GROUPS results in the app. Embedded: `idx_create_agg(name, prefix, field, ty, group_by)` / `idx_group(name, g)` / `idx_groups(name, by, limit)`. Memory ≈ `groups × (gkey+64) + distinct_values × 18 + rows × (key+10)` (constants calibrated against measured RSS); gated vs real RSS by `bench/agggate.sh` along with GROUP p99 < 1ms @ 1M×10k groups, GROUPS top-100 < 5ms, and write tax < 10%. ============================================================================== # vector-search ============================================================================== # Vector search (`KIND ann`) ANN is the index engine's fourth kind: declare it like any index and the field's raw bytes parse as an f32 vector indexed in a per-shard HNSW graph, maintained synchronously with every write. There is no sidecar vector database and no ingestion job: embeddings live in the same hash rows as the rest of the record, and the graph can never drift from the data. ``` IDX.CREATE embs ON PREFIX doc: FIELD v TYPE vector KIND ann DIM 768 [DISTANCE cosine|l2|ip] [M 16] [EF 200] IDX.QUERY embs KNN LIMIT 10 [EF 400] [FIELDS title] IDX.REBUILD embs ``` ## Quick start (server) Rows are hash keys under the declared prefix; the indexed value is one declared field holding the raw vector bytes: ```console kevy-cli -p 6004 IDX.CREATE embs ON PREFIX doc: FIELD v TYPE vector KIND ann DIM 4 kevy-cli -p 6004 HSET doc:1 title "intro" v "csv:0.1,0.2,0.3,0.4" kevy-cli -p 6004 HSET doc:2 title "search" v "csv:0.4,0.3,0.2,0.1" kevy-cli -p 6004 IDX.QUERY embs KNN "csv:0.1,0.2,0.3,0.4" LIMIT 2 FIELDS title ``` The reply is `key, distance` pairs ascending (closest first); `FIELDS` hydrates hash fields on each hit's owning shard in the same call. In production the vector value is the binary blob (`dim × 4` bytes of little-endian f32) written by your client library; the `csv:` form above is the debugging convenience. Supporting verbs work like on every kind: `IDX.EXPLAIN embs KNN …` (parse + plan without execution), `IDX.VERIFY` / `IDX.LIST` (live stats), `IDX.DROP`. `IDX.REBUILD` is specific to ANN — see "Deletes and rebuild". ## Quick start (embedded) The ANN kind is behind the `vector` cargo feature (on by default; compiled out it answers `KevyError::Unsupported`): ```rust use kevy_embedded::{AnnSpec, Config, Store}; fn main() -> kevy_embedded::KevyResult<()> { let store = Store::open(Config::default())?; // m / ef of 0 select the defaults (16 / 200); // distance: 0 = cosine, 1 = l2, 2 = ip. store.idx_create_ann(b"embs", b"doc:", b"v", AnnSpec { dim: 4, distance: 0, m: 0, ef: 0, })?; let v1: Vec = [0.1f32, 0.2, 0.3, 0.4] .iter().flat_map(|f| f.to_le_bytes()).collect(); store.hset(b"doc:1", &[ (b"title".as_slice(), b"intro".as_slice()), (b"v".as_slice(), v1.as_slice()), ])?; // Nearest neighbors ascending: Vec<(key, distance)>. // ef = 0 takes the engine default beam width. let hits = store.idx_knn(b"embs", &[0.1, 0.2, 0.3, 0.4], 10, 0)?; for (key, dist) in &hits { println!("{} {dist}", String::from_utf8_lossy(key)); } Ok(()) } ``` `idx_knn` returns `KevyResult, f32)>>` (`k` clamps to 1..=1000); `KevyError::NotFound` names a missing index, `KevyError::InvalidInput` a bad `AnnSpec` (zero dim, unknown distance tag). Embedded builds are synchronous — `idx_create_ann` returns when the index serves. No `FIELDS` hydration in-process: read fields with `hget`. ## Wire format A vector field holds `dim × 4` bytes of little-endian f32 (the RediSearch convention). Wrong length or non-finite values exclude the row (counted, visible in VERIFY) — same discipline as scalar coercion failures. Query vectors use the same format; `csv:1.0,2.5,…` is accepted for debugging. ## Distances and results `cosine` (default; vectors are normalized at insert — the stored copy is unit length), `l2` (squared euclidean), `ip` (negative inner product). Every metric is oriented "smaller = closer", so cross-shard results merge with one ascending sort. `LIMIT` caps at 1000; no cursor (deep ANN pagination is an anti-pattern). Per-shard graphs are independent (index-follows-key, zero cross-shard write coordination); a query fans out, takes each shard's top-k, and merges. ## Recall: the EF knob `EF` (16-4096, default max(4·LIMIT, 100)) is the query beam width — the canonical HNSW recall/latency knob. Dense near-duplicate regions need wider beams (measured on a 20k cluster @128d: EF 64 → 0.67 recall@10, 100 → 0.77, 400 → the ≥ 0.90 gate line). Embedded: `idx_knn(…, ef)` (0 = default). Recall is gated ≥ 0.90 at EF 400 against exact brute-force ground truth by [`bench/vectorgate.sh`](https://github.com/goliajp/kevy/blob/main/bench/vectorgate.sh) — on a realistic corpus geometry (an intrinsic-dimension-20 manifold in 128d; ambient-uniform random vectors suffer distance concentration and represent nothing). If your recall matters, measure it on YOUR corpus with a brute-force sample exactly the way the gate does, and tune EF per query, not per index. ## Parameters, deletes, rebuild `M` (links per node per layer, 4-64) and `EF` (construction beam, 16-1024) are **immutable once created** — changing them means DROP + re-CREATE. Neighbor selection uses the diversity heuristic (Malkov Alg. 4), which preserves bridge links to outlying regions. Deletes tombstone the graph node (it keeps routing, stops matching); updates tombstone + reinsert. `IDX.VERIFY` reports vectors / bytes / tombstones and flags `rebuild_recommended` past 30% dead; `IDX.REBUILD` re-inserts the living per shard (bounded O(n · ef_construction · M · dim) work — every living key goes back through the full HNSW insert, so the neighbour cap M and the dimensionality both multiply in, answer-preserving — asserted in e2e). Graphs are derived state: never persisted, rebuilt from data after restart. On the server that restart rebuild is a background backfill — queries answer `-INDEXBUILDING` until it completes ([indexes.md](https://kevy.golia.jp/docs/indexes/)); embedded rebuilds are synchronous. ## Filtering, and hybrid retrieval No filter-during-search (partition predicates inside the graph walk are the query-engine slope): KNN first, then hydrate with `FIELDS` and filter client-side. The one server-side composition that IS built in: an ANN index and a text index over the same corpus fuse with reciprocal-rank fusion — ``` IDX.QUERY HYBRID posts MATCH "rust storage" embs KNN [LIMIT n] [RRFK k] [EF ef] [FIELDS f…] ``` — rank-based (`Σ 1/(k + rank_i)`, `RRFK` default 60), so BM25 scores and vector distances need no calibration against each other. See [text-search.md](https://kevy.golia.jp/docs/text-search/) for the MATCH half. Embedded callers run `idx_knn` + `idx_match` and fuse in-process. **Multi-modal caveat**: navigation graphs degrade on corpora made of far-separated modes (inter-mode gaps ≫ intra-mode distances, e.g. two disjoint tenants' embeddings in one index) — late inserts in one mode can't discover the other, starving the graph of bridges. Real embedding corpora are continuous manifolds and don't trigger this; if your data is strongly multi-modal, index the modes separately (one index per prefix — they're cheap and independent). ## Consistency Same envelope as every index kind ([indexes.md](https://kevy.golia.jp/docs/indexes/)): a write and its graph update are atomic within the owning shard; cross-shard queries merge per-shard top-k without a global snapshot (SCAN-class). The catalog persists in a data-dir sidecar; graph CONTENT is derived state, rebuilt after restart. ## Performance Measured envelope (receipts in the bench tree): - [`bench/vectorgate.sh`](https://github.com/goliajp/kevy/blob/main/bench/vectorgate.sh) gates KNN LIMIT 10 p95 < 30ms at 1M × 128d against a real server, with recall ≥ 0.90 at EF 400 holding simultaneously (both clamps at once — a fast wrong answer doesn't pass), plus the memory formula against real RSS growth (0.5-1.5×). - [`bench/PERF-LEDGER.md`](https://github.com/goliajp/kevy/blob/main/bench/PERF-LEDGER.md) records the comparative shootout: recall-aligned at 1.000, KNN answers in 0.48 ms vs 0.79 ms — **1.64× ahead** of the RediSearch HNSW on the same corpus. Write-side cost is the standard index tax (one field parse + one graph insert per matching index per write); construction cost scales with `EF` (construction beam), which is why it is a declaration-time parameter. ## Sizing `bytes ≈ vectors × (dim×4 + 40) + links × 8 + vectors × 32`, reported live by `IDX.VERIFY`/`IDX.LIST`. Cosine keeps only the normalized copy (single copy — the raw field bytes stay in the row itself). 1M × 1024d ≈ 4.1 GiB plus links. The gate checks the formula against real RSS growth (0.5-1.5×). ## See also - [indexes.md](https://kevy.golia.jp/docs/indexes/) — the index engine this kind plugs into (declaration grammar, backfill contract, consistency envelope). - [text-search.md](https://kevy.golia.jp/docs/text-search/) — the BM25 kind and the other half of `HYBRID`. - [verb-reference.md](https://kevy.golia.jp/docs/verb-reference/) — generated grammar for every `IDX.*` form. - [cookbook.md](https://kevy.golia.jp/docs/cookbook/) — retrieval recipes in context. ============================================================================== # text-search ============================================================================== # Full-text search (`KIND text`) Text is the index engine's third kind: declare it like any index and the field's raw bytes tokenize into a per-shard inverted segment, maintained synchronously with every write (zero drift, same derived-by-construction discipline as `range`/`unique`). There is no separate search server, no ingestion pipeline, and no analyzer configuration — one verb declares the index, and every subsequent write keeps it exact. ``` IDX.CREATE posts ON PREFIX post: FIELD body TYPE str KIND text IDX.QUERY posts MATCH "rust 全文检索" LIMIT 10 [FIELDS title body] ``` ## Quick start (server) Rows are hash keys under the declared prefix; the indexed value is one declared field, exactly as with `range` indexes ([indexes.md](https://kevy.golia.jp/docs/indexes/)): ```console kevy-cli -p 6004 IDX.CREATE posts ON PREFIX post: FIELD body TYPE str KIND text kevy-cli -p 6004 HSET post:1 title "intro" body "kevy is a pure-Rust key-value store" kevy-cli -p 6004 HSET post:2 title "search" body "dictionary-free CJK full-text search" kevy-cli -p 6004 IDX.QUERY posts MATCH "full-text rust" LIMIT 10 ``` The reply is a flat array of `key, score` pairs, best first. Add `FIELDS` to hydrate named hash fields on each hit's owning shard in the same call (no second round-trip): ```console kevy-cli -p 6004 IDX.QUERY posts MATCH "search" LIMIT 10 FIELDS title ``` Supporting verbs work on text indexes like on every other kind: - `IDX.EXPLAIN posts MATCH "full-text rust"` — the parse without the execution: kind, build state, estimated rows, and the plan line. - `IDX.VERIFY posts` / `IDX.LIST` — entries / bytes / postings / token statistics, live. - `IDX.DROP posts` — drop the declaration (catalog mutation, sidecar-persisted). `MATCH` accepts `LIMIT` (≤ 1000) and `FIELDS`; there is no `CURSOR` form (see "Matching and ranking" for why). ## Quick start (embedded) Same engine in-process. The text kind is behind the `text` cargo feature (on by default; `idx_create` with `IndexKind::Text` answers `KevyError::Unsupported` when compiled out): ```rust use kevy_embedded::{Config, IndexKind, IndexValType, Store}; fn main() -> kevy_embedded::KevyResult<()> { let store = Store::open(Config::default())?; store.idx_create(b"posts", b"post:", b"body", IndexValType::Str, IndexKind::Text)?; // builds synchronously store.hset(b"post:1", &[ (b"title".as_slice(), b"intro".as_slice()), (b"body".as_slice(), b"kevy is a pure-Rust key-value store".as_slice()), ])?; // BM25-ranked hits, best first: Vec<(key, score)>. let hits = store.idx_match(b"posts", b"rust store", 10)?; for (key, score) in &hits { println!("{} {score}", String::from_utf8_lossy(key)); } Ok(()) } ``` `idx_match` returns `KevyResult, f64)>>`; `KevyError::NotFound` names a missing index. There is no `FIELDS` hydration embedded — you are in-process, read fields with `hget`. Embedded builds are synchronous: `idx_create` returns when the index serves (no `-INDEXBUILDING` phase to poll). ## Tokenization (dictionary-free) - Latin/alphanumeric runs → lowercased word tokens (length ≥ 2). - CJK (unified ideographs, kana, hangul) → **adjacent bigrams** — 「全文检索」 indexes as 全文/文检/检索, so any two-character substring query matches without a dictionary. A lone CJK character emits itself. - Tokens never cross a script boundary; queries tokenize with the same rules. The bigram scheme is the whole CJK story: no dictionary to ship, no dictionary to go stale, and mixed-script documents (`"Rust 入门"`) index both halves under their own rules. The cost is recall noise on one-character CJK queries (they match only lone-character emissions) — query with two or more characters. ## Matching and ranking `MATCH` is **OR semantics over query tokens, BM25-ranked** (k1=1.2, b=0.75, non-negative idf variant). Documents matching more (and rarer) query terms rank higher; term frequency saturates; long documents are normalized down. Two declared approximations: - **Scores are shard-local.** df/avgdl come from each shard's own corpus (global statistics would require cross-shard write coordination). With hash-sharded keys the statistics converge across shards and the merged ranking is stable; scores from different shards are comparable, not identical to a single-corpus run. - **No cursor.** BM25 deep pagination is an anti-pattern (page N requires re-scoring everything above it); `LIMIT` caps at 1000. No phrase queries, no boolean syntax, no highlighting — that's the query-engine slope (deliberately out of scope). If you need those, you are describing a search engine; kevy's text kind stops at "ranked lookup over declared fields". ## Hybrid retrieval (BM25 + KNN) A text index and an ANN index over the same corpus fuse server-side with reciprocal-rank fusion: ``` IDX.QUERY HYBRID posts MATCH "rust storage" embs KNN [LIMIT n] [RRFK k] [EF ef] [FIELDS f…] ``` Each hit's fused score is `Σ 1/(k + rank_i)` over the two result lists (`RRFK` default 60 — the standard fusion constant; rank positions, not raw scores, so BM25 and distance need no calibration against each other). See [vector-search.md](https://kevy.golia.jp/docs/vector-search/) for the KNN half. Hybrid is a server verb; embedded callers run `idx_match` + `idx_knn` and fuse in-process. ## Lifecycle and consistency Same envelope as every index kind ([indexes.md](https://kevy.golia.jp/docs/indexes/)): - A write and its segment update are **atomic within the owning shard** (single reactor thread / shard lock). An update removes exactly the old document's tokens and inserts the new ones — documents keep their original text for this, so there is no tombstone drift. - A row whose declared field is missing simply contributes no tokens; there is no coercion failure for `TYPE str` text fields. - Cross-shard queries merge per-shard top-K without a global snapshot (SCAN-class, same as `DBSIZE`). - **Server backfill is asynchronous**: after `IDX.CREATE` on a live keyspace, or after a restart, queries answer `-INDEXBUILDING` until the rebuild completes (data availability never waits for index builds; poll or retry — see [indexes.md](https://kevy.golia.jp/docs/indexes/)). Embedded builds are synchronous. - Inverted segments are **derived state** — never snapshotted or AOF-logged, rebuilt from data after restart. - `MAXMEM` (on `IDX.CREATE`) caps the segment's memory; a build that crosses the budget fails declaratively (`-INDEXOVERBUDGET` on queries) instead of growing unbounded. ## Performance Top-K evaluation uses MaxScore pruning (rarest terms first; commoner lists are probed per candidate once they can't lift new documents into the top K). Postings are doc-id inverted lists impact-ordered two ways: tf buckets descending, sparse log2 dl bands ascending inside each bucket — a single-term query (no second list to prune against) stops exactly at the first band whose lower edge can't beat the kth floor, so even the most common term answers in ~0.1ms at 200k docs (was ~6ms as a full postings scan). One-posting tokens (the Zipf long tail) stay inline and cost no heap. Measured envelope (receipts in the bench tree): - [`bench/textgate.sh`](https://github.com/goliajp/kevy/blob/main/bench/textgate.sh) gates `MATCH` p95 < 20ms at 1M mixed-script documents (~100 bytes each) against a real server, plus the memory formula against real RSS growth. It runs in CI-adjacent release checks — the numbers are clamps, not aspirations. - [`bench/PERF-LEDGER.md`](https://github.com/goliajp/kevy/blob/main/bench/PERF-LEDGER.md) records the comparative shootout: BM25 top-10 at +21% qps with a p95 tie against RediSearch's `FT.SEARCH` on the same corpus. The write side is the standard index tax: one hash-field read plus one segment update per matching index per write; an empty catalog costs one untaken branch per write. ## Sizing `bytes ≈ Σ_token (token_len + 48) + postings × 64 + Σ_doc (key_len + text_len + 72)` (docs keep their original text so updates remove exactly their own tokens), reported live by `IDX.VERIFY` / `IDX.LIST` (entries/bytes/postings/tokens for text kinds). `bench/textgate.sh` gates the formula against real RSS growth. Rebuild rides the standard backfill skeleton (tick-incremental on the server, synchronous embedded). ## See also - [indexes.md](https://kevy.golia.jp/docs/indexes/) — the index engine this kind plugs into (declaration grammar, cursor contract, consistency envelope). - [vector-search.md](https://kevy.golia.jp/docs/vector-search/) — the ANN kind and the other half of `HYBRID`. - [verb-reference.md](https://kevy.golia.jp/docs/verb-reference/) — generated grammar for every `IDX.*` form. - [cookbook.md](https://kevy.golia.jp/docs/cookbook/) — full-text recipes in context. ============================================================================== # views ============================================================================== # Views (`VIEW.*` / `view_*`) A view is a **named composition of declared indexes** — an AND/OR/DIFF tree of index shapes with an ordering index — queryable as one unit, either evaluated per query (**virtual**) or maintained incrementally on every write (**materialized**, optionally top-K bounded). Views are the engine's answer to the "hot list" query — `WHERE state = 'ready' AND pri BETWEEN 0 AND 100 ORDER BY pri DESC LIMIT 10` — as a declared access path instead of a query-time scan. ``` IDX.CREATE j_pri ON PREFIX job: FIELD pri TYPE i64 KIND range IDX.CREATE j_state ON PREFIX job: FIELD state TYPE str KIND range VIEW.CREATE ready_jobs QUERY ( AND j_pri RANGE 0 100 j_state EQ ready ) ORDER BY j_pri DESC MODE materialized TOPK 100 VIEW.QUERY ready_jobs LIMIT 10 ``` ## Quick start (server) Everything a view composes must exist first — leaves and the ORDER BY index are declared `IDX.*` indexes over the same prefix domain ([indexes.md](https://kevy.golia.jp/docs/indexes/)): ```console kevy-cli -p 6004 IDX.CREATE j_pri ON PREFIX job: FIELD pri TYPE i64 KIND range kevy-cli -p 6004 IDX.CREATE j_state ON PREFIX job: FIELD state TYPE str KIND range kevy-cli -p 6004 VIEW.CREATE ready_jobs QUERY ( AND j_pri RANGE 0 100 j_state EQ ready ) ORDER BY j_pri DESC MODE materialized TOPK 100 kevy-cli -p 6004 HSET job:1 pri 10 state ready kevy-cli -p 6004 HSET job:2 pri 99 state blocked kevy-cli -p 6004 VIEW.QUERY ready_jobs LIMIT 10 ``` The reply pages `key, order-value` pairs in view order with a resume `CURSOR`. The tree grammar (one line, parenthesized): ``` tree = '(' AND|OR|DIFF sub sub ')' | leaf leaf = RANGE | EQ ``` Supporting verbs: `VIEW.LIST` (catalog + mode + shape), `VIEW.EXPLAIN name` (the tree with per-leaf cardinalities), `VIEW.VERIFY name` (members / bytes / order-exclusions — **bytes and order-exclusions are materialized-only**; a virtual view stores nothing and reports both as 0, and verifying one costs a full fresh `eval_tree`, which is the one place a virtual view is the expensive one), `VIEW.REBUILD name` (force a materialized rebuild — answer- preserving), `VIEW.DROP name`. ## The three structural rules 1. **Components are named indexes.** Leaves carry a shape (`RANGE min max` | `EQ v`, coerced to the referenced index's type at CREATE); the view layer holds no predicates of its own. Trees are depth ≤ 3, ≤ 4 leaves; `AND`/`OR` may be re-ordered by the engine, `DIFF` is fixed left-minus-right. 2. **A view stores membership + order only** — never field values. `ORDER BY ` supplies the sort key; rows absent from the order index are excluded (counted, visible in `VIEW.VERIFY`). 3. **Hydration is dereference, not query.** `VIA