kevy4.0

kevy on IoT devices

kevy-embedded scales down as deliberately as the server scales up: a feature-tiered build that goes from a ~411 KB in-memory KV core to the full index/replication surface, musl cross-builds for Linux-class IoT boards (Pi Zero 2, OpenWrt routers, industrial ARM), and a no_std core that has been booted and exercised on a bare-metal Cortex-M target, not merely compile-checked. Resource budgets are enforced by a gate, not promised by a README.

The feature tiers

kevy-embedded exposes one Cargo feature per subsystem. The default is everything; IoT builds start from core and add only what the device actually does:

FeatureAddsPulls in
coreKV + TTL + counters + pub/sub + atomic/pipeline facades(the base surface — always on in practice; named so the minimal archetype is spellable)
persistSnapshot + AOF durability: Config::with_persist, save_snapshot, rewrite_aof, AOF replay on openkevy-persist
indexDeclared secondary indexes + materialized viewskevy-index
textFull-text (BM25) index segmentsindex + kevy-text
vectorANN / HNSW vector index segmentsindex + kevy-vector
replicateEmbed-as-replica, embed-as-writer, and the CDC feedpersist + kevy-replicate + kevy-resp
listenerThe read-only RESP listener (docs/embedded-listener.md)

Dependencies between tiers are encoded in the features themselves: text and vector imply index; replicate implies persist (replicated frames replay through the AOF verb table). Whatever you leave out is not just dead code that the linker drops — the crates behind it never enter the build graph at all.

Six archetypes are compile-gated in CI on every push, so each combination keeps building as the workspace moves:

core                        # sensor cache: RAM-only KV + TTL
core,persist                # + survives power cycles
core,index                  # + declared indexes / views
core,index,text,vector      # + search (BM25, HNSW)
core,persist,replicate      # + edge node feeding a hub
core,listener               # + redis-cli-able diagnostics port

In Cargo.toml:

[dependencies]
kevy-embedded = { version = "4", default-features = false, features = ["core"] }

The API face is the same at every tier: Store::open(Config), KevyResult / KevyError errors, borrowed-slice argv on the write methods. Code written against core recompiles unchanged when the device later grows persist.

Resource budgets (gated, not aspirational)

Two budget lines, enforced by bench/iotgate.sh as a ratchet — raising either number requires a written verdict:

BudgetLineMeasured
Binary size (core consumer, static musl)≤ 600 KB454 KB (x86_64) · 411 KB (aarch64) · 383 KB (armv7)
Empty-store RSS right after open (Linux)≤ 2 MB336 KB (aarch64)

These are measured on bench/iot-consumer — a crate deliberately kept outside the workspace whose only dependency is kevy-embedded. That is the shape you ship. An earlier version of this gate sized a workspace --example instead; building an example pulls dev-dependencies (the kevy server crate), which inflated the reported size by ~60%.

The iot cargo profile is defined in the workspace root — release codegen with size the priority:

[profile.iot]
inherits = "release"
opt-level = "z"     # size over speed
lto = true          # fat LTO: the linker drops unused subsystems
codegen-units = 1
strip = true

Reproduce the size number:

cargo build --profile iot -p kevy-embedded --example iot_core \
  --no-default-features --features core
ls -l target/iot/examples/iot_core

A sensor-cache in full

The iot_core example is the shape most devices need — an in-memory KV with TTLs and a manually driven expiry sweep (no background threads unless you ask for them):

use kevy_embedded::{Config, Store};

fn main() -> kevy_embedded::KevyResult<()> {
    // Manual reaper: no thread is spawned; the device's own loop
    // drives expiry at whatever cadence it already runs.
    let s = Store::open(Config::default().with_ttl_reaper_manual())?;

    s.set(b"sensor:1", b"22.5")?;
    s.set(b"sensor:2", b"3.3")?;
    s.expire(b"sensor:2", core::time::Duration::from_secs(60))?;

    // Call from the main loop / timer ISR bottom half:
    let _expired = s.tick();
    Ok(())
}

Leave the reaper on its default (a background thread) on boards where a thread is cheap; take with_ttl_reaper_manual() + tick() where you own the loop. TTL precision tracks the tick cadence.

Cross-compiling: musl and the CI matrix

Static musl binaries are the deployment currency of Linux-class IoT — one file, no glibc coupling, scp and run.

Platform coverage — what has actually been run

Every row below was built AND executed, not merely compile-checked. The core-tier consumer was run on a real kernel for that architecture; the sizes are the static-musl artifact.

TargetBoard classSizeStatus
x86_64-unknown-linux-muslindustrial x86, gateways454 KBrun
aarch64-unknown-linux-muslPi Zero 2 / Pi 4-5 / most ARM64 SBC411 KBrun — natively, 336 KB empty-store RSS, 2.86M store-ops/s
armv7-unknown-linux-musleabihfPi 2-3, older 32-bit ARM383 KBrun (emulated)
arm-unknown-linux-musleabihfPi Zero / Pi 1 (ARMv6)411 KBrun (emulated)
riscv64gc-unknown-linux-muslRISC-V SBC420 KBrun (emulated) — needs a RISC-V cross-gcc as the linker (its target spec wants libgcc_s, which rust-lld cannot supply) plus crt-static
thumbv7em-none-eabihfCortex-M4/M7 MCU145 KB firmwarerun on bare metalkevy-store only, not the full kevy-embedded surface (that needs std). See below.

The aarch64 numbers are the trustworthy performance ones: that row ran natively on ARM64. The armv7/ARMv6 rows prove the code is correct on 32-bit ARM, but their timing and RSS carry emulator overhead and should not be read as device figures.

All shipped targets are compile-gated in CI (the standalone consumer is built for each on every push), with the full default surface checked on the Tier A targets:

rustup target add aarch64-unknown-linux-musl
cargo build --profile iot --target aarch64-unknown-linux-musl -p kevy-embedded

rustup target add armv7-unknown-linux-musleabihf
cargo build --profile iot --target armv7-unknown-linux-musleabihf -p kevy-embedded

kevy's zero-dependency law pays off here: there is no C library to cross-compile and no -sys crate zoo — the only OS boundary is kevy's own kevy-sys, and the embedded closure below core doesn't even include that.

Running the test suite under emulation is a one-liner when you want more than a compile proof:

CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUNNER=qemu-aarch64 \
  cargo test -p kevy-embedded --target aarch64-unknown-linux-musl

The no_std core

Below Linux entirely, the storage stones build without std. Five crates carry #![no_std] cores behind their std default feature — kevy-store, kevy-hash, kevy-bytes, kevy-map, kevy-madvise.

CI does not merely compile them for a Cortex-M: it boots them on one. bench/mcu-probe is a bare-metal firmware — hand-written vector table, bump allocator, panic handler, semihosting I/O, because kevy takes no dependencies and so cannot reach for cortex-m-rt or embedded-alloc — that stands up a real Store under QEMU (mps2-an386, Cortex-M4) and exercises the KV and TTL surface. It runs on every push; if the store misbehaves on an MCU, the build fails.

cd bench/mcu-probe && cargo run --release

Measured on that MCU, with a 64 KiB heap:

Firmware image145 KB
Heap for an empty Store0 B — the store allocates nothing until you write
Heap for 64 sensor keys17.8 KB
TTLexpires correctly when the firmware advances its own clock

What runs on an MCU is kevy-store, not kevy-embedded. The embedded façade (Config, background reapers, persistence, the listener) needs std; the MCU gets the storage engine underneath it, driven directly.

What the no_std cut means in practice:

One detail is worth knowing because it is the kind that silently breaks elsewhere: the host-fed clock is a single 64-bit cell that must read atomically. On ISAs with 64-bit atomics it is a plain AtomicU64; on 32-bit-only MCUs (ARMv7E-M has no 64-bit atomics) it degrades to a single-writer seqlock over two AtomicU32 halves — readers retry on a torn read, and since the host feeds the clock from one context, the retry loop settles immediately. Feeding the clock from multiple contexts concurrently is outside the contract.

Sizing guidance

You useBinaryDelta
KV + TTL (core)392 KB
+ durability (snapshot + AOF)601 KB+209 KB
+ RESP listener629 KB+28 KB
+ secondary index667 KB+66 KB
+ full-text (BM25)691 KB+24 KB
+ vector ANN (HNSW)696 KB+29 KB

Durability is by far the largest single line — everything else is a small increment on top of it. Empty-store RSS moves the same way: 336 KB at core, 656 KB with every feature compiled in, so the tier you pick shows up in resident memory even more than in bytes on disk.

What this is not

There is no attempt to make the server small: kevy (the binary), kevy-rt, kevy-uring and friends assume a real kernel and are out of scope for the IoT cut. The embedded library is the product here — your firmware is the server.