317 lines
16 KiB
Zig
317 lines
16 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));
|
|
}
|
|
}
|
|
|