kevy4.0

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:

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:

ORDER BY / LIMIT / OFFSET

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]

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)

Pagination and optimistic locking patterns

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 / auto 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
OFFSET paginationO(N) skip is an anti-patterncursors
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

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

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.