On this page
- The division of labor
- Measured against PostgreSQL 18
- Tables, rows, columns
- Type system
- PRIMARY KEY, UNIQUE, AUTO_INCREMENT
- SELECT
- Scalar functions
- ORDER BY / LIMIT / OFFSET
- GROUP BY and aggregates
- JOIN
- VIEW
- Transactions
- Constraints and triggers
- Secondary index DDL
- Pagination and optimistic locking patterns
- Backup and PITR
- Replication and read scaling
- CDC
- What kevy will NOT do
- Sizing and operational deltas
- This page vs the cookbook
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 turns these rows into runnable recipes; 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
WHEREwith 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), and write absorption an RDS cannot match at the same durability setting (see Sizing).
Measured against PostgreSQL 18
Not asserted — run. 2M rows / 854 MB of CSV, one harness driving both, PG stock, median of three interleaved passes on kevy 5.4:
kevy (everysec) | PG 18 stock | |
|---|---|---|
| single-row update p99 | 41 µs | 1,676 µs |
| single-row update p99, matched durability (fsync per write) | 3,339 µs | 1,676 µs |
| secondary-index lookup p99 | 160 µs | 154 µs |
| list page p99 (filter + range + order + limit) | 200 µs | 162 µs |
| memory per MB of CSV | 5,821 KB | 883 KB |
These numbers replace a set that was not comparing like with like. Until 5.4 the kevy read shapes asked for no columns while the SQL they were timed against selected two or three, and the kevy table declared six of the seven columns its rows carried. Both are fixed, and both moved the answer: the index lookup was published as 212 µs against PostgreSQL's 126 and is now a tie within 4%.
Read the rows as separate findings, because they point different ways. kevy absorbs writes 41× faster if one second of loss is acceptable — that is the pressure that puts a cache in front of an RDS in the first place. At matched per-write durability PostgreSQL is 2.0× faster: it group-commits its WAL, kevy fsyncs per command. On the indexed lookup the two engines are level; on the list page PostgreSQL is 1.2× faster — a planner over a B-tree is good at its job. And kevy holds the same data in 6.6× the memory: rows held in-process cost RAM. When the dataset stops fitting — 11.5 GB of CSV, PostgreSQL capped at 2 GB of memory, kevy at a 1 GB tiering budget — only the random-access shape changes hands: a lookup by random primary key is 184 µs on PG against 78 µs on tiered kevy, whose rows are on disk and still answer faster than a buffer-pool miss. The indexed shapes above stay with PG, and a follow-up run with predicates that genuinely scatter across the table widened rather than closed that gap — 176 µs against 385 µs on the indexed lookup. It is not an I/O effect: kevy loses it while holding everything in RAM, so the cost is the query path, not the fetch. Tiering holds the same 12 GB in 2.95 GB of RSS instead of 17.95 GB, and answers that lookup from the index without touching the 98 % of rows that live on disk.
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). 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 = 42HGET 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 |
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 modeSELECT
| 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 <max-sentinel> |
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|ORof exactly two indexes (key-ordered — the two value domains differ, so key order is the only shared order), or a 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: defaultLIMIT 100, max10000, 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 asSCAN/DBSIZE.- Prefix
LIKE: aTYPE str KIND rangeindex is ordered by lexicographic byte order, soLIKE 'abc%'isRANGE 'abc' 'abc<0xff>'— an index scan, not a keyspace walk. (Key-name prefix matching viaSCAN 0 MATCH 'user:*'also exists but walks the whole keyspace incrementally — use it for ops chores, never as a serving path.) MATCHis OR-semantics over query tokens, BM25-ranked, CJK bigrams built in;LIMITcaps at 1000, no cursor; no phrase queries, no boolean syntax, no highlighting (text-search).FIELDShydrates 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.EXPLAINis 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.
Scalar functions
SQL expressions in migrated queries lean on a scalar-function vocabulary, and kevy covers it in the SQL face, not the engine: kevy-cli sql eval (and the sql toolbox's constant folding) evaluate expressions client-side with PostgreSQL-canonical semantics, and the serving engine never sees an expression — the same division of labor as everywhere else on this page.
Covered today (names matched case-insensitively, PG's fold):
| Family | Functions | ||
|---|---|---|---|
| strings | lower upper initcap length char_length concat concat_ws trim btrim ltrim rtrim replace split_part repeat lpad rpad strpos position left right reverse translate substr substring format | ||
| math | floor ceil ceiling round trunc mod power pow sqrt sign abs | ||
| operators | AND OR NOT (three-valued), comparisons = <> != < <= > >=, `\ | \ | concatenation, searched CASE WHEN … THEN … [ELSE …] END, ::bool` with PG's input vocabulary |
| NULL family | coalesce nullif greatest least | ||
| date/time | extract date_part date_trunc age to_char (interval carries PG's real three-component form: months, days, micros) | ||
| regexp (POSIX ERE) | regexp_replace regexp_matches regexp_split_to_array | ||
| hash | md5 | ||
| MySQL aliases | date_format unix_timestamp from_unixtime (probe-transcribed against MySQL's semantics — the WordPress/Laravel display triplet) |
The claims discipline behind that table:
- Semantics are probe-transcribed from PostgreSQL's own regression corpus and gated in CI (
funcgate): a covered function answering differently from PG is a hard failure — the gate's wrong-answer count must be zero. The corpus-coverage ratio is a ratchet (currently ≥ 82 % of subset-foldable probes) that only moves up. - Anything outside the table is refused by name, never guessed. The same goes for shapes inside a covered function that would change cardinality —
regexp_matchesreturning zero or several rows is refused rather than flattened into a wrong scalar.
ORDER BY / LIMIT / OFFSET
- A
rangeindex is the order:IDX.QUERY … RANGEreturns rows ascending by indexed value.ORDER BY col ASC LIMIT n= declare a range index oncol, query withLIMIT n. - Descending order on the bare index surface: not an
IDX.QUERYoption. 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 + bfor bounded integers, zero-padded strings for lexicographic composites (cookbook recipe 8).OFFSETexists on the claused surface, and you should still paginate with the cursor. This page used to say it did not exist; that was wrong —IDX.QUERY … OFFSET nparses and runs (seedocs/tables.md). What is true is the cost: it is a "skip N rows", linear inn, and — unlike every other axis here — it gets worse as you add shards, because each shard must fetchlimit + offset(none of them can know which of its hits survive the global merge) and the origin materialises(limit + offset) × shardshits to returnlimit. Measured on 30 000 in-range rows returningLIMIT 20:OFFSET 1000costs 1.53 ms on one shard and 6.90 ms on eight;OFFSET 10000costs 11.5 ms and 20.1 ms. 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 does not hide this cost — but until now it did not state it either, which is worse than hiding it.
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 rankedGROUP 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, noGROUP BYover arbitrary predicates — filter the (bounded)GROUPSresult 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:
- Denormalize at write time. The serving-model answer: if the page shows
order.totalnext touser.name, writeuser_nameinto 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. - 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 statusisSELECT 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 isHMGETper parent — batch the ids and pipeline. - A view with
VIAhydration.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 <index> [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).
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) |
| 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 — 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) | ||
pgvector USING hnsw | `KIND ann DIM d [DISTANCE cosine\ | l2\ | ip] [M m] [EF ef]` (vector-search) |
DROP INDEX | IDX.DROP idx | ||
\d / information_schema | IDX.LIST / VIEW.LIST (state, entries, bytes) |
- Online build:
IDX.CREATEreturns immediately and backfills in the background (~7s per million small rows; multi-KB text bodies ~85s/M). Queries answer-INDEXBUILDINGuntil ready — pollIDX.LISTforstate=ready(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;
MAXMEMcaps 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
versionfield,WATCHthe 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-<id>.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) |
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), 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).
CDC
The feed is kevy's binlog-as-API — what Debezium extracts from an RDS, served natively (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, 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 / per-query 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 |
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 |
Where that second row stops. "Not decide" is about query time: no plan is chosen for you, because your query names its own path. It is not a refusal to ever add one. TABLE.DECLARE … AUTODECLARE n lets the engine turn a shape you keep being refused into a declared path — off unless you ask, capped at the n you write, only over columns you declared, addition-only, and marked auto in IDX.LIST (tables.md). An optimiser you cannot predict and a schema change you asked for are different things, and only the first one is refused here.
One entry that used to sit in that table and does not belong there: OFFSET. It was listed as refused; the engine has always provided it on the claused surface (docs/tables.md), so the table was describing an intention rather than the behaviour. What is true, measured on 30 000 rows in range returning LIMIT 20 (p50 of 30 calls):
| OFFSET | 1 shard | 8 shards |
|---|---|---|
| 0 | 219 µs | 526 µs |
| 1 000 | 1.53 ms | 6.90 ms |
| 10 000 | 11.5 ms | 20.1 ms |
It is linear in the offset, and it gets worse as you add shards — each shard must fetch limit + offset because none of them can know which of its hits survive the global merge, so the origin materialises (limit + offset) × shards hits to return limit. Every other axis in this engine gets faster with more cores; this one does not.
Use cursors. OFFSET is there, it is not refused, and a deep one is the O(N) skip the rest of this table exists to keep out of your latency.
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.LISTbytes 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 / vector-search (1M × 1024d vectors ≈ 4.1 GiB); agg ≈ groups-dominated (indexes); view members ≈ order_value_width + key_len + 48 (views). Set maxmemory + an eviction policy or accept -OOM refusals (writes are rejected at admission — existing data is never corrupted).
Serving headroom. The arena, re-measured 2026-07-19 on v4.0.0 (kevy vs valkey 9.1, fair-fight protocol, median-of-5, throughput read from each server's own command counter — bench/ARENA-2026-07-19.txt): GET 2.46×, SET 4.00×, INCR 2.86×, SADD 2.95×, HSET 2.17×, ZADD 1.78×, LPUSH 1.76× — 7/7 wins with the full replication/heartbeat pipeline landed. Some of these ratios are lower than the v3.18.0 figures they replace, because that run read redis-benchmark's own rate, which is quantized low under --threads; counting server-side raised every engine's number, the competitors' by more. 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).
This page vs the cookbook
This page is the reference matrix — SQL construct in, kevy shape out, deltas stated. The 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.