Files
cobalt/src/collections.zig
T

1108 lines
40 KiB
Zig

const std = @import("std");
const dbmod = @import("db.zig");
const row = @import("row.zig");
const txnmod = @import("txn.zig");
const Allocator = std.mem.Allocator;
const Db = dbmod.Db;
const Transaction = txnmod.Transaction;
/// Redis-like typed collections layered over the flat KV keyspace, sharing
/// one Db with tables/indexes (row.zig/index.zig use tags 'R'/'I'/'M'; every
/// key here starts with 'X' so the two namespaces never collide):
/// "X" "C\0" name -> raw i64 (counter)
/// "X" "H\0" name "\0" field -> raw bytes (hash field)
/// "X" "S\0" name "\0" member -> empty marker (set member)
/// "X" "L\0" name "\0M" -> raw {headSeq i64, tailSeq i64} (list meta)
/// "X" "L\0" name "\0E\0" orderedInt(seq) -> raw bytes (list element)
/// "X" "Z\0" name "\0M\0" member -> raw f64 (zset member -> score)
/// "X" "Z\0" name "\0S\0" orderedFloat(score) orderedText(member) -> empty marker (zset score index)
pub const nsTag: u8 = 'X';
const sep: u8 = 0x00;
const maxConflictRetries: usize = 64;
fn appendSegment(out: *std.ArrayList(u8), segment: []const u8) !void {
try out.appendSlice(segment);
try out.append(sep);
}
fn putI64(out: *std.ArrayList(u8), v: i64) !void {
var b: [8]u8 = undefined;
std.mem.writeInt(i64, &b, v, .big);
try out.appendSlice(&b);
}
fn readI64(data: []const u8) !i64 {
if (data.len != 8) return error.Malformed;
return std.mem.readInt(i64, data[0..8], .big);
}
fn putF64(out: *std.ArrayList(u8), v: f64) !void {
var b: [8]u8 = undefined;
std.mem.writeInt(u64, &b, @bitCast(v), .big);
try out.appendSlice(&b);
}
fn readF64(data: []const u8) !f64 {
if (data.len != 8) return error.Malformed;
return @bitCast(std.mem.readInt(u64, data[0..8], .big));
}
/// Every atomic collection mutator below retries up to `maxConflictRetries`
/// times on error.Conflict, re-reading current state fresh through `txn.get`
/// on each attempt rather than replaying a stale value. `error.TooManyConflicts`
/// is returned only if the retry budget is exhausted.
/// Identifies which typed collection a name belongs to - used by collttl.zig
/// to expire a WHOLE collection (every physical key under its namespace),
/// since unlike a flat key a collection has no single Db key of its own
/// (except Counter, see `exists`/`drop` below).
pub const Kind = enum {
counter,
hash,
set,
list,
zset,
pub fn parse(s: []const u8) ?Kind {
if (std.mem.eql(u8, s, "counter")) return .counter;
if (std.mem.eql(u8, s, "hash")) return .hash;
if (std.mem.eql(u8, s, "set")) return .set;
if (std.mem.eql(u8, s, "list")) return .list;
if (std.mem.eql(u8, s, "zset")) return .zset;
return null;
}
pub fn label(self: Kind) []const u8 {
return switch (self) {
.counter => "counter",
.hash => "hash",
.set => "set",
.list => "list",
.zset => "zset",
};
}
pub fn tagByte(self: Kind) u8 {
return switch (self) {
.counter => 'C',
.hash => 'H',
.set => 'S',
.list => 'L',
.zset => 'Z',
};
}
/// Inverse of `tagByte`, null on any other byte. Used to decode a
/// `Kind` back out of a wire payload (`protocol.zig`'s `coll_ttl_op`)
/// or a marker key (`collttl.zig`'s `sweepExpired`), which previously
/// duplicated this exact switch inline.
pub fn fromTagByte(b: u8) ?Kind {
return switch (b) {
'C' => .counter,
'H' => .hash,
'S' => .set,
'L' => .list,
'Z' => .zset,
else => null,
};
}
};
/// Prefix common to every physical key a Hash/Set/List/ZSet named `name`
/// owns - each of those four kinds always segments as
/// nsTag+kindTag+sep+name+sep+<rest>, so this single prefix reaches every
/// field/member/element/score-entry at once. Counter is the one kind NOT
/// covered here: its single key has no trailing separator after `name`
/// (see Counter.key), so a same-named counter is handled separately in
/// `exists`/`drop` below rather than through a prefix scan.
fn genericPrefix(allocator: Allocator, k: Kind, name: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, &[_]u8{k.tagByte()});
try appendSegment(&out, name);
return out.toOwnedSlice();
}
/// True if `name` currently has any physical presence under `k`'s namespace.
/// A counter counts as existing once `set`/`incrBy` has stored a value for
/// it, even 0 - that stored 0 is real data, not the "absent" default
/// Counter.get returns for a name nothing ever touched.
pub fn collectionExists(db: *Db, allocator: Allocator, k: Kind, name: []const u8) !bool {
if (k == .counter) {
const ck = try Counter.key(allocator, name);
defer allocator.free(ck);
const raw = try db.get(allocator, ck) orelse return false;
allocator.free(raw);
return true;
}
const p = try genericPrefix(allocator, k, name);
defer allocator.free(p);
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
return (try it.next()) != null;
}
/// Deletes every physical key belonging to `name` under `k`'s namespace -
/// every field of a hash, the meta record and every element of a list, both
/// the member row and score-index row of every zset entry. Returns the
/// count of physical keys removed. This is what makes a collection-level
/// TTL (collttl.zig) actually mean something: expiring a collection has to
/// remove ALL of it, not just one key the way ttl.zig's flat-key expiry does.
pub fn dropCollection(db: *Db, allocator: Allocator, k: Kind, name: []const u8) !usize {
if (k == .counter) {
const ck = try Counter.key(allocator, name);
defer allocator.free(ck);
const raw = try db.get(allocator, ck) orelse return 0;
allocator.free(raw);
try db.delete(ck);
return 1;
}
const p = try genericPrefix(allocator, k, name);
defer allocator.free(p);
var keys = std.ArrayList([]u8).init(allocator);
defer {
for (keys.items) |kk| allocator.free(kk);
keys.deinit();
}
{
// Collect first, delete after: db.delete mid-scan would mutate the
// memtable structure a live RangeIterator's cursors are threaded
// through (same reasoning as ttl.zig's sweepExpired).
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
while (try it.next()) |entry| {
try keys.append(try allocator.dupe(u8, entry.key));
}
}
for (keys.items) |kk| try db.delete(kk);
return keys.items.len;
}
fn prefixUpperBound(allocator: Allocator, prefix: []const u8) !?[]u8 {
var end = prefix.len;
while (end > 0 and prefix[end - 1] == 0xff) end -= 1;
if (end == 0) return null;
const upper = try allocator.alloc(u8, end);
@memcpy(upper, prefix[0..end]);
upper[end - 1] += 1;
return upper;
}
// ---------------------------------------------------------------------
// Counter
// ---------------------------------------------------------------------
pub const Counter = struct {
fn key(allocator: Allocator, name: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "C");
try out.appendSlice(name);
return out.toOwnedSlice();
}
pub fn get(db: *Db, allocator: Allocator, name: []const u8) !i64 {
const k = try key(allocator, name);
defer allocator.free(k);
const raw = try db.get(allocator, k) orelse return 0;
defer allocator.free(raw);
return readI64(raw);
}
pub fn set(db: *Db, allocator: Allocator, name: []const u8, value: i64) !void {
const k = try key(allocator, name);
defer allocator.free(k);
var out = std.ArrayList(u8).init(allocator);
defer out.deinit();
try putI64(&out, value);
try db.put(k, out.items);
}
/// Atomically adds `delta` to the counter (creating it at 0 if absent)
/// and returns the new value. Uses an OCC transaction so concurrent
/// increments never lose an update to a lost read-modify-write race.
pub fn incrBy(db: *Db, allocator: Allocator, name: []const u8, delta: i64) !i64 {
const k = try key(allocator, name);
defer allocator.free(k);
var attempt: usize = 0;
while (attempt < maxConflictRetries) : (attempt += 1) {
var txn = Transaction.begin(db);
defer txn.deinit();
const current: i64 = blk: {
const raw = try txn.get(allocator, k) orelse break :blk 0;
defer allocator.free(raw);
break :blk try readI64(raw);
};
const next = current + delta;
var out = std.ArrayList(u8).init(allocator);
defer out.deinit();
try putI64(&out, next);
try txn.put(k, out.items);
txn.commit() catch |err| {
if (err == error.Conflict) continue;
return err;
};
return next;
}
return error.TooManyConflicts;
}
};
// ---------------------------------------------------------------------
// Hash
// ---------------------------------------------------------------------
pub const HashField = struct {
field: []u8,
value: []u8,
pub fn free(self: HashField, allocator: Allocator) void {
allocator.free(self.field);
allocator.free(self.value);
}
};
pub const Hash = struct {
fn prefix(allocator: Allocator, name: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "H");
try appendSegment(&out, name);
return out.toOwnedSlice();
}
fn fieldKey(allocator: Allocator, name: []const u8, field: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "H");
try appendSegment(&out, name);
try out.appendSlice(field);
return out.toOwnedSlice();
}
/// Sets a field, returning true if the field was newly created.
pub fn set(db: *Db, allocator: Allocator, name: []const u8, field: []const u8, value: []const u8) !bool {
const k = try fieldKey(allocator, name, field);
defer allocator.free(k);
const existing = try db.get(allocator, k);
if (existing) |raw| allocator.free(raw);
try db.put(k, value);
return existing == null;
}
pub fn get(db: *Db, allocator: Allocator, name: []const u8, field: []const u8) !?[]u8 {
const k = try fieldKey(allocator, name, field);
defer allocator.free(k);
return db.get(allocator, k);
}
pub fn exists(db: *Db, allocator: Allocator, name: []const u8, field: []const u8) !bool {
const raw = try get(db, allocator, name, field) orelse return false;
allocator.free(raw);
return true;
}
/// Deletes a field, returning true if it existed.
pub fn del(db: *Db, allocator: Allocator, name: []const u8, field: []const u8) !bool {
const existed = try exists(db, allocator, name, field);
if (!existed) return false;
const k = try fieldKey(allocator, name, field);
defer allocator.free(k);
try db.delete(k);
return true;
}
pub fn len(db: *Db, allocator: Allocator, name: []const u8) !usize {
const p = try prefix(allocator, name);
defer allocator.free(p);
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
var count: usize = 0;
while (try it.next()) |_| count += 1;
return count;
}
/// Returns every field/value pair currently stored under `name`. Caller
/// owns the returned slice and each entry's `field`/`value` buffers.
pub fn getAll(db: *Db, allocator: Allocator, name: []const u8) ![]HashField {
const p = try prefix(allocator, name);
defer allocator.free(p);
var results = std.ArrayList(HashField).init(allocator);
errdefer {
for (results.items) |f| f.free(allocator);
results.deinit();
}
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
while (try it.next()) |entry| {
const field = try allocator.dupe(u8, entry.key[p.len..]);
errdefer allocator.free(field);
const value = try allocator.dupe(u8, entry.value);
try results.append(.{ .field = field, .value = value });
}
return results.toOwnedSlice();
}
};
// ---------------------------------------------------------------------
// Set
// ---------------------------------------------------------------------
pub const Set = struct {
fn prefix(allocator: Allocator, name: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "S");
try appendSegment(&out, name);
return out.toOwnedSlice();
}
fn memberKey(allocator: Allocator, name: []const u8, member: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "S");
try appendSegment(&out, name);
try out.appendSlice(member);
return out.toOwnedSlice();
}
/// Returns true if the member was newly added (false if already present).
pub fn add(db: *Db, allocator: Allocator, name: []const u8, member: []const u8) !bool {
if (try isMember(db, allocator, name, member)) return false;
const k = try memberKey(allocator, name, member);
defer allocator.free(k);
try db.put(k, &.{});
return true;
}
/// Returns true if the member was present and has been removed.
pub fn remove(db: *Db, allocator: Allocator, name: []const u8, member: []const u8) !bool {
if (!try isMember(db, allocator, name, member)) return false;
const k = try memberKey(allocator, name, member);
defer allocator.free(k);
try db.delete(k);
return true;
}
pub fn isMember(db: *Db, allocator: Allocator, name: []const u8, member: []const u8) !bool {
const k = try memberKey(allocator, name, member);
defer allocator.free(k);
const raw = try db.get(allocator, k) orelse return false;
allocator.free(raw);
return true;
}
pub fn card(db: *Db, allocator: Allocator, name: []const u8) !usize {
const members_ = try members(db, allocator, name);
defer {
for (members_) |m| allocator.free(m);
allocator.free(members_);
}
return members_.len;
}
/// Returns every member currently in the set. Caller owns the slice and
/// each element.
pub fn members(db: *Db, allocator: Allocator, name: []const u8) ![][]u8 {
const p = try prefix(allocator, name);
defer allocator.free(p);
var results = std.ArrayList([]u8).init(allocator);
errdefer {
for (results.items) |m| allocator.free(m);
results.deinit();
}
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
while (try it.next()) |entry| {
try results.append(try allocator.dupe(u8, entry.key[p.len..]));
}
return results.toOwnedSlice();
}
fn containsSorted(haystack: [][]const u8, needle: []const u8) bool {
for (haystack) |h| {
if (std.mem.eql(u8, h, needle)) return true;
}
return false;
}
/// Members present in every one of `names`. Caller owns the result.
pub fn intersect(db: *Db, allocator: Allocator, names: []const []const u8) ![][]u8 {
var results = std.ArrayList([]u8).init(allocator);
errdefer {
for (results.items) |m| allocator.free(m);
results.deinit();
}
if (names.len == 0) return results.toOwnedSlice();
const first = try members(db, allocator, names[0]);
defer {
for (first) |m| allocator.free(m);
allocator.free(first);
}
outer: for (first) |candidate| {
for (names[1..]) |other| {
if (!try isMember(db, allocator, other, candidate)) continue :outer;
}
try results.append(try allocator.dupe(u8, candidate));
}
return results.toOwnedSlice();
}
/// Members present in any of `names`, deduplicated. Caller owns the result.
pub fn unionOf(db: *Db, allocator: Allocator, names: []const []const u8) ![][]u8 {
var results = std.ArrayList([]u8).init(allocator);
errdefer {
for (results.items) |m| allocator.free(m);
results.deinit();
}
for (names) |name| {
const ms = try members(db, allocator, name);
defer {
for (ms) |m| allocator.free(m);
allocator.free(ms);
}
for (ms) |m| {
var seen = false;
for (results.items) |existing| {
if (std.mem.eql(u8, existing, m)) {
seen = true;
break;
}
}
if (!seen) try results.append(try allocator.dupe(u8, m));
}
}
return results.toOwnedSlice();
}
/// Members in `name` that are not in any set of `others`. Caller owns the result.
pub fn difference(db: *Db, allocator: Allocator, name: []const u8, others: []const []const u8) ![][]u8 {
var results = std.ArrayList([]u8).init(allocator);
errdefer {
for (results.items) |m| allocator.free(m);
results.deinit();
}
const base = try members(db, allocator, name);
defer {
for (base) |m| allocator.free(m);
allocator.free(base);
}
outer: for (base) |candidate| {
for (others) |other| {
if (try isMember(db, allocator, other, candidate)) continue :outer;
}
try results.append(try allocator.dupe(u8, candidate));
}
return results.toOwnedSlice();
}
};
// ---------------------------------------------------------------------
// List (double-ended queue keyed by a monotonic sequence range)
// ---------------------------------------------------------------------
const ListMeta = struct {
// Elements occupy sequence numbers (head+1 .. tail-1) inclusive; an
// empty list has tail == head + 1. Pushing left decrements head and
// writes at the new head; pushing right increments tail and writes at
// the slot just below the new tail.
head: i64,
tail: i64,
};
pub const List = struct {
fn metaKey(allocator: Allocator, name: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "L");
try appendSegment(&out, name);
try out.append('M');
return out.toOwnedSlice();
}
fn elementPrefix(allocator: Allocator, name: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "L");
try appendSegment(&out, name);
try appendSegment(&out, "E");
return out.toOwnedSlice();
}
fn elementKey(allocator: Allocator, name: []const u8, seq: i64) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "L");
try appendSegment(&out, name);
try appendSegment(&out, "E");
try row.appendOrdered(&out, .{ .int = seq });
return out.toOwnedSlice();
}
fn readMeta(txn: *Transaction, allocator: Allocator, k: []const u8) !ListMeta {
const raw = try txn.get(allocator, k) orelse return ListMeta{ .head = 0, .tail = 1 };
defer allocator.free(raw);
if (raw.len != 16) return error.Malformed;
return .{
.head = std.mem.readInt(i64, raw[0..8], .big),
.tail = std.mem.readInt(i64, raw[8..16], .big),
};
}
fn writeMeta(txn: *Transaction, k: []const u8, meta: ListMeta) !void {
var buf: [16]u8 = undefined;
std.mem.writeInt(i64, buf[0..8], meta.head, .big);
std.mem.writeInt(i64, buf[8..16], meta.tail, .big);
try txn.put(k, &buf);
}
pub fn pushLeft(db: *Db, allocator: Allocator, name: []const u8, value: []const u8) !void {
const mk = try metaKey(allocator, name);
defer allocator.free(mk);
var attempt: usize = 0;
while (attempt < maxConflictRetries) : (attempt += 1) {
var txn = Transaction.begin(db);
defer txn.deinit();
var meta = try readMeta(&txn, allocator, mk);
const seq = meta.head;
meta.head -= 1;
const ek = try elementKey(allocator, name, seq);
defer allocator.free(ek);
try txn.put(ek, value);
try writeMeta(&txn, mk, meta);
txn.commit() catch |err| {
if (err == error.Conflict) continue;
return err;
};
return;
}
return error.TooManyConflicts;
}
pub fn pushRight(db: *Db, allocator: Allocator, name: []const u8, value: []const u8) !void {
const mk = try metaKey(allocator, name);
defer allocator.free(mk);
var attempt: usize = 0;
while (attempt < maxConflictRetries) : (attempt += 1) {
var txn = Transaction.begin(db);
defer txn.deinit();
var meta = try readMeta(&txn, allocator, mk);
const seq = meta.tail;
meta.tail += 1;
const ek = try elementKey(allocator, name, seq);
defer allocator.free(ek);
try txn.put(ek, value);
try writeMeta(&txn, mk, meta);
txn.commit() catch |err| {
if (err == error.Conflict) continue;
return err;
};
return;
}
return error.TooManyConflicts;
}
pub fn popLeft(db: *Db, allocator: Allocator, name: []const u8) !?[]u8 {
const mk = try metaKey(allocator, name);
defer allocator.free(mk);
var attempt: usize = 0;
while (attempt < maxConflictRetries) : (attempt += 1) {
var txn = Transaction.begin(db);
defer txn.deinit();
var meta = try readMeta(&txn, allocator, mk);
if (meta.tail - meta.head <= 1) return null;
const seq = meta.head + 1;
meta.head += 1;
const ek = try elementKey(allocator, name, seq);
defer allocator.free(ek);
const value = try txn.get(allocator, ek) orelse return error.Malformed;
errdefer allocator.free(value);
try txn.delete(ek);
try writeMeta(&txn, mk, meta);
txn.commit() catch |err| {
allocator.free(value);
if (err == error.Conflict) continue;
return err;
};
return value;
}
return error.TooManyConflicts;
}
pub fn popRight(db: *Db, allocator: Allocator, name: []const u8) !?[]u8 {
const mk = try metaKey(allocator, name);
defer allocator.free(mk);
var attempt: usize = 0;
while (attempt < maxConflictRetries) : (attempt += 1) {
var txn = Transaction.begin(db);
defer txn.deinit();
var meta = try readMeta(&txn, allocator, mk);
if (meta.tail - meta.head <= 1) return null;
const seq = meta.tail - 1;
meta.tail -= 1;
const ek = try elementKey(allocator, name, seq);
defer allocator.free(ek);
const value = try txn.get(allocator, ek) orelse return error.Malformed;
errdefer allocator.free(value);
try txn.delete(ek);
try writeMeta(&txn, mk, meta);
txn.commit() catch |err| {
allocator.free(value);
if (err == error.Conflict) continue;
return err;
};
return value;
}
return error.TooManyConflicts;
}
pub fn len(db: *Db, allocator: Allocator, name: []const u8) !usize {
const mk = try metaKey(allocator, name);
defer allocator.free(mk);
const raw = try db.get(allocator, mk) orelse return 0;
defer allocator.free(raw);
if (raw.len != 16) return error.Malformed;
const head = std.mem.readInt(i64, raw[0..8], .big);
const tail = std.mem.readInt(i64, raw[8..16], .big);
return @intCast(tail - head - 1);
}
/// Redis-style inclusive range with negative indices counting from the
/// end (-1 is the last element). Out-of-range indices are clamped;
/// start > stop after clamping yields an empty result. Caller owns the
/// returned slice and each element.
pub fn range(db: *Db, allocator: Allocator, name: []const u8, start: i64, stop: i64) ![][]u8 {
const total: i64 = @intCast(try len(db, allocator, name));
var results = std.ArrayList([]u8).init(allocator);
errdefer {
for (results.items) |m| allocator.free(m);
results.deinit();
}
if (total == 0) return results.toOwnedSlice();
var lo = if (start < 0) total + start else start;
var hi = if (stop < 0) total + stop else stop;
if (lo < 0) lo = 0;
if (hi > total - 1) hi = total - 1;
if (lo > hi) return results.toOwnedSlice();
const p = try elementPrefix(allocator, name);
defer allocator.free(p);
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
var idx: i64 = 0;
while (try it.next()) |entry| {
if (idx >= lo and idx <= hi) {
try results.append(try allocator.dupe(u8, entry.value));
}
idx += 1;
}
return results.toOwnedSlice();
}
};
// ---------------------------------------------------------------------
// ZSet (sorted set: member -> score, plus a score-ordered index for
// range/rank queries)
// ---------------------------------------------------------------------
pub const ZMember = struct {
member: []u8,
score: f64,
pub fn free(self: ZMember, allocator: Allocator) void {
allocator.free(self.member);
}
};
pub const ZSet = struct {
fn memberKey(allocator: Allocator, name: []const u8, member: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "Z");
try appendSegment(&out, name);
try appendSegment(&out, "M");
try out.appendSlice(member);
return out.toOwnedSlice();
}
fn scorePrefix(allocator: Allocator, name: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "Z");
try appendSegment(&out, name);
try appendSegment(&out, "S");
return out.toOwnedSlice();
}
fn scoreEntryKey(allocator: Allocator, name: []const u8, scoreVal: f64, member: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "Z");
try appendSegment(&out, name);
try appendSegment(&out, "S");
try row.appendOrdered(&out, .{ .float = scoreVal });
try row.appendOrdered(&out, .{ .text = member });
return out.toOwnedSlice();
}
/// Score-index key truncated to just the ordered score (no member
/// suffix), used as a scan bound. Every real entry is a strict byte
/// extension of this (the member part is never empty), so a key equal to
/// `scoreBound(s)` never itself denotes a stored member.
fn scoreBound(allocator: Allocator, name: []const u8, scoreVal: f64) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(nsTag);
try appendSegment(&out, "Z");
try appendSegment(&out, name);
try appendSegment(&out, "S");
try row.appendOrdered(&out, .{ .float = scoreVal });
return out.toOwnedSlice();
}
/// Upserts `member`'s score, atomically re-pointing the score index if
/// the member already existed with a different score.
pub fn add(db: *Db, allocator: Allocator, name: []const u8, member: []const u8, scoreVal: f64) !void {
const mk = try memberKey(allocator, name, member);
defer allocator.free(mk);
var attempt: usize = 0;
while (attempt < maxConflictRetries) : (attempt += 1) {
var txn = Transaction.begin(db);
defer txn.deinit();
const old = try txn.get(allocator, mk);
if (old) |raw| {
defer allocator.free(raw);
const oldScore = try readF64(raw);
if (oldScore == scoreVal) return;
const oldEntryKey = try scoreEntryKey(allocator, name, oldScore, member);
defer allocator.free(oldEntryKey);
try txn.delete(oldEntryKey);
}
var scoreBuf = std.ArrayList(u8).init(allocator);
defer scoreBuf.deinit();
try putF64(&scoreBuf, scoreVal);
try txn.put(mk, scoreBuf.items);
const entryKey = try scoreEntryKey(allocator, name, scoreVal, member);
defer allocator.free(entryKey);
try txn.put(entryKey, &.{});
txn.commit() catch |err| {
if (err == error.Conflict) continue;
return err;
};
return;
}
return error.TooManyConflicts;
}
pub fn score(db: *Db, allocator: Allocator, name: []const u8, member: []const u8) !?f64 {
const mk = try memberKey(allocator, name, member);
defer allocator.free(mk);
const raw = try db.get(allocator, mk) orelse return null;
defer allocator.free(raw);
return try readF64(raw);
}
pub fn incrBy(db: *Db, allocator: Allocator, name: []const u8, member: []const u8, delta: f64) !f64 {
const mk = try memberKey(allocator, name, member);
defer allocator.free(mk);
var attempt: usize = 0;
while (attempt < maxConflictRetries) : (attempt += 1) {
var txn = Transaction.begin(db);
defer txn.deinit();
const oldRaw = try txn.get(allocator, mk);
const oldScore: f64 = if (oldRaw) |raw| blk: {
defer allocator.free(raw);
break :blk try readF64(raw);
} else 0.0;
const newScore = oldScore + delta;
if (oldRaw != null) {
const oldEntryKey = try scoreEntryKey(allocator, name, oldScore, member);
defer allocator.free(oldEntryKey);
try txn.delete(oldEntryKey);
}
var scoreBuf = std.ArrayList(u8).init(allocator);
defer scoreBuf.deinit();
try putF64(&scoreBuf, newScore);
try txn.put(mk, scoreBuf.items);
const entryKey = try scoreEntryKey(allocator, name, newScore, member);
defer allocator.free(entryKey);
try txn.put(entryKey, &.{});
txn.commit() catch |err| {
if (err == error.Conflict) continue;
return err;
};
return newScore;
}
return error.TooManyConflicts;
}
/// Returns true if the member was present and has been removed.
pub fn remove(db: *Db, allocator: Allocator, name: []const u8, member: []const u8) !bool {
const mk = try memberKey(allocator, name, member);
defer allocator.free(mk);
var attempt: usize = 0;
while (attempt < maxConflictRetries) : (attempt += 1) {
var txn = Transaction.begin(db);
defer txn.deinit();
const raw = try txn.get(allocator, mk) orelse return false;
defer allocator.free(raw);
const existingScore = try readF64(raw);
try txn.delete(mk);
const entryKey = try scoreEntryKey(allocator, name, existingScore, member);
defer allocator.free(entryKey);
try txn.delete(entryKey);
txn.commit() catch |err| {
if (err == error.Conflict) continue;
return err;
};
return true;
}
return error.TooManyConflicts;
}
pub fn card(db: *Db, allocator: Allocator, name: []const u8) !usize {
const p = try scorePrefix(allocator, name);
defer allocator.free(p);
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
var count: usize = 0;
while (try it.next()) |_| count += 1;
return count;
}
fn decodeScoreEntry(allocator: Allocator, name: []const u8, key: []const u8) !ZMember {
const p = try scorePrefix(allocator, name);
defer allocator.free(p);
if (key.len <= p.len or !std.mem.startsWith(u8, key, p)) return error.Malformed;
const rest = key[p.len..];
// rest = [tag u8][8-byte ordered score][tag u8][escaped member][0x00 0x00]
if (rest.len < 9 or rest[0] != @intFromEnum(row.ValueTag.float)) return error.Malformed;
var bits = std.mem.readInt(u64, rest[1..9], .big);
if (bits & 0x8000000000000000 != 0) {
bits &= 0x7fffffffffffffff;
} else {
bits = ~bits;
}
const decodedScore: f64 = @bitCast(bits);
const memberPart = rest[9..];
if (memberPart.len < 3 or memberPart[0] != @intFromEnum(row.ValueTag.text)) return error.Malformed;
var unescaped = std.ArrayList(u8).init(allocator);
errdefer unescaped.deinit();
var i: usize = 1;
while (i < memberPart.len) {
if (memberPart[i] == 0x00) {
if (i + 1 >= memberPart.len) return error.Malformed;
if (memberPart[i + 1] == 0x00) break;
if (memberPart[i + 1] == 0xFF) {
try unescaped.append(0x00);
i += 2;
continue;
}
return error.Malformed;
}
try unescaped.append(memberPart[i]);
i += 1;
}
return .{ .member = try unescaped.toOwnedSlice(), .score = decodedScore };
}
/// Ascending-by-score range selected by rank (0-based, inclusive on both
/// ends), Redis-style: negative indices count from the end. Caller owns
/// the returned slice and each member's buffer.
pub fn rangeByRank(db: *Db, allocator: Allocator, name: []const u8, start: i64, stop: i64) ![]ZMember {
const total: i64 = @intCast(try card(db, allocator, name));
var results = std.ArrayList(ZMember).init(allocator);
errdefer {
for (results.items) |m| m.free(allocator);
results.deinit();
}
if (total == 0) return results.toOwnedSlice();
var lo = if (start < 0) total + start else start;
var hi = if (stop < 0) total + stop else stop;
if (lo < 0) lo = 0;
if (hi > total - 1) hi = total - 1;
if (lo > hi) return results.toOwnedSlice();
const p = try scorePrefix(allocator, name);
defer allocator.free(p);
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
var idx: i64 = 0;
while (try it.next()) |entry| {
if (idx >= lo and idx <= hi) {
try results.append(try decodeScoreEntry(allocator, name, entry.key));
}
idx += 1;
}
return results.toOwnedSlice();
}
/// All members whose score falls within [min, max], found via a real
/// bounded scan over the score index (not a full-collection scan): the
/// start bound is `scoreBound(min)` and the end bound is the byte
/// immediately after `scoreBound(max)`, which is enough to include every
/// entry whose score equals `max` since real entries strictly extend
/// their score bound (see `scoreBound`'s doc comment). Caller owns the
/// returned slice and each member's buffer.
pub fn rangeByScore(db: *Db, allocator: Allocator, name: []const u8, min: f64, max: f64) ![]ZMember {
var results = std.ArrayList(ZMember).init(allocator);
errdefer {
for (results.items) |m| m.free(allocator);
results.deinit();
}
const startBound = try scoreBound(allocator, name, min);
defer allocator.free(startBound);
const maxBound = try scoreBound(allocator, name, max);
defer allocator.free(maxBound);
const endBound = try prefixUpperBound(allocator, maxBound) orelse try allocator.dupe(u8, maxBound);
defer allocator.free(endBound);
var it = try db.scanRange(allocator, .{
.start = startBound,
.end = endBound,
.startInclusive = true,
.endInclusive = false,
});
defer it.deinit();
while (try it.next()) |entry| {
try results.append(try decodeScoreEntry(allocator, name, entry.key));
}
return results.toOwnedSlice();
}
/// 0-based ascending-by-score rank of `member`, or null if absent.
pub fn rank(db: *Db, allocator: Allocator, name: []const u8, member: []const u8) !?usize {
const target = try score(db, allocator, name, member) orelse return null;
const p = try scorePrefix(allocator, name);
defer allocator.free(p);
var it = try db.scanPrefix(allocator, p);
defer it.deinit();
var idx: usize = 0;
while (try it.next()) |entry| {
const decoded = try decodeScoreEntry(allocator, name, entry.key);
defer decoded.free(allocator);
if (decoded.score == target and std.mem.eql(u8, decoded.member, member)) return idx;
idx += 1;
}
return null;
}
};
// ---------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------
fn openTemp(tmp: *std.testing.TmpDir, allocator: Allocator) !Db {
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
return Db.open(allocator, dbPath, .{});
}
test "counter incrBy accumulates and get defaults to zero" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
try std.testing.expectEqual(@as(i64, 0), try Counter.get(&db, allocator, "hits"));
try std.testing.expectEqual(@as(i64, 5), try Counter.incrBy(&db, allocator, "hits", 5));
try std.testing.expectEqual(@as(i64, 3), try Counter.incrBy(&db, allocator, "hits", -2));
try std.testing.expectEqual(@as(i64, 3), try Counter.get(&db, allocator, "hits"));
try Counter.set(&db, allocator, "hits", 100);
try std.testing.expectEqual(@as(i64, 100), try Counter.get(&db, allocator, "hits"));
}
test "counter incrBy rejects a real external conflict" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
// incrBy retries internally, so exercise the underlying primitive
// directly: begin a txn, let an external writer land in between, then
// confirm the stale commit is rejected rather than silently overwriting.
const k = try Counter.key(allocator, "race");
defer allocator.free(k);
var txn = Transaction.begin(&db);
defer txn.deinit();
_ = try txn.get(allocator, k);
try db.put(k, "external");
try txn.put(k, "mine");
try std.testing.expectError(error.Conflict, txn.commit());
}