From 11230ca97c8624ad338ea4c3e3101ceb7c7ddcce Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 17:23:22 +0200 Subject: [PATCH] feat: add openTemp to collttl --- src/collttl.zig | 182 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/src/collttl.zig b/src/collttl.zig index 778a719..6473bbc 100644 --- a/src/collttl.zig +++ b/src/collttl.zig @@ -170,3 +170,185 @@ pub fn sweepExpired(db: *Db, allocator: Allocator, nowNanos: i128) !usize { return due.items.len; } +fn openTemp(tmp: *std.testing.TmpDir, allocator: Allocator) !Db { + const dbPath = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dbPath); + return Db.open(allocator, dbPath, .{}); +} + +test "remaining reports noKey for a collection that was never created" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + try std.testing.expectEqual(TtlResult.noKey, try remaining(&db, allocator, .hash, "missing", 0)); +} + +test "remaining reports noExpiry then counted seconds for a real hash" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + _ = try collmod.Hash.set(&db, allocator, "profile", "name", "ada"); + try std.testing.expectEqual(TtlResult.noExpiry, try remaining(&db, allocator, .hash, "profile", 0)); + + try setExpireSeconds(&db, allocator, .hash, "profile", 1000, 30); + const r = try remaining(&db, allocator, .hash, "profile", 1000); + try std.testing.expectEqual(@as(u64, 30), r.seconds); +} + +test "expireIfDue drops every field of a hash, not just one key" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + _ = try collmod.Hash.set(&db, allocator, "session:1", "user", "ada"); + _ = try collmod.Hash.set(&db, allocator, "session:1", "role", "admin"); + try setExpireSeconds(&db, allocator, .hash, "session:1", 0, 5); + + try std.testing.expect(!try expireIfDue(&db, allocator, .hash, "session:1", 4 * ttlmod.secondNanos)); + try std.testing.expectEqual(@as(usize, 2), try collmod.Hash.len(&db, allocator, "session:1")); + + try std.testing.expect(try expireIfDue(&db, allocator, .hash, "session:1", 5 * ttlmod.secondNanos)); + try std.testing.expectEqual(@as(usize, 0), try collmod.Hash.len(&db, allocator, "session:1")); + try std.testing.expect(!try collmod.collectionExists(&db, allocator, .hash, "session:1")); + try std.testing.expectEqual(@as(?i128, null), try getExpiresAt(&db, allocator, .hash, "session:1")); +} + +test "expireIfDueMany reaps only the due names among several, mirroring a Set.intersect call" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + _ = try collmod.Set.add(&db, allocator, "a", "x"); + _ = try collmod.Set.add(&db, allocator, "b", "x"); + _ = try collmod.Set.add(&db, allocator, "c", "x"); + try setExpireSeconds(&db, allocator, .set, "b", 0, 5); + + // Mirrors `coll set inter a b c`: the CLI's blanket lazy check only + // ever covered args[0] ("a"); this call is what now covers args[1..]. + const reaped = try expireIfDueMany(&db, allocator, .set, &.{ "b", "c" }, 5 * ttlmod.secondNanos); + try std.testing.expectEqual(@as(usize, 1), reaped); + + try std.testing.expect(!try collmod.collectionExists(&db, allocator, .set, "b")); + try std.testing.expect(try collmod.collectionExists(&db, allocator, .set, "a")); + try std.testing.expect(try collmod.collectionExists(&db, allocator, .set, "c")); + + // Now Set.intersect(a, c) sees a live pair and finds the real member, + // instead of silently intersecting against a phantom "b" that a + // periodic sweep hadn't reaped yet. + const inter = try collmod.Set.intersect(&db, allocator, &.{ "a", "c" }); + defer { + for (inter) |m| allocator.free(m); + allocator.free(inter); + } + try std.testing.expectEqual(@as(usize, 1), inter.len); + try std.testing.expectEqualStrings("x", inter[0]); +} + +test "expireIfDueMany is a no-op count of zero when nothing is due" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + _ = try collmod.Set.add(&db, allocator, "a", "x"); + _ = try collmod.Set.add(&db, allocator, "b", "x"); + + try std.testing.expectEqual(@as(usize, 0), try expireIfDueMany(&db, allocator, .set, &.{ "a", "b" }, 0)); +} + +test "clearExpire makes a collection immortal again" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + try collmod.List.pushRight(&db, allocator, "queue", "job-1"); + try setExpireSeconds(&db, allocator, .list, "queue", 0, 1); + try clearExpire(&db, allocator, .list, "queue"); + + try std.testing.expectEqual(TtlResult.noExpiry, try remaining(&db, allocator, .list, "queue", 100 * ttlmod.secondNanos)); + try std.testing.expect(!try expireIfDue(&db, allocator, .list, "queue", 100 * ttlmod.secondNanos)); + try std.testing.expectEqual(@as(usize, 1), try collmod.List.len(&db, allocator, "queue")); +} + +test "sweepExpired reaps due collections of every kind without touching live ones" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + _ = try collmod.Counter.incrBy(&db, allocator, "hits", 3); + try setExpireSeconds(&db, allocator, .counter, "hits", 0, 1); + + _ = try collmod.Hash.set(&db, allocator, "profile", "name", "ada"); + try setExpireSeconds(&db, allocator, .hash, "profile", 0, 100); + + try collmod.ZSet.add(&db, allocator, "leaderboard", "alice", 10); + try setExpireSeconds(&db, allocator, .zset, "leaderboard", 0, 1); + + _ = try collmod.Set.add(&db, allocator, "tags", "zig"); + + const reaped = try sweepExpired(&db, allocator, 2 * ttlmod.secondNanos); + try std.testing.expectEqual(@as(usize, 2), reaped); + + try std.testing.expect(!try collmod.collectionExists(&db, allocator, .counter, "hits")); + try std.testing.expect(!try collmod.collectionExists(&db, allocator, .zset, "leaderboard")); + try std.testing.expect(try collmod.collectionExists(&db, allocator, .hash, "profile")); + try std.testing.expect(try collmod.collectionExists(&db, allocator, .set, "tags")); + + // Idempotent: a second sweep at the same instant finds nothing left. + try std.testing.expectEqual(@as(usize, 0), try sweepExpired(&db, allocator, 2 * ttlmod.secondNanos)); +} + +test "collection sweep survives a wal reopen: markers are durable" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + { + var db = try openTemp(&tmp, allocator); + defer db.close(); + _ = try collmod.Hash.set(&db, allocator, "durable", "k", "v"); + try setExpireSeconds(&db, allocator, .hash, "durable", 0, 5); + } + + var db = try openTemp(&tmp, allocator); + defer db.close(); + const r = try remaining(&db, allocator, .hash, "durable", 1 * ttlmod.secondNanos); + try std.testing.expectEqual(@as(u64, 4), r.seconds); + + try std.testing.expectEqual(@as(usize, 1), try sweepExpired(&db, allocator, 10 * ttlmod.secondNanos)); + try std.testing.expect(!try collmod.collectionExists(&db, allocator, .hash, "durable")); +} + +test "markers for the same name under different kinds never collide" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + _ = try collmod.Hash.set(&db, allocator, "shared", "f", "v"); + _ = try collmod.Set.add(&db, allocator, "shared", "m"); + + try setExpireSeconds(&db, allocator, .hash, "shared", 0, 5); + // The set named "shared" must still read as noExpiry - the marker for + // the hash "shared" must not leak across the kind boundary. + try std.testing.expectEqual(TtlResult.noExpiry, try remaining(&db, allocator, .set, "shared", 0)); + + const hashResult = try remaining(&db, allocator, .hash, "shared", 0); + try std.testing.expectEqual(@as(u64, 5), hashResult.seconds); +}