Files
cobalt/src/server.zig
T
2026-09-12 13:21:10 +02:00

5648 lines
258 KiB
Zig

const std = @import("std");
const protocol = @import("protocol.zig");
const dbmod = @import("db.zig");
const metrics = @import("metrics.zig");
const querymod = @import("query.zig");
const exprmod = @import("expr.zig");
const authmod = @import("auth.zig");
const sessionmod = @import("session.zig");
const ttlmod = @import("ttl.zig");
const collmod = @import("collections.zig");
const collttlmod = @import("collttl.zig");
const rowmod = @import("row.zig");
const txnmod = @import("txn.zig");
const txntablemod = @import("txn_table.zig");
const joinmod = @import("join.zig");
const aggregatemod = @import("aggregate.zig");
const windowmod = @import("window.zig");
const dumpmod = @import("dump.zig");
const pubsubmod = @import("pubsub.zig");
const Allocator = std.mem.Allocator;
const Db = dbmod.Db;
/// Which role a given opcode needs at minimum when auth is enabled. `ping`
/// and `auth` itself are handled before this is ever consulted (see
/// `dispatch`) - every other read op needs `readonly`, every mutation
/// needs `readwrite`, and `user_admin` (remote user management: create,
/// change password, change role, delete, list) needs `admin` - the one
/// opcode reserved for administrative server operations. Bootstrapping the
/// very first admin account still has to happen CLI-only over a local Db
/// handle (see `main.zig`'s `user add` and `serve --auth`'s refusal to
/// start without one), since a fresh server has no admin to authenticate
/// this opcode with in the first place.
///
/// `ttl_op` bundles four actions (see `protocol.TtlAction`) behind one
/// opcode, unlike every other entry here which is a single action - so it
/// is the one case that needs to peek at the payload's first byte (the
/// action tag) rather than deciding purely from `op`. `query` is read-only;
/// `expire`/`persist`/`sweep` all mutate the store and need `readwrite`. A
/// payload too short to carry an action byte falls back to `readwrite` (the
/// stricter requirement) - the actual decode in `handleTtlOp` will reject it
/// as malformed either way, so this only affects which error status a
/// bad frame gets before it is ever decoded.
fn requiredRole(op: protocol.Op, payload: []const u8) authmod.Role {
return switch (op) {
.ping, .auth, .auth_token => .readonly,
.get, .scan, .prefix, .stats, .table_select, .table_join, .table_aggregate, .table_window, .table_explain, .table_export, .metrics => .readonly,
.set, .del, .batch, .flush, .compact, .table_update, .table_delete, .table_import => .readwrite,
.user_admin => .admin,
.ttl_op => if (payload.len > 0 and payload[0] == @intFromEnum(protocol.TtlAction.query))
.readonly
else
.readwrite,
// Same action-byte peek as `.ttl_op` above, and for the same
// reason: `coll_ttl_op` bundles the same four actions, just
// against a whole collection instead of one flat key.
.coll_ttl_op => if (payload.len > 0 and payload[0] == @intFromEnum(protocol.TtlAction.query))
.readonly
else
.readwrite,
// `coll_op` bundles thirty collection actions behind one opcode
// (see `protocol.CollAction`) - same action-byte peek as `ttl_op`/
// `coll_ttl_op`, but the read/write split is looked up from
// `CollAction.isReadOnly` instead of a single fixed action value,
// since unlike the four TTL actions this opcode's actions are not
// all one enum-comparison away from each other.
.coll_op => if (payload.len > 0)
(if ((protocol.CollAction.fromByte(payload[0]) catch null)) |a|
(if (a.isReadOnly()) authmod.Role.readonly else .readwrite)
else
.readwrite)
else
.readwrite,
// `txn_op` is always a write (every entry is a put or a delete),
// unlike `ttl_op`/`coll_ttl_op`/`coll_op` above it has no read-only
// action to peek for, so it needs no payload inspection at all.
.txn_op => .readwrite,
// Pub/sub never touches `Db` at all (see `pubsub.zig`'s `Hub` doc
// comment) - all three are gated `readonly` purely so a server with
// auth enabled still requires SOME authenticated connection before
// any of them works, matching every other opcode's default
// posture, rather than because any of them reads storage.
// `unsubscribe` in particular is always sent from a DIFFERENT
// connection than the one that subscribed (see
// `protocol.encodeUnsubscribe`'s doc comment) - it needs no
// stronger role than `subscribe`/`publish` just because it arrives
// over its own connection.
.subscribe, .publish, .unsubscribe => .readonly,
};
}
const PerConnLimitConfig = struct {
capacity: f64,
refillPerSecond: f64,
};
/// A TCP server that exposes a single `Db` over the binary protocol in
/// `protocol.zig`. One accept loop thread hands each connection off to
/// its own detached worker thread; every worker serialises its access to
/// `db` through `mutex` since `Db` itself keeps no internal locking.
pub const Server = struct {
allocator: Allocator,
db: *Db,
mutex: std.Thread.Mutex = .{},
net: std.net.Server,
acceptThread: ?std.Thread = null,
active: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
stopping: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
limiter: ?metrics.TokenBucket = null,
perConnLimit: ?PerConnLimitConfig = null,
requestsTotal: metrics.MetricCounter = .{},
rateLimitedTotal: metrics.MetricCounter = .{},
connRateLimitedTotal: metrics.MetricCounter = .{},
authStore: ?authmod.Store = null,
unauthorizedTotal: metrics.MetricCounter = .{},
forbiddenTotal: metrics.MetricCounter = .{},
sessions: ?*sessionmod.SessionStore = null,
sessionTtlNanos: i128 = 0,
sessionExpiredTotal: metrics.MetricCounter = .{},
sweepIntervalNanos: u64 = 0,
sweeperThread: ?std.Thread = null,
sessionsSweptTotal: metrics.MetricCounter = .{},
ttlSweepIntervalNanos: u64 = 0,
ttlSweeperThread: ?std.Thread = null,
ttlSweptTotal: metrics.MetricCounter = .{},
collTtlSweepIntervalNanos: u64 = 0,
collTtlSweeperThread: ?std.Thread = null,
collTtlSweptTotal: metrics.MetricCounter = .{},
/// Channel registry for `subscribe`/`publish` - see `pubsub.zig`'s doc
/// comment on `Hub` for why this is entirely separate from `mutex`/`db`
/// above (a live subscriber must never be able to block a store read or
/// write, or vice versa).
pubsub: pubsubmod.Hub = undefined,
messagesPublishedTotal: metrics.MetricCounter = .{},
pub fn init(allocator: Allocator, db: *Db, address: std.net.Address) !Server {
const net = try address.listen(.{ .reuse_address = true });
return .{ .allocator = allocator, .db = db, .net = net, .pubsub = pubsubmod.Hub.init(allocator) };
}
pub fn boundAddress(self: *Server) std.net.Address {
return self.net.listen_address;
}
/// Number of connections currently being served. Exposed for tests
/// that need to wait for a handler to finish without racing on
/// thread teardown.
pub fn activeConnections(self: *Server) usize {
return self.active.load(.acquire);
}
/// Turns on a token-bucket rate limit shared across every connection:
/// each dispatched request consumes one token before it is allowed to
/// touch `db.mutex` at all, so an overload is rejected cheaply instead
/// of queuing behind real storage work. Disabled (unlimited) by
/// default; call once before `start()`.
pub fn enableRateLimit(self: *Server, capacity: f64, refillPerSecond: f64) void {
self.limiter = metrics.TokenBucket.init(capacity, refillPerSecond, @intCast(std.time.nanoTimestamp()));
}
/// Gives every accepted connection its own private token bucket,
/// independent of every other connection's budget and of the shared
/// `limiter` above. A fresh bucket is created per connection in
/// `handleConnection` (not shared state, so no locking is needed beyond
/// what TokenBucket already does internally) - one noisy client cannot
/// starve another client's share of the server, which a single global
/// bucket cannot express. Both limits can be enabled together: a
/// request must clear the per-connection budget before it is even
/// checked against the global one. Disabled by default; call once
/// before `start()`.
pub fn enablePerConnectionRateLimit(self: *Server, capacity: f64, refillPerSecond: f64) void {
self.perConnLimit = .{ .capacity = capacity, .refillPerSecond = refillPerSecond };
}
/// Turns on username/password authentication and role enforcement for
/// every connection: after this call, every opcode except `ping` and
/// `auth` itself requires a prior successful `auth` on that connection
/// (`unauthorized` otherwise), and the authenticated role must meet
/// `requiredRole(op)` (`forbidden` otherwise). `store` is not owned by
/// the server - the caller keeps managing the underlying `Db` user
/// records (creating the first admin, etc.) through `store` directly.
/// Disabled (no auth required, matching every prior version of this
/// server) by default; call once before `start()`.
pub fn enableAuth(self: *Server, store: authmod.Store) void {
self.authStore = store;
}
/// Layers expiring session tokens on top of password auth (requires
/// `enableAuth` to also be called - sessions are only ever minted from
/// a successful password check, never a substitute for one). Once
/// enabled: a successful `auth` response carries a fresh token in its
/// payload (empty payload otherwise, unchanged from before this
/// existed) valid for `ttlNanos`; a NEW connection can skip the
/// password entirely by sending that token as `auth_token` instead;
/// and - the actual behavior change from plain password auth - a
/// connection's authenticated role stops being honored the moment its
/// session's expiry passes, even if the TCP connection itself has
/// stayed open the whole time (`dispatch` re-checks the wall clock on
/// every request, not just at `auth` time). `store` is not owned by
/// the server, exactly like `enableAuth`'s `authmod.Store` - the
/// caller keeps it alive and can inspect/revoke sessions directly.
pub fn enableSessions(self: *Server, store: *sessionmod.SessionStore, ttlNanos: i128) void {
self.sessions = store;
self.sessionTtlNanos = ttlNanos;
}
/// Runs `SessionStore.sweepExpired` on a background thread every
/// `intervalNanos`, starting once `start()` is called. Without this, a
/// session nobody ever presents again (the client that held it simply
/// vanished) sits in `sessions` forever - `validate()` only ever cleans
/// up a token someone actually tries to use. Requires `enableSessions`
/// to have been called first (checked in `start()`, not here, since
/// callers are free to call these two in either order before `start`).
pub fn enableSessionSweep(self: *Server, intervalNanos: u64) void {
self.sweepIntervalNanos = intervalNanos;
}
/// Runs `ttl.sweepExpired` on a background thread every `intervalNanos`,
/// starting once `start()` is called - independent of `enableAuth`/
/// `enableSessions`/`enableSessionSweep` above, since key TTLs (set via
/// the `ttl_op` opcode or the CLI's local `put --ttl`/`expire`) are a
/// property of the `Db` itself, not of authentication. Without this, a
/// key whose TTL elapsed but that nobody ever reads again (so
/// `ttl.getLive`'s lazy expiry never fires) sits in the store forever -
/// exactly the same gap `enableSessionSweep` closes for session tokens.
/// Call once before `start()`.
pub fn enableTtlSweep(self: *Server, intervalNanos: u64) void {
self.ttlSweepIntervalNanos = intervalNanos;
}
/// Runs `collttl.sweepExpired` on a background thread every
/// `intervalNanos`, starting once `start()` is called - the whole-
/// collection counterpart of `enableTtlSweep` above, independent of it
/// (a server can enable one, both, or neither). Without this, a
/// collection whose TTL elapsed but that nobody ever touches again (so
/// `coll_ttl_op`'s lazy `expireIfDue` never fires, matching the CLI's
/// local `coll` dispatcher) sits in the store forever. Call once before
/// `start()`.
pub fn enableCollTtlSweep(self: *Server, intervalNanos: u64) void {
self.collTtlSweepIntervalNanos = intervalNanos;
}
/// Registers this server's request counters into `reg` under fixed
/// names, so they show up alongside any other Prometheus metrics an
/// embedding application already exports.
pub fn registerMetrics(self: *Server, reg: *metrics.Registry) !void {
try reg.registerCounter("cobalt_server_requests_total", &self.requestsTotal);
try reg.registerCounter("cobalt_server_rate_limited_total", &self.rateLimitedTotal);
try reg.registerCounter("cobalt_server_conn_rate_limited_total", &self.connRateLimitedTotal);
try reg.registerCounter("cobalt_server_unauthorized_total", &self.unauthorizedTotal);
try reg.registerCounter("cobalt_server_forbidden_total", &self.forbiddenTotal);
try reg.registerCounter("cobalt_server_session_expired_total", &self.sessionExpiredTotal);
try reg.registerCounter("cobalt_server_sessions_swept_total", &self.sessionsSweptTotal);
try reg.registerCounter("cobalt_server_ttl_swept_total", &self.ttlSweptTotal);
try reg.registerCounter("cobalt_server_coll_ttl_swept_total", &self.collTtlSweptTotal);
try reg.registerCounter("cobalt_server_messages_published_total", &self.messagesPublishedTotal);
}
pub fn start(self: *Server) !void {
self.acceptThread = try std.Thread.spawn(.{}, acceptLoop, .{self});
if (self.sessions != null and self.sweepIntervalNanos > 0) {
self.sweeperThread = try std.Thread.spawn(.{}, sweepLoop, .{self});
}
if (self.ttlSweepIntervalNanos > 0) {
self.ttlSweeperThread = try std.Thread.spawn(.{}, ttlSweepLoop, .{self});
}
if (self.collTtlSweepIntervalNanos > 0) {
self.collTtlSweeperThread = try std.Thread.spawn(.{}, collTtlSweepLoop, .{self});
}
}
/// Blocks until the accept loop exits (only happens via `stop()` or
/// an unrecoverable accept error). Used by long-running foreground
/// servers such as the CLI's `serve` command.
pub fn wait(self: *Server) void {
if (self.acceptThread) |t| t.join();
}
/// Background loop started by `start()` when `enableSessionSweep` set a
/// nonzero interval and sessions are enabled. Sleeps the full interval
/// between sweeps (a sweep is pure housekeeping, never load-bearing for
/// correctness - `validate()` already refuses an expired token on its
/// own), and checks `stopping` both before and after each sleep so
/// `stop()` never has to wait more than one interval to join it.
fn sweepLoop(self: *Server) void {
const store = self.sessions orelse return;
while (!self.stopping.load(.acquire)) {
std.time.sleep(self.sweepIntervalNanos);
if (self.stopping.load(.acquire)) return;
const dropped = store.sweepExpired(std.time.nanoTimestamp());
if (dropped > 0) self.sessionsSweptTotal.incrBy(@intCast(dropped));
}
}
/// Background loop started by `start()` when `enableTtlSweep` set a
/// nonzero interval. Unlike `sweepLoop` above, `ttl.sweepExpired` reads
/// and mutates the same `Db` every request handler touches, so this
/// loop must take `self.mutex` around the call exactly like `dispatch`
/// does for every opcode - `Db` itself keeps no internal locking (see
/// the doc comment on `Server`). A failed sweep (e.g. a transient I/O
/// error) is skipped rather than crashing the server; the next tick
/// tries again.
fn ttlSweepLoop(self: *Server) void {
while (!self.stopping.load(.acquire)) {
std.time.sleep(self.ttlSweepIntervalNanos);
if (self.stopping.load(.acquire)) return;
self.mutex.lock();
const reaped = ttlmod.sweepExpired(self.db, self.allocator, std.time.nanoTimestamp()) catch {
self.mutex.unlock();
continue;
};
self.mutex.unlock();
if (reaped > 0) self.ttlSweptTotal.incrBy(@intCast(reaped));
}
}
/// Background loop started by `start()` when `enableCollTtlSweep` set a
/// nonzero interval - the whole-collection counterpart of `ttlSweepLoop`
/// above, same shape (own thread, takes `self.mutex` around the actual
/// `Db` access, skips a failed sweep rather than crashing).
/// `collttl.sweepExpired` walks every kind's marker namespace in one
/// prefix scan, so one thread covers Counter/Hash/Set/List/ZSet at once
/// rather than needing one sweeper per kind.
fn collTtlSweepLoop(self: *Server) void {
while (!self.stopping.load(.acquire)) {
std.time.sleep(self.collTtlSweepIntervalNanos);
if (self.stopping.load(.acquire)) return;
self.mutex.lock();
const reaped = collttlmod.sweepExpired(self.db, self.allocator, std.time.nanoTimestamp()) catch {
self.mutex.unlock();
continue;
};
self.mutex.unlock();
if (reaped > 0) self.collTtlSweptTotal.incrBy(@intCast(reaped));
}
}
fn acceptLoop(self: *Server) void {
while (true) {
const conn = self.net.accept() catch return;
if (self.stopping.load(.acquire)) {
conn.stream.close();
return;
}
_ = self.active.fetchAdd(1, .acq_rel);
const t = std.Thread.spawn(.{}, handleConnection, .{ self, conn }) catch {
_ = self.active.fetchSub(1, .acq_rel);
conn.stream.close();
continue;
};
t.detach();
}
}
fn handleConnection(self: *Server, conn: std.net.Server.Connection) void {
defer conn.stream.close();
defer _ = self.active.fetchSub(1, .acq_rel);
var stream = conn.stream;
// A private bucket for this one connection, if enabled. It lives on
// this worker's own stack and is only ever touched by this thread,
// so unlike `self.limiter` it needs no cross-thread synchronization
// even though TokenBucket carries its own mutex regardless.
var connLimiter: ?metrics.TokenBucket = if (self.perConnLimit) |cfg|
metrics.TokenBucket.init(cfg.capacity, cfg.refillPerSecond, @intCast(std.time.nanoTimestamp()))
else
null;
// Which role (if any) this one connection has authenticated as.
// Lives on this worker's stack, exactly like `connLimiter` above -
// a fresh, private slot per connection, never shared.
var connRole: ?authmod.Role = null;
// When sessions are enabled (see `enableSessions`), the nanosecond
// timestamp at which `connRole` above stops being honored - set
// whenever `auth`/`auth_token` succeeds, re-checked on every
// request in `dispatch`. Stays null (never expires on its own) for
// a server with auth but no session store, matching the original
// "authenticated for as long as the connection is open" behavior.
var connExpiresAtNanos: ?i128 = null;
while (true) {
var frame = protocol.readFrame(self.allocator, stream.reader()) catch return;
defer frame.deinit();
const limPtr: ?*metrics.TokenBucket = if (connLimiter) |*cl| cl else null;
self.dispatch(&frame, stream, stream.writer(), limPtr, &connRole, &connExpiresAtNanos) catch return;
}
}
fn writeErr(writer: anytype, status: protocol.Status, message: []const u8) !void {
try protocol.writeFrame(writer, @intFromEnum(status), message);
}
fn dispatch(self: *Server, frame: *protocol.Frame, stream: std.net.Stream, writer: anytype, connLimiter: ?*metrics.TokenBucket, connRole: *?authmod.Role, connExpiresAtNanos: *?i128) !void {
self.requestsTotal.incr();
if (connLimiter) |cl| {
if (!cl.tryAcquireNow(1)) {
self.connRateLimitedTotal.incr();
return writeErr(writer, .rate_limited, "per-connection rate limit exceeded");
}
}
if (self.limiter) |*lim| {
if (!lim.tryAcquireNow(1)) {
self.rateLimitedTotal.incr();
return writeErr(writer, .rate_limited, "rate limit exceeded");
}
}
const op = protocol.Op.fromByte(frame.tag) catch {
return writeErr(writer, .bad_request, "unknown opcode");
};
if (self.authStore) |store| {
if (op == .auth) return self.handleAuth(store, frame.payload, writer, connRole, connExpiresAtNanos);
if (op == .auth_token) return self.handleAuthToken(frame.payload, writer, connRole, connExpiresAtNanos);
if (op != .ping) {
const role = connRole.* orelse {
self.unauthorizedTotal.incr();
return writeErr(writer, .unauthorized, "authentication required");
};
// A role survives only as long as its session does, when
// sessions are enabled: a session that expired since the
// last request on this same connection is treated exactly
// like never having authenticated at all, forcing a fresh
// `auth`/`auth_token` before anything else proceeds.
if (connExpiresAtNanos.*) |expiresAt| {
if (std.time.nanoTimestamp() >= expiresAt) {
connRole.* = null;
connExpiresAtNanos.* = null;
self.sessionExpiredTotal.incr();
self.unauthorizedTotal.incr();
return writeErr(writer, .unauthorized, "session expired");
}
}
if (!role.atLeast(requiredRole(op, frame.payload))) {
self.forbiddenTotal.incr();
return writeErr(writer, .forbidden, "insufficient role for this operation");
}
}
}
// `subscribe`/`publish` never touch `db` - handled here, before
// `self.mutex` is ever taken, so neither one can be blocked behind
// (or block) a store read/write. `subscribe` in particular can hold
// this stack frame open for the entire remaining lifetime of the
// connection (see `handleSubscribe`'s doc comment), which would
// starve every other connection forever if it happened under
// `self.mutex`.
if (op == .subscribe) return self.handleSubscribe(frame.payload, stream);
if (op == .publish) return self.handlePublish(frame.payload, writer);
if (op == .unsubscribe) return self.handleUnsubscribe(frame.payload, writer);
self.mutex.lock();
defer self.mutex.unlock();
switch (op) {
.ping => try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "PONG"),
// Only reachable when `self.authStore == null` - a server with
// auth enabled answers `auth` requests in `handleAuth` above,
// before this switch is ever entered. A server with auth
// disabled has nothing to check credentials against, so it
// always accepts, matching every other opcode's "auth is
// simply not enforced" behavior on this server.
.auth => try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), ""),
// Only reachable when `self.authStore == null`, for the exact
// same reason as `.auth` above: a server without auth enabled
// has no notion of a session either, so it accepts any token
// unconditionally rather than ever consulting a session store.
.auth_token => try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), ""),
.get => {
const value = self.db.get(self.allocator, frame.payload) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
if (value) |v| {
defer self.allocator.free(v);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), v);
} else {
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.not_found), "");
}
},
.set => {
const args = protocol.decodeSet(frame.payload) catch {
return writeErr(writer, .bad_request, "malformed set payload");
};
self.db.put(args.key, args.value) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.del => {
self.db.delete(frame.payload) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.scan => try self.handleScan(frame.payload, writer),
.prefix => try self.handlePrefix(frame.payload, writer),
.stats => {
const s = self.db.stats();
const payload = protocol.encodeStats(self.allocator, s) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(payload);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), payload);
},
.flush => {
self.db.flush() catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.compact => {
self.db.compact() catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.batch => try self.handleBatch(frame.payload, writer),
.table_select => try self.handleTableSelect(frame.payload, writer),
.table_join => try self.handleTableJoin(frame.payload, writer),
.table_aggregate => try self.handleTableAggregate(frame.payload, writer),
.table_window => try self.handleTableWindow(frame.payload, writer),
// All three already handled and returned from above, before
// `self.mutex` was ever taken - never reached here.
.subscribe, .publish, .unsubscribe => unreachable,
.table_explain => try self.handleTableExplain(frame.payload, writer),
.table_update => try self.handleTableUpdate(frame.payload, writer),
.table_delete => try self.handleTableDelete(frame.payload, writer),
.table_export => try self.handleTableExport(frame.payload, writer),
.table_import => try self.handleTableImport(frame.payload, writer),
.user_admin => try self.handleUserAdmin(frame.payload, writer),
.metrics => try self.handleMetrics(writer),
.ttl_op => try self.handleTtlOp(frame.payload, writer),
.coll_ttl_op => try self.handleCollTtlOp(frame.payload, writer),
.coll_op => try self.handleCollOp(frame.payload, writer),
.txn_op => try self.handleTxnOp(frame.payload, writer),
}
}
/// Answers the `ttl_op` opcode: the network counterpart of the CLI's
/// local `expire`/`persist`/`ttl`/`expire-sweep` (main.zig), all backed
/// by the same `ttl.zig` functions the local commands call directly -
/// `query`/`sweep` were previously reachable only from a process that
/// had the `Db` open itself. `expire` mirrors the CLI's Redis-EXPIRE-
/// strict existence check (main.zig checks `db.get` before calling
/// `ttl.setExpireSeconds`; here the same check happens before the same
/// call) and answers `not_found` rather than `ok` when the key does not
/// currently exist, instead of the looser PEXPIREAT-like
/// `ttl.setExpireAt` that would silently accept it.
fn handleTtlOp(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTtlOp(payload) catch {
return writeErr(writer, .bad_request, "malformed ttl payload");
};
const now = std.time.nanoTimestamp();
switch (args.action) {
.expire => {
const existing = self.db.get(self.allocator, args.key) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
if (existing == null) return writeErr(writer, .not_found, "no such key");
self.allocator.free(existing.?);
ttlmod.setExpireSeconds(self.db, self.allocator, args.key, now, args.seconds) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.persist => {
ttlmod.clearExpire(self.db, self.allocator, args.key) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.query => {
const result = ttlmod.remaining(self.db, self.allocator, args.key, now) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const respPayload = protocol.encodeTtlResult(self.allocator, result) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(respPayload);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), respPayload);
},
.sweep => {
const reaped = ttlmod.sweepExpired(self.db, self.allocator, now) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
if (reaped > 0) self.ttlSweptTotal.incrBy(@intCast(reaped));
const respPayload = protocol.encodeTtlSweepCount(self.allocator, @intCast(reaped)) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(respPayload);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), respPayload);
},
}
}
/// Answers the `coll_ttl_op` opcode: the network counterpart of the
/// CLI's local `coll <kind> expire|ttl|persist <name>`/`coll
/// expire-sweep` (main.zig), all backed by the same `collttl.zig`
/// functions the local commands call directly - same shape as
/// `handleTtlOp` above, but `expire`'s existence check is
/// `collections.collectionExists` (a whole namespace) instead of
/// `db.get` (one key), matching the CLI's own `coll <kind> expire`
/// check.
fn handleCollTtlOp(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeCollTtlOp(payload) catch {
return writeErr(writer, .bad_request, "malformed coll ttl payload");
};
const now = std.time.nanoTimestamp();
switch (args.action) {
.expire => {
const exists = collmod.collectionExists(self.db, self.allocator, args.kind, args.name) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
if (!exists) return writeErr(writer, .not_found, "no such collection");
collttlmod.setExpireSeconds(self.db, self.allocator, args.kind, args.name, now, args.seconds) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.persist => {
collttlmod.clearExpire(self.db, self.allocator, args.kind, args.name) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.query => {
const result = collttlmod.remaining(self.db, self.allocator, args.kind, args.name, now) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const respPayload = protocol.encodeTtlResult(self.allocator, result) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(respPayload);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), respPayload);
},
.sweep => {
const reaped = collttlmod.sweepExpired(self.db, self.allocator, now) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
if (reaped > 0) self.collTtlSweptTotal.incrBy(@intCast(reaped));
const respPayload = protocol.encodeTtlSweepCount(self.allocator, @intCast(reaped)) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(respPayload);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), respPayload);
},
}
}
/// Answers the `coll_op` opcode: the network counterpart of the CLI's
/// local `coll <kind> <op> ...` (main.zig's `runColl`, backed by
/// `collections.zig`) - collections were previously reachable only from
/// a process holding the `Db` directly, the same gap `ttl_op`/
/// `coll_ttl_op` already closed for TTL management. Applies the same
/// lazy-expiry check `runColl` performs locally before every dispatch
/// (`collttl.expireIfDue` on the target name, plus `expireIfDueMany` on
/// the extra names of the three multi-set actions) so a collection
/// whose TTL is already due never appears alive over the wire either.
/// The actual per-action work lives in `dispatchCollOp` below; this
/// function only handles decode and the shared expiry check.
fn handleCollOp(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeCollOp(self.allocator, payload) catch {
return writeErr(writer, .bad_request, "malformed coll payload");
};
defer self.allocator.free(args.args);
const now = std.time.nanoTimestamp();
_ = collttlmod.expireIfDue(self.db, self.allocator, args.action.kind(), args.name, now) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
switch (args.action) {
.set_inter, .set_union, .set_diff => _ = collttlmod.expireIfDueMany(self.db, self.allocator, .set, args.args, now) catch |err| {
return writeErr(writer, .internal, @errorName(err));
},
else => {},
}
// Thirty actions each wrapping their own `db` call in a `catch |err|
// return writeErr(...)` (the pattern every other handler in this
// file uses) would triple the size of this function for no benefit
// over one shared boundary: `dispatchCollOp` uses plain `try` and
// two sentinel errors for malformed arguments, and this one catch
// maps both cases to the right status - the same information either
// way, just not repeated thirty times.
self.dispatchCollOp(args, writer) catch |err| return switch (err) {
error.InvalidArgCount, error.InvalidNumber => writeErr(writer, .bad_request, @errorName(err)),
else => writeErr(writer, .internal, @errorName(err)),
};
}
fn writeCollReply(self: *Server, writer: anytype, reply: protocol.CollReply) !void {
const respPayload = protocol.encodeCollReply(self.allocator, reply) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(respPayload);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), respPayload);
}
/// One arm per `protocol.CollAction`, each translating straight from
/// `args.args` (the CLI-text-form string arguments) into the matching
/// `collections.zig` call and back into a `protocol.CollReply` - the
/// exact same translation `main.zig`'s `runColl` already does for the
/// local CLI, just producing a wire reply instead of a printed line.
fn dispatchCollOp(self: *Server, args: protocol.CollOpArgs, writer: anytype) !void {
const a = args.args;
switch (args.action) {
.counter_get => {
const v = try collmod.Counter.get(self.db, self.allocator, args.name);
try self.writeCollReply(writer, .{ .int = v });
},
.counter_set => {
if (a.len < 1) return error.InvalidArgCount;
const v = std.fmt.parseInt(i64, a[0], 10) catch return error.InvalidNumber;
try collmod.Counter.set(self.db, self.allocator, args.name, v);
try self.writeCollReply(writer, .ok);
},
.counter_incrby => {
if (a.len < 1) return error.InvalidArgCount;
const delta = std.fmt.parseInt(i64, a[0], 10) catch return error.InvalidNumber;
const v = try collmod.Counter.incrBy(self.db, self.allocator, args.name, delta);
try self.writeCollReply(writer, .{ .int = v });
},
.hash_set => {
if (a.len < 2) return error.InvalidArgCount;
const created = try collmod.Hash.set(self.db, self.allocator, args.name, a[0], a[1]);
try self.writeCollReply(writer, .{ .int = if (created) 1 else 0 });
},
.hash_get => {
if (a.len < 1) return error.InvalidArgCount;
if (try collmod.Hash.get(self.db, self.allocator, args.name, a[0])) |v| {
defer self.allocator.free(v);
try self.writeCollReply(writer, .{ .bytes = v });
} else {
try self.writeCollReply(writer, .nil);
}
},
.hash_del => {
if (a.len < 1) return error.InvalidArgCount;
const existed = try collmod.Hash.del(self.db, self.allocator, args.name, a[0]);
try self.writeCollReply(writer, .{ .int = if (existed) 1 else 0 });
},
.hash_len => {
const n = try collmod.Hash.len(self.db, self.allocator, args.name);
try self.writeCollReply(writer, .{ .int = @intCast(n) });
},
.hash_getall => {
const fields = try collmod.Hash.getAll(self.db, self.allocator, args.name);
defer {
for (fields) |f| f.free(self.allocator);
self.allocator.free(fields);
}
const pairs = try self.allocator.alloc(protocol.CollPair, fields.len);
defer self.allocator.free(pairs);
for (fields, 0..) |f, i| pairs[i] = .{ .field = f.field, .value = f.value };
try self.writeCollReply(writer, .{ .pairList = pairs });
},
.set_add => {
if (a.len < 1) return error.InvalidArgCount;
const added = try collmod.Set.add(self.db, self.allocator, args.name, a[0]);
try self.writeCollReply(writer, .{ .int = if (added) 1 else 0 });
},
.set_rem => {
if (a.len < 1) return error.InvalidArgCount;
const removed = try collmod.Set.remove(self.db, self.allocator, args.name, a[0]);
try self.writeCollReply(writer, .{ .int = if (removed) 1 else 0 });
},
.set_ismember => {
if (a.len < 1) return error.InvalidArgCount;
const is = try collmod.Set.isMember(self.db, self.allocator, args.name, a[0]);
try self.writeCollReply(writer, .{ .int = if (is) 1 else 0 });
},
.set_card => {
const n = try collmod.Set.card(self.db, self.allocator, args.name);
try self.writeCollReply(writer, .{ .int = @intCast(n) });
},
.set_members => {
const ms = try collmod.Set.members(self.db, self.allocator, args.name);
defer {
for (ms) |m| self.allocator.free(m);
self.allocator.free(ms);
}
try self.writeCollReply(writer, .{ .stringList = ms });
},
.set_inter, .set_union => |action| {
const names = try self.allocator.alloc([]const u8, a.len + 1);
defer self.allocator.free(names);
names[0] = args.name;
@memcpy(names[1..], a);
const ms = if (action == .set_inter)
try collmod.Set.intersect(self.db, self.allocator, names)
else
try collmod.Set.unionOf(self.db, self.allocator, names);
defer {
for (ms) |m| self.allocator.free(m);
self.allocator.free(ms);
}
try self.writeCollReply(writer, .{ .stringList = ms });
},
.set_diff => {
const ms = try collmod.Set.difference(self.db, self.allocator, args.name, a);
defer {
for (ms) |m| self.allocator.free(m);
self.allocator.free(ms);
}
try self.writeCollReply(writer, .{ .stringList = ms });
},
.list_lpush => {
if (a.len < 1) return error.InvalidArgCount;
try collmod.List.pushLeft(self.db, self.allocator, args.name, a[0]);
try self.writeCollReply(writer, .ok);
},
.list_rpush => {
if (a.len < 1) return error.InvalidArgCount;
try collmod.List.pushRight(self.db, self.allocator, args.name, a[0]);
try self.writeCollReply(writer, .ok);
},
.list_lpop => {
if (try collmod.List.popLeft(self.db, self.allocator, args.name)) |v| {
defer self.allocator.free(v);
try self.writeCollReply(writer, .{ .bytes = v });
} else {
try self.writeCollReply(writer, .nil);
}
},
.list_rpop => {
if (try collmod.List.popRight(self.db, self.allocator, args.name)) |v| {
defer self.allocator.free(v);
try self.writeCollReply(writer, .{ .bytes = v });
} else {
try self.writeCollReply(writer, .nil);
}
},
.list_len => {
const n = try collmod.List.len(self.db, self.allocator, args.name);
try self.writeCollReply(writer, .{ .int = @intCast(n) });
},
.list_range => {
if (a.len < 2) return error.InvalidArgCount;
const rangeStart = std.fmt.parseInt(i64, a[0], 10) catch return error.InvalidNumber;
const rangeStop = std.fmt.parseInt(i64, a[1], 10) catch return error.InvalidNumber;
const vals = try collmod.List.range(self.db, self.allocator, args.name, rangeStart, rangeStop);
defer {
for (vals) |v| self.allocator.free(v);
self.allocator.free(vals);
}
try self.writeCollReply(writer, .{ .stringList = vals });
},
.zset_add => {
if (a.len < 2) return error.InvalidArgCount;
const score = std.fmt.parseFloat(f64, a[1]) catch return error.InvalidNumber;
try collmod.ZSet.add(self.db, self.allocator, args.name, a[0], score);
try self.writeCollReply(writer, .ok);
},
.zset_score => {
if (a.len < 1) return error.InvalidArgCount;
if (try collmod.ZSet.score(self.db, self.allocator, args.name, a[0])) |s| {
try self.writeCollReply(writer, .{ .real = s });
} else {
try self.writeCollReply(writer, .nil);
}
},
.zset_incrby => {
if (a.len < 2) return error.InvalidArgCount;
const delta = std.fmt.parseFloat(f64, a[1]) catch return error.InvalidNumber;
const s = try collmod.ZSet.incrBy(self.db, self.allocator, args.name, a[0], delta);
try self.writeCollReply(writer, .{ .real = s });
},
.zset_rem => {
if (a.len < 1) return error.InvalidArgCount;
const removed = try collmod.ZSet.remove(self.db, self.allocator, args.name, a[0]);
try self.writeCollReply(writer, .{ .int = if (removed) 1 else 0 });
},
.zset_card => {
const n = try collmod.ZSet.card(self.db, self.allocator, args.name);
try self.writeCollReply(writer, .{ .int = @intCast(n) });
},
.zset_rangebyrank, .zset_rangebyscore => |action| {
if (a.len < 2) return error.InvalidArgCount;
const zms = if (action == .zset_rangebyrank) blk: {
const rangeStart = std.fmt.parseInt(i64, a[0], 10) catch return error.InvalidNumber;
const rangeStop = std.fmt.parseInt(i64, a[1], 10) catch return error.InvalidNumber;
break :blk try collmod.ZSet.rangeByRank(self.db, self.allocator, args.name, rangeStart, rangeStop);
} else blk: {
const min = std.fmt.parseFloat(f64, a[0]) catch return error.InvalidNumber;
const max = std.fmt.parseFloat(f64, a[1]) catch return error.InvalidNumber;
break :blk try collmod.ZSet.rangeByScore(self.db, self.allocator, args.name, min, max);
};
defer {
for (zms) |zm| zm.free(self.allocator);
self.allocator.free(zms);
}
const pairs = try self.allocator.alloc(protocol.CollScorePair, zms.len);
defer self.allocator.free(pairs);
for (zms, 0..) |zm, i| pairs[i] = .{ .member = zm.member, .score = zm.score };
try self.writeCollReply(writer, .{ .scorePairList = pairs });
},
.zset_rank => {
if (a.len < 1) return error.InvalidArgCount;
if (try collmod.ZSet.rank(self.db, self.allocator, args.name, a[0])) |r| {
try self.writeCollReply(writer, .{ .int = @intCast(r) });
} else {
try self.writeCollReply(writer, .nil);
}
},
}
}
/// Answers the `metrics` opcode: the network counterpart of the CLI's
/// `stats --prom` (which only ever renders `Db.stats()` locally) plus
/// this server's own request/rate-limit/auth/session counters from
/// `registerMetrics` - until now those counters were only ever
/// observable in-process, by a test holding a direct `*Server`
/// pointer. The registry is built fresh per request and thrown away
/// immediately after rendering: there is nothing to keep alive between
/// calls, since every counter it reads lives on `self` regardless.
fn handleMetrics(self: *Server, writer: anytype) !void {
var reg = metrics.Registry.init(self.allocator);
defer reg.deinit();
self.registerMetrics(&reg) catch |err| return writeErr(writer, .internal, @errorName(err));
var buf = std.ArrayList(u8).init(self.allocator);
defer buf.deinit();
reg.exportText(self.allocator, buf.writer()) catch |err| return writeErr(writer, .internal, @errorName(err));
metrics.formatDbStats(buf.writer(), "cobalt", self.db.stats()) catch |err| return writeErr(writer, .internal, @errorName(err));
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), buf.items);
}
/// Verifies credentials against `store` and, on success, stamps the
/// resulting role into this connection's `connRole` slot so every
/// subsequent request on the same connection is dispatched under it.
/// Locks `self.mutex` only around the actual `Db` read `verify`
/// performs, same as every other handler here.
fn handleAuth(self: *Server, store: authmod.Store, payload: []const u8, writer: anytype, connRole: *?authmod.Role, connExpiresAtNanos: *?i128) !void {
const args = protocol.decodeAuth(payload) catch {
return writeErr(writer, .bad_request, "malformed auth payload");
};
self.mutex.lock();
const result = store.verify(self.allocator, args.username, args.password);
self.mutex.unlock();
const role = result catch |err| {
self.unauthorizedTotal.incr();
return switch (err) {
error.InvalidCredentials, error.Malformed => writeErr(writer, .unauthorized, "invalid credentials"),
else => writeErr(writer, .internal, @errorName(err)),
};
};
connRole.* = role;
connExpiresAtNanos.* = null;
if (self.sessions) |store_| {
const now = std.time.nanoTimestamp();
const token = store_.issue(role, self.sessionTtlNanos, now) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(token);
connExpiresAtNanos.* = now + self.sessionTtlNanos;
const responsePayload = protocol.encodeAuthOk(self.allocator, token) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(responsePayload);
return protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), responsePayload);
}
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
}
/// Authenticates a connection from a previously issued session token
/// instead of a username/password pair (see `enableSessions`). Only
/// reachable when `self.sessions` is set, since `dispatch` only routes
/// `auth_token` here while `self.authStore` is also set.
fn handleAuthToken(self: *Server, payload: []const u8, writer: anytype, connRole: *?authmod.Role, connExpiresAtNanos: *?i128) !void {
const store = self.sessions orelse {
self.unauthorizedTotal.incr();
return writeErr(writer, .unauthorized, "session tokens are not enabled on this server");
};
const token = protocol.decodeAuthToken(payload);
const session = store.validate(token, std.time.nanoTimestamp()) orelse {
self.unauthorizedTotal.incr();
return writeErr(writer, .unauthorized, "invalid or expired session token");
};
connRole.* = session.role;
connExpiresAtNanos.* = session.expiresAtNanos;
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
}
fn handleScan(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeScan(payload) catch {
return writeErr(writer, .bad_request, "malformed scan payload");
};
var entries = std.ArrayList(dbmod.Entry).init(self.allocator);
defer entries.deinit();
if (args.reverse) {
var it = self.db.scanReverse(self.allocator, .{ .start = args.start, .end = args.end }) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer it.deinit();
while (try it.next()) |e| {
try entries.append(e);
if (args.limit != 0 and entries.items.len >= args.limit) break;
}
} else {
var it = self.db.scanRange(self.allocator, .{ .start = args.start, .end = args.end }) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer it.deinit();
while (try it.next()) |e| {
try entries.append(e);
if (args.limit != 0 and entries.items.len >= args.limit) break;
}
}
const out = protocol.encodeEntries(self.allocator, entries.items) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
fn handlePrefix(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodePrefix(payload) catch {
return writeErr(writer, .bad_request, "malformed prefix payload");
};
var it = self.db.scanPrefix(self.allocator, args.prefix) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer it.deinit();
var entries = std.ArrayList(dbmod.Entry).init(self.allocator);
defer entries.deinit();
while (try it.next()) |e| {
try entries.append(e);
if (args.limit != 0 and entries.items.len >= args.limit) break;
}
const out = protocol.encodeEntries(self.allocator, entries.items) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
fn handleBatch(self: *Server, payload: []const u8, writer: anytype) !void {
const ops = protocol.decodeBatch(self.allocator, payload) catch {
return writeErr(writer, .bad_request, "malformed batch payload");
};
defer self.allocator.free(ops);
var wb = dbmod.WriteBatch.init(self.allocator);
defer wb.deinit();
for (ops) |op| {
switch (op) {
.put => |kv| try wb.put(kv.key, kv.value),
.del => |key| try wb.delete(key),
}
}
self.db.apply(&wb) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
}
/// Executes a `txn_op` request: the network counterpart of the CLI's
/// local `txn <script-file>` (main.zig's `runTxn`), applying every
/// put/delete in `entries` as one atomic `Transaction` commit across
/// however many tables they touch. Runs entirely inside `dispatch`'s
/// single `self.mutex` acquisition, like every other opcode - which
/// means, unlike a raw multi-thread `Transaction` against an unguarded
/// `*Db` (see `concurrent.zig`'s `GuardedDb` doc comment), no other
/// connection's request can ever interleave between this transaction's
/// begin and its commit. `txn.commit()`'s OCC conflict check stays in
/// place and still maps to `Status.conflict` below, but under this
/// server's locking discipline nothing can actually trigger it for a
/// request that arrives, stages, and commits within one dispatch call -
/// the same reason `concurrent.zig`'s `TableTransferWorker` test treats
/// a real `error.Conflict` under `GuardedDb.acquire()` as a bug, not an
/// expected outcome.
fn handleTxnOp(self: *Server, payload: []const u8, writer: anytype) !void {
const entries = protocol.decodeTxnOp(self.allocator, payload) catch {
return writeErr(writer, .bad_request, "malformed txn_op payload");
};
defer self.allocator.free(entries);
if (entries.len == 0) return writeErr(writer, .bad_request, "txn_op requires at least one operation");
var txn = txnmod.Transaction.begin(self.db);
defer txn.deinit();
var tables = std.StringHashMap(txntablemod.TableTxn).init(self.allocator);
defer {
var it = tables.valueIterator();
while (it.next()) |t| t.deinit();
tables.deinit();
}
for (entries) |e| {
const gop = tables.getOrPut(e.table) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
if (!gop.found_existing) {
gop.value_ptr.* = txntablemod.TableTxn.open(&txn, self.allocator, e.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
gop.key_ptr.* = e.table;
}
const table = gop.value_ptr;
const pk = rowmod.decodeValue(self.allocator, e.pkBytes) catch {
return writeErr(writer, .bad_request, "malformed primary key");
};
defer pk.free(self.allocator);
switch (e.verb) {
.put => {
var fieldsRow = rowmod.Row.decode(self.allocator, e.fieldsBytes) catch {
return writeErr(writer, .bad_request, "malformed fields");
};
defer fieldsRow.deinit();
table.put(pk, fieldsRow.fields.items) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
},
.del => {
table.delete(pk) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
},
}
}
txn.commit() catch |err| {
return switch (err) {
error.Conflict => writeErr(writer, .conflict, "transaction conflict"),
else => writeErr(writer, .internal, @errorName(err)),
};
};
const out = protocol.encodeTxnResult(self.allocator, @intCast(entries.len)) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
/// Executes a `table select <table> [expr]` remotely: opens the named
/// table over the same `*Db` the rest of the server serves (so it sees
/// exactly the indexes/rows a local CLI `table` command would), parses
/// the expression text with the same `expr.zig` grammar the CLI uses
/// (empty text means "every row", matching `Table.select(null)`), and
/// wire-encodes the resulting rows. `args.orderBy`, when present, is
/// forwarded to `Table.selectPageOrdered` so the offset/limit window
/// applies AFTER sorting, exactly like the local `table select
/// --order-by` CLI path - `null` falls through to the plain
/// `selectPage` early-stop behavior instead. A bad table name or
/// malformed expression is a client mistake (`bad_request`), not a
/// server fault.
fn handleTableSelect(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableSelect(self.allocator, payload) catch {
return writeErr(writer, .bad_request, "malformed table_select payload");
};
defer self.allocator.free(args.orderBy);
var table = querymod.Table.open(self.db, self.allocator, args.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer table.deinit();
var parsed: ?exprmod.Parsed = null;
defer if (parsed) |*p| p.deinit();
if (args.exprText.len > 0) {
parsed = exprmod.parse(self.allocator, args.exprText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const pred: ?querymod.Predicate = if (parsed) |p| p.predicate else null;
const rows = table.selectPageOrdered(pred, .{ .offset = args.offset, .limit = args.limit }, args.orderBy) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer querymod.freeRows(self.allocator, rows);
const out = protocol.encodeRows(self.allocator, rows) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
/// Executes a `table_join` request against `join.zig`'s four equi-join
/// variants, chosen by `args.mode` - the network counterpart of the
/// CLI's local `table join [--left|--right|--full]`. Each variant
/// produces its own result type (`JoinedRow`/`OuterJoinedRow`/
/// `RightOuterJoinedRow`/`FullOuterJoinedRow`), but every one of them is
/// just two optional `Row`s under the hood, so this flattens whichever
/// one came back into `protocol.JoinPair` before encoding - one wire
/// format for all four modes, exactly like `coll_op` bundles thirty
/// collection actions behind one opcode instead of thirty. Ownership of
/// each row is moved (by value) from the mode-specific slice into
/// `pairs`, then that mode-specific slice is freed as a raw allocation
/// (not through `join.freeJoined`/`freeOuterJoined`/etc, which would
/// double-deinit rows already moved into `pairs`) - `pairs`'s own defer
/// is what actually frees the row contents, once encoding is done.
/// `args.offset`/`args.limit` window the mode-specific slice through
/// `join.applyPage` right after it comes back, before the flatten -
/// `applyPage` already frees the pre-page slice and hands back a fresh
/// one, so the pattern below frees `paged`, never `joined` directly.
fn handleTableJoin(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableJoin(payload) catch {
return writeErr(writer, .bad_request, "malformed table_join payload");
};
var left = querymod.Table.open(self.db, self.allocator, args.leftTable) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer left.deinit();
var right = querymod.Table.open(self.db, self.allocator, args.rightTable) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer right.deinit();
var leftParsed: ?exprmod.Parsed = null;
defer if (leftParsed) |*p| p.deinit();
if (args.leftExprText.len > 0) {
leftParsed = exprmod.parse(self.allocator, args.leftExprText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const leftPred: ?querymod.Predicate = if (leftParsed) |p| p.predicate else null;
var rightParsed: ?exprmod.Parsed = null;
defer if (rightParsed) |*p| p.deinit();
if (args.rightExprText.len > 0) {
rightParsed = exprmod.parse(self.allocator, args.rightExprText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const rightPred: ?querymod.Predicate = if (rightParsed) |p| p.predicate else null;
var pairs = std.ArrayList(protocol.JoinPair).init(self.allocator);
defer {
for (pairs.items) |*p| {
if (p.left) |*l| l.deinit();
if (p.right) |*r| r.deinit();
}
pairs.deinit();
}
const page = querymod.Page{ .offset = args.offset, .limit = args.limit };
switch (args.mode) {
.inner => {
const joined = joinmod.innerJoin(self.allocator, &left, args.leftField, leftPred, &right, args.rightField, rightPred) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const paged = joinmod.applyPage(joinmod.JoinedRow, self.allocator, joined, page) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(paged);
for (paged) |p| try pairs.append(.{ .left = p.left, .right = p.right });
},
.left => {
const joined = joinmod.leftOuterJoin(self.allocator, &left, args.leftField, leftPred, &right, args.rightField, rightPred) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const paged = joinmod.applyPage(joinmod.OuterJoinedRow, self.allocator, joined, page) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(paged);
for (paged) |p| try pairs.append(.{ .left = p.left, .right = p.right });
},
.right => {
const joined = joinmod.rightOuterJoin(self.allocator, &left, args.leftField, leftPred, &right, args.rightField, rightPred) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const paged = joinmod.applyPage(joinmod.RightOuterJoinedRow, self.allocator, joined, page) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(paged);
for (paged) |p| try pairs.append(.{ .left = p.left, .right = p.right });
},
.full => {
const joined = joinmod.fullOuterJoin(self.allocator, &left, args.leftField, leftPred, &right, args.rightField, rightPred) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const paged = joinmod.applyPage(joinmod.FullOuterJoinedRow, self.allocator, joined, page) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(paged);
for (paged) |p| try pairs.append(.{ .left = p.left, .right = p.right });
},
}
const out = protocol.encodeJoinedRows(self.allocator, pairs.items) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
/// Executes a `table_aggregate` request: opens the named table over the
/// same `*Db` the rest of the server serves, filters rows with the same
/// `expr.zig` grammar `table_select`/`table_join` already use (empty
/// text selects every row), then hands the filtered rows to
/// `aggregate.zig` - `runGlobal` when `args.groupField` is empty (the
/// network counterpart of the CLI's `*` sentinel for "no GROUP BY"),
/// `run` otherwise. When `args.havingText` is non-empty, it is parsed
/// with the same `expr.zig` grammar and applied AFTER aggregation but
/// BEFORE paging - `globalMatchesHaving` on the `runGlobal` path (an
/// excluded single result comes back as zero values, not an error) or
/// `filterHaving` on the grouped path - then, on the grouped path only,
/// a non-empty `args.orderBy` sorts the HAVING-narrowed groups via
/// `aggregate.sortGroupsBy` (an unknown order field is `bad_request`,
/// same treatment as a malformed HAVING expr) - then windowed by
/// `args.offset`/`args.limit` through `aggregate.applyPageGroups` (0/0 =
/// unlimited, ignored for the `runGlobal` path since that always
/// produces at most one result row, same as `args.orderBy`). Two distinct wire shapes come back
/// (`encodeAggGlobalResult` vs `encodeAggGroupRows`) because the client
/// already knows which one it asked for (it is the one that chose
/// `groupField`), so no extra discriminator byte is needed on the wire -
/// the client just decodes with whichever matches its own request, the
/// same reasoning `Client.tableAggregateGlobal`/`tableAggregateGroups`
/// document. A bad table name, malformed expr, or a spec referencing a
/// non-numeric field (`error.NonNumericField`/`error.Overflow` from
/// `aggregate.zig`) is a client mistake (`bad_request`), not a server
/// fault.
fn handleTableAggregate(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableAggregate(self.allocator, payload) catch {
return writeErr(writer, .bad_request, "malformed table_aggregate payload");
};
defer self.allocator.free(args.specs);
defer self.allocator.free(args.orderBy);
var table = querymod.Table.open(self.db, self.allocator, args.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer table.deinit();
var parsed: ?exprmod.Parsed = null;
defer if (parsed) |*p| p.deinit();
if (args.exprText.len > 0) {
parsed = exprmod.parse(self.allocator, args.exprText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const pred: ?querymod.Predicate = if (parsed) |p| p.predicate else null;
var havingParsed: ?exprmod.Parsed = null;
defer if (havingParsed) |*p| p.deinit();
if (args.havingText.len > 0) {
havingParsed = exprmod.parse(self.allocator, args.havingText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const havingPred: ?querymod.Predicate = if (havingParsed) |p| p.predicate else null;
const rows = table.select(pred) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer querymod.freeRows(self.allocator, rows);
if (args.groupField.len == 0) {
const values = aggregatemod.runGlobal(self.allocator, rows, args.specs) catch |err| {
return switch (err) {
error.NonNumericField, error.Overflow => writeErr(writer, .bad_request, @errorName(err)),
else => writeErr(writer, .internal, @errorName(err)),
};
};
defer aggregatemod.freeGlobalResult(self.allocator, values);
const matches = if (havingPred) |hp|
aggregatemod.globalMatchesHaving(self.allocator, args.specs, values, hp) catch |err| {
return writeErr(writer, .internal, @errorName(err));
}
else
true;
const out = protocol.encodeAggGlobalResult(self.allocator, if (matches) values else &.{}) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
} else {
const unpaged = aggregatemod.run(self.allocator, rows, args.groupField, args.specs) catch |err| {
return switch (err) {
error.NonNumericField, error.Overflow => writeErr(writer, .bad_request, @errorName(err)),
else => writeErr(writer, .internal, @errorName(err)),
};
};
const filtered = if (havingPred) |hp|
aggregatemod.filterHaving(self.allocator, unpaged, args.groupField, args.specs, hp) catch |err| {
return writeErr(writer, .internal, @errorName(err));
}
else
unpaged;
if (args.orderBy.len > 0) {
aggregatemod.sortGroupsBy(self.allocator, filtered, args.groupField, args.specs, args.orderBy) catch |err| {
protocol.freeAggGroupRows(self.allocator, filtered);
return switch (err) {
error.UnknownOrderField => writeErr(writer, .bad_request, @errorName(err)),
else => writeErr(writer, .internal, @errorName(err)),
};
};
}
const page = querymod.Page{ .offset = args.offset, .limit = args.limit };
const groups = aggregatemod.applyPageGroups(self.allocator, filtered, page) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer protocol.freeAggGroupRows(self.allocator, groups);
const out = protocol.encodeAggGroupRows(self.allocator, groups) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
}
/// Executes a `table_window` request: opens the named table, filters
/// rows with the same `expr.zig` grammar every other `table_*` opcode
/// uses (empty text selects every row), then hands the filtered rows to
/// `window.compute` for the single `args.spec` annotation, along with
/// `args.frame` (ROWS or RANGE - see `window.zig`'s `FrameMode` doc
/// comment) - unlike `table_aggregate`, this NEVER collapses rows, so
/// `results[i]` stays aligned 1:1 with `rows[i]` regardless of paging (a
/// `NonNumericField`/`Overflow`/`MissingField`/`RangeNotApplicable` from
/// `window.zig` is a client mistake, `bad_request`, same treatment as
/// `table_aggregate`).
/// `args.offset`/`args.limit` then window which rows get PRINTED, walked
/// in `window.orderedIndices`' partition-then-order sequence (the same
/// display order local `table window` uses, not raw scan order) rather
/// than slicing `rows`/`results` directly - the exact bug local `table
/// window` hit and fixed (see `window.zig`'s `orderedIndices` doc
/// comment) before this network path existed, so it is built paginated
/// the correct way from the start instead of repeating that mistake.
/// Each surviving row is paired with its already-computed value into a
/// `protocol.WindowRow` and sent back via `encodeWindowRows` - the
/// client prints them in the order received, no further sorting needed.
/// A `subscribe` request never returns in the normal sense: once the
/// connection is registered in `self.pubsub`, this function owns the
/// rest of the connection's lifetime (both directions of the socket)
/// until the client disconnects, then propagates `error.SubscriptionEnded`
/// so `handleConnection`'s `catch return` unwinds cleanly - the same
/// "any error just ends the connection" contract every other opcode
/// already relies on for a plain disconnect mid-frame.
///
/// Design: a `subscribe` connection becomes receive-only after the ack.
/// A dedicated watcher thread does exactly one blocking read attempt on
/// `stream` - whatever it gets back (a byte the client sent, or an
/// error/EOF from a disconnect) closes the subscription, since there is
/// no way to both keep reading further commands AND push messages on
/// one TCP connection without either a second concurrent writer (a
/// write race against the push loop below) or re-entering this same
/// read/dispatch loop (a read race against the watcher). Unlike a
/// protocol like Redis's, which multiplexes further commands
/// (UNSUBSCRIBE, PING) onto the same subscribed connection, this
/// connection itself never reads another request - but `unsubscribe`
/// (see `handleUnsubscribe`) reaches the SAME `sub` from a completely
/// different connection, keyed by the id this function's ack carries
/// (`sub.id`), so dropping a subset of channels still does not require
/// closing the connection outright; only dropping every remaining
/// channel does (`pubsub.Hub.unsubscribe`'s doc comment). Once the push loop ends (for any reason - the
/// watcher closed `sub`, or a write here failed because the peer is
/// gone), `std.posix.shutdown` unblocks the watcher's still-pending read
/// (rather than relying on `conn.stream.close()` racing a live read on
/// the same fd from another thread, which is a well-known way to
/// recycle a file descriptor into an unrelated, concurrently-accepted
/// connection) before joining it - so by the time this returns, no
/// other thread still touches `stream` or `sub`.
fn handleSubscribe(self: *Server, payload: []const u8, stream: std.net.Stream) !void {
const args = protocol.decodeSubscribe(self.allocator, payload) catch {
return writeErr(stream.writer(), .bad_request, "malformed subscribe payload");
};
defer args.deinit(self.allocator);
if (args.channels.len == 0) {
return writeErr(stream.writer(), .bad_request, "subscribe needs at least one channel");
}
const channelNames = try self.allocator.alloc([]const u8, args.channels.len);
defer self.allocator.free(channelNames);
for (args.channels, 0..) |c, i| channelNames[i] = c;
const sub = self.pubsub.subscribe(channelNames, pubsubmod.defaultCapacity) catch {
return writeErr(stream.writer(), .internal, "failed to register subscription");
};
const ack = try protocol.encodeSubscribeAck(self.allocator, sub.id, @intCast(channelNames.len));
defer self.allocator.free(ack);
protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Status.ok), ack) catch {
self.pubsub.closeSubscriber(sub);
self.pubsub.destroySubscriber(sub);
return error.SubscriptionEnded;
};
const watcher = try std.Thread.spawn(.{}, watchForDisconnect, .{ &self.pubsub, sub, stream });
while (sub.waitMessage()) |msg| {
defer msg.deinit(self.allocator);
const framePayload = protocol.encodePubSubMessage(self.allocator, msg.channel, msg.payload) catch break;
defer self.allocator.free(framePayload);
protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Status.ok), framePayload) catch break;
}
std.posix.shutdown(stream.handle, .both) catch {};
watcher.join();
self.pubsub.closeSubscriber(sub);
self.pubsub.destroySubscriber(sub);
return error.SubscriptionEnded;
}
/// The watcher half of `handleSubscribe`: blocks on exactly one read
/// attempt, then closes `sub` regardless of the outcome (data, EOF, or
/// an error - all three mean "stop pushing to this connection"). Runs
/// on its own thread so `handleSubscribe`'s push loop can block
/// independently on `sub.waitMessage()` - see that function's doc
/// comment for why this is a single read, not a loop.
fn watchForDisconnect(hub: *pubsubmod.Hub, sub: *pubsubmod.Subscriber, stream: std.net.Stream) void {
var buf: [1]u8 = undefined;
_ = stream.read(&buf) catch {};
hub.closeSubscriber(sub);
}
/// Executes a `publish` request: any connection may call this
/// regardless of whether it has ever subscribed to anything (see
/// `protocol.encodePublish`'s doc comment). Delivery is fire-and-forget
/// from this handler's point of view - `pubsub.Hub.publish` never
/// blocks on a slow subscriber, so this returns as soon as every
/// currently-registered subscriber's mailbox has been handed its own
/// copy (or dropped one, under `Subscriber.push`'s backpressure policy).
fn handlePublish(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodePublish(payload) catch {
return writeErr(writer, .bad_request, "malformed publish payload");
};
const count = self.pubsub.publish(self.allocator, args.channel, args.message) catch {
return writeErr(writer, .internal, "failed to publish message");
};
if (count > 0) self.messagesPublishedTotal.incrBy(@intCast(count));
const resp = try protocol.encodePublishResult(self.allocator, @intCast(count));
defer self.allocator.free(resp);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), resp);
}
/// Executes an `unsubscribe` request: unlike `subscribe`, this is a
/// plain request/response opcode that returns like any other - it never
/// touches the connection that originally subscribed, because that
/// connection became receive-only the moment its ack arrived (see
/// `handleSubscribe`'s doc comment) and this one is always a separate
/// connection entirely (see `protocol.encodeUnsubscribe`'s doc
/// comment). `pubsub.Hub.unsubscribe`'s `error.UnknownSubscriber` (a
/// stale id, a typo, or a subscriber that already disconnected on its
/// own - all indistinguishable from here, see that function's doc
/// comment) is the caller's mistake, `bad_request`, the same treatment
/// `table_window` gives a `MissingField` from a bad field name.
fn handleUnsubscribe(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeUnsubscribe(self.allocator, payload) catch {
return writeErr(writer, .bad_request, "malformed unsubscribe payload");
};
defer args.deinit(self.allocator);
if (args.channels.len == 0) {
return writeErr(writer, .bad_request, "unsubscribe needs at least one channel");
}
const channelNames = try self.allocator.alloc([]const u8, args.channels.len);
defer self.allocator.free(channelNames);
for (args.channels, 0..) |c, i| channelNames[i] = c;
const removed = self.pubsub.unsubscribe(args.subscriberId, channelNames) catch |err| {
return switch (err) {
error.UnknownSubscriber => writeErr(writer, .bad_request, "unknown subscriber id"),
else => writeErr(writer, .internal, @errorName(err)),
};
};
const resp = try protocol.encodeUnsubscribeResult(self.allocator, @intCast(removed));
defer self.allocator.free(resp);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), resp);
}
fn handleTableWindow(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableWindow(self.allocator, payload) catch {
return writeErr(writer, .bad_request, "malformed table_window payload");
};
defer self.allocator.free(args.specs);
defer self.allocator.free(args.partitionBy);
defer self.allocator.free(args.orderBy);
var table = querymod.Table.open(self.db, self.allocator, args.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer table.deinit();
var parsed: ?exprmod.Parsed = null;
defer if (parsed) |*p| p.deinit();
if (args.exprText.len > 0) {
parsed = exprmod.parse(self.allocator, args.exprText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const pred: ?querymod.Predicate = if (parsed) |p| p.predicate else null;
const rows = table.select(pred) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer querymod.freeRows(self.allocator, rows);
const results = windowmod.computeMany(self.allocator, rows, args.partitionBy, args.orderBy, args.specs, args.frame) catch |err| {
return switch (err) {
error.NonNumericField, error.Overflow, error.MissingField, error.RangeNotApplicable => writeErr(writer, .bad_request, @errorName(err)),
else => writeErr(writer, .internal, @errorName(err)),
};
};
defer windowmod.freeManyResults(self.allocator, results);
const displayOrder = windowmod.orderedIndices(self.allocator, rows, args.partitionBy, args.orderBy) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(displayOrder);
const total = displayOrder.len;
const pageStart = @min(args.offset, total);
const end = if (args.limit == 0) total else @min(pageStart + args.limit, total);
// `out`'s WindowRow.values arrays hold SHALLOW copies borrowed from
// `results` (safe: `encodeWindowRows` below only reads them to
// serialize, never frees them) - `results` stays the true owner,
// freed by the `defer` above; only the borrowing arrays themselves
// get freed here, same "shallow copy into a throwaway wire-shape
// struct, one true owner frees the contents" pattern
// `handleTableAggregate`'s `GroupRow`-to-wire path already relies on.
var out = std.ArrayList(protocol.WindowRow).init(self.allocator);
defer {
for (out.items) |wr| self.allocator.free(wr.values);
out.deinit();
}
var i: usize = pageStart;
while (i < end) : (i += 1) {
const idx = displayOrder[i];
const values = try self.allocator.alloc(rowmod.Value, args.specs.len);
@memcpy(values, results[idx]);
try out.append(.{ .row = rows[idx], .values = values });
}
const encoded = protocol.encodeWindowRows(self.allocator, out.items) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(encoded);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), encoded);
}
/// Executes a `table_explain` request: opens the named table over the
/// same `*Db` the rest of the server serves, parses the expr text with
/// the same grammar `table_select`/`table_join`/`table_aggregate`
/// already use, then reports which strategy `querymod.Table.explain`
/// says a matching `table select`/`table_select` would take - without
/// running the query or touching any row. A bad table name or malformed
/// expression is a client mistake (`bad_request`), not a server fault.
fn handleTableExplain(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableExplain(payload) catch {
return writeErr(writer, .bad_request, "malformed table_explain payload");
};
var table = querymod.Table.open(self.db, self.allocator, args.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer table.deinit();
var parsed: ?exprmod.Parsed = null;
defer if (parsed) |*p| p.deinit();
if (args.exprText.len > 0) {
parsed = exprmod.parse(self.allocator, args.exprText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const pred: ?querymod.Predicate = if (parsed) |p| p.predicate else null;
var plan = table.explain(self.allocator, pred) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer plan.deinit(self.allocator);
const out = protocol.encodePlan(self.allocator, plan) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
/// Executes a `table_update` request: the network counterpart of the
/// CLI's local `table update <table> --set ... [expr...]`, running
/// `query.Table.updateWhere` against the server's `*Db`. The wire
/// `sets` field list arrives pre-encoded with `Row.encode` (the same
/// trick `decodeTxnOp`'s `fieldsBytes` uses), so it is decoded into a
/// real `Row` here before `updateWhere` ever sees it. The response is a
/// bare row count reusing `encodeTtlSweepCount`'s u64 wire shape - the
/// same "no new response type, an existing shape already fits"
/// precedent `coll_ttl_op` set for `ttl_op`'s result encoding.
fn handleTableUpdate(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableUpdate(payload) catch {
return writeErr(writer, .bad_request, "malformed table_update payload");
};
var setsRow = rowmod.Row.decode(self.allocator, args.setsBytes) catch {
return writeErr(writer, .bad_request, "malformed table_update set fields");
};
defer setsRow.deinit();
var table = querymod.Table.open(self.db, self.allocator, args.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer table.deinit();
var parsed: ?exprmod.Parsed = null;
defer if (parsed) |*p| p.deinit();
if (args.exprText.len > 0) {
parsed = exprmod.parse(self.allocator, args.exprText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const pred: ?querymod.Predicate = if (parsed) |p| p.predicate else null;
const updated = table.updateWhere(pred, setsRow.fields.items) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const out = protocol.encodeTtlSweepCount(self.allocator, @intCast(updated)) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
/// Executes a `table_delete` request: the network counterpart of the
/// CLI's local `table delete <table> [expr...]`, running
/// `query.Table.deleteWhere` against the server's `*Db`. Same wire shape
/// as `table_explain` (table + exprText, no `SET` clause) and same
/// response shape as `table_update` (a bare row count reusing
/// `encodeTtlSweepCount`'s u64 wire shape).
fn handleTableDelete(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableDelete(payload) catch {
return writeErr(writer, .bad_request, "malformed table_delete payload");
};
var table = querymod.Table.open(self.db, self.allocator, args.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer table.deinit();
var parsed: ?exprmod.Parsed = null;
defer if (parsed) |*p| p.deinit();
if (args.exprText.len > 0) {
parsed = exprmod.parse(self.allocator, args.exprText) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
}
const pred: ?querymod.Predicate = if (parsed) |p| p.predicate else null;
const deleted = table.deleteWhere(pred) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const out = protocol.encodeTtlSweepCount(self.allocator, @intCast(deleted)) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
/// Executes a `table_export` request: the network counterpart of the
/// CLI's local `table export <table> csv|json <path>`, rendering the
/// whole table through `dump.zig`'s `exportCsvWriter`/`exportJsonWriter`
/// (the same functions the local path calls, just pointed at an
/// in-memory `ArrayList` writer instead of a file, since there is no
/// filesystem path to write to on the server side of a request) and
/// sending the row count plus the rendered bytes back in one frame.
fn handleTableExport(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableExport(payload) catch {
return writeErr(writer, .bad_request, "malformed table_export payload");
};
var table = querymod.Table.open(self.db, self.allocator, args.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer table.deinit();
var buf = std.ArrayList(u8).init(self.allocator);
defer buf.deinit();
const rowCount = switch (args.format) {
.csv => dumpmod.exportCsvWriter(self.allocator, &table, buf.writer()),
.json => dumpmod.exportJsonWriter(self.allocator, &table, buf.writer()),
} catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const out = protocol.encodeExportResult(self.allocator, @intCast(rowCount), buf.items) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
/// Executes a `table_import` request: the network counterpart of the
/// CLI's local `table import <table> csv|json <path>`, feeding the
/// wire-carried `content` bytes straight into `dump.zig`'s
/// `importCsvContent`/`importJsonContent` (the same functions the local
/// path calls after reading a file into memory - the client already did
/// that reading, so the server only ever sees bytes, never a path)
/// against the server's `*Db`. The response is a bare row count, the
/// same u64 wire shape `table_update`/`table_delete` already use.
fn handleTableImport(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeTableImport(payload) catch {
return writeErr(writer, .bad_request, "malformed table_import payload");
};
var table = querymod.Table.open(self.db, self.allocator, args.table) catch |err| {
return writeErr(writer, .bad_request, @errorName(err));
};
defer table.deinit();
const imported = switch (args.format) {
.csv => dumpmod.importCsvContent(self.allocator, &table, args.content),
.json => dumpmod.importJsonContent(self.allocator, &table, args.content),
} catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
const out = protocol.encodeTtlSweepCount(self.allocator, @intCast(imported)) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
}
/// Executes a `user_admin` request (create/set_password/set_role/delete/
/// list) against a fresh `authmod.Store` over the same `*Db` the rest of
/// the server serves. Deliberately does NOT consult `self.authStore` -
/// that field only controls whether *login enforcement* is turned on
/// for this server, not whether the user table exists, so remote user
/// management works the same way whether or not `--auth` is active.
/// `requiredRole` already gates this opcode behind `admin` whenever
/// auth enforcement IS enabled; a server with auth disabled enforces
/// nothing on any opcode, this one included, matching every other op's
/// documented behavior.
fn handleUserAdmin(self: *Server, payload: []const u8, writer: anytype) !void {
const args = protocol.decodeUserAdmin(payload) catch {
return writeErr(writer, .bad_request, "malformed user_admin payload");
};
const store = authmod.Store.init(self.db);
switch (args.action) {
.create => {
const role = authmod.Role.fromByte(args.roleByte) catch {
return writeErr(writer, .bad_request, "invalid role");
};
const rounds = if (args.rounds != 0) args.rounds else authmod.recommendedRounds;
store.createUser(self.allocator, args.username, args.password, role, rounds) catch |err| {
return switch (err) {
error.UserExists => writeErr(writer, .conflict, "user already exists"),
else => writeErr(writer, .internal, @errorName(err)),
};
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.set_password => {
const rounds = if (args.rounds != 0) args.rounds else authmod.recommendedRounds;
store.setPassword(self.allocator, args.username, args.password, rounds) catch |err| {
return switch (err) {
error.UserNotFound => writeErr(writer, .not_found, "user not found"),
else => writeErr(writer, .internal, @errorName(err)),
};
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.set_role => {
const role = authmod.Role.fromByte(args.roleByte) catch {
return writeErr(writer, .bad_request, "invalid role");
};
store.setRole(self.allocator, args.username, role) catch |err| {
return switch (err) {
error.UserNotFound => writeErr(writer, .not_found, "user not found"),
else => writeErr(writer, .internal, @errorName(err)),
};
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.delete => {
store.deleteUser(self.allocator, args.username) catch |err| {
return switch (err) {
error.UserNotFound => writeErr(writer, .not_found, "user not found"),
else => writeErr(writer, .internal, @errorName(err)),
};
};
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), "");
},
.list => {
const users = store.listUsers(self.allocator) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer authmod.freeUserList(self.allocator, users);
const out = protocol.encodeUserList(self.allocator, users) catch |err| {
return writeErr(writer, .internal, @errorName(err));
};
defer self.allocator.free(out);
try protocol.writeFrame(writer, @intFromEnum(protocol.Status.ok), out);
},
}
}
/// Stops accepting new connections and waits for the accept loop to
/// exit. Closing the listening socket while another thread is
/// blocked inside `accept()` on that same fd is a race the standard
/// library treats as a bug (it asserts on EBADF), so instead we flip
/// `stopping` and connect a throwaway client to our own listen
/// address: that wakes the pending `accept()` with a real, briefly
/// alive connection, which the loop then discards and exits on. Only
/// once the accept thread has rejoined is it safe to close the
/// listening socket itself.
///
/// Already-accepted connections are `.detach()`ed worker threads this
/// struct holds no `std.Thread` handle for, so they cannot be `join`ed
/// directly - `drainConnections` below waits on the `active` counter
/// instead. This used to be entirely the caller's problem ("leave them
/// to finish on their own, call `activeConnections()` first if you need
/// to wait"), but every real caller in this codebase - every test in
/// this file included - called `stop()` and then immediately tore down
/// the `Db`/allocator the detached worker's `dispatch()` still reads
/// from, with nothing enforcing that the worker had actually noticed
/// its connection closed and returned first. Most of the time the
/// worker's blocking read already sees EOF and exits before the caller
/// gets around to teardown, so the race stayed invisible for a long
/// time; occasionally it does not, and a worker thread touches a freed
/// `Db`/`Allocator.VTable`, corrupting the heap in a way that only
/// surfaces later as an unrelated test's leak/double-free abort - this
/// is exactly the flaky `zig build test` failure the gate re-run in
/// this session caught (roughly 1 run in 4-6, always attributed to
/// whichever unrelated test happened to run next). Waiting here by
/// default, bounded so a genuinely long-lived idle connection (still
/// blocked in `stream.reader()` with nothing sent and no EOF) cannot
/// hang shutdown forever, closes that gap for every caller instead of
/// relying on each one to remember `activeConnections()`.
pub fn stop(self: *Server) void {
if (self.acceptThread == null) return;
self.stopping.store(true, .release);
const listenAddr = self.net.listen_address;
if (std.net.tcpConnectToAddress(listenAddr)) |s| {
s.close();
} else |_| {}
self.acceptThread.?.join();
self.acceptThread = null;
if (self.sweeperThread) |t| {
t.join();
self.sweeperThread = null;
}
if (self.ttlSweeperThread) |t| {
t.join();
self.ttlSweeperThread = null;
}
if (self.collTtlSweeperThread) |t| {
t.join();
self.collTtlSweeperThread = null;
}
self.drainConnections(500 * std.time.ns_per_ms);
self.net.deinit();
// Best-effort, same as `drainConnections` above: a subscriber
// connection that never disconnects (client vanished without
// closing its socket) is still `active` past the drain timeout, so
// its `Subscriber` is never `destroySubscriber`'d and leaks for the
// remaining life of the process - this `deinit` only frees `Hub`'s
// own channel map, which by then holds no dangling entries for any
// subscriber that WAS properly torn down.
self.pubsub.deinit();
}
/// Polls `activeConnections()` until it reaches zero or `timeoutNanos`
/// elapses, whichever comes first. See the doc comment on `stop()` for
/// why this exists and why it is bounded rather than unconditional.
fn drainConnections(self: *Server, timeoutNanos: u64) void {
const startedAt = std.time.nanoTimestamp();
while (self.activeConnections() > 0) {
if (std.time.nanoTimestamp() - startedAt >= timeoutNanos) return;
std.time.sleep(1 * std.time.ns_per_ms);
}
}
};
fn testDb(allocator: Allocator, dir: []const u8) !Db {
return Db.open(allocator, dir, .{});
}
// `drainConnections` only ever touches `self.active`/`self.stopping`, so it
// can be exercised on a `Server` value that was never `init()`ed with a
// real `Db`/socket - every other field stays `undefined` and is never
// read. This isolates the drain/timeout contract itself from the (much
// harder to force deterministically) real-socket race it exists to close.
fn undefinedServerForDrainTest() Server {
var srv: Server = undefined;
srv.active = std.atomic.Value(usize).init(0);
srv.stopping = std.atomic.Value(bool).init(false);
return srv;
}
test "drainConnections returns immediately when there is nothing to drain" {
var srv = undefinedServerForDrainTest();
const start = std.time.nanoTimestamp();
srv.drainConnections(2 * std.time.ns_per_s);
const elapsedNanos = std.time.nanoTimestamp() - start;
try std.testing.expect(elapsedNanos < 200 * std.time.ns_per_ms);
}
test "drainConnections waits for a connection that finishes before the timeout" {
var srv = undefinedServerForDrainTest();
srv.active.store(1, .release);
const Ctx = struct {
srv: *Server,
fn run(ctx: *@This()) void {
std.time.sleep(30 * std.time.ns_per_ms);
_ = ctx.srv.active.fetchSub(1, .acq_rel);
}
};
var ctx = Ctx{ .srv = &srv };
const t = try std.Thread.spawn(.{}, Ctx.run, .{&ctx});
defer t.join();
const start = std.time.nanoTimestamp();
srv.drainConnections(2 * std.time.ns_per_s);
const elapsedNanos = std.time.nanoTimestamp() - start;
try std.testing.expectEqual(@as(usize, 0), srv.activeConnections());
// Returned once the worker actually finished, not after burning the
// full 2-second timeout - proves this is a real wait-for-drain, not a
// fixed sleep in disguise.
try std.testing.expect(elapsedNanos < 1 * std.time.ns_per_s);
}
test "drainConnections gives up after the timeout instead of hanging forever" {
var srv = undefinedServerForDrainTest();
srv.active.store(1, .release); // never decremented by anyone
const start = std.time.nanoTimestamp();
srv.drainConnections(20 * std.time.ns_per_ms);
const elapsedNanos = std.time.nanoTimestamp() - start;
try std.testing.expect(elapsedNanos >= 20 * std.time.ns_per_ms);
// Still stuck at 1 - drainConnections gave up, it did not (and cannot)
// force the phantom connection closed itself.
try std.testing.expectEqual(@as(usize, 1), srv.activeConnections());
}
test "server responds to ping, set, get, and delete over loopback" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.ping();
try client.set("hello", "world");
const got = try client.get(allocator, "hello");
defer if (got) |g| allocator.free(g);
try std.testing.expectEqualStrings("world", got.?);
try client.delete("hello");
const afterDelete = try client.get(allocator, "hello");
try std.testing.expect(afterDelete == null);
}
test "server scan, prefix, and stats round trip real data" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.set("user:1", "alice");
try client.set("user:2", "bob");
try client.set("order:1", "widget");
const prefixed = try client.prefix(allocator, "user:", 0);
defer protocol.freeOwnedEntries(allocator, prefixed);
try std.testing.expectEqual(@as(usize, 2), prefixed.len);
const scanned = try client.scan(allocator, .{});
defer protocol.freeOwnedEntries(allocator, scanned);
try std.testing.expectEqual(@as(usize, 3), scanned.len);
const s = try client.stats();
try std.testing.expectEqual(@as(usize, 3), s.memtableEntries);
}
test "server batch applies puts and deletes atomically" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.set("keep", "1");
const ops = [_]protocol.BatchOp{
.{ .put = .{ .key = "a", .value = "1" } },
.{ .put = .{ .key = "b", .value = "2" } },
.{ .del = "keep" },
};
try client.batch(&ops);
const a = try client.get(allocator, "a");
defer if (a) |v| allocator.free(v);
try std.testing.expectEqualStrings("1", a.?);
const kept = try client.get(allocator, "keep");
try std.testing.expect(kept == null);
}
test "server rejects a malformed frame with bad_request without crashing" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const stream = try std.net.tcpConnectToAddress(server.boundAddress());
defer stream.close();
try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.set), "not a valid set payload with no length prefix boundary");
var frame = try protocol.readFrame(allocator, stream.reader());
defer frame.deinit();
try std.testing.expectEqual(@intFromEnum(protocol.Status.bad_request), frame.tag);
}
test "server serves multiple concurrent clients without corrupting state" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
const WorkerCtx = struct {
allocator: Allocator,
addr: std.net.Address,
index: usize,
ok: bool = false,
};
const worker = struct {
fn run(ctx: *WorkerCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var buf: [32]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "k{d}", .{ctx.index}) catch return;
client.set(key, "v") catch return;
const got = client.get(ctx.allocator, key) catch return;
defer if (got) |g| ctx.allocator.free(g);
ctx.ok = got != null and std.mem.eql(u8, got.?, "v");
}
}.run;
var contexts: [8]WorkerCtx = undefined;
var threads: [8]std.Thread = undefined;
for (0..8) |i| {
contexts[i] = .{ .allocator = allocator, .addr = server.boundAddress(), .index = i };
threads[i] = try std.Thread.spawn(.{}, worker, .{&contexts[i]});
}
for (0..8) |i| threads[i].join();
for (0..8) |i| try std.testing.expect(contexts[i].ok);
}
test "server enforces a token-bucket rate limit and rejects the overflow" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
// Slow refill (1 token/sec): the whole burst below completes in well
// under a second of wall-clock time, so refill contributes nowhere
// near a full token and the outcome is deterministic.
server.enableRateLimit(3, 1);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
var allowed: usize = 0;
var rejected: usize = 0;
var i: usize = 0;
while (i < 6) : (i += 1) {
client.ping() catch |err| {
try std.testing.expectEqual(client_mod.ClientError.ServerRateLimited, err);
rejected += 1;
continue;
};
allowed += 1;
}
try std.testing.expectEqual(@as(usize, 3), allowed);
try std.testing.expectEqual(@as(usize, 3), rejected);
try std.testing.expectEqual(@as(u64, 6), server.requestsTotal.get());
try std.testing.expectEqual(@as(u64, 3), server.rateLimitedTotal.get());
}
test "server without an enabled rate limit never rejects" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
var i: usize = 0;
while (i < 20) : (i += 1) try client.ping();
try std.testing.expectEqual(@as(u64, 20), server.requestsTotal.get());
try std.testing.expectEqual(@as(u64, 0), server.rateLimitedTotal.get());
}
test "per-connection rate limit gives each connection its own budget" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enablePerConnectionRateLimit(2, 1);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var clientA = try client_mod.Client.connect(allocator, server.boundAddress());
defer clientA.close();
var clientB = try client_mod.Client.connect(allocator, server.boundAddress());
defer clientB.close();
// Exhaust A's private budget first: B must be unaffected because each
// connection got its own bucket, not a bucket shared across the server.
try clientA.ping();
try clientA.ping();
try std.testing.expectError(client_mod.ClientError.ServerRateLimited, clientA.ping());
try clientB.ping();
try clientB.ping();
try std.testing.expectError(client_mod.ClientError.ServerRateLimited, clientB.ping());
try std.testing.expectEqual(@as(u64, 2), server.connRateLimitedTotal.get());
try std.testing.expectEqual(@as(u64, 0), server.rateLimitedTotal.get());
}
test "per-connection and global rate limits both apply, connection budget checked first" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enablePerConnectionRateLimit(10, 1);
server.enableRateLimit(2, 1);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.ping();
try client.ping();
try std.testing.expectError(client_mod.ClientError.ServerRateLimited, client.ping());
// The global bucket was the one that actually ran out, not the
// per-connection one (which had plenty of headroom left).
try std.testing.expectEqual(@as(u64, 1), server.rateLimitedTotal.get());
try std.testing.expectEqual(@as(u64, 0), server.connRateLimitedTotal.get());
}
test "server table_select executes a remote expr against real rows, using an index when available" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 20 } },
});
try table.put(.{ .int = 2 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 40 } },
});
try table.put(.{ .int = 3 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "age", .value = .{ .int = 40 } },
});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const rows = try client.tableSelect(allocator, "users", "city eq s:warsaw and age gte i:30", 0, 0, &.{});
defer protocol.freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
try std.testing.expectEqual(@as(i64, 2), rows[0].get(querymod.pkField).?.int);
const all = try client.tableSelect(allocator, "users", "", 0, 0, &.{});
defer protocol.freeRows(allocator, all);
try std.testing.expectEqual(@as(usize, 3), all.len);
}
test "server table_explain reports index_eq, composite_eq, and full_scan over real TCP, matching what table_select would actually use" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("age");
try table.createCompositeIndex("city_age", &.{ "city", "age" });
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
// Single-field index eq on `age`.
var agePlan = try client.tableExplain(allocator, "users", "age eq i:30");
defer agePlan.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.index_eq, agePlan.kind);
try std.testing.expectEqualStrings("age", agePlan.indexName.?);
// Composite index, full prefix matched by equality on both columns.
var compositePlan = try client.tableExplain(allocator, "users", "city eq s:warsaw and age eq i:30");
defer compositePlan.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.composite_eq, compositePlan.kind);
try std.testing.expectEqualStrings("city_age", compositePlan.indexName.?);
try std.testing.expectEqual(@as(usize, 2), compositePlan.matchedFields);
try std.testing.expectEqual(@as(usize, 2), compositePlan.totalFields);
// No predicate on an indexed field anywhere -> full scan.
var scanPlan = try client.tableExplain(allocator, "users", "city eq s:warsaw");
defer scanPlan.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.full_scan, scanPlan.kind);
// Cross-check: what table_explain reports for the composite query is
// exactly what table_select actually finds when it runs it - the whole
// point of an explain feature is not lying about what execution does.
const rows = try client.tableSelect(allocator, "users", "city eq s:warsaw and age eq i:30", 0, 0, &.{});
defer protocol.freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
}
test "server table_explain rejects a malformed expr with bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const err = client.tableExplain(allocator, "users", "city bogus s:warsaw");
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
// The connection survives a bad_request and can still serve a good
// request right after - same expectation `table_select`'s malformed-expr
// test above holds for its own opcode.
var plan = try client.tableExplain(allocator, "users", "");
defer plan.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.full_scan, plan.kind);
}
test "server table_update applies a set clause to matching rows over real TCP, keeping an index in sync" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "active", .value = .{ .int = 0 } } });
try table.put(.{ .int = 2 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "active", .value = .{ .int = 0 } } });
try table.put(.{ .int = 3 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "active", .value = .{ .int = 0 } } });
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const sets = [_]rowmod.Field{.{ .name = "city", .value = .{ .text = "gdansk" } }};
const updated = try client.tableUpdate("users", &sets, "city eq s:warsaw");
try std.testing.expectEqual(@as(u64, 2), updated);
// The old index entry is gone for both moved rows...
const stale = try client.tableSelect(allocator, "users", "city eq s:warsaw", 0, 0, &.{});
defer protocol.freeRows(allocator, stale);
try std.testing.expectEqual(@as(usize, 0), stale.len);
// ...a fresh one under the new value finds them, and the row not
// matched by the predicate (pk 3, krakow) was left untouched.
const moved = try client.tableSelect(allocator, "users", "city eq s:gdansk", 0, 0, &.{});
defer protocol.freeRows(allocator, moved);
try std.testing.expectEqual(@as(usize, 2), moved.len);
const untouched = try client.tableSelect(allocator, "users", "city eq s:krakow", 0, 0, &.{});
defer protocol.freeRows(allocator, untouched);
try std.testing.expectEqual(@as(usize, 1), untouched.len);
}
test "server table_update rejects a malformed expr with bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const sets = [_]rowmod.Field{.{ .name = "active", .value = .{ .int = 1 } }};
const err = client.tableUpdate("users", &sets, "city bogus s:warsaw");
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
// The connection survives a bad_request and can still serve a good
// request right after - same expectation `table_explain`'s malformed
// test above holds for its own opcode.
const updated = try client.tableUpdate("users", &sets, "");
try std.testing.expectEqual(@as(u64, 1), updated);
}
test "server rejects a malformed table_update frame (bad set fields) with bad_request without crashing" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const stream = try std.net.tcpConnectToAddress(server.boundAddress());
defer stream.close();
// A well-formed table name ("users"), then a "set fields" byte string
// that decodeTableUpdate happily hands back as opaque bytes but
// Row.decode rejects (a field count of 0xFFFFFFFF with nothing behind
// it) - the decode failure `handleTableUpdate` itself must catch,
// unlike `decodeTableUpdate`'s own malformed-frame check above it.
var payload = std.ArrayList(u8).init(allocator);
defer payload.deinit();
var lenBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &lenBuf, 5, .little);
try payload.appendSlice(&lenBuf);
try payload.appendSlice("users");
std.mem.writeInt(u32, &lenBuf, 4, .little);
try payload.appendSlice(&lenBuf);
std.mem.writeInt(u32, &lenBuf, 0xFFFFFFFF, .little);
try payload.appendSlice(&lenBuf);
try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.table_update), payload.items);
var frame = try protocol.readFrame(allocator, stream.reader());
defer frame.deinit();
try std.testing.expectEqual(@intFromEnum(protocol.Status.bad_request), frame.tag);
// The connection must still be usable afterward.
try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.ping), "");
var pingFrame = try protocol.readFrame(allocator, stream.reader());
defer pingFrame.deinit();
try std.testing.expectEqual(@intFromEnum(protocol.Status.ok), pingFrame.tag);
}
// `table_update` shares the same underlying-sstable-reshaping hazard as
// `table_select`/`table_join`/`table_aggregate` above, but with a sharper
// edge: `updateWhere` both READS (via `selectAllMatches`) and WRITES (via
// `put`) in the same request, while those three only read. `self.mutex` in
// `Server.dispatch` still serializes every request end-to-end including
// `flush`/`compact`, so no single request can observe a torn sstable set -
// what this test actually proves is that real structural reshaping,
// interleaved via real OS threads through the real TCP/dispatch path, never
// desyncs `updateWhere`'s read-then-write from the index it must keep in
// sync, and that a single updater thread's own sequence of writes lands
// exactly where expected once every other thread has stopped.
//
// Thirty rows split evenly three ways by `@mod(pk, 3)` (same helper as the
// `table_aggregate` concurrency test above) so the "city" index always
// holds exactly 10 rows per bucket - that count must never move, because
// this test's single updater thread only ever touches the "epoch" field,
// never "city" itself, so index population is an invariant a reader can
// check at every single snapshot, not just at the end.
test "table_update survives concurrent flush and compact reshaping the underlying sstables" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
// Small target size so a handful of `flush`/`compact` calls do real
// structural work instead of needing megabytes of data first - same
// trick as the `table_select`/`table_join`/`table_aggregate`
// concurrency tests above.
var db = try Db.open(allocator, path, .{ .sstableTargetBytes = 512 });
defer db.close();
const rowCount = 30;
{
var accounts = try querymod.Table.open(&db, allocator, "accounts");
defer accounts.deinit();
try accounts.createIndex("city");
var pk: i64 = 1;
while (pk <= rowCount) : (pk += 1) {
const city: []const u8 = switch (@mod(pk, 3)) {
0 => "warsaw",
1 => "krakow",
else => "gdansk",
};
try accounts.put(.{ .int = pk }, &.{
.{ .name = "city", .value = .{ .text = city } },
.{ .name = "epoch", .value = .{ .int = 0 } },
});
}
try db.flush();
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
const readerThreads = 4;
const readerIters = 20;
const reshapeThreads = 2;
const reshapeIters = 15;
const updaterIters = 20;
const ReaderCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
const readerWorker = struct {
fn run(ctx: *ReaderCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < readerIters) : (i += 1) {
inline for (.{ "warsaw", "krakow", "gdansk" }) |city| {
const buf = std.fmt.allocPrint(ctx.allocator, "city eq s:{s}", .{city}) catch return;
defer ctx.allocator.free(buf);
const rows = client.tableSelect(ctx.allocator, "accounts", buf, 0, 0, &.{}) catch return;
defer protocol.freeRows(ctx.allocator, rows);
if (rows.len != 10) return;
for (rows) |row| {
const got = row.get("city") orelse return;
if (!std.mem.eql(u8, got.text, city)) return;
}
}
}
ctx.ok = true;
}
}.run;
const ReshapeCtx = struct {
allocator: Allocator,
addr: std.net.Address,
thread: usize,
ok: bool = false,
};
const reshapeWorker = struct {
fn run(ctx: *ReshapeCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < reshapeIters) : (i += 1) {
var buf: [32]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "update-churn-{d}-{d}", .{ ctx.thread, i }) catch return;
client.set(key, "x") catch return;
client.flush() catch return;
client.compact() catch return;
}
ctx.ok = true;
}
}.run;
const UpdaterCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
const updaterWorker = struct {
fn run(ctx: *UpdaterCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: i64 = 0;
while (i < updaterIters) : (i += 1) {
const target: []const u8 = if (@mod(i, 2) == 0) "krakow" else "gdansk";
const expr = std.fmt.allocPrint(ctx.allocator, "city eq s:{s}", .{target}) catch return;
defer ctx.allocator.free(expr);
const sets = [_]rowmod.Field{.{ .name = "epoch", .value = .{ .int = i } }};
const updated = client.tableUpdate("accounts", &sets, expr) catch return;
if (updated != 10) return;
}
ctx.ok = true;
}
}.run;
var readerContexts: [readerThreads]ReaderCtx = undefined;
var readerHandles: [readerThreads]std.Thread = undefined;
for (0..readerThreads) |i| {
readerContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress() };
readerHandles[i] = try std.Thread.spawn(.{}, readerWorker, .{&readerContexts[i]});
}
var reshapeContexts: [reshapeThreads]ReshapeCtx = undefined;
var reshapeHandles: [reshapeThreads]std.Thread = undefined;
for (0..reshapeThreads) |i| {
reshapeContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress(), .thread = i };
reshapeHandles[i] = try std.Thread.spawn(.{}, reshapeWorker, .{&reshapeContexts[i]});
}
var updaterContext = UpdaterCtx{ .allocator = allocator, .addr = server.boundAddress() };
const updaterHandle = try std.Thread.spawn(.{}, updaterWorker, .{&updaterContext});
for (0..readerThreads) |i| readerHandles[i].join();
for (0..reshapeThreads) |i| reshapeHandles[i].join();
updaterHandle.join();
for (0..readerThreads) |i| try std.testing.expect(readerContexts[i].ok);
for (0..reshapeThreads) |i| try std.testing.expect(reshapeContexts[i].ok);
try std.testing.expect(updaterContext.ok);
// Final sanity check on a fresh connection: the updater alternated
// krakow/gdansk, so the last krakow write was iteration 18 (last even
// below 20) and the last gdansk write was iteration 19 (last odd) -
// warsaw was never touched by any predicate and must still read 0.
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const krakowRows = try client.tableSelect(allocator, "accounts", "city eq s:krakow", 0, 0, &.{});
defer protocol.freeRows(allocator, krakowRows);
try std.testing.expectEqual(@as(usize, 10), krakowRows.len);
for (krakowRows) |row| try std.testing.expectEqual(@as(i64, 18), row.get("epoch").?.int);
const gdanskRows = try client.tableSelect(allocator, "accounts", "city eq s:gdansk", 0, 0, &.{});
defer protocol.freeRows(allocator, gdanskRows);
try std.testing.expectEqual(@as(usize, 10), gdanskRows.len);
for (gdanskRows) |row| try std.testing.expectEqual(@as(i64, 19), row.get("epoch").?.int);
const warsawRows = try client.tableSelect(allocator, "accounts", "city eq s:warsaw", 0, 0, &.{});
defer protocol.freeRows(allocator, warsawRows);
try std.testing.expectEqual(@as(usize, 10), warsawRows.len);
for (warsawRows) |row| try std.testing.expectEqual(@as(i64, 0), row.get("epoch").?.int);
}
// Same shape as the test above, but the index guarding the concurrent
// reads/updates is a COMPOSITE one (`city_age`, two columns) instead of a
// single-column `city` index - `updateWhere` reached through
// `composite_eq` was only proven correct single-threaded so far (see
// query.zig's "updateWhere keeps a composite index in sync" test); this
// proves the same code path survives real `flush`/`compact` reshaping the
// underlying sstables while other connections read and write through it.
test "table_update survives concurrent flush and compact reshaping a composite index" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try Db.open(allocator, path, .{ .sstableTargetBytes = 512 });
defer db.close();
// 30 rows: 3 cities x 2 age bands x 5 rows each. Every (city, age) pair
// is a full-tuple equality match against `city_age`, so
// `Table.selectPage`/`updateWhere` both reach it through `composite_eq`
// (see query.zig's "createCompositeIndex backfills then full-tuple eq
// select" test for the same match shape single-threaded).
const rowCount = 30;
{
var accounts = try querymod.Table.open(&db, allocator, "accounts");
defer accounts.deinit();
try accounts.createCompositeIndex("city_age", &.{ "city", "age" });
var pk: i64 = 1;
while (pk <= rowCount) : (pk += 1) {
const city: []const u8 = switch (@mod(pk, 3)) {
0 => "warsaw",
1 => "krakow",
else => "gdansk",
};
const age: i64 = if (@mod(@divTrunc(pk - 1, 3), 2) == 0) 30 else 40;
try accounts.put(.{ .int = pk }, &.{
.{ .name = "city", .value = .{ .text = city } },
.{ .name = "age", .value = .{ .int = age } },
.{ .name = "epoch", .value = .{ .int = 0 } },
});
}
try db.flush();
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
const readerThreads = 4;
const readerIters = 20;
const reshapeThreads = 2;
const reshapeIters = 15;
const updaterIters = 20;
const Combo = struct { city: []const u8, age: i64 };
const combos = [_]Combo{
.{ .city = "warsaw", .age = 30 },
.{ .city = "warsaw", .age = 40 },
.{ .city = "krakow", .age = 30 },
.{ .city = "krakow", .age = 40 },
.{ .city = "gdansk", .age = 30 },
.{ .city = "gdansk", .age = 40 },
};
const ReaderCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
const readerWorker = struct {
fn run(ctx: *ReaderCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < readerIters) : (i += 1) {
for (combos) |combo| {
const buf = std.fmt.allocPrint(ctx.allocator, "city eq s:{s} and age eq i:{d}", .{ combo.city, combo.age }) catch return;
defer ctx.allocator.free(buf);
const rows = client.tableSelect(ctx.allocator, "accounts", buf, 0, 0, &.{}) catch return;
defer protocol.freeRows(ctx.allocator, rows);
if (rows.len != 5) return;
for (rows) |row| {
const gotCity = row.get("city") orelse return;
if (!std.mem.eql(u8, gotCity.text, combo.city)) return;
const gotAge = row.get("age") orelse return;
if (gotAge.int != combo.age) return;
}
}
}
ctx.ok = true;
}
}.run;
const ReshapeCtx = struct {
allocator: Allocator,
addr: std.net.Address,
thread: usize,
ok: bool = false,
};
const reshapeWorker = struct {
fn run(ctx: *ReshapeCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < reshapeIters) : (i += 1) {
var buf: [32]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "update-composite-churn-{d}-{d}", .{ ctx.thread, i }) catch return;
client.set(key, "x") catch return;
client.flush() catch return;
client.compact() catch return;
}
ctx.ok = true;
}
}.run;
const UpdaterCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
const updaterWorker = struct {
fn run(ctx: *UpdaterCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: i64 = 0;
while (i < updaterIters) : (i += 1) {
// Alternates between two full-tuple matches on the composite
// index, never touching `city`/`age` themselves, so the
// (5, 5, 5, 5, 5, 5) population split is a checkable
// invariant on every single reader iteration in flight.
const combo = if (@mod(i, 2) == 0) Combo{ .city = "krakow", .age = 30 } else Combo{ .city = "gdansk", .age = 40 };
const expr = std.fmt.allocPrint(ctx.allocator, "city eq s:{s} and age eq i:{d}", .{ combo.city, combo.age }) catch return;
defer ctx.allocator.free(expr);
const sets = [_]rowmod.Field{.{ .name = "epoch", .value = .{ .int = i } }};
const updated = client.tableUpdate("accounts", &sets, expr) catch return;
if (updated != 5) return;
}
ctx.ok = true;
}
}.run;
var readerContexts: [readerThreads]ReaderCtx = undefined;
var readerHandles: [readerThreads]std.Thread = undefined;
for (0..readerThreads) |i| {
readerContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress() };
readerHandles[i] = try std.Thread.spawn(.{}, readerWorker, .{&readerContexts[i]});
}
var reshapeContexts: [reshapeThreads]ReshapeCtx = undefined;
var reshapeHandles: [reshapeThreads]std.Thread = undefined;
for (0..reshapeThreads) |i| {
reshapeContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress(), .thread = i };
reshapeHandles[i] = try std.Thread.spawn(.{}, reshapeWorker, .{&reshapeContexts[i]});
}
var updaterContext = UpdaterCtx{ .allocator = allocator, .addr = server.boundAddress() };
const updaterHandle = try std.Thread.spawn(.{}, updaterWorker, .{&updaterContext});
for (0..readerThreads) |i| readerHandles[i].join();
for (0..reshapeThreads) |i| reshapeHandles[i].join();
updaterHandle.join();
for (0..readerThreads) |i| try std.testing.expect(readerContexts[i].ok);
for (0..reshapeThreads) |i| try std.testing.expect(reshapeContexts[i].ok);
try std.testing.expect(updaterContext.ok);
// Final sanity check on a fresh connection: the updater alternated
// krakow/30 and gdansk/40, so the last krakow/30 write was iteration 18
// (last even below 20) and the last gdansk/40 write was iteration 19
// (last odd) - the other four (city, age) combos were never touched by
// any predicate and must still read epoch 0.
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
for (combos) |combo| {
const expr = try std.fmt.allocPrint(allocator, "city eq s:{s} and age eq i:{d}", .{ combo.city, combo.age });
defer allocator.free(expr);
const rows = try client.tableSelect(allocator, "accounts", expr, 0, 0, &.{});
defer protocol.freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 5), rows.len);
const wantEpoch: i64 = if (std.mem.eql(u8, combo.city, "krakow") and combo.age == 30)
18
else if (std.mem.eql(u8, combo.city, "gdansk") and combo.age == 40)
19
else
0;
for (rows) |row| try std.testing.expectEqual(wantEpoch, row.get("epoch").?.int);
// The composite index must still be the strategy actually used
// after all this churn, not a fallback to a full scan.
var plan = try client.tableExplain(allocator, "accounts", expr);
defer plan.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.composite_eq, plan.kind);
}
}
test "server table_delete removes matching rows over real TCP, keeping an index in sync" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.put(.{ .int = 3 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const deleted = try client.tableDelete("users", "city eq s:warsaw");
try std.testing.expectEqual(@as(u64, 2), deleted);
// The index entries for both deleted rows are gone, not just the rows -
// a stale entry here would surface as a phantom in a later select.
const gone = try client.tableSelect(allocator, "users", "city eq s:warsaw", 0, 0, &.{});
defer protocol.freeRows(allocator, gone);
try std.testing.expectEqual(@as(usize, 0), gone.len);
// The row not matched by the predicate (pk 3, krakow) was left untouched.
const untouched = try client.tableSelect(allocator, "users", "city eq s:krakow", 0, 0, &.{});
defer protocol.freeRows(allocator, untouched);
try std.testing.expectEqual(@as(usize, 1), untouched.len);
// A fresh row can reuse the "warsaw" value without colliding with a
// leftover index entry from the deleted rows.
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 4 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
}
const reused = try client.tableSelect(allocator, "users", "city eq s:warsaw", 0, 0, &.{});
defer protocol.freeRows(allocator, reused);
try std.testing.expectEqual(@as(usize, 1), reused.len);
try std.testing.expectEqual(@as(i64, 4), reused[0].get(querymod.pkField).?.int);
}
test "server table_delete with an empty expr removes every row" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "counters");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "n", .value = .{ .int = 5 } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "n", .value = .{ .int = 7 } }});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const deleted = try client.tableDelete("counters", "");
try std.testing.expectEqual(@as(u64, 2), deleted);
const everyone = try client.tableSelect(allocator, "counters", "", 0, 0, &.{});
defer protocol.freeRows(allocator, everyone);
try std.testing.expectEqual(@as(usize, 0), everyone.len);
}
test "server table_delete rejects a malformed expr with bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const err = client.tableDelete("users", "city bogus s:warsaw");
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
// The connection survives a bad_request and can still serve a good
// request right after - same expectation `table_update`'s malformed
// test above holds for its own opcode.
const deleted = try client.tableDelete("users", "");
try std.testing.expectEqual(@as(u64, 1), deleted);
}
// `handleTableDelete` calls `Table.deleteWhere` directly (see its own
// source), the exact function `Table.delete`'s ON DELETE RESTRICT check
// (query.zig, backed by `ReferencedBySet`) is wired into - so this was
// never a separate opcode to teach about FOREIGN KEY, only a round trip to
// prove the wire path actually reaches it, the same "real dispatch, not
// just the in-process unit test" shape the CHECK/FK/UNIQUE `txn_op` tests
// elsewhere in this file already are.
test "server table_delete rejects a FOREIGN KEY RESTRICT violation over the wire and leaves the row in place" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var customers = try querymod.Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{});
var orders = try querymod.Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", querymod.pkField, .restrict);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const err = client.tableDelete("customers", "");
try std.testing.expectError(client_mod.ClientError.ServerInternal, err);
var customers = try querymod.Table.open(&db, allocator, "customers");
defer customers.deinit();
var stillThere = (try customers.get(.{ .int = 1 })).?;
defer stillThere.deinit();
// Once the referencing order is gone, the same request succeeds.
_ = try client.tableDelete("orders", "");
const deleted = try client.tableDelete("customers", "");
try std.testing.expectEqual(@as(u64, 1), deleted);
}
test "server rejects a malformed table_delete frame (bad table name) with bad_request without crashing" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const stream = try std.net.tcpConnectToAddress(server.boundAddress());
defer stream.close();
// A length prefix (0xFFFFFFFF) claiming far more table-name bytes than
// the payload actually carries - `decodeTableDelete`'s own malformed-frame
// check must catch this before `Table.open` is ever reached.
var payload = std.ArrayList(u8).init(allocator);
defer payload.deinit();
var lenBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &lenBuf, 0xFFFFFFFF, .little);
try payload.appendSlice(&lenBuf);
try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.table_delete), payload.items);
var frame = try protocol.readFrame(allocator, stream.reader());
defer frame.deinit();
try std.testing.expectEqual(@intFromEnum(protocol.Status.bad_request), frame.tag);
// The connection must still be usable afterward.
try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.ping), "");
var pingFrame = try protocol.readFrame(allocator, stream.reader());
defer pingFrame.deinit();
try std.testing.expectEqual(@intFromEnum(protocol.Status.ok), pingFrame.tag);
}
test "server table_export renders a real table to csv and json over TCP, matching the local dump.zig output" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "people");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "age", .value = .{ .int = 30 } } });
try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
var csvResult = try client.tableExport(allocator, "people", .csv);
defer csvResult.deinit(allocator);
try std.testing.expectEqual(@as(u64, 2), csvResult.rowCount);
var localCsv = std.ArrayList(u8).init(allocator);
defer localCsv.deinit();
{
var table = try querymod.Table.open(&db, allocator, "people");
defer table.deinit();
const n = try dumpmod.exportCsvWriter(allocator, &table, localCsv.writer());
try std.testing.expectEqual(@as(usize, 2), n);
}
try std.testing.expectEqualStrings(localCsv.items, csvResult.content);
var jsonResult = try client.tableExport(allocator, "people", .json);
defer jsonResult.deinit(allocator);
try std.testing.expectEqual(@as(u64, 2), jsonResult.rowCount);
// Every row's primary key shows up somewhere in the rendered JSON -
// enough to prove the server actually rendered real content instead
// of an empty/placeholder body, without re-parsing JSON here too.
try std.testing.expect(std.mem.indexOf(u8, jsonResult.content, "warsaw") != null);
try std.testing.expect(std.mem.indexOf(u8, jsonResult.content, "krakow") != null);
}
test "server table_import loads csv and json content sent over TCP into a real table" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const csvImported = try client.tableImport("from_csv", .csv, "__pk,city\ni:1,s:warsaw\ni:2,s:krakow\n");
try std.testing.expectEqual(@as(u64, 2), csvImported);
const jsonImported = try client.tableImport("from_json", .json, "[{\"__pk\": \"i:1\", \"city\": \"s:gdansk\"}]");
try std.testing.expectEqual(@as(u64, 1), jsonImported);
const csvRows = try client.tableSelect(allocator, "from_csv", "", 0, 0, &.{});
defer protocol.freeRows(allocator, csvRows);
try std.testing.expectEqual(@as(usize, 2), csvRows.len);
const jsonRows = try client.tableSelect(allocator, "from_json", "city eq s:gdansk", 0, 0, &.{});
defer protocol.freeRows(allocator, jsonRows);
try std.testing.expectEqual(@as(usize, 1), jsonRows.len);
}
test "server table_export then table_import round trips a real table over two TCP round trips" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "source");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .float = 4.5 } } });
try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "lodz" } }});
try table.put(.{ .int = 3 }, &.{});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
var exported = try client.tableExport(allocator, "source", .json);
defer exported.deinit(allocator);
try std.testing.expectEqual(@as(u64, 3), exported.rowCount);
const imported = try client.tableImport("dest", .json, exported.content);
try std.testing.expectEqual(@as(u64, 3), imported);
const warsawRows = try client.tableSelect(allocator, "dest", "city eq s:warsaw", 0, 0, &.{});
defer protocol.freeRows(allocator, warsawRows);
try std.testing.expectEqual(@as(usize, 1), warsawRows.len);
try std.testing.expectEqual(@as(f64, 4.5), warsawRows[0].get("score").?.float);
const untouched = try client.tableSelect(allocator, "dest", "city eq s:lodz", 0, 0, &.{});
defer protocol.freeRows(allocator, untouched);
try std.testing.expectEqual(@as(usize, 1), untouched.len);
const allRows = try client.tableSelect(allocator, "dest", "", 0, 0, &.{});
defer protocol.freeRows(allocator, allRows);
try std.testing.expectEqual(@as(usize, 3), allRows.len);
}
test "server table_export succeeds on a never-written table (schemaless tables have no declare step), table_import rejects malformed content without crashing" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
// `table` is just a keyspace prefix, never declared ahead of time (see
// `query.Table.open`), so exporting one nothing has ever put a row into
// is a valid zero-row export, not an error.
var exported = try client.tableExport(allocator, "nosuch", .csv);
defer exported.deinit(allocator);
try std.testing.expectEqual(@as(u64, 0), exported.rowCount);
const badImport = client.tableImport("nosuch", .json, "not json");
try std.testing.expectError(client_mod.ClientError.ServerInternal, badImport);
// The connection survives an internal error and can still serve a
// good request right after.
const rowCount = try client.tableImport("nosuch", .json, "[]");
try std.testing.expectEqual(@as(u64, 0), rowCount);
}
test "server rejects a malformed table_export frame (bad format byte) with bad_request without crashing" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "people");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const stream = try std.net.tcpConnectToAddress(server.boundAddress());
defer stream.close();
// A well-formed table name ("people") followed by a format byte
// outside the DumpFormat enum (99) - `decodeTableExport` itself must
// reject this before `Table.open` is ever reached.
var payload = std.ArrayList(u8).init(allocator);
defer payload.deinit();
var lenBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &lenBuf, 6, .little);
try payload.appendSlice(&lenBuf);
try payload.appendSlice("people");
try payload.append(99);
try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.table_export), payload.items);
var frame = try protocol.readFrame(allocator, stream.reader());
defer frame.deinit();
try std.testing.expectEqual(@intFromEnum(protocol.Status.bad_request), frame.tag);
// The connection must still be usable afterward.
try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.ping), "");
var pingFrame = try protocol.readFrame(allocator, stream.reader());
defer pingFrame.deinit();
try std.testing.expectEqual(@intFromEnum(protocol.Status.ok), pingFrame.tag);
}
test "table_export requires only readonly role, table_import requires readwrite" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "people");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{});
}
const store = authmod.Store.init(&db);
try store.createUser(allocator, "viewer", "pass", .readonly, 16);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enableAuth(store);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.authenticate("viewer", "pass");
var exported = try client.tableExport(allocator, "people", .csv);
defer exported.deinit(allocator);
try std.testing.expectEqual(@as(u64, 1), exported.rowCount);
try std.testing.expectError(client_mod.ClientError.ServerForbidden, client.tableImport("people", .csv, exported.content));
}
// `table_delete` shares the same underlying-sstable-reshaping hazard as
// `table_update` above (see that test's header comment for the full
// rationale), but where `updateWhere` reads-then-writes each matched row in
// place, `deleteWhere` reads-then-REMOVES it - a sharper edge again, since a
// removed row's index entries must never resurface even if a `flush`/
// `compact` reshapes the underlying sstables mid-scan. Three buckets
// (warsaw/krakow/gdansk) stay untouched for the whole test and are the
// reader threads' stable invariant (always exactly 10 rows each); a fourth
// bucket (poznan) is what the deleter thread progressively drains one row
// at a time via a "seq" field unrelated to the "city" index, so every
// delete is a distinct, real structural change rather than a no-op repeat.
test "table_delete survives concurrent flush and compact reshaping the underlying sstables" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
// Small target size so a handful of `flush`/`compact` calls do real
// structural work instead of needing megabytes of data first - same
// trick as `table_update`'s own concurrency test above.
var db = try Db.open(allocator, path, .{ .sstableTargetBytes = 512 });
defer db.close();
const stableBucketRows = 10;
const poznanRows = 10;
{
var accounts = try querymod.Table.open(&db, allocator, "accounts");
defer accounts.deinit();
try accounts.createIndex("city");
var pk: i64 = 1;
var i: i64 = 0;
while (i < stableBucketRows) : (i += 1) {
inline for (.{ "warsaw", "krakow", "gdansk" }) |city| {
try accounts.put(.{ .int = pk }, &.{.{ .name = "city", .value = .{ .text = city } }});
pk += 1;
}
}
var seq: i64 = 0;
while (seq < poznanRows) : (seq += 1) {
try accounts.put(.{ .int = pk }, &.{
.{ .name = "city", .value = .{ .text = "poznan" } },
.{ .name = "seq", .value = .{ .int = seq } },
});
pk += 1;
}
try db.flush();
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
const readerThreads = 4;
const readerIters = 20;
const reshapeThreads = 2;
const reshapeIters = 15;
const ReaderCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
const readerWorker = struct {
fn run(ctx: *ReaderCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < readerIters) : (i += 1) {
inline for (.{ "warsaw", "krakow", "gdansk" }) |city| {
const buf = std.fmt.allocPrint(ctx.allocator, "city eq s:{s}", .{city}) catch return;
defer ctx.allocator.free(buf);
const rows = client.tableSelect(ctx.allocator, "accounts", buf, 0, 0, &.{}) catch return;
defer protocol.freeRows(ctx.allocator, rows);
if (rows.len != stableBucketRows) return;
for (rows) |row| {
const got = row.get("city") orelse return;
if (!std.mem.eql(u8, got.text, city)) return;
}
}
}
ctx.ok = true;
}
}.run;
const ReshapeCtx = struct {
allocator: Allocator,
addr: std.net.Address,
thread: usize,
ok: bool = false,
};
const reshapeWorker = struct {
fn run(ctx: *ReshapeCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < reshapeIters) : (i += 1) {
var buf: [32]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "delete-churn-{d}-{d}", .{ ctx.thread, i }) catch return;
client.set(key, "x") catch return;
client.flush() catch return;
client.compact() catch return;
}
ctx.ok = true;
}
}.run;
const DeleterCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
const deleterWorker = struct {
fn run(ctx: *DeleterCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var seq: i64 = 0;
while (seq < poznanRows) : (seq += 1) {
const expr = std.fmt.allocPrint(ctx.allocator, "city eq s:poznan and seq eq i:{d}", .{seq}) catch return;
defer ctx.allocator.free(expr);
const deleted = client.tableDelete("accounts", expr) catch return;
if (deleted != 1) return;
}
ctx.ok = true;
}
}.run;
var readerContexts: [readerThreads]ReaderCtx = undefined;
var readerHandles: [readerThreads]std.Thread = undefined;
for (0..readerThreads) |i| {
readerContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress() };
readerHandles[i] = try std.Thread.spawn(.{}, readerWorker, .{&readerContexts[i]});
}
var reshapeContexts: [reshapeThreads]ReshapeCtx = undefined;
var reshapeHandles: [reshapeThreads]std.Thread = undefined;
for (0..reshapeThreads) |i| {
reshapeContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress(), .thread = i };
reshapeHandles[i] = try std.Thread.spawn(.{}, reshapeWorker, .{&reshapeContexts[i]});
}
var deleterContext = DeleterCtx{ .allocator = allocator, .addr = server.boundAddress() };
const deleterHandle = try std.Thread.spawn(.{}, deleterWorker, .{&deleterContext});
for (0..readerThreads) |i| readerHandles[i].join();
for (0..reshapeThreads) |i| reshapeHandles[i].join();
deleterHandle.join();
for (0..readerThreads) |i| try std.testing.expect(readerContexts[i].ok);
for (0..reshapeThreads) |i| try std.testing.expect(reshapeContexts[i].ok);
try std.testing.expect(deleterContext.ok);
// Final sanity check on a fresh connection: the three stable buckets are
// still exactly 10 rows each, poznan is completely gone (not just its
// index entries - real rows too), and no phantom entry survives under
// the "poznan" index bucket for a later put to collide with.
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
inline for (.{ "warsaw", "krakow", "gdansk" }) |city| {
const buf = try std.fmt.allocPrint(allocator, "city eq s:{s}", .{city});
defer allocator.free(buf);
const rows = try client.tableSelect(allocator, "accounts", buf, 0, 0, &.{});
defer protocol.freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, stableBucketRows), rows.len);
}
const poznanRowsLeft = try client.tableSelect(allocator, "accounts", "city eq s:poznan", 0, 0, &.{});
defer protocol.freeRows(allocator, poznanRowsLeft);
try std.testing.expectEqual(@as(usize, 0), poznanRowsLeft.len);
var plan = try client.tableExplain(allocator, "accounts", "city eq s:poznan");
defer plan.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.index_eq, plan.kind);
}
test "server table_select pages a remote result with offset and limit" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
var pk: i64 = 1;
while (pk <= 5) : (pk += 1) try table.put(.{ .int = pk }, &.{});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const page = try client.tableSelect(allocator, "users", "", 1, 2, &.{});
defer protocol.freeRows(allocator, page);
try std.testing.expectEqual(@as(usize, 2), page.len);
try std.testing.expectEqual(@as(i64, 2), page[0].get(querymod.pkField).?.int);
try std.testing.expectEqual(@as(i64, 3), page[1].get(querymod.pkField).?.int);
const rest = try client.tableSelect(allocator, "users", "", 3, 0, &.{});
defer protocol.freeRows(allocator, rest);
try std.testing.expectEqual(@as(usize, 2), rest.len);
try std.testing.expectEqual(@as(i64, 4), rest[0].get(querymod.pkField).?.int);
try std.testing.expectEqual(@as(i64, 5), rest[1].get(querymod.pkField).?.int);
}
// `table_explain` reports which strategy `selectPage` would use, but
// `table_select`'s own `--order-by` is the one place the network protocol
// carries a sort key: `handleTableSelect` forwards `args.orderBy` into
// `Table.selectPageOrdered`, which must sort BEFORE windowing by
// offset/limit (see query.zig's "applies offset/limit after sorting"
// test). This exercises that ordering end to end over a real socket,
// including on a field that has an index (`age`) - `selectAllMatches`
// still narrows through the index, it just cannot early-stop while a sort
// is pending, and the wire round trip must not lose that.
test "server table_select orders a remote result by a field before windowing it, ascending and descending" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("age");
// pk order deliberately not age order: pk 1 has the highest age
// (50), pk 5 the lowest (10) - a bug that sorted only within pk
// scan order would fail every assertion below.
var pk: i64 = 1;
while (pk <= 5) : (pk += 1) {
try table.put(.{ .int = pk }, &.{.{ .name = "age", .value = .{ .int = 60 - pk * 10 } }});
}
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const asc = try client.tableSelect(allocator, "users", "", 0, 0, &.{.{ .field = "age" }});
defer protocol.freeRows(allocator, asc);
try std.testing.expectEqual(@as(usize, 5), asc.len);
try std.testing.expectEqual(@as(i64, 5), asc[0].get(querymod.pkField).?.int);
try std.testing.expectEqual(@as(i64, 10), asc[0].get("age").?.int);
try std.testing.expectEqual(@as(i64, 1), asc[4].get(querymod.pkField).?.int);
try std.testing.expectEqual(@as(i64, 50), asc[4].get("age").?.int);
// Descending, windowed to the middle page (offset 1, limit 2). Age
// decreases as pk increases here, so descending-by-age happens to land
// on the same row order as ascending pk (pk1..5 = ages 50,40,30,20,10)
// - the offset/limit window (pk 2, pk 3) is still only correct if the
// full 5-row set was sorted first and THEN windowed, not windowed off
// the raw scan and left unsorted (which would coincidentally return
// the same two rows here, but in the ascending-age windowed case below
// would not).
const midDesc = try client.tableSelect(allocator, "users", "", 1, 2, &.{.{ .field = "age", .descending = true }});
defer protocol.freeRows(allocator, midDesc);
try std.testing.expectEqual(@as(usize, 2), midDesc.len);
try std.testing.expectEqual(@as(i64, 2), midDesc[0].get(querymod.pkField).?.int);
try std.testing.expectEqual(@as(i64, 40), midDesc[0].get("age").?.int);
try std.testing.expectEqual(@as(i64, 3), midDesc[1].get(querymod.pkField).?.int);
try std.testing.expectEqual(@as(i64, 30), midDesc[1].get("age").?.int);
// Ascending, same window (offset 1, limit 2): ascending-by-age order is
// pk 5,4,3,2,1 (ages 10,20,30,40,50), so offset 1..3 is pk 4 then pk 3
// - a DIFFERENT pair than the descending window above, which a
// window-before-sort bug (paging the raw pk scan, then sorting only
// the already-cut window) would fail to distinguish.
const midAsc = try client.tableSelect(allocator, "users", "", 1, 2, &.{.{ .field = "age" }});
defer protocol.freeRows(allocator, midAsc);
try std.testing.expectEqual(@as(usize, 2), midAsc.len);
try std.testing.expectEqual(@as(i64, 4), midAsc[0].get(querymod.pkField).?.int);
try std.testing.expectEqual(@as(i64, 20), midAsc[0].get("age").?.int);
try std.testing.expectEqual(@as(i64, 3), midAsc[1].get(querymod.pkField).?.int);
try std.testing.expectEqual(@as(i64, 30), midAsc[1].get("age").?.int);
// Cross-check: table_explain reports the `age` index is used for the
// predicate that would narrow this query, same as the ordered path's
// `selectAllMatches` internally reuses.
var plan = try client.tableExplain(allocator, "users", "age gte i:0");
defer plan.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.index_range, plan.kind);
}
// A second, INDEPENDENT `orderBy` column exercises a path the single-column
// test above cannot: `handleTableSelect` decoding a wire count > 1 and
// `sortRowsBy`'s tie-breaking chain, both new with multi-column support.
test "server table_select orders a remote result by two columns, sorting then breaking ties" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "people");
defer table.deinit();
// Two cities, scores deliberately NOT already grouped by city in
// scan order, so a single-column `city` sort alone would leave
// each city's rows unsorted by `score` - only a real tiebreak
// fixes that.
try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .int = 30 } } });
try table.put(.{ .int = 2 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "score", .value = .{ .int = 20 } } });
try table.put(.{ .int = 3 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .int = 10 } } });
try table.put(.{ .int = 4 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "score", .value = .{ .int = 40 } } });
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const rows = try client.tableSelect(allocator, "people", "", 0, 0, &.{ .{ .field = "city" }, .{ .field = "score" } });
defer protocol.freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 4), rows.len);
try std.testing.expectEqual(@as(i64, 2), rows[0].get(querymod.pkField).?.int); // krakow/20
try std.testing.expectEqual(@as(i64, 4), rows[1].get(querymod.pkField).?.int); // krakow/40
try std.testing.expectEqual(@as(i64, 3), rows[2].get(querymod.pkField).?.int); // warsaw/10
try std.testing.expectEqual(@as(i64, 1), rows[3].get(querymod.pkField).?.int); // warsaw/30
}
test "server table_select rejects a malformed expr with bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var table = try querymod.Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const err = client.tableSelect(allocator, "users", "city bogus s:warsaw", 0, 0, &.{});
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
fn seedAggregateOrders(allocator: Allocator, db: *Db) !void {
var orders = try querymod.Table.open(db, allocator, "orders");
defer orders.deinit();
try orders.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "total", .value = .{ .int = 100 } },
});
try orders.put(.{ .int = 2 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "total", .value = .{ .int = 250 } },
});
try orders.put(.{ .int = 3 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "total", .value = .{ .int = 40 } },
});
}
test "server table_aggregate groups remote rows by field with count/sum, matching the local library result" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
};
const groups = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "", "");
defer protocol.freeAggGroupRows(allocator, groups);
try std.testing.expectEqual(@as(usize, 2), groups.len);
try std.testing.expectEqualStrings("warsaw", groups[0].group.text);
try std.testing.expectEqual(@as(i64, 2), groups[0].values[0].int);
try std.testing.expectEqual(@as(i64, 350), groups[0].values[1].int);
try std.testing.expectEqualStrings("krakow", groups[1].group.text);
try std.testing.expectEqual(@as(i64, 1), groups[1].values[0].int);
try std.testing.expectEqual(@as(i64, 40), groups[1].values[1].int);
}
test "server table_aggregate honors offset/limit to window the result groups server-side" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
var orders = try querymod.Table.open(&db, allocator, "orders");
try orders.put(.{ .int = 4 }, &.{
.{ .name = "city", .value = .{ .text = "gdansk" } },
.{ .name = "total", .value = .{ .int = 5 } },
});
orders.deinit();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{.{ .field = "", .kind = .count }};
// Unpaged baseline: 3 distinct cities in first-seen order (warsaw twice
// seeded first, then krakow, then gdansk added above).
const all = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "", "");
defer protocol.freeAggGroupRows(allocator, all);
try std.testing.expectEqual(@as(usize, 3), all.len);
const paged = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 1, 1, &.{}, "", "");
defer protocol.freeAggGroupRows(allocator, paged);
try std.testing.expectEqual(@as(usize, 1), paged.len);
try std.testing.expectEqualStrings(all[1].group.text, paged[0].group.text);
}
test "server table_aggregate with no groupField runs a single global aggregate and honors the expr filter" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
};
const all = try client.tableAggregateGlobal(allocator, "orders", &specs, "", "");
defer protocol.freeAggValues(allocator, all);
try std.testing.expectEqual(@as(i64, 3), all[0].int);
try std.testing.expectEqual(@as(i64, 390), all[1].int);
const filtered = try client.tableAggregateGlobal(allocator, "orders", &specs, "", "total gt i:50");
defer protocol.freeAggValues(allocator, filtered);
try std.testing.expectEqual(@as(i64, 2), filtered[0].int);
try std.testing.expectEqual(@as(i64, 350), filtered[1].int);
}
test "server table_aggregate applies a HAVING clause to result groups after aggregation, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db); // warsaw: count=2 sum=350, krakow: count=1 sum=40
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
};
// Referencing the printed aggregate label (sum_total): only warsaw's
// group sum clears 100.
const bySum = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "sum_total gt i:100", "");
defer protocol.freeAggGroupRows(allocator, bySum);
try std.testing.expectEqual(@as(usize, 1), bySum.len);
try std.testing.expectEqualStrings("warsaw", bySum[0].group.text);
// Referencing the group key itself (city), not just an aggregate result.
const byGroup = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "city eq s:krakow", "");
defer protocol.freeAggGroupRows(allocator, byGroup);
try std.testing.expectEqual(@as(usize, 1), byGroup.len);
try std.testing.expectEqualStrings("krakow", byGroup[0].group.text);
// A HAVING clause that excludes every group comes back as zero groups,
// not an error.
const none = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "count gt i:1000", "");
defer protocol.freeAggGroupRows(allocator, none);
try std.testing.expectEqual(@as(usize, 0), none.len);
}
test "server table_aggregate HAVING narrows groups before LIMIT/OFFSET windows them, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
var orders = try querymod.Table.open(&db, allocator, "orders");
try orders.put(.{ .int = 4 }, &.{
.{ .name = "city", .value = .{ .text = "gdansk" } },
.{ .name = "total", .value = .{ .int = 500 } },
});
orders.deinit();
// Groups in first-seen order: warsaw(count=2 sum=350), krakow(count=1
// sum=40), gdansk(count=1 sum=500). HAVING count gt 0 keeps all three,
// but HAVING sum_total gt i:100 keeps only warsaw and gdansk (in that
// order) - a limit of 1 must then return warsaw, not krakow (which
// would be first if the window applied before HAVING).
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
};
const paged = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 1, &.{}, "sum_total gt i:100", "");
defer protocol.freeAggGroupRows(allocator, paged);
try std.testing.expectEqual(@as(usize, 1), paged.len);
try std.testing.expectEqualStrings("warsaw", paged[0].group.text);
const pagedOffset = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 1, 1, &.{}, "sum_total gt i:100", "");
defer protocol.freeAggGroupRows(allocator, pagedOffset);
try std.testing.expectEqual(@as(usize, 1), pagedOffset.len);
try std.testing.expectEqualStrings("gdansk", pagedOffset[0].group.text);
}
test "server table_aggregate HAVING on the global (no groupField) path excludes the single row without erroring, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db); // global: count=3 sum=390
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
};
const passing = try client.tableAggregateGlobal(allocator, "orders", &specs, "sum_total gt i:100", "");
defer protocol.freeAggValues(allocator, passing);
try std.testing.expectEqual(@as(usize, 2), passing.len);
try std.testing.expectEqual(@as(i64, 390), passing[1].int);
const excluded = try client.tableAggregateGlobal(allocator, "orders", &specs, "sum_total gt i:100000", "");
defer protocol.freeAggValues(allocator, excluded);
try std.testing.expectEqual(@as(usize, 0), excluded.len);
}
test "server table_aggregate reports a malformed HAVING expr as bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{.{ .field = "", .kind = .count }};
const err = client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "count bogus i:1", "");
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
test "server table_aggregate count_distinct collapses repeated field values, locally and globally, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var orders = try querymod.Table.open(&db, allocator, "orders");
defer orders.deinit();
// Both warsaw rows share the same region on purpose - the whole
// point of this test is that COUNT(DISTINCT region) collapses them
// to 1, unlike plain COUNT which would report 2.
try orders.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "region", .value = .{ .text = "mazovia" } },
});
try orders.put(.{ .int = 2 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "region", .value = .{ .text = "mazovia" } },
});
try orders.put(.{ .int = 3 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "region", .value = .{ .text = "malopolska" } },
});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "region", .kind = .count_distinct },
};
const groups = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "", "");
defer protocol.freeAggGroupRows(allocator, groups);
try std.testing.expectEqual(@as(usize, 2), groups.len);
try std.testing.expectEqualStrings("warsaw", groups[0].group.text);
try std.testing.expectEqual(@as(i64, 2), groups[0].values[0].int); // COUNT(*)
try std.testing.expectEqual(@as(i64, 1), groups[0].values[1].int); // COUNT(DISTINCT region)
try std.testing.expectEqualStrings("krakow", groups[1].group.text);
try std.testing.expectEqual(@as(i64, 1), groups[1].values[0].int);
try std.testing.expectEqual(@as(i64, 1), groups[1].values[1].int);
const globalSpecs = [_]aggregatemod.AggSpec{.{ .field = "region", .kind = .count_distinct }};
const global = try client.tableAggregateGlobal(allocator, "orders", &globalSpecs, "", "");
defer protocol.freeAggValues(allocator, global);
try std.testing.expectEqual(@as(i64, 2), global[0].int); // mazovia + malopolska
}
test "server table_aggregate reports a non-numeric sum field as bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{.{ .field = "city", .kind = .sum }};
const err = client.tableAggregateGlobal(allocator, "orders", &specs, "", "");
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
test "server table_window annotates remote rows with row_number, partition-then-order matching the local library result" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const partitionBy = [_][]const u8{"city"};
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "", .kind = .row_number }};
const results = try client.tableWindow(allocator, "orders", &specs, &partitionBy, &orderBy, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, results);
// First-seen partition order (warsaw at pk1, krakow at pk3), sorted by
// total within each partition: warsaw 100 then 250, krakow 40 alone.
try std.testing.expectEqual(@as(usize, 3), results.len);
try std.testing.expectEqualStrings("warsaw", results[0].row.get("city").?.text);
try std.testing.expectEqual(@as(i64, 100), results[0].row.get("total").?.int);
try std.testing.expectEqual(@as(i64, 1), results[0].values[0].int);
try std.testing.expectEqualStrings("warsaw", results[1].row.get("city").?.text);
try std.testing.expectEqual(@as(i64, 250), results[1].row.get("total").?.int);
try std.testing.expectEqual(@as(i64, 2), results[1].values[0].int);
try std.testing.expectEqualStrings("krakow", results[2].row.get("city").?.text);
try std.testing.expectEqual(@as(i64, 1), results[2].values[0].int);
}
test "server table_window honors offset/limit to page rows in partition-then-order display order, not raw scan order" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const partitionBy = [_][]const u8{"city"};
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "", .kind = .row_number }};
const all = try client.tableWindow(allocator, "orders", &specs, &partitionBy, &orderBy, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, all);
try std.testing.expectEqual(@as(usize, 3), all.len);
const paged = try client.tableWindow(allocator, "orders", &specs, &partitionBy, &orderBy, 1, 1, "", .rows);
defer protocol.freeWindowRows(allocator, paged);
try std.testing.expectEqual(@as(usize, 1), paged.len);
try std.testing.expectEqual(all[1].values[0].int, paged[0].values[0].int);
try std.testing.expectEqual(all[1].row.get("total").?.int, paged[0].row.get("total").?.int);
}
test "server table_window with MULTIPLE specs returns one WindowRow.values entry per spec, in request order, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const partitionBy = [_][]const u8{"city"};
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{
.{ .field = "", .kind = .row_number },
.{ .field = "total", .kind = .sum },
};
const results = try client.tableWindow(allocator, "orders", &specs, &partitionBy, &orderBy, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, results);
try std.testing.expectEqual(@as(usize, 3), results.len);
try std.testing.expectEqual(@as(usize, 2), results[0].values.len);
// warsaw: total 100 then 250 (running sum), krakow: total 40 alone.
try std.testing.expectEqualStrings("warsaw", results[0].row.get("city").?.text);
try std.testing.expectEqual(@as(i64, 1), results[0].values[0].int);
try std.testing.expectEqual(@as(i64, 100), results[0].values[1].int);
try std.testing.expectEqualStrings("warsaw", results[1].row.get("city").?.text);
try std.testing.expectEqual(@as(i64, 2), results[1].values[0].int);
try std.testing.expectEqual(@as(i64, 350), results[1].values[1].int);
try std.testing.expectEqualStrings("krakow", results[2].row.get("city").?.text);
try std.testing.expectEqual(@as(i64, 1), results[2].values[0].int);
try std.testing.expectEqual(@as(i64, 40), results[2].values[1].int);
}
test "server table_window with a partition but no order runs a whole-partition sum, matching sum:field semantics" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const partitionBy = [_][]const u8{"city"};
const specs = [_]windowmod.WindowSpec{.{ .field = "total", .kind = .sum }};
const results = try client.tableWindow(allocator, "orders", &specs, &partitionBy, &.{}, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, results);
try std.testing.expectEqual(@as(usize, 3), results.len);
for (results) |wr| {
const expected: i64 = if (std.mem.eql(u8, wr.row.get("city").?.text, "warsaw")) 350 else 40;
try std.testing.expectEqual(expected, wr.values[0].int);
}
}
test "server table_window RANGE frame groups tied rows by day, ROWS gives each row its own running total, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
{
var orders = try querymod.Table.open(&db, allocator, "orders");
defer orders.deinit();
// Two rows tied at day=1 (10, 20), then one row at day=2 (5).
try orders.put(.{ .int = 1 }, &.{
.{ .name = "day", .value = .{ .int = 1 } },
.{ .name = "amount", .value = .{ .int = 10 } },
});
try orders.put(.{ .int = 2 }, &.{
.{ .name = "day", .value = .{ .int = 1 } },
.{ .name = "amount", .value = .{ .int = 20 } },
});
try orders.put(.{ .int = 3 }, &.{
.{ .name = "day", .value = .{ .int = 2 } },
.{ .name = "amount", .value = .{ .int = 5 } },
});
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const orderBy = [_]querymod.OrderBy{.{ .field = "day", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "amount", .kind = .sum }};
const rangeResults = try client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .range);
defer protocol.freeWindowRows(allocator, rangeResults);
try std.testing.expectEqual(@as(usize, 3), rangeResults.len);
// Both day=1 rows share the tie group's combined total (10+20=30).
try std.testing.expectEqual(@as(i64, 30), rangeResults[0].values[0].int);
try std.testing.expectEqual(@as(i64, 30), rangeResults[1].values[0].int);
try std.testing.expectEqual(@as(i64, 35), rangeResults[2].values[0].int);
const rowsResults = try client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, rowsResults);
try std.testing.expectEqual(@as(usize, 3), rowsResults.len);
// ROWS: each physical row gets its own running total, even tied ones.
try std.testing.expectEqual(@as(i64, 10), rowsResults[0].values[0].int);
try std.testing.expectEqual(@as(i64, 30), rowsResults[1].values[0].int);
try std.testing.expectEqual(@as(i64, 35), rowsResults[2].values[0].int);
}
test "server table_window rejects a RANGE frame combined with row_number as bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "", .kind = .row_number }};
const err = client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .range);
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
test "server table_window lag/lead read the adjacent row's value along order, NULL at the sequence edge, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{
.{ .field = "total", .kind = .lag },
.{ .field = "total", .kind = .lead },
};
const results = try client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, results);
// Sorted by total ascending: krakow/40, warsaw/100, warsaw/250.
try std.testing.expectEqual(@as(usize, 3), results.len);
try std.testing.expectEqual(@as(i64, 40), results[0].row.get("total").?.int);
try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, results[0].values[0]));
try std.testing.expectEqual(@as(i64, 100), results[0].values[1].int);
try std.testing.expectEqual(@as(i64, 100), results[1].row.get("total").?.int);
try std.testing.expectEqual(@as(i64, 40), results[1].values[0].int);
try std.testing.expectEqual(@as(i64, 250), results[1].values[1].int);
try std.testing.expectEqual(@as(i64, 250), results[2].row.get("total").?.int);
try std.testing.expectEqual(@as(i64, 100), results[2].values[0].int);
try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, results[2].values[1]));
}
test "server table_window lag with an explicit offset and a caller-supplied default, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "total", .kind = .lag, .offset = 2, .default = .{ .int = -1 } }};
const results = try client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, results);
// Sorted: krakow/40, warsaw/100, warsaw/250 - lag offset 2 reaches back
// two physical positions, so only the LAST row has a real source (40);
// the first two fall outside the sequence and get the -1 default.
try std.testing.expectEqual(@as(i64, -1), results[0].values[0].int);
try std.testing.expectEqual(@as(i64, -1), results[1].values[0].int);
try std.testing.expectEqual(@as(i64, 40), results[2].values[0].int);
}
test "server table_window rejects a RANGE frame combined with lag as bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "total", .kind = .lead }};
const err = client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .range);
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
test "server table_window first_value/last_value read the current RANGE tie group's edges, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{
.{ .field = "total", .kind = .first_value },
.{ .field = "total", .kind = .last_value },
};
const results = try client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .range);
defer protocol.freeWindowRows(allocator, results);
// Sorted by total ascending: krakow/40, warsaw/100, warsaw/250 - no ties,
// so RANGE reduces to one tie group per row (same as ROWS): first_value
// is always the partition's first row (40, frame start is unaffected by
// ties), but last_value is each row's OWN value since it is its own
// singleton tie group.
try std.testing.expectEqual(@as(usize, 3), results.len);
for (results) |r| try std.testing.expectEqual(@as(i64, 40), r.values[0].int);
try std.testing.expectEqual(@as(i64, 40), results[0].values[1].int);
try std.testing.expectEqual(@as(i64, 100), results[1].values[1].int);
try std.testing.expectEqual(@as(i64, 250), results[2].values[1].int);
}
test "server table_window nth_value reads a fixed frame position, default at the sequence edge, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "total", .kind = .nth_value, .offset = 2, .default = .{ .int = -1 } }};
const results = try client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, results);
// Sorted: krakow/40, warsaw/100, warsaw/250. Position 2 (n=2) needs a
// frame of at least size 2 - the first row's ROWS frame is still just
// itself (size 1), so it falls back to the caller's default (-1); the
// remaining two rows both see position 2's value, 100.
try std.testing.expectEqual(@as(usize, 3), results.len);
try std.testing.expectEqual(@as(i64, -1), results[0].values[0].int);
try std.testing.expectEqual(@as(i64, 100), results[1].values[0].int);
try std.testing.expectEqual(@as(i64, 100), results[2].values[0].int);
}
test "server table_window count_distinct runs a distinct-city total over real TCP, matching local semantics" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
// Sorted by total ascending: krakow/40, warsaw/100, warsaw/250 - distinct
// "city" running count grows 1 (krakow), 2 (warsaw joins), then stays 2
// (warsaw repeats).
const orderBy = [_]querymod.OrderBy{.{ .field = "total", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "city", .kind = .count_distinct }};
const results = try client.tableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .rows);
defer protocol.freeWindowRows(allocator, results);
try std.testing.expectEqual(@as(usize, 3), results.len);
try std.testing.expectEqual(@as(i64, 1), results[0].values[0].int);
try std.testing.expectEqual(@as(i64, 2), results[1].values[0].int);
try std.testing.expectEqual(@as(i64, 2), results[2].values[0].int);
}
test "server table_window applies the expr filter before annotating, over real TCP" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]windowmod.WindowSpec{.{ .field = "", .kind = .count }};
const results = try client.tableWindow(allocator, "orders", &specs, &.{}, &.{}, 0, 0, "city eq s:warsaw", .rows);
defer protocol.freeWindowRows(allocator, results);
try std.testing.expectEqual(@as(usize, 2), results.len);
for (results) |wr| try std.testing.expectEqual(@as(i64, 2), wr.values[0].int);
}
test "server table_window reports a malformed expr as bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]windowmod.WindowSpec{.{ .field = "", .kind = .count }};
const err = client.tableWindow(allocator, "orders", &specs, &.{}, &.{}, 0, 0, "city bogus s:warsaw", .rows);
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
test "server table_window reports a non-numeric sum field as bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]windowmod.WindowSpec{.{ .field = "city", .kind = .sum }};
const err = client.tableWindow(allocator, "orders", &specs, &.{}, &.{}, 0, 0, "", .rows);
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
test "server table_window reports a sum spec with no field as bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedAggregateOrders(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
// The CLI's `parseWindowSpec` would reject this before ever sending it,
// but nothing on the wire itself stops a client from asking for it -
// `window.computeMany`'s `MissingField` must come back as
// `bad_request`, not an internal server error.
const specs = [_]windowmod.WindowSpec{.{ .field = "", .kind = .sum }};
const err = client.tableWindow(allocator, "orders", &specs, &.{}, &.{}, 0, 0, "", .rows);
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
// `table_aggregate` shares the exact same underlying-sstable-reshaping
// hazard as `table_select`/`table_join` above (see the `table_select`
// concurrency test's header comment): `flush`/`compact` swap `Db.sstables`
// out from under any in-flight reader while `Server.dispatch`'s `self.mutex`
// is what keeps an aggregate's full-table scan (and the grouping/windowing
// on top of it in `aggregate.zig`) from tearing. Thirty rows split evenly
// three ways by `@mod(pk, 3)` so every group lands on an independently
// checkable count and sum: pk%3==0 -> "warsaw" (pks 3,6,..,30, count 10, sum
// 165), pk%3==1 -> "krakow" (pks 1,4,..,28, count 10, sum 145), pk%3==2 ->
// "gdansk" (pks 2,5,..,29, count 10, sum 155) - global count 30, sum 465.
test "table_aggregate survives concurrent flush and compact reshaping the underlying sstables" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
// Small target size so a handful of `flush`/`compact` calls do real
// structural work instead of needing megabytes of data first - same
// trick as the `table_select`/`table_join` concurrency tests above.
var db = try Db.open(allocator, path, .{ .sstableTargetBytes = 512 });
defer db.close();
const rowCount = 30;
{
var orders = try querymod.Table.open(&db, allocator, "orders");
defer orders.deinit();
var pk: i64 = 1;
while (pk <= rowCount) : (pk += 1) {
const city: []const u8 = switch (@mod(pk, 3)) {
0 => "warsaw",
1 => "krakow",
else => "gdansk",
};
try orders.put(.{ .int = pk }, &.{
.{ .name = "city", .value = .{ .text = city } },
.{ .name = "total", .value = .{ .int = pk } },
});
}
try db.flush();
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
const readerThreads = 4;
const readerIters = 20;
const mutatorThreads = 2;
const mutatorIters = 15;
const expectedCount = struct {
fn get(city: []const u8) i64 {
return if (std.mem.eql(u8, city, "warsaw") or std.mem.eql(u8, city, "krakow") or std.mem.eql(u8, city, "gdansk")) 10 else -1;
}
}.get;
const expectedSum = struct {
fn get(city: []const u8) i64 {
if (std.mem.eql(u8, city, "warsaw")) return 165;
if (std.mem.eql(u8, city, "krakow")) return 145;
if (std.mem.eql(u8, city, "gdansk")) return 155;
return -1;
}
}.get;
const ReaderCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
const readerWorker = struct {
fn run(ctx: *ReaderCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < readerIters) : (i += 1) {
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
};
const groups = client.tableAggregateGroups(ctx.allocator, "orders", "city", &specs, 0, 0, &.{}, "", "") catch return;
defer protocol.freeAggGroupRows(ctx.allocator, groups);
if (groups.len != 3) return;
for (groups) |g| {
if (g.values[0].int != expectedCount(g.group.text)) return;
if (g.values[1].int != expectedSum(g.group.text)) return;
}
const global = client.tableAggregateGlobal(ctx.allocator, "orders", &specs, "", "") catch return;
defer protocol.freeAggValues(ctx.allocator, global);
if (global[0].int != rowCount) return;
if (global[1].int != 465) return;
}
ctx.ok = true;
}
}.run;
const MutatorCtx = struct {
allocator: Allocator,
addr: std.net.Address,
thread: usize,
ok: bool = false,
};
const mutatorWorker = struct {
fn run(ctx: *MutatorCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < mutatorIters) : (i += 1) {
var buf: [32]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "agg-churn-{d}-{d}", .{ ctx.thread, i }) catch return;
client.set(key, "x") catch return;
client.flush() catch return;
client.compact() catch return;
}
ctx.ok = true;
}
}.run;
var readerContexts: [readerThreads]ReaderCtx = undefined;
var readerHandles: [readerThreads]std.Thread = undefined;
for (0..readerThreads) |i| {
readerContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress() };
readerHandles[i] = try std.Thread.spawn(.{}, readerWorker, .{&readerContexts[i]});
}
var mutatorContexts: [mutatorThreads]MutatorCtx = undefined;
var mutatorHandles: [mutatorThreads]std.Thread = undefined;
for (0..mutatorThreads) |i| {
mutatorContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress(), .thread = i };
mutatorHandles[i] = try std.Thread.spawn(.{}, mutatorWorker, .{&mutatorContexts[i]});
}
for (0..readerThreads) |i| readerHandles[i].join();
for (0..mutatorThreads) |i| mutatorHandles[i].join();
for (0..readerThreads) |i| try std.testing.expect(readerContexts[i].ok);
for (0..mutatorThreads) |i| try std.testing.expect(mutatorContexts[i].ok);
// Final sanity check on a fresh connection: after all the concurrent
// churn, the aggregate results are still exactly what they were before
// the server ever started - the churn wrote only unrelated
// `agg-churn-*` keys and reshaped physical sstable layout, never touched
// `orders` values.
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const specs = [_]aggregatemod.AggSpec{.{ .field = "", .kind = .count }};
const finalGroups = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "", "");
defer protocol.freeAggGroupRows(allocator, finalGroups);
try std.testing.expectEqual(@as(usize, 3), finalGroups.len);
for (finalGroups) |g| try std.testing.expectEqual(expectedCount(g.group.text), g.values[0].int);
}
fn seedJoinTables(allocator: Allocator, db: *Db) !void {
var customers = try querymod.Table.open(db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "alice" } }});
try customers.put(.{ .int = 2 }, &.{.{ .name = "name", .value = .{ .text = "bob" } }});
try customers.put(.{ .int = 3 }, &.{.{ .name = "name", .value = .{ .text = "carol" } }});
var orders = try querymod.Table.open(db, allocator, "orders");
defer orders.deinit();
try orders.put(.{ .int = 101 }, &.{
.{ .name = "customer_id", .value = .{ .int = 1 } },
.{ .name = "total", .value = .{ .int = 50 } },
});
try orders.put(.{ .int = 102 }, &.{
.{ .name = "customer_id", .value = .{ .int = 1 } },
.{ .name = "total", .value = .{ .int = 30 } },
});
try orders.put(.{ .int = 103 }, &.{
.{ .name = "customer_id", .value = .{ .int = 999 } },
.{ .name = "total", .value = .{ .int = 999 } },
});
}
test "server table_join runs a remote inner join, using an index when available" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedJoinTables(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const noIndex = try client.tableJoin(allocator, .inner, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "");
defer protocol.freeJoinPairs(allocator, noIndex);
try std.testing.expectEqual(@as(usize, 2), noIndex.len);
for (noIndex) |p| {
try std.testing.expect(p.left != null);
try std.testing.expect(p.right != null);
try std.testing.expectEqualStrings("alice", p.left.?.get("name").?.text);
}
{
var orders = try querymod.Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createIndex("customer_id");
}
const withIndex = try client.tableJoin(allocator, .inner, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "");
defer protocol.freeJoinPairs(allocator, withIndex);
try std.testing.expectEqual(@as(usize, 2), withIndex.len);
}
test "server table_join runs left, right, and full outer variants with predicates" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedJoinTables(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const left = try client.tableJoin(allocator, .left, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "");
defer protocol.freeJoinPairs(allocator, left);
try std.testing.expectEqual(@as(usize, 4), left.len);
var carolUnmatched = false;
for (left) |p| {
if (p.left) |l| {
if (std.mem.eql(u8, l.get("name").?.text, "carol")) {
try std.testing.expect(p.right == null);
carolUnmatched = true;
}
}
}
try std.testing.expect(carolUnmatched);
const right = try client.tableJoin(allocator, .right, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "");
defer protocol.freeJoinPairs(allocator, right);
try std.testing.expectEqual(@as(usize, 3), right.len);
var orphanOrderUnmatched = false;
for (right) |p| {
if (p.left == null) orphanOrderUnmatched = true;
}
try std.testing.expect(orphanOrderUnmatched);
const full = try client.tableJoin(allocator, .full, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "");
defer protocol.freeJoinPairs(allocator, full);
try std.testing.expectEqual(@as(usize, 5), full.len);
// rightExprText filters the orders side before the join runs, same as
// `innerJoin`'s `rightPred` - narrowing to `total gt i:40` drops one of
// alice's two orders.
const filtered = try client.tableJoin(allocator, .inner, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "total gt i:40");
defer protocol.freeJoinPairs(allocator, filtered);
try std.testing.expectEqual(@as(usize, 1), filtered.len);
try std.testing.expectEqual(@as(i64, 50), filtered[0].right.?.get("total").?.int);
}
test "server table_join windows a real TCP result through offset and limit" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedJoinTables(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
// Unpaged left outer join order: (alice,101) (alice,102) (bob,nil) (carol,nil).
const unpaged = try client.tableJoin(allocator, .left, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "");
defer protocol.freeJoinPairs(allocator, unpaged);
try std.testing.expectEqual(@as(usize, 4), unpaged.len);
const windowed = try client.tableJoin(allocator, .left, "customers", querymod.pkField, "orders", "customer_id", 1, 2, "", "");
defer protocol.freeJoinPairs(allocator, windowed);
try std.testing.expectEqual(@as(usize, 2), windowed.len);
try std.testing.expectEqual(@as(i64, 30), windowed[0].right.?.get("total").?.int);
try std.testing.expectEqualStrings("bob", windowed[1].left.?.get("name").?.text);
try std.testing.expect(windowed[1].right == null);
const pastEnd = try client.tableJoin(allocator, .left, "customers", querymod.pkField, "orders", "customer_id", 50, 0, "", "");
defer protocol.freeJoinPairs(allocator, pastEnd);
try std.testing.expectEqual(@as(usize, 0), pastEnd.len);
}
test "server table_join rejects a malformed expr with bad_request, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
try seedJoinTables(allocator, &db);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const err = client.tableJoin(allocator, .inner, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "name bogus s:alice", "");
try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err);
}
// `table_select` is the only networked table opcode - rows can only be
// written locally (CLI `table put`), never over the wire - so this cannot
// reuse the coll_op "many writers, one shared counter" shape. What it CAN
// share with a live server is the sstable storage underneath: `flush` and
// `compact` (both real networked opcodes) mutate `Db.sstables` in place,
// closing and destroying old `Reader`s and swapping in new ones while
// `compact` in particular collapses the whole list down to a single file.
// A `table_select` cursor concurrently mid-scan over those same readers is
// exactly the use-after-free/torn-read shape a missing lock around
// `Server.dispatch` would produce - `self.mutex` must serialize every
// request regardless of which opcode it is, not just same-opcode traffic.
test "table_select survives concurrent flush and compact reshaping the underlying sstables" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
// A small target size so a handful of `flush`/`compact` calls do real
// structural work instead of needing megabytes of data first.
var db = try Db.open(allocator, path, .{ .sstableTargetBytes = 512 });
defer db.close();
const rowCount = 30;
{
var table = try querymod.Table.open(&db, allocator, "people");
defer table.deinit();
var pk: i64 = 1;
while (pk <= rowCount) : (pk += 1) {
const city: []const u8 = switch (@mod(pk, 3)) {
0 => "warsaw",
1 => "krakow",
else => "gdansk",
};
try table.put(.{ .int = pk }, &.{
.{ .name = "city", .value = .{ .text = city } },
.{ .name = "age", .value = .{ .int = pk * 2 } },
});
}
try db.flush();
}
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
const readerThreads = 4;
const readerIters = 40;
const mutatorThreads = 2;
const mutatorIters = 15;
const ReaderCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
// A multi-element order-by (city ascending, then age descending as a
// tiebreak within each city) - `handleTableSelect` decodes a wire
// orderBy count > 1 into `Table.selectPageOrdered`, which cannot
// early-stop mid-scan the way an unordered `selectPage` can, so it reads
// more of the table before sorting. This proves that path survives real
// `flush`/`compact` reshaping the underlying sstables mid-scan, not just
// the single-column order-by already covered by
// "orders a remote result by a field before windowing it" above.
const cityOrderBy = [_]querymod.OrderBy{
.{ .field = "city" },
.{ .field = "age", .descending = true },
};
const readerWorker = struct {
fn run(ctx: *ReaderCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < readerIters) : (i += 1) {
const warsawRows = client.tableSelect(ctx.allocator, "people", "city eq s:warsaw", 0, 0, &.{}) catch return;
defer protocol.freeRows(ctx.allocator, warsawRows);
if (warsawRows.len != rowCount / 3) return;
const allRows = client.tableSelect(ctx.allocator, "people", "", 0, 0, &.{}) catch return;
defer protocol.freeRows(ctx.allocator, allRows);
if (allRows.len != rowCount) return;
for (allRows) |row| {
const pk = row.get(querymod.pkField) orelse return;
const age = row.get("age") orelse return;
if (age.int != pk.int * 2) return;
}
const ordered = client.tableSelect(ctx.allocator, "people", "", 0, 0, &cityOrderBy) catch return;
defer protocol.freeRows(ctx.allocator, ordered);
if (ordered.len != rowCount) return;
var prevCity: ?[]const u8 = null;
var prevAge: i64 = std.math.maxInt(i64);
for (ordered) |row| {
const city = (row.get("city") orelse return).text;
const age = (row.get("age") orelse return).int;
if (prevCity) |pc| {
const cmp = std.mem.order(u8, pc, city);
if (cmp == .gt) return;
if (cmp == .eq and age > prevAge) return;
if (cmp != .eq) prevAge = std.math.maxInt(i64);
}
prevCity = city;
prevAge = age;
}
}
ctx.ok = true;
}
}.run;
const MutatorCtx = struct {
allocator: Allocator,
addr: std.net.Address,
thread: usize,
ok: bool = false,
};
const mutatorWorker = struct {
fn run(ctx: *MutatorCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < mutatorIters) : (i += 1) {
var buf: [32]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "churn-{d}-{d}", .{ ctx.thread, i }) catch return;
client.set(key, "x") catch return;
client.flush() catch return;
client.compact() catch return;
}
ctx.ok = true;
}
}.run;
var readerContexts: [readerThreads]ReaderCtx = undefined;
var readerHandles: [readerThreads]std.Thread = undefined;
for (0..readerThreads) |i| {
readerContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress() };
readerHandles[i] = try std.Thread.spawn(.{}, readerWorker, .{&readerContexts[i]});
}
var mutatorContexts: [mutatorThreads]MutatorCtx = undefined;
var mutatorHandles: [mutatorThreads]std.Thread = undefined;
for (0..mutatorThreads) |i| {
mutatorContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress(), .thread = i };
mutatorHandles[i] = try std.Thread.spawn(.{}, mutatorWorker, .{&mutatorContexts[i]});
}
for (0..readerThreads) |i| readerHandles[i].join();
for (0..mutatorThreads) |i| mutatorHandles[i].join();
for (0..readerThreads) |i| try std.testing.expect(readerContexts[i].ok);
for (0..mutatorThreads) |i| try std.testing.expect(mutatorContexts[i].ok);
// Final sanity check on a fresh connection: after all the concurrent
// churn, the table data itself must be byte-for-byte what was written
// before the server ever started - `flush`/`compact` change physical
// layout, never live values.
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const finalAll = try client.tableSelect(allocator, "people", "", 0, 0, &.{});
defer protocol.freeRows(allocator, finalAll);
try std.testing.expectEqual(@as(usize, rowCount), finalAll.len);
const finalWarsaw = try client.tableSelect(allocator, "people", "city eq s:warsaw", 0, 0, &.{});
defer protocol.freeRows(allocator, finalWarsaw);
try std.testing.expectEqual(@as(usize, rowCount / 3), finalWarsaw.len);
// Precise check of the multi-column order-by: city groups sorted
// alphabetically (gdansk < krakow < warsaw), each group internally by
// age (== pk*2) descending. pk%3 maps 2->gdansk, 1->krakow, 0->warsaw,
// so each group's highest pk (== highest age) leads that group.
const finalOrdered = try client.tableSelect(allocator, "people", "", 0, 0, &cityOrderBy);
defer protocol.freeRows(allocator, finalOrdered);
try std.testing.expectEqual(@as(usize, rowCount), finalOrdered.len);
try std.testing.expectEqualStrings("gdansk", finalOrdered[0].get("city").?.text);
try std.testing.expectEqual(@as(i64, 29), finalOrdered[0].get(querymod.pkField).?.int);
try std.testing.expectEqualStrings("gdansk", finalOrdered[9].get("city").?.text);
try std.testing.expectEqual(@as(i64, 2), finalOrdered[9].get(querymod.pkField).?.int);
try std.testing.expectEqualStrings("krakow", finalOrdered[10].get("city").?.text);
try std.testing.expectEqual(@as(i64, 28), finalOrdered[10].get(querymod.pkField).?.int);
try std.testing.expectEqualStrings("krakow", finalOrdered[19].get("city").?.text);
try std.testing.expectEqual(@as(i64, 1), finalOrdered[19].get(querymod.pkField).?.int);
try std.testing.expectEqualStrings("warsaw", finalOrdered[20].get("city").?.text);
try std.testing.expectEqual(@as(i64, 30), finalOrdered[20].get(querymod.pkField).?.int);
try std.testing.expectEqualStrings("warsaw", finalOrdered[29].get("city").?.text);
try std.testing.expectEqual(@as(i64, 3), finalOrdered[29].get(querymod.pkField).?.int);
}
// `table_join` shares the exact same underlying-sstable-reshaping hazard as
// `table_select` above (see that test's header comment) - `flush`/`compact`
// swap `Db.sstables` out from under any in-flight reader while
// `Server.dispatch`'s `self.mutex` is what keeps a join's two-table scan
// from tearing. Ten customers / ten orders, deliberately NOT 1:1: two
// customers (9, 10) never appear in `orders`, and two orders (9, 10) carry a
// `customer_id` nobody has, so inner/left/right/full each land on a
// different, independently-checkable pair count instead of degenerating to
// the same number.
fn seedJoinConcurrencyTables(allocator: Allocator, db: *Db) !void {
var customers = try querymod.Table.open(db, allocator, "customers");
defer customers.deinit();
var pk: i64 = 1;
while (pk <= 10) : (pk += 1) {
var buf: [16]u8 = undefined;
const name = try std.fmt.bufPrint(&buf, "cust{d}", .{pk});
try customers.put(.{ .int = pk }, &.{.{ .name = "name", .value = .{ .text = name } }});
}
var orders = try querymod.Table.open(db, allocator, "orders");
defer orders.deinit();
pk = 1;
while (pk <= 8) : (pk += 1) {
try orders.put(.{ .int = pk }, &.{
.{ .name = "customer_id", .value = .{ .int = pk } },
.{ .name = "total", .value = .{ .int = pk * 10 } },
});
}
try orders.put(.{ .int = 9 }, &.{
.{ .name = "customer_id", .value = .{ .int = 999 } },
.{ .name = "total", .value = .{ .int = 900 } },
});
try orders.put(.{ .int = 10 }, &.{
.{ .name = "customer_id", .value = .{ .int = 998 } },
.{ .name = "total", .value = .{ .int = 800 } },
});
}
test "table_join survives concurrent flush and compact reshaping the underlying sstables" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
// Small target size so a handful of `flush`/`compact` calls do real
// structural work instead of needing megabytes of data first - same
// trick as the `table_select` concurrency test above.
var db = try Db.open(allocator, path, .{ .sstableTargetBytes = 512 });
defer db.close();
try seedJoinConcurrencyTables(allocator, &db);
try db.flush();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
const readerThreads = 4;
const readerIters = 20;
const mutatorThreads = 2;
const mutatorIters = 15;
const matchedCount = 8;
const customerCount = 10;
const ReaderCtx = struct {
allocator: Allocator,
addr: std.net.Address,
ok: bool = false,
};
const readerWorker = struct {
fn run(ctx: *ReaderCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < readerIters) : (i += 1) {
const inner = client.tableJoin(ctx.allocator, .inner, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "") catch return;
defer protocol.freeJoinPairs(ctx.allocator, inner);
if (inner.len != matchedCount) return;
for (inner) |pair| {
const left = pair.left orelse return;
const right = pair.right orelse return;
const pk = left.get(querymod.pkField) orelse return;
const total = right.get("total") orelse return;
if (total.int != pk.int * 10) return;
}
const left = client.tableJoin(ctx.allocator, .left, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "") catch return;
defer protocol.freeJoinPairs(ctx.allocator, left);
if (left.len != customerCount) return;
const right = client.tableJoin(ctx.allocator, .right, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "") catch return;
defer protocol.freeJoinPairs(ctx.allocator, right);
if (right.len != matchedCount + 2) return;
const full = client.tableJoin(ctx.allocator, .full, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "") catch return;
defer protocol.freeJoinPairs(ctx.allocator, full);
if (full.len != customerCount + 2) return;
}
ctx.ok = true;
}
}.run;
const MutatorCtx = struct {
allocator: Allocator,
addr: std.net.Address,
thread: usize,
ok: bool = false,
};
const mutatorWorker = struct {
fn run(ctx: *MutatorCtx) void {
var client = client_mod.Client.connect(ctx.allocator, ctx.addr) catch return;
defer client.close();
var i: usize = 0;
while (i < mutatorIters) : (i += 1) {
var buf: [32]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "join-churn-{d}-{d}", .{ ctx.thread, i }) catch return;
client.set(key, "x") catch return;
client.flush() catch return;
client.compact() catch return;
}
ctx.ok = true;
}
}.run;
var readerContexts: [readerThreads]ReaderCtx = undefined;
var readerHandles: [readerThreads]std.Thread = undefined;
for (0..readerThreads) |i| {
readerContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress() };
readerHandles[i] = try std.Thread.spawn(.{}, readerWorker, .{&readerContexts[i]});
}
var mutatorContexts: [mutatorThreads]MutatorCtx = undefined;
var mutatorHandles: [mutatorThreads]std.Thread = undefined;
for (0..mutatorThreads) |i| {
mutatorContexts[i] = .{ .allocator = allocator, .addr = server.boundAddress(), .thread = i };
mutatorHandles[i] = try std.Thread.spawn(.{}, mutatorWorker, .{&mutatorContexts[i]});
}
for (0..readerThreads) |i| readerHandles[i].join();
for (0..mutatorThreads) |i| mutatorHandles[i].join();
for (0..readerThreads) |i| try std.testing.expect(readerContexts[i].ok);
for (0..mutatorThreads) |i| try std.testing.expect(mutatorContexts[i].ok);
// Final sanity check on a fresh connection: all four join shapes are
// still exactly what they were before the server ever started - the
// concurrent churn wrote only unrelated `join-churn-*` keys and
// reshaped physical sstable layout, never touched `customers`/`orders`
// values.
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const finalInner = try client.tableJoin(allocator, .inner, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "");
defer protocol.freeJoinPairs(allocator, finalInner);
try std.testing.expectEqual(@as(usize, matchedCount), finalInner.len);
const finalFull = try client.tableJoin(allocator, .full, "customers", querymod.pkField, "orders", "customer_id", 0, 0, "", "");
defer protocol.freeJoinPairs(allocator, finalFull);
try std.testing.expectEqual(@as(usize, customerCount + 2), finalFull.len);
}
test "server without enableAuth accepts any auth attempt and every op" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.authenticate("nobody", "wrong");
try client.set("k", "v");
}
test "server with auth enabled rejects unauthenticated requests, ping excepted" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const store = authmod.Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readwrite, 16);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enableAuth(store);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.ping();
try std.testing.expectError(client_mod.ClientError.ServerUnauthorized, client.get(allocator, "k"));
try std.testing.expectError(client_mod.ClientError.ServerUnauthorized, client.set("k", "v"));
try std.testing.expectEqual(@as(u64, 2), server.unauthorizedTotal.get());
}
test "server rejects a bad password and accepts the correct one, then serves requests" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const store = authmod.Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readwrite, 16);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enableAuth(store);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try std.testing.expectError(client_mod.ClientError.ServerUnauthorized, client.authenticate("alice", "wrong"));
try client.authenticate("alice", "hunter2");
try client.set("k", "v");
const got = try client.get(allocator, "k");
defer if (got) |g| allocator.free(g);
try std.testing.expectEqualStrings("v", got.?);
}
test "server enforces readonly role: reads succeed, writes are forbidden" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const store = authmod.Store.init(&db);
try store.createUser(allocator, "viewer", "pass", .readonly, 16);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enableAuth(store);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.authenticate("viewer", "pass");
const got = try client.get(allocator, "missing");
try std.testing.expect(got == null);
try std.testing.expectError(client_mod.ClientError.ServerForbidden, client.set("k", "v"));
try std.testing.expectError(client_mod.ClientError.ServerForbidden, client.delete("k"));
try std.testing.expectError(client_mod.ClientError.ServerForbidden, client.compact());
const txnEntries = [_]protocol.TxnOpEntry{
.{ .verb = .put, .table = "accounts", .pk = .{ .int = 1 } },
};
try std.testing.expectError(client_mod.ClientError.ServerForbidden, client.txnOp(&txnEntries));
try std.testing.expectEqual(@as(u64, 4), server.forbiddenTotal.get());
}
test "server without enableSessions returns no token from authenticate" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const store = authmod.Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readwrite, 16);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enableAuth(store);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
try client.authenticate("alice", "hunter2");
try std.testing.expect(client.sessionToken() == null);
}
test "a session token from one connection authenticates a brand new connection without a password" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const store = authmod.Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readwrite, 16);
var sessions = sessionmod.SessionStore.init(allocator);
defer sessions.deinit();
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enableAuth(store);
server.enableSessions(&sessions, 60 * std.time.ns_per_s);
try server.start();
defer server.stop();
const client_mod = @import("client.zig");
var first = try client_mod.Client.connect(allocator, server.boundAddress());
try first.authenticate("alice", "hunter2");
const token = try allocator.dupe(u8, first.sessionToken().?);
defer allocator.free(token);
first.close();
// A fully independent second connection, never given the password.
var second = try client_mod.Client.connect(allocator, server.boundAddress());
defer second.close();
try std.testing.expectError(client_mod.ClientError.ServerUnauthorized, second.get(allocator, "k"));
try second.authenticateWithToken(token);
try second.set("k", "v");
const got = try second.get(allocator, "k");
defer if (got) |g| allocator.free(g);
try std.testing.expectEqualStrings("v", got.?);
}
test "auth_token is rejected as unauthorized when sessions are not enabled" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
var db = try testDb(allocator, path);
defer db.close();
const store = authmod.Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readwrite, 16);
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try Server.init(allocator, &db, address);
server.enableAuth(store);
try server.start();
defer server.stop();