kevy
On this page

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 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), 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 p9941 µs1,676 µs
single-row update p99, matched durability (fsync per write)3,339 µs1,676 µs
secondary-index lookup p99160 µs154 µs
list page p99 (filter + range + order + limit)200 µs162 µs
memory per MB of CSV5,821 KB883 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

RDSkevy
tablekey prefix (user:)
rowhash under the prefix (user:42)
columnhash field
PRIMARY KEYthe key itself
NULLabsent 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 typekevy formnotes
INT / BIGINT / SERIALfield bytes, index TYPE i64full i64 range
DECIMAL / NUMERICinteger 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 / DOUBLEfield bytes, index TYPE f64IEEE 754 semantics
VARCHAR / CHAR / TEXTfield bytes, index TYPE strbinary-safe; lexicographic byte order
BLOB / BYTEAfield bytes (no index) or a dedicated string keyvalues are arbitrary bytes everywhere
DATETIME / TIMESTAMPUnix epoch (secs or millis) as i64range index gives you BETWEEN; kevy has no date arithmetic — the app does calendar math
BOOL0 / 1 as i64indexable, composable (EQ 0)
JSON / JSONBflattened 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 vectorvector-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 / sequencesINCR 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).

UNIQUEKIND 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

SQLkevyverbs
WHERE pk = ?direct key readGET / HGETALL / HMGET
WHERE col = ?equality on a declared indexIDX.QUERY idx EQ v [FIELDS f…]
WHERE col BETWEEN a AND brange scan on a declared indexIDX.QUERY idx RANGE a b
WHERE col > ahalf-open rangeRANGE a <max-sentinel>
WHERE a = ? AND b BETWEEN …two-index compositionIDX.QUERY COMPOSE AND idxA EQ v idxB RANGE a b
WHERE col IN (v1, v2)two-leg OR, or N app-side EQ queriesIDX.QUERY COMPOSE OR idx EQ v1 idx EQ v2
LIKE 'abc%' (prefix)str range index, prefix boundsIDX.QUERY idx RANGE 'abc' 'abc\xff'
LIKE '%abc%' (infix)not supported as suchfull-text MATCH if token-shaped; else redesign the field
full-text searchKIND text, BM25IDX.QUERY idx MATCH "…"
SELECT col1, col2 (projection)field hydration… FIELDS col1 col2
COUNT(*) WHERE …count without materializingIDX.COUNT idx RANGE a b
EXPLAINparse + plan line, zero executionIDX.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 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).
  • 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.

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):

FamilyFunctions
stringslower 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
mathfloor ceil ceiling round trunc mod power pow sqrt sign abs
operatorsAND OR NOT (three-valued), comparisons = <> != < <= > >=, `\\ concatenation, searched CASE WHEN … THEN … [ELSE …] END, ::bool` with PG's input vocabulary
NULL familycoalesce nullif greatest least
date/timeextract 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
hashmd5
MySQL aliasesdate_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_matches returning zero or several rows is refused rather than flattened into a wrong scalar.

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 exists 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 n parses and runs (see docs/tables.md). What is true is the cost: it is a "skip N rows", linear in n, and — unlike every other axis here — it gets worse as you add shards, because each shard must fetch limit + offset (none of them can know which of its hits survive the global merge) and the origin materialises (limit + offset) × shards hits to return limit. Measured on 30 000 in-range rows returning LIMIT 20: OFFSET 1000 costs 1.53 ms on one shard and 6.90 ms on eight; OFFSET 10000 costs 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 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 propertykevy view
arbitrary SELECT bodyno — 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 + REFRESHMODE materialized — incrementally maintained in the write hook, never stale, no refresh job; optional TOPK k bound
ORDER BY in the viewORDER BY <index> [DESC] — a declared range index supplies the order
view over a viewno — 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:

SQLkevydelta
BEGIN … COMMIT (batch)MULTIEXECcommands queue and apply as one unit; no interactive reads inside — reads you branch on happen before MULTI
SELECT … FOR UPDATEWATCH key + MULTI/EXECoptimistic CAS, not a lock: EXEC answers nil if a watched key changed — re-read and retry (cookbook recipe 4)
ROLLBACKnonea 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 RMWEVAL (Lua)the whole script is one atomic unit on KEYS[1]'s shard; read-decide-write with real branching (lua)
serializable single-entity txnembedded store.atomic(key, …)shard-locked closure: reads see your own writes, commit is atomic + one fsync (cookbook recipe 5)
cross-entity serializableembedded atomic_all_shardsdeterministic 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

SQLkevy answer
NOT NULL / type checksthe 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)
UNIQUEfence + 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 CASCADEapp pattern: atomic block (small), kevy-cli delete-prefix (bulk), or a CDC consumer reacting to parent deletes (async — cookbook recipe 10)
triggersCDC 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

SQLkevy
CREATE INDEX idx ON t (col)`IDX.CREATE idx ON PREFIX t: FIELD col TYPE i64\f64\str KIND range`
CREATE UNIQUE INDEXKIND 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 INDEXIDX.DROP idx
\d / information_schemaIDX.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). 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

RDSkevy
mysqldump / pg_dumpkevy-cli export (logical RESP stream, redis-cli --pipe-compatible)
binary backupsnapshot files (SAVE/BGSAVEdump-<id>.rdb)
WAL / binlogthe AOF (append-only command log, per shard)
synchronous_commitappendfsync 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 notionkevy rung
async replica (MySQL default)rung 0: primary acks after local apply, replicas trail
session read-your-writesrung 1: REPL.TOKEN on the primary → REPL.WAIT token on the replica → your next read observes your write
semi-sync replicationrung 2: WAIT n timeout per write — on the primary and acked by ≥ n replicas (ACK ≠ fsync)
max replica lag guardrung 3: replica_max_staleness_ms — a stale replica answers -STALE instead of serving old reads
refuse writes without standbysrung 4: min_replicas_to_write (-NOREPLICAS)
fencing / split-brain guardrung 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 notionkevy
binlog position / LSN(generation, offset) cursor, per shard
connector snapshot + streamrebuild (SCAN your prefix) + resume from FEED.TAIL
topic-per-table filteringPREFIX filter on FEED.READ (fail-open for multi-key frames)
at-least-once deliverysame — consumers must be idempotent
transactional outbox patternunnecessary: 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:

RefusedBecauseUse instead
SQL parser / query DSLthe planner slope starts hereexplicit IDX.*/VIEW.* + this matrix
query planner / per-query index selectionengine must execute declared paths, not decideIDX.EXPLAIN (diagnostic only)
JOINsno server-side plan searchdenormalize / hydrate (FIELDS, VIA) / view
WHERE without an indexaccidental O(n) is a lie waiting to page youdeclare the path or don't ship the query
server-side constraint DSL / triggersstored app logic isn't engine-genreLua EVAL (atomic unit) + CDC consumers
DECIMAL typeno server-side arithmetic beyond i64/f64integer minor units
JSON-path querieshash fields ARE the column modelflatten to fields (cookbook recipe 9)
HAVING / aggregate expressionsquery-language slopefilter GROUPS output app-side
multi-database SELECT none keyspace per serverprefixes or separate instances
AUTH / TLStrust boundary is the network perimeterloopback bind (default), sidecar proxy
cross-DC active-active, CRDTssingle-DC charterapp-level federation
dynamic membership / auto-replace / reshardingtopology is operator-declaredconfig + rolling restart
HTTP/REST APIRESP + MCP are the two access planesany 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):

OFFSET1 shard8 shards
0219 µs526 µs
1 0001.53 ms6.90 ms
10 00011.5 ms20.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.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 / 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.