diff --git a/src/server.zig b/src/server.zig index 09c297a..c0677bb 100644 --- a/src/server.zig +++ b/src/server.zig @@ -6440,3 +6440,163 @@ test "enableCollTtlSweep periodically reaps due collections nobody ever touches var client = try client_mod.Client.connect(allocator, server.boundAddress()); defer client.close(); + _ = try collmod.ZSet.add(&db, allocator, "board", "alice", 10); + try client.collTtlExpire(.zset, "board", 0); + + // Long enough for the collection to be due and for at least one sweep + // tick (every 50ms) to run past that point - nobody ever reads "board" + // again, so without the background sweeper it would sit there forever. + std.time.sleep(300 * std.time.ns_per_ms); + + try std.testing.expect(!try collmod.collectionExists(&db, allocator, .zset, "board")); + try std.testing.expect(server.collTtlSweptTotal.get() >= 1); +} + +test "a server with no coll ttl sweep interval never runs the coll ttl sweeper" { + 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 collmod.Set.add(&db, allocator, "tags", "zig"); + try client.collTtlExpire(.set, "tags", 0); + std.time.sleep(100 * std.time.ns_per_ms); + + // Nothing swept it in the background, but the collection IS logically + // due - it is still physically present until something sweeps it. + try std.testing.expectEqual(@as(u64, 0), server.collTtlSweptTotal.get()); + try std.testing.expect(try collmod.collectionExists(&db, allocator, .set, "tags")); +} + +test "a server with the flat ttl sweeper and the coll ttl sweeper enabled at once runs both independently" { + // Every prior session's notes on both sweepers flagged the same gap: + // `enableTtlSweep` and `enableCollTtlSweep` start two structurally + // independent background threads on the same `Server` (separate + // fields, separate `std.Thread.spawn` calls, separate join calls in + // `stop()`), but nothing had ever exercised them running at the same + // time. This proves three things at once: a flat key due for + // expiry and a whole collection due for expiry both get reaped by + // their OWN sweeper without either one starving or double-counting + // the other's target, and `stop()` cleanly joins both sweeper threads + // (the exact bug class caught and fixed in session #28, where a + // missing join on one sweeper thread left it alive past `db.close()` + // and segfaulted the next test). + 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.enableTtlSweep(50 * std.time.ns_per_ms); + server.enableCollTtlSweep(50 * std.time.ns_per_ms); + 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("flatkey", "1"); + try client.ttlExpire("flatkey", 0); + _ = try collmod.Hash.set(&db, allocator, "session:1", "user", "ada"); + try client.collTtlExpire(.hash, "session:1", 0); + + // Poll rather than sleep a single fixed window: two background + // sweeper threads plus the accept/connection threads contending for + // `self.mutex` on a loaded host can occasionally push either sweeper's + // tick past a fixed 300ms, which is not a correctness bug in the + // sweeper - it is exactly the kind of host-load flakiness this + // project's own frostbyte sessions document repeatedly. Waiting up to + // 3s in 50ms increments keeps the assertion meaningful (still fails + // for real if a sweeper never runs) without being a coin flip on a + // busy machine. + var waited: u64 = 0; + while (waited < 3000 and (server.ttlSweptTotal.get() == 0 or server.collTtlSweptTotal.get() == 0)) { + std.time.sleep(50 * std.time.ns_per_ms); + waited += 50; + } + + try std.testing.expectEqual(@as(?[]u8, null), try client.get(allocator, "flatkey")); + try std.testing.expect(!try collmod.collectionExists(&db, allocator, .hash, "session:1")); + try std.testing.expect(server.ttlSweptTotal.get() >= 1); + try std.testing.expect(server.collTtlSweptTotal.get() >= 1); + + // Neither sweeper's counter absorbed the other's reap: exactly one + // flat key was due and exactly one collection was due, so each + // counter reads exactly 1, not a shared/blended total. + try std.testing.expectEqual(@as(u64, 1), server.ttlSweptTotal.get()); + try std.testing.expectEqual(@as(u64, 1), server.collTtlSweptTotal.get()); +} + +test "coll_op over the wire exercises all five collection kinds against a real server" { + 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(); + + // Counter: get on a name nobody ever touched defaults to 0, set + // pins an exact value, incrby accumulates - the same three-step + // shape `collections.test.counter incrBy accumulates` already + // covers locally, now round-tripped over the wire. + { + var got = try client.collOp(allocator, .counter_get, "visits", &.{}); + defer got.deinit(allocator); + try std.testing.expectEqual(@as(i64, 0), got.int); + + var setReply = try client.collOp(allocator, .counter_set, "visits", &[_][]const u8{"10"}); + defer setReply.deinit(allocator); + try std.testing.expect(setReply == .ok); + + var incrReply = try client.collOp(allocator, .counter_incrby, "visits", &[_][]const u8{"5"}); + defer incrReply.deinit(allocator); + try std.testing.expectEqual(@as(i64, 15), incrReply.int); + } + + // Hash: set reports created vs updated as 1/0, get returns the + // value or nil, del removes a field, len/getall reflect what remains. + { + var created = try client.collOp(allocator, .hash_set, "profile", &[_][]const u8{ "name", "ada" }); + defer created.deinit(allocator); + try std.testing.expectEqual(@as(i64, 1), created.int); + + var updated = try client.collOp(allocator, .hash_set, "profile", &[_][]const u8{ "name", "grace" }); + defer updated.deinit(allocator); + try std.testing.expectEqual(@as(i64, 0), updated.int); + + _ = try client.collOp(allocator, .hash_set, "profile", &[_][]const u8{ "age", "36" }); + + var got = try client.collOp(allocator, .hash_get, "profile", &[_][]const u8{"name"}); + defer got.deinit(allocator); + try std.testing.expectEqualStrings("grace", got.bytes); +