KV
Flo’s KV store provides strongly consistent key-value storage backed by the Unified Append Log. Every write is Raft-replicated across the cluster before being acknowledged. Reads go directly to the local projection — no Raft round-trip, no cross-node hops for single-key lookups.
Core Concepts
Section titled “Core Concepts”Versioned Writes
Section titled “Versioned Writes”Every mutation to a key creates a new version. The version number is the Raft log index at which the write was committed. This means versions are globally ordered and monotonically increasing across all keys.
Put responses include the new version number, which you can use for subsequent CAS updates:
flo kv set counter "1" # → version=1flo kv set counter "2" --cas 1 # → version=2 (succeeds)flo kv set counter "3" --cas 1 # → error: Version mismatchNamespace Isolation
Section titled “Namespace Isolation”Keys live inside namespaces. The default namespace is used when no -n flag is provided. Keys with the same name in different namespaces are fully independent — they have separate values, versions, and TTLs.
# Create namespacesflo ns create stagingflo ns create production
# Same key, different namespacesflo kv set db_url "postgres://staging" -n stagingflo kv set db_url "postgres://prod" -n production
# Each returns its own valueflo kv get db_url -n staging # → postgres://stagingflo kv get db_url -n production # → postgres://prodDeleting or overwriting a key in one namespace has no effect on the same key in other namespaces. Conditional flags like --nx are also per-namespace — a key can be “new” in one namespace while already existing in another.
Operations
Section titled “Operations”flo kv set mykey "hello world"| Flag | Description |
|---|---|
--ttl <seconds> | Expire after N seconds (0 = no expiry) |
--nx | Only set if the key does not already exist |
--xx | Only set if the key does already exist |
--cas <version> | Only set if the current version matches exactly |
-n <namespace> | Target namespace |
-r <routing-key> | Explicit shard routing key for co-location |
--nx and --xx are mutually exclusive. --cas cannot be combined with --nx.
CAS version 0 has a special meaning: it asserts the key must not exist yet. This is equivalent to --nx but expressed as a version constraint.
flo kv get mykeyflo kv get mykey --format json # includes version number| Flag | Description |
|---|---|
--wait <ms> | Wait until the key exists, then return (0 = wait forever) |
--block <ms> | Wait for the next version change, even if the key already exists (0 = forever) |
--format <fmt> | Output format: json, table, raw |
-n <namespace> | Target namespace |
-r <routing-key> | Explicit shard routing key |
--wait and --block serve different purposes:
--waitis for coordination — one process creates a key, another waits for it to appear. If the key already exists, it returns immediately.--blockis for watching — subscribe to the next change on an existing key. Even if the key exists now, it waits for a newer version.
# Process A: wait for a result to appearflo kv get job:result --wait 30000
# Process B: watch for config changesflo kv get config:feature-flags --block 0Multi-key get
Section titled “Multi-key get”Fetch many keys in a single round trip. Keys may live on different shards — the server gathers results in parallel and returns one entry per requested key (with a found flag distinguishing missing keys from empty values).
flo kv mget user:1 user:2 user:3flo kv mget user:1 user:2 --output json| Flag | Description |
|---|---|
--output <fmt> | text (default), json, table |
-n <namespace> | Target namespace |
Limited to 256 keys per call. The total response is capped at 1 MB; entries beyond that limit are omitted (re-issue the call with the remaining keys).
entries, err := client.KV.MGet([]string{"user:1", "user:2", "user:3"}, nil)if err != nil { return err }for _, e := range entries { if e.Found { fmt.Printf("%s = %s (v%d)\n", e.Key, e.Value, e.Version) } else { fmt.Printf("%s missing\n", e.Key) }}entries = await client.kv.mget(["user:1", "user:2", "user:3"])for e in entries: if e.found: print(f"{e.key} = {e.value!r} (v{e.version})") else: print(f"{e.key} missing")const entries = await client.kv.mget(["user:1", "user:2", "user:3"]);for (const e of entries) { if (e.found) { console.log(`${e.key} = ${new TextDecoder().decode(e.value)} (v${e.version})`); } else { console.log(`${e.key} missing`); }}var result = try kv.mget(&.{ "user:1", "user:2", "user:3" }, .{});defer result.deinit();for (result.entries) |e| { if (e.found) { std.debug.print("{s} = {s} (v{d})\n", .{ e.key, e.value, e.version }); } else { std.debug.print("{s} missing\n", .{e.key}); }}Delete
Section titled “Delete”flo kv delete mykeyAliases: del. Deleting a non-existent key returns a not-found error.
| Flag | Description |
|---|---|
-n <namespace> | Target namespace |
-r <routing-key> | Explicit shard routing key |
List / Scan
Section titled “List / Scan”flo kv list # all keysflo kv list --prefix "user:" # prefix filterflo kv list --limit 50 # cap resultsAliases: ls, scan.
| Flag | Description |
|---|---|
--prefix <p> / -p | Filter by key prefix |
--limit <n> / -l | Max keys to return (default: 100, max: 1,000) |
-n <namespace> | Target namespace |
List walks all shards — keys are returned regardless of which shard they hash to. The prefix filter is applied on each shard locally before results are merged.
Version History
Section titled “Version History”flo kv history mykeyflo kv history mykey --limit 5Aliases: hist. Returns previous values with version numbers and timestamps. History for a non-existent key returns an error.
| Flag | Description |
|---|---|
--limit <n> / -l | Max entries (default: 10) |
-n <namespace> | Target namespace |
Flo maintains a bounded version chain per key (default depth: 64). Oldest versions are evicted when the chain is full. Deletes create tombstone entries — prior versions remain queryable through history even after deletion.
TTL (Time-to-Live)
Section titled “TTL (Time-to-Live)”Set an expiration on keys:
flo kv set session:abc '{"user":"alice"}' --ttl 3600The key expires 3600 seconds after the write. Expired keys return (nil) on get, as if they were never written. Setting --ttl 0 explicitly means “no expiration.”
Overwriting a key resets its TTL. If you set a key with --ttl 60 and then overwrite it without a TTL flag, the new value has no expiration:
flo kv set temp "short-lived" --ttl 5 # expires in 5sflo kv set temp "permanent" # TTL clearedTTL combines with conditional flags. A common pattern is --ttl + --nx for “set once with expiry”:
flo kv set lock:resource "owner-1" --ttl 30 --nxExpiry is lazy — keys are checked at read time rather than proactively swept. This means expired keys don’t consume I/O until accessed.
Compare-and-Swap (CAS)
Section titled “Compare-and-Swap (CAS)”CAS enables optimistic concurrency control. Read the current version, then conditionally write only if nobody else has modified the key since:
# Step 1: read current value and versionflo kv get counter --format json# {"key":"counter","value":"41","version":7}
# Step 2: update only if version is still 7flo kv set counter "42" --cas 7If another writer updated the key between your read and write, the CAS fails with “Version mismatch” and the original value is preserved.
CAS guarantees hold across the cluster. You can read the version from one node and CAS-update from another — the version is the Raft log index, which is globally consistent.
Atomicity model
Section titled “Atomicity model”Flo’s KV store is built around per-key linearizable writes rather than multi-key transactions. Every mutation goes through Raft as a single committed entry, so each individual operation is atomic and durable on its own — the same model used by etcd, Consul, and FoundationDB single-key paths.
Conditional & atomic writes
Section titled “Conditional & atomic writes”Use the conditional flags on kv set/KV.Put to express “do this only if X”. Each is a single Raft entry — no coordinator, no roundtrips, no rollback to worry about.
| Pattern | How |
|---|---|
| Insert if absent | --nx (or --cas 0) |
| Update only if present | --xx |
| Compare-and-swap by version | --cas <version> |
| Atomic create with lease | --nx --ttl <seconds> |
| Atomic counter | kv incr |
| Atomic JSON sub-field update | kv jset |
The classic read-modify-write loop becomes:
# 1. Read current state and version (PUT/GET both return the version)flo kv get account:42 --format json # → value + version=11
# 2. Compute new state client-side# 3. Conditional write — fails if anyone else changed the keyflo kv set account:42 '{"balance":150}' --cas 11If the CAS fails, re-read and retry. For a counter, prefer kv incr — it is unconditional and never conflicts.
CAS-guarded delete, touch, and persist
Section titled “CAS-guarded delete, touch, and persist”The same if_match guard is also accepted by delete, touch, and persist. This closes the classic distributed-lock race: the holder can release the lock (or extend its lease) only if it still owns the version it acquired.
// Acquire: NX put with TTL returns versionv, _ := kv.Put(ctx, "lock:job-42", []byte(ownerID), &flo.PutOptions{NX: true, TTL: 30 * time.Second})
// ... do work ...
// Release: only the owner deletes — no risk of unlocking after expiry_ = kv.Delete(ctx, "lock:job-42", &flo.DeleteOptions{IfMatch: &v.Version})v = await kv.put("lock:job-42", owner_id.encode(), nx=True, ttl=30)# ... work ...await kv.delete("lock:job-42", DeleteOptions(if_match=v.version))# Renew the lease atomicallyawait kv.touch("lock:job-42", 30, KVTouchOptions(if_match=v.version))A version mismatch (or a missing key when if_match is set) returns the same CAS-failed error as put with --cas, carrying the current version so callers can decide whether to retry or give up.
Multi-key atomicity
Section titled “Multi-key atomicity”For multi-key writes that must commit or fail as a unit, Flo offers per-shard transactions. Operations are buffered on a single pinned partition and replicated as one Raft entry on commit. Pick the right tool:
| Need | Recommended approach |
|---|---|
| ”Several keys, all-or-nothing” | Open a transaction pinned to a routing key — all writes commit atomically as one Raft entry. |
| ”All these keys live together” | Set the same --routing-key on every related key (see Shard Co-location). Reads and writes stay on one shard. |
| ”Update several fields of one document” | Model the state as a single key with a JSON value and use kv jset or CAS on the whole document. One Raft entry, no coordination. |
| ”Atomic counter” | Use kv incr — unconditional and conflict-free. |
| ”Long-running multi-step state machine” | Use a workflow. Workflows give you durable execution, retries, and compensation steps. |
| ”Idempotent producer / exactly-once write” | Use --nx on a dedupe key (e.g. dedupe:<request-id>) before doing the work. |
| ”Cross-partition transaction” | Not supported. Either restructure keys to share a routing key, or use a workflow with compensating actions. |
Counters
Section titled “Counters”kv incr (opcode 0x10B) atomically adds a signed delta to a key holding a 64-bit counter. The first incr on a missing key creates it at the delta value. The operation is unconditional — it never conflicts — and is ideal for rate-limit buckets, sequence generators, and metric accumulators.
flo kv incr visits:home # +1, returns new valueflo kv incr visits:home -d 10 # +10flo kv incr visits:home -d -1 # decrementA counter and a string value cannot share a key. incr against a non-counter key returns Conflict.
Recipe: per-minute rate limiter
Section titled “Recipe: per-minute rate limiter”minute = int(time.time() // 60)key = f"rl:{user_id}:{minute}"count = await client.kv.incr(key)if count == 1: await client.kv.touch(key, ttl_seconds=120) # auto-expire old bucketsif count > LIMIT: raise RateLimited()TTL lifecycle
Section titled “TTL lifecycle”TTLs are set at write time via --ttl / PutOptions.TTLSeconds, but you can also adjust them later:
| Op | Effect |
|---|---|
kv touch <key> --ttl N | Update the TTL on an existing key without rewriting the value |
kv touch <key> --ttl 0 | Clear the TTL (equivalent to kv persist) |
kv persist <key> | Make a key permanent |
These are single-shard, no-replication-skip operations. Use them to extend session leases, refresh distributed locks, or promote a temporary value to permanent without losing it.
Existence check
Section titled “Existence check”kv exists (opcode 0x115) returns a single byte (0 or 1) without transferring the value, and is significantly cheaper than get for large values.
flo kv exists user:123 # exit 0 if present, 1 if absentJSON paths
Section titled “JSON paths”For keys whose value is a JSON document, three operations let you read or mutate sub-fields without round-tripping the whole document:
| Op | Wire | Purpose |
|---|---|---|
kv jget <key> [path] | 0x10C | Extract path from the JSON document. Path defaults to $ (the whole document). |
kv jset <key> <path> <json> | 0x10D | Atomically set the value at path. path = "$" replaces the document (and creates the key if missing). |
kv jdel <key> [path] | 0x10E | Remove the value at path. |
Paths use a tiny JSONPath subset: $, .field, .field.nested, [index]. The operations are atomic per Raft entry — a sub-field update is a single committed write, not a read-modify-write loop.
jget returns the document’s current version alongside the bytes (same GetResult shape as kv get); jset and jdel return a PutResult with the new version. Use that version to drive CAS on subsequent writes.
Recipe: status enrichment
Section titled “Recipe: status enrichment”flo kv set order:42 '{"id":42,"items":3,"status":"new"}'flo kv jset order:42 '$.status' '"shipped"'flo kv jget order:42 '$.status' # → "shipped"The jset is a single Raft entry; concurrent writers updating different fields on the same document still each succeed atomically (last writer wins per field).
Transactions
Section titled “Transactions”Per-shard transactions buffer multiple writes on a single pinned partition and commit them atomically as one Raft entry. Open with a routing key — every key touched inside the transaction must hash to the same partition, otherwise the server returns kv_txn_cross_shard.
| Op | Wire | Purpose |
|---|---|---|
kv begin <routing-key> | 0x110 | Open a new transaction. Returns a txn_id and the partition’s pinned_hash. |
kv commit <txn-id> | 0x111 | Apply all buffered ops atomically. Returns the commit_index and op_count. |
kv rollback <txn-id> | 0x112 | Discard buffered ops. Idempotent. |
Inside a transaction, normal kv ops (put, get, delete, incr, touch, persist, exists) accept a --txn <id> flag and are buffered until commit. Reads inside the transaction see the buffered writes. The following are not supported inside a transaction and return kv_txn_unsupported_op: scan, mget, jget, jset, jdel, history.
Server caps: 256 ops per transaction, 1 MiB total payload, 1024 open transactions per server.
# Open, write, commitTXN=$(flo kv begin user:42 --format json | jq -r .txn_id)flo kv set user:42:name "Jane" --txn $TXNflo kv incr user:42:visits --txn $TXNflo kv set user:42:last_seen "$(date -u +%s)" --txn $TXNflo kv commit $TXN # → commit_index=N op_count=3If the client crashes before commit, the buffered ops are discarded automatically when the transaction expires server-side. Transactions survive across stateless connections — they are owned by txn_id, not by the TCP connection.
When to use transactions vs. JSON or workflows
Section titled “When to use transactions vs. JSON or workflows”| Pattern | Use |
|---|---|
| Atomic update of 2–10 related keys on the same partition | Transaction |
| Atomic update of fields inside one document | kv jset |
| Atomic counter increment | kv incr |
| Multi-step business process across shards / external systems | Workflow |
Shard Co-location
Section titled “Shard Co-location”By default, each key is routed to a shard based on its hash. When you need related keys to land on the same shard (for locality or future atomic operations), use --routing-key:
flo kv set user:123:name "Alice" -r "user:123"flo kv set user:123:email "alice@co.io" -r "user:123"flo kv set user:123:prefs '{"theme":"dark"}' -r "user:123"All three keys route to the same shard because they share the routing key user:123.
Cluster Behavior
Section titled “Cluster Behavior”In a multi-node cluster:
- Writes are proposed to the Raft leader and replicated to a majority before being acknowledged.
- Reads are served from the local shard’s projection — no Raft round-trip needed.
- Data replicates to all nodes. A key written on node 1 is readable from node 2 and node 3 after replication.
- Node failures are tolerated as long as a majority (quorum) of nodes remain healthy. A 3-node cluster survives 1 node failure.
- CAS and conditional writes are consistent across the cluster — the version check happens at the leader during Raft proposal.
SDK Examples
Section titled “SDK Examples”client := flo.NewClient("localhost:9000")client.Connect()defer client.Close()
// Put returns a PutResult with the new versionres, _ := client.KV.Put("mykey", []byte("hello"), nil)fmt.Println("committed at version", res.Version)
// Put with TTLttl := uint64(3600)client.KV.Put("session:abc", []byte(`{"user":"alice"}`), &flo.PutOptions{ TTLSeconds: &ttl,})
// Get returns *GetResult (nil if not found) — contains both value and versionif r, _ := client.KV.Get("session:abc", nil); r != nil { fmt.Printf("%s @ v%d\n", r.Value, r.Version)}
// CAS update — read version from Get, conditional writer, _ := client.KV.Get("counter", nil)err := client.KV.Put("counter", []byte("42"), &flo.PutOptions{ CASVersion: &r.Version,})if flo.IsConflict(err) { // Another writer modified the key — re-read and retry}
// Conditional write (only if key doesn't exist)client.KV.Put("lock:resource", []byte("owner-1"), &flo.PutOptions{ IfNotExists: true, TTLSeconds: ptr(uint64(30)),})
// Atomic countern, _ := client.KV.Incr("visits:home", nil)
// TTL lifecycleclient.KV.Touch("lock:resource", 60, nil) // extend leaseclient.KV.Persist("lock:resource", nil) // make permanentok, _ := client.KV.Exists("lock:resource", nil)
// JSON path operationsclient.KV.JsonSet("order:42", "$.status", []byte(`"shipped"`), nil)status, _ := client.KV.JsonGet("order:42", "$.status", nil)
// Blocking get — wait for key to appearblockMS := uint32(5000)r, _ = client.KV.Get("job:result", &flo.GetOptions{BlockMS: &blockMS})
// Prefix scan with paginationresult, _ := client.KV.Scan("user:", &flo.ScanOptions{Limit: ptr(uint32(100))})for _, entry := range result.Entries { fmt.Printf("%s = %s\n", entry.Key, entry.Value)}
// Version historyhistory, _ := client.KV.History("counter", &flo.HistoryOptions{Limit: ptr(uint32(5))})for _, v := range history { fmt.Printf("v%d: %s (at %d)\n", v.Version, v.Value, v.Timestamp)}
// Namespace-scoped operationsclient.KV.Put("config", []byte("staging-db"), &flo.PutOptions{ Namespace: "staging",})r, _ = client.KV.Get("config", &flo.GetOptions{Namespace: "staging"})
// Transaction — multiple writes on one shard, atomic committxn, _ := client.KV.Begin("user:42", nil)_, _ = txn.Put("user:42:name", []byte("Jane"), nil)_, _ = txn.Incr("user:42:visits", 1)result, err := txn.Commit()if err != nil { _ = txn.Rollback() // idempotent}fmt.Printf("committed %d ops at index %d\n", result.OpCount, result.CommitIndex)async with FloClient("localhost:9000") as client: # Put returns PutResult with the new version res = await client.kv.put("mykey", b"hello") print("committed at version", res.version)
# Put with TTL await client.kv.put("session:abc", b'{"user":"alice"}', PutOptions(ttl_seconds=3600))
# Get returns GetResult | None — contains both value and version r = await client.kv.get("session:abc") if r is not None: print(f"{r.value} @ v{r.version}")
# CAS update — read version from Get, conditional write r = await client.kv.get("counter") try: await client.kv.put("counter", b"42", PutOptions(cas_version=r.version)) except ConflictError: pass # re-read and retry
# Conditional write await client.kv.put("lock:resource", b"owner-1", PutOptions(if_not_exists=True, ttl_seconds=30))
# Atomic counter n = await client.kv.incr("visits:home")
# TTL lifecycle await client.kv.touch("lock:resource", 60) await client.kv.persist("lock:resource") ok = await client.kv.exists("lock:resource")
# JSON paths await client.kv.json_set("order:42", "$.status", b'"shipped"') status = await client.kv.json_get("order:42", "$.status")
# Blocking get r = await client.kv.get("job:result", GetOptions(block_ms=5000))
# Prefix scan result = await client.kv.scan("user:", ScanOptions(limit=100)) for entry in result.entries: print(f"{entry.key}: {entry.value}")
# Version history history = await client.kv.history("counter", HistoryOptions(limit=5)) for v in history: print(f"v{v.version}: {v.value}")
# Namespace-scoped await client.kv.put("config", b"staging-db", PutOptions(namespace="staging"))
# Transaction — multiple writes on one shard, atomic commit txn = await client.kv.begin("user:42") try: await txn.put("user:42:name", b"Jane") await txn.incr("user:42:visits") result = await txn.commit() print(f"committed {result.op_count} ops at index {result.commit_index}") except Exception: await txn.rollback() # idempotent raiseconst client = new FloClient("localhost:9000");await client.connect();
// Put returns PutResult with the new versionconst res = await client.kv.put("mykey", encode("hello"));console.log("committed at version", res.version);
// Put with TTLawait client.kv.put("session:abc", encode('{"user":"alice"}'), { ttlSeconds: 3600n,});
// Get returns GetResult | null — contains both value and versionconst r = await client.kv.get("session:abc");if (r) console.log(`${decode(r.value)} @ v${r.version}`);
// CAS update — read version from Get, conditional writeconst cur = await client.kv.get("counter");try { await client.kv.put("counter", encode("42"), { casVersion: cur!.version });} catch (err) { // ConflictError — re-read and retry}
// Conditional writeawait client.kv.put("lock:resource", encode("owner-1"), { ifNotExists: true, ttlSeconds: 30n,});
// Atomic counterconst n = await client.kv.incr("visits:home");
// TTL lifecycleawait client.kv.touch("lock:resource", 60n);await client.kv.persist("lock:resource");const exists = await client.kv.exists("lock:resource");
// JSON pathsawait client.kv.jsonSet("order:42", "$.status", encode('"shipped"'));const status = await client.kv.jsonGet("order:42", "$.status");
// Blocking getconst pending = await client.kv.get("job:result", { blockMs: 5000 });
// Prefix scanconst result = await client.kv.scan("user:", { limit: 100 });for (const entry of result.entries) { console.log(`${entry.key} = ${entry.value}`);}
// Version historyconst history = await client.kv.history("counter", { limit: 5 });
// Namespace-scopedawait client.kv.put("config", encode("staging-db"), { namespace: "staging",});
// Transaction — multiple writes on one shard, atomic commitconst txn = await client.kv.begin("user:42");try { await txn.put("user:42:name", encode("Jane")); await txn.incr("user:42:visits", 1n); const result = await txn.commit(); console.log(`committed ${result.opCount} ops at index ${result.commitIndex}`);} catch (err) { await txn.rollback(); // idempotent throw err;}var client = flo.Client.init(allocator, "localhost:9000", .{});defer client.deinit();try client.connect();
var kv = flo.KV.init(&client);
// Put returns PutResult with the new versionconst put_res = try kv.put("mykey", "hello", .{});std.debug.print("committed at v{d}\n", .{put_res.version});
// Put with TTL_ = try kv.put("session:abc", "{\"user\":\"alice\"}", .{ .ttl_seconds = 3600 });
// Get returns ?GetResult — contains both value and versionif (try kv.get("session:abc", .{})) |r_const| { var r = r_const; defer r.deinit(allocator); std.debug.print("{s} @ v{d}\n", .{ r.value, r.version });}
// CAS update — read version from Get, conditional writeif (try kv.get("counter", .{})) |r_const| { var r = r_const; defer r.deinit(allocator); _ = kv.put("counter", "42", .{ .cas_version = r.version }) catch |err| switch (err) { error.Conflict => {}, // re-read and retry else => return err, };}
// Conditional write_ = try kv.put("lock:resource", "owner-1", .{ .if_not_exists = true, .ttl_seconds = 30,});
// Atomic counterconst n = try kv.incr("visits:home", .{});
// TTL lifecycletry kv.touch("lock:resource", 60, .{});try kv.persist("lock:resource", .{});const exists = try kv.exists("lock:resource", .{});
// JSON paths_ = try kv.jsonSet("order:42", "$.status", "\"shipped\"", .{});if (try kv.jsonGet("order:42", "$.status", .{})) |status_const| { var status = status_const; defer status.deinit(allocator); std.debug.print("{s} @ v{d}\n", .{ status.value, status.version });}
// Blocking getif (try kv.get("job:result", .{ .block_ms = 5000 })) |r_const| { var r = r_const; defer r.deinit(allocator);}
// Prefix scanvar result = try kv.scan("user:", .{ .limit = 100 });defer result.deinit();
// Version historyvar history = try kv.history("counter", .{ .limit = 5 });defer history.deinit();
// Namespace-scopedtry kv.put("config", "staging-db", .{ .namespace = "staging" });
// Transaction — multiple writes on one shard, atomic commitvar txn = try kv.begin("user:42", .{});defer txn.deinit();_ = try txn.put("user:42:name", "Jane", .{});_ = try txn.incr("user:42:visits", 1);const result = try txn.commit();std.debug.print("committed {d} ops at index {d}\n", .{ result.op_count, result.commit_index });Use Cases
Section titled “Use Cases”Distributed Locks
Section titled “Distributed Locks”Use --nx with --ttl as a simple distributed lock with automatic expiry:
# Acquire lock (fails if already held)flo kv set lock:payment "worker-7" --ttl 30 --nx
# Release lockflo kv delete lock:paymentThe TTL acts as a safety net — if the lock holder crashes, the lock auto-expires.
Configuration Store
Section titled “Configuration Store”Store configuration per environment using namespaces, and watch for changes with --block:
# Set configflo kv set feature:dark-mode "true" -n production
# Watch for config changes (long-poll)flo kv get feature:dark-mode --block 0 -n productionJob Coordination
Section titled “Job Coordination”Use --wait for producer/consumer coordination where one process publishes a result and another waits for it:
# Worker: process job and publish resultflo kv set job:abc:result '{"status":"done","output":"..."}' --ttl 3600
# Requester: wait for result (up to 60 seconds)flo kv get job:abc:result --wait 60000Internals
Section titled “Internals”The KV store is implemented as a KV Projection — a hash table with MVCC version chains, derived from the Unified Append Log.
| Property | Value |
|---|---|
| Max key size | ~3.9 KB |
| Max value size | ~256 KB |
| Max namespace length | 128 bytes |
| Version chain depth | 64 versions per key (oldest evicted) |
| Default scan limit | 100 (max: 1,000) |
| Reserved key prefixes | _action:, _worker:, _sys:, _internal:, _flo: |
Write path: kv_put → Raft propose → majority commit → UAL append → KV projection update → response to client.
Read path: kv_get → KV projection lookup → response. No Raft involvement.
Recovery: On server restart, the projection is rebuilt by replaying UAL entries from the last snapshot. Read-after-write consistency is immediate after recovery — there is no warm-up period.
TTL enforcement: Lazy — expired keys are detected at read time rather than proactively swept. This avoids background I/O for expiry.
Tombstones: Deletes write a tombstone entry to the UAL. Prior versions remain in the version chain and are queryable via history. Tombstones are purged during compaction.