kevy-embedded 1.15.0

Embedded mode for kevy — in-process Redis-compatible KV without the server/runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# kevy-embedded

In-process Redis-compatible key–value store — kevy without the network.
Pure Rust, zero `crates.io` dependencies, builds for `wasm32` as well as
native.

```rust
use kevy_embedded::{Store, Config};

let s = Store::open(Config::default())?;
s.set(b"greeting", b"hello")?;
assert_eq!(s.get(b"greeting")?, Some(b"hello".to_vec()));
# Ok::<(), std::io::Error>(())
```

## Install

```sh
cargo add kevy-embedded
```

## When to use

- **Embedded cache** — replace `lru::LruCache` / `moka` / `dashmap` with
  a fully Redis-semantic LRU (or LFU) that speaks all 5 data types.
- **Embedded persistent store** — opt into AOF + snapshot via
  `Config::default().with_persist("./data")`. Restart-safe out of the
  box.
- **WASM / single-threaded apps** — use
  `Config::with_ttl_reaper_manual()` and call `Store::tick()` from your
  own event loop. Full WASM walkthrough (browser / WASI / Cloudflare
  Workers) in [`docs/wasm.md`]https://github.com/goliajp/kevy/blob/develop/docs/wasm.md.

## When NOT to use

- You want a TCP-reachable Redis server → use the [`kevy`]https://crates.io/crates/kevy
  crate's `serve(...)` entry point or the `goliakk/kevy` Docker image.
  `kevy` server runs the full thread-per-core reactor + cross-shard
  routing.
- You need cross-process concurrency → kevy-embedded is single-process
  (one mutex). For multi-process / multi-host, the network layer is the
  contract — use the server.

## All five Redis data types

```rust
use kevy_embedded::{Store, Config};

let s = Store::open(Config::default())?;

// String
s.set(b"k", b"v")?;
assert_eq!(s.get(b"k")?, Some(b"v".to_vec()));
s.incr(b"counter")?;            // returns 1
s.incr_by(b"counter", 41)?;     // returns 42

// Hash
s.hset(b"user:1", &[(b"name", b"alice"), (b"age", b"30")])?;
assert_eq!(s.hget(b"user:1", b"name")?, Some(b"alice".to_vec()));

// List
s.rpush(b"queue", &[b"a", b"b", b"c"])?;
assert_eq!(s.lpop(b"queue", 1)?, vec![b"a".to_vec()]);

// Set
s.sadd(b"tags", &[b"rust", b"kv", b"embed"])?;
assert_eq!(s.scard(b"tags")?, 3);
assert!(s.smembers(b"tags")?.iter().any(|m| m == b"rust"));

// Sorted set — note the (score, member) tuple order
s.zadd(b"leaderboard", &[(100.0, b"alice"), (200.0, b"bob")])?;
assert_eq!(s.zscore(b"leaderboard", b"bob")?, Some(200.0));
# Ok::<(), std::io::Error>(())
```

## Phase 2+ ops (v1.5.0 → v1.8.1, +41 new methods)

The 1.4.21 surface was sufficient for cache / session / pubsub / job-queue
use; v1.5.0 → v1.8.1 round out the embedded API to "Redis-shaped expected"
parity. Net-additive — every 1.4.21 user upgrades drop-in.

