feat: flesh out server

This commit is contained in:
dualfroz committed 2026-09-12 18:07:36 +02:00
1 parent 13d3b3e43b
commit 794a2465f5
1 file changed
+161
+161
View File
@@ -7552,3 +7552,164 @@ test "server txn_op rejects a CHECK constraint violation over the wire and commi
try server.start(); try server.start();
defer server.stop(); defer server.stop();
const client_mod = @import("client.zig");
var client = try client_mod.Client.connect(allocator, server.boundAddress());
defer client.close();
const entries = [_]protocol.TxnOpEntry{
.{ .verb = .put, .table = "orders", .pk = .{ .int = 1 }, .fields = &.{.{ .name = "qty", .value = .{ .int = 5 } }} },
.{ .verb = .put, .table = "orders", .pk = .{ .int = 2 }, .fields = &.{.{ .name = "qty", .value = .{ .int = -1 } }} },
};
try std.testing.expectError(client_mod.ClientError.ServerInternal, client.txnOp(&entries));
// Whole transaction is atomic across both entries: the earlier,
// individually-valid row 1 must not have been committed either.
var orders = try querymod.Table.open(&db, allocator, "orders");
defer orders.deinit();
try std.testing.expect((try orders.get(.{ .int = 1 })) == null);
try std.testing.expect((try orders.get(.{ .int = 2 })) == null);
}
// `handleTxnOp` runs every entry through `TableTxn.put`/`TableTxn.delete`
// (see txn_table.zig), which now enforce ON DELETE RESTRICT exactly like
// CHECK/UNIQUE/FOREIGN KEY on put above - never separately wired for this
// opcode either. The interesting case a plain `table_delete` test cannot
// cover: both the child insert AND the restricted parent delete are only
// staged within this ONE `txn_op` batch, never durable on their own -
// `TableTxn.delete`'s RESTRICT check has to see the child through
// `Transaction.scanPrefix`'s same-transaction visibility (see
// txn_table.zig's own unit test for this), not a plain Db scan, or this
// would wrongly let the delete through.
test "server txn_op rejects an ON DELETE RESTRICT violation over the wire, seeing a child staged earlier in the same batch" {
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);
}
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 entries = [_]protocol.TxnOpEntry{
.{ .verb = .put, .table = "orders", .pk = .{ .int = 100 }, .fields = &.{.{ .name = "customer_id", .value = .{ .int = 1 } }} },
.{ .verb = .del, .table = "customers", .pk = .{ .int = 1 } },
};
try std.testing.expectError(client_mod.ClientError.ServerInternal, client.txnOp(&entries));
// Nothing committed: neither the staged order nor the rejected delete.
var orders = try querymod.Table.open(&db, allocator, "orders");
defer orders.deinit();
try std.testing.expect((try orders.get(.{ .int = 100 })) == null);
var customers = try querymod.Table.open(&db, allocator, "customers");
defer customers.deinit();
var stillThere = (try customers.get(.{ .int = 1 })).?;
stillThere.deinit();
}
test "server txn_op rejects an empty operation list with bad_request" {
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 std.testing.expectError(client_mod.ClientError.ServerBadRequest, client.txnOp(&.{}));
}
test "server rejects a malformed txn_op 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();
// A u32 entry count of 1 promises one full entry, but the payload ends
// right after it - not even a verb byte follows.
var payload: [4]u8 = undefined;
std.mem.writeInt(u32, &payload, 1, .little);
try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.txn_op), &payload);
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 - a malformed frame is
// a client mistake, not a fatal error for the server.
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);
}
// Proves the `txn_op` code path (decode -> `Transaction`/`TableTxn` ->
// index maintenance -> commit, all under `Server.dispatch`'s single
// `self.mutex`) holds up under real concurrent OS threads each on their
// own real TCP connection - the same rigor `coll_op`'s/`table_select`'s
// own concurrency tests apply, but for the table+index layer instead of
// the flat keyspace or the Redis-like collections, which is a genuinely
// different code path (this is the first concurrency test to drive
// `TableTxn`/index maintenance through a live socket at all;
// `concurrent.zig`'s own `TableTxn` concurrency test bypasses the network
// entirely via `GuardedDb`). Each worker writes into two disjoint rows per
// round (its own row in `widgets`, indexed by `owner`, and a companion row
// in `tags`) so a lost or duplicated write from bad interleaving would
// show up as a wrong row count or a wrong index membership, not just a
// wrong number.
test "server txn_op survives many real concurrent connections writing disjoint rows across two tables" {
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 widgets = try querymod.Table.open(&db, allocator, "widgets");
defer widgets.deinit();
try widgets.createIndex("owner");
}