diff --git a/build.zig b/build.zig index 70de7fc..ddbd1f5 100644 --- a/build.zig +++ b/build.zig @@ -32,17 +32,19 @@ pub fn build(b: *std.Build) void { check_step.dependOn(&check_exe.step); // --- Tests --- + const test_filters = b.option([][]const u8, "test-filter", "Filter tests (e.g. -Dtest-filter=string)") orelse &[0][]const u8{}; + const test_mod = b.createModule(.{ - .root_source_file = b.path("src/unit_tests.zig"), + .root_source_file = b.path("src/main.zig"), .target = target, - .optimize = optimize, + .optimize = .Debug, }); test_mod.addImport("zio", zio_mod); const unit_tests = b.addTest(.{ .name = "test-unit", .root_module = test_mod, - .filters = b.args orelse &.{}, + .filters = test_filters, }); const run_unit_tests = b.addRunArtifact(unit_tests); diff --git a/build.zig.zon b/build.zig.zon index 1d2fb49..8f6f0cd 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ // internet connectivity. .dependencies = .{ .zio = .{ - .url = "git+https://github.com/lalinsky/zio?ref=v0.12.0#9f622147033da64b5d51402a1f06fe3e640f4f5c", - .hash = "zio-0.12.0-xHbVVD79GwD7y4FMkB5DroWGWxCELLiOPM3G-tuLGa8C", + .url = "git+https://github.com/lalinsky/zio?ref=v0.14.0#aa860381beb42bc077e24ef304827cbdc23fcef9", + .hash = "zio-0.14.0-xHbVVPxFHgBCop4CvfF4u360kzWlHD4FS8Ud-RIkPcZ7", }, }, .paths = .{ diff --git a/src/aof/aof.zig b/src/aof/aof.zig index 6c8b70d..9c56a7b 100644 --- a/src/aof/aof.zig +++ b/src/aof/aof.zig @@ -1,50 +1,95 @@ const std = @import("std"); -const Server = @import("../server.zig"); const Parser = @import("../parser.zig"); const Command = @import("../parser.zig").Command; const Registry = @import("../commands/registry.zig").CommandRegistry; const Client = @import("../client.zig").Client; const Store = @import("../store.zig").Store; +const resp = @import("../commands/resp.zig"); +const Value = @import("../parser.zig").Value; const Io = std.Io; -const File = Io.File; const Dir = Io.Dir; -const builtin = @import("builtin"); -const posix = std.posix; const Clock = @import("../clock.zig"); - -const DEFAULT_NAME = "test.aof"; - -// TODO: AOF Rewrite -// Get the state of the store at the time of rewrite, and create -// the necessary commands to replicate it. +const Allocator = std.mem.Allocator; pub const Writer = struct { enabled: bool, file_writer: ?Io.File.Writer, + write_buffer: ?[]u8, + io: Io, + filename: []const u8, + allocator: Allocator, + fsync_policy: FsyncPolicy, + last_fsync_ms: i64 = 0, + + pub const FsyncPolicy = enum { always, everysec, no }; - // take path when ready to - pub fn init(io: Io, enabled: bool) !Writer { - var fw: File.Writer = undefined; + pub fn init(alloc: Allocator, io: Io, enabled: bool, filename: []const u8, work_dir: []const u8, buffer_size: usize, fsync_policy: FsyncPolicy) !Writer { + var file_writer: ?Io.File.Writer = null; + var write_buffer: ?[]u8 = null; if (enabled) { - const file = Dir.cwd().openFile(io, DEFAULT_NAME, .{ .mode = .write_only }) catch - try Dir.cwd().createFile(io, DEFAULT_NAME, .{}); - fw = file.writer(io, &.{}); + Dir.cwd().createDir(io, work_dir, .default_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + const dir = try Dir.cwd().openDir(io, work_dir, .{}); + + const file = dir.openFile(io, filename, .{ .mode = .write_only }) catch + try dir.createFile(io, filename, .{}); + const buf = try alloc.alloc(u8, buffer_size); + errdefer alloc.free(buf); + var fw = file.writer(io, buf); const length = try file.length(io); try fw.seekTo(length); + file_writer = fw; + write_buffer = buf; } return .{ .enabled = enabled, - .file_writer = if (enabled) fw else null, + .file_writer = file_writer, + .write_buffer = write_buffer, + .io = io, + .filename = filename, + .allocator = alloc, + .fsync_policy = fsync_policy, }; } - pub fn deinit(self: *Writer, io: Io) void { + pub fn deinit(self: *Writer) void { + if (self.write_buffer) |buf| { + self.allocator.free(buf); + } if (self.file_writer) |fw| { - fw.file.close(io); + fw.file.close(self.io); + } + } + + /// Encode command args as RESP and write to the AOF file. + /// Called outside the store_mutex critical section — zio makes file I/O async, + /// suspending the coroutine instead of blocking the OS thread. + pub fn writeCommand(self: *Writer, args: []const Value) void { + if (!self.enabled) return; + resp.writeListLen(self.writer(), args.len) catch return; + for (args) |arg| { + resp.writeBulkString(self.writer(), arg.asSlice()) catch return; + } + + switch (self.fsync_policy) { + .always => { + _ = self.file_writer.?.interface.flush() catch {}; + self.file_writer.?.file.sync(self.io) catch {}; + }, + .everysec => { + const now_ms = Io.Timestamp.now(self.io, .awake).toMilliseconds(); + if (now_ms - self.last_fsync_ms >= 1000) { + _ = self.file_writer.?.interface.flush() catch {}; + self.file_writer.?.file.sync(self.io) catch {}; + self.last_fsync_ms = now_ms; + } + }, + .no => {}, } } - // only to be called if enabled pub fn writer(self: *Writer) *Io.Writer { return &self.file_writer.?.interface; } @@ -52,15 +97,15 @@ pub const Writer = struct { pub const Reader = struct { file_reader: Io.File.Reader, - allocator: std.mem.Allocator, + allocator: Allocator, store: *Store, registry: *Registry, reader_buffer: [8192]u8 = undefined, io: Io, - // take path when ready to - pub fn init(allocator: std.mem.Allocator, store: *Store, registry: *Registry, io: Io) !Reader { - const file = try Dir.cwd().openFile(io, DEFAULT_NAME, .{ .mode = .read_only }); + pub fn init(allocator: Allocator, store: *Store, registry: *Registry, io: Io, work_dir: []const u8, filename: []const u8) !Reader { + const dir = try Dir.cwd().openDir(io, work_dir, .{}); + const file = try dir.openFile(io, filename, .{ .mode = .read_only }); var result = Reader{ .file_reader = undefined, .allocator = allocator, @@ -150,16 +195,25 @@ test "aof writing test" { var dummy_client: Client = undefined; dummy_client.authenticated = true; + // Execute command (mutates store, no AOF write) + try registry.executeCommand(&discarding.writer, &dummy_client, &store, cmd.getArgs()); + try testing.expect(std.mem.eql(u8, store.get("t").?.value.short_string.asSlice(), "test")); + + // Write AOF separately (simulating what server.writeAof does) var aof_writer: Writer = undefined; - aof_writer.file_writer = test_file.writer(testing.io, &.{}); aof_writer.enabled = true; + aof_writer.file_writer = test_file.writer(testing.io, &.{}); + aof_writer.io = testing.io; + aof_writer.filename = &.{}; + aof_writer.allocator = testing.allocator; + aof_writer.write_buffer = null; + aof_writer.writeCommand(cmd.getArgs()); - try registry.executeCommand(&discarding.writer, &dummy_client, &store, &aof_writer, cmd.getArgs()); + _ = aof_writer.file_writer.?.interface.flush() catch {}; var file_reader_buffer: [8192]u8 = undefined; var file_reader = test_file.reader(testing.io, &file_reader_buffer); - try testing.expect(std.mem.eql(u8, store.get("t").?.value.short_string.asSlice(), "test")); const buf = try testing.allocator.alloc(u8, 1024); defer testing.allocator.free(buf); file_reader.interface.readSliceAll(buf) catch |e| { diff --git a/src/client.zig b/src/client.zig index 81f84eb..917e137 100644 --- a/src/client.zig +++ b/src/client.zig @@ -142,6 +142,10 @@ pub const Client = struct { self.server.processCommandDirect(self, command.getArgs(), &response_writer); self.server.store_mutex.unlock(); + // AOF write after mutex release: zio makes file I/O async, + // suspending this coroutine instead of blocking the OS thread. + self.server.writeAof(command.getArgSlice(0) orelse "", command.getArgs()); + command.deinit(); // Write response directly to socket (no mailbox indirection) diff --git a/src/commands/bloom.zig b/src/commands/bloom.zig index 2482f05..08c2904 100644 --- a/src/commands/bloom.zig +++ b/src/commands/bloom.zig @@ -8,6 +8,7 @@ const Io = std.Io; const Writer = Io.Writer; const ScalableBloomFilter = @import("../bloom/bloom.zig").BloomFilter; +const Clock = @import("../clock.zig"); pub fn bf_reserve(writer: *Writer, store: *Store, args: []const Value) !void { // BF.RESERVE key error_rate capacity [EXPANSION expansion] [NONSCALING] @@ -27,6 +28,7 @@ pub fn bf_reserve(writer: *Writer, store: *Store, args: []const Value) !void { // Parse capacity const capacity = std.fmt.parseInt(u64, capacity_str, 10) catch { + const testing = std.testing; return error.InvalidArgument; }; if (capacity == 0) { @@ -330,3 +332,453 @@ pub fn bf_insert(writer: *Writer, store: *Store, args: []const Value) !void { try resp.writeInt(writer, @as(i64, @intFromBool(added))); } } + +test "BF.RESERVE command with valid parameters" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, // error rate + .{ .data = "1000" }, // capacity + }; + + try bf_reserve(&writer, &store, &args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + // Verify the bloom filter was created + const bf = try store.getBloomFilter("bloom1"); + try testing.expect(bf != null); +} + +test "BF.RESERVE command with invalid error rate too high" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "1.5" }, // invalid error rate > 1.0 + .{ .data = "1000" }, + }; + + try testing.expectError(error.InvalidArgument, bf_reserve(&writer, &store, &args)); +} + +test "BF.RESERVE command with missing arguments" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + // Missing error rate and capacity + }; + + try testing.expectError(error.WrongNumberOfArguments, bf_reserve(&writer, &store, &args)); +} + +test "BF.ADD command with new item" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // First reserve a bloom filter + var buffer1: [4096]u8 = undefined; + var writer1 = Writer.fixed(&buffer1); + + const reserve_args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, + .{ .data = "1000" }, + }; + + try bf_reserve(&writer1, &store, &reserve_args); + + // Now add an item + var buffer2: [4096]u8 = undefined; + var writer2 = Writer.fixed(&buffer2); + + const args = [_]Value{ + .{ .data = "BF.ADD" }, + .{ .data = "bloom1" }, + .{ .data = "item1" }, + }; + + try bf_add(&writer2, &store, &args); + + try testing.expectEqualStrings(":1\r\n", writer2.buffered()); // 1 means newly added +} + +test "BF.ADD command with existing item" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // First reserve a bloom filter + var buffer1: [4096]u8 = undefined; + var writer1 = Writer.fixed(&buffer1); + + const reserve_args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, + .{ .data = "1000" }, + }; + + try bf_reserve(&writer1, &store, &reserve_args); + + // Add an item first + var buffer2: [4096]u8 = undefined; + var writer2 = Writer.fixed(&buffer2); + + const add_args1 = [_]Value{ + .{ .data = "BF.ADD" }, + .{ .data = "bloom1" }, + .{ .data = "item1" }, + }; + + try bf_add(&writer2, &store, &add_args1); + + // Add the same item again + var buffer3: [4096]u8 = undefined; + var writer3 = Writer.fixed(&buffer3); + + const add_args2 = [_]Value{ + .{ .data = "BF.ADD" }, + .{ .data = "bloom1" }, + .{ .data = "item1" }, + }; + + try bf_add(&writer3, &store, &add_args2); + + try testing.expectEqualStrings(":0\r\n", writer3.buffered()); // 0 means already existed +} + +test "BF.EXISTS command with existing item" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // First reserve a bloom filter + var buffer1: [4096]u8 = undefined; + var writer1 = Writer.fixed(&buffer1); + + const reserve_args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, + .{ .data = "1000" }, + }; + + try bf_reserve(&writer1, &store, &reserve_args); + + // Add an item + var buffer2: [4096]u8 = undefined; + var writer2 = Writer.fixed(&buffer2); + + const add_args = [_]Value{ + .{ .data = "BF.ADD" }, + .{ .data = "bloom1" }, + .{ .data = "item1" }, + }; + + try bf_add(&writer2, &store, &add_args); + + // Check if item exists + var buffer3: [4096]u8 = undefined; + var writer3 = Writer.fixed(&buffer3); + + const exists_args = [_]Value{ + .{ .data = "BF.EXISTS" }, + .{ .data = "bloom1" }, + .{ .data = "item1" }, + }; + + try bf_exists(&writer3, &store, &exists_args); + + try testing.expectEqualStrings(":1\r\n", writer3.buffered()); // 1 means may exist +} + +test "BF.EXISTS command with non-existing item" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // First reserve a bloom filter + var buffer1: [4096]u8 = undefined; + var writer1 = Writer.fixed(&buffer1); + + const reserve_args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, + .{ .data = "1000" }, + }; + + try bf_reserve(&writer1, &store, &reserve_args); + + // Check if non-existing item exists + var buffer2: [4096]u8 = undefined; + var writer2 = Writer.fixed(&buffer2); + + const exists_args = [_]Value{ + .{ .data = "BF.EXISTS" }, + .{ .data = "bloom1" }, + .{ .data = "nonexistent" }, + }; + + try bf_exists(&writer2, &store, &exists_args); + + try testing.expectEqualStrings(":0\r\n", writer2.buffered()); // 0 means definitely doesn't exist +} + +test "BF.MADD command with multiple items" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // First reserve a bloom filter + var buffer1: [4096]u8 = undefined; + var writer1 = Writer.fixed(&buffer1); + + const reserve_args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, + .{ .data = "1000" }, + }; + + try bf_reserve(&writer1, &store, &reserve_args); + + // Add multiple items + var buffer2: [4096]u8 = undefined; + var writer2 = Writer.fixed(&buffer2); + + const args = [_]Value{ + .{ .data = "BF.MADD" }, + .{ .data = "bloom1" }, + .{ .data = "item1" }, + .{ .data = "item2" }, + .{ .data = "item3" }, + }; + + try bf_madd(&writer2, &store, &args); + + // Should return array with 3 items, all 1 (newly added) + try testing.expectEqualStrings("*3\r\n:1\r\n:1\r\n:1\r\n", writer2.buffered()); +} + +test "BF.MEXISTS command with multiple items" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // First reserve a bloom filter + var buffer1: [4096]u8 = undefined; + var writer1 = Writer.fixed(&buffer1); + + const reserve_args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, + .{ .data = "1000" }, + }; + + try bf_reserve(&writer1, &store, &reserve_args); + + // Add some items + var buffer2: [4096]u8 = undefined; + var writer2 = Writer.fixed(&buffer2); + + const add_args = [_]Value{ + .{ .data = "BF.ADD" }, + .{ .data = "bloom1" }, + .{ .data = "item1" }, + }; + + try bf_add(&writer2, &store, &add_args); + + // Check multiple items (some existing, some not) + var buffer3: [4096]u8 = undefined; + var writer3 = Writer.fixed(&buffer3); + + const exists_args = [_]Value{ + .{ .data = "BF.MEXISTS" }, + .{ .data = "bloom1" }, + .{ .data = "item1" }, // should exist + .{ .data = "item2" }, // should not exist + }; + + try bf_mexists(&writer3, &store, &exists_args); + + // Should return array with 2 items: 1 (exists), 0 (doesn't exist) + try testing.expectEqualStrings("*2\r\n:1\r\n:0\r\n", writer3.buffered()); +} + +test "BF.INFO command" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // First reserve a bloom filter + var buffer1: [4096]u8 = undefined; + var writer1 = Writer.fixed(&buffer1); + + const reserve_args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, + .{ .data = "1000" }, + }; + + try bf_reserve(&writer1, &store, &reserve_args); + + // Get info + var buffer2: [4096]u8 = undefined; + var writer2 = Writer.fixed(&buffer2); + + const args = [_]Value{ + .{ .data = "BF.INFO" }, + .{ .data = "bloom1" }, + }; + + try bf_info(&writer2, &store, &args); + + // Should return info array with key-value pairs + // Format: *10\r\n$8\r\nCapacity\r\n:[number]\r\n$4\r\nSize\r\n:[number]\r\n... + const result = writer2.buffered(); + try testing.expect(result.len > 0); + try testing.expect(result[0] == '*'); // Starts with array +} + +test "BF.INSERT command with new bloom filter" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "BF.INSERT" }, + .{ .data = "bloom1" }, + .{ .data = "ITEMS" }, + .{ .data = "item1" }, + .{ .data = "item2" }, + }; + + try bf_insert(&writer, &store, &args); + + // Should return array with results for each item + const result = writer.buffered(); + try testing.expect(result.len > 0); + try testing.expect(result[0] == '*'); // Starts with array + + // Verify the bloom filter was created and items were added + const bf = try store.getBloomFilter("bloom1"); + try testing.expect(bf != null); + try testing.expect(bf.?.check("item1")); + try testing.expect(bf.?.check("item2")); +} + +test "BF.INSERT command with existing bloom filter" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // First reserve a bloom filter + var buffer1: [4096]u8 = undefined; + var writer1 = Writer.fixed(&buffer1); + + const reserve_args = [_]Value{ + .{ .data = "BF.RESERVE" }, + .{ .data = "bloom1" }, + .{ .data = "0.01" }, + .{ .data = "1000" }, + }; + + try bf_reserve(&writer1, &store, &reserve_args); + + // Insert items into existing bloom filter + var buffer2: [4096]u8 = undefined; + var writer2 = Writer.fixed(&buffer2); + + const args = [_]Value{ + .{ .data = "BF.INSERT" }, + .{ .data = "bloom1" }, + .{ .data = "ITEMS" }, + .{ .data = "item1" }, + .{ .data = "item2" }, + }; + + try bf_insert(&writer2, &store, &args); + + // Should return array with results for each item + const result = writer2.buffered(); + try testing.expect(result.len > 0); + try testing.expect(result[0] == '*'); // Starts with array +} diff --git a/src/commands/connection.zig b/src/commands/connection.zig index e3137e8..cec59df 100644 --- a/src/commands/connection.zig +++ b/src/commands/connection.zig @@ -14,15 +14,30 @@ const ConfigParameter = struct { }; const config_parameters = [_]ConfigParameter{ + .{ .name = "acllog-max-len" }, + .{ .name = "aof-load-broken" }, + .{ .name = "aof-load-broken-max-size" }, + .{ .name = "aof-load-truncated" }, + .{ .name = "aof-use-rdb-preamble" }, + .{ .name = "aof-write-buffer-size", .mutable = true }, + .{ .name = "appenddirname" }, .{ .name = "appendfilename" }, .{ .name = "appendfsync" }, .{ .name = "appendonly" }, + .{ .name = "auto-aof-rewrite-min-size" }, + .{ .name = "auto-aof-rewrite-percentage" }, .{ .name = "bind" }, .{ .name = "clock-update-ms" }, .{ .name = "daemonize" }, .{ .name = "dbfilename" }, .{ .name = "dir" }, + .{ .name = "disable-thp" }, .{ .name = "initial-capacity" }, + .{ .name = "lazyfree-lazy-eviction" }, + .{ .name = "lazyfree-lazy-expire" }, + .{ .name = "lazyfree-lazy-server-del" }, + .{ .name = "lazyfree-lazy-user-del" }, + .{ .name = "lazyfree-lazy-user-flush" }, .{ .name = "logfile" }, .{ .name = "loglevel" }, .{ .name = "max-channels", .mutable = true }, @@ -31,17 +46,24 @@ const config_parameters = [_]ConfigParameter{ .{ .name = "maxmemory", .aliases = &.{"kv-memory-budget"} }, .{ .name = "maxmemory-policy", .aliases = &.{"eviction-policy"}, .mutable = true }, .{ .name = "maxmemory-samples", .mutable = true }, + .{ .name = "no-appendfsync-on-rewrite" }, + .{ .name = "oom-score-adj" }, .{ .name = "pidfile" }, .{ .name = "port" }, .{ .name = "protected-mode", .mutable = true }, + .{ .name = "rdb-del-sync-files" }, .{ .name = "rdb-write-buffer-size", .mutable = true }, .{ .name = "rdbchecksum", .mutable = true }, .{ .name = "rdbcompression", .mutable = true }, - .{ .name = "replica-read-only", .mutable = true }, - .{ .name = "replica-serve-stale-data", .mutable = true }, + .{ .name = "repl-disable-tcp-nodelay" }, .{ .name = "repl-diskless-load" }, .{ .name = "repl-diskless-sync", .mutable = true }, .{ .name = "repl-diskless-sync-delay", .mutable = true }, + .{ .name = "repl-diskless-sync-max-replicas" }, + .{ .name = "replica-lazy-flush" }, + .{ .name = "replica-priority" }, + .{ .name = "replica-read-only", .mutable = true }, + .{ .name = "replica-serve-stale-data", .mutable = true }, .{ .name = "requirepass", .mutable = true }, .{ .name = "save" }, .{ .name = "stop-writes-on-bgsave-error", .mutable = true }, @@ -51,6 +73,13 @@ const config_parameters = [_]ConfigParameter{ .{ .name = "timeout", .mutable = true }, }; +const Clock = @import("../clock.zig"); +const Store = @import("../store.zig").Store; +const KeyValueAllocator = @import("../kv_allocator.zig"); +const Server = @import("../server.zig"); +const Io = std.Io; +const testing = std.testing; + const ConfigSetError = error{ UnknownParameter, ImmutableParameter, @@ -217,10 +246,14 @@ fn findConfigParameter(name: []const u8) ?ConfigParameter { fn writeConfigValue(client: *Client, writer: *Writer, name: []const u8) !void { const server_config = client.server.config; - if (std.mem.eql(u8, name, "appendfilename")) { + if (std.mem.eql(u8, name, "appenddirname")) { + try resp.writeBulkString(writer, server_config.appenddirname); + } else if (std.mem.eql(u8, name, "appendfilename")) { try resp.writeBulkString(writer, server_config.appendfilename); } else if (std.mem.eql(u8, name, "appendfsync")) { try resp.writeBulkString(writer, server_config.appendfsync); + } else if (std.mem.eql(u8, name, "aof-write-buffer-size")) { + try writeConfigInt(writer, server_config.aof_write_buffer_size); } else if (std.mem.eql(u8, name, "appendonly")) { try writeConfigBool(writer, server_config.appendonly); } else if (std.mem.eql(u8, name, "bind")) { @@ -287,6 +320,46 @@ fn writeConfigValue(client: *Client, writer: *Writer, name: []const u8) !void { try writeConfigInt(writer, server_config.temp_arena_size); } else if (std.mem.eql(u8, name, "timeout")) { try writeConfigInt(writer, server_config.timeout); + } else if (std.mem.eql(u8, name, "acllog-max-len")) { + try writeConfigInt(writer, server_config.acllog_max_len); + } else if (std.mem.eql(u8, name, "aof-load-broken")) { + try writeConfigBool(writer, server_config.aof_load_broken); + } else if (std.mem.eql(u8, name, "aof-load-broken-max-size")) { + try writeConfigInt(writer, server_config.aof_load_broken_max_size); + } else if (std.mem.eql(u8, name, "aof-load-truncated")) { + try writeConfigBool(writer, server_config.aof_load_truncated); + } else if (std.mem.eql(u8, name, "aof-use-rdb-preamble")) { + try writeConfigBool(writer, server_config.aof_use_rdb_preamble); + } else if (std.mem.eql(u8, name, "auto-aof-rewrite-min-size")) { + try resp.writeBulkString(writer, server_config.auto_aof_rewrite_min_size); + } else if (std.mem.eql(u8, name, "auto-aof-rewrite-percentage")) { + try writeConfigInt(writer, server_config.auto_aof_rewrite_percentage); + } else if (std.mem.eql(u8, name, "disable-thp")) { + try writeConfigBool(writer, server_config.disable_thp); + } else if (std.mem.eql(u8, name, "lazyfree-lazy-eviction")) { + try writeConfigBool(writer, server_config.lazyfree_lazy_eviction); + } else if (std.mem.eql(u8, name, "lazyfree-lazy-expire")) { + try writeConfigBool(writer, server_config.lazyfree_lazy_expire); + } else if (std.mem.eql(u8, name, "lazyfree-lazy-server-del")) { + try writeConfigBool(writer, server_config.lazyfree_lazy_server_del); + } else if (std.mem.eql(u8, name, "lazyfree-lazy-user-del")) { + try writeConfigBool(writer, server_config.lazyfree_lazy_user_del); + } else if (std.mem.eql(u8, name, "lazyfree-lazy-user-flush")) { + try writeConfigBool(writer, server_config.lazyfree_lazy_user_flush); + } else if (std.mem.eql(u8, name, "no-appendfsync-on-rewrite")) { + try writeConfigBool(writer, server_config.no_appendfsync_on_rewrite); + } else if (std.mem.eql(u8, name, "oom-score-adj")) { + try writeConfigBool(writer, server_config.oom_score_adj); + } else if (std.mem.eql(u8, name, "rdb-del-sync-files")) { + try writeConfigBool(writer, server_config.rdb_del_sync_files); + } else if (std.mem.eql(u8, name, "repl-disable-tcp-nodelay")) { + try writeConfigBool(writer, server_config.repl_disable_tcp_nodelay); + } else if (std.mem.eql(u8, name, "repl-diskless-sync-max-replicas")) { + try writeConfigInt(writer, server_config.repl_diskless_sync_max_replicas); + } else if (std.mem.eql(u8, name, "replica-lazy-flush")) { + try writeConfigBool(writer, server_config.replica_lazy_flush); + } else if (std.mem.eql(u8, name, "replica-priority")) { + try writeConfigInt(writer, server_config.replica_priority); } else { unreachable; } @@ -296,6 +369,10 @@ fn applyConfigSet(client: *Client, name: []const u8, value: []const u8) ConfigSe const param = findConfigParameter(name) orelse return error.UnknownParameter; if (!param.mutable) return error.ImmutableParameter; + if (std.mem.eql(u8, param.name, "aof-write-buffer-size")) { + client.server.config.aof_write_buffer_size = Config.parseMemorySize(value) catch return error.InvalidValue; + return; + } if (std.mem.eql(u8, param.name, "max-channels")) { client.server.config.max_channels = parseConfigInt(u32, value) catch return error.InvalidValue; return; @@ -433,3 +510,174 @@ pub fn help(writer: *std.Io.Writer, args: []const Value) !void { try resp.writeBulkString(writer, help_text); } + +const TestContext = struct { + allocator: std.mem.Allocator, + clock: Clock, + server: Server, + client: Client, + + fn init(self: *TestContext, allocator: std.mem.Allocator) !void { + self.allocator = allocator; + self.clock = Clock.init(testing.io, 0); + + self.server = undefined; + self.server.config = .{ + .appendonly = false, + .kv_memory_budget = 4096, + .maxmemory_samples = 5, + .eviction_policy = .allkeys_lru, + }; + self.server.store = try Store.init(allocator, testing.io, &self.clock, .{ + .initial_capacity = 16, + .eviction_policy = .allkeys_lru, + .maxmemory_samples = 5, + }); + self.server.kv_allocator = try KeyValueAllocator.init(allocator, 4096, .allkeys_lru); + + self.client = undefined; + self.client.allocator = allocator; + self.client.server = &self.server; + } + + fn deinit(self: *TestContext) void { + self.server.store.deinit(); + if (self.server.config.requirepass) |password| { + self.allocator.free(password); + } + } +}; + +test "CONFIG GET returns exact parameter" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + var ctx: TestContext = undefined; + try ctx.init(arena.allocator()); + defer ctx.deinit(); + + var buffer: [256]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "CONFIG" }, + .{ .data = "GET" }, + .{ .data = "appendonly" }, + }; + + try config(&ctx.client, &args, &writer); + + try testing.expectEqualStrings("*2\r\n$10\r\nappendonly\r\n$2\r\nno\r\n", writer.buffered()); +} + +test "CONFIG GET resolves exact alias to canonical name" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + var ctx: TestContext = undefined; + try ctx.init(arena.allocator()); + defer ctx.deinit(); + + var buffer: [256]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "CONFIG" }, + .{ .data = "GET" }, + .{ .data = "kv-memory-budget" }, + }; + + try config(&ctx.client, &args, &writer); + + try testing.expectEqualStrings("*2\r\n$9\r\nmaxmemory\r\n$4\r\n4096\r\n", writer.buffered()); +} + +test "CONFIG GET supports wildcard patterns" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + var ctx: TestContext = undefined; + try ctx.init(arena.allocator()); + defer ctx.deinit(); + + ctx.server.config.maxmemory_samples = 9; + ctx.server.store.maxmemory_samples = 9; + ctx.server.config.eviction_policy = .volatile_lru; + ctx.server.store.eviction_policy = .volatile_lru; + + var buffer: [512]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "CONFIG" }, + .{ .data = "GET" }, + .{ .data = "maxmemory*" }, + }; + + try config(&ctx.client, &args, &writer); + + try testing.expectEqualStrings( + "*6\r\n" ++ + "$9\r\nmaxmemory\r\n$4\r\n4096\r\n" ++ + "$16\r\nmaxmemory-policy\r\n$12\r\nvolatile-lru\r\n" ++ + "$17\r\nmaxmemory-samples\r\n$1\r\n9\r\n", + writer.buffered(), + ); +} + +test "CONFIG SET updates supported runtime parameters" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + var ctx: TestContext = undefined; + try ctx.init(arena.allocator()); + defer ctx.deinit(); + + var buffer: [256]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "CONFIG" }, + .{ .data = "SET" }, + .{ .data = "maxmemory-samples" }, + .{ .data = "11" }, + .{ .data = "eviction-policy" }, + .{ .data = "noeviction" }, + .{ .data = "requirepass" }, + .{ .data = "secret" }, + }; + + try config(&ctx.client, &args, &writer); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + try testing.expectEqual(@as(u32, 11), ctx.server.config.maxmemory_samples); + try testing.expectEqual(@as(usize, 11), ctx.server.store.maxmemory_samples); + try testing.expectEqual(.noeviction, ctx.server.config.eviction_policy); + try testing.expectEqual(.noeviction, ctx.server.store.eviction_policy); + try testing.expectEqual(.noeviction, ctx.server.kv_allocator.eviction_policy); + try testing.expectEqualStrings("secret", ctx.server.config.requirepass.?); +} + +test "CONFIG SET rejects immutable parameters" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + var ctx: TestContext = undefined; + try ctx.init(arena.allocator()); + defer ctx.deinit(); + + var buffer: [256]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "CONFIG" }, + .{ .data = "SET" }, + .{ .data = "appendonly" }, + .{ .data = "yes" }, + }; + + try config(&ctx.client, &args, &writer); + + try testing.expectEqualStrings("-ERR CONFIG SET does not support runtime updates for 'appendonly'\r\n", writer.buffered()); + try testing.expect(!ctx.server.config.appendonly); +} diff --git a/src/commands/keys.zig b/src/commands/keys.zig index bd6af04..cc01658 100644 --- a/src/commands/keys.zig +++ b/src/commands/keys.zig @@ -6,6 +6,10 @@ const Value = @import("../parser.zig").Value; const resp = @import("./resp.zig"); const Io = std.Io; const Writer = Io.Writer; +const Clock = @import("../clock.zig"); +const PrimitiveValue = @import("../types.zig").PrimitiveValue; +const testing = std.testing; +const mem = std.mem; pub fn keys(writer: *Writer, store: *Store, args: []const Value) !void { const pattern = args[1].asSlice(); @@ -89,3 +93,480 @@ pub fn randomkey(writer: *Writer, store: *Store, _: []const Value) !void { try resp.writeNull(writer); } } + +test "EXISTS command with existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "value"); + + const args = [_]Value{ + .{ .data = "EXISTS" }, + .{ .data = "mykey" }, + }; + + try exists(&writer, &store, &args); + + try testing.expectEqualStrings(":1\r\n", writer.buffered()); +} + +test "EXISTS command with non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "EXISTS" }, + .{ .data = "nonexistent" }, + }; + + try exists(&writer, &store, &args); + + try testing.expectEqualStrings(":0\r\n", writer.buffered()); +} + +test "TTL command with non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "TTL" }, + .{ .data = "nonexistent" }, + }; + + try ttl(&writer, &store, &args); + + try testing.expectEqualStrings(":-2\r\n", writer.buffered()); +} + +test "TTL command with key without expiration" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "value"); + + const args = [_]Value{ + .{ .data = "TTL" }, + .{ .data = "mykey" }, + }; + + try ttl(&writer, &store, &args); + + try testing.expectEqualStrings(":-1\r\n", writer.buffered()); +} + +test "TTL command with key with expiration" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "value"); + const now = Io.Clock.real.now(testing.io); + const future_time = now.toMilliseconds() + 10000; + _ = try store.expire("mykey", future_time); + + const args = [_]Value{ + .{ .data = "TTL" }, + .{ .data = "mykey" }, + }; + + try ttl(&writer, &store, &args); + + const output = writer.buffered(); + // Should return the expiration timestamp + try testing.expect(mem.startsWith(u8, output, ":")); +} + +test "PERSIST command with key having expiration" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "value"); + + const now = Io.Clock.real.now(testing.io); + const future_time = now.toMilliseconds() + 10000; + _ = try store.expire("mykey", future_time); + + const args = [_]Value{ + .{ .data = "PERSIST" }, + .{ .data = "mykey" }, + }; + + try persist(&writer, &store, &args); + + try testing.expectEqualStrings(":1\r\n", writer.buffered()); + + // Verify expiration was removed + const ttl_val = store.getTtl("mykey"); + try testing.expect(ttl_val == null); +} + +test "PERSIST command with key without expiration" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "value"); + + const args = [_]Value{ + .{ .data = "PERSIST" }, + .{ .data = "mykey" }, + }; + + try persist(&writer, &store, &args); + + try testing.expectEqualStrings(":0\r\n", writer.buffered()); +} + +test "TYPE command with string value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "hello"); + + const args = [_]Value{ + .{ .data = "TYPE" }, + .{ .data = "mykey" }, + }; + + try typeCmd(&writer, &store, &args); + + try testing.expectEqualStrings("$6\r\nstring\r\n", writer.buffered()); +} + +test "TYPE command with integer value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.setInt("mykey", 42); + + const args = [_]Value{ + .{ .data = "TYPE" }, + .{ .data = "mykey" }, + }; + + try typeCmd(&writer, &store, &args); + + try testing.expectEqualStrings("$6\r\nstring\r\n", writer.buffered()); +} + +test "TYPE command with list value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + _ = try store.createList("mylist"); + + const args = [_]Value{ + .{ .data = "TYPE" }, + .{ .data = "mylist" }, + }; + + try typeCmd(&writer, &store, &args); + + try testing.expectEqualStrings("$4\r\nlist\r\n", writer.buffered()); +} + +test "TYPE command with non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "TYPE" }, + .{ .data = "nonexistent" }, + }; + + try typeCmd(&writer, &store, &args); + + try testing.expectEqualStrings("$4\r\nnone\r\n", writer.buffered()); +} + +test "RENAME command with existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("oldkey", "value"); + + const args = [_]Value{ + .{ .data = "RENAME" }, + .{ .data = "oldkey" }, + .{ .data = "newkey" }, + }; + + try rename(&writer, &store, &args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + // Verify old key is gone + try testing.expect(store.get("oldkey") == null); + + // Verify new key exists with same value + const new_value = store.get("newkey"); + try testing.expect(new_value != null); + try testing.expectEqualStrings("value", new_value.?.value.short_string.asSlice()); +} + +test "RENAME command with non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "RENAME" }, + .{ .data = "nonexistent" }, + .{ .data = "newkey" }, + }; + + const result = rename(&writer, &store, &args); + try testing.expectError(error.KeyNotFound, result); +} + +test "RANDOMKEY command with non-empty store" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("key1", "value1"); + try store.set("key2", "value2"); + try store.set("key3", "value3"); + + const args = [_]Value{ + .{ .data = "RANDOMKEY" }, + }; + + try randomkey(&writer, &store, &args); + + const output = writer.buffered(); + // Should return a bulk string (key) + try testing.expect(mem.startsWith(u8, output, "$")); + // Should not be null + try testing.expect(!mem.eql(u8, output, "$-1\r\n")); +} + +test "RANDOMKEY command with empty store" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "RANDOMKEY" }, + }; + + try randomkey(&writer, &store, &args); + + try testing.expectEqualStrings("$-1\r\n", writer.buffered()); +} + +test "KEYS command returns all keys when pattern is wildcard" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("apple", "fruit"); + try store.set("banana", "fruit"); + try store.setInt("count", 42); + + const args = [_]Value{ + .{ .data = "KEYS" }, + .{ .data = "*" }, + }; + + try keys(&writer, &store, &args); + + const output = writer.buffered(); + // Should return array with 3 elements + try testing.expect(mem.startsWith(u8, output, "*3\r\n")); + // Verify all keys are present in output + try testing.expect(mem.indexOf(u8, output, "apple") != null); + try testing.expect(mem.indexOf(u8, output, "banana") != null); + try testing.expect(mem.indexOf(u8, output, "count") != null); +} + +test "RENAME overwrites existing destination key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("source", "source_value"); + try store.set("dest", "dest_value"); + + const args = [_]Value{ + .{ .data = "RENAME" }, + .{ .data = "source" }, + .{ .data = "dest" }, + }; + + try rename(&writer, &store, &args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + // Verify source is gone + try testing.expect(store.get("source") == null); + + // Verify dest has source's value + const dest_value = store.get("dest"); + try testing.expect(dest_value != null); + try testing.expectEqualStrings("source_value", dest_value.?.value.short_string.asSlice()); +} + +test "RENAME preserves list ownership" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const list = try store.createList("source"); + try list.append(PrimitiveValue{ .int = 42 }); + + const args = [_]Value{ + .{ .data = "RENAME" }, + .{ .data = "source" }, + .{ .data = "dest" }, + }; + + try rename(&writer, &store, &args); + + try testing.expect(store.get("source") == null); + + const renamed_list = (try store.getList("dest")).?; + try testing.expectEqual(@as(usize, 1), renamed_list.len()); + + const item = renamed_list.pop().?; + switch (item) { + .int => |value| try testing.expectEqual(@as(i64, 42), value), + else => return error.TestUnexpectedResult, + } +} diff --git a/src/commands/list.zig b/src/commands/list.zig index f1a2770..99fdcb7 100644 --- a/src/commands/list.zig +++ b/src/commands/list.zig @@ -7,7 +7,9 @@ const ZedisList = @import("../list.zig").ZedisList; const Store = @import("../store.zig").Store; const resp = @import("./resp.zig"); const Io = std.Io; +const testing = std.testing; const Writer = Io.Writer; +const Clock = @import("../clock.zig"); /// Helper function to normalize a list index (handles negative indices). /// Returns null if the index is out of bounds. @@ -236,3 +238,989 @@ pub fn lrange(writer: *Writer, store: *Store, args: []const Value) !void { current = node.next; } } + +// LPUSH Tests +test "LPUSH single element to new list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "world" }, + }; + + try lpush(&writer, &store, &args); + + try testing.expectEqualStrings(":1\r\n", writer.buffered()); + + // Verify the list was created and contains the element + const list = try store.getList("mylist"); + try testing.expect(list != null); + try testing.expectEqual(@as(usize, 1), list.?.len()); +} + +test "LPUSH multiple elements to new list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "three" }, + .{ .data = "two" }, + .{ .data = "one" }, + }; + + try lpush(&writer, &store, &args); + + try testing.expectEqualStrings(":3\r\n", writer.buffered()); + + // Verify the list has 3 elements + const list = try store.getList("mylist"); + try testing.expect(list != null); + try testing.expectEqual(@as(usize, 3), list.?.len()); +} + +test "LPUSH to existing list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // First, add some elements + const args1 = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "initial" }, + }; + try lpush(&writer, &store, &args1); + writer = Writer.fixed(&buffer); + + // Then add more elements + const args2 = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "second" }, + .{ .data = "first" }, + }; + try lpush(&writer, &store, &args2); + + try testing.expectEqualStrings(":3\r\n", writer.buffered()); + + const list = try store.getList("mylist"); + try testing.expect(list != null); + try testing.expectEqual(@as(usize, 3), list.?.len()); +} + +// RPUSH Tests +test "RPUSH single element to new list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "hello" }, + }; + + try rpush(&writer, &store, &args); + + try testing.expectEqualStrings(":1\r\n", writer.buffered()); + + const list = try store.getList("mylist"); + try testing.expect(list != null); + try testing.expectEqual(@as(usize, 1), list.?.len()); +} + +test "RPUSH multiple elements to new list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + + try rpush(&writer, &store, &args); + + try testing.expectEqualStrings(":3\r\n", writer.buffered()); + + const list = try store.getList("mylist"); + try testing.expect(list != null); + try testing.expectEqual(@as(usize, 3), list.?.len()); +} + +// LPOP Tests +test "LPOP from list with single element" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // First create a list with one element + const push_args = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "hello" }, + }; + try lpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Then pop the element + const pop_args = [_]Value{ + .{ .data = "LPOP" }, + .{ .data = "mylist" }, + }; + try lpop(&writer, &store, &pop_args); + + try testing.expectEqualStrings("$5\r\nhello\r\n", writer.buffered()); + + // List should be empty now + const list = try store.getList("mylist"); + try testing.expect(list == null or list.?.len() == 0); +} + +test "LPOP from non-existing list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "LPOP" }, + .{ .data = "nonexistent" }, + }; + + try lpop(&writer, &store, &args); + + try testing.expectEqualStrings("$-1\r\n", writer.buffered()); +} + +test "LPOP with count from list with multiple elements" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create a list with multiple elements + const push_args = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "three" }, + .{ .data = "two" }, + .{ .data = "one" }, + }; + try lpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Pop 2 elements + const pop_args = [_]Value{ + .{ .data = "LPOP" }, + .{ .data = "mylist" }, + .{ .data = "2" }, + }; + try lpop(&writer, &store, &pop_args); + + // Should return an array with 2 elements + try testing.expectEqualStrings("*2\r\n$3\r\none\r\n$3\r\ntwo\r\n", writer.buffered()); + + // List should have 1 element left + const list = try store.getList("mylist"); + try testing.expect(list != null); + try testing.expectEqual(@as(usize, 1), list.?.len()); +} + +test "LPOP with count of 0" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create a list with elements + const push_args = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "hello" }, + }; + try lpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Pop 0 elements + const pop_args = [_]Value{ + .{ .data = "LPOP" }, + .{ .data = "mylist" }, + .{ .data = "0" }, + }; + try lpop(&writer, &store, &pop_args); + + try testing.expectEqualStrings("$-1\r\n", writer.buffered()); +} + +// RPOP Tests +test "RPOP from list with single element" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // First create a list with one element + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "hello" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Then pop the element + const pop_args = [_]Value{ + .{ .data = "RPOP" }, + .{ .data = "mylist" }, + }; + try rpop(&writer, &store, &pop_args); + + try testing.expectEqualStrings("$5\r\nhello\r\n", writer.buffered()); +} + +test "RPOP with count from list with multiple elements" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create a list with multiple elements + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Pop 2 elements from the right + const pop_args = [_]Value{ + .{ .data = "RPOP" }, + .{ .data = "mylist" }, + .{ .data = "2" }, + }; + try rpop(&writer, &store, &pop_args); + + // Should return an array with 2 elements (in reverse order from LPOP) + try testing.expectEqualStrings("*2\r\n$5\r\nthree\r\n$3\r\ntwo\r\n", writer.buffered()); + + // List should have 1 element left + const list = try store.getList("mylist"); + try testing.expect(list != null); + try testing.expectEqual(@as(usize, 1), list.?.len()); +} + +// LLEN Tests +test "LLEN on existing list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create a list with elements + const push_args = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try lpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Check length + const llen_args = [_]Value{ + .{ .data = "LLEN" }, + .{ .data = "mylist" }, + }; + try llen(&writer, &store, &llen_args); + + try testing.expectEqualStrings(":3\r\n", writer.buffered()); +} + +test "LLEN on non-existing list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "LLEN" }, + .{ .data = "nonexistent" }, + }; + + try llen(&writer, &store, &args); + + try testing.expectEqualStrings(":0\r\n", writer.buffered()); +} + +test "Mixed LPUSH and RPUSH operations" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // LPUSH "middle" + const lpush_args = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "middle" }, + }; + try lpush(&writer, &store, &lpush_args); + writer = Writer.fixed(&buffer); + + // LPUSH "left" + const lpush_args2 = [_]Value{ + .{ .data = "LPUSH" }, + .{ .data = "mylist" }, + .{ .data = "left" }, + }; + try lpush(&writer, &store, &lpush_args2); + writer = Writer.fixed(&buffer); + + // RPUSH "right" + const rpush_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "right" }, + }; + try rpush(&writer, &store, &rpush_args); + writer = Writer.fixed(&buffer); + + // Should have 3 elements in order: left, middle, right + const llen_args = [_]Value{ + .{ .data = "LLEN" }, + .{ .data = "mylist" }, + }; + try llen(&writer, &store, &llen_args); + + try testing.expectEqualStrings(":3\r\n", writer.buffered()); +} + +test "LPOP and RPOP from the same list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // LPOP should get "one" + const lpop_args = [_]Value{ + .{ .data = "LPOP" }, + .{ .data = "mylist" }, + }; + try lpop(&writer, &store, &lpop_args); + try testing.expectEqualStrings("$3\r\none\r\n", writer.buffered()); + writer = Writer.fixed(&buffer); + + // RPOP should get "three" + const rpop_args = [_]Value{ + .{ .data = "RPOP" }, + .{ .data = "mylist" }, + }; + try rpop(&writer, &store, &rpop_args); + try testing.expectEqualStrings("$5\r\nthree\r\n", writer.buffered()); + writer = Writer.fixed(&buffer); + + // Should have 1 element left ("two") + const llen_args = [_]Value{ + .{ .data = "LLEN" }, + .{ .data = "mylist" }, + }; + try llen(&writer, &store, &llen_args); + try testing.expectEqualStrings(":1\r\n", writer.buffered()); +} + +// LINDEX Tests +test "LINDEX get first element" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Get first element (index 0) + const lindex_args = [_]Value{ + .{ .data = "LINDEX" }, + .{ .data = "mylist" }, + .{ .data = "0" }, + }; + try lindex(&writer, &store, &lindex_args); + + try testing.expectEqualStrings("$3\r\none\r\n", writer.buffered()); +} + +test "LINDEX get last element with negative index" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Get last element (index -1) + const lindex_args = [_]Value{ + .{ .data = "LINDEX" }, + .{ .data = "mylist" }, + .{ .data = "-1" }, + }; + try lindex(&writer, &store, &lindex_args); + + try testing.expectEqualStrings("$5\r\nthree\r\n", writer.buffered()); +} + +test "LINDEX with out of range index" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Try to get element at index 10 + const lindex_args = [_]Value{ + .{ .data = "LINDEX" }, + .{ .data = "mylist" }, + .{ .data = "10" }, + }; + try lindex(&writer, &store, &lindex_args); + + try testing.expectEqualStrings("$-1\r\n", writer.buffered()); +} + +test "LINDEX on non-existing list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "LINDEX" }, + .{ .data = "nonexistent" }, + .{ .data = "0" }, + }; + + try lindex(&writer, &store, &args); + + try testing.expectEqualStrings("$-1\r\n", writer.buffered()); +} + +// LSET Tests +test "LSET update element at index" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Set element at index 1 to "TWO" + const lset_args = [_]Value{ + .{ .data = "LSET" }, + .{ .data = "mylist" }, + .{ .data = "1" }, + .{ .data = "TWO" }, + }; + try lset(&writer, &store, &lset_args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + writer = Writer.fixed(&buffer); + + // Verify the element was updated + const lindex_args = [_]Value{ + .{ .data = "LINDEX" }, + .{ .data = "mylist" }, + .{ .data = "1" }, + }; + try lindex(&writer, &store, &lindex_args); + + try testing.expectEqualStrings("$3\r\nTWO\r\n", writer.buffered()); +} + +test "LSET with negative index" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Set last element using -1 + const lset_args = [_]Value{ + .{ .data = "LSET" }, + .{ .data = "mylist" }, + .{ .data = "-1" }, + .{ .data = "THREE" }, + }; + try lset(&writer, &store, &lset_args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + writer = Writer.fixed(&buffer); + + // Verify the last element was updated + const lindex_args = [_]Value{ + .{ .data = "LINDEX" }, + .{ .data = "mylist" }, + .{ .data = "-1" }, + }; + try lindex(&writer, &store, &lindex_args); + + try testing.expectEqualStrings("$5\r\nTHREE\r\n", writer.buffered()); +} + +test "LSET on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "LSET" }, + .{ .data = "nonexistent" }, + .{ .data = "0" }, + .{ .data = "value" }, + }; + + const result = lset(&writer, &store, &args); + try testing.expectError(error.NoSuchKey, result); +} + +test "LSET with out of range index" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Try to set element at index 10 + const lset_args = [_]Value{ + .{ .data = "LSET" }, + .{ .data = "mylist" }, + .{ .data = "10" }, + .{ .data = "value" }, + }; + + const result = lset(&writer, &store, &lset_args); + try testing.expectError(error.KeyNotFound, result); +} + +// LRANGE Tests +test "LRANGE get all elements" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Get all elements (0 to -1) + const lrange_args = [_]Value{ + .{ .data = "LRANGE" }, + .{ .data = "mylist" }, + .{ .data = "0" }, + .{ .data = "-1" }, + }; + try lrange(&writer, &store, &lrange_args); + + try testing.expectEqualStrings("*3\r\n$3\r\none\r\n$3\r\ntwo\r\n$5\r\nthree\r\n", writer.buffered()); +} + +test "LRANGE get subset of elements" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three, four, five + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + .{ .data = "four" }, + .{ .data = "five" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Get elements from index 1 to 3 + const lrange_args = [_]Value{ + .{ .data = "LRANGE" }, + .{ .data = "mylist" }, + .{ .data = "1" }, + .{ .data = "3" }, + }; + try lrange(&writer, &store, &lrange_args); + + try testing.expectEqualStrings("*3\r\n$3\r\ntwo\r\n$5\r\nthree\r\n$4\r\nfour\r\n", writer.buffered()); +} + +test "LRANGE with negative indices" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three, four, five + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + .{ .data = "four" }, + .{ .data = "five" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Get last 2 elements (-2 to -1) + const lrange_args = [_]Value{ + .{ .data = "LRANGE" }, + .{ .data = "mylist" }, + .{ .data = "-2" }, + .{ .data = "-1" }, + }; + try lrange(&writer, &store, &lrange_args); + + try testing.expectEqualStrings("*2\r\n$4\r\nfour\r\n$4\r\nfive\r\n", writer.buffered()); +} + +test "LRANGE on non-existing list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "LRANGE" }, + .{ .data = "nonexistent" }, + .{ .data = "0" }, + .{ .data = "-1" }, + }; + + try lrange(&writer, &store, &args); + + try testing.expectEqualStrings("*0\r\n", writer.buffered()); +} + +test "LRANGE with out of range indices" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Try to get elements from 10 to 20 (out of range) + const lrange_args = [_]Value{ + .{ .data = "LRANGE" }, + .{ .data = "mylist" }, + .{ .data = "10" }, + .{ .data = "20" }, + }; + try lrange(&writer, &store, &lrange_args); + + try testing.expectEqualStrings("*0\r\n", writer.buffered()); +} + +test "LRANGE with reversed range" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create list with: one, two, three + const push_args = [_]Value{ + .{ .data = "RPUSH" }, + .{ .data = "mylist" }, + .{ .data = "one" }, + .{ .data = "two" }, + .{ .data = "three" }, + }; + try rpush(&writer, &store, &push_args); + writer = Writer.fixed(&buffer); + + // Try reversed range (start > stop) + const lrange_args = [_]Value{ + .{ .data = "LRANGE" }, + .{ .data = "mylist" }, + .{ .data = "2" }, + .{ .data = "1" }, + }; + try lrange(&writer, &store, &lrange_args); + + try testing.expectEqualStrings("*0\r\n", writer.buffered()); +} diff --git a/src/commands/pubsub.zig b/src/commands/pubsub.zig index 70c05c2..c17c5bf 100644 --- a/src/commands/pubsub.zig +++ b/src/commands/pubsub.zig @@ -87,90 +87,6 @@ fn serializePubSubMessage(allocator: std.mem.Allocator, channel_name: []const u8 return try buffer.toOwnedSlice(); } -// Test imports -const testing = std.testing; -const MockClient = @import("../test_utils.zig").MockClient; -const MockServer = @import("../test_utils.zig").MockServer; -const MockPubSubContext = @import("../test_utils.zig").MockPubSubContext; -const Store = @import("../store.zig").Store; -const Io = std.Io; -const Clock = @import("../clock.zig"); - -// Test wrapper for publish command to work with MockClient -fn testPublish(client: *MockClient, args: []const Value) !void { - const channel_name = args[1].data; - const message = args[2].data; - - // Find channel - const channels = client.pubsub_context.getChannelNames(); - var channel_id: ?u32 = null; - for (channels[0..client.pubsub_context.getChannelCount()], 0..) |existing_name, i| { - if (existing_name) |name| { - if (mem.eql(u8, name, channel_name)) { - channel_id = @intCast(i); - break; - } - } - } - - if (channel_id == null) { - try client.writeInt(@as(u32, 0)); - return; - } - - // Get subscribers - const subscribers = client.pubsub_context.getChannelSubscribers(channel_id.?); - - // Send message to each subscriber - for (subscribers) |subscriber_id| { - const subscriber = client.pubsub_context.findClientById(subscriber_id); - if (subscriber) |sub_client| { - // Send the message as a 3-element array: ["message", channel, content] - try sub_client.writeTupleAsArray(.{ "message", channel_name, message }); - } - } - - // Return number of recipients - try client.writeInt(@as(u32, @intCast(subscribers.len))); -} - -// Test wrapper for subscribe command to work with MockClient -fn testSubscribe(client: *MockClient, args: []const Value) !void { - // Handle multiple channels (args[1..]) - for (args[1..]) |channel_arg| { - const channel_name = channel_arg.data; - - // Find or create channel - const channel_id = client.pubsub_context.findOrCreateChannel(channel_name) orelse { - try client.writeError("ERR maximum number of channels reached", .{}); - return; - }; - - // Subscribe client to channel - client.pubsub_context.subscribeToChannel(channel_id, client.client_id) catch |err| switch (err) { - error.ChannelFull => { - try client.writeError("ERR maximum subscribers per channel reached", .{}); - return; - }, - else => return err, - }; - - // Send subscription confirmation (channel name, total subscription count for client) - // Redis returns the total number of channels this client is subscribed to - var client_subscription_count: u64 = 0; - for (0..client.pubsub_context.getChannelCount()) |i| { - const subscribers = client.pubsub_context.getChannelSubscribers(@intCast(i)); - for (subscribers) |sub_id| { - if (sub_id == client.client_id) { - client_subscription_count += 1; - break; - } - } - } - try client.writeTupleAsArray(.{ "subscribe", channel_name, client_subscription_count }); - } -} - // test "PubSubContext - findOrCreateChannel creates new channels" { // var arena = std.heap.ArenaAllocator.init(testing.allocator); // defer arena.deinit(); diff --git a/src/commands/rdb.zig b/src/commands/rdb.zig index 4d7fcce..1639d57 100644 --- a/src/commands/rdb.zig +++ b/src/commands/rdb.zig @@ -11,7 +11,7 @@ pub fn save(client: *Client, args: []const Value, writer: *std.Io.Writer) !void _ = args; // SAVE command persists the single store - var zdb = try ZDB.Writer.init(client.allocator, client.getCurrentStore(), "test.rdb", client.server.config, client.server.io); + var zdb = try ZDB.Writer.init(client.allocator, client.getCurrentStore(), client.server.config.dbfilename, client.server.config, client.server.io); defer zdb.deinit(); try zdb.writeFile(); diff --git a/src/commands/registry.zig b/src/commands/registry.zig index 6431ddc..3867b56 100644 --- a/src/commands/registry.zig +++ b/src/commands/registry.zig @@ -2,7 +2,6 @@ const std = @import("std"); const Client = @import("../client.zig").Client; const Value = @import("../parser.zig").Value; const Store = @import("../store.zig").Store; -const aof = @import("../aof/aof.zig"); const resp = @import("./resp.zig"); pub const CommandError = error{ @@ -66,6 +65,16 @@ pub const CommandRegistry = struct { return self.commands.get(name); } + pub fn shouldWriteToAof(self: *CommandRegistry, name: []const u8) bool { + var buf: [32]u8 = undefined; + if (name.len > buf.len) return false; + const upper_name = std.ascii.upperString(&buf, name); + if (self.commands.get(upper_name)) |cmd_info| { + return cmd_info.write_to_aof; + } + return false; + } + fn handleCommandError(writer: *std.Io.Writer, command_name: []const u8, err: anyerror) void { const msg = switch (err) { error.WrongType => "WRONGTYPE Operation against a key holding the wrong kind of value", @@ -96,7 +105,7 @@ pub const CommandRegistry = struct { writer: *std.Io.Writer, args: []const Value, ) !void { - try self.executeCommand(writer, client, client.getCurrentStore(), &client.server.aof_writer, args); + try self.executeCommand(writer, client, client.getCurrentStore(), args); try writer.flush(); } @@ -109,10 +118,9 @@ pub const CommandRegistry = struct { var dummy_client: Client = undefined; dummy_client.authenticated = true; var discarding = std.Io.Writer.Discarding.init(&.{}); - var aof_writer: aof.Writer = try .init(store.io, false); // We should only be calling this command from the aof, so auth is assumed. // We should not be calling commands that require a real client. - try self.executeCommand(&discarding.writer, &dummy_client, store, &aof_writer, args); + try self.executeCommand(&discarding.writer, &dummy_client, store, args); } pub fn executeCommand( @@ -120,7 +128,6 @@ pub const CommandRegistry = struct { writer: *std.Io.Writer, client: *Client, store: *Store, - aof_writer: *aof.Writer, args: []const Value, ) !void { if (args.len == 0) { @@ -131,11 +138,7 @@ pub const CommandRegistry = struct { var buf: [32]u8 = undefined; if (command_name.len > buf.len) return error.CommandTooLong; - - for (command_name, 0..) |c, i| { - buf[i] = std.ascii.toUpper(c); - } - const upper_name = buf[0..command_name.len]; + const upper_name = std.ascii.upperString(&buf, command_name); // Skip auth check for commands that don't need it if (!std.mem.eql(u8, upper_name, "AUTH") and @@ -178,12 +181,6 @@ pub const CommandRegistry = struct { }; }, } - if (aof_writer.enabled and cmd_info.write_to_aof) { - try resp.writeListLen(aof_writer.writer(), args.len); - for (args) |arg| { - try resp.writeBulkString(aof_writer.writer(), arg.asSlice()); - } - } } else { resp.writeError(writer, "ERR unknown command") catch {}; } diff --git a/src/commands/string.zig b/src/commands/string.zig index 5dc351a..6142af7 100644 --- a/src/commands/string.zig +++ b/src/commands/string.zig @@ -1,12 +1,14 @@ const std = @import("std"); const storeModule = @import("../store.zig"); -const Store = storeModule.Store; -const ZedisObject = storeModule.ZedisObject; const Client = @import("../client.zig").Client; const Value = @import("../parser.zig").Value; const resp = @import("./resp.zig"); const Io = std.Io; +const Store = storeModule.Store; +const ZedisObject = storeModule.ZedisObject; const Writer = Io.Writer; +const testing = std.testing; +const Clock = @import("../clock.zig"); pub fn set(writer: *Writer, store: *Store, args: []const Value) !void { const key = args[1].asSlice(); @@ -368,3 +370,857 @@ pub fn incrbyfloat(writer: *Writer, store: *Store, args: []const Value) !void { // Return as bulk string try resp.writeBulkString(writer, result); } + +test "SET command with string value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "SET" }, + .{ .data = "key1" }, + .{ .data = "hello" }, + }; + + try set(&writer, &store, &args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + const stored_value = store.get("key1"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("hello", stored_value.?.value.short_string.asSlice()); +} + +test "SET command with integer value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "SET" }, + .{ .data = "key1" }, + .{ .data = "42" }, + }; + + try set(&writer, &store, &args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + const stored_value = store.get("key1"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("42", stored_value.?.value.short_string.asSlice()); +} + +test "GET command with existing string value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("key1", "hello"); + + const args = [_]Value{ + .{ .data = "GET" }, + .{ .data = "key1" }, + }; + + try get(&writer, &store, &args); + + try testing.expectEqualStrings("$5\r\nhello\r\n", writer.buffered()); +} + +test "GET command with existing integer value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.setInt("key1", 42); + + const args = [_]Value{ + .{ .data = "GET" }, + .{ .data = "key1" }, + }; + + try get(&writer, &store, &args); + + try testing.expectEqualStrings("$2\r\n42\r\n", writer.buffered()); +} + +test "GET command with non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "GET" }, + .{ .data = "nonexistent" }, + }; + + try get(&writer, &store, &args); + + try testing.expectEqualStrings("$-1\r\n", writer.buffered()); +} + +test "INCR command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "INCR" }, + .{ .data = "counter" }, + }; + + try incr(&writer, &store, &args); + + try testing.expectEqualStrings("$1\r\n1\r\n", writer.buffered()); + + const stored_value = store.get("counter"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, 1), stored_value.?.value.int); +} + +test "INCR command on existing integer" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.setInt("counter", 5); + + const args = [_]Value{ + .{ .data = "INCR" }, + .{ .data = "counter" }, + }; + + try incr(&writer, &store, &args); + + try testing.expectEqualStrings("$1\r\n6\r\n", writer.buffered()); + + const stored_value = store.get("counter"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, 6), stored_value.?.value.int); +} + +test "INCR command on string that represents integer" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("counter", "10"); + + const args = [_]Value{ + .{ .data = "INCR" }, + .{ .data = "counter" }, + }; + + try incr(&writer, &store, &args); + + try testing.expectEqualStrings("$2\r\n11\r\n", writer.buffered()); + + const stored_value = store.get("counter"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, 11), stored_value.?.value.int); +} + +test "INCR command on non-integer string" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("key1", "hello"); + + const args = [_]Value{ + .{ .data = "INCR" }, + .{ .data = "key1" }, + }; + + const result = incr(&writer, &store, &args); + try testing.expectError(error.ValueNotInteger, result); +} + +test "DECR command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "DECR" }, + .{ .data = "counter" }, + }; + + try decr(&writer, &store, &args); + + try testing.expectEqualStrings("$2\r\n-1\r\n", writer.buffered()); + + const stored_value = store.get("counter"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, -1), stored_value.?.value.int); +} + +test "DECR command on existing integer" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.setInt("counter", 10); + + const args = [_]Value{ + .{ .data = "DECR" }, + .{ .data = "counter" }, + }; + + try decr(&writer, &store, &args); + + try testing.expectEqualStrings("$1\r\n9\r\n", writer.buffered()); + + const stored_value = store.get("counter"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, 9), stored_value.?.value.int); +} + +test "DEL command with single existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("key1", "value1"); + + const args = [_]Value{ + .{ .data = "DEL" }, + .{ .data = "key1" }, + }; + + try del(&writer, &store, &args); + + try testing.expectEqualStrings(":1\r\n", writer.buffered()); + + const stored_value = store.get("key1"); + try testing.expect(stored_value == null); +} + +test "DEL command with multiple keys" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("key1", "value1"); + try store.set("key2", "value2"); + try store.setInt("key3", 42); + + const args = [_]Value{ + .{ .data = "DEL" }, + .{ .data = "key1" }, + .{ .data = "key2" }, + .{ .data = "key3" }, + .{ .data = "nonexistent" }, + }; + + try del(&writer, &store, &args); + + try testing.expectEqualStrings(":3\r\n", writer.buffered()); + + try testing.expect(store.get("key1") == null); + try testing.expect(store.get("key2") == null); + try testing.expect(store.get("key3") == null); +} + +test "DEL command with non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "DEL" }, + .{ .data = "nonexistent" }, + }; + + try del(&writer, &store, &args); + + try testing.expectEqualStrings(":0\r\n", writer.buffered()); +} + +test "APPEND command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "APPEND" }, + .{ .data = "mykey" }, + .{ .data = "Hello" }, + }; + + try append(&writer, &store, &args); + + try testing.expectEqualStrings(":5\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("Hello", stored_value.?.value.short_string.asSlice()); +} + +test "APPEND command on existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "Hello"); + + const args = [_]Value{ + .{ .data = "APPEND" }, + .{ .data = "mykey" }, + .{ .data = " World" }, + }; + + try append(&writer, &store, &args); + + try testing.expectEqualStrings(":11\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("Hello World", stored_value.?.value.short_string.asSlice()); +} + +test "STRLEN command on existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "Hello World"); + + const args = [_]Value{ + .{ .data = "STRLEN" }, + .{ .data = "mykey" }, + }; + + try strlen(&writer, &store, &args); + + try testing.expectEqualStrings(":11\r\n", writer.buffered()); +} + +test "STRLEN command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "STRLEN" }, + .{ .data = "nonexistent" }, + }; + + try strlen(&writer, &store, &args); + + try testing.expectEqualStrings(":0\r\n", writer.buffered()); +} + +test "GETSET command on existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "Hello"); + + const args = [_]Value{ + .{ .data = "GETSET" }, + .{ .data = "mykey" }, + .{ .data = "World" }, + }; + + try getset(&writer, &store, &args); + + try testing.expectEqualStrings("$5\r\nHello\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("World", stored_value.?.value.short_string.asSlice()); +} + +test "GETSET command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "GETSET" }, + .{ .data = "mykey" }, + .{ .data = "World" }, + }; + + try getset(&writer, &store, &args); + + try testing.expectEqualStrings("$-1\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("World", stored_value.?.value.short_string.asSlice()); +} + +test "MGET command with multiple keys" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("key1", "value1"); + try store.setInt("key2", 42); + + const args = [_]Value{ + .{ .data = "MGET" }, + .{ .data = "key1" }, + .{ .data = "key2" }, + .{ .data = "key3" }, + }; + + try mget(&writer, &store, &args); + + try testing.expectEqualStrings("*3\r\n$6\r\nvalue1\r\n$2\r\n42\r\n$-1\r\n", writer.buffered()); +} + +test "MSET command with multiple key-value pairs" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "MSET" }, + .{ .data = "key1" }, + .{ .data = "value1" }, + .{ .data = "key2" }, + .{ .data = "value2" }, + }; + + try mset(&writer, &store, &args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + const v1 = store.get("key1"); + try testing.expect(v1 != null); + try testing.expectEqualStrings("value1", v1.?.value.short_string.asSlice()); + + const v2 = store.get("key2"); + try testing.expect(v2 != null); + try testing.expectEqualStrings("value2", v2.?.value.short_string.asSlice()); +} + +test "SETEX command sets key with expiration" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "SETEX" }, + .{ .data = "mykey" }, + .{ .data = "10" }, + .{ .data = "Hello" }, + }; + + try setex(&writer, &store, &args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("Hello", stored_value.?.value.short_string.asSlice()); +} + +test "SETNX command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "SETNX" }, + .{ .data = "mykey" }, + .{ .data = "Hello" }, + }; + + try setnx(&writer, &store, &args); + + try testing.expectEqualStrings(":1\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("Hello", stored_value.?.value.short_string.asSlice()); +} + +test "SETNX command on existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "World"); + + const args = [_]Value{ + .{ .data = "SETNX" }, + .{ .data = "mykey" }, + .{ .data = "Hello" }, + }; + + try setnx(&writer, &store, &args); + + try testing.expectEqualStrings(":0\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqualStrings("World", stored_value.?.value.short_string.asSlice()); +} + +test "INCRBY command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "INCRBY" }, + .{ .data = "mykey" }, + .{ .data = "5" }, + }; + + try incrby(&writer, &store, &args); + + try testing.expectEqualStrings("$1\r\n5\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, 5), stored_value.?.value.int); +} + +test "INCRBY command on existing integer" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.setInt("mykey", 10); + + const args = [_]Value{ + .{ .data = "INCRBY" }, + .{ .data = "mykey" }, + .{ .data = "5" }, + }; + + try incrby(&writer, &store, &args); + + try testing.expectEqualStrings("$2\r\n15\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, 15), stored_value.?.value.int); +} + +test "DECRBY command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "DECRBY" }, + .{ .data = "mykey" }, + .{ .data = "3" }, + }; + + try decrby(&writer, &store, &args); + + try testing.expectEqualStrings("$2\r\n-3\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, -3), stored_value.?.value.int); +} + +test "DECRBY command on existing integer" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.setInt("mykey", 10); + + const args = [_]Value{ + .{ .data = "DECRBY" }, + .{ .data = "mykey" }, + .{ .data = "3" }, + }; + + try decrby(&writer, &store, &args); + + try testing.expectEqualStrings("$1\r\n7\r\n", writer.buffered()); + + const stored_value = store.get("mykey"); + try testing.expect(stored_value != null); + try testing.expectEqual(@as(i64, 7), stored_value.?.value.int); +} + +test "INCRBYFLOAT command on non-existing key" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + const args = [_]Value{ + .{ .data = "INCRBYFLOAT" }, + .{ .data = "mykey" }, + .{ .data = "2.5" }, + }; + + try incrbyfloat(&writer, &store, &args); + + try testing.expectEqualStrings("$3\r\n2.5\r\n", writer.buffered()); +} + +test "INCRBYFLOAT command on existing float" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "10.5"); + + const args = [_]Value{ + .{ .data = "INCRBYFLOAT" }, + .{ .data = "mykey" }, + .{ .data = "0.1" }, + }; + + try incrbyfloat(&writer, &store, &args); + + // Result should be "10.6" (trailing zeros removed) + try testing.expectEqualStrings("$4\r\n10.6\r\n", writer.buffered()); +} + +test "INCRBYFLOAT command with negative increment" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var clock = Clock.init(testing.io, 0); + var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + try store.set("mykey", "5.0"); + + const args = [_]Value{ + .{ .data = "INCRBYFLOAT" }, + .{ .data = "mykey" }, + .{ .data = "-2.0" }, + }; + + try incrbyfloat(&writer, &store, &args); + + // Result should be "3" (trailing zeros and decimal point removed) + try testing.expectEqualStrings("$1\r\n3\r\n", writer.buffered()); +} diff --git a/src/config.zig b/src/config.zig index 4d68191..71dd1aa 100644 --- a/src/config.zig +++ b/src/config.zig @@ -27,118 +27,117 @@ pub const MemoryStats = struct { }; // Network -/// Bind to specific network interfaces. Default: "127.0.0.1 -::1" +/// Bind to specific network interfaces. bind: []const u8 = "127.0.0.1", -/// Enable protected mode for security when no password is set. Default: yes +/// Enable protected mode for security when no password is set. protected_mode: bool = true, -/// TCP port to listen on. Default: 6379 +/// TCP port to listen on. port: u16 = 6379, -/// TCP listen backlog. Default: 511 +/// TCP listen backlog. tcp_backlog: u32 = 511, -/// Close connection after client is idle for N seconds (0 to disable). Default: 0 +/// Close connection after client is idle for N seconds (0 to disable). timeout: u32 = 0, -/// TCP keepalive interval in seconds. Default: 300 +/// TCP keepalive interval in seconds. tcp_keepalive: u32 = 300, // General -/// Run as a daemon process. Default: no +/// Run as a daemon process. daemonize: bool = false, -/// PID file location when daemonized. Default: "/var/run/redis_6379.pid" +/// PID file location when daemonized. pidfile: []const u8 = "/var/run/redis_6379.pid", -/// Log verbosity level (debug, verbose, notice, warning). Default: "notice" +/// Log verbosity level (debug, verbose, notice, warning). loglevel: []const u8 = "notice", -/// Log file path (empty string for stdout). Default: "" +/// Log file path (empty string for stdout). logfile: []const u8 = "", /// Clock update interval in milliseconds. /// 0 = always use realtime syscall (most accurate, higher CPU) /// >0 = cache and update every N ms (good performance) -/// Default: 100ms clock_update_ms: u32 = 100, /// Number of candidates sampled during approximate LRU eviction. -/// Higher values are more accurate and more expensive. Default: 5 +/// Higher values are more accurate and more expensive. maxmemory_samples: u32 = 5, // Snapshotting -/// Stop accepting writes if RDB snapshot fails. Default: yes +/// Stop accepting writes if RDB snapshot fails. stop_writes_on_bgsave_error: bool = true, -/// Compress RDB snapshot files with LZF. Default: yes +/// Compress RDB snapshot files with LZF. rdbcompression: bool = true, -/// Add CRC64 checksum to RDB files. Default: yes +/// Add CRC64 checksum to RDB files. rdbchecksum: bool = true, -/// Filename for RDB snapshot. Default: "dump.rdb" +/// Filename for RDB snapshot. dbfilename: []const u8 = "dump.rdb", -/// Delete sync files used for replication. Default: no -rdb_del_sync_files: bool = false, -/// Working directory for RDB/AOF files. Default: "./" +/// Delete sync files used for replication. +rdb_del_sync_files: bool = false, // (not implemented) +/// Working directory for RDB/AOF files. dir: []const u8 = "./", // Replication -/// Serve stale data when replica loses connection to master. Default: yes +/// Serve stale data when replica loses connection to master. replica_serve_stale_data: bool = true, -/// Make replicas read-only. Default: yes +/// Make replicas read-only. replica_read_only: bool = true, -/// Use diskless replication (transfer RDB via socket). Default: yes +/// Use diskless replication (transfer RDB via socket). repl_diskless_sync: bool = true, -/// Delay before diskless sync starts (seconds). Default: 5 +/// Delay before diskless sync starts (seconds). repl_diskless_sync_delay: u32 = 5, -/// Max replicas to sync in parallel (0 = unlimited). Default: 0 -repl_diskless_sync_max_replicas: u32 = 0, -/// How replicas load RDB (disabled, on-empty-db, swapdb). Default: "disabled" +/// Max replicas to sync in parallel (0 = unlimited). +repl_diskless_sync_max_replicas: u32 = 0, // (not implemented) +/// How replicas load RDB (disabled, on-empty-db, swapdb). repl_diskless_load: []const u8 = "disabled", -/// Disable TCP_NODELAY on replica socket. Default: no -repl_disable_tcp_nodelay: bool = false, -/// Priority for replica promotion (lower = higher priority). Default: 100 -replica_priority: u32 = 100, +/// Disable TCP_NODELAY on replica socket. +repl_disable_tcp_nodelay: bool = false, // (not implemented) +/// Priority for replica promotion (lower = higher priority). +replica_priority: u32 = 100, // (not implemented) // Security -/// Maximum length of ACL log. Default: 128 -acllog_max_len: u32 = 128, +/// Maximum length of ACL log. +acllog_max_len: u32 = 128, // (not implemented) // Lazy freeing -/// Async free memory for evicted keys. Default: no -lazyfree_lazy_eviction: bool = false, -/// Async free memory for expired keys. Default: no -lazyfree_lazy_expire: bool = false, -/// Async free memory for deleted keys (DEL, RENAME, etc). Default: no -lazyfree_lazy_server_del: bool = false, -/// Async flush replica's database during full resync. Default: no -replica_lazy_flush: bool = false, -/// Async free memory for user-called DEL commands. Default: no -lazyfree_lazy_user_del: bool = false, -/// Async free memory for FLUSHDB/FLUSHALL commands. Default: no -lazyfree_lazy_user_flush: bool = false, +/// Async free memory for evicted keys. +lazyfree_lazy_eviction: bool = false, // (not implemented) +/// Async free memory for expired keys. +lazyfree_lazy_expire: bool = false, // (not implemented) +/// Async free memory for deleted keys (DEL, RENAME, etc). +lazyfree_lazy_server_del: bool = false, // (not implemented) +/// Async flush replica's database during full resync. +replica_lazy_flush: bool = false, // (not implemented) +/// Async free memory for user-called DEL commands. +lazyfree_lazy_user_del: bool = false, // (not implemented) +/// Async free memory for FLUSHDB/FLUSHALL commands. +lazyfree_lazy_user_flush: bool = false, // (not implemented) // OOM handling -/// Adjust OOM killer score. Default: no -oom_score_adj: bool = false, -/// OOM score values for different states. Default: [0, 200, 800] -oom_score_adj_values: []const u32 = &[_]u32{ 0, 200, 800 }, -/// Disable Transparent Huge Pages. Default: yes -disable_thp: bool = true, +/// Adjust OOM killer score. +oom_score_adj: bool = false, // (not implemented) +/// OOM score values for different states. +oom_score_adj_values: []const u32 = &[_]u32{ 0, 200, 800 }, // (not implemented) +/// Disable Transparent Huge Pages. +disable_thp: bool = true, // (not implemented) // Append only mode -/// Enable AOF persistence mode. Default: no +/// Enable AOF persistence mode. appendonly: bool = false, -/// AOF filename. Default: "appendonly.aof" +/// AOF filename. appendfilename: []const u8 = "appendonly.aof", -/// Directory for AOF files. Default: "appendonlydir" -appenddirname: []const u8 = "appendonlydir", -/// AOF fsync policy (always, everysec, no). Default: "everysec" +/// Directory for AOF files. +appenddirname: []const u8 = "appendonlydir", // (not implemented — use dir instead) +/// AOF fsync policy (always, everysec, no). appendfsync: []const u8 = "everysec", -/// Disable fsync during BGSAVE or BGREWRITEAOF. Default: no -no_appendfsync_on_rewrite: bool = false, -/// Trigger AOF rewrite when size grows by this percentage. Default: 100 -auto_aof_rewrite_percentage: u32 = 100, -/// Minimum AOF size to trigger auto rewrite. Default: "64mb" -auto_aof_rewrite_min_size: []const u8 = "64mb", -/// Load truncated AOF file on startup. Default: yes -aof_load_truncated: bool = true, -/// Allow loading corrupted AOF with warnings. Default: no -aof_load_broken: bool = false, -/// Max size of broken AOF file to load (bytes). Default: 4096 -aof_load_broken_max_size: u32 = 4096, -/// Use RDB preamble in AOF for faster restarts. Default: yes -aof_use_rdb_preamble: bool = true, +/// Disable fsync during BGSAVE or BGREWRITEAOF. +no_appendfsync_on_rewrite: bool = false, // (not implemented) +/// Trigger AOF rewrite when size grows by this percentage. +auto_aof_rewrite_percentage: u32 = 100, // (not implemented) +/// Minimum AOF size to trigger auto rewrite. +auto_aof_rewrite_min_size: []const u8 = "64mb", // (not implemented) +/// Load truncated AOF file on startup. +aof_load_truncated: bool = true, // (not implemented) +/// Allow loading corrupted AOF with warnings. +aof_load_broken: bool = false, // (not implemented) +/// Max size of broken AOF file to load (bytes). +aof_load_broken_max_size: u32 = 4096, // (not implemented) +/// Use RDB preamble in AOF for faster restarts. +aof_use_rdb_preamble: bool = true, // (not implemented) // Legacy fields timeout_seconds: u64 = 0, @@ -154,6 +153,7 @@ initial_capacity: u32 = 8192, // Initial hash map capacity for Store (reduces ea eviction_policy: EvictionPolicy = .allkeys_lru, // LRU eviction policy requirepass: ?[]const u8 = null, // Password authentication (null = disabled) rdb_write_buffer_size: usize = 256 * 1024, // 256KB buffer for RDB writes (optimal SSD throughput) +aof_write_buffer_size: usize = 64 * 1024, // 64KB buffer for AOF writes // Computed constants (calculated from other fields) pub fn clientPoolSize(self: Config) usize { @@ -268,6 +268,38 @@ fn parseConfigLine(config: *Config, allocator: std.mem.Allocator, key: []const u config.repl_diskless_sync_delay = try parseInt(u32, trimmed_value, 10); } else if (eql(u8, key, "repl-diskless-load")) { config.repl_diskless_load = try allocator.dupe(u8, trimmed_value); + } else if (eql(u8, key, "repl-diskless-sync-max-replicas")) { + config.repl_diskless_sync_max_replicas = try parseInt(u32, trimmed_value, 10); + } else if (eql(u8, key, "repl-disable-tcp-nodelay")) { + config.repl_disable_tcp_nodelay = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "replica-priority")) { + config.replica_priority = try parseInt(u32, trimmed_value, 10); + } else if (eql(u8, key, "rdb-del-sync-files")) { + config.rdb_del_sync_files = eql(u8, trimmed_value, "yes"); + } + // Security + else if (eql(u8, key, "acllog-max-len")) { + config.acllog_max_len = try parseInt(u32, trimmed_value, 10); + } + // Lazy freeing + else if (eql(u8, key, "lazyfree-lazy-eviction")) { + config.lazyfree_lazy_eviction = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "lazyfree-lazy-expire")) { + config.lazyfree_lazy_expire = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "lazyfree-lazy-server-del")) { + config.lazyfree_lazy_server_del = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "replica-lazy-flush")) { + config.replica_lazy_flush = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "lazyfree-lazy-user-del")) { + config.lazyfree_lazy_user_del = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "lazyfree-lazy-user-flush")) { + config.lazyfree_lazy_user_flush = eql(u8, trimmed_value, "yes"); + } + // OOM handling + else if (eql(u8, key, "oom-score-adj")) { + config.oom_score_adj = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "disable-thp")) { + config.disable_thp = eql(u8, trimmed_value, "yes"); } // Append only mode else if (eql(u8, key, "appendonly")) { @@ -302,6 +334,24 @@ fn parseConfigLine(config: *Config, allocator: std.mem.Allocator, key: []const u config.requirepass = try allocator.dupe(u8, trimmed_value); } else if (eql(u8, key, "rdb-write-buffer-size")) { config.rdb_write_buffer_size = try parseMemorySize(trimmed_value); + } else if (eql(u8, key, "aof-write-buffer-size")) { + config.aof_write_buffer_size = try parseMemorySize(trimmed_value); + } else if (eql(u8, key, "appenddirname")) { + config.appenddirname = try allocator.dupe(u8, trimmed_value); + } else if (eql(u8, key, "no-appendfsync-on-rewrite")) { + config.no_appendfsync_on_rewrite = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "auto-aof-rewrite-percentage")) { + config.auto_aof_rewrite_percentage = try parseInt(u32, trimmed_value, 10); + } else if (eql(u8, key, "auto-aof-rewrite-min-size")) { + config.auto_aof_rewrite_min_size = try allocator.dupe(u8, trimmed_value); + } else if (eql(u8, key, "aof-load-truncated")) { + config.aof_load_truncated = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "aof-load-broken")) { + config.aof_load_broken = eql(u8, trimmed_value, "yes"); + } else if (eql(u8, key, "aof-load-broken-max-size")) { + config.aof_load_broken_max_size = try parseInt(u32, trimmed_value, 10); + } else if (eql(u8, key, "aof-use-rdb-preamble")) { + config.aof_use_rdb_preamble = eql(u8, trimmed_value, "yes"); } } diff --git a/src/parser.zig b/src/parser.zig index 153c9b7..88ccc32 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -579,12 +579,6 @@ test "Value asUsize positive number" { try testing.expectEqual(@as(usize, 42), result); } -test "Value asUsize zero" { - const value = Value{ .data = "0" }; - const result = try value.asUsize(); - try testing.expectEqual(@as(usize, 0), result); -} - test "Value asU16 small number" { const value = Value{ .data = "6379" }; const result = try value.asU16(); @@ -603,12 +597,6 @@ test "Value asU16 overflow" { try testing.expectError(error.Overflow, result); } -test "Value asSlice returns correct slice" { - const value = Value{ .data = "hello world" }; - const result = value.asSlice(); - try testing.expectEqualStrings("hello world", result); -} - test "Command init and addArg" { var command = Command.init(testing.allocator); defer command.deinit(); diff --git a/src/rdb/zdb.zig b/src/rdb/zdb.zig index 043a0a3..b920137 100644 --- a/src/rdb/zdb.zig +++ b/src/rdb/zdb.zig @@ -75,11 +75,16 @@ pub const Writer = struct { } pub fn init(allocator: mem.Allocator, store: *Store, fileName: []const u8, config: Config, io: Io) !Writer { - Dir.cwd().deleteFile(io, fileName) catch |err| switch (err) { + Dir.cwd().createDir(io, config.dir, .default_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + const dir = try Dir.cwd().openDir(io, config.dir, .{}); + dir.deleteFile(io, fileName) catch |err| switch (err) { error.FileNotFound => {}, else => return err, }; - const file = try Dir.cwd().createFile(io, fileName, .{ .truncate = true }); + const file = try dir.createFile(io, fileName, .{ .truncate = true }); // Allocate buffer using configured size (default 256KB for optimal SSD throughput) const buffer = try allocator.alloc(u8, config.rdb_write_buffer_size); diff --git a/src/server.zig b/src/server.zig index f82bba5..98c2657 100644 --- a/src/server.zig +++ b/src/server.zig @@ -230,7 +230,15 @@ pub fn initWithConfig( .createdTime = now, // AOF - .aof_writer = try aof.Writer.init(io, false), + .aof_writer = try aof.Writer.init( + base_allocator, + io, + config.appendonly, + config.appendfilename, + config.dir, + config.aof_write_buffer_size, + std.meta.stringToEnum(aof.Writer.FsyncPolicy, config.appendfsync) orelse .everysec, + ), }; // Rebind self-references after the final Server value is in place. @@ -249,7 +257,7 @@ pub fn initWithConfig( // Load AOF file if it exists // 'true' to be replaced with user option (use aof/rdb on boot) if (true) { - if (aof.Reader.init(server.temp_arena.allocator(), &server.store, &server.registry, io)) |reader_value| { + if (aof.Reader.init(server.temp_arena.allocator(), &server.store, &server.registry, io, config.dir, config.appendfilename)) |reader_value| { var reader = reader_value; log.info("Loading AOF into store", .{}); reader.read() catch |err| { @@ -332,7 +340,7 @@ pub fn deinit(self: *Server) void { self.temp_arena.deinit(); // AOF Deinit - self.aof_writer.deinit(self.io); + self.aof_writer.deinit(); log.info("Server deinitialized - all memory freed", .{}); } @@ -403,13 +411,20 @@ pub fn processCommandDirect(self: *Server, client: *Client, args: []const Value, response_writer, client, client.getCurrentStore(), - &self.aof_writer, args, ) catch |err| { log.err("Command execution failed: {s}", .{@errorName(err)}); }; } +/// Write a command to the AOF file. Called after store_mutex is released, +/// so the file write (which zio makes async) does not serialize other commands. +pub fn writeAof(self: *Server, command_name: []const u8, args: []const Value) void { + if (!self.aof_writer.enabled) return; + if (!self.registry.shouldWriteToAof(command_name)) return; + self.aof_writer.writeCommand(args); +} + // The main server loop. It waits for incoming connections and // handles each client (one thread per connection). pub fn listen(self: *Server) !void { diff --git a/src/store.zig b/src/store.zig index 553804b..f5b9c0a 100644 --- a/src/store.zig +++ b/src/store.zig @@ -15,6 +15,7 @@ const optimal_max_load_percentage = 80; const expired_eviction_scan_limit = 16; const EntryMap = std.StringHashMapUnmanaged(*StoreEntry); +const testing = std.testing; pub const ValueType = enum(u8) { string = 0, @@ -721,3 +722,764 @@ pub const Store = struct { } } }; + +test "Store init and deinit" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try testing.expectEqual(@as(u32, 0), store.size()); +} + +test "Store set and get" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("key1", "hello"); + try testing.expectEqual(@as(u32, 1), store.size()); + + const result = store.get("key1"); + try testing.expect(result != null); + try testing.expectEqualStrings("hello", result.?.value.short_string.asSlice()); +} + +test "Store setInt and get" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.setInt("counter", 42); + try testing.expectEqual(@as(u32, 1), store.size()); + + const result = store.get("counter"); + try testing.expect(result != null); + try testing.expectEqual(@as(i64, 42), result.?.value.int); +} + +test "Store setObject with ZedisObject" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + const obj = ZedisObject{ .value = .{ .string = try testing.testing.allocator.dupe(u8, "test") } }; + defer testing.allocator.free(obj.value.string); + try store.putObject("key1", obj); + + const result = store.get("key1"); + try testing.expect(result != null); + try testing.expectEqualStrings("test", result.?.value.string); +} + +test "Store delete existing key" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("key1", "value1"); + try testing.expectEqual(@as(u32, 1), store.size()); + try testing.expect(store.exists("key1")); + + const deleted = store.delete("key1"); + try testing.expect(deleted); + try testing.expectEqual(@as(u32, 0), store.size()); + try testing.expect(!store.exists("key1")); +} + +test "Store delete non-existing key" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + const deleted = store.delete("nonexistent"); + try testing.expect(!deleted); +} + +test "Store exists" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try testing.expect(!store.exists("key1")); + + try store.set("key1", "value1"); + try testing.expect(store.exists("key1")); + + _ = store.delete("key1"); + try testing.expect(!store.exists("key1")); +} + +test "Store getType" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try testing.expect(store.getType("nonexistent") == null); + + try store.set("str_key", "hello"); + try testing.expectEqual(ValueType.short_string, store.getType("str_key").?); + + try store.setInt("int_key", 42); + try testing.expectEqual(ValueType.int, store.getType("int_key").?); +} + +test "Store overwrite existing key" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("key1", "original"); + try testing.expectEqual(@as(u32, 1), store.size()); + + const result1 = store.get("key1"); + try testing.expect(result1 != null); + try testing.expectEqualStrings("original", result1.?.value.short_string.asSlice()); + + try store.set("key1", "updated"); + try testing.expectEqual(@as(u32, 1), store.size()); + + const result2 = store.get("key1"); + try testing.expect(result2 != null); + try testing.expectEqualStrings("updated", result2.?.value.short_string.asSlice()); +} + +test "Store overwrite string with integer" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("key1", "hello"); + try testing.expectEqual(ValueType.short_string, store.getType("key1").?); + + try store.setInt("key1", 123); + try testing.expectEqual(ValueType.int, store.getType("key1").?); + try testing.expectEqual(@as(i64, 123), store.get("key1").?.value.int); +} + +test "Store overwrite integer with string" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.setInt("key1", 456); + try testing.expectEqual(ValueType.int, store.getType("key1").?); + + try store.set("key1", "world"); + try testing.expectEqual(ValueType.short_string, store.getType("key1").?); + try testing.expectEqualStrings("world", store.get("key1").?.value.short_string.asSlice()); +} + +test "Store expire functionality" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("key1", "value1"); + try testing.expect(!store.isExpired("key1")); + + // Set expiration to far future + const now = Io.Clock.real.now(testing.io); + const future_time = now.toMilliseconds() + 1000000; + const success = try store.expire("key1", future_time); + try testing.expect(success); + try testing.expect(!store.isExpired("key1")); + try testing.expect(store.get("key1") != null); + try testing.expectEqual(future_time, store.getTtl("key1").?); + + // Set expiration to past + const past_time: i64 = 12345; + _ = try store.expire("key1", past_time); + try testing.expect(store.isExpired("key1")); + try testing.expect(store.get("key1") == null); // Should be deleted on get +} + +test "Store expire non-existing key" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + const success = try store.expire("nonexistent", 12345); + try testing.expect(!success); +} + +test "Store delete removes from expiration map" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("key1", "value1"); + _ = try store.expire("key1", 12345); + + const deleted = store.delete("key1"); + try testing.expect(deleted); + try testing.expect(!store.isExpired("key1")); +} + +test "Store multiple keys with different types" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("str1", "hello"); + try store.set("str2", "world"); + try store.setInt("int1", 123); + try store.setInt("int2", -456); + + try testing.expectEqual(@as(u32, 4), store.size()); + + try testing.expectEqualStrings("hello", store.get("str1").?.value.short_string.asSlice()); + try testing.expectEqualStrings("world", store.get("str2").?.value.short_string.asSlice()); + try testing.expectEqual(@as(i64, 123), store.get("int1").?.value.int); + try testing.expectEqual(@as(i64, -456), store.get("int2").?.value.int); +} + +test "Store empty string values" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("empty", ""); + + const result = store.get("empty"); + try testing.expect(result != null); + try testing.expectEqualStrings("", result.?.value.short_string.asSlice()); +} + +test "Store zero integer values" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.setInt("zero", 0); + + const result = store.get("zero"); + try testing.expect(result != null); + try testing.expectEqual(@as(i64, 0), result.?.value.int); +} + +test "Store createList and getList" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try testing.expect(try store.getList("mylist") == null); + + const list = try store.createList("mylist"); + try testing.expectEqual(@as(usize, 0), list.len()); + + const retrieved_list = try store.getList("mylist"); + try testing.expect(retrieved_list != null); + try testing.expectEqual(@as(usize, 0), retrieved_list.?.len()); +} + +test "Store list append and insert operations" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + const list = try store.createList("test_append_insert"); + + try testing.expectEqual(@as(usize, 0), list.len()); + + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "first") }); + try testing.expectEqual(@as(usize, 1), list.len()); + try testing.expectEqualStrings("first", list.getByIndex(0).?.string); + + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "second") }); + try testing.expectEqual(@as(usize, 2), list.len()); + try testing.expectEqualStrings("second", list.getByIndex(1).?.string); + + try list.prepend(.{ .string = try testing.testing.allocator.dupe(u8, "zero") }); + try testing.expectEqual(@as(usize, 3), list.len()); + try testing.expectEqualStrings("zero", list.getByIndex(0).?.string); + try testing.expectEqualStrings("first", list.getByIndex(1).?.string); + try testing.expectEqualStrings("second", list.getByIndex(2).?.string); +} + +test "Store list with mixed value types" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + const list = try store.createList("test_mixed_values"); + + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "hello") }); + try list.append(.{ .int = 42 }); + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "world") }); + + try testing.expectEqual(@as(usize, 3), list.len()); + try testing.expectEqualStrings("hello", list.getByIndex(0).?.string); + try testing.expectEqual(@as(i64, 42), list.getByIndex(1).?.int); + try testing.expectEqualStrings("world", list.getByIndex(2).?.string); +} + +test "Store getList with wrong type" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("notalist", "hello"); + + const list = store.getList("notalist"); + try testing.expect(list == error.WrongType); +} + +test "Store list type checking" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + _ = try store.createList("mylist"); + try testing.expectEqual(ValueType.list, store.getType("mylist").?); +} + +test "Store overwrite string with list" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try store.set("key1", "hello"); + try testing.expectEqual(ValueType.short_string, store.getType("key1").?); + + _ = try store.createList("key1"); + try testing.expectEqual(ValueType.list, store.getType("key1").?); + + const list = try store.getList("key1"); + try testing.expect(list != null); + try testing.expectEqual(@as(usize, 0), list.?.len()); +} + +test "Store overwrite list with string" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + const list = try store.createList("key1"); + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "item") }); + try testing.expectEqual(ValueType.list, store.getType("key1").?); + + try store.set("key1", "hello"); + try testing.expectEqual(ValueType.short_string, store.getType("key1").?); + try testing.expectEqualStrings("hello", store.get("key1").?.value.short_string.asSlice()); + + const retrieved_list = store.getList("key1"); + try testing.expect(retrieved_list == error.WrongType); +} + +test "Store delete list key" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + const list = try store.createList("mylist"); + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "item1") }); + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "item2") }); + + try testing.expect(store.exists("mylist")); + try testing.expectEqual(@as(u32, 1), store.size()); + + const deleted = store.delete("mylist"); + try testing.expect(deleted); + try testing.expect(!store.exists("mylist")); + try testing.expectEqual(@as(u32, 0), store.size()); + try testing.expect(try store.getList("mylist") == null); +} + +test "Store empty list operations" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + const list = try store.createList("test_empty_ops"); + try testing.expectEqual(@as(usize, 0), list.len()); + + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "") }); + try testing.expectEqual(@as(usize, 1), list.len()); + try testing.expectEqualStrings("", list.getByIndex(0).?.string); + + try list.append(.{ .int = 0 }); + try testing.expectEqual(@as(usize, 2), list.len()); + try testing.expectEqual(@as(i64, 0), list.getByIndex(1).?.int); +} + +test "Store flush_db removes all keys" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // Add various types of keys + try store.set("str1", "hello"); + try store.set("str2", "world"); + try store.setInt("int1", 42); + try store.setInt("int2", -100); + + const list = try store.createList("mylist"); + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "item1") }); + try list.append(.{ .string = try testing.testing.allocator.dupe(u8, "item2") }); + + // Verify all keys exist + try testing.expectEqual(@as(u32, 5), store.size()); + try testing.expect(store.exists("str1")); + try testing.expect(store.exists("str2")); + try testing.expect(store.exists("int1")); + try testing.expect(store.exists("int2")); + try testing.expect(store.exists("mylist")); + + // Flush the database + store.flush_db(); + + // Verify all keys are removed + try testing.expectEqual(@as(u32, 0), store.size()); + try testing.expect(!store.exists("str1")); + try testing.expect(!store.exists("str2")); + try testing.expect(!store.exists("int1")); + try testing.expect(!store.exists("int2")); + try testing.expect(!store.exists("mylist")); + + // Verify getting keys returns null + try testing.expect(store.get("str1") == null); + try testing.expect(store.get("int1") == null); + try testing.expect(try store.getList("mylist") == null); +} + +test "Store flush_db on empty store" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + try testing.expectEqual(@as(u32, 0), store.size()); + + // Flush empty store should not crash + store.flush_db(); + + try testing.expectEqual(@as(u32, 0), store.size()); +} + +test "Store flush_db allows reuse after flush" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + // Add keys + try store.set("key1", "value1"); + try store.setInt("key2", 123); + try testing.expectEqual(@as(u32, 2), store.size()); + + // Flush + store.flush_db(); + try testing.expectEqual(@as(u32, 0), store.size()); + + // Add new keys after flush + try store.set("key3", "value3"); + try store.setInt("key4", 456); + try testing.expectEqual(@as(u32, 2), store.size()); + + // Verify new keys work correctly + try testing.expectEqualStrings("value3", store.get("key3").?.value.short_string.asSlice()); + try testing.expectEqual(@as(i64, 456), store.get("key4").?.value.int); + + // Verify old keys don't exist + try testing.expect(store.get("key1") == null); + try testing.expect(store.get("key2") == null); +} + +test "Store maintenance() rehashes and reduces capacity" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 16 }); + defer store.deinit(); + + // Add many keys to grow the capacity + var i: usize = 0; + while (i < 1000) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + try store.set(key, "value"); + } + + const capacity_before = store.map.capacity(); + const size_before = store.map.count(); + try testing.expect(capacity_before > 0); + try testing.expectEqual(@as(usize, 1000), size_before); + + // Delete half the keys to create tombstones + i = 0; + while (i < 500) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + const deleted = store.delete(key); + try testing.expect(deleted); + } + + const size_after_delete = store.map.count(); + try testing.expectEqual(@as(usize, 500), size_after_delete); + + // Deletes may trigger automatic maintenance, but capacity should never grow here. + try testing.expect(store.map.capacity() <= capacity_before); + + // Run maintenance to clean up tombstones + store.maintenance(); + + const capacity_after = store.map.capacity(); + const size_after = store.map.count(); + + // Size should remain the same + try testing.expectEqual(@as(usize, 500), size_after); + + // Capacity should be reduced or at least not larger + try testing.expect(capacity_after <= capacity_before); + + // Verify remaining keys are still accessible + i = 500; + while (i < 1000) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + const result = store.get(key); + try testing.expect(result != null); + try testing.expectEqualStrings("value", result.?.value.short_string.asSlice()); + } +} + +test "Store maintenance() resets deletion counter" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 16 }); + defer store.deinit(); + + // Add and delete keys to increment deletion counter + var i: usize = 0; + while (i < 100) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + try store.set(key, "value"); + _ = store.delete(key); + } + + try testing.expect(store.deletions_since_rehash > 0); + + // Run maintenance + store.maintenance(); + + // Deletion counter should be reset + try testing.expectEqual(@as(usize, 0), store.deletions_since_rehash); +} + +test "Store maybeMaintenance() respects rate limiting" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 16 }); + defer store.deinit(); + + // Add enough keys to trigger capacity growth + var i: usize = 0; + while (i < 1000) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + try store.set(key, "value"); + } + + // Delete many keys to exceed threshold + i = 0; + while (i < 700) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + _ = store.delete(key); + } + + const capacity_before = store.map.capacity(); + + // Reset last_maintenance_check to ensure our explicit call isn't rate-limited + // (delete() calls maybeMaintenance() automatically, which may have updated it recently) + store.last_maintenance_check = 0; + const last_check_before = store.last_maintenance_check; + + // Call maybeMaintenance multiple times in quick succession + store.maybeMaintenance(); + const capacity_after_first = store.map.capacity(); + const last_check_after_first = store.last_maintenance_check; + + // First call should trigger maintenance + try testing.expect(capacity_after_first <= capacity_before); + try testing.expect(last_check_after_first > last_check_before); + + // Immediately call again (within 50ms) + store.maybeMaintenance(); + const capacity_after_second = store.map.capacity(); + const last_check_after_second = store.last_maintenance_check; + + // Second call should be rate-limited (no maintenance) + try testing.expectEqual(capacity_after_first, capacity_after_second); + try testing.expectEqual(last_check_after_first, last_check_after_second); +} + +test "Store maybeMaintenance() triggers on 50% waste threshold" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 16 }); + defer store.deinit(); + + // Add many keys + var i: usize = 0; + while (i < 1000) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + try store.set(key, "value"); + } + + const capacity_before = store.map.capacity(); + + // Delete more than 50% of capacity to trigger waste threshold + // (capacity - count) > capacity / 2 + const target_deletions = (capacity_before / 2) + 10; + i = 0; + while (i < target_deletions) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + _ = store.delete(key); + } + + // Reset last_maintenance_check to avoid rate limiting + store.last_maintenance_check = 0; + + // This should trigger maintenance due to waste threshold + store.maybeMaintenance(); + + // Deletion counter should be reset after maintenance + try testing.expectEqual(@as(usize, 0), store.deletions_since_rehash); +} + +test "Store maybeMaintenance() triggers on 25% deletions threshold" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 16 }); + defer store.deinit(); + + // Add keys to establish capacity + var i: usize = 0; + while (i < 500) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + try store.set(key, "value"); + } + + const capacity = store.map.capacity(); + const threshold = capacity / 4; + + // Block the implicit maintenance calls inside delete() so this test can + // deterministically verify the explicit maybeMaintenance() invocation. + store.last_maintenance_check = store.clock.now().toMilliseconds() + 60_000; + + // Delete exactly threshold + 1 keys to trigger maintenance + i = 0; + while (i < threshold + 1) : (i += 1) { + const key = try std.fmt.allocPrint(testing.allocator, "key{d}", .{i}); + defer testing.allocator.free(key); + _ = store.delete(key); + } + + try testing.expectEqual(threshold + 1, store.deletions_since_rehash); + + // Reset last_maintenance_check to avoid rate limiting + store.last_maintenance_check = 0; + + // This should trigger maintenance due to deletion threshold + store.maybeMaintenance(); + + // Deletion counter should be reset after maintenance + try testing.expectEqual(@as(usize, 0), store.deletions_since_rehash); +} + +test "Store deletion tracking increments counter" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 16 }); + defer store.deinit(); + + // Initially, deletion counter should be 0 + try testing.expectEqual(@as(usize, 0), store.deletions_since_rehash); + + // Add and delete some keys + try store.set("key1", "value1"); + try store.set("key2", "value2"); + try store.set("key3", "value3"); + + // Keep delete() from auto-triggering maintenance so we can verify the + // raw deletion counter behavior directly. + store.last_maintenance_check = store.clock.now().toMilliseconds() + 60_000; + + _ = store.delete("key1"); + try testing.expect(store.deletions_since_rehash >= 1); + + _ = store.delete("key2"); + try testing.expect(store.deletions_since_rehash >= 2); + + _ = store.delete("key3"); + try testing.expect(store.deletions_since_rehash >= 3); + + // Deleting non-existent key should not increment + const before_failed_delete = store.deletions_since_rehash; + _ = store.delete("nonexistent"); + try testing.expectEqual(before_failed_delete, store.deletions_since_rehash); +} + +test "Store evictOne allkeys_lru evicts least recently used key" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ + .initial_capacity = 16, + .eviction_policy = .allkeys_lru, + .maxmemory_samples = 5, + }); + defer store.deinit(); + + try store.set("key1", "value1"); + try store.set("key2", "value2"); + try store.set("key3", "value3"); + + _ = store.get("key1"); + + try testing.expect(store.evictOne(.allkeys_lru)); + try testing.expect(store.get("key2") == null); + try testing.expect(store.get("key1") != null); + try testing.expect(store.get("key3") != null); +} + +test "Store evictOne volatile_lru only evicts volatile keys" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ + .initial_capacity = 16, + .eviction_policy = .volatile_lru, + .maxmemory_samples = 5, + }); + defer store.deinit(); + + try store.set("persistent", "value"); + try store.set("ttl1", "value1"); + try store.set("ttl2", "value2"); + + const now = Io.Clock.real.now(testing.io).toMilliseconds(); + _ = try store.expire("ttl1", now + 10_000); + _ = try store.expire("ttl2", now + 10_000); + + _ = store.get("ttl1"); + + try testing.expect(store.evictOne(.volatile_lru)); + try testing.expect(store.get("ttl2") == null); + try testing.expect(store.get("ttl1") != null); + try testing.expect(store.get("persistent") != null); +} + +test "Store evictOne prefers expired ttl entries before LRU tail" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ + .initial_capacity = 16, + .eviction_policy = .allkeys_lru, + .maxmemory_samples = 5, + }); + defer store.deinit(); + + try store.set("fresh1", "value1"); + try store.set("expired", "value2"); + try store.set("fresh2", "value3"); + + _ = try store.expire("expired", 1); + _ = store.get("fresh2"); + + try testing.expect(store.evictOne(.allkeys_lru)); + try testing.expect(store.get("expired") == null); + try testing.expect(store.get("fresh1") != null); + try testing.expect(store.get("fresh2") != null); +} diff --git a/src/test_runner.zig b/src/test_runner.zig deleted file mode 100644 index 98dbb93..0000000 --- a/src/test_runner.zig +++ /dev/null @@ -1,319 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const time = std.time; - -/// Test result tracking -pub const TestResult = struct { - name: []const u8, - passed: bool, - error_message: ?[]const u8, - duration_ns: u64, -}; - -/// Test statistics -pub const TestStats = struct { - total: u32 = 0, - passed: u32 = 0, - failed: u32 = 0, - skipped: u32 = 0, - duration_ns: u64 = 0, - - pub fn success_rate(self: TestStats) f64 { - if (self.total == 0) return 0.0; - return @as(f64, @floatFromInt(self.passed)) / @as(f64, @floatFromInt(self.total)); - } -}; - -/// Test runner configuration -pub const TestConfig = struct { - filter: ?[]const u8 = null, - verbose: bool = false, - quiet: bool = false, - parallel: bool = false, - timeout_ms: u32 = 30000, // 30 second default timeout - max_threads: u32 = 0, // 0 = use CPU count -}; - -/// Enhanced test runner with filtering and reporting -pub const TestRunner = struct { - allocator: std.mem.Allocator, - config: TestConfig, - results: std.array_list.Managed(TestResult), - stats: TestStats, - - const Self = @This(); - - pub fn init(allocator: std.mem.Allocator, config: TestConfig) Self { - return Self{ - .allocator = allocator, - .config = config, - .results = std.array_list.Managed(TestResult).init(allocator), - .stats = .{}, - }; - } - - pub fn deinit(self: *Self) void { - for (self.results.items) |result| { - if (result.error_message) |msg| { - self.allocator.free(msg); - } - } - self.results.deinit(); - } - - /// Check if a test name matches the filter - pub fn matchesFilter(self: *const Self, test_name: []const u8) bool { - if (self.config.filter == null) return true; - - const filter = self.config.filter.?; - return std.mem.indexOf(u8, test_name, filter) != null; - } - - /// Run a single test function - pub fn runTest(self: *Self, comptime test_name: []const u8, comptime test_func: fn () anyerror!void) !void { - if (!self.matchesFilter(test_name)) return; - - const test_start = try time.Timer.start(); - - if (!self.config.quiet) { - if (self.config.verbose) { - std.debug.print("Running test: {s}...\n", .{test_name}); - } else { - std.debug.print(".", .{}); - } - } - - var result = TestResult{ - .name = test_name, - .passed = false, - .error_message = null, - .duration_ns = 0, - }; - - // Run the test with error handling - test_func() catch |err| { - result.passed = false; - result.error_message = try std.fmt.allocPrint(self.allocator, "{}", .{err}); - self.stats.failed += 1; - }; - - if (result.error_message == null) { - result.passed = true; - self.stats.passed += 1; - } - - result.duration_ns = test_start.read(); - self.stats.total += 1; - self.stats.duration_ns += result.duration_ns; - - try self.results.append(result); - } - - /// Print comprehensive test report - pub fn printReport(self: *Self) void { - const total_duration = @as(f64, @floatFromInt(self.stats.duration_ns)) / 1_000_000_000.0; - - if (!self.config.quiet) { - std.debug.print("\n\n"); - std.debug.print("========================================\n"); - std.debug.print(" TEST RESULTS\n"); - std.debug.print("========================================\n"); - std.debug.print("Total: {d}\n", .{self.stats.total}); - std.debug.print("Passed: {d}\n", .{self.stats.passed}); - std.debug.print("Failed: {d}\n", .{self.stats.failed}); - std.debug.print("Success: {d:.1}%\n", .{self.stats.success_rate() * 100.0}); - std.debug.print("Time: {d:.3}s\n", .{total_duration}); - std.debug.print("========================================\n"); - } - - // Print detailed failure information - if (self.stats.failed > 0) { - std.debug.print("\nFAILED TESTS:\n"); - std.debug.print("----------------------------------------\n"); - - for (self.results.items) |result| { - if (!result.passed) { - const duration_ms = @as(f64, @floatFromInt(result.duration_ns)) / 1_000_000.0; - std.debug.print("✗ {s} ({d:.2}ms)\n", .{ result.name, duration_ms }); - if (result.error_message) |msg| { - std.debug.print(" Error: {s}\n", .{msg}); - } - } - } - std.debug.print("----------------------------------------\n"); - } - - // Print verbose success information if requested - if (self.config.verbose and self.stats.passed > 0) { - std.debug.print("\nPASSED TESTS:\n"); - std.debug.print("----------------------------------------\n"); - - for (self.results.items) |result| { - if (result.passed) { - const duration_ms = @as(f64, @floatFromInt(result.duration_ns)) / 1_000_000.0; - std.debug.print("✓ {s} ({d:.2}ms)\n", .{ result.name, duration_ms }); - } - } - std.debug.print("----------------------------------------\n"); - } - - if (self.stats.failed == 0) { - std.debug.print("\n🎉 All tests passed!\n"); - } else { - std.debug.print("\n❌ {d} test(s) failed.\n", .{self.stats.failed}); - } - } - - /// Get exit code based on test results - pub fn getExitCode(self: *const Self) u8 { - return if (self.stats.failed == 0) 0 else 1; - } -}; - -/// Helper macro for running all tests in a module -pub fn runAllTests( - allocator: std.mem.Allocator, - config: TestConfig, - comptime test_module: type, -) !u8 { - var runner = TestRunner.init(allocator, config); - defer runner.deinit(); - - const module_info = @typeInfo(test_module); - - if (module_info != .Struct) { - @compileError("Expected struct type for test module"); - } - - // Run all test functions in the module - inline for (module_info.Struct.decls) |decl| { - if (comptime std.mem.startsWith(u8, decl.name, "test")) { - const test_func = @field(test_module, decl.name); - const func_info = @typeInfo(@TypeOf(test_func)); - - if (func_info == .Fn and func_info.Fn.params.len == 0) { - try runner.runTest(decl.name, test_func); - } - } - } - - runner.printReport(); - return runner.getExitCode(); -} - -/// Parse command line arguments into TestConfig -pub fn parseArgs(_: std.mem.Allocator, args: []const []const u8) !TestConfig { - var config = TestConfig{}; - - var i: usize = 0; - while (i < args.len) : (i += 1) { - const arg = args[i]; - - if (std.mem.eql(u8, arg, "--filter") or std.mem.eql(u8, arg, "-f")) { - if (i + 1 >= args.len) { - std.debug.print("Error: --filter requires a value\n", .{}); - return error.InvalidArgument; - } - i += 1; - config.filter = args[i]; - } else if (std.mem.eql(u8, arg, "--verbose") or std.mem.eql(u8, arg, "-v")) { - config.verbose = true; - } else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q")) { - config.quiet = true; - } else if (std.mem.eql(u8, arg, "--parallel") or std.mem.eql(u8, arg, "-p")) { - config.parallel = true; - } else if (std.mem.eql(u8, arg, "--timeout")) { - if (i + 1 >= args.len) { - std.debug.print("Error: --timeout requires a value\n", .{}); - return error.InvalidArgument; - } - i += 1; - config.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch { - std.debug.print("Error: Invalid timeout value: {s}\n", .{args[i]}); - return error.InvalidArgument; - }; - } else if (std.mem.eql(u8, arg, "--max-threads")) { - if (i + 1 >= args.len) { - std.debug.print("Error: --max-threads requires a value\n", .{}); - return error.InvalidArgument; - } - i += 1; - config.max_threads = std.fmt.parseInt(u32, args[i], 10) catch { - std.debug.print("Error: Invalid max-threads value: {s}\n", .{args[i]}); - return error.InvalidArgument; - }; - } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { - printHelp(); - return error.HelpRequested; - } else if (!std.mem.startsWith(u8, arg, "-")) { - // Treat non-flag arguments as filter patterns - config.filter = arg; - } - } - - return config; -} - -fn printHelp() void { - std.debug.print( - \\Zedis Test Runner - \\ - \\USAGE: - \\ zig build test [OPTIONS] [FILTER] - \\ - \\OPTIONS: - \\ -f, --filter PATTERN Run only tests matching PATTERN - \\ -v, --verbose Show detailed output for all tests - \\ -q, --quiet Suppress progress output - \\ -p, --parallel Run tests in parallel (when supported) - \\ --timeout MS Set test timeout in milliseconds (default: 30000) - \\ --max-threads N Maximum number of threads for parallel execution - \\ -h, --help Show this help message - \\ - \\EXAMPLES: - \\ zig build test # Run all tests - \\ zig build test -- string # Run tests matching "string" - \\ zig build test -- --verbose # Run all tests with verbose output - \\ zig build test -- --filter SET # Run only SET-related tests - \\ - , .{}); -} - -test "TestRunner basic functionality" { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - const config = TestConfig{}; - var runner = TestRunner.init(allocator, config); - defer runner.deinit(); - - // Test the filter matching - try std.testing.expect(runner.matchesFilter("test_something")); - - const config_with_filter = TestConfig{ .filter = "string" }; - var filtered_runner = TestRunner.init(allocator, config_with_filter); - defer filtered_runner.deinit(); - - try std.testing.expect(filtered_runner.matchesFilter("test_string_operations")); - try std.testing.expect(!filtered_runner.matchesFilter("test_integer_operations")); -} - -test "TestConfig argument parsing" { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - // Test basic arguments - const args1 = [_][]const u8{ "--verbose", "--filter", "test_string" }; - const config1 = try parseArgs(allocator, &args1); - - try std.testing.expect(config1.verbose); - try std.testing.expectEqualStrings("test_string", config1.filter.?); - - // Test timeout parsing - const args2 = [_][]const u8{ "--timeout", "5000" }; - const config2 = try parseArgs(allocator, &args2); - - try std.testing.expectEqual(@as(u32, 5000), config2.timeout_ms); -} diff --git a/src/test_utils.zig b/src/test_utils.zig deleted file mode 100644 index 8057992..0000000 --- a/src/test_utils.zig +++ /dev/null @@ -1,329 +0,0 @@ -// const std = @import("std"); -// const Store = @import("store.zig").Store; -// const Value = @import("parser.zig").Value; -// const PrimitiveValue = @import("store.zig").PrimitiveValue; - -// pub const MockClient = struct { -// client_id: u64, -// allocator: std.mem.Allocator, -// store: *Store, -// pubsub_context: *MockPubSubContext, -// output: std.ArrayList(u8), - -// pub fn init(allocator: std.mem.Allocator, store: *Store, pubsub_context: *MockPubSubContext) MockClient { -// return MockClient{ -// .client_id = 1, -// .allocator = allocator, -// .store = store, -// .pubsub_context = pubsub_context, -// .output = std.ArrayList(u8).initCapacity(allocator, 10), -// }; -// } - -// // Legacy init for existing tests (without pubsub functionality) -// pub fn initLegacy(allocator: std.mem.Allocator, store: *Store) MockClient { -// // Create a dummy pubsub context for legacy tests -// var dummy_server = MockServer{ -// .allocator = allocator, -// .channels = [_]?[]const u8{null} ** 8, -// .subscribers = [_][16]u64{[_]u64{0} ** 16} ** 8, -// .subscriber_counts = [_]u32{0} ** 8, -// .clients = std.ArrayList(*MockClient).initCapacity(allocator, 10), -// .channel_count = 0, -// }; - -// var dummy_context = MockPubSubContext.init(&dummy_server); - -// return MockClient{ -// .client_id = 1, -// .allocator = allocator, -// .store = store, -// .pubsub_context = &dummy_context, -// .output = std.ArrayList(u8).initCapacity(allocator, 10), -// }; -// } - -// pub fn initWithId(client_id: u64, allocator: std.mem.Allocator, store: *Store, pubsub_context: *MockPubSubContext) MockClient { -// return MockClient{ -// .client_id = client_id, -// .allocator = allocator, -// .store = store, -// .pubsub_context = pubsub_context, -// .output = std.ArrayList(u8).initCapacity(allocator, 10), -// }; -// } - -// pub fn deinit(self: *MockClient) void { -// self.output.deinit(); -// } - -// pub fn writeBulkString(self: *MockClient, str: []const u8) !void { -// try self.output.writer().print("${d}\r\n{s}\r\n", .{ str.len, str }); -// } - -// pub fn writeNull(self: *MockClient) !void { -// try self.output.appendSlice(self.allocator, "$-1\r\n"); -// } - -// pub fn writeError(self: *MockClient, comptime fmt: []const u8, args: anytype) !void { -// try self.output.appendSlice(self.allocator, "-"); -// try self.output.writer().print(fmt, args); -// try self.output.appendSlice(self.allocator, "\r\n"); -// } - -// pub fn writeInt(self: *MockClient, num: anytype) !void { -// try self.output.writer().print(":{d}\r\n", .{num}); -// } - -// pub fn writePrimitiveValue(self: *MockClient, value: PrimitiveValue) !void { -// switch (value) { -// .string => |s| try self.writeBulkString(s), -// .int => |i| try self.writeIntAsString(i), -// } -// } - -// pub fn getOutput(self: *MockClient) []const u8 { -// return self.output.items; -// } - -// pub fn testDecrby(self: *MockClient, args: []const Value) !void { -// const key = args[1].asSlice(); -// const decrement = args[2].asInt() catch { -// try self.writeError("ERR value is not an integer or out of range", .{}); -// return; -// }; - -// const current_value = self.store.get(key); -// var new_value: i64 = -decrement; - -// if (current_value) |v| { -// switch (v.value) { -// .string => |s| { -// const int_val = std.fmt.parseInt(i64, s, 10) catch { -// try self.writeError("ERR value is not an integer or out of range", .{}); -// return; -// }; -// new_value = std.math.sub(i64, int_val, decrement) catch { -// try self.writeError("ERR value is not an integer or out of range", .{}); -// return; -// }; -// }, -// .short_string => |ss| { -// const int_val = std.fmt.parseInt(i64, ss.asSlice(), 10) catch { -// try self.writeError("ERR value is not an integer or out of range", .{}); -// return; -// }; -// new_value = std.math.sub(i64, int_val, decrement) catch { -// try self.writeError("ERR value is not an integer or out of range", .{}); -// return; -// }; -// }, -// .int => |i| { -// new_value = std.math.sub(i64, i, decrement) catch { -// try self.writeError("ERR value is not an integer or out of range", .{}); -// return; -// }; -// }, -// .list => { -// try self.writeError("WRONGTYPE Operation against a key holding the wrong kind of value", .{}); -// return; -// }, -// } -// } - -// try self.store.setInt(key, new_value); -// const result_str = try std.fmt.allocPrint(self.allocator, "{d}", .{new_value}); -// defer self.allocator.free(result_str); -// try self.writeBulkString(result_str); -// } - -// // List command test methods -// pub fn writeListLen(self: *MockClient, count: usize) !void { -// try self.output.writer().print("*{d}\r\n", .{count}); -// } - -// pub fn writeIntAsString(self: *MockClient, i: i64) !void { -// var buf: [21]u8 = undefined; // Enough for i64 -// const int_str = try std.fmt.bufPrint(&buf, "{}", .{i}); -// try self.writeBulkString(int_str); -// } - -// pub fn writeTupleAsArray(self: *MockClient, items: anytype) !void { -// const fields = std.meta.fields(@TypeOf(items)); -// try self.output.writer().print("*{d}\r\n", .{fields.len}); - -// inline for (fields) |field| { -// const value = @field(items, field.name); -// switch (@TypeOf(value)) { -// []const u8 => try self.writeBulkString(value), -// i64, u64, u32, i32 => try self.output.writer().print(":{d}\r\n", .{value}), -// else => { -// // Handle string literals like *const [N:0]u8 -// const TypeInfo = @typeInfo(@TypeOf(value)); -// switch (TypeInfo) { -// .pointer => |ptr_info| { -// // Handle both *const [N:0]u8 and []const u8 types -// const child_info = @typeInfo(ptr_info.child); -// if (ptr_info.child == u8 or (child_info == .array and child_info.array.child == u8)) { -// try self.writeBulkString(value); -// } else { -// @compileError("Unsupported tuple field type: " ++ @typeName(@TypeOf(value))); -// } -// }, -// else => @compileError("Unsupported tuple field type: " ++ @typeName(@TypeOf(value))), -// } -// }, -// } -// } -// } -// }; - -// // MockServer for testing PubSub functionality -// pub const MockServer = struct { -// allocator: std.mem.Allocator, -// channels: [8]?[]const u8, // Channel names (reduced for tests) -// subscribers: [8][16]u64, // Subscriber lists per channel (reduced for tests) -// subscriber_counts: [8]u32, // Number of subscribers per channel -// clients: std.ArrayList(*MockClient), // List of connected clients -// channel_count: u32, - -// pub fn init(allocator: std.mem.Allocator) MockServer { -// return MockServer{ -// .allocator = allocator, -// .channels = [_]?[]const u8{null} ** 8, -// .subscribers = [_][16]u64{[_]u64{0} ** 16} ** 8, -// .subscriber_counts = [_]u32{0} ** 8, -// .clients = .initCapacity(allocator, 10), -// .channel_count = 0, -// }; -// } - -// pub fn deinit(self: *MockServer) void { -// // Free allocated channel names -// for (self.channels) |channel| { -// if (channel) |name| { -// self.allocator.free(name); -// } -// } -// self.clients.deinit(self.allocator); -// } - -// pub fn addClient(self: *MockServer, client: *MockClient) !void { -// try self.clients.append(self.allocator, client); -// } - -// pub fn findOrCreateChannel(self: *MockServer, channel_name: []const u8) ?u32 { -// // Check if channel already exists -// for (self.channels[0..self.channel_count], 0..) |existing_name, i| { -// if (existing_name) |name| { -// if (std.mem.eql(u8, name, channel_name)) { -// return @intCast(i); -// } -// } -// } - -// // Create new channel if we have space -// if (self.channel_count >= self.channels.len) { -// return null; // Maximum channels reached -// } - -// const owned_name = self.allocator.dupe(u8, channel_name) catch return null; -// self.channels[self.channel_count] = owned_name; -// const channel_id = self.channel_count; -// self.channel_count += 1; -// return channel_id; -// } - -// pub fn subscribeToChannel(self: *MockServer, channel_id: u32, client_id: u64) !void { -// if (channel_id >= self.channel_count) return error.InvalidChannel; - -// const current_count = self.subscriber_counts[channel_id]; -// if (current_count >= self.subscribers[channel_id].len) { -// return error.ChannelFull; -// } - -// // Check if already subscribed -// for (self.subscribers[channel_id][0..current_count]) |existing_id| { -// if (existing_id == client_id) return; // Already subscribed -// } - -// self.subscribers[channel_id][current_count] = client_id; -// self.subscriber_counts[channel_id] += 1; -// } - -// pub fn unsubscribeFromChannel(self: *MockServer, channel_id: u32, client_id: u64) void { -// if (channel_id >= self.channel_count) return; - -// const current_count = self.subscriber_counts[channel_id]; -// var i: u32 = 0; -// while (i < current_count) : (i += 1) { -// if (self.subscribers[channel_id][i] == client_id) { -// // Move last subscriber to this position -// if (i < current_count - 1) { -// self.subscribers[channel_id][i] = self.subscribers[channel_id][current_count - 1]; -// } -// self.subscriber_counts[channel_id] -= 1; -// return; -// } -// } -// } - -// pub fn getChannelSubscribers(self: *MockServer, channel_id: u32) []const u64 { -// if (channel_id >= self.channel_count) return &[_]u64{}; -// return self.subscribers[channel_id][0..self.subscriber_counts[channel_id]]; -// } - -// pub fn getChannelNames(self: *MockServer) []const ?[]const u8 { -// return &self.channels; -// } - -// pub fn getChannelCount(self: *MockServer) u32 { -// return self.channel_count; -// } - -// pub fn findClientById(self: *MockServer, client_id: u64) ?*MockClient { -// for (self.clients.items) |client| { -// if (client.client_id == client_id) { -// return client; -// } -// } -// return null; -// } -// }; - -// // MockPubSubContext that wraps MockServer -// pub const MockPubSubContext = struct { -// server: *MockServer, - -// pub fn init(server: *MockServer) MockPubSubContext { -// return MockPubSubContext{ .server = server }; -// } - -// pub fn findOrCreateChannel(self: *MockPubSubContext, channel_name: []const u8) ?u32 { -// return self.server.findOrCreateChannel(channel_name); -// } - -// pub fn subscribeToChannel(self: *MockPubSubContext, channel_id: u32, client_id: u64) !void { -// return self.server.subscribeToChannel(channel_id, client_id); -// } - -// pub fn unsubscribeFromChannel(self: *MockPubSubContext, channel_id: u32, client_id: u64) void { -// self.server.unsubscribeFromChannel(channel_id, client_id); -// } - -// pub fn getChannelSubscribers(self: *MockPubSubContext, channel_id: u32) []const u64 { -// return self.server.getChannelSubscribers(channel_id); -// } - -// pub fn getChannelNames(self: *MockPubSubContext) []const ?[]const u8 { -// return self.server.getChannelNames(); -// } - -// pub fn getChannelCount(self: *MockPubSubContext) u32 { -// return self.server.getChannelCount(); -// } - -// pub fn findClientById(self: *MockPubSubContext, client_id: u64) ?*MockClient { -// return self.server.findClientById(client_id); -// } -// }; diff --git a/src/testing/bloom.zig b/src/testing/bloom.zig deleted file mode 100644 index 0522f35..0000000 --- a/src/testing/bloom.zig +++ /dev/null @@ -1,458 +0,0 @@ -const std = @import("std"); -const Store = @import("../store.zig").Store; -const Value = @import("../parser.zig").Value; -const testing = std.testing; -const bloom_commands = @import("../commands/bloom.zig"); -const Io = std.Io; -const Writer = Io.Writer; -const Clock = @import("../clock.zig"); - -test "BF.RESERVE command with valid parameters" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, // error rate - .{ .data = "1000" }, // capacity - }; - - try bloom_commands.bf_reserve(&writer, &store, &args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - // Verify the bloom filter was created - const bf = try store.getBloomFilter("bloom1"); - try testing.expect(bf != null); -} - -test "BF.RESERVE command with invalid error rate too high" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "1.5" }, // invalid error rate > 1.0 - .{ .data = "1000" }, - }; - - try testing.expectError(error.InvalidArgument, bloom_commands.bf_reserve(&writer, &store, &args)); -} - -test "BF.RESERVE command with missing arguments" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - // Missing error rate and capacity - }; - - try testing.expectError(error.WrongNumberOfArguments, bloom_commands.bf_reserve(&writer, &store, &args)); -} - -test "BF.ADD command with new item" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // First reserve a bloom filter - var buffer1: [4096]u8 = undefined; - var writer1 = Writer.fixed(&buffer1); - - const reserve_args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, - .{ .data = "1000" }, - }; - - try bloom_commands.bf_reserve(&writer1, &store, &reserve_args); - - // Now add an item - var buffer2: [4096]u8 = undefined; - var writer2 = Writer.fixed(&buffer2); - - const args = [_]Value{ - .{ .data = "BF.ADD" }, - .{ .data = "bloom1" }, - .{ .data = "item1" }, - }; - - try bloom_commands.bf_add(&writer2, &store, &args); - - try testing.expectEqualStrings(":1\r\n", writer2.buffered()); // 1 means newly added -} - -test "BF.ADD command with existing item" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // First reserve a bloom filter - var buffer1: [4096]u8 = undefined; - var writer1 = Writer.fixed(&buffer1); - - const reserve_args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, - .{ .data = "1000" }, - }; - - try bloom_commands.bf_reserve(&writer1, &store, &reserve_args); - - // Add an item first - var buffer2: [4096]u8 = undefined; - var writer2 = Writer.fixed(&buffer2); - - const add_args1 = [_]Value{ - .{ .data = "BF.ADD" }, - .{ .data = "bloom1" }, - .{ .data = "item1" }, - }; - - try bloom_commands.bf_add(&writer2, &store, &add_args1); - - // Add the same item again - var buffer3: [4096]u8 = undefined; - var writer3 = Writer.fixed(&buffer3); - - const add_args2 = [_]Value{ - .{ .data = "BF.ADD" }, - .{ .data = "bloom1" }, - .{ .data = "item1" }, - }; - - try bloom_commands.bf_add(&writer3, &store, &add_args2); - - try testing.expectEqualStrings(":0\r\n", writer3.buffered()); // 0 means already existed -} - -test "BF.EXISTS command with existing item" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // First reserve a bloom filter - var buffer1: [4096]u8 = undefined; - var writer1 = Writer.fixed(&buffer1); - - const reserve_args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, - .{ .data = "1000" }, - }; - - try bloom_commands.bf_reserve(&writer1, &store, &reserve_args); - - // Add an item - var buffer2: [4096]u8 = undefined; - var writer2 = Writer.fixed(&buffer2); - - const add_args = [_]Value{ - .{ .data = "BF.ADD" }, - .{ .data = "bloom1" }, - .{ .data = "item1" }, - }; - - try bloom_commands.bf_add(&writer2, &store, &add_args); - - // Check if item exists - var buffer3: [4096]u8 = undefined; - var writer3 = Writer.fixed(&buffer3); - - const exists_args = [_]Value{ - .{ .data = "BF.EXISTS" }, - .{ .data = "bloom1" }, - .{ .data = "item1" }, - }; - - try bloom_commands.bf_exists(&writer3, &store, &exists_args); - - try testing.expectEqualStrings(":1\r\n", writer3.buffered()); // 1 means may exist -} - -test "BF.EXISTS command with non-existing item" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // First reserve a bloom filter - var buffer1: [4096]u8 = undefined; - var writer1 = Writer.fixed(&buffer1); - - const reserve_args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, - .{ .data = "1000" }, - }; - - try bloom_commands.bf_reserve(&writer1, &store, &reserve_args); - - // Check if non-existing item exists - var buffer2: [4096]u8 = undefined; - var writer2 = Writer.fixed(&buffer2); - - const exists_args = [_]Value{ - .{ .data = "BF.EXISTS" }, - .{ .data = "bloom1" }, - .{ .data = "nonexistent" }, - }; - - try bloom_commands.bf_exists(&writer2, &store, &exists_args); - - try testing.expectEqualStrings(":0\r\n", writer2.buffered()); // 0 means definitely doesn't exist -} - -test "BF.MADD command with multiple items" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // First reserve a bloom filter - var buffer1: [4096]u8 = undefined; - var writer1 = Writer.fixed(&buffer1); - - const reserve_args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, - .{ .data = "1000" }, - }; - - try bloom_commands.bf_reserve(&writer1, &store, &reserve_args); - - // Add multiple items - var buffer2: [4096]u8 = undefined; - var writer2 = Writer.fixed(&buffer2); - - const args = [_]Value{ - .{ .data = "BF.MADD" }, - .{ .data = "bloom1" }, - .{ .data = "item1" }, - .{ .data = "item2" }, - .{ .data = "item3" }, - }; - - try bloom_commands.bf_madd(&writer2, &store, &args); - - // Should return array with 3 items, all 1 (newly added) - try testing.expectEqualStrings("*3\r\n:1\r\n:1\r\n:1\r\n", writer2.buffered()); -} - -test "BF.MEXISTS command with multiple items" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // First reserve a bloom filter - var buffer1: [4096]u8 = undefined; - var writer1 = Writer.fixed(&buffer1); - - const reserve_args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, - .{ .data = "1000" }, - }; - - try bloom_commands.bf_reserve(&writer1, &store, &reserve_args); - - // Add some items - var buffer2: [4096]u8 = undefined; - var writer2 = Writer.fixed(&buffer2); - - const add_args = [_]Value{ - .{ .data = "BF.ADD" }, - .{ .data = "bloom1" }, - .{ .data = "item1" }, - }; - - try bloom_commands.bf_add(&writer2, &store, &add_args); - - // Check multiple items (some existing, some not) - var buffer3: [4096]u8 = undefined; - var writer3 = Writer.fixed(&buffer3); - - const exists_args = [_]Value{ - .{ .data = "BF.MEXISTS" }, - .{ .data = "bloom1" }, - .{ .data = "item1" }, // should exist - .{ .data = "item2" }, // should not exist - }; - - try bloom_commands.bf_mexists(&writer3, &store, &exists_args); - - // Should return array with 2 items: 1 (exists), 0 (doesn't exist) - try testing.expectEqualStrings("*2\r\n:1\r\n:0\r\n", writer3.buffered()); -} - -test "BF.INFO command" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // First reserve a bloom filter - var buffer1: [4096]u8 = undefined; - var writer1 = Writer.fixed(&buffer1); - - const reserve_args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, - .{ .data = "1000" }, - }; - - try bloom_commands.bf_reserve(&writer1, &store, &reserve_args); - - // Get info - var buffer2: [4096]u8 = undefined; - var writer2 = Writer.fixed(&buffer2); - - const args = [_]Value{ - .{ .data = "BF.INFO" }, - .{ .data = "bloom1" }, - }; - - try bloom_commands.bf_info(&writer2, &store, &args); - - // Should return info array with key-value pairs - // Format: *10\r\n$8\r\nCapacity\r\n:[number]\r\n$4\r\nSize\r\n:[number]\r\n... - const result = writer2.buffered(); - try testing.expect(result.len > 0); - try testing.expect(result[0] == '*'); // Starts with array -} - -test "BF.INSERT command with new bloom filter" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "BF.INSERT" }, - .{ .data = "bloom1" }, - .{ .data = "ITEMS" }, - .{ .data = "item1" }, - .{ .data = "item2" }, - }; - - try bloom_commands.bf_insert(&writer, &store, &args); - - // Should return array with results for each item - const result = writer.buffered(); - try testing.expect(result.len > 0); - try testing.expect(result[0] == '*'); // Starts with array - - // Verify the bloom filter was created and items were added - const bf = try store.getBloomFilter("bloom1"); - try testing.expect(bf != null); - try testing.expect(bf.?.check("item1")); - try testing.expect(bf.?.check("item2")); -} - -test "BF.INSERT command with existing bloom filter" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // First reserve a bloom filter - var buffer1: [4096]u8 = undefined; - var writer1 = Writer.fixed(&buffer1); - - const reserve_args = [_]Value{ - .{ .data = "BF.RESERVE" }, - .{ .data = "bloom1" }, - .{ .data = "0.01" }, - .{ .data = "1000" }, - }; - - try bloom_commands.bf_reserve(&writer1, &store, &reserve_args); - - // Insert items into existing bloom filter - var buffer2: [4096]u8 = undefined; - var writer2 = Writer.fixed(&buffer2); - - const args = [_]Value{ - .{ .data = "BF.INSERT" }, - .{ .data = "bloom1" }, - .{ .data = "ITEMS" }, - .{ .data = "item1" }, - .{ .data = "item2" }, - }; - - try bloom_commands.bf_insert(&writer2, &store, &args); - - // Should return array with results for each item - const result = writer2.buffered(); - try testing.expect(result.len > 0); - try testing.expect(result[0] == '*'); // Starts with array -} diff --git a/src/testing/connection.zig b/src/testing/connection.zig deleted file mode 100644 index 1c26ec7..0000000 --- a/src/testing/connection.zig +++ /dev/null @@ -1,182 +0,0 @@ -const std = @import("std"); -const testing = std.testing; -const Writer = std.Io.Writer; - -const Clock = @import("../clock.zig"); -const Store = @import("../store.zig").Store; -const KeyValueAllocator = @import("../kv_allocator.zig"); -const Server = @import("../server.zig"); -const Client = @import("../client.zig").Client; -const Value = @import("../parser.zig").Value; -const connection_commands = @import("../commands/connection.zig"); - -const TestContext = struct { - allocator: std.mem.Allocator, - clock: Clock, - server: Server, - client: Client, - - fn init(self: *TestContext, allocator: std.mem.Allocator) !void { - self.allocator = allocator; - self.clock = Clock.init(testing.io, 0); - - self.server = undefined; - self.server.config = .{ - .appendonly = false, - .kv_memory_budget = 4096, - .maxmemory_samples = 5, - .eviction_policy = .allkeys_lru, - }; - self.server.store = try Store.init(allocator, testing.io, &self.clock, .{ - .initial_capacity = 16, - .eviction_policy = .allkeys_lru, - .maxmemory_samples = 5, - }); - self.server.kv_allocator = try KeyValueAllocator.init(allocator, 4096, .allkeys_lru); - - self.client = undefined; - self.client.allocator = allocator; - self.client.server = &self.server; - } - - fn deinit(self: *TestContext) void { - self.server.store.deinit(); - if (self.server.config.requirepass) |password| { - self.allocator.free(password); - } - } -}; - -test "CONFIG GET returns exact parameter" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - var ctx: TestContext = undefined; - try ctx.init(arena.allocator()); - defer ctx.deinit(); - - var buffer: [256]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "CONFIG" }, - .{ .data = "GET" }, - .{ .data = "appendonly" }, - }; - - try connection_commands.config(&ctx.client, &args, &writer); - - try testing.expectEqualStrings("*2\r\n$10\r\nappendonly\r\n$2\r\nno\r\n", writer.buffered()); -} - -test "CONFIG GET resolves exact alias to canonical name" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - var ctx: TestContext = undefined; - try ctx.init(arena.allocator()); - defer ctx.deinit(); - - var buffer: [256]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "CONFIG" }, - .{ .data = "GET" }, - .{ .data = "kv-memory-budget" }, - }; - - try connection_commands.config(&ctx.client, &args, &writer); - - try testing.expectEqualStrings("*2\r\n$9\r\nmaxmemory\r\n$4\r\n4096\r\n", writer.buffered()); -} - -test "CONFIG GET supports wildcard patterns" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - var ctx: TestContext = undefined; - try ctx.init(arena.allocator()); - defer ctx.deinit(); - - ctx.server.config.maxmemory_samples = 9; - ctx.server.store.maxmemory_samples = 9; - ctx.server.config.eviction_policy = .volatile_lru; - ctx.server.store.eviction_policy = .volatile_lru; - - var buffer: [512]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "CONFIG" }, - .{ .data = "GET" }, - .{ .data = "maxmemory*" }, - }; - - try connection_commands.config(&ctx.client, &args, &writer); - - try testing.expectEqualStrings( - "*6\r\n" ++ - "$9\r\nmaxmemory\r\n$4\r\n4096\r\n" ++ - "$16\r\nmaxmemory-policy\r\n$12\r\nvolatile-lru\r\n" ++ - "$17\r\nmaxmemory-samples\r\n$1\r\n9\r\n", - writer.buffered(), - ); -} - -test "CONFIG SET updates supported runtime parameters" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - var ctx: TestContext = undefined; - try ctx.init(arena.allocator()); - defer ctx.deinit(); - - var buffer: [256]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "CONFIG" }, - .{ .data = "SET" }, - .{ .data = "maxmemory-samples" }, - .{ .data = "11" }, - .{ .data = "eviction-policy" }, - .{ .data = "noeviction" }, - .{ .data = "requirepass" }, - .{ .data = "secret" }, - }; - - try connection_commands.config(&ctx.client, &args, &writer); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - try testing.expectEqual(@as(u32, 11), ctx.server.config.maxmemory_samples); - try testing.expectEqual(@as(usize, 11), ctx.server.store.maxmemory_samples); - try testing.expectEqual(.noeviction, ctx.server.config.eviction_policy); - try testing.expectEqual(.noeviction, ctx.server.store.eviction_policy); - try testing.expectEqual(.noeviction, ctx.server.kv_allocator.eviction_policy); - try testing.expectEqualStrings("secret", ctx.server.config.requirepass.?); -} - -test "CONFIG SET rejects immutable parameters" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - var ctx: TestContext = undefined; - try ctx.init(arena.allocator()); - defer ctx.deinit(); - - var buffer: [256]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "CONFIG" }, - .{ .data = "SET" }, - .{ .data = "appendonly" }, - .{ .data = "yes" }, - }; - - try connection_commands.config(&ctx.client, &args, &writer); - - try testing.expectEqualStrings("-ERR CONFIG SET does not support runtime updates for 'appendonly'\r\n", writer.buffered()); - try testing.expect(!ctx.server.config.appendonly); -} diff --git a/src/testing/keys.zig b/src/testing/keys.zig deleted file mode 100644 index f064cd9..0000000 --- a/src/testing/keys.zig +++ /dev/null @@ -1,537 +0,0 @@ -const std = @import("std"); -const Store = @import("../store.zig").Store; -const Value = @import("../parser.zig").Value; -const testing = std.testing; -const keys_commands = @import("../commands/keys.zig"); -const mem = std.mem; -const Io = std.Io; -const Writer = Io.Writer; -const Clock = @import("../clock.zig"); -const PrimitiveValue = @import("../types.zig").PrimitiveValue; - -test "EXISTS command with existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "value"); - - const args = [_]Value{ - .{ .data = "EXISTS" }, - .{ .data = "mykey" }, - }; - - try keys_commands.exists(&writer, &store, &args); - - try testing.expectEqualStrings(":1\r\n", writer.buffered()); -} - -test "EXISTS command with non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "EXISTS" }, - .{ .data = "nonexistent" }, - }; - - try keys_commands.exists(&writer, &store, &args); - - try testing.expectEqualStrings(":0\r\n", writer.buffered()); -} - -test "KEYS command with wildcard pattern" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("user:1", "alice"); - try store.set("user:2", "bob"); - try store.set("post:1", "hello"); - - const args = [_]Value{ - .{ .data = "KEYS" }, - .{ .data = "*" }, - }; - - try keys_commands.keys(&writer, &store, &args); - - const output = writer.buffered(); - // Should return array of 3 keys - try testing.expect(mem.startsWith(u8, output, "*3\r\n")); -} - -test "KEYS command with empty store" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "KEYS" }, - .{ .data = "*" }, - }; - - try keys_commands.keys(&writer, &store, &args); - - try testing.expectEqualStrings("*0\r\n", writer.buffered()); -} - -test "TTL command with non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "TTL" }, - .{ .data = "nonexistent" }, - }; - - try keys_commands.ttl(&writer, &store, &args); - - try testing.expectEqualStrings(":-2\r\n", writer.buffered()); -} - -test "TTL command with key without expiration" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "value"); - - const args = [_]Value{ - .{ .data = "TTL" }, - .{ .data = "mykey" }, - }; - - try keys_commands.ttl(&writer, &store, &args); - - try testing.expectEqualStrings(":-1\r\n", writer.buffered()); -} - -test "TTL command with key with expiration" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "value"); - const now = Io.Clock.real.now(testing.io); - const future_time = now.toMilliseconds() + 10000; - _ = try store.expire("mykey", future_time); - - const args = [_]Value{ - .{ .data = "TTL" }, - .{ .data = "mykey" }, - }; - - try keys_commands.ttl(&writer, &store, &args); - - const output = writer.buffered(); - // Should return the expiration timestamp - try testing.expect(mem.startsWith(u8, output, ":")); -} - -test "PERSIST command with key having expiration" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "value"); - - const now = Io.Clock.real.now(testing.io); - const future_time = now.toMilliseconds() + 10000; - _ = try store.expire("mykey", future_time); - - const args = [_]Value{ - .{ .data = "PERSIST" }, - .{ .data = "mykey" }, - }; - - try keys_commands.persist(&writer, &store, &args); - - try testing.expectEqualStrings(":1\r\n", writer.buffered()); - - // Verify expiration was removed - const ttl = store.getTtl("mykey"); - try testing.expect(ttl == null); -} - -test "PERSIST command with key without expiration" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "value"); - - const args = [_]Value{ - .{ .data = "PERSIST" }, - .{ .data = "mykey" }, - }; - - try keys_commands.persist(&writer, &store, &args); - - try testing.expectEqualStrings(":0\r\n", writer.buffered()); -} - -test "TYPE command with string value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "hello"); - - const args = [_]Value{ - .{ .data = "TYPE" }, - .{ .data = "mykey" }, - }; - - try keys_commands.typeCmd(&writer, &store, &args); - - try testing.expectEqualStrings("$6\r\nstring\r\n", writer.buffered()); -} - -test "TYPE command with integer value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.setInt("mykey", 42); - - const args = [_]Value{ - .{ .data = "TYPE" }, - .{ .data = "mykey" }, - }; - - try keys_commands.typeCmd(&writer, &store, &args); - - try testing.expectEqualStrings("$6\r\nstring\r\n", writer.buffered()); -} - -test "TYPE command with list value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - _ = try store.createList("mylist"); - - const args = [_]Value{ - .{ .data = "TYPE" }, - .{ .data = "mylist" }, - }; - - try keys_commands.typeCmd(&writer, &store, &args); - - try testing.expectEqualStrings("$4\r\nlist\r\n", writer.buffered()); -} - -test "TYPE command with non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "TYPE" }, - .{ .data = "nonexistent" }, - }; - - try keys_commands.typeCmd(&writer, &store, &args); - - try testing.expectEqualStrings("$4\r\nnone\r\n", writer.buffered()); -} - -test "RENAME command with existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("oldkey", "value"); - - const args = [_]Value{ - .{ .data = "RENAME" }, - .{ .data = "oldkey" }, - .{ .data = "newkey" }, - }; - - try keys_commands.rename(&writer, &store, &args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - // Verify old key is gone - try testing.expect(store.get("oldkey") == null); - - // Verify new key exists with same value - const new_value = store.get("newkey"); - try testing.expect(new_value != null); - try testing.expectEqualStrings("value", new_value.?.value.short_string.asSlice()); -} - -test "RENAME command with non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "RENAME" }, - .{ .data = "nonexistent" }, - .{ .data = "newkey" }, - }; - - const result = keys_commands.rename(&writer, &store, &args); - try testing.expectError(error.KeyNotFound, result); -} - -test "RANDOMKEY command with non-empty store" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("key1", "value1"); - try store.set("key2", "value2"); - try store.set("key3", "value3"); - - const args = [_]Value{ - .{ .data = "RANDOMKEY" }, - }; - - try keys_commands.randomkey(&writer, &store, &args); - - const output = writer.buffered(); - // Should return a bulk string (key) - try testing.expect(mem.startsWith(u8, output, "$")); - // Should not be null - try testing.expect(!mem.eql(u8, output, "$-1\r\n")); -} - -test "RANDOMKEY command with empty store" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "RANDOMKEY" }, - }; - - try keys_commands.randomkey(&writer, &store, &args); - - try testing.expectEqualStrings("$-1\r\n", writer.buffered()); -} - -test "KEYS command returns all keys when pattern is wildcard" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("apple", "fruit"); - try store.set("banana", "fruit"); - try store.setInt("count", 42); - - const args = [_]Value{ - .{ .data = "KEYS" }, - .{ .data = "*" }, - }; - - try keys_commands.keys(&writer, &store, &args); - - const output = writer.buffered(); - // Should return array with 3 elements - try testing.expect(mem.startsWith(u8, output, "*3\r\n")); - // Verify all keys are present in output - try testing.expect(mem.indexOf(u8, output, "apple") != null); - try testing.expect(mem.indexOf(u8, output, "banana") != null); - try testing.expect(mem.indexOf(u8, output, "count") != null); -} - -test "RENAME overwrites existing destination key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("source", "source_value"); - try store.set("dest", "dest_value"); - - const args = [_]Value{ - .{ .data = "RENAME" }, - .{ .data = "source" }, - .{ .data = "dest" }, - }; - - try keys_commands.rename(&writer, &store, &args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - // Verify source is gone - try testing.expect(store.get("source") == null); - - // Verify dest has source's value - const dest_value = store.get("dest"); - try testing.expect(dest_value != null); - try testing.expectEqualStrings("source_value", dest_value.?.value.short_string.asSlice()); -} - -test "RENAME preserves list ownership" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const list = try store.createList("source"); - try list.append(PrimitiveValue{ .int = 42 }); - - const args = [_]Value{ - .{ .data = "RENAME" }, - .{ .data = "source" }, - .{ .data = "dest" }, - }; - - try keys_commands.rename(&writer, &store, &args); - - try testing.expect(store.get("source") == null); - - const renamed_list = (try store.getList("dest")).?; - try testing.expectEqual(@as(usize, 1), renamed_list.len()); - - const item = renamed_list.pop().?; - switch (item) { - .int => |value| try testing.expectEqual(@as(i64, 42), value), - else => return error.TestUnexpectedResult, - } -} diff --git a/src/testing/list.zig b/src/testing/list.zig deleted file mode 100644 index 1d5df88..0000000 --- a/src/testing/list.zig +++ /dev/null @@ -1,1031 +0,0 @@ -const std = @import("std"); -const Store = @import("../store.zig").Store; -const Value = @import("../parser.zig").Value; -const testing = std.testing; -const list_commands = @import("../commands/list.zig"); -const Io = std.Io; -const Writer = Io.Writer; -const Clock = @import("../clock.zig"); - -// LPUSH Tests -test "LPUSH single element to new list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "world" }, - }; - - try list_commands.lpush(&writer, &store, &args); - - try testing.expectEqualStrings(":1\r\n", writer.buffered()); - - // Verify the list was created and contains the element - const list = try store.getList("mylist"); - try testing.expect(list != null); - try testing.expectEqual(@as(usize, 1), list.?.len()); -} - -test "LPUSH multiple elements to new list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "three" }, - .{ .data = "two" }, - .{ .data = "one" }, - }; - - try list_commands.lpush(&writer, &store, &args); - - try testing.expectEqualStrings(":3\r\n", writer.buffered()); - - // Verify the list has 3 elements - const list = try store.getList("mylist"); - try testing.expect(list != null); - try testing.expectEqual(@as(usize, 3), list.?.len()); -} - -test "LPUSH to existing list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // First, add some elements - const args1 = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "initial" }, - }; - try list_commands.lpush(&writer, &store, &args1); - writer = Writer.fixed(&buffer); - - // Then add more elements - const args2 = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "second" }, - .{ .data = "first" }, - }; - try list_commands.lpush(&writer, &store, &args2); - - try testing.expectEqualStrings(":3\r\n", writer.buffered()); - - const list = try store.getList("mylist"); - try testing.expect(list != null); - try testing.expectEqual(@as(usize, 3), list.?.len()); -} - -// RPUSH Tests -test "RPUSH single element to new list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "hello" }, - }; - - try list_commands.rpush(&writer, &store, &args); - - try testing.expectEqualStrings(":1\r\n", writer.buffered()); - - const list = try store.getList("mylist"); - try testing.expect(list != null); - try testing.expectEqual(@as(usize, 1), list.?.len()); -} - -test "RPUSH multiple elements to new list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - - try list_commands.rpush(&writer, &store, &args); - - try testing.expectEqualStrings(":3\r\n", writer.buffered()); - - const list = try store.getList("mylist"); - try testing.expect(list != null); - try testing.expectEqual(@as(usize, 3), list.?.len()); -} - -// LPOP Tests -test "LPOP from list with single element" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // First create a list with one element - const push_args = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "hello" }, - }; - try list_commands.lpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Then pop the element - const pop_args = [_]Value{ - .{ .data = "LPOP" }, - .{ .data = "mylist" }, - }; - try list_commands.lpop(&writer, &store, &pop_args); - - try testing.expectEqualStrings("$5\r\nhello\r\n", writer.buffered()); - - // List should be empty now - const list = try store.getList("mylist"); - try testing.expect(list == null or list.?.len() == 0); -} - -test "LPOP from non-existing list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "LPOP" }, - .{ .data = "nonexistent" }, - }; - - try list_commands.lpop(&writer, &store, &args); - - try testing.expectEqualStrings("$-1\r\n", writer.buffered()); -} - -test "LPOP with count from list with multiple elements" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create a list with multiple elements - const push_args = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "three" }, - .{ .data = "two" }, - .{ .data = "one" }, - }; - try list_commands.lpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Pop 2 elements - const pop_args = [_]Value{ - .{ .data = "LPOP" }, - .{ .data = "mylist" }, - .{ .data = "2" }, - }; - try list_commands.lpop(&writer, &store, &pop_args); - - // Should return an array with 2 elements - try testing.expectEqualStrings("*2\r\n$3\r\none\r\n$3\r\ntwo\r\n", writer.buffered()); - - // List should have 1 element left - const list = try store.getList("mylist"); - try testing.expect(list != null); - try testing.expectEqual(@as(usize, 1), list.?.len()); -} - -test "LPOP with count of 0" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create a list with elements - const push_args = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "hello" }, - }; - try list_commands.lpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Pop 0 elements - const pop_args = [_]Value{ - .{ .data = "LPOP" }, - .{ .data = "mylist" }, - .{ .data = "0" }, - }; - try list_commands.lpop(&writer, &store, &pop_args); - - try testing.expectEqualStrings("$-1\r\n", writer.buffered()); -} - -// RPOP Tests -test "RPOP from list with single element" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // First create a list with one element - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "hello" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Then pop the element - const pop_args = [_]Value{ - .{ .data = "RPOP" }, - .{ .data = "mylist" }, - }; - try list_commands.rpop(&writer, &store, &pop_args); - - try testing.expectEqualStrings("$5\r\nhello\r\n", writer.buffered()); -} - -test "RPOP with count from list with multiple elements" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create a list with multiple elements - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Pop 2 elements from the right - const pop_args = [_]Value{ - .{ .data = "RPOP" }, - .{ .data = "mylist" }, - .{ .data = "2" }, - }; - try list_commands.rpop(&writer, &store, &pop_args); - - // Should return an array with 2 elements (in reverse order from LPOP) - try testing.expectEqualStrings("*2\r\n$5\r\nthree\r\n$3\r\ntwo\r\n", writer.buffered()); - - // List should have 1 element left - const list = try store.getList("mylist"); - try testing.expect(list != null); - try testing.expectEqual(@as(usize, 1), list.?.len()); -} - -// LLEN Tests -test "LLEN on existing list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create a list with elements - const push_args = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.lpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Check length - const llen_args = [_]Value{ - .{ .data = "LLEN" }, - .{ .data = "mylist" }, - }; - try list_commands.llen(&writer, &store, &llen_args); - - try testing.expectEqualStrings(":3\r\n", writer.buffered()); -} - -test "LLEN on non-existing list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "LLEN" }, - .{ .data = "nonexistent" }, - }; - - try list_commands.llen(&writer, &store, &args); - - try testing.expectEqualStrings(":0\r\n", writer.buffered()); -} - -test "LLEN on empty list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create a list and then pop all elements - const push_args = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "temp" }, - }; - try list_commands.lpush(&writer, &store, &push_args); - - const pop_args = [_]Value{ - .{ .data = "LPOP" }, - .{ .data = "mylist" }, - }; - try list_commands.lpop(&writer, &store, &pop_args); - writer = Writer.fixed(&buffer); - - // Check length of now-empty list - const llen_args = [_]Value{ - .{ .data = "LLEN" }, - .{ .data = "mylist" }, - }; - try list_commands.llen(&writer, &store, &llen_args); - - try testing.expectEqualStrings(":0\r\n", writer.buffered()); -} - -test "Mixed LPUSH and RPUSH operations" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // LPUSH "middle" - const lpush_args = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "middle" }, - }; - try list_commands.lpush(&writer, &store, &lpush_args); - writer = Writer.fixed(&buffer); - - // LPUSH "left" - const lpush_args2 = [_]Value{ - .{ .data = "LPUSH" }, - .{ .data = "mylist" }, - .{ .data = "left" }, - }; - try list_commands.lpush(&writer, &store, &lpush_args2); - writer = Writer.fixed(&buffer); - - // RPUSH "right" - const rpush_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "right" }, - }; - try list_commands.rpush(&writer, &store, &rpush_args); - writer = Writer.fixed(&buffer); - - // Should have 3 elements in order: left, middle, right - const llen_args = [_]Value{ - .{ .data = "LLEN" }, - .{ .data = "mylist" }, - }; - try list_commands.llen(&writer, &store, &llen_args); - - try testing.expectEqualStrings(":3\r\n", writer.buffered()); -} - -test "LPOP and RPOP from the same list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // LPOP should get "one" - const lpop_args = [_]Value{ - .{ .data = "LPOP" }, - .{ .data = "mylist" }, - }; - try list_commands.lpop(&writer, &store, &lpop_args); - try testing.expectEqualStrings("$3\r\none\r\n", writer.buffered()); - writer = Writer.fixed(&buffer); - - // RPOP should get "three" - const rpop_args = [_]Value{ - .{ .data = "RPOP" }, - .{ .data = "mylist" }, - }; - try list_commands.rpop(&writer, &store, &rpop_args); - try testing.expectEqualStrings("$5\r\nthree\r\n", writer.buffered()); - writer = Writer.fixed(&buffer); - - // Should have 1 element left ("two") - const llen_args = [_]Value{ - .{ .data = "LLEN" }, - .{ .data = "mylist" }, - }; - try list_commands.llen(&writer, &store, &llen_args); - try testing.expectEqualStrings(":1\r\n", writer.buffered()); -} - -// LINDEX Tests -test "LINDEX get first element" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Get first element (index 0) - const lindex_args = [_]Value{ - .{ .data = "LINDEX" }, - .{ .data = "mylist" }, - .{ .data = "0" }, - }; - try list_commands.lindex(&writer, &store, &lindex_args); - - try testing.expectEqualStrings("$3\r\none\r\n", writer.buffered()); -} - -test "LINDEX get last element with negative index" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Get last element (index -1) - const lindex_args = [_]Value{ - .{ .data = "LINDEX" }, - .{ .data = "mylist" }, - .{ .data = "-1" }, - }; - try list_commands.lindex(&writer, &store, &lindex_args); - - try testing.expectEqualStrings("$5\r\nthree\r\n", writer.buffered()); -} - -test "LINDEX with out of range index" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Try to get element at index 10 - const lindex_args = [_]Value{ - .{ .data = "LINDEX" }, - .{ .data = "mylist" }, - .{ .data = "10" }, - }; - try list_commands.lindex(&writer, &store, &lindex_args); - - try testing.expectEqualStrings("$-1\r\n", writer.buffered()); -} - -test "LINDEX on non-existing list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "LINDEX" }, - .{ .data = "nonexistent" }, - .{ .data = "0" }, - }; - - try list_commands.lindex(&writer, &store, &args); - - try testing.expectEqualStrings("$-1\r\n", writer.buffered()); -} - -// LSET Tests -test "LSET update element at index" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Set element at index 1 to "TWO" - const lset_args = [_]Value{ - .{ .data = "LSET" }, - .{ .data = "mylist" }, - .{ .data = "1" }, - .{ .data = "TWO" }, - }; - try list_commands.lset(&writer, &store, &lset_args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - writer = Writer.fixed(&buffer); - - // Verify the element was updated - const lindex_args = [_]Value{ - .{ .data = "LINDEX" }, - .{ .data = "mylist" }, - .{ .data = "1" }, - }; - try list_commands.lindex(&writer, &store, &lindex_args); - - try testing.expectEqualStrings("$3\r\nTWO\r\n", writer.buffered()); -} - -test "LSET with negative index" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Set last element using -1 - const lset_args = [_]Value{ - .{ .data = "LSET" }, - .{ .data = "mylist" }, - .{ .data = "-1" }, - .{ .data = "THREE" }, - }; - try list_commands.lset(&writer, &store, &lset_args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - writer = Writer.fixed(&buffer); - - // Verify the last element was updated - const lindex_args = [_]Value{ - .{ .data = "LINDEX" }, - .{ .data = "mylist" }, - .{ .data = "-1" }, - }; - try list_commands.lindex(&writer, &store, &lindex_args); - - try testing.expectEqualStrings("$5\r\nTHREE\r\n", writer.buffered()); -} - -test "LSET on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "LSET" }, - .{ .data = "nonexistent" }, - .{ .data = "0" }, - .{ .data = "value" }, - }; - - const result = list_commands.lset(&writer, &store, &args); - try testing.expectError(error.NoSuchKey, result); -} - -test "LSET with out of range index" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Try to set element at index 10 - const lset_args = [_]Value{ - .{ .data = "LSET" }, - .{ .data = "mylist" }, - .{ .data = "10" }, - .{ .data = "value" }, - }; - - const result = list_commands.lset(&writer, &store, &lset_args); - try testing.expectError(error.KeyNotFound, result); -} - -// LRANGE Tests -test "LRANGE get all elements" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Get all elements (0 to -1) - const lrange_args = [_]Value{ - .{ .data = "LRANGE" }, - .{ .data = "mylist" }, - .{ .data = "0" }, - .{ .data = "-1" }, - }; - try list_commands.lrange(&writer, &store, &lrange_args); - - try testing.expectEqualStrings("*3\r\n$3\r\none\r\n$3\r\ntwo\r\n$5\r\nthree\r\n", writer.buffered()); -} - -test "LRANGE get subset of elements" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three, four, five - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - .{ .data = "four" }, - .{ .data = "five" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Get elements from index 1 to 3 - const lrange_args = [_]Value{ - .{ .data = "LRANGE" }, - .{ .data = "mylist" }, - .{ .data = "1" }, - .{ .data = "3" }, - }; - try list_commands.lrange(&writer, &store, &lrange_args); - - try testing.expectEqualStrings("*3\r\n$3\r\ntwo\r\n$5\r\nthree\r\n$4\r\nfour\r\n", writer.buffered()); -} - -test "LRANGE with negative indices" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three, four, five - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - .{ .data = "four" }, - .{ .data = "five" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Get last 2 elements (-2 to -1) - const lrange_args = [_]Value{ - .{ .data = "LRANGE" }, - .{ .data = "mylist" }, - .{ .data = "-2" }, - .{ .data = "-1" }, - }; - try list_commands.lrange(&writer, &store, &lrange_args); - - try testing.expectEqualStrings("*2\r\n$4\r\nfour\r\n$4\r\nfive\r\n", writer.buffered()); -} - -test "LRANGE on non-existing list" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "LRANGE" }, - .{ .data = "nonexistent" }, - .{ .data = "0" }, - .{ .data = "-1" }, - }; - - try list_commands.lrange(&writer, &store, &args); - - try testing.expectEqualStrings("*0\r\n", writer.buffered()); -} - -test "LRANGE with out of range indices" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Try to get elements from 10 to 20 (out of range) - const lrange_args = [_]Value{ - .{ .data = "LRANGE" }, - .{ .data = "mylist" }, - .{ .data = "10" }, - .{ .data = "20" }, - }; - try list_commands.lrange(&writer, &store, &lrange_args); - - try testing.expectEqualStrings("*0\r\n", writer.buffered()); -} - -test "LRANGE with reversed range" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create list with: one, two, three - const push_args = [_]Value{ - .{ .data = "RPUSH" }, - .{ .data = "mylist" }, - .{ .data = "one" }, - .{ .data = "two" }, - .{ .data = "three" }, - }; - try list_commands.rpush(&writer, &store, &push_args); - writer = Writer.fixed(&buffer); - - // Try reversed range (start > stop) - const lrange_args = [_]Value{ - .{ .data = "LRANGE" }, - .{ .data = "mylist" }, - .{ .data = "2" }, - .{ .data = "1" }, - }; - try list_commands.lrange(&writer, &store, &lrange_args); - - try testing.expectEqualStrings("*0\r\n", writer.buffered()); -} diff --git a/src/testing/store.zig b/src/testing/store.zig deleted file mode 100644 index 3ee4fa5..0000000 --- a/src/testing/store.zig +++ /dev/null @@ -1,770 +0,0 @@ -const std = @import("std"); -const Store = @import("../store.zig").Store; -const ZedisObject = @import("../store.zig").ZedisObject; -const ZedisValue = @import("../store.zig").ZedisValue; -const ValueType = @import("../store.zig").ValueType; -const testing = std.testing; -const Io = std.Io; -const Clock = @import("../clock.zig"); - -const allocator = testing.allocator; -test "Store init and deinit" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try testing.expectEqual(@as(u32, 0), store.size()); -} - -test "Store set and get" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("key1", "hello"); - try testing.expectEqual(@as(u32, 1), store.size()); - - const result = store.get("key1"); - try testing.expect(result != null); - try testing.expectEqualStrings("hello", result.?.value.short_string.asSlice()); -} - -test "Store setInt and get" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.setInt("counter", 42); - try testing.expectEqual(@as(u32, 1), store.size()); - - const result = store.get("counter"); - try testing.expect(result != null); - try testing.expectEqual(@as(i64, 42), result.?.value.int); -} - -test "Store setObject with ZedisObject" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - const obj = ZedisObject{ .value = .{ .string = try allocator.dupe(u8, "test") } }; - defer allocator.free(obj.value.string); - try store.putObject("key1", obj); - - const result = store.get("key1"); - try testing.expect(result != null); - try testing.expectEqualStrings("test", result.?.value.string); -} - -test "Store delete existing key" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("key1", "value1"); - try testing.expectEqual(@as(u32, 1), store.size()); - try testing.expect(store.exists("key1")); - - const deleted = store.delete("key1"); - try testing.expect(deleted); - try testing.expectEqual(@as(u32, 0), store.size()); - try testing.expect(!store.exists("key1")); -} - -test "Store delete non-existing key" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - const deleted = store.delete("nonexistent"); - try testing.expect(!deleted); -} - -test "Store exists" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try testing.expect(!store.exists("key1")); - - try store.set("key1", "value1"); - try testing.expect(store.exists("key1")); - - _ = store.delete("key1"); - try testing.expect(!store.exists("key1")); -} - -test "Store getType" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try testing.expect(store.getType("nonexistent") == null); - - try store.set("str_key", "hello"); - try testing.expectEqual(ValueType.short_string, store.getType("str_key").?); - - try store.setInt("int_key", 42); - try testing.expectEqual(ValueType.int, store.getType("int_key").?); -} - -test "Store overwrite existing key" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("key1", "original"); - try testing.expectEqual(@as(u32, 1), store.size()); - - const result1 = store.get("key1"); - try testing.expect(result1 != null); - try testing.expectEqualStrings("original", result1.?.value.short_string.asSlice()); - - try store.set("key1", "updated"); - try testing.expectEqual(@as(u32, 1), store.size()); - - const result2 = store.get("key1"); - try testing.expect(result2 != null); - try testing.expectEqualStrings("updated", result2.?.value.short_string.asSlice()); -} - -test "Store overwrite string with integer" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("key1", "hello"); - try testing.expectEqual(ValueType.short_string, store.getType("key1").?); - - try store.setInt("key1", 123); - try testing.expectEqual(ValueType.int, store.getType("key1").?); - try testing.expectEqual(@as(i64, 123), store.get("key1").?.value.int); -} - -test "Store overwrite integer with string" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.setInt("key1", 456); - try testing.expectEqual(ValueType.int, store.getType("key1").?); - - try store.set("key1", "world"); - try testing.expectEqual(ValueType.short_string, store.getType("key1").?); - try testing.expectEqualStrings("world", store.get("key1").?.value.short_string.asSlice()); -} - -test "Store expire functionality" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("key1", "value1"); - try testing.expect(!store.isExpired("key1")); - - // Set expiration to far future - const now = Io.Clock.real.now(testing.io); - const future_time = now.toMilliseconds() + 1000000; - const success = try store.expire("key1", future_time); - try testing.expect(success); - try testing.expect(!store.isExpired("key1")); - try testing.expect(store.get("key1") != null); - try testing.expectEqual(future_time, store.getTtl("key1").?); - - // Set expiration to past - const past_time: i64 = 12345; - _ = try store.expire("key1", past_time); - try testing.expect(store.isExpired("key1")); - try testing.expect(store.get("key1") == null); // Should be deleted on get -} - -test "Store expire non-existing key" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - const success = try store.expire("nonexistent", 12345); - try testing.expect(!success); -} - -test "Store delete removes from expiration map" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("key1", "value1"); - _ = try store.expire("key1", 12345); - - const deleted = store.delete("key1"); - try testing.expect(deleted); - try testing.expect(!store.isExpired("key1")); -} - -test "Store multiple keys with different types" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("str1", "hello"); - try store.set("str2", "world"); - try store.setInt("int1", 123); - try store.setInt("int2", -456); - - try testing.expectEqual(@as(u32, 4), store.size()); - - try testing.expectEqualStrings("hello", store.get("str1").?.value.short_string.asSlice()); - try testing.expectEqualStrings("world", store.get("str2").?.value.short_string.asSlice()); - try testing.expectEqual(@as(i64, 123), store.get("int1").?.value.int); - try testing.expectEqual(@as(i64, -456), store.get("int2").?.value.int); -} - -test "Store empty string values" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("empty", ""); - - const result = store.get("empty"); - try testing.expect(result != null); - try testing.expectEqualStrings("", result.?.value.short_string.asSlice()); -} - -test "Store zero integer values" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.setInt("zero", 0); - - const result = store.get("zero"); - try testing.expect(result != null); - try testing.expectEqual(@as(i64, 0), result.?.value.int); -} - -test "Store createList and getList" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try testing.expect(try store.getList("mylist") == null); - - const list = try store.createList("mylist"); - try testing.expectEqual(@as(usize, 0), list.len()); - - const retrieved_list = try store.getList("mylist"); - try testing.expect(retrieved_list != null); - try testing.expectEqual(@as(usize, 0), retrieved_list.?.len()); -} - -test "Store list append and insert operations" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - const list = try store.createList("test_append_insert"); - - try testing.expectEqual(@as(usize, 0), list.len()); - - try list.append(.{ .string = try allocator.dupe(u8, "first") }); - try testing.expectEqual(@as(usize, 1), list.len()); - try testing.expectEqualStrings("first", list.getByIndex(0).?.string); - - try list.append(.{ .string = try allocator.dupe(u8, "second") }); - try testing.expectEqual(@as(usize, 2), list.len()); - try testing.expectEqualStrings("second", list.getByIndex(1).?.string); - - try list.prepend(.{ .string = try allocator.dupe(u8, "zero") }); - try testing.expectEqual(@as(usize, 3), list.len()); - try testing.expectEqualStrings("zero", list.getByIndex(0).?.string); - try testing.expectEqualStrings("first", list.getByIndex(1).?.string); - try testing.expectEqualStrings("second", list.getByIndex(2).?.string); -} - -test "Store list with mixed value types" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - const list = try store.createList("test_mixed_values"); - - try list.append(.{ .string = try allocator.dupe(u8, "hello") }); - try list.append(.{ .int = 42 }); - try list.append(.{ .string = try allocator.dupe(u8, "world") }); - - try testing.expectEqual(@as(usize, 3), list.len()); - try testing.expectEqualStrings("hello", list.getByIndex(0).?.string); - try testing.expectEqual(@as(i64, 42), list.getByIndex(1).?.int); - try testing.expectEqualStrings("world", list.getByIndex(2).?.string); -} - -test "Store getList with wrong type" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("notalist", "hello"); - - const list = store.getList("notalist"); - try testing.expect(list == error.WrongType); -} - -test "Store list type checking" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - _ = try store.createList("mylist"); - try testing.expectEqual(ValueType.list, store.getType("mylist").?); -} - -test "Store overwrite string with list" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try store.set("key1", "hello"); - try testing.expectEqual(ValueType.short_string, store.getType("key1").?); - - _ = try store.createList("key1"); - try testing.expectEqual(ValueType.list, store.getType("key1").?); - - const list = try store.getList("key1"); - try testing.expect(list != null); - try testing.expectEqual(@as(usize, 0), list.?.len()); -} - -test "Store overwrite list with string" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - const list = try store.createList("key1"); - try list.append(.{ .string = try allocator.dupe(u8, "item") }); - try testing.expectEqual(ValueType.list, store.getType("key1").?); - - try store.set("key1", "hello"); - try testing.expectEqual(ValueType.short_string, store.getType("key1").?); - try testing.expectEqualStrings("hello", store.get("key1").?.value.short_string.asSlice()); - - const retrieved_list = store.getList("key1"); - try testing.expect(retrieved_list == error.WrongType); -} - -test "Store delete list key" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - const list = try store.createList("mylist"); - try list.append(.{ .string = try allocator.dupe(u8, "item1") }); - try list.append(.{ .string = try allocator.dupe(u8, "item2") }); - - try testing.expect(store.exists("mylist")); - try testing.expectEqual(@as(u32, 1), store.size()); - - const deleted = store.delete("mylist"); - try testing.expect(deleted); - try testing.expect(!store.exists("mylist")); - try testing.expectEqual(@as(u32, 0), store.size()); - try testing.expect(try store.getList("mylist") == null); -} - -test "Store empty list operations" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - const list = try store.createList("test_empty_ops"); - try testing.expectEqual(@as(usize, 0), list.len()); - - try list.append(.{ .string = try allocator.dupe(u8, "") }); - try testing.expectEqual(@as(usize, 1), list.len()); - try testing.expectEqualStrings("", list.getByIndex(0).?.string); - - try list.append(.{ .int = 0 }); - try testing.expectEqual(@as(usize, 2), list.len()); - try testing.expectEqual(@as(i64, 0), list.getByIndex(1).?.int); -} - -test "Store flush_db removes all keys" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // Add various types of keys - try store.set("str1", "hello"); - try store.set("str2", "world"); - try store.setInt("int1", 42); - try store.setInt("int2", -100); - - const list = try store.createList("mylist"); - try list.append(.{ .string = try allocator.dupe(u8, "item1") }); - try list.append(.{ .string = try allocator.dupe(u8, "item2") }); - - // Verify all keys exist - try testing.expectEqual(@as(u32, 5), store.size()); - try testing.expect(store.exists("str1")); - try testing.expect(store.exists("str2")); - try testing.expect(store.exists("int1")); - try testing.expect(store.exists("int2")); - try testing.expect(store.exists("mylist")); - - // Flush the database - store.flush_db(); - - // Verify all keys are removed - try testing.expectEqual(@as(u32, 0), store.size()); - try testing.expect(!store.exists("str1")); - try testing.expect(!store.exists("str2")); - try testing.expect(!store.exists("int1")); - try testing.expect(!store.exists("int2")); - try testing.expect(!store.exists("mylist")); - - // Verify getting keys returns null - try testing.expect(store.get("str1") == null); - try testing.expect(store.get("int1") == null); - try testing.expect(try store.getList("mylist") == null); -} - -test "Store flush_db on empty store" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - try testing.expectEqual(@as(u32, 0), store.size()); - - // Flush empty store should not crash - store.flush_db(); - - try testing.expectEqual(@as(u32, 0), store.size()); -} - -test "Store flush_db allows reuse after flush" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - // Add keys - try store.set("key1", "value1"); - try store.setInt("key2", 123); - try testing.expectEqual(@as(u32, 2), store.size()); - - // Flush - store.flush_db(); - try testing.expectEqual(@as(u32, 0), store.size()); - - // Add new keys after flush - try store.set("key3", "value3"); - try store.setInt("key4", 456); - try testing.expectEqual(@as(u32, 2), store.size()); - - // Verify new keys work correctly - try testing.expectEqualStrings("value3", store.get("key3").?.value.short_string.asSlice()); - try testing.expectEqual(@as(i64, 456), store.get("key4").?.value.int); - - // Verify old keys don't exist - try testing.expect(store.get("key1") == null); - try testing.expect(store.get("key2") == null); -} - -test "Store maintenance() rehashes and reduces capacity" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 16 }); - defer store.deinit(); - - // Add many keys to grow the capacity - var i: usize = 0; - while (i < 1000) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - try store.set(key, "value"); - } - - const capacity_before = store.map.capacity(); - const size_before = store.map.count(); - try testing.expect(capacity_before > 0); - try testing.expectEqual(@as(usize, 1000), size_before); - - // Delete half the keys to create tombstones - i = 0; - while (i < 500) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - const deleted = store.delete(key); - try testing.expect(deleted); - } - - const size_after_delete = store.map.count(); - try testing.expectEqual(@as(usize, 500), size_after_delete); - - // Deletes may trigger automatic maintenance, but capacity should never grow here. - try testing.expect(store.map.capacity() <= capacity_before); - - // Run maintenance to clean up tombstones - store.maintenance(); - - const capacity_after = store.map.capacity(); - const size_after = store.map.count(); - - // Size should remain the same - try testing.expectEqual(@as(usize, 500), size_after); - - // Capacity should be reduced or at least not larger - try testing.expect(capacity_after <= capacity_before); - - // Verify remaining keys are still accessible - i = 500; - while (i < 1000) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - const result = store.get(key); - try testing.expect(result != null); - try testing.expectEqualStrings("value", result.?.value.short_string.asSlice()); - } -} - -test "Store maintenance() resets deletion counter" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 16 }); - defer store.deinit(); - - // Add and delete keys to increment deletion counter - var i: usize = 0; - while (i < 100) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - try store.set(key, "value"); - _ = store.delete(key); - } - - try testing.expect(store.deletions_since_rehash > 0); - - // Run maintenance - store.maintenance(); - - // Deletion counter should be reset - try testing.expectEqual(@as(usize, 0), store.deletions_since_rehash); -} - -test "Store maybeMaintenance() respects rate limiting" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 16 }); - defer store.deinit(); - - // Add enough keys to trigger capacity growth - var i: usize = 0; - while (i < 1000) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - try store.set(key, "value"); - } - - // Delete many keys to exceed threshold - i = 0; - while (i < 700) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - _ = store.delete(key); - } - - const capacity_before = store.map.capacity(); - - // Reset last_maintenance_check to ensure our explicit call isn't rate-limited - // (delete() calls maybeMaintenance() automatically, which may have updated it recently) - store.last_maintenance_check = 0; - const last_check_before = store.last_maintenance_check; - - // Call maybeMaintenance multiple times in quick succession - store.maybeMaintenance(); - const capacity_after_first = store.map.capacity(); - const last_check_after_first = store.last_maintenance_check; - - // First call should trigger maintenance - try testing.expect(capacity_after_first <= capacity_before); - try testing.expect(last_check_after_first > last_check_before); - - // Immediately call again (within 50ms) - store.maybeMaintenance(); - const capacity_after_second = store.map.capacity(); - const last_check_after_second = store.last_maintenance_check; - - // Second call should be rate-limited (no maintenance) - try testing.expectEqual(capacity_after_first, capacity_after_second); - try testing.expectEqual(last_check_after_first, last_check_after_second); -} - -test "Store maybeMaintenance() triggers on 50% waste threshold" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 16 }); - defer store.deinit(); - - // Add many keys - var i: usize = 0; - while (i < 1000) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - try store.set(key, "value"); - } - - const capacity_before = store.map.capacity(); - - // Delete more than 50% of capacity to trigger waste threshold - // (capacity - count) > capacity / 2 - const target_deletions = (capacity_before / 2) + 10; - i = 0; - while (i < target_deletions) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - _ = store.delete(key); - } - - // Reset last_maintenance_check to avoid rate limiting - store.last_maintenance_check = 0; - - // This should trigger maintenance due to waste threshold - store.maybeMaintenance(); - - // Deletion counter should be reset after maintenance - try testing.expectEqual(@as(usize, 0), store.deletions_since_rehash); -} - -test "Store maybeMaintenance() triggers on 25% deletions threshold" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 16 }); - defer store.deinit(); - - // Add keys to establish capacity - var i: usize = 0; - while (i < 500) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - try store.set(key, "value"); - } - - const capacity = store.map.capacity(); - const threshold = capacity / 4; - - // Block the implicit maintenance calls inside delete() so this test can - // deterministically verify the explicit maybeMaintenance() invocation. - store.last_maintenance_check = store.clock.now().toMilliseconds() + 60_000; - - // Delete exactly threshold + 1 keys to trigger maintenance - i = 0; - while (i < threshold + 1) : (i += 1) { - const key = try std.fmt.allocPrint(allocator, "key{d}", .{i}); - defer allocator.free(key); - _ = store.delete(key); - } - - try testing.expectEqual(threshold + 1, store.deletions_since_rehash); - - // Reset last_maintenance_check to avoid rate limiting - store.last_maintenance_check = 0; - - // This should trigger maintenance due to deletion threshold - store.maybeMaintenance(); - - // Deletion counter should be reset after maintenance - try testing.expectEqual(@as(usize, 0), store.deletions_since_rehash); -} - -test "Store deletion tracking increments counter" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 16 }); - defer store.deinit(); - - // Initially, deletion counter should be 0 - try testing.expectEqual(@as(usize, 0), store.deletions_since_rehash); - - // Add and delete some keys - try store.set("key1", "value1"); - try store.set("key2", "value2"); - try store.set("key3", "value3"); - - // Keep delete() from auto-triggering maintenance so we can verify the - // raw deletion counter behavior directly. - store.last_maintenance_check = store.clock.now().toMilliseconds() + 60_000; - - _ = store.delete("key1"); - try testing.expect(store.deletions_since_rehash >= 1); - - _ = store.delete("key2"); - try testing.expect(store.deletions_since_rehash >= 2); - - _ = store.delete("key3"); - try testing.expect(store.deletions_since_rehash >= 3); - - // Deleting non-existent key should not increment - const before_failed_delete = store.deletions_since_rehash; - _ = store.delete("nonexistent"); - try testing.expectEqual(before_failed_delete, store.deletions_since_rehash); -} - -test "Store evictOne allkeys_lru evicts least recently used key" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ - .initial_capacity = 16, - .eviction_policy = .allkeys_lru, - .maxmemory_samples = 5, - }); - defer store.deinit(); - - try store.set("key1", "value1"); - try store.set("key2", "value2"); - try store.set("key3", "value3"); - - _ = store.get("key1"); - - try testing.expect(store.evictOne(.allkeys_lru)); - try testing.expect(store.get("key2") == null); - try testing.expect(store.get("key1") != null); - try testing.expect(store.get("key3") != null); -} - -test "Store evictOne volatile_lru only evicts volatile keys" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ - .initial_capacity = 16, - .eviction_policy = .volatile_lru, - .maxmemory_samples = 5, - }); - defer store.deinit(); - - try store.set("persistent", "value"); - try store.set("ttl1", "value1"); - try store.set("ttl2", "value2"); - - const now = Io.Clock.real.now(testing.io).toMilliseconds(); - _ = try store.expire("ttl1", now + 10_000); - _ = try store.expire("ttl2", now + 10_000); - - _ = store.get("ttl1"); - - try testing.expect(store.evictOne(.volatile_lru)); - try testing.expect(store.get("ttl2") == null); - try testing.expect(store.get("ttl1") != null); - try testing.expect(store.get("persistent") != null); -} - -test "Store evictOne prefers expired ttl entries before LRU tail" { - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ - .initial_capacity = 16, - .eviction_policy = .allkeys_lru, - .maxmemory_samples = 5, - }); - defer store.deinit(); - - try store.set("fresh1", "value1"); - try store.set("expired", "value2"); - try store.set("fresh2", "value3"); - - _ = try store.expire("expired", 1); - _ = store.get("fresh2"); - - try testing.expect(store.evictOne(.allkeys_lru)); - try testing.expect(store.get("expired") == null); - try testing.expect(store.get("fresh1") != null); - try testing.expect(store.get("fresh2") != null); -} diff --git a/src/testing/string.zig b/src/testing/string.zig deleted file mode 100644 index 062fc4b..0000000 --- a/src/testing/string.zig +++ /dev/null @@ -1,862 +0,0 @@ -const std = @import("std"); -const Store = @import("../store.zig").Store; -const Value = @import("../parser.zig").Value; -const testing = std.testing; -const string_commands = @import("../commands/string.zig"); -const Io = std.Io; -const Writer = Io.Writer; -const Clock = @import("../clock.zig"); - -test "SET command with string value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "SET" }, - .{ .data = "key1" }, - .{ .data = "hello" }, - }; - - try string_commands.set(&writer, &store, &args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - const stored_value = store.get("key1"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("hello", stored_value.?.value.short_string.asSlice()); -} - -test "SET command with integer value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "SET" }, - .{ .data = "key1" }, - .{ .data = "42" }, - }; - - try string_commands.set(&writer, &store, &args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - const stored_value = store.get("key1"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("42", stored_value.?.value.short_string.asSlice()); -} - -test "GET command with existing string value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("key1", "hello"); - - const args = [_]Value{ - .{ .data = "GET" }, - .{ .data = "key1" }, - }; - - try string_commands.get(&writer, &store, &args); - - try testing.expectEqualStrings("$5\r\nhello\r\n", writer.buffered()); -} - -test "GET command with existing integer value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.setInt("key1", 42); - - const args = [_]Value{ - .{ .data = "GET" }, - .{ .data = "key1" }, - }; - - try string_commands.get(&writer, &store, &args); - - try testing.expectEqualStrings("$2\r\n42\r\n", writer.buffered()); -} - -test "GET command with non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "GET" }, - .{ .data = "nonexistent" }, - }; - - try string_commands.get(&writer, &store, &args); - - try testing.expectEqualStrings("$-1\r\n", writer.buffered()); -} - -test "INCR command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "INCR" }, - .{ .data = "counter" }, - }; - - try string_commands.incr(&writer, &store, &args); - - try testing.expectEqualStrings("$1\r\n1\r\n", writer.buffered()); - - const stored_value = store.get("counter"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, 1), stored_value.?.value.int); -} - -test "INCR command on existing integer" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.setInt("counter", 5); - - const args = [_]Value{ - .{ .data = "INCR" }, - .{ .data = "counter" }, - }; - - try string_commands.incr(&writer, &store, &args); - - try testing.expectEqualStrings("$1\r\n6\r\n", writer.buffered()); - - const stored_value = store.get("counter"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, 6), stored_value.?.value.int); -} - -test "INCR command on string that represents integer" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("counter", "10"); - - const args = [_]Value{ - .{ .data = "INCR" }, - .{ .data = "counter" }, - }; - - try string_commands.incr(&writer, &store, &args); - - try testing.expectEqualStrings("$2\r\n11\r\n", writer.buffered()); - - const stored_value = store.get("counter"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, 11), stored_value.?.value.int); -} - -test "INCR command on non-integer string" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("key1", "hello"); - - const args = [_]Value{ - .{ .data = "INCR" }, - .{ .data = "key1" }, - }; - - const result = string_commands.incr(&writer, &store, &args); - try testing.expectError(error.ValueNotInteger, result); -} - -test "DECR command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "DECR" }, - .{ .data = "counter" }, - }; - - try string_commands.decr(&writer, &store, &args); - - try testing.expectEqualStrings("$2\r\n-1\r\n", writer.buffered()); - - const stored_value = store.get("counter"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, -1), stored_value.?.value.int); -} - -test "DECR command on existing integer" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.setInt("counter", 10); - - const args = [_]Value{ - .{ .data = "DECR" }, - .{ .data = "counter" }, - }; - - try string_commands.decr(&writer, &store, &args); - - try testing.expectEqualStrings("$1\r\n9\r\n", writer.buffered()); - - const stored_value = store.get("counter"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, 9), stored_value.?.value.int); -} - -test "DEL command with single existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("key1", "value1"); - - const args = [_]Value{ - .{ .data = "DEL" }, - .{ .data = "key1" }, - }; - - try string_commands.del(&writer, &store, &args); - - try testing.expectEqualStrings(":1\r\n", writer.buffered()); - - const stored_value = store.get("key1"); - try testing.expect(stored_value == null); -} - -test "DEL command with multiple keys" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("key1", "value1"); - try store.set("key2", "value2"); - try store.setInt("key3", 42); - - const args = [_]Value{ - .{ .data = "DEL" }, - .{ .data = "key1" }, - .{ .data = "key2" }, - .{ .data = "key3" }, - .{ .data = "nonexistent" }, - }; - - try string_commands.del(&writer, &store, &args); - - try testing.expectEqualStrings(":3\r\n", writer.buffered()); - - try testing.expect(store.get("key1") == null); - try testing.expect(store.get("key2") == null); - try testing.expect(store.get("key3") == null); -} - -test "DEL command with non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "DEL" }, - .{ .data = "nonexistent" }, - }; - - try string_commands.del(&writer, &store, &args); - - try testing.expectEqualStrings(":0\r\n", writer.buffered()); -} - -test "APPEND command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "APPEND" }, - .{ .data = "mykey" }, - .{ .data = "Hello" }, - }; - - try string_commands.append(&writer, &store, &args); - - try testing.expectEqualStrings(":5\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("Hello", stored_value.?.value.short_string.asSlice()); -} - -test "APPEND command on existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "Hello"); - - const args = [_]Value{ - .{ .data = "APPEND" }, - .{ .data = "mykey" }, - .{ .data = " World" }, - }; - - try string_commands.append(&writer, &store, &args); - - try testing.expectEqualStrings(":11\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("Hello World", stored_value.?.value.short_string.asSlice()); -} - -test "STRLEN command on existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "Hello World"); - - const args = [_]Value{ - .{ .data = "STRLEN" }, - .{ .data = "mykey" }, - }; - - try string_commands.strlen(&writer, &store, &args); - - try testing.expectEqualStrings(":11\r\n", writer.buffered()); -} - -test "STRLEN command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "STRLEN" }, - .{ .data = "nonexistent" }, - }; - - try string_commands.strlen(&writer, &store, &args); - - try testing.expectEqualStrings(":0\r\n", writer.buffered()); -} - -test "GETSET command on existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "Hello"); - - const args = [_]Value{ - .{ .data = "GETSET" }, - .{ .data = "mykey" }, - .{ .data = "World" }, - }; - - try string_commands.getset(&writer, &store, &args); - - try testing.expectEqualStrings("$5\r\nHello\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("World", stored_value.?.value.short_string.asSlice()); -} - -test "GETSET command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "GETSET" }, - .{ .data = "mykey" }, - .{ .data = "World" }, - }; - - try string_commands.getset(&writer, &store, &args); - - try testing.expectEqualStrings("$-1\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("World", stored_value.?.value.short_string.asSlice()); -} - -test "MGET command with multiple keys" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("key1", "value1"); - try store.setInt("key2", 42); - - const args = [_]Value{ - .{ .data = "MGET" }, - .{ .data = "key1" }, - .{ .data = "key2" }, - .{ .data = "key3" }, - }; - - try string_commands.mget(&writer, &store, &args); - - try testing.expectEqualStrings("*3\r\n$6\r\nvalue1\r\n$2\r\n42\r\n$-1\r\n", writer.buffered()); -} - -test "MSET command with multiple key-value pairs" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "MSET" }, - .{ .data = "key1" }, - .{ .data = "value1" }, - .{ .data = "key2" }, - .{ .data = "value2" }, - }; - - try string_commands.mset(&writer, &store, &args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - const v1 = store.get("key1"); - try testing.expect(v1 != null); - try testing.expectEqualStrings("value1", v1.?.value.short_string.asSlice()); - - const v2 = store.get("key2"); - try testing.expect(v2 != null); - try testing.expectEqualStrings("value2", v2.?.value.short_string.asSlice()); -} - -test "SETEX command sets key with expiration" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "SETEX" }, - .{ .data = "mykey" }, - .{ .data = "10" }, - .{ .data = "Hello" }, - }; - - try string_commands.setex(&writer, &store, &args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("Hello", stored_value.?.value.short_string.asSlice()); -} - -test "SETNX command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "SETNX" }, - .{ .data = "mykey" }, - .{ .data = "Hello" }, - }; - - try string_commands.setnx(&writer, &store, &args); - - try testing.expectEqualStrings(":1\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("Hello", stored_value.?.value.short_string.asSlice()); -} - -test "SETNX command on existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "World"); - - const args = [_]Value{ - .{ .data = "SETNX" }, - .{ .data = "mykey" }, - .{ .data = "Hello" }, - }; - - try string_commands.setnx(&writer, &store, &args); - - try testing.expectEqualStrings(":0\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqualStrings("World", stored_value.?.value.short_string.asSlice()); -} - -test "INCRBY command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "INCRBY" }, - .{ .data = "mykey" }, - .{ .data = "5" }, - }; - - try string_commands.incrby(&writer, &store, &args); - - try testing.expectEqualStrings("$1\r\n5\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, 5), stored_value.?.value.int); -} - -test "INCRBY command on existing integer" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.setInt("mykey", 10); - - const args = [_]Value{ - .{ .data = "INCRBY" }, - .{ .data = "mykey" }, - .{ .data = "5" }, - }; - - try string_commands.incrby(&writer, &store, &args); - - try testing.expectEqualStrings("$2\r\n15\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, 15), stored_value.?.value.int); -} - -test "DECRBY command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "DECRBY" }, - .{ .data = "mykey" }, - .{ .data = "3" }, - }; - - try string_commands.decrby(&writer, &store, &args); - - try testing.expectEqualStrings("$2\r\n-3\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, -3), stored_value.?.value.int); -} - -test "DECRBY command on existing integer" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.setInt("mykey", 10); - - const args = [_]Value{ - .{ .data = "DECRBY" }, - .{ .data = "mykey" }, - .{ .data = "3" }, - }; - - try string_commands.decrby(&writer, &store, &args); - - try testing.expectEqualStrings("$1\r\n7\r\n", writer.buffered()); - - const stored_value = store.get("mykey"); - try testing.expect(stored_value != null); - try testing.expectEqual(@as(i64, 7), stored_value.?.value.int); -} - -test "INCRBYFLOAT command on non-existing key" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - const args = [_]Value{ - .{ .data = "INCRBYFLOAT" }, - .{ .data = "mykey" }, - .{ .data = "2.5" }, - }; - - try string_commands.incrbyfloat(&writer, &store, &args); - - try testing.expectEqualStrings("$3\r\n2.5\r\n", writer.buffered()); -} - -test "INCRBYFLOAT command on existing float" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "10.5"); - - const args = [_]Value{ - .{ .data = "INCRBYFLOAT" }, - .{ .data = "mykey" }, - .{ .data = "0.1" }, - }; - - try string_commands.incrbyfloat(&writer, &store, &args); - - // Result should be "10.6" (trailing zeros removed) - try testing.expectEqualStrings("$4\r\n10.6\r\n", writer.buffered()); -} - -test "INCRBYFLOAT command with negative increment" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - try store.set("mykey", "5.0"); - - const args = [_]Value{ - .{ .data = "INCRBYFLOAT" }, - .{ .data = "mykey" }, - .{ .data = "-2.0" }, - }; - - try string_commands.incrbyfloat(&writer, &store, &args); - - // Result should be "3" (trailing zeros and decimal point removed) - try testing.expectEqualStrings("$1\r\n3\r\n", writer.buffered()); -} diff --git a/src/testing/time_series.zig b/src/testing/time_series.zig deleted file mode 100644 index a0d22ba..0000000 --- a/src/testing/time_series.zig +++ /dev/null @@ -1,1666 +0,0 @@ -const std = @import("std"); -const mem = std.mem; -const testing = std.testing; -const ts_mod = @import("../time_series.zig"); -const TimeSeries = ts_mod.TimeSeries; -const Duplicate_Policy = @import("../time_series.zig").Duplicate_Policy; -const EncodingType = @import("../time_series.zig").EncodingType; -const Io = std.Io; -const Writer = Io.Writer; - -test "TimeSeries: basic uncompressed storage" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, // no retention - .BLOCK, - 100, // max samples per chunk - .Uncompressed, - 0, // no ignore time diff - 0.0, // no ignore val diff - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.5); - try ts.addSample(1001, 20.5); - try ts.addSample(1002, 30.5); - - try testing.expectEqual(@as(u64, 3), ts.total_samples); - try testing.expect(ts.tail != null); - try testing.expectEqual(@as(i64, 1002), ts.tail.?.last_ts); -} - -test "TimeSeries: basic compressed storage" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .DeltaXor, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 100.0); - try ts.addSample(1010, 105.0); - try ts.addSample(1020, 110.0); - - try testing.expectEqual(@as(u64, 3), ts.total_samples); -} - -test "TimeSeries: duplicate policy BLOCK" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - - // Duplicate timestamp should return error - const result = ts.addSample(1000, 20.0); - try testing.expectError(error.TSDB_DuplicateTimestamp, result); -} - -test "TimeSeries: duplicate policy FIRST" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .FIRST, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(1000, 20.0); // Should be ignored - - try testing.expectEqual(@as(u64, 1), ts.total_samples); - try testing.expectEqual(@as(f64, 10.0), ts.last_sample.?.value); -} - -test "TimeSeries: duplicate policy LAST" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .LAST, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(1000, 20.0); // Should update - - try testing.expectEqual(@as(u64, 2), ts.total_samples); - try testing.expectEqual(@as(f64, 20.0), ts.last_sample.?.value); -} - -test "TimeSeries: duplicate policy MIN" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .MIN, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(1000, 20.0); // Should be ignored (10 < 20) - - try testing.expectEqual(@as(f64, 10.0), ts.last_sample.?.value); - - try ts.addSample(1001, 30.0); - try ts.addSample(1001, 5.0); // Should be kept (5 < 30) - - try testing.expectEqual(@as(f64, 5.0), ts.last_sample.?.value); -} - -test "TimeSeries: duplicate policy MAX" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .MAX, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(1000, 20.0); // Should be kept (20 > 10) - - try testing.expectEqual(@as(f64, 20.0), ts.last_sample.?.value); - - try ts.addSample(1001, 30.0); - try ts.addSample(1001, 15.0); // Should be ignored (15 < 30) - - try testing.expectEqual(@as(f64, 30.0), ts.last_sample.?.value); -} - -test "TimeSeries: IGNORE parameter filters samples" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .LAST, // IGNORE only works with LAST policy - 100, - .Uncompressed, - 1000, // ignore_max_time_diff = 1000ms - 5.0, // ignore_max_val_diff = 5.0 - ); - defer ts.deinit(); - - try ts.addSample(1000, 100.0); - - // Time diff = 500ms, val diff = 2.0 - both within threshold, should be ignored - try ts.addSample(1500, 102.0); - try testing.expectEqual(@as(u64, 1), ts.total_samples); - - // Time diff = 2000ms - exceeds threshold, should be added - try ts.addSample(3000, 102.0); - try testing.expectEqual(@as(u64, 2), ts.total_samples); - - // Time diff = 500ms, val diff = 10.0 - val exceeds threshold, should be added - try ts.addSample(3500, 112.0); - try testing.expectEqual(@as(u64, 3), ts.total_samples); -} - -test "TimeSeries: IGNORE does not apply to non-LAST policies" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, // Not LAST policy - 100, - .Uncompressed, - 1000, - 5.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 100.0); - - // Even though within IGNORE thresholds, should be added (BLOCK policy) - try ts.addSample(1500, 102.0); - try testing.expectEqual(@as(u64, 2), ts.total_samples); -} - -test "TimeSeries: retention policy evicts old chunks" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 5000, // 5 second retention - .BLOCK, - 2, // 2 samples per chunk - forces multiple chunks - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Add samples that will create multiple chunks - try ts.addSample(1000, 10.0); - try ts.addSample(2000, 20.0); - try testing.expectEqual(@as(u64, 2), ts.total_samples); - - // New chunk - try ts.addSample(3000, 30.0); - try ts.addSample(4000, 40.0); - try testing.expectEqual(@as(u64, 4), ts.total_samples); - - // New chunk - try ts.addSample(5000, 50.0); - try ts.addSample(6000, 60.0); - try testing.expectEqual(@as(u64, 6), ts.total_samples); - - // Add sample at 10000ms - retention window is [5000, 10000] - // First chunk (1000-2000) should be evicted - try ts.addSample(10000, 100.0); - - // Should still have samples from chunks 2 and 3 - try testing.expect(ts.head != null); - try testing.expect(ts.head.?.first_ts >= 3000); -} - -test "TimeSeries: retention policy with zero retention" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, // No retention - keep all data - .BLOCK, - 2, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(2000, 20.0); - try ts.addSample(3000, 30.0); - try ts.addSample(100000, 100.0); // Much later timestamp - - // All samples should be retained - try testing.expectEqual(@as(u64, 4), ts.total_samples); - try testing.expect(ts.head != null); - try testing.expectEqual(@as(i64, 1000), ts.head.?.first_ts); -} - -test "TimeSeries: chunk sealing creates new chunk when full" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 3, // Only 3 samples per chunk - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(1001, 11.0); - try ts.addSample(1002, 12.0); - - // First chunk should be full - try testing.expect(ts.tail != null); - try testing.expectEqual(@as(u16, 3), ts.tail.?.sample_count); - - // Adding another sample should create a new chunk - try ts.addSample(1003, 13.0); - - try testing.expect(ts.tail != null); - try testing.expectEqual(@as(u16, 1), ts.tail.?.sample_count); - try testing.expect(ts.tail.?.prev != null); -} - -test "TimeSeries: compressed vs uncompressed encoding" { - const allocator = testing.allocator; - - var ts_compressed = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 10, - .DeltaXor, - 0, - 0.0, - ); - defer ts_compressed.deinit(); - - var ts_uncompressed = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 10, - .Uncompressed, - 0, - 0.0, - ); - defer ts_uncompressed.deinit(); - - // Add same samples to both - var i: i64 = 0; - while (i < 5) : (i += 1) { - try ts_compressed.addSample(1000 + i, @as(f64, @floatFromInt(i))); - try ts_uncompressed.addSample(1000 + i, @as(f64, @floatFromInt(i))); - } - - try testing.expectEqual(@as(u64, 5), ts_compressed.total_samples); - try testing.expectEqual(@as(u64, 5), ts_uncompressed.total_samples); -} - -test "TimeSeries: multiple chunks with retention" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 10000, // 10 second retention - .BLOCK, - 2, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Create 5 chunks over time - try ts.addSample(1000, 1.0); - try ts.addSample(2000, 2.0); - - try ts.addSample(3000, 3.0); - try ts.addSample(4000, 4.0); - - try ts.addSample(5000, 5.0); - try ts.addSample(6000, 6.0); - - try ts.addSample(7000, 7.0); - try ts.addSample(8000, 8.0); - - try ts.addSample(9000, 9.0); - try ts.addSample(10000, 10.0); - - // All should still exist (within 10s window from 10000) - try testing.expectEqual(@as(u64, 10), ts.total_samples); - - // Add sample at 15000 - retention window [5000, 15000] - // First two chunks should be evicted - try ts.addSample(15000, 15.0); - - try testing.expect(ts.head != null); - try testing.expect(ts.head.?.first_ts >= 5000); -} - -test "TimeSeries: out of order samples with LAST policy" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .LAST, - 10, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(2000, 20.0); - - // Out of order sample (IGNORE only applies to in-order) - try ts.addSample(1500, 15.0); - - try testing.expectEqual(@as(u64, 3), ts.total_samples); -} - -test "TimeSeries: edge case - empty time series retention" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 5000, - .BLOCK, - 10, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Empty time series - eviction should be a no-op - ts.evictExpiredChunks(10000); - - try testing.expect(ts.head == null); - try testing.expect(ts.tail == null); -} - -test "TimeSeries: all chunks evicted clears head and tail" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 1000, // 1 second retention - .BLOCK, - 2, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(1500, 15.0); - - // Add sample way in the future - all old chunks should be evicted - try ts.addSample(10000, 100.0); - - // Old chunk should be gone, only new chunk remains - try testing.expect(ts.head != null); - try testing.expect(ts.head == ts.tail); - try testing.expectEqual(@as(i64, 10000), ts.head.?.first_ts); -} - -test "TimeSeries: getLastValue returns last value" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 10, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Empty time series should return 0.0 - try testing.expectEqual(@as(f64, 0.0), ts.getLastValue()); - - // Add samples - try ts.addSample(1000, 10.0); - try testing.expectEqual(@as(f64, 10.0), ts.getLastValue()); - - try ts.addSample(2000, 20.5); - try testing.expectEqual(@as(f64, 20.5), ts.getLastValue()); -} - -test "TimeSeries: alter updates properties" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 1000, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Alter retention - ts.alter(5000, null, null); - try testing.expectEqual(@as(u64, 5000), ts.retention_ms); - - // Alter duplicate policy - ts.alter(null, .LAST, null); - try testing.expectEqual(Duplicate_Policy.LAST, ts.duplicate_policy); - - // Alter chunk size - ts.alter(null, null, 200); - try testing.expectEqual(@as(u16, 200), ts.max_chunk_samples); - - // Alter multiple at once - ts.alter(10000, .MIN, 50); - try testing.expectEqual(@as(u64, 10000), ts.retention_ms); - try testing.expectEqual(Duplicate_Policy.MIN, ts.duplicate_policy); - try testing.expectEqual(@as(u16, 50), ts.max_chunk_samples); -} - -// Command-level tests -const Store = @import("../store.zig").Store; -const Value = @import("../parser.zig").Value; -const ts_commands = @import("../commands/time_series.zig"); -const Clock = @import("../clock.zig"); - -test "TS.INCRBY increments from zero" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create time series - const create_args = [_]Value{ - .{ .data = "TS.CREATE" }, - .{ .data = "myts" }, - .{ .data = "RETENTION" }, - .{ .data = "0" }, - .{ .data = "ENCODING" }, - .{ .data = "UNCOMPRESSED" }, - .{ .data = "DUPLICATE_POLICY" }, - .{ .data = "LAST" }, - }; - try ts_commands.ts_create(&writer, &store, &create_args); - - // Increment by 5.0 (should start from 0.0) - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const incrby_args = [_]Value{ - .{ .data = "TS.INCRBY" }, - .{ .data = "myts" }, - .{ .data = "1000" }, - .{ .data = "5.0" }, - }; - try ts_commands.ts_incrby(&writer, &store, &incrby_args); - - // Should return timestamp - try testing.expectEqualStrings(":1000\r\n", writer.buffered()); - - // Verify value is 5.0 (formatted as "5" in RESP) - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const get_args = [_]Value{ - .{ .data = "TS.GET" }, - .{ .data = "myts" }, - }; - try ts_commands.ts_get(&writer, &store, &get_args); - - const output = writer.buffered(); - try testing.expect(mem.indexOf(u8, output, "$1\r\n5\r\n") != null or mem.indexOf(u8, output, "$3\r\n5.0\r\n") != null); -} - -test "TS.INCRBY increments from existing value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create and add initial value - const create_args = [_]Value{ - .{ .data = "TS.CREATE" }, - .{ .data = "myts" }, - }; - try ts_commands.ts_create(&writer, &store, &create_args); - - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const add_args = [_]Value{ - .{ .data = "TS.ADD" }, - .{ .data = "myts" }, - .{ .data = "1000" }, - .{ .data = "10.0" }, - }; - try ts_commands.ts_add(&writer, &store, &add_args); - - // Increment by 3.0 - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const incrby_args = [_]Value{ - .{ .data = "TS.INCRBY" }, - .{ .data = "myts" }, - .{ .data = "2000" }, - .{ .data = "3.0" }, - }; - try ts_commands.ts_incrby(&writer, &store, &incrby_args); - - // Verify value is 13.0 (formatted as "13" in RESP) - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const get_args = [_]Value{ - .{ .data = "TS.GET" }, - .{ .data = "myts" }, - }; - try ts_commands.ts_get(&writer, &store, &get_args); - - const output = writer.buffered(); - try testing.expect(mem.indexOf(u8, output, "$2\r\n13\r\n") != null or mem.indexOf(u8, output, "$4\r\n13.0\r\n") != null); -} - -test "TS.DECRBY decrements value" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create and add initial value - const create_args = [_]Value{ - .{ .data = "TS.CREATE" }, - .{ .data = "myts" }, - }; - try ts_commands.ts_create(&writer, &store, &create_args); - - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const add_args = [_]Value{ - .{ .data = "TS.ADD" }, - .{ .data = "myts" }, - .{ .data = "1000" }, - .{ .data = "20.0" }, - }; - try ts_commands.ts_add(&writer, &store, &add_args); - - // Decrement by 7.0 - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const decrby_args = [_]Value{ - .{ .data = "TS.DECRBY" }, - .{ .data = "myts" }, - .{ .data = "2000" }, - .{ .data = "7.0" }, - }; - try ts_commands.ts_decrby(&writer, &store, &decrby_args); - - // Verify value is 13.0 (formatted as "13" in RESP) - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const get_args = [_]Value{ - .{ .data = "TS.GET" }, - .{ .data = "myts" }, - }; - try ts_commands.ts_get(&writer, &store, &get_args); - - const output = writer.buffered(); - try testing.expect(mem.indexOf(u8, output, "$2\r\n13\r\n") != null or mem.indexOf(u8, output, "$4\r\n13.0\r\n") != null); -} - -test "TS.ALTER changes retention" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create with retention 1000 - const create_args = [_]Value{ - .{ .data = "TS.CREATE" }, - .{ .data = "myts" }, - .{ .data = "RETENTION" }, - .{ .data = "1000" }, - }; - try ts_commands.ts_create(&writer, &store, &create_args); - - // Alter retention to 5000 - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const alter_args = [_]Value{ - .{ .data = "TS.ALTER" }, - .{ .data = "myts" }, - .{ .data = "RETENTION" }, - .{ .data = "5000" }, - }; - try ts_commands.ts_alter(&writer, &store, &alter_args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - // Verify the retention was changed - const ts = try store.getTimeSeries("myts"); - try testing.expectEqual(@as(u64, 5000), ts.?.retention_ms); -} - -test "TS.ALTER changes duplicate policy" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create with BLOCK policy - const create_args = [_]Value{ - .{ .data = "TS.CREATE" }, - .{ .data = "myts" }, - .{ .data = "DUPLICATE_POLICY" }, - .{ .data = "BLOCK" }, - }; - try ts_commands.ts_create(&writer, &store, &create_args); - - // Alter to LAST policy - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const alter_args = [_]Value{ - .{ .data = "TS.ALTER" }, - .{ .data = "myts" }, - .{ .data = "DUPLICATE_POLICY" }, - .{ .data = "LAST" }, - }; - try ts_commands.ts_alter(&writer, &store, &alter_args); - - try testing.expectEqualStrings("+OK\r\n", writer.buffered()); - - // Verify the policy was changed - const ts = try store.getTimeSeries("myts"); - try testing.expectEqual(Duplicate_Policy.LAST, ts.?.duplicate_policy); -} - -test "TS.RANGE returns samples from active unsealed chunk - Uncompressed" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, // Large chunk size to avoid sealing - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Add samples without sealing the chunk - try ts.addSample(1000, 10.0); - try ts.addSample(2000, 20.0); - try ts.addSample(3000, 30.0); - - // Verify chunk is active (not sealed) - try testing.expect(ts.tail != null); - try testing.expectEqual(@as(usize, 0), ts.tail.?.data.len); // No sealed data yet - - // Query range - should read from active buffer - var samples = try ts.range("-", "+", null, null); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 3), samples.items.len); - try testing.expectEqual(@as(i64, 1000), samples.items[0].timestamp); - try testing.expectEqual(@as(f64, 10.0), samples.items[0].value); - try testing.expectEqual(@as(i64, 2000), samples.items[1].timestamp); - try testing.expectEqual(@as(f64, 20.0), samples.items[1].value); - try testing.expectEqual(@as(i64, 3000), samples.items[2].timestamp); - try testing.expectEqual(@as(f64, 30.0), samples.items[2].value); -} - -test "TS.RANGE can read from active unsealed chunk (hybrid approach)" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .DeltaXor, // Compressed encoding - 0, - 0.0, - ); - defer ts.deinit(); - - // Add samples without sealing - try ts.addSample(1000, 100.0); - try ts.addSample(2000, 105.0); - try ts.addSample(3000, 110.0); - - // Verify chunk is active (unsealed) - try testing.expect(ts.tail != null); - try testing.expectEqual(@as(usize, 0), ts.tail.?.data.len); - - // Query range - with hybrid approach, active chunks are always readable - // Active samples are kept uncompressed regardless of encoding setting - // Compression only happens during chunk sealing - var samples = try ts.range("-", "+", null, null); - defer samples.deinit(allocator); - - // Active chunk should return all 3 samples - try testing.expectEqual(@as(usize, 3), samples.items.len); - try testing.expectEqual(@as(i64, 1000), samples.items[0].timestamp); - try testing.expectEqual(@as(f64, 100.0), samples.items[0].value); - try testing.expectEqual(@as(i64, 2000), samples.items[1].timestamp); - try testing.expectEqual(@as(f64, 105.0), samples.items[1].value); - try testing.expectEqual(@as(i64, 3000), samples.items[2].timestamp); - try testing.expectEqual(@as(f64, 110.0), samples.items[2].value); -} - -test "TS.RANGE with COUNT parameter limits results" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Add 10 samples - var i: i64 = 0; - while (i < 10) : (i += 1) { - try ts.addSample(1000 + i * 100, @as(f64, @floatFromInt(i))); - } - - // Query with COUNT 5 - var samples = try ts.range("-", "+", 5, null); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 5), samples.items.len); - try testing.expectEqual(@as(i64, 1000), samples.items[0].timestamp); - try testing.expectEqual(@as(f64, 0.0), samples.items[0].value); - try testing.expectEqual(@as(i64, 1400), samples.items[4].timestamp); - try testing.expectEqual(@as(f64, 4.0), samples.items[4].value); -} - -test "TS.RANGE with COUNT zero returns empty" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(2000, 20.0); - - // COUNT 0 is not valid, but let's test the edge case - var samples = try ts.range("-", "+", 0, null); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 0), samples.items.len); -} - -test "TS.RANGE with COUNT larger than available samples returns all" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - try ts.addSample(1000, 10.0); - try ts.addSample(2000, 20.0); - try ts.addSample(3000, 30.0); - - // Request 100 samples but only 3 exist - var samples = try ts.range("-", "+", 100, null); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 3), samples.items.len); -} - -test "TS.RANGE with COUNT across multiple chunks" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 3, // Small chunks to force multiple - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Add 10 samples, creating multiple chunks - var i: i64 = 0; - while (i < 10) : (i += 1) { - try ts.addSample(1000 + i * 100, @as(f64, @floatFromInt(i))); - } - - // Query with COUNT 7 - should span across 3 chunks - var samples = try ts.range("-", "+", 7, null); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 7), samples.items.len); - try testing.expectEqual(@as(i64, 1000), samples.items[0].timestamp); - try testing.expectEqual(@as(i64, 1600), samples.items[6].timestamp); -} - -test "TS.RANGE command with COUNT parameter" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create time series with uncompressed encoding - const create_args = [_]Value{ - .{ .data = "TS.CREATE" }, - .{ .data = "myts" }, - .{ .data = "ENCODING" }, - .{ .data = "UNCOMPRESSED" }, - }; - try ts_commands.ts_create(&writer, &store, &create_args); - - // Add 5 samples - var i: usize = 0; - while (i < 5) : (i += 1) { - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const timestamp_str = try std.fmt.allocPrint(allocator, "{d}", .{1000 + i * 100}); - const value_str = try std.fmt.allocPrint(allocator, "{d}.0", .{i * 10}); - const add_args = [_]Value{ - .{ .data = "TS.ADD" }, - .{ .data = "myts" }, - .{ .data = timestamp_str }, - .{ .data = value_str }, - }; - try ts_commands.ts_add(&writer, &store, &add_args); - } - - // Range with COUNT 3 - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const range_args = [_]Value{ - .{ .data = "TS.RANGE" }, - .{ .data = "myts" }, - .{ .data = "-" }, - .{ .data = "+" }, - .{ .data = "COUNT" }, - .{ .data = "3" }, - }; - try ts_commands.ts_range(&writer, &store, &range_args); - - const output = writer.buffered(); - // Should return array of 3 elements - try testing.expect(mem.startsWith(u8, output, "*3\r\n")); -} - -test "TS.RANGE with 5000 random samples using compressed encoding" { - const allocator = testing.allocator; - - // Create a PRNG with fixed seed for reproducibility - var prng = std.Random.DefaultPrng.init(12345); - const random = prng.random(); - - // Create time series with compressed encoding and reasonable chunk size - var ts = try TimeSeries.init( - allocator, - 0, // No retention - .BLOCK, - 100, // 100 samples per chunk - will create ~50 chunks - .DeltaXor, // Use compression to test Gorilla codec at scale - 0, - 0.0, - ); - defer ts.deinit(); - - // Generate and add 5000 random samples - const num_samples = 5000; - var i: i64 = 0; - while (i < num_samples) : (i += 1) { - const timestamp = 1000000 + i * 1000; // Start at 1000000, increment by 1 second - const value = 20.0 + random.float(f64) * 10.0; // Random temperature between 20-30°C - try ts.addSample(timestamp, value); - } - - // Verify all samples were added - try testing.expectEqual(@as(u64, num_samples), ts.total_samples); - - // Fetch all samples using range query - var samples = try ts.range("-", "+", null, null); - defer samples.deinit(allocator); - - // Verify we got all samples back - try testing.expectEqual(@as(usize, num_samples), samples.items.len); - - // Verify the timestamps are sequential (data integrity check) - for (samples.items, 0..) |sample, idx| { - const expected_ts = 1000000 + @as(i64, @intCast(idx)) * 1000; - try testing.expectEqual(expected_ts, sample.timestamp); - // Verify value is in expected range - try testing.expect(sample.value >= 20.0 and sample.value <= 30.0); - } - - // Test range query with specific start and end timestamps - const start_ts = 1000000 + 1000 * 1000; // Sample 1000 - const end_ts = 1000000 + 2000 * 1000; // Sample 2000 - const start_str = try std.fmt.allocPrint(allocator, "{d}", .{start_ts}); - defer allocator.free(start_str); - const end_str = try std.fmt.allocPrint(allocator, "{d}", .{end_ts}); - defer allocator.free(end_str); - - var range_samples = try ts.range(start_str, end_str, null, null); - defer range_samples.deinit(allocator); - - // Should get samples from 1000 to 2000 inclusive (1001 samples) - try testing.expectEqual(@as(usize, 1001), range_samples.items.len); - try testing.expectEqual(start_ts, range_samples.items[0].timestamp); - try testing.expectEqual(end_ts, range_samples.items[range_samples.items.len - 1].timestamp); - - // Test range query with COUNT parameter - var limited_samples = try ts.range("-", "+", 500, null); - defer limited_samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 500), limited_samples.items.len); - try testing.expectEqual(@as(i64, 1000000), limited_samples.items[0].timestamp); -} - -test "TS.RANGE with 5000 random samples using uncompressed encoding" { - const allocator = testing.allocator; - - var prng = std.Random.DefaultPrng.init(67890); - const random = prng.random(); - - // Create time series with uncompressed encoding - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, // Test uncompressed path as well - 0, - 0.0, - ); - defer ts.deinit(); - - // Generate and add 5000 random samples - const num_samples = 5000; - var i: i64 = 0; - while (i < num_samples) : (i += 1) { - const timestamp = 2000000 + i * 500; // Different base timestamp and interval - const value = 15.0 + random.float(f64) * 20.0; // Random values between 15-35 - try ts.addSample(timestamp, value); - } - - try testing.expectEqual(@as(u64, num_samples), ts.total_samples); - - // Fetch all samples - var samples = try ts.range("-", "+", null, null); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, num_samples), samples.items.len); - - // Verify data integrity - for (samples.items, 0..) |sample, idx| { - const expected_ts = 2000000 + @as(i64, @intCast(idx)) * 500; - try testing.expectEqual(expected_ts, sample.timestamp); - try testing.expect(sample.value >= 15.0 and sample.value <= 35.0); - } -} - -// Aggregation tests -const AggregationType = @import("../time_series.zig").AggregationType; -const Aggregation = @import("../time_series.zig").Aggregation; - -test "TS.RANGE with AVG aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Add samples with known values across time buckets - // Bucket 0-999: samples at 0, 500 with values 10, 20 (avg = 15) - // Bucket 1000-1999: samples at 1000, 1500 with values 30, 50 (avg = 40) - // Bucket 2000-2999: samples at 2000 with value 100 (avg = 100) - try ts.addSample(0, 10.0); - try ts.addSample(500, 20.0); - try ts.addSample(1000, 30.0); - try ts.addSample(1500, 50.0); - try ts.addSample(2000, 100.0); - - const agg = Aggregation{ - .agg_type = .AVG, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 3), samples.items.len); - - // Bucket 0: avg(10, 20) = 15 - try testing.expectEqual(@as(i64, 0), samples.items[0].timestamp); - try testing.expectEqual(@as(f64, 15.0), samples.items[0].value); - - // Bucket 1000: avg(30, 50) = 40 - try testing.expectEqual(@as(i64, 1000), samples.items[1].timestamp); - try testing.expectEqual(@as(f64, 40.0), samples.items[1].value); - - // Bucket 2000: avg(100) = 100 - try testing.expectEqual(@as(i64, 2000), samples.items[2].timestamp); - try testing.expectEqual(@as(f64, 100.0), samples.items[2].value); -} - -test "TS.RANGE with SUM aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Add samples: bucket 0 (0-99): 5+10=15, bucket 100 (100-199): 20+30=50 - try ts.addSample(10, 5.0); - try ts.addSample(50, 10.0); - try ts.addSample(110, 20.0); - try ts.addSample(150, 30.0); - - const agg = Aggregation{ - .agg_type = .SUM, - .time_bucket = 100, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 2), samples.items.len); - try testing.expectEqual(@as(i64, 0), samples.items[0].timestamp); - try testing.expectEqual(@as(f64, 15.0), samples.items[0].value); - try testing.expectEqual(@as(i64, 100), samples.items[1].timestamp); - try testing.expectEqual(@as(f64, 50.0), samples.items[1].value); -} - -test "TS.RANGE with MIN aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: values 100, 50, 75 -> min = 50 - // Bucket 1000: values 200, 150 -> min = 150 - try ts.addSample(100, 100.0); - try ts.addSample(200, 50.0); - try ts.addSample(500, 75.0); - try ts.addSample(1000, 200.0); - try ts.addSample(1500, 150.0); - - const agg = Aggregation{ - .agg_type = .MIN, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 2), samples.items.len); - try testing.expectEqual(@as(f64, 50.0), samples.items[0].value); - try testing.expectEqual(@as(f64, 150.0), samples.items[1].value); -} - -test "TS.RANGE with MAX aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: values 100, 50, 75 -> max = 100 - // Bucket 1000: values 200, 150 -> max = 200 - try ts.addSample(100, 100.0); - try ts.addSample(200, 50.0); - try ts.addSample(500, 75.0); - try ts.addSample(1000, 200.0); - try ts.addSample(1500, 150.0); - - const agg = Aggregation{ - .agg_type = .MAX, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 2), samples.items.len); - try testing.expectEqual(@as(f64, 100.0), samples.items[0].value); - try testing.expectEqual(@as(f64, 200.0), samples.items[1].value); -} - -test "TS.RANGE with COUNT aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: 3 samples - // Bucket 1000: 2 samples - // Bucket 2000: 1 sample - try ts.addSample(100, 1.0); - try ts.addSample(200, 2.0); - try ts.addSample(500, 3.0); - try ts.addSample(1000, 4.0); - try ts.addSample(1500, 5.0); - try ts.addSample(2000, 6.0); - - const agg = Aggregation{ - .agg_type = .COUNT, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 3), samples.items.len); - try testing.expectEqual(@as(f64, 3.0), samples.items[0].value); - try testing.expectEqual(@as(f64, 2.0), samples.items[1].value); - try testing.expectEqual(@as(f64, 1.0), samples.items[2].value); -} - -test "TS.RANGE with FIRST aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: first = 10 - // Bucket 1000: first = 30 - try ts.addSample(100, 10.0); - try ts.addSample(200, 20.0); - try ts.addSample(1000, 30.0); - try ts.addSample(1500, 40.0); - - const agg = Aggregation{ - .agg_type = .FIRST, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 2), samples.items.len); - try testing.expectEqual(@as(f64, 10.0), samples.items[0].value); - try testing.expectEqual(@as(f64, 30.0), samples.items[1].value); -} - -test "TS.RANGE with LAST aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: last = 20 - // Bucket 1000: last = 40 - try ts.addSample(100, 10.0); - try ts.addSample(200, 20.0); - try ts.addSample(1000, 30.0); - try ts.addSample(1500, 40.0); - - const agg = Aggregation{ - .agg_type = .LAST, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 2), samples.items.len); - try testing.expectEqual(@as(f64, 20.0), samples.items[0].value); - try testing.expectEqual(@as(f64, 40.0), samples.items[1].value); -} - -test "TS.RANGE with RANGE aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: values 10, 50, 30 -> range = 50 - 10 = 40 - // Bucket 1000: values 100, 200 -> range = 200 - 100 = 100 - try ts.addSample(100, 10.0); - try ts.addSample(200, 50.0); - try ts.addSample(500, 30.0); - try ts.addSample(1000, 100.0); - try ts.addSample(1500, 200.0); - - const agg = Aggregation{ - .agg_type = .RANGE, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 2), samples.items.len); - try testing.expectEqual(@as(f64, 40.0), samples.items[0].value); - try testing.expectEqual(@as(f64, 100.0), samples.items[1].value); -} - -test "TS.RANGE with STD.P (population standard deviation) aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: values 10, 20, 30 -> mean = 20, variance = ((10-20)^2 + (20-20)^2 + (30-20)^2)/3 = 66.666.../3 ≈ 66.67/3, std = sqrt(66.67/3) - try ts.addSample(0, 10.0); - try ts.addSample(100, 20.0); - try ts.addSample(200, 30.0); - - const agg = Aggregation{ - .agg_type = .STD_P, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 1), samples.items.len); - - // Expected: sqrt(((10-20)^2 + (20-20)^2 + (30-20)^2)/3) = sqrt((100 + 0 + 100)/3) = sqrt(66.666...) ≈ 8.165 - const expected_std = @sqrt(200.0 / 3.0); - try testing.expectApproxEqAbs(expected_std, samples.items[0].value, 0.001); -} - -test "TS.RANGE with STD.S (sample standard deviation) aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: values 10, 20, 30 -> sample std uses n-1 in denominator - try ts.addSample(0, 10.0); - try ts.addSample(100, 20.0); - try ts.addSample(200, 30.0); - - const agg = Aggregation{ - .agg_type = .STD_S, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 1), samples.items.len); - - // Expected: sqrt(((10-20)^2 + (20-20)^2 + (30-20)^2)/2) = sqrt((100 + 0 + 100)/2) = sqrt(100) = 10 - try testing.expectEqual(@as(f64, 10.0), samples.items[0].value); -} - -test "TS.RANGE with VAR.P (population variance) aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: values 10, 20, 30 - try ts.addSample(0, 10.0); - try ts.addSample(100, 20.0); - try ts.addSample(200, 30.0); - - const agg = Aggregation{ - .agg_type = .VAR_P, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 1), samples.items.len); - - // Expected: ((10-20)^2 + (20-20)^2 + (30-20)^2)/3 = 200/3 ≈ 66.666... - const expected_var = 200.0 / 3.0; - try testing.expectApproxEqAbs(expected_var, samples.items[0].value, 0.001); -} - -test "TS.RANGE with VAR.S (sample variance) aggregation" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Bucket 0: values 10, 20, 30 - try ts.addSample(0, 10.0); - try ts.addSample(100, 20.0); - try ts.addSample(200, 30.0); - - const agg = Aggregation{ - .agg_type = .VAR_S, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 1), samples.items.len); - - // Expected: ((10-20)^2 + (20-20)^2 + (30-20)^2)/2 = 200/2 = 100 - try testing.expectEqual(@as(f64, 100.0), samples.items[0].value); -} - -test "TS.RANGE aggregation with COUNT limit" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 100, - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Create 10 buckets with 1 sample each - var i: i64 = 0; - while (i < 10) : (i += 1) { - try ts.addSample(i * 1000, @as(f64, @floatFromInt(i))); - } - - const agg = Aggregation{ - .agg_type = .AVG, - .time_bucket = 1000, - }; - - // Request only first 5 buckets - var samples = try ts.range("-", "+", 5, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 5), samples.items.len); - try testing.expectEqual(@as(i64, 0), samples.items[0].timestamp); - try testing.expectEqual(@as(i64, 4000), samples.items[4].timestamp); -} - -test "TS.RANGE aggregation across multiple chunks" { - const allocator = testing.allocator; - - var ts = try TimeSeries.init( - allocator, - 0, - .BLOCK, - 5, // Small chunks to force multiple - .Uncompressed, - 0, - 0.0, - ); - defer ts.deinit(); - - // Add samples across multiple chunks - // Bucket 0: 4 samples - // Bucket 1000: 4 samples - try ts.addSample(0, 10.0); - try ts.addSample(100, 20.0); - try ts.addSample(200, 30.0); - try ts.addSample(300, 40.0); - try ts.addSample(1000, 50.0); - try ts.addSample(1100, 60.0); - try ts.addSample(1200, 70.0); - try ts.addSample(1300, 80.0); - - const agg = Aggregation{ - .agg_type = .SUM, - .time_bucket = 1000, - }; - - var samples = try ts.range("-", "+", null, agg); - defer samples.deinit(allocator); - - try testing.expectEqual(@as(usize, 2), samples.items.len); - try testing.expectEqual(@as(f64, 100.0), samples.items[0].value); // 10+20+30+40 - try testing.expectEqual(@as(f64, 260.0), samples.items[1].value); // 50+60+70+80 -} - -test "TS.RANGE command with aggregation parameter" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - var clock = Clock.init(testing.io, 0); - var store = try Store.init(allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); - defer store.deinit(); - - var buffer: [4096]u8 = undefined; - var writer = Writer.fixed(&buffer); - - // Create time series - const create_args = [_]Value{ - .{ .data = "TS.CREATE" }, - .{ .data = "myts" }, - }; - try ts_commands.ts_create(&writer, &store, &create_args); - - // Add samples across multiple buckets - const timestamps = [_][]const u8{ "0", "500", "1000", "1500", "2000", "2500" }; - const values = [_][]const u8{ "10", "20", "30", "40", "50", "60" }; - - for (timestamps, values) |ts_str, val_str| { - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const add_args = [_]Value{ - .{ .data = "TS.ADD" }, - .{ .data = "myts" }, - .{ .data = ts_str }, - .{ .data = val_str }, - }; - try ts_commands.ts_add(&writer, &store, &add_args); - } - - // Range with AVG aggregation, bucket size 1000 - buffer = mem.zeroes([4096]u8); - writer = Writer.fixed(&buffer); - const range_args = [_]Value{ - .{ .data = "TS.RANGE" }, - .{ .data = "myts" }, - .{ .data = "-" }, - .{ .data = "+" }, - .{ .data = "AGGREGATION" }, - .{ .data = "AVG" }, - .{ .data = "1000" }, - }; - try ts_commands.ts_range(&writer, &store, &range_args); - - const output = writer.buffered(); - // Should return 3 buckets: [0-999], [1000-1999], [2000-2999] - try testing.expect(mem.startsWith(u8, output, "*3\r\n")); -} diff --git a/src/time_series.zig b/src/time_series.zig index 546c870..f74b326 100644 --- a/src/time_series.zig +++ b/src/time_series.zig @@ -10,6 +10,9 @@ const ValueDecompressor = gorilla.ValueDecompressor; const eqlIgnoreCase = std.ascii.eqlIgnoreCase; const eql = std.mem.eql; +const Store = @import("store.zig").Store; +const Value = @import("parser.zig").Value; + const Sample = struct { timestamp: i64, value: f64, @@ -655,3 +658,1497 @@ pub const TimeSeries = struct { if (chunk_size) |cs| self.max_chunk_samples = cs; } }; + +test "TimeSeries: basic uncompressed storage" { + var ts = try TimeSeries.init( + testing.allocator, + 0, // no retention + .BLOCK, + 100, // max samples per chunk + .Uncompressed, + 0, // no ignore time diff + 0.0, // no ignore val diff + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.5); + try ts.addSample(1001, 20.5); + try ts.addSample(1002, 30.5); + + try testing.expectEqual(@as(u64, 3), ts.total_samples); + try testing.expect(ts.tail != null); + try testing.expectEqual(@as(i64, 1002), ts.tail.?.last_ts); +} + +test "TimeSeries: basic compressed storage" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .DeltaXor, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 100.0); + try ts.addSample(1010, 105.0); + try ts.addSample(1020, 110.0); + + try testing.expectEqual(@as(u64, 3), ts.total_samples); +} + +test "TimeSeries: duplicate policy BLOCK" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + + // Duplicate timestamp should return error + const result = ts.addSample(1000, 20.0); + try testing.expectError(error.TSDB_DuplicateTimestamp, result); +} + +test "TimeSeries: duplicate policy FIRST" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .FIRST, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(1000, 20.0); // Should be ignored + + try testing.expectEqual(@as(u64, 1), ts.total_samples); + try testing.expectEqual(@as(f64, 10.0), ts.last_sample.?.value); +} + +test "TimeSeries: duplicate policy LAST" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .LAST, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(1000, 20.0); // Should update + + try testing.expectEqual(@as(u64, 2), ts.total_samples); + try testing.expectEqual(@as(f64, 20.0), ts.last_sample.?.value); +} + +test "TimeSeries: duplicate policy MIN" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .MIN, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(1000, 20.0); // Should be ignored (10 < 20) + + try testing.expectEqual(@as(f64, 10.0), ts.last_sample.?.value); + + try ts.addSample(1001, 30.0); + try ts.addSample(1001, 5.0); // Should be kept (5 < 30) + + try testing.expectEqual(@as(f64, 5.0), ts.last_sample.?.value); +} + +test "TimeSeries: duplicate policy MAX" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .MAX, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(1000, 20.0); // Should be kept (20 > 10) + + try testing.expectEqual(@as(f64, 20.0), ts.last_sample.?.value); + + try ts.addSample(1001, 30.0); + try ts.addSample(1001, 15.0); // Should be ignored (15 < 30) + + try testing.expectEqual(@as(f64, 30.0), ts.last_sample.?.value); +} + +test "TimeSeries: IGNORE parameter filters samples" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .LAST, // IGNORE only works with LAST policy + 100, + .Uncompressed, + 1000, // ignore_max_time_diff = 1000ms + 5.0, // ignore_max_val_diff = 5.0 + ); + defer ts.deinit(); + + try ts.addSample(1000, 100.0); + + // Time diff = 500ms, val diff = 2.0 - both within threshold, should be ignored + try ts.addSample(1500, 102.0); + try testing.expectEqual(@as(u64, 1), ts.total_samples); + + // Time diff = 2000ms - exceeds threshold, should be added + try ts.addSample(3000, 102.0); + try testing.expectEqual(@as(u64, 2), ts.total_samples); + + // Time diff = 500ms, val diff = 10.0 - val exceeds threshold, should be added + try ts.addSample(3500, 112.0); + try testing.expectEqual(@as(u64, 3), ts.total_samples); +} + +test "TimeSeries: IGNORE does not apply to non-LAST policies" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, // Not LAST policy + 100, + .Uncompressed, + 1000, + 5.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 100.0); + + // Even though within IGNORE thresholds, should be added (BLOCK policy) + try ts.addSample(1500, 102.0); + try testing.expectEqual(@as(u64, 2), ts.total_samples); +} + +test "TimeSeries: retention policy evicts old chunks" { + var ts = try TimeSeries.init( + testing.allocator, + 5000, // 5 second retention + .BLOCK, + 2, // 2 samples per chunk - forces multiple chunks + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Add samples that will create multiple chunks + try ts.addSample(1000, 10.0); + try ts.addSample(2000, 20.0); + try testing.expectEqual(@as(u64, 2), ts.total_samples); + + // New chunk + try ts.addSample(3000, 30.0); + try ts.addSample(4000, 40.0); + try testing.expectEqual(@as(u64, 4), ts.total_samples); + + // New chunk + try ts.addSample(5000, 50.0); + try ts.addSample(6000, 60.0); + try testing.expectEqual(@as(u64, 6), ts.total_samples); + + // Add sample at 10000ms - retention window is [5000, 10000] + // First chunk (1000-2000) should be evicted + try ts.addSample(10000, 100.0); + + // Should still have samples from chunks 2 and 3 + try testing.expect(ts.head != null); + try testing.expect(ts.head.?.first_ts >= 3000); +} + +test "TimeSeries: retention policy with zero retention" { + var ts = try TimeSeries.init( + testing.allocator, + 0, // No retention - keep all data + .BLOCK, + 2, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(2000, 20.0); + try ts.addSample(3000, 30.0); + try ts.addSample(100000, 100.0); // Much later timestamp + + // All samples should be retained + try testing.expectEqual(@as(u64, 4), ts.total_samples); + try testing.expect(ts.head != null); + try testing.expectEqual(@as(i64, 1000), ts.head.?.first_ts); +} + +test "TimeSeries: chunk sealing creates new chunk when full" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 3, // Only 3 samples per chunk + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(1001, 11.0); + try ts.addSample(1002, 12.0); + + // First chunk should be full + try testing.expect(ts.tail != null); + try testing.expectEqual(@as(u16, 3), ts.tail.?.sample_count); + + // Adding another sample should create a new chunk + try ts.addSample(1003, 13.0); + + try testing.expect(ts.tail != null); + try testing.expectEqual(@as(u16, 1), ts.tail.?.sample_count); + try testing.expect(ts.tail.?.prev != null); +} + +test "TimeSeries: multiple chunks with retention" { + var ts = try TimeSeries.init( + testing.allocator, + 10000, // 10 second retention + .BLOCK, + 2, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Create 5 chunks over time + try ts.addSample(1000, 1.0); + try ts.addSample(2000, 2.0); + + try ts.addSample(3000, 3.0); + try ts.addSample(4000, 4.0); + + try ts.addSample(5000, 5.0); + try ts.addSample(6000, 6.0); + + try ts.addSample(7000, 7.0); + try ts.addSample(8000, 8.0); + + try ts.addSample(9000, 9.0); + try ts.addSample(10000, 10.0); + + // All should still exist (within 10s window from 10000) + try testing.expectEqual(@as(u64, 10), ts.total_samples); + + // Add sample at 15000 - retention window [5000, 15000] + // First two chunks should be evicted + try ts.addSample(15000, 15.0); + + try testing.expect(ts.head != null); + try testing.expect(ts.head.?.first_ts >= 5000); +} + +test "TimeSeries: out of order samples with LAST policy" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .LAST, + 10, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(2000, 20.0); + + // Out of order sample (IGNORE only applies to in-order) + try ts.addSample(1500, 15.0); + + try testing.expectEqual(@as(u64, 3), ts.total_samples); +} + +test "TimeSeries: edge case - empty time series retention" { + var ts = try TimeSeries.init( + testing.allocator, + 5000, + .BLOCK, + 10, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Empty time series - eviction should be a no-op + ts.evictExpiredChunks(10000); + + try testing.expect(ts.head == null); + try testing.expect(ts.tail == null); +} + +test "TimeSeries: all chunks evicted clears head and tail" { + var ts = try TimeSeries.init( + testing.allocator, + 1000, // 1 second retention + .BLOCK, + 2, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(1500, 15.0); + + // Add sample way in the future - all old chunks should be evicted + try ts.addSample(10000, 100.0); + + // Old chunk should be gone, only new chunk remains + try testing.expect(ts.head != null); + try testing.expect(ts.head == ts.tail); + try testing.expectEqual(@as(i64, 10000), ts.head.?.first_ts); +} + +test "TimeSeries: getLastValue returns last value" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 10, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Empty time series should return 0.0 + try testing.expectEqual(@as(f64, 0.0), ts.getLastValue()); + + // Add samples + try ts.addSample(1000, 10.0); + try testing.expectEqual(@as(f64, 10.0), ts.getLastValue()); + + try ts.addSample(2000, 20.5); + try testing.expectEqual(@as(f64, 20.5), ts.getLastValue()); +} + +test "TimeSeries: alter updates properties" { + var ts = try TimeSeries.init( + testing.allocator, + 1000, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Alter retention + ts.alter(5000, null, null); + try testing.expectEqual(@as(u64, 5000), ts.retention_ms); + + // Alter duplicate policy + ts.alter(null, .LAST, null); + try testing.expectEqual(Duplicate_Policy.LAST, ts.duplicate_policy); + + // Alter chunk size + ts.alter(null, null, 200); + try testing.expectEqual(@as(u16, 200), ts.max_chunk_samples); + + // Alter multiple at once + ts.alter(10000, .MIN, 50); + try testing.expectEqual(@as(u64, 10000), ts.retention_ms); + try testing.expectEqual(Duplicate_Policy.MIN, ts.duplicate_policy); + try testing.expectEqual(@as(u16, 50), ts.max_chunk_samples); +} + +// Command-level tests +const ts_commands = @import("commands/time_series.zig"); + +// Test aliases +const testing = std.testing; +const mem = std.mem; +const Clock = @import("clock.zig"); +const Io = std.Io; +const Writer = Io.Writer; + +test "TS.INCRBY increments from zero" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create time series + const create_args = [_]Value{ + .{ .data = "TS.CREATE" }, + .{ .data = "myts" }, + .{ .data = "RETENTION" }, + .{ .data = "0" }, + .{ .data = "ENCODING" }, + .{ .data = "UNCOMPRESSED" }, + .{ .data = "DUPLICATE_POLICY" }, + .{ .data = "LAST" }, + }; + try ts_commands.ts_create(&writer, &store, &create_args); + + // Increment by 5.0 (should start from 0.0) + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const incrby_args = [_]Value{ + .{ .data = "TS.INCRBY" }, + .{ .data = "myts" }, + .{ .data = "1000" }, + .{ .data = "5.0" }, + }; + try ts_commands.ts_incrby(&writer, &store, &incrby_args); + + // Should return timestamp + try testing.expectEqualStrings(":1000\r\n", writer.buffered()); + + // Verify value is 5.0 (formatted as "5" in RESP) + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const get_args = [_]Value{ + .{ .data = "TS.GET" }, + .{ .data = "myts" }, + }; + try ts_commands.ts_get(&writer, &store, &get_args); + + const output = writer.buffered(); + try testing.expect(mem.indexOf(u8, output, "$1\r\n5\r\n") != null or mem.indexOf(u8, output, "$3\r\n5.0\r\n") != null); +} + +test "TS.INCRBY increments from existing value" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create and add initial value + const create_args = [_]Value{ + .{ .data = "TS.CREATE" }, + .{ .data = "myts" }, + }; + try ts_commands.ts_create(&writer, &store, &create_args); + + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const add_args = [_]Value{ + .{ .data = "TS.ADD" }, + .{ .data = "myts" }, + .{ .data = "1000" }, + .{ .data = "10.0" }, + }; + try ts_commands.ts_add(&writer, &store, &add_args); + + // Increment by 3.0 + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const incrby_args = [_]Value{ + .{ .data = "TS.INCRBY" }, + .{ .data = "myts" }, + .{ .data = "2000" }, + .{ .data = "3.0" }, + }; + try ts_commands.ts_incrby(&writer, &store, &incrby_args); + + // Verify value is 13.0 (formatted as "13" in RESP) + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const get_args = [_]Value{ + .{ .data = "TS.GET" }, + .{ .data = "myts" }, + }; + try ts_commands.ts_get(&writer, &store, &get_args); + + const output = writer.buffered(); + try testing.expect(mem.indexOf(u8, output, "$2\r\n13\r\n") != null or mem.indexOf(u8, output, "$4\r\n13.0\r\n") != null); +} + +test "TS.DECRBY decrements value" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create and add initial value + const create_args = [_]Value{ + .{ .data = "TS.CREATE" }, + .{ .data = "myts" }, + }; + try ts_commands.ts_create(&writer, &store, &create_args); + + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const add_args = [_]Value{ + .{ .data = "TS.ADD" }, + .{ .data = "myts" }, + .{ .data = "1000" }, + .{ .data = "20.0" }, + }; + try ts_commands.ts_add(&writer, &store, &add_args); + + // Decrement by 7.0 + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const decrby_args = [_]Value{ + .{ .data = "TS.DECRBY" }, + .{ .data = "myts" }, + .{ .data = "2000" }, + .{ .data = "7.0" }, + }; + try ts_commands.ts_decrby(&writer, &store, &decrby_args); + + // Verify value is 13.0 (formatted as "13" in RESP) + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const get_args = [_]Value{ + .{ .data = "TS.GET" }, + .{ .data = "myts" }, + }; + try ts_commands.ts_get(&writer, &store, &get_args); + + const output = writer.buffered(); + try testing.expect(mem.indexOf(u8, output, "$2\r\n13\r\n") != null or mem.indexOf(u8, output, "$4\r\n13.0\r\n") != null); +} + +test "TS.ALTER changes retention" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create with retention 1000 + const create_args = [_]Value{ + .{ .data = "TS.CREATE" }, + .{ .data = "myts" }, + .{ .data = "RETENTION" }, + .{ .data = "1000" }, + }; + try ts_commands.ts_create(&writer, &store, &create_args); + + // Alter retention to 5000 + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const alter_args = [_]Value{ + .{ .data = "TS.ALTER" }, + .{ .data = "myts" }, + .{ .data = "RETENTION" }, + .{ .data = "5000" }, + }; + try ts_commands.ts_alter(&writer, &store, &alter_args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + // Verify the retention was changed + const ts = try store.getTimeSeries("myts"); + try testing.expectEqual(@as(u64, 5000), ts.?.retention_ms); +} + +test "TS.ALTER changes duplicate policy" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create with BLOCK policy + const create_args = [_]Value{ + .{ .data = "TS.CREATE" }, + .{ .data = "myts" }, + .{ .data = "DUPLICATE_POLICY" }, + .{ .data = "BLOCK" }, + }; + try ts_commands.ts_create(&writer, &store, &create_args); + + // Alter to LAST policy + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const alter_args = [_]Value{ + .{ .data = "TS.ALTER" }, + .{ .data = "myts" }, + .{ .data = "DUPLICATE_POLICY" }, + .{ .data = "LAST" }, + }; + try ts_commands.ts_alter(&writer, &store, &alter_args); + + try testing.expectEqualStrings("+OK\r\n", writer.buffered()); + + // Verify the policy was changed + const ts = try store.getTimeSeries("myts"); + try testing.expectEqual(Duplicate_Policy.LAST, ts.?.duplicate_policy); +} + +test "TS.RANGE returns samples from active unsealed chunk - Uncompressed" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, // Large chunk size to avoid sealing + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Add samples without sealing the chunk + try ts.addSample(1000, 10.0); + try ts.addSample(2000, 20.0); + try ts.addSample(3000, 30.0); + + // Verify chunk is active (not sealed) + try testing.expect(ts.tail != null); + try testing.expectEqual(@as(usize, 0), ts.tail.?.data.len); // No sealed data yet + + // Query range - should read from active buffer + var samples = try ts.range("-", "+", null, null); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 3), samples.items.len); + try testing.expectEqual(@as(i64, 1000), samples.items[0].timestamp); + try testing.expectEqual(@as(f64, 10.0), samples.items[0].value); + try testing.expectEqual(@as(i64, 2000), samples.items[1].timestamp); + try testing.expectEqual(@as(f64, 20.0), samples.items[1].value); + try testing.expectEqual(@as(i64, 3000), samples.items[2].timestamp); + try testing.expectEqual(@as(f64, 30.0), samples.items[2].value); +} + +test "TS.RANGE can read from active unsealed chunk (hybrid approach)" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .DeltaXor, // Compressed encoding + 0, + 0.0, + ); + defer ts.deinit(); + + // Add samples without sealing + try ts.addSample(1000, 100.0); + try ts.addSample(2000, 105.0); + try ts.addSample(3000, 110.0); + + // Verify chunk is active (unsealed) + try testing.expect(ts.tail != null); + try testing.expectEqual(@as(usize, 0), ts.tail.?.data.len); + + // Query range - with hybrid approach, active chunks are always readable + // Active samples are kept uncompressed regardless of encoding setting + // Compression only happens during chunk sealing + var samples = try ts.range("-", "+", null, null); + defer samples.deinit(testing.allocator); + + // Active chunk should return all 3 samples + try testing.expectEqual(@as(usize, 3), samples.items.len); + try testing.expectEqual(@as(i64, 1000), samples.items[0].timestamp); + try testing.expectEqual(@as(f64, 100.0), samples.items[0].value); + try testing.expectEqual(@as(i64, 2000), samples.items[1].timestamp); + try testing.expectEqual(@as(f64, 105.0), samples.items[1].value); + try testing.expectEqual(@as(i64, 3000), samples.items[2].timestamp); + try testing.expectEqual(@as(f64, 110.0), samples.items[2].value); +} + +test "TS.RANGE with COUNT parameter limits results" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Add 10 samples + var i: i64 = 0; + while (i < 10) : (i += 1) { + try ts.addSample(1000 + i * 100, @as(f64, @floatFromInt(i))); + } + + // Query with COUNT 5 + var samples = try ts.range("-", "+", 5, null); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 5), samples.items.len); + try testing.expectEqual(@as(i64, 1000), samples.items[0].timestamp); + try testing.expectEqual(@as(f64, 0.0), samples.items[0].value); + try testing.expectEqual(@as(i64, 1400), samples.items[4].timestamp); + try testing.expectEqual(@as(f64, 4.0), samples.items[4].value); +} + +test "TS.RANGE with COUNT larger than available samples returns all" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + try ts.addSample(1000, 10.0); + try ts.addSample(2000, 20.0); + try ts.addSample(3000, 30.0); + + // Request 100 samples but only 3 exist + var samples = try ts.range("-", "+", 100, null); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 3), samples.items.len); +} + +test "TS.RANGE with COUNT across multiple chunks" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 3, // Small chunks to force multiple + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Add 10 samples, creating multiple chunks + var i: i64 = 0; + while (i < 10) : (i += 1) { + try ts.addSample(1000 + i * 100, @as(f64, @floatFromInt(i))); + } + + // Query with COUNT 7 - should span across 3 chunks + var samples = try ts.range("-", "+", 7, null); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 7), samples.items.len); + try testing.expectEqual(@as(i64, 1000), samples.items[0].timestamp); + try testing.expectEqual(@as(i64, 1600), samples.items[6].timestamp); +} + +test "TS.RANGE command with COUNT parameter" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create time series with uncompressed encoding + const create_args = [_]Value{ + .{ .data = "TS.CREATE" }, + .{ .data = "myts" }, + .{ .data = "ENCODING" }, + .{ .data = "UNCOMPRESSED" }, + }; + try ts_commands.ts_create(&writer, &store, &create_args); + + // Add 5 samples + var i: usize = 0; + while (i < 5) : (i += 1) { + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const timestamp_str = try std.fmt.allocPrint(testing.allocator, "{d}", .{1000 + i * 100}); + const value_str = try std.fmt.allocPrint(testing.allocator, "{d}.0", .{i * 10}); + const add_args = [_]Value{ + .{ .data = "TS.ADD" }, + .{ .data = "myts" }, + .{ .data = timestamp_str }, + .{ .data = value_str }, + }; + try ts_commands.ts_add(&writer, &store, &add_args); + } + + // Range with COUNT 3 + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const range_args = [_]Value{ + .{ .data = "TS.RANGE" }, + .{ .data = "myts" }, + .{ .data = "-" }, + .{ .data = "+" }, + .{ .data = "COUNT" }, + .{ .data = "3" }, + }; + try ts_commands.ts_range(&writer, &store, &range_args); + + const output = writer.buffered(); + // Should return array of 3 elements + try testing.expect(mem.startsWith(u8, output, "*3\r\n")); +} + +test "TS.RANGE with 5000 random samples using compressed encoding" { + + // Create a PRNG with fixed seed for reproducibility + var prng = std.Random.DefaultPrng.init(12345); + const random = prng.random(); + + // Create time series with compressed encoding and reasonable chunk size + var ts = try TimeSeries.init( + testing.allocator, + 0, // No retention + .BLOCK, + 100, // 100 samples per chunk - will create ~50 chunks + .DeltaXor, // Use compression to test Gorilla codec at scale + 0, + 0.0, + ); + defer ts.deinit(); + + // Generate and add 5000 random samples + const num_samples = 5000; + var i: i64 = 0; + while (i < num_samples) : (i += 1) { + const timestamp = 1000000 + i * 1000; // Start at 1000000, increment by 1 second + const value = 20.0 + random.float(f64) * 10.0; // Random temperature between 20-30°C + try ts.addSample(timestamp, value); + } + + // Verify all samples were added + try testing.expectEqual(@as(u64, num_samples), ts.total_samples); + + // Fetch all samples using range query + var samples = try ts.range("-", "+", null, null); + defer samples.deinit(testing.allocator); + + // Verify we got all samples back + try testing.expectEqual(@as(usize, num_samples), samples.items.len); + + // Verify the timestamps are sequential (data integrity check) + for (samples.items, 0..) |sample, idx| { + const expected_ts = 1000000 + @as(i64, @intCast(idx)) * 1000; + try testing.expectEqual(expected_ts, sample.timestamp); + // Verify value is in expected range + try testing.expect(sample.value >= 20.0 and sample.value <= 30.0); + } + + // Test range query with specific start and end timestamps + const start_ts = 1000000 + 1000 * 1000; // Sample 1000 + const end_ts = 1000000 + 2000 * 1000; // Sample 2000 + const start_str = try std.fmt.allocPrint(testing.allocator, "{d}", .{start_ts}); + defer testing.allocator.free(start_str); + const end_str = try std.fmt.allocPrint(testing.allocator, "{d}", .{end_ts}); + defer testing.allocator.free(end_str); + + var range_samples = try ts.range(start_str, end_str, null, null); + defer range_samples.deinit(testing.allocator); + + // Should get samples from 1000 to 2000 inclusive (1001 samples) + try testing.expectEqual(@as(usize, 1001), range_samples.items.len); + try testing.expectEqual(start_ts, range_samples.items[0].timestamp); + try testing.expectEqual(end_ts, range_samples.items[range_samples.items.len - 1].timestamp); + + // Test range query with COUNT parameter + var limited_samples = try ts.range("-", "+", 500, null); + defer limited_samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 500), limited_samples.items.len); + try testing.expectEqual(@as(i64, 1000000), limited_samples.items[0].timestamp); +} + +test "TS.RANGE with 5000 random samples using uncompressed encoding" { + var prng = std.Random.DefaultPrng.init(67890); + const random = prng.random(); + + // Create time series with uncompressed encoding + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, // Test uncompressed path as well + 0, + 0.0, + ); + defer ts.deinit(); + + // Generate and add 5000 random samples + const num_samples = 5000; + var i: i64 = 0; + while (i < num_samples) : (i += 1) { + const timestamp = 2000000 + i * 500; // Different base timestamp and interval + const value = 15.0 + random.float(f64) * 20.0; // Random values between 15-35 + try ts.addSample(timestamp, value); + } + + try testing.expectEqual(@as(u64, num_samples), ts.total_samples); + + // Fetch all samples + var samples = try ts.range("-", "+", null, null); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, num_samples), samples.items.len); + + // Verify data integrity + for (samples.items, 0..) |sample, idx| { + const expected_ts = 2000000 + @as(i64, @intCast(idx)) * 500; + try testing.expectEqual(expected_ts, sample.timestamp); + try testing.expect(sample.value >= 15.0 and sample.value <= 35.0); + } +} + +// Aggregation tests + +test "TS.RANGE with AVG aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Add samples with known values across time buckets + // Bucket 0-999: samples at 0, 500 with values 10, 20 (avg = 15) + // Bucket 1000-1999: samples at 1000, 1500 with values 30, 50 (avg = 40) + // Bucket 2000-2999: samples at 2000 with value 100 (avg = 100) + try ts.addSample(0, 10.0); + try ts.addSample(500, 20.0); + try ts.addSample(1000, 30.0); + try ts.addSample(1500, 50.0); + try ts.addSample(2000, 100.0); + + const agg = Aggregation{ + .agg_type = .AVG, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 3), samples.items.len); + + // Bucket 0: avg(10, 20) = 15 + try testing.expectEqual(@as(i64, 0), samples.items[0].timestamp); + try testing.expectEqual(@as(f64, 15.0), samples.items[0].value); + + // Bucket 1000: avg(30, 50) = 40 + try testing.expectEqual(@as(i64, 1000), samples.items[1].timestamp); + try testing.expectEqual(@as(f64, 40.0), samples.items[1].value); + + // Bucket 2000: avg(100) = 100 + try testing.expectEqual(@as(i64, 2000), samples.items[2].timestamp); + try testing.expectEqual(@as(f64, 100.0), samples.items[2].value); +} + +test "TS.RANGE with SUM aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Add samples: bucket 0 (0-99): 5+10=15, bucket 100 (100-199): 20+30=50 + try ts.addSample(10, 5.0); + try ts.addSample(50, 10.0); + try ts.addSample(110, 20.0); + try ts.addSample(150, 30.0); + + const agg = Aggregation{ + .agg_type = .SUM, + .time_bucket = 100, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), samples.items.len); + try testing.expectEqual(@as(i64, 0), samples.items[0].timestamp); + try testing.expectEqual(@as(f64, 15.0), samples.items[0].value); + try testing.expectEqual(@as(i64, 100), samples.items[1].timestamp); + try testing.expectEqual(@as(f64, 50.0), samples.items[1].value); +} + +test "TS.RANGE with MIN aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: values 100, 50, 75 -> min = 50 + // Bucket 1000: values 200, 150 -> min = 150 + try ts.addSample(100, 100.0); + try ts.addSample(200, 50.0); + try ts.addSample(500, 75.0); + try ts.addSample(1000, 200.0); + try ts.addSample(1500, 150.0); + + const agg = Aggregation{ + .agg_type = .MIN, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), samples.items.len); + try testing.expectEqual(@as(f64, 50.0), samples.items[0].value); + try testing.expectEqual(@as(f64, 150.0), samples.items[1].value); +} + +test "TS.RANGE with MAX aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: values 100, 50, 75 -> max = 100 + // Bucket 1000: values 200, 150 -> max = 200 + try ts.addSample(100, 100.0); + try ts.addSample(200, 50.0); + try ts.addSample(500, 75.0); + try ts.addSample(1000, 200.0); + try ts.addSample(1500, 150.0); + + const agg = Aggregation{ + .agg_type = .MAX, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), samples.items.len); + try testing.expectEqual(@as(f64, 100.0), samples.items[0].value); + try testing.expectEqual(@as(f64, 200.0), samples.items[1].value); +} + +test "TS.RANGE with COUNT aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: 3 samples + // Bucket 1000: 2 samples + // Bucket 2000: 1 sample + try ts.addSample(100, 1.0); + try ts.addSample(200, 2.0); + try ts.addSample(500, 3.0); + try ts.addSample(1000, 4.0); + try ts.addSample(1500, 5.0); + try ts.addSample(2000, 6.0); + + const agg = Aggregation{ + .agg_type = .COUNT, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 3), samples.items.len); + try testing.expectEqual(@as(f64, 3.0), samples.items[0].value); + try testing.expectEqual(@as(f64, 2.0), samples.items[1].value); + try testing.expectEqual(@as(f64, 1.0), samples.items[2].value); +} + +test "TS.RANGE with FIRST aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: first = 10 + // Bucket 1000: first = 30 + try ts.addSample(100, 10.0); + try ts.addSample(200, 20.0); + try ts.addSample(1000, 30.0); + try ts.addSample(1500, 40.0); + + const agg = Aggregation{ + .agg_type = .FIRST, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), samples.items.len); + try testing.expectEqual(@as(f64, 10.0), samples.items[0].value); + try testing.expectEqual(@as(f64, 30.0), samples.items[1].value); +} + +test "TS.RANGE with LAST aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: last = 20 + // Bucket 1000: last = 40 + try ts.addSample(100, 10.0); + try ts.addSample(200, 20.0); + try ts.addSample(1000, 30.0); + try ts.addSample(1500, 40.0); + + const agg = Aggregation{ + .agg_type = .LAST, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), samples.items.len); + try testing.expectEqual(@as(f64, 20.0), samples.items[0].value); + try testing.expectEqual(@as(f64, 40.0), samples.items[1].value); +} + +test "TS.RANGE with RANGE aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: values 10, 50, 30 -> range = 50 - 10 = 40 + // Bucket 1000: values 100, 200 -> range = 200 - 100 = 100 + try ts.addSample(100, 10.0); + try ts.addSample(200, 50.0); + try ts.addSample(500, 30.0); + try ts.addSample(1000, 100.0); + try ts.addSample(1500, 200.0); + + const agg = Aggregation{ + .agg_type = .RANGE, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), samples.items.len); + try testing.expectEqual(@as(f64, 40.0), samples.items[0].value); + try testing.expectEqual(@as(f64, 100.0), samples.items[1].value); +} + +test "TS.RANGE with STD.P (population standard deviation) aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: values 10, 20, 30 -> mean = 20, variance = ((10-20)^2 + (20-20)^2 + (30-20)^2)/3 = 66.666.../3 ≈ 66.67/3, std = sqrt(66.67/3) + try ts.addSample(0, 10.0); + try ts.addSample(100, 20.0); + try ts.addSample(200, 30.0); + + const agg = Aggregation{ + .agg_type = .STD_P, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 1), samples.items.len); + + // Expected: sqrt(((10-20)^2 + (20-20)^2 + (30-20)^2)/3) = sqrt((100 + 0 + 100)/3) = sqrt(66.666...) ≈ 8.165 + const expected_std = @sqrt(200.0 / 3.0); + try testing.expectApproxEqAbs(expected_std, samples.items[0].value, 0.001); +} + +test "TS.RANGE with STD.S (sample standard deviation) aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: values 10, 20, 30 -> sample std uses n-1 in denominator + try ts.addSample(0, 10.0); + try ts.addSample(100, 20.0); + try ts.addSample(200, 30.0); + + const agg = Aggregation{ + .agg_type = .STD_S, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 1), samples.items.len); + + // Expected: sqrt(((10-20)^2 + (20-20)^2 + (30-20)^2)/2) = sqrt((100 + 0 + 100)/2) = sqrt(100) = 10 + try testing.expectEqual(@as(f64, 10.0), samples.items[0].value); +} + +test "TS.RANGE with VAR.P (population variance) aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: values 10, 20, 30 + try ts.addSample(0, 10.0); + try ts.addSample(100, 20.0); + try ts.addSample(200, 30.0); + + const agg = Aggregation{ + .agg_type = .VAR_P, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 1), samples.items.len); + + // Expected: ((10-20)^2 + (20-20)^2 + (30-20)^2)/3 = 200/3 ≈ 66.666... + const expected_var = 200.0 / 3.0; + try testing.expectApproxEqAbs(expected_var, samples.items[0].value, 0.001); +} + +test "TS.RANGE with VAR.S (sample variance) aggregation" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Bucket 0: values 10, 20, 30 + try ts.addSample(0, 10.0); + try ts.addSample(100, 20.0); + try ts.addSample(200, 30.0); + + const agg = Aggregation{ + .agg_type = .VAR_S, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 1), samples.items.len); + + // Expected: ((10-20)^2 + (20-20)^2 + (30-20)^2)/2 = 200/2 = 100 + try testing.expectEqual(@as(f64, 100.0), samples.items[0].value); +} + +test "TS.RANGE aggregation with COUNT limit" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 100, + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Create 10 buckets with 1 sample each + var i: i64 = 0; + while (i < 10) : (i += 1) { + try ts.addSample(i * 1000, @as(f64, @floatFromInt(i))); + } + + const agg = Aggregation{ + .agg_type = .AVG, + .time_bucket = 1000, + }; + + // Request only first 5 buckets + var samples = try ts.range("-", "+", 5, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 5), samples.items.len); + try testing.expectEqual(@as(i64, 0), samples.items[0].timestamp); + try testing.expectEqual(@as(i64, 4000), samples.items[4].timestamp); +} + +test "TS.RANGE aggregation across multiple chunks" { + var ts = try TimeSeries.init( + testing.allocator, + 0, + .BLOCK, + 5, // Small chunks to force multiple + .Uncompressed, + 0, + 0.0, + ); + defer ts.deinit(); + + // Add samples across multiple chunks + // Bucket 0: 4 samples + // Bucket 1000: 4 samples + try ts.addSample(0, 10.0); + try ts.addSample(100, 20.0); + try ts.addSample(200, 30.0); + try ts.addSample(300, 40.0); + try ts.addSample(1000, 50.0); + try ts.addSample(1100, 60.0); + try ts.addSample(1200, 70.0); + try ts.addSample(1300, 80.0); + + const agg = Aggregation{ + .agg_type = .SUM, + .time_bucket = 1000, + }; + + var samples = try ts.range("-", "+", null, agg); + defer samples.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), samples.items.len); + try testing.expectEqual(@as(f64, 100.0), samples.items[0].value); // 10+20+30+40 + try testing.expectEqual(@as(f64, 260.0), samples.items[1].value); // 50+60+70+80 +} + +test "TS.RANGE command with aggregation parameter" { + var clock = Clock.init(testing.io, 0); + var store = try Store.init(testing.allocator, testing.io, &clock, .{ .initial_capacity = 4096 }); + defer store.deinit(); + + var buffer: [4096]u8 = undefined; + var writer = Writer.fixed(&buffer); + + // Create time series + const create_args = [_]Value{ + .{ .data = "TS.CREATE" }, + .{ .data = "myts" }, + }; + try ts_commands.ts_create(&writer, &store, &create_args); + + // Add samples across multiple buckets + const timestamps = [_][]const u8{ "0", "500", "1000", "1500", "2000", "2500" }; + const values = [_][]const u8{ "10", "20", "30", "40", "50", "60" }; + + for (timestamps, values) |ts_str, val_str| { + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const add_args = [_]Value{ + .{ .data = "TS.ADD" }, + .{ .data = "myts" }, + .{ .data = ts_str }, + .{ .data = val_str }, + }; + try ts_commands.ts_add(&writer, &store, &add_args); + } + + // Range with AVG aggregation, bucket size 1000 + buffer = mem.zeroes([4096]u8); + writer = Writer.fixed(&buffer); + const range_args = [_]Value{ + .{ .data = "TS.RANGE" }, + .{ .data = "myts" }, + .{ .data = "-" }, + .{ .data = "+" }, + .{ .data = "AGGREGATION" }, + .{ .data = "AVG" }, + .{ .data = "1000" }, + }; + try ts_commands.ts_range(&writer, &store, &range_args); + + const output = writer.buffered(); + // Should return 3 buckets: [0-999], [1000-1999], [2000-2999] + try testing.expect(mem.startsWith(u8, output, "*3\r\n")); +} diff --git a/src/unit_tests.zig b/src/unit_tests.zig deleted file mode 100644 index e272944..0000000 --- a/src/unit_tests.zig +++ /dev/null @@ -1,34 +0,0 @@ -comptime { - // String commands tests - _ = @import("commands/string.zig"); - - // Core functionality tests - _ = @import("store.zig"); - _ = @import("kv_allocator.zig"); - _ = @import("parser.zig"); - _ = @import("client.zig"); - _ = @import("server.zig"); - _ = @import("testing/store.zig"); - _ = @import("testing/connection.zig"); - _ = @import("testing/string.zig"); - _ = @import("testing/list.zig"); - _ = @import("testing/time_series.zig"); - _ = @import("testing/keys.zig"); - _ = @import("testing/bloom.zig"); - _ = @import("util/string_match.zig"); - _ = @import("compression/gorilla.zig"); - - // Pub/Sub tests - _ = @import("commands/pubsub.zig"); - - _ = @import("rdb/zdb.zig"); - - // AOF tests - _ = @import("aof/aof.zig"); - - // Test utilities - _ = @import("test_utils.zig"); - - // Test runner framework - _ = @import("test_runner.zig"); -}