Data Structures That Replicate: Hashes, Lists, Sets, and Sorted Sets in Zaris
Zaris 1.x stored values. Zaris 2.0.0 stores collections. Hashes, lists, sets, and sorted sets are now first-class, server-side types with real, typed .NET APIs — and, just as importantly, they behave like everything else in the store.
That last part is what people usually get wrong. In a lot of systems, "data structures" are a client-side illusion: a library serializes a dictionary into a blob, Puts it, and calls that a hash. It works until two clients touch the same collection at once, or until you ask what happens on a failover. In Zaris, a hash is a hash on the server. It sits on the partition that owns its key, it replicates to that partition's replica, and it survives a node loss exactly the way a plain key does.
Four types, one typed API
Every collection kind hangs off client.DataStructures, keyed by the name you'd use for any other key:
// Hash — field/value map
await client.DataStructures.Hash("user:42").SetAsync("email", "ada@example.com");
await client.DataStructures.Hash("user:42").SetAsync("plan", "pro");
var email = await client.DataStructures.Hash("user:42").GetAsync("email");
var all = await client.DataStructures.Hash("user:42").GetAllAsync();
// Sorted set — members ordered by score
await client.DataStructures.SortedSet("leaderboard").AddAsync("ada", score: 4096);
await client.DataStructures.SortedSet("leaderboard").AddAsync("grace", score: 8192);
var top = await client.DataStructures.SortedSet("leaderboard").RangeByRankAsync(0, 9);
var band = await client.DataStructures.SortedSet("leaderboard").RangeByScoreAsync(1000, 5000);
// List — ordered, push and range
await client.DataStructures.List("events:42").PushAsync("login");
var recent = await client.DataStructures.List("events:42").RangeAsync(0, 19);
// Set — unique membership
await client.DataStructures.Set("tags:42").AddAsync("beta");
var isMember = await client.DataStructures.Set("tags:42").IsMemberAsync("beta");
var members = await client.DataStructures.Set("tags:42").MembersAsync();
Nothing here is a helper that fetches a blob and mutates it in your process. Hash("user:42").SetAsync(...) is a server-side operation against the collection named user:42, the same way Put("user:42", ...) is against the value named user:42.
Same placement, same replication, same failover
Here's the design decision that makes these types trustworthy: a data-structure operation rides the exact same replicated path as an ordinary key write.
Each collection is a single logical value that lives on the partition owning its key. It's placed by the same partition map, covered by the same replication factor, and fails over the same way. There is no separate "collections subsystem" with its own durability story — if you understand how a Zaris key is placed and replicated, you already understand how a sorted set is.
Under the hood, a mutating operation is a whole-value compare-and-set (CAS) read-modify-write over the same replicated Put/Get path that plain keys use. Adding a member to a sorted set reads the current collection, applies the change, and commits it with a compare-and-set; if another writer got there first, the CAS fails and the operation retries against the fresh value. Because the commit is the same replicated Put, the update is:
- Atomic per key — a single collection mutation either lands whole or not at all.
- Replicated to the partition's replica — the change is durable across the node loss that Zaris is built to survive.
- Consistent with plain keys — a hash update and a key update get the same guarantees, because they are the same write.
That's the whole trick: instead of a bespoke consistency model per data type, Zaris expresses every mutation in terms of the one operation it already replicates correctly.
The same collections, from any Redis client
Every one of these types is also exposed over the Redis-protocol (RESP) front-end, so a Redis client in any language operates on the same server-side collections — not a parallel set living somewhere else.
# Hash
redis-cli -h zaris-0 -p 6379 HSET user:42 email ada@example.com plan pro
redis-cli -h zaris-0 -p 6379 HGETALL user:42
# Sorted set
redis-cli -h zaris-0 -p 6379 ZADD leaderboard 4096 ada 8192 grace
redis-cli -h zaris-0 -p 6379 ZRANGE leaderboard 0 9
# List and set
redis-cli -h zaris-0 -p 6379 LPUSH events:42 login
redis-cli -h zaris-0 -p 6379 LRANGE events:42 0 19
redis-cli -h zaris-0 -p 6379 SADD tags:42 beta
redis-cli -h zaris-0 -p 6379 SMEMBERS tags:42
A .NET service writing through client.DataStructures.Hash("user:42") and a Python service issuing HSET user:42 are touching the same hash on the same partition. The RESP layer translates the command onto the native collection; it isn't a second implementation.
We validated this across languages rather than just asserting it: the cross-language client suites run 38/38 passing in .NET, 51/51 in Java, and 40/40 in Ruby, exercising the same collection semantics through each language's client.
The honest tradeoff: whole-value CAS
Whole-value CAS is what buys the clean guarantee, and it's also where the cost lives. Every mutation reads and rewrites the whole collection value, and concurrent writers to the same collection contend on the compare-and-set — a loser retries against the new value.
In practice these types are a great fit for small-to-medium collections, where rewriting the whole value is cheap, and for read-heavy or moderate-write patterns, where CAS retries are rare.
Where you'd feel it is the opposite extreme: one very large collection with many very hot concurrent writers. A giant hash that dozens of clients hammer at once will spend real time losing and retrying compare-and-sets, and every write pays to serialize the whole value.
The fix is the usual distributed-systems move — shard the huge collection across keys so writes spread over many independent CAS domains and partitions. A single sessions hash with a million fields hammered by every request is an anti-pattern; a thousand sessions:{shard} hashes, each with its own owner and CAS, scales the way the rest of Zaris does. Match the collection size to the write concurrency and these types stay fast.
Five kinds of collection
Hashes, lists, sets, and sorted sets are four of the five server-side collection kinds Zaris ships. The fifth is Streams — a native, replicated, append-only log with consumer groups and blocking reads — which gets its own post.
If you're already modeling data in Redis, the mental model carries over almost unchanged: the types are the ones you know, the commands are the ones you know, and the difference is what happens when a node goes down. In Zaris, your leaderboard is still there — it replicated, the same way your keys always have.