5648 lines
258 KiB
Zig
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(®) 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();
|
|
|