```rust
use kevy_embedded::{Store, Config};
use std::time::Duration;

let s = Store::open(Config::default())?;

// === Hash mass-getters (v1.5.0) ===
s.hset(b"thread:1", &[(b"subject", b"Re: hi"), (b"unread", b"3")])?;
let row: Vec<(Vec<u8>, Vec<u8>)> = s.hgetall(b"thread:1")?;
let some_fields = s.hmget(b"thread:1", &[b"subject", b"unread"])?;
assert!(s.hexists(b"thread:1", b"subject")?);
assert_eq!(s.hlen(b"thread:1")?, 2);

// === Hash atomic increments (v1.5.0 + v1.7.0) ===
s.hincrby(b"thread:1", b"unread", 1)?;       // +1
s.hincrbyfloat(b"stat", b"avg_latency", 0.5)?;

// === Sorted set range + atomic incr (v1.5.0) ===
s.zadd(b"by_activity", &[(100.0, b"thread:1"), (200.0, b"thread:2")])?;
let top: Vec<(Vec<u8>, f64)> = s.zrevrange(b"by_activity", 0, 9)?;
s.zincrby(b"by_activity", 50.0, b"thread:1")?; // atomic bump

// === Multi-key strings (v1.6.0) ===
s.mset(&[(b"k1", b"v1"), (b"k2", b"v2"), (b"k3", b"v3")])?;
let vals: Vec<Option<Vec<u8>>> = s.mget(&[b"k1", b"k2", b"missing"])?;

// === Keyspace introspection (v1.6.0) ===
let user_keys: Vec<Vec<u8>> = s.keys(Some(b"user:*"), None);

// === Atomic get + TTL (v1.6.0) ===
let val = s.getex(b"session:42", Duration::from_secs(3600))?;

// === Set algebra (v1.6.0) ===
s.sadd(b"allow:user:1", &[b"a", b"b"])?;
s.sadd(b"allow:user:2", &[b"b", b"c"])?;
let common: Vec<Vec<u8>> = s.sinter(&[b"allow:user:1", b"allow:user:2"])?;

// === List slice + index (v1.5.0) ===
s.rpush(b"log", &[b"a", b"b", b"c", b"d"])?;
let window: Vec<Vec<u8>> = s.lrange(b"log", 0, 1)?;
let last = s.lindex(b"log", -1)?;
let removed = s.lrem(b"log", 0, b"a")?;          // remove all "a"
let new_len = s.linsert(b"log", true, b"c", b"NEW")?; // before "c"

// === String single-call atomic (v1.5.0 + v1.6.0 + v1.8.1) ===
let prev = s.getset(b"counter", b"0")?;
let deleted = s.getdel(b"once-only")?;
let set_ok = s.setnx(b"singleton", b"locked")?;
s.append(b"log", b" + more")?;
assert!(s.strlen(b"log")? > 0);
s.decr(b"countdown")?;
s.decrby(b"countdown", 5)?;
s.incrbyfloat(b"price", 1.99)?;

// === Hash conditional + bonus (v1.8.1) ===
s.hsetnx(b"thread:1", b"created_at", b"2026-07-01")?;

// === TTL — absolute + relative (v1.6.0) ===
s.expireat(b"session:42", 1_900_000_000)?;
s.pexpire(b"cache:k", 30_000)?;                // 30 seconds in ms
let ttl_s = s.ttl_secs(b"session:42");         // -1 no TTL, -2 absent

// === Bitmap (v1.8.0) ===
s.setbit(b"bloom:1", 0xff, 1)?;
let bit = s.getbit(b"bloom:1", 0xff)?;
let n = s.bitcount(b"bloom:1", None)?;

// === Perfgate observability (v1.7.0) ===
let lock_ns: u128 = s.ping_ns();
# Ok::<(), std::io::Error>(())
```

