Skip to main content

Which Primitive, Which Problem: A Field Guide to the Zaris Data Model

· 14 min read
Clustron Team
Distributed Systems Engineering

Choosing the right Zaris primitive for the problem

Most of this blog has gone deep on one thing at a time — compare-and-swap, multi-key transactions, native data structures, streams, watch, the op-log underneath. Each post answers "how does this work." This one answers a different, more practical question: when you have a problem in front of you, which one do you reach for?

That question matters because the wrong choice is rarely a crash. It's a system that works in the demo and then loses an update under load, or funnels a thousand writers through one key, or uses a fire-and-forget notification where it needed a durable log. The primitives don't stop you from using them wrong. So this is a field guide: the mental model that ties them together, a decision table, a walk down each branch with its honest fit and honest edge, and a flowchart at the end.

One core, many views​

Start with the thing everything else is built on. Underneath every feature in this post, Zaris is one partitioned, replicated key-value store, and every value carries a store-owned monotonic version. That's it. That's the core.

Everything else is one of two things:

  • A typed, server-side view over keys — a hash, a list, a set, a sorted set, a stream. These aren't blobs you fetch and mutate in your process; they're real server-side collections that live on the partition that owns their key, and they replicate exactly like a plain value does.
  • A coordination layer over the version — compare-and-swap, multi-key transactions, locks, watch. These use the version (and the ownership machinery around it) to let many clients agree on what happened without stepping on each other.

Hold that picture and the whole surface stops looking like a grab-bag of features and starts looking like what it is: a small core, and a set of purpose-built tools that each solve a specific class of problem on top of it. Pick by the shape of your problem, not by the name that sounds closest.

The decision table​

Your problemReach forWhyWatch out for
Stash bytes other nodes can read backPlain GetAsync/PutAsync, or a cache integrationThe core, nothing moreNo expiry at write time — TTL is a second call (below)
A single value must change safely under concurrencyCompare-and-swapOne winner per round, no lost updates, no lock heldHot single key becomes a serialization point — shard it
An invariant spans two or more keysMulti-key transactionAll-or-nothing across keys and nodesHeavier than CAS; it's a 2PC, not free
The data is a collection — map, list, membership, rankingNative data structuresPer-element server-side ops, no whole-value rewritePick the collection that matches the access pattern
An ordered, replayable event log with at-least-once consumersStreamsDurable log, consumer groups, per-consumer pending + ackIt's a log, not a queue with priorities
React to changes as they happenWatch (native) / keyspace notifications (Redis clients)Replicated, cluster-wide, survives failoverFire-and-forget pub/sub is not a durable feed
Coarse mutual exclusion around a resourceLock (lease-based)A named, TTL-bounded gate that auto-releases on crashA lease lock is not a correctness fence — see below

The rest of this post is that table, slowed down.

KV and cache: when a value is just a value​

If all you need is to put bytes somewhere every node can read them — a rendered fragment, a serialized DTO, a config snapshot — you don't need a coordination layer. PutAsync(key, value) and GetAsync<T>(key) are the whole story, and on the server side those bytes are partitioned and replicated for you.

Two honest notes. First, expiry is not set at write time. There's no TTL field on the write options; you set a time-to-live with a separate call — ExpireAsync(key, ttl) — and clear it with PersistAsync(key), or read what's left with GetTimeToLiveAsync(key). Second, if your use case is literally "a distributed cache for my ASP.NET Core app," don't hand-roll any of this: register one of the drop-in cache packages and the framework's IDistributedCache (or HybridCache, or the session store) is backed by your cluster with a one-line startup change and no code touching the cache abstraction.

Reach for plain KV when the value has no concurrency invariant — last writer winning is fine, or there's only ever one writer. Don't, the moment two writers can race on the same key and you'd mind if one silently clobbered the other. That's the next tool's job.

Compare-and-swap: when one value must stay correct under concurrency​

