Skip to content

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.

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:

Terminal window
flo kv set counter "1" # → version=1
flo kv set counter "2" --cas 1 # → version=2 (succeeds)
flo kv set counter "3" --cas 1 # → error: Version mismatch

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.

Terminal window
# Create namespaces
flo ns create staging
flo ns create production
# Same key, different namespaces
flo kv set db_url "postgres://staging" -n staging
flo kv set db_url "postgres://prod" -n production
# Each returns its own value
flo kv get db_url -n staging # → postgres://staging
flo kv get db_url -n production # → postgres://prod

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

Terminal window
flo kv set mykey "hello world"
FlagDescription
--ttl <seconds>Expire after N seconds (0 = no expiry)
--nxOnly set if the key does not already exist
--xxOnly 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.

Terminal window
flo kv get mykey
flo kv get mykey --format json # includes version number
FlagDescription
--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:

  • --wait is for coordination — one process creates a key, another waits for it to appear. If the key already exists, it returns immediately.
  • --block is for watching — subscribe to the next change on an existing key. Even if the key exists now, it waits for a newer version.
Terminal window
# Process A: wait for a result to appear
flo kv get job:result --wait 30000
# Process B: watch for config changes
flo kv get config:feature-flags --block 0

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

Terminal window
flo kv mget user:1 user:2 user:3
flo kv mget user:1 user:2 --output json
FlagDescription
--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)
}
}
Terminal window
flo kv delete mykey

Aliases: del. Deleting a non-existent key returns a not-found error.

FlagDescription
-n <namespace>Target namespace
-r <routing-key>Explicit shard routing key
Terminal window
flo kv list # all keys
flo kv list --prefix "user:" # prefix filter
flo kv list --limit 50 # cap results

Aliases: ls, scan.

FlagDescription
--prefix <p> / -pFilter by key prefix
--limit <n> / -lMax 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.

Terminal window
flo kv history mykey
flo kv history mykey --limit 5

Aliases: hist. Returns previous values with version numbers and timestamps. History for a non-existent key returns an error.

FlagDescription
--limit <n> / -lMax 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.

Set an expiration on keys:

Terminal window
flo kv set session:abc '{"user":"alice"}' --ttl 3600

The 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:

Terminal window
flo kv set temp "short-lived" --ttl 5 # expires in 5s
flo kv set temp "permanent" # TTL cleared

TTL combines with conditional flags. A common pattern is --ttl + --nx for “set once with expiry”:

Terminal window
flo kv set lock:resource "owner-1" --ttl 30 --nx

Expiry is lazy — keys are checked at read time rather than proactively swept. This means expired keys don’t consume I/O until accessed.

CAS enables optimistic concurrency control. Read the current version, then conditionally write only if nobody else has modified the key since:

Terminal window
# Step 1: read current value and version
flo kv get counter --format json
# {"key":"counter","value":"41","version":7}
# Step 2: update only if version is still 7
flo kv set counter "42" --cas 7

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

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.

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.

PatternHow
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 counterkv incr
Atomic JSON sub-field updatekv jset

The classic read-modify-write loop becomes:

Terminal window
# 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 key
flo kv set account:42 '{"balance":150}' --cas 11

If the CAS fails, re-read and retry. For a counter, prefer kv incr — it is unconditional and never conflicts.

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 version
v, _ := 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 atomically
await 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.

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:

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

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.

Terminal window
flo kv incr visits:home # +1, returns new value
flo kv incr visits:home -d 10 # +10
flo kv incr visits:home -d -1 # decrement

A counter and a string value cannot share a key. incr against a non-counter key returns Conflict.

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 buckets
if count > LIMIT:
raise RateLimited()

TTLs are set at write time via --ttl / PutOptions.TTLSeconds, but you can also adjust them later:

OpEffect
kv touch <key> --ttl NUpdate the TTL on an existing key without rewriting the value
kv touch <key> --ttl 0Clear 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.

kv exists (opcode 0x115) returns a single byte (0 or 1) without transferring the value, and is significantly cheaper than get for large values.

Terminal window
flo kv exists user:123 # exit 0 if present, 1 if absent

For keys whose value is a JSON document, three operations let you read or mutate sub-fields without round-tripping the whole document:

OpWirePurpose
kv jget <key> [path]0x10CExtract path from the JSON document. Path defaults to $ (the whole document).
kv jset <key> <path> <json>0x10DAtomically set the value at path. path = "$" replaces the document (and creates the key if missing).
kv jdel <key> [path]0x10ERemove 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.

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

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.

OpWirePurpose
kv begin <routing-key>0x110Open a new transaction. Returns a txn_id and the partition’s pinned_hash.
kv commit <txn-id>0x111Apply all buffered ops atomically. Returns the commit_index and op_count.
kv rollback <txn-id>0x112Discard 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.

Terminal window
# Open, write, commit
TXN=$(flo kv begin user:42 --format json | jq -r .txn_id)
flo kv set user:42:name "Jane" --txn $TXN
flo kv incr user:42:visits --txn $TXN
flo kv set user:42:last_seen "$(date -u +%s)" --txn $TXN
flo kv commit $TXN # → commit_index=N op_count=3

If 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”
PatternUse
Atomic update of 2–10 related keys on the same partitionTransaction
Atomic update of fields inside one documentkv jset
Atomic counter incrementkv incr
Multi-step business process across shards / external systemsWorkflow

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:

Terminal window
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.

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.
client := flo.NewClient("localhost:9000")
client.Connect()
defer client.Close()
// Put returns a PutResult with the new version
res, _ := client.KV.Put("mykey", []byte("hello"), nil)
fmt.Println("committed at version", res.Version)
// Put with TTL
ttl := 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 version
if 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 write
r, _ := 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 counter
n, _ := client.KV.Incr("visits:home", nil)
// TTL lifecycle
client.KV.Touch("lock:resource", 60, nil) // extend lease
client.KV.Persist("lock:resource", nil) // make permanent
ok, _ := client.KV.Exists("lock:resource", nil)
// JSON path operations
client.KV.JsonSet("order:42", "$.status", []byte(`"shipped"`), nil)
status, _ := client.KV.JsonGet("order:42", "$.status", nil)
// Blocking get — wait for key to appear
blockMS := uint32(5000)
r, _ = client.KV.Get("job:result", &flo.GetOptions{BlockMS: &blockMS})
// Prefix scan with pagination
result, _ := 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 history
history, _ := 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 operations
client.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 commit
txn, _ := 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)

Use --nx with --ttl as a simple distributed lock with automatic expiry:

Terminal window
# Acquire lock (fails if already held)
flo kv set lock:payment "worker-7" --ttl 30 --nx
# Release lock
flo kv delete lock:payment

The TTL acts as a safety net — if the lock holder crashes, the lock auto-expires.

Store configuration per environment using namespaces, and watch for changes with --block:

Terminal window
# Set config
flo kv set feature:dark-mode "true" -n production
# Watch for config changes (long-poll)
flo kv get feature:dark-mode --block 0 -n production

Use --wait for producer/consumer coordination where one process publishes a result and another waits for it:

Terminal window
# Worker: process job and publish result
flo 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 60000

The KV store is implemented as a KV Projection — a hash table with MVCC version chains, derived from the Unified Append Log.

PropertyValue
Max key size~3.9 KB
Max value size~256 KB
Max namespace length128 bytes
Version chain depth64 versions per key (oldest evicted)
Default scan limit100 (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.