**Full method list per release**: see the [CHANGELOG](https://github.com/goliajp/kevy/blob/master/CHANGELOG.md)
entries for v2.0.3 through v2.0.8. **Open items** (pending design conversation
with downstream embedded users): `atomic { … }` closure-style transaction,
cursor-based `scan` / `hscan` / `zscan`, and `pipeline()` for batched writes.

## Persistence

`Config::default().with_persist(dir)` enables both snapshot
(`dir/dump-0.rdb`) and AOF (`dir/aof-0.aof`). On `Store::open` the
snapshot loads first, then the AOF replays — a fresh process picks up
exactly where the previous one left off. AOF auto-appends on every
write; fsync policy:

| Policy | Data loss on crash | Throughput |
|---|---|---|
| `Always` | 0 bytes | ~50 % vs `EverySec` |
| `EverySec` (default) | ≤ 1 second | baseline |
| `No` | up to ~30 s (kernel pagecache flush) | slightly faster |

```rust
use kevy_embedded::{Store, Config, AppendFsync};

let s = Store::open(
    Config::default()
        .with_persist("./mydata")
        .with_appendfsync(AppendFsync::Always)   // strict no-loss
)?;
```

`Store::save_snapshot()` runs the equivalent of `SAVE` — dumps a full
snapshot synchronously. `Store::rewrite_aof()` runs the equivalent of
`BGREWRITEAOF` — rebuilds a compact AOF from current in-memory state
and atomically swaps it in. v1.0 is synchronous (blocks the calling
thread); v1.x will incrementalise.

## Eviction

Set a hard memory ceiling via `Config::with_max_memory(bytes)` plus an
`EvictionPolicy`:

```rust
use kevy_embedded::{Store, Config, EvictionPolicy};

let s = Store::open(
    Config::default()
        .with_max_memory(64 * 1024 * 1024)    // 64 MB
        .with_eviction(EvictionPolicy::AllKeysLru)
)?;
```

All 8 Redis policies are supported: `NoEviction`, `AllKeysLru`,
`AllKeysLfu`, `AllKeysRandom`, `VolatileLru`, `VolatileLfu`,
`VolatileRandom`, `VolatileTtl`. LRU/LFU approximation matches Redis
(24-bit clock + sample-based selection with `maxmemory-samples = 5`).

## Thread safety

`Store::set` / `get` / etc. take `&self`. Internally there's **one
`Mutex`** around the keyspace — fine for embedded use, where the
amortised cost is dwarfed by your app's work. **`Store` is `Clone`
(v1.1.0+)**: a clone is a cheap `Arc` bump that reaches the same
underlying keyspace + AOF + reaper + pub/sub bus. The reaper thread is
joined and the AOF is flushed exactly once, when the last clone drops.

```rust
use kevy_embedded::{Store, Config};

let s = Store::open(Config::default())?;
let s2 = s.clone();
std::thread::spawn(move || {
    s2.set(b"from-thread", b"works").unwrap();
});
# Ok::<(), std::io::Error>(())
```

For cross-core scale, use the [`kevy`](https://crates.io/crates/kevy)
server instead — it shards the keyspace across cores with no shared lock.

## In-process pub/sub (v1.1.0+)

```rust
use kevy_embedded::{Store, Config, PubsubFrame};

let s = Store::open(Config::default())?;
let s2 = s.clone();
let mut sub = s.subscribe(&[b"news"]);
let _ack = sub.recv()?;

s2.publish(b"news", b"hello");
match sub.recv()? {
    PubsubFrame::Message { channel, payload } => { /* deliver to your app */ }
    _ => {}
}
# Ok::<(), std::io::Error>(())
```

Channel + pattern subscriptions (`PSUBSCRIBE` glob syntax). Drop the
`Subscription` to unsubscribe from everything atomically. Pair with the
[`kevy-client`](https://crates.io/crates/kevy-client) URL facade to
make the same code work against an in-process bus (`mem://name`) in dev
and a kevy server (`kevy://host:port`) in prod — no scheme branching.

## Embed + server: join a cluster from your own process (v1.22+)

`kevy-embedded` isn't only "kevy without the network" — it also
**plugs into a server cluster** as a first-class participant. The
same `Store` handle that serves your in-process reads can subscribe
to a server primary's replication stream, or own a key-prefix in a
multi-writer cluster. RESP2 / replication wire-format is identical
to a `kevy-server` replica, so the cluster sees no difference.

Three deployment shapes, all backed by the same `Store` API:

### 1. Pure embed — no network at all

The default. Reads and writes hit the in-process keyspace. Use this
when one process owns the data.

```rust
use kevy_embedded::{Store, Config};
let s = Store::open(Config::default().with_persist("./data"))?;
s.set(b"k", b"v")?;
# Ok::<(), std::io::Error>(())
```

### 2. Embed-as-read-replica (Phase 2 of v3-cluster, v1.22)

Subscribe to a server primary's replication stream — every applied
mutation flows into the in-process `Store` over RESP. Local reads
pay **zero network round-trip**; local writes return `READONLY`
(send them to the primary instead). Catch-up on reconnect is
automatic (per-shard offsets + snapshot ship for fall-behind
replicas).

```rust
use kevy_embedded::Store;

// One-liner: connect to primary, fresh replica-id, sensible reconnect.
let s = Store::open_replica("primary.internal:16004")?;

// Read fan-out scales with N application processes; primary handles writes.
let v: Option<Vec<u8>> = s.get(b"hot-key")?;
assert!(s.is_replica());
// s.set(b"k", b"v") → Err(READONLY)
# Ok::<(), std::io::Error>(())
```

Tunable variant when you need a custom reconnect window or a stable
replica-id (so the primary reuses your backlog after a quick
restart):

```rust
use kevy_embedded::{Store, Config};
use std::time::Duration;

let s = Store::open(
    Config::default()
        .with_replica_upstream("primary.internal:16004")
        .with_replica_id("app-billing-pod-7")
        .with_replica_reconnect(Duration::from_millis(50), Duration::from_secs(5))
)?;
# Ok::<(), std::io::Error>(())
```

What you get for free vs running a `kevy` server as the replica:

- **No extra process** — the replica state lives in the same address
  space as your app, so reads are a `Store::get` call, not a TCP
  round-trip.
- **Cache locality** — application code that reads via this `Store`
  doesn't compete with a `redis-cli` proxy port for the page cache.
- **Same wire protocol** — the primary serves embed-replicas and
  server-replicas off the same per-shard backlog; `INFO replication`
  on the primary lists both.

When to use it: read-heavy apps that want **eventual** consistency
with a single writeable primary, deployed as N stateless application
pods that each carry the keyspace in-process for read fan-out.

Full server-side recipe in
[`docs/replication.md`](https://github.com/goliajp/kevy/blob/develop/docs/replication.md);
cluster topology + failover in
[`docs/cluster.md`](https://github.com/goliajp/kevy/blob/develop/docs/cluster.md).

### 3. Embed-as-scoped-writer (Phase 3 of v3-cluster, v1.22)

`[cluster] scopes = "app:billing:=embed-a,app:catalog:=embed-b"` on
the server side declares per-prefix writer ownership; an embed
process that owns a prefix can **write locally** and the cluster
routes wrong-prefix writes to the correct owner via the
`-MISDIRECTED writer is <host:port>` redirect (in the spirit of
`-MOVED`, scoped to writer identity).

This makes "the writer" a piece of application logic that lives
inside your app process, while everyone else sees a normal cluster
client. Use it when one application module is the natural source of
truth for a key-prefix (the billing service owns `app:billing:*`,
the catalog service owns `app:catalog:*`), and you want the writes
to skip the network entirely while reads stay cluster-cached.

Server-side TOML and the `MOVE-SCOPE` migration protocol live in
[`docs/cluster.md`](https://github.com/goliajp/kevy/blob/develop/docs/cluster.md);
the embed side is just `Store::open(Config::default())` — the cluster
wiring is operator config, not embed config.

### 4. URL facade — same code, dev = embed, prod = server

`kevy-client` v1.6.0+ accepts both `mem://` (in-process via
`kevy-embedded`) and `kevy://host:port` / `redis://…` (TCP):

```rust
let url = std::env::var("KEVY_URL").unwrap_or_else(|_| "mem://app".into());
let mut conn = kevy_client::Connection::open(&url)?;  // works for both
conn.set(b"k", b"v")?;
# Ok::<(), std::io::Error>(())
```

So a single Rust binary can boot **as a server, as a pure embedded
library, or as a hybrid** (embed-as-replica + server primary
elsewhere) — the calling code never branches on transport. Pub/sub,
`WATCH`-driven transactions, and typed `Transaction::exec_typed`
reply cursors are all URL-symmetric.

Caveats:

- Embed-as-replica is **single-DC** and shares all of kevy's
  out-of-scope items (no AUTH/TLS, no cross-DC active-active —
  primary + embeds in the same trust domain).
- Scope ownership conflicts at startup are fatal (the boot prints
  `bad [cluster] scopes config` and exits) rather than ambiguous —
  the operator picks one writer per prefix.
- `Store::is_replica()` lets your code branch when a write would
  return `READONLY`; route writes via `Connection::open(kevy_url)`
  for the read-replica deployment shape.

## Migrating from `lru` / `moka` / `dashmap`

| If you had... | kevy-embedded equivalent | Notes |
|---|---|---|
| `lru::LruCache<K, V>` | `Store + with_eviction(AllKeysLru)` | Byte-keys (`&[u8]`); `with_max_memory` instead of count cap |
| `moka::sync::Cache` | `Store + with_eviction(AllKeysLfu)` | LFU matches moka's default expectation |
| `dashmap::DashMap` | `Arc<Store>` | DashMap is concurrent; one Mutex but value is much richer (5 types, persistence) |
| `sled::Db` | `Store + with_persist` | sled is a tree DB; kevy is a hash KV — pick by access pattern |

Versus `redis::Client::open("redis://...")` against a local Redis — you
**lose** zero performance and **gain**:
- No TCP roundtrip (~100 µs each)
- No serialization overhead
- One process to deploy
- No background server to monitor

You **keep** Redis semantics: TTL, eviction, all 5 types, byte-strings.

## Maintenance hooks

For very long-running embedded use, periodically:

```rust
s.tick();           // active TTL reaper — drops expired keys eagerly
s.save_snapshot()?; // RDB-style dump for restart speed
s.rewrite_aof()?;   // compact AOF, drops redundant writes
```

If you're in `Config::with_ttl_reaper_manual()` mode (WASM /
single-threaded), `tick()` is the only way TTL'd keys get reaped between
accesses.

## Examples

In the repo: [`examples/embedded.rs`](https://github.com/goliajp/kevy/blob/develop/crates/kevy-embedded/examples/embedded.rs)
— minimal CRUD; [`examples/embedded-cache.rs`](https://github.com/goliajp/kevy/blob/develop/crates/kevy-embedded/examples/embedded-cache.rs)
— hard-cap LRU cache.

## Dependencies

Zero `crates.io` dependencies. Only `kevy-store` (keyspace) +
`kevy-persist` (snapshot / AOF). The whole network layer
(`kevy-rt`, `kevy-sys`, `kevy-uring`) is intentionally NOT pulled in,
so kevy-embedded compiles for any target `kevy-store + kevy-persist`
compile for — including `wasm32-unknown-unknown` and `wasm32-wasip1`.

## License

MIT OR Apache-2.0, at your option.