A counter with a ceiling, a balance that can't go negative, a config object two admins edit at once. The failure you're trying to avoid is the lost update — read, read, write, write, and the second write erases the first. CAS turns that silent loss into a detected, retryable conflict: read a value and its version, write back only if the version hasn't moved, and get a Conflict instead of a clobber if someone beat you to it.

KvResult<int> read = await client.GetAsync<int>("counter:api");
var put = await client.PutAsync(
"counter:api", read.Value + 1,
new PutOptions { IfMatchVersion = read.Version });

// put.Status == KvStatus.Success -> you won this round
// put.Status == KvStatus.Conflict -> someone wrote first; re-read and retry

That's the entire mechanism, and the CAS field guide shows how far it goes — a rate limiter, a wallet that never overdraws under a thousand concurrent debits, a one-winner job claim (via "create only if absent"), lost-update-free config, and the idempotency hook that rides along for free.

Reach for CAS when the invariant lives on one key and you want correctness without holding a lock. Don't point a crowd of writers at a single hot key and expect it to scale — CAS deliberately lets exactly one writer win per round, so a hot key becomes a serialization point. Shard it (a bucket per client-second, a balance split into reconciled sub-balances) and the contention melts.

Multi-key transactions: when the invariant spans keys​

The hard line CAS draws is that it's atomic on one key. Move money from account A to account B, or keep a secondary index in step with the record it points at, and no single compare-and-swap covers both. That's not a reason to fake it with two CAS writes and hope — it's the exact case the multi-key transaction exists for.

You open a transaction, stage reads and writes into it, and commit. Under the hood it's a client-coordinated two-phase commit over optimistic versions: the keys may live on different nodes, so the client groups the write-set by owner, each node checks that every key you read is still at the version you read it at and takes a short-lived lock, and only if everyone votes yes does the whole set apply. Any version that moved, and the commit returns Conflict with nothing changed.

Reach for a transaction when two or more keys must move together or not at all. Don't use it as your default write path — it's a real 2PC with prepare/commit round-trips and short-lived locks, heavier than a single CAS. If the invariant honestly fits on one key, keep it on one key.

Native data structures: when the data is a collection​

Sometimes the value isn't a scalar you guard — it's a shape. A user profile is a map of fields. A recent-activity feed is a list. A tag membership is a set. A leaderboard is a ranking. You could serialize each as one blob and CAS it, but then every single-field edit rewrites the whole object, and you've turned an O(1) update into an O(n) one.

Zaris gives these their own native, server-side collection types — hashes, lists, sets, sorted sets — where a single-field or single-element operation touches only that element on the server, not the whole value in your process. They partition and replicate like any key, and because the RESP front-end maps the same Redis commands onto the same server-side collections, a .NET service and a Python service using HSET touch the identical hash.

Reach for a collection when your access pattern is per-element — set one field, push one entry, test one membership, read the top N by score. Don't reach for a sorted set when what you actually need is an ordered, acknowledged, replayable event log. That's a stream.

Streams: when you need an ordered, replayable log​

A queue and a log look similar until a consumer crashes. Zaris streams are a real append-only log: entries get monotonic IDs, you can read ranges, and a consumer group divides the log across a pool of workers, tracks each worker's pending (delivered-but-unacked) entries, and lets another worker reclaim what a crashed one never acknowledged. That pending-and-ack machinery is what buys you at-least-once delivery — the thing a fire-and-forget notification can't give you.

Reach for a stream when you need durable, ordered, replayable events with explicit acknowledgement and work-sharing — order ingestion, an outbox, an audit trail a projection rebuilds from. Don't use it as a priority queue or a request/response channel; it's a log, and its strengths are ordering and replay, not routing.

Watch, notifications, and the fan-out family: when you react to change​

There are a few ways to learn that something changed, and they are not interchangeable — the difference is whether the signal is durable.

The one to reach for from .NET is Watch: a native, cluster-wide change feed. You subscribe to a key or a prefix, optionally fold in an initial snapshot so there's no gap between "read current state" and "subscribe to changes," and receive events as writes land:

