Features

EdgeStore bundles KV storage, vector search, full-text search, and replication into a single embedded library. Each layer sits cleanly on top of the core without polluting it.

Key-Value Store

Ordered byte keys with namespace isolation. Every key is prefix-encoded as {ns_len:u16}{ns}{key}, giving you a single flat sorted keyspace with logical separation.

  • put / get / delete — single-record operations
  • range — exclusive-end range scans
  • prefix — prefix-bounded iteration
  • namespaces — multi-tenant isolation without ColumnFamily complexity
// Basic operations
db.put(b"users", b"alice", b"{"age":30}")?;
let val = db.get(b"users", b"alice")?;

// Range scan within a namespace
for (k, v) in db.range(b"users", b"a", b"z")? {
    println!("{} = {}", k, v);
}

// Prefix scan
for (k, v) in db.prefix(b"events", b"2024-01-")? {
    // all keys starting with 2024-01-
}

Transactions

Atomic multi-record batches with single-writer semantics. A transaction accumulates puts and deletes in memory, then commits as a single WAL-append with group fsync.

  • begin() — start a transaction
  • commit_transaction() — atomic commit to WAL
  • rollback_transaction() — discard without writing
  • All-or-nothing: partial commits are impossible
let mut tx = db.begin();
tx.put(b"accounts", b"alice", b"900", 0, 0)?;
tx.put(b"accounts", b"bob", b"1100", 0, 0)?;

// Both writes land atomically, or neither does
db.commit_transaction(tx)?;

TTL & Lazy Expiry

Records with TTL are grouped by their predicted death time into cohorts. When a cohort fully expires, compaction removes it with zero live-data relocation — the key mechanism that drives write amplification toward 1.0.

  • put_with_ttl — write with seconds-to-live
  • Lazy expiry — expired records remain readable until compaction
  • Zero-cost collection — fully-expired cohorts need no live-record scans
// Session expires in 60 seconds
db.put_with_ttl(b"sessions", b"sess-42", b"active", 60)?;

// Still readable immediately after TTL expires
// (lazy expiry contract)
std::thread::sleep(Duration::from_secs(61));
let val = db.get(b"sessions", b"sess-42")?; // Some(...)

// After compaction, the cohort is gone
db.compact_once()?;
let val = db.get(b"sessions", b"sess-42")?; // None

Vector Search

Store and search dense vectors with SIMD-accelerated distance computation. Two search modes: flat brute-force scan (exact, fast for <500K vectors) and HNSW graph index (approximate, scales to millions).

  • Metrics — Cosine, L2 (Euclidean), Dot Product
  • Dtypes — f32, f16, i8 with automatic widening
  • SIMD — wide::f32x8 on x86_64/AVX2, scalar fallback everywhere
  • HNSW — probabilistic layer assignment, greedy beam search
// Insert vectors
db.vector_put(b"embeddings", b"doc1", 128, Dtype::F32, &vec_data)?;

// Build HNSW index for fast ANN search
db.build_vector_index(b"embeddings", 128, Dtype::F32, Metric::Cosine)?;

// Search top-10 nearest neighbors
let results = db.vector_search(
    b"embeddings", &query_vec, 10, Metric::Cosine
)?;
for r in results {
    println!("{} distance={:.4}", r.key, r.distance);
}

Full-Text Search

Embedded Algolia-like search with BM25 relevance scoring. Single merged inverted index per namespace, incrementally updated on write. Search reads the merged index directly — O(1) single-record deserialize, not O(N) per-document merging. No separate server process.

  • BM25 ranking — k1=1.2, b=0.75, configurable
  • Tokenization — English with stopwords and simple stemming
  • Faceting — filter results by facet values
  • Typo tolerance — Levenshtein distance ≤ 1 with 0.5 weight penalty
// Index a document
db.index_text(b"docs", b"doc1", "EdgeStore is fast and reliable")?;

// Search with BM25 ranking
let results = db.search_text(b"docs", "fast database", 5)?;
for hit in results {
    println!("{} score={:.3}", hit.key, hit.score);
}

// With facet filtering
let opts = SearchOptions::new()
    .with_facet("category", "database");
let filtered = db.search_text(b"docs", "fast", 5)?;

Cost Accounting & Bounded Scans v1.5.0

Every read API has a _with_stats variant that returns a QueryStats struct alongside the result. Agent loops and quota-enforcement systems can measure exactly how many bytes and segments each query touched — no guesswork.

  • get_with_stats — point lookup + QueryStats{segments_scanned, bytes_scanned, items_examined}
  • range_with_stats / prefix_with_stats — scan variants that count bytes as they iterate
  • vector_search_with_stats / search_text_with_stats — same for ANN and BM25 paths
  • range_budgeted / prefix_budgeted — accept a ScanBudget{max_items, max_bytes} and stop early, returning a BudgetedScan{items, truncated, next_key} with a continuation cursor
// Cost-aware point lookup
let (val, stats) = db.get_with_stats(b"docs", b"doc1")?;
println!("bytes={} segs={}", stats.bytes_scanned, stats.segments_scanned);

// Bounded range scan — stop after 500 items or 1 MB
let budget = ScanBudget { max_items: 500, max_bytes: 1024 * 1024 };
let result = db.range_budgeted(b"logs", b"2026-01", b"2026-07", budget)?;
println!("{} items, truncated={}", result.items.len(), result.truncated);

// Continue from where we stopped
if result.truncated {
    let page2 = db.range_budgeted(
        b"logs", &result.next_key, b"2026-07", budget
    )?;
}

// ANN search with cost reporting
let (hits, stats) = db.vector_search_with_stats(
    b"vec", &query, 10, Metric::Cosine
)?;

Search Snippets v1.5.0

search_text_with_snippets returns SnippetResult values that bundle a document ID, BM25 score, and a list of Snippet objects — each with a context window around every matched term. The tokenizer now records exact character offsets so context windows align precisely with the original text.

  • SnippetResult{doc_id, score, snippets: Vec<Snippet>}
  • Snippet{term, span, context_start, context_end} where span is the matched text
  • Character offsets — tokenizer v3 stores char-start positions, enabling accurate window extraction for any Unicode text
  • Ideal for RAG pipelines: pass span to an LLM as highlighted evidence without sending the full document
db.index_text(b"docs", b"doc1",
    "The quick brown fox jumps over the lazy dog")?;

let results = db.search_text_with_snippets(
    b"docs", "fox", 5
)?;

for r in results {
    println!("doc={} score={:.3}", r.doc_id, r.score);
    for s in r.snippets {
        // span = "fox", context window around it
        println!("  term={} span={:?}", s.term, s.span);
    }
}
// Output:
//   doc=doc1  score=1.847
//   term=fox  span="fox"

Replication

Merkle-based delta sync compares range-level Merkle trees between nodes to identify divergent segments. Only missing segments are transferred — not full copies. Lives in edgestore-repl, not the core crate.

  • Content-addressed — BLAKE3 hashes guarantee integrity
  • LWW conflict resolution — wall-clock timestamp wins
  • Resume-friendly — interrupted sync resumes from last Ack(lsn)
  • S3 backend — cold archive and replication mailbox (enable s3 feature)
// Requires edgestore-repl crate
use edgestore_repl::{AntiEntropyLoop, S3RemoteStore};

// Primary: export manifest
let segments = primary.export_manifest()?;

// Replica: compare and find missing
let missing = replica.missing_segments(&segments);

// Transfer each missing segment
for seg in missing {
    let data = primary.read_segment(&seg)?;
    replica.import_segment(&seg, &data)?;
}

// Verify Merkle roots match
let match_result = replica.compare_merkle(&primary_root)?;

Tiered Storage edgestore-tier

When local disk can't hold your full dataset, edgestore-tier wraps a local Engine with a transparent S3 read-through layer. Reads try local first; on a miss they fetch the matching archived segment from S3 ephemerally without permanently importing it.

  • Caller-driven archiving — policy (when to archive, when to prune) stays in your code
  • Read lock fast path — hot-window queries never block behind the writer (v1.4.0)
  • Flush-wake notifyflush_notify() on AsyncTieredEngine wakes a tiering worker the moment a segment lands
  • Text index strippingwith_text_stripping(true) auto-strips text index bytes from archived segments to reclaim local disk
  • Vector index strippingwith_vector_stripping(true) (v1.5.0) strips __vec__ records from archived segments. Paired with strip_vector_index(seg_id) on the core engine for manual use.
// Requires edgestore-tier crate
use edgestore_tier::TieredEngine;
use edgestore_repl::S3RemoteStore;

let local = Engine::open(config)?;
let remote = S3RemoteStore::new("bucket", None, None)?;
let mut tiered = TieredEngine::new(local, Box::new(remote));

// Writes stay local
tiered.put(b"logs", b"2026-07-12", data)?;

// Reads fall through to S3 on local miss
let val = tiered.get(b"logs", b"2025-01-01")?;

// Archive old segments to S3
let old = tiered.local().list_segment_metas();
tiered.archive_segments(&old)?;

Snapshots

RAII point-in-time reads that pin segments in the manifest. Compaction can proceed on live data while snapshots hold old segments for consistent reads.

  • snapshot() — capture current segment set
  • RAII release — Drop unpins segments automatically
  • SnapshotRegistry — tracks active snapshots, excludes pinned segments from compaction
// Capture point-in-time view
let snap = db.snapshot()?;

// Modify live data
db.put(b"data", b"key", b"new")?;

// Snapshot still sees old value
let old = snap.get(b"data", b"key")?;

// Release when done — pins are dropped automatically
drop(snap);

SSD Optimization

Every design decision prioritizes SSD health: append-only writes avoid in-place rewrites, deathtime-cohort compaction aligns invalidation with erase blocks, and FDP hints place related data together on NVMe 2.0 hardware.

  • Append-only — no page rewrites, minimal SSD GC pressure
  • ZSTD L1 compression — reduces bytes written without CPU cost
  • 4 KiB block alignment — matches SSD page size
  • FDP hints — NVMe 2.0 placement guidance (Linux stub)

Write Amplification Comparison

Database App WA Device WA
EdgeStore~1x~1.2x
RocksDB10-30x~90x
LevelDB10-30x~90x
SQLite WAL2-4x~8x

For TTL workloads. See Papers for methodology.