var (subscription, initial) = await client.Watch.WatchKeyAsync(
"job:42",
new WatchOptions { IncludeInitialSnapshot = true },
evt => Handle(evt));

The important property is that the watch is registered across the cluster and survives failover — it isn't tied to the one node that happened to process a write. That's also why the Redis keyspace notifications Zaris speaks (__keyevent@0__ / __keyspace@0__, a plain PSUBSCRIBE from any Redis client) are worth more here than on a single Redis box: they're a thin bridge onto that same replicated watch, so a failover doesn't silently drop the event stream.

Reach for watch/keyspace notifications when you want to react to state changes — invalidate a cache, wake a worker, refresh a projection. Don't mistake plain best-effort pub/sub fan-out for a durable feed: if you need every event even across crashes and restarts, that's a stream, not a notification.

Locks: coarse mutual exclusion, with a sharp caveat​

Zaris does have a first-class distributed lock — a named, lease-based gate with a TTL, acquired from the client and released on dispose or crash-expiry:

await using var gate = await client.Locks.AcquireAsync("order:123", TimeSpan.FromSeconds(10));
if (gate is null)
{
// someone else holds it — back off, do other work, or retry later
return;
}
// critical section; the lease auto-expires if we crash, and can be renewed

It's genuinely useful for coarse mutual exclusion — keep two schedulers from running the same sweep, serialize a maintenance task — and the TTL means a crashed holder doesn't wedge the lock forever.

But here is the caveat the glossy version leaves out, and it's the most important sentence in this post about locks: a lease-based lock is not a correctness fence. If your holder pauses — a GC stall, a slow syscall — long enough for the lease to expire, a second holder can acquire the "same" lock while the first still believes it holds it, and now two workers are in the critical section at once. For liveness and efficiency (usually only one worker runs the job, contention is low) a lease lock is exactly right. For a correctness invariant that must never be violated, don't rely on the lock to enforce it — enforce it on the data with compare-and-swap or a transaction, so that even a spurious second actor is rejected by a version check at the write.

Reach for a lock when you want coarse, best-effort mutual exclusion and occasional overlap is merely wasteful, not wrong. Reach for CAS instead when overlap would corrupt data — let the version, not the lock, be the thing that says no.

The cross-cutting part: ownership, durability, consistency​

Three properties hold across every primitive above, because they're properties of the core, not of any one tool:

  • The owner moves, and you don't have to notice. Every key is owned by a partition that can fail over, migrate, or fail back. The .NET client turns that churn into a typed, retryable signal and reroutes to the new owner, bounded by an attempt budget and a wall-clock deadline — so a PutAsync during a failover usually just takes a few milliseconds longer and returns success.
  • Durability is by replication, not disk. Zaris keeps data in memory and durable by keeping copies on other nodes. That's why replication factor is not the same as fault tolerance: the number only protects you if the topology actually places a second copy on a node that can survive the first one's loss. The whole ownership-and-recovery backbone exists to make sure no acknowledged write is lost while ownership is in motion.
  • Writes and reads route to the current owner. The retry-and-reroute machinery means your operations find the node that currently owns the key, so a read follows a committed write rather than racing ahead of the ownership change that just happened.

None of these is a primitive you choose. They're the floor every choice stands on.

The flowchart​

The short version​

The length of the feature list was never the point. Zaris is a versioned, partitioned, replicated key-value core, plus typed server-side views (hash, list, set, sorted set, stream) and coordination layers over the version (CAS, transactions, locks, watch). Match the tool to the shape of the problem: a scalar under contention wants CAS; a cross-key invariant wants a transaction; data that is a collection wants the matching collection; durable ordered events want a stream; reacting to change wants watch; coarse mutual exclusion wants a lock — and anything that must never be violated wants its guarantee enforced on the data, with a version check, not on a lock that can lapse.

Choose by shape, lean on the version for correctness, and the small core carries a remarkable amount of system.