From f401c52f45e56d063c7373e0bd0e1cc2bd1d1c95 Mon Sep 17 00:00:00 2001 From: Marsel Mavletkulov Date: Tue, 17 Mar 2026 17:35:35 -0400 Subject: [PATCH 1/4] Add lookupCached() method --- src/maxminddb.zig | 3 +- src/reader.zig | 218 ++++++++++++++++++++++++++++------------------ 2 files changed, 136 insertions(+), 85 deletions(-) diff --git a/src/maxminddb.zig b/src/maxminddb.zig index 8db5363..5f6e618 100644 --- a/src/maxminddb.zig +++ b/src/maxminddb.zig @@ -14,10 +14,11 @@ pub const Reader = reader.Reader; pub const Result = reader.Result; pub const Metadata = reader.Metadata; pub const Iterator = reader.Iterator; -pub const Network = net.Network; +pub const LookupCache = reader.Cache; pub const Options = reader.Options; pub const LookupOptions = reader.LookupOptions; pub const WithinOptions = reader.WithinOptions; +pub const Network = net.Network; pub const Map = collection.Map; pub const Array = collection.Array; diff --git a/src/reader.zig b/src/reader.zig index 856ebf4..b17e228 100644 --- a/src/reader.zig +++ b/src/reader.zig @@ -183,23 +183,55 @@ pub const Reader = struct { address: std.net.Address, options: LookupOptions, ) !?Result(T) { - const ip = net.IP.init(address); - if (ip.bitCount() == 128 and self.metadata.ip_version == 4) { - return ReadError.IPv6AddressInIPv4Database; - } + const pointer, const network = try self.findAddress(address) orelse return null; - var pointer: usize = 0; - var prefix_len: usize = 0; - if (self.ipv4_index != null and ip == .v4) { - pointer, prefix_len = try self.findAddressInTreeWithIndex(ip); - } else { - const start_node = self.startNode(ip.bitCount()); - pointer, prefix_len = try self.findAddressInTree(ip, start_node, 0); + if (!options.include_empty_values and try self.isEmptyRecord(pointer)) { + return null; } - if (pointer == 0) { - return null; + var arena = std.heap.ArenaAllocator.init(allocator); + errdefer arena.deinit(); + + const value = try self.resolveDataPointerAndDecode( + arena.allocator(), + T, + pointer, + options.only, + ); + + return .{ + .network = network, + .value = value, + .arena = arena, + }; + } + + /// Looks up a value by an IP address using a cache. + /// Many IPs within the same network share the same record, + /// so the cache skips decoding on repeated hits. + /// + /// The caller owns the cache and each returned value is valid until its + /// cache entry is evicted. + pub fn lookupCached( + self: *Reader, + allocator: std.mem.Allocator, + T: type, + address: std.net.Address, + cache: *Cache(T), + options: LookupOptions, + ) !?struct { + network: net.Network, + value: T, + } { + const pointer, const network = try self.findAddress(address) orelse return null; + + if (cache.get(pointer)) |v| { + return .{ + .network = network, + .value = v, + }; } + if (!options.include_empty_values and try self.isEmptyRecord(pointer)) { return null; } @@ -214,10 +246,15 @@ pub const Reader = struct { options.only, ); - return .{ - .network = ip.mask(prefix_len).network(prefix_len), + cache.insert(.{ + .pointer = pointer, .value = value, .arena = arena, + }); + + return .{ + .network = network, + .value = value, }; } @@ -282,6 +319,28 @@ pub const Reader = struct { }; } + fn findAddress(self: *Reader, address: std.net.Address) !?struct { usize, net.Network } { + const ip = net.IP.init(address); + if (ip.bitCount() == 128 and self.metadata.ip_version == 4) { + return ReadError.IPv6AddressInIPv4Database; + } + + var pointer: usize = 0; + var prefix_len: usize = 0; + if (self.ipv4_index != null and ip == .v4) { + pointer, prefix_len = try self.findAddressInTreeWithIndex(ip); + } else { + const start_node = self.startNode(ip.bitCount()); + pointer, prefix_len = try self.findAddressInTree(ip, start_node, 0); + } + + if (pointer == 0) { + return null; + } + + return .{ pointer, ip.mask(prefix_len).network(prefix_len) }; + } + // Decodes database metadata which is stored as a separate data section, // see https://maxmind.github.io/MaxMind-DB/#database-metadata. fn decodeMetadata(allocator: std.mem.Allocator, src: []const u8) !Metadata { @@ -515,6 +574,59 @@ pub const Reader = struct { } }; +/// Ring buffer cache of recently decoded records. +/// The cache owns the memory that backs decoded values, +/// so each value is valid until its cache entry is evicted. +pub fn Cache(comptime T: type) type { + return struct { + entries: [cache_size]Entry = undefined, + // Indicates number of entries in the cache. + len: usize = 0, + // It's an index in the entries array where a new item will be written at. + write_pos: usize = 0, + + // 16 showed a good tradeoff in DuckDB table scan and random IPv4 lookups, + // see https://github.com/marselester/duckdb-maxmind. + const cache_size = 16; + const Self = @This(); + const Entry = struct { + pointer: usize, + value: T, + arena: std.heap.ArenaAllocator, + }; + + pub fn deinit(self: *Self) void { + for (self.entries[0..self.len]) |*e| { + e.arena.deinit(); + } + } + + fn get(self: *Self, pointer: usize) ?T { + for (self.entries[0..self.len]) |*e| { + if (e.pointer == pointer) { + return e.value; + } + } + + return null; + } + + fn insert(self: *Self, e: Entry) void { + if (self.len < cache_size) { + self.entries[self.len] = e; + self.len += 1; + + return; + } + + // Evict the oldest entry and insert the new one. + self.entries[self.write_pos].arena.deinit(); + self.entries[self.write_pos] = e; + self.write_pos = (self.write_pos + 1) % cache_size; + } + }; +} + /// Result wraps a decoded value with an arena that owns all its allocations. /// Use deinit() to free the result's memory, or skip it when using an outer arena. pub fn Result(comptime T: type) type { @@ -543,73 +655,7 @@ pub fn Iterator(T: type) type { allocator: std.mem.Allocator, field_names: ?[]const []const u8, include_empty_values: bool, - cache: Cache, - - // Ring buffer cache of recently decoded records. - // Many adjacent networks in the tree share the same data pointer, - // so caching avoids re-decoding the same record repeatedly. - // Once full, new entries overwrite the oldest slot in a circular fashion. - // Each entry owns an arena that backs the decoded value's allocations; - // the arena is freed on eviction. - const Cache = struct { - const Entry = struct { - pointer: usize, - value: T, - arena: std.heap.ArenaAllocator, - }; - - // 16 showed a good tradeoff in DuckDB table scan, - // see https://github.com/marselester/duckdb-maxmind. - const cache_size = 16; - entries: [cache_size]Entry = undefined, - // Indicates number of entries in the cache. - len: usize = 0, - // It's an index in the entries array where a new item will be written at. - write_pos: usize = 0, - - fn lookup(self: *Cache, pointer: usize) ?T { - for (self.entries[0..self.len]) |e| { - if (e.pointer == pointer) { - return e.value; - } - } - - return null; - } - - fn insert( - self: *Cache, - pointer: usize, - value: T, - arena: std.heap.ArenaAllocator, - ) void { - if (self.len < cache_size) { - self.entries[self.len] = .{ - .pointer = pointer, - .value = value, - .arena = arena, - }; - self.len += 1; - - return; - } - - // Evict oldest entry. - self.entries[self.write_pos].arena.deinit(); - self.entries[self.write_pos] = .{ - .pointer = pointer, - .value = value, - .arena = arena, - }; - self.write_pos = (self.write_pos + 1) % cache_size; - } - - fn deinit(self: *Cache) void { - for (self.entries[0..self.len]) |*e| { - e.arena.deinit(); - } - } - }; + cache: Cache(T), const Self = @This(); @@ -640,7 +686,7 @@ pub fn Iterator(T: type) type { // Check the ring buffer cache. // Recently decoded records are reused. - if (self.cache.lookup(current.node)) |cached_value| { + if (self.cache.get(current.node)) |cached_value| { return Item{ .network = ip_net, .value = cached_value, @@ -663,7 +709,11 @@ pub fn Iterator(T: type) type { self.field_names, ); - self.cache.insert(current.node, value, entry_arena); + self.cache.insert(.{ + .pointer = current.node, + .value = value, + .arena = entry_arena, + }); return Item{ .network = ip_net, From 878a30d222168047d0bc8b16d95a4bcfbd028a4c Mon Sep 17 00:00:00 2001 From: Marsel Mavletkulov Date: Tue, 17 Mar 2026 18:17:18 -0400 Subject: [PATCH 2/4] Merge lookup() and lookupCached() --- src/reader.zig | 91 +++++++++++++++++++------------------------------- 1 file changed, 35 insertions(+), 56 deletions(-) diff --git a/src/reader.zig b/src/reader.zig index b17e228..d9bf2cf 100644 --- a/src/reader.zig +++ b/src/reader.zig @@ -58,10 +58,13 @@ pub const Options = struct { ipv4_index_first_n_bits: u8 = 0, }; -pub const LookupOptions = struct { - only: ?[]const []const u8 = null, - include_empty_values: bool = false, -}; +pub fn LookupOptions(comptime T: type) type { + return struct { + only: ?[]const []const u8 = null, + include_empty_values: bool = false, + cache: ?*Cache(T) = null, + }; +} pub const WithinOptions = struct { only: ?[]const []const u8 = null, @@ -175,16 +178,28 @@ pub const Reader = struct { } /// Looks up a value by an IP address. - /// The returned Result owns an arena with all decoded allocations. + /// + /// Without a cache the returned Result owns an arena, so you should call deinit() to free it. + /// Otherwise the cache owns the memory, free it with cache.deinit(). pub fn lookup( self: *Reader, allocator: std.mem.Allocator, T: type, address: std.net.Address, - options: LookupOptions, + options: LookupOptions(T), ) !?Result(T) { const pointer, const network = try self.findAddress(address) orelse return null; + if (options.cache) |cache| { + if (cache.get(pointer)) |v| { + return .{ + .network = network, + .value = v, + .arena = null, + }; + } + } + if (!options.include_empty_values and try self.isEmptyRecord(pointer)) { return null; } @@ -199,62 +214,24 @@ pub const Reader = struct { options.only, ); - return .{ - .network = network, - .value = value, - .arena = arena, - }; - } - - /// Looks up a value by an IP address using a cache. - /// Many IPs within the same network share the same record, - /// so the cache skips decoding on repeated hits. - /// - /// The caller owns the cache and each returned value is valid until its - /// cache entry is evicted. - pub fn lookupCached( - self: *Reader, - allocator: std.mem.Allocator, - T: type, - address: std.net.Address, - cache: *Cache(T), - options: LookupOptions, - ) !?struct { - network: net.Network, - value: T, - } { - const pointer, const network = try self.findAddress(address) orelse return null; + if (options.cache) |cache| { + cache.insert(.{ + .pointer = pointer, + .value = value, + .arena = arena, + }); - if (cache.get(pointer)) |v| { return .{ .network = network, - .value = v, + .value = value, + .arena = null, }; } - if (!options.include_empty_values and try self.isEmptyRecord(pointer)) { - return null; - } - - var arena = std.heap.ArenaAllocator.init(allocator); - errdefer arena.deinit(); - - const value = try self.resolveDataPointerAndDecode( - arena.allocator(), - T, - pointer, - options.only, - ); - - cache.insert(.{ - .pointer = pointer, - .value = value, - .arena = arena, - }); - return .{ .network = network, .value = value, + .arena = arena, }; } @@ -628,15 +605,17 @@ pub fn Cache(comptime T: type) type { } /// Result wraps a decoded value with an arena that owns all its allocations. -/// Use deinit() to free the result's memory, or skip it when using an outer arena. +/// When a cache is used, the cache owns the memory and arena is null. pub fn Result(comptime T: type) type { return struct { network: net.Network, value: T, - arena: std.heap.ArenaAllocator, + arena: ?std.heap.ArenaAllocator, pub fn deinit(self: @This()) void { - self.arena.deinit(); + if (self.arena) |a| { + a.deinit(); + } } }; } From 77c0a19735ccb80005fa3c1efe59c7a957c33b7f Mon Sep 17 00:00:00 2001 From: Marsel Mavletkulov Date: Tue, 17 Mar 2026 19:22:15 -0400 Subject: [PATCH 3/4] Update within() to make caching optional --- README.md | 120 ++++++++++++++++++++++++++++++++++-- benchmarks/lookup_cache.zig | 97 +++++++++++++++++++++++++++++ benchmarks/within.zig | 59 ++++++++++++++++++ benchmarks/within_cache.zig | 66 ++++++++++++++++++++ build.zig | 3 + examples/within.zig | 3 +- src/maxminddb.zig | 22 +++++-- src/reader.zig | 68 +++++++++++--------- 8 files changed, 397 insertions(+), 41 deletions(-) create mode 100644 benchmarks/lookup_cache.zig create mode 100644 benchmarks/within.zig create mode 100644 benchmarks/within_cache.zig diff --git a/README.md b/README.md index 1aa59f6..6865a41 100644 --- a/README.md +++ b/README.md @@ -87,14 +87,23 @@ if (result) |r| { } ``` +Use a `Cache` to skip decoding when different IPs resolve to the same record. + +```zig +var cache: maxminddb.Cache(maxminddb.geolite2.City) = .{}; +defer cache.deinit(); + +const city = try db.lookup(allocator, maxminddb.geolite2.City, ip, .{ .cache = &cache }); +``` + Here are reference results on Apple M2 Pro (1M random IPv4 lookups against GeoLite2-City with `ipv4_index_first_n_bits = 16`): -| Benchmark | All fields | Filtered (city) | -|--- |--- |--- | -| `geolite2.City` | ~1,284,000 | ~1,348,000 | -| `MyCity` | ~1,383,000 | — | -| `any.Value` | ~1,254,000 | ~1,349,000 | +| Type | Default | `.only` | `Cache` | +|--- |--- |--- |--- | +| `geolite2.City` | ~1,284,000 | ~1,348,000 | ~1,474,000 | +| `MyCity` | ~1,383,000 | | | +| `any.Value` | ~1,254,000 | ~1,349,000 | |
@@ -140,6 +149,30 @@ Lookups Per Second (avg):1315870.3443053183
+geolite2.City with Cache + +```sh +$ for i in $(seq 1 10); do + zig build benchmark_lookup_cache -Doptimize=ReleaseFast -- GeoLite2-City.mmdb 1000000 \ + 2>&1 | grep 'Lookups Per Second' + done + +Lookups Per Second (avg):1493822.3908664712 +Lookups Per Second (avg):1503051.0049070602 +Lookups Per Second (avg):1499514.437731375 +Lookups Per Second (avg):1491749.9700251492 +Lookups Per Second (avg):1449924.9391983037 +Lookups Per Second (avg):1396100.6211600688 +Lookups Per Second (avg):1465750.9875955326 +Lookups Per Second (avg):1515611.9396877384 +Lookups Per Second (avg):1485235.6423035355 +Lookups Per Second (avg):1439334.222943596 +``` + +
+ +
+ MyCity ```sh @@ -203,3 +236,80 @@ Lookups Per Second (avg):1315986.2950186788 ```
+ +Use `within()` to iterate over all networks in the database. +A `Cache` avoids re-decoding networks that share the same record. + +```zig +var cache: maxminddb.Cache(maxminddb.any.Value) = .{}; +defer cache.deinit(); + +var it = try db.within( + allocator, + maxminddb.any.Value, + maxminddb.Network.all_ipv6, + .{ .cache = &cache }, +); +defer it.deinit(); + +while (try it.next()) |item| { + std.debug.print("{f} {f}\n", .{item.network, item.value}); +} +``` + +Without a cache each result owns its memory and must be freed with `item.deinit()`. + +Here are reference results on Apple M2 Pro (full GeoLite2-City scan using `any.Value`): + +| Mode | Records/sec | +|--- |--- | +| Default | ~1,235,000 | +| `Cache` | ~2,900,000 | + +
+ +no cache (any.Value) + +```sh +$ for i in $(seq 1 10); do + zig build benchmark_within -Doptimize=ReleaseFast -- GeoLite2-City.mmdb \ + 2>&1 | grep 'Records Per Second' + done + +Records Per Second: 1216758.945145436 +Records Per Second: 1238440.9772222256 +Records Per Second: 1234710.6362391203 +Records Per Second: 1229527.4688849829 +Records Per Second: 1243478.3908140333 +Records Per Second: 1226863.3718734735 +Records Per Second: 1240073.3248202254 +Records Per Second: 1247541.1528026997 +Records Per Second: 1230510.441029532 +Records Per Second: 1246311.587919839 +``` + +
+ +
+ +cache (any.Value) + +```sh +$ for i in $(seq 1 10); do + zig build benchmark_within_cache -Doptimize=ReleaseFast -- GeoLite2-City.mmdb \ + 2>&1 | grep 'Records Per Second' + done + +Records Per Second: 2847560.3756875996 +Records Per Second: 2925388.867798729 +Records Per Second: 2919203.9046571665 +Records Per Second: 2814410.555872645 +Records Per Second: 2933972.04386147 +Records Per Second: 2900700.06160036 +Records Per Second: 2922279.338699886 +Records Per Second: 2862525.847598088 +Records Per Second: 2916760.542913819 +Records Per Second: 2908245.98918392 +``` + +
diff --git a/benchmarks/lookup_cache.zig b/benchmarks/lookup_cache.zig new file mode 100644 index 0000000..ee1c6d5 --- /dev/null +++ b/benchmarks/lookup_cache.zig @@ -0,0 +1,97 @@ +const std = @import("std"); +const maxminddb = @import("maxminddb"); + +const default_db_path: []const u8 = "GeoLite2-City.mmdb"; +const default_num_lookups: u64 = 1_000_000; +const max_mmdb_fields = 32; + +pub fn main() !void { + const allocator = std.heap.smp_allocator; + + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + var db_path: []const u8 = default_db_path; + var num_lookups = default_num_lookups; + var fields: ?[]const []const u8 = null; + if (args.len > 1) db_path = args[1]; + if (args.len > 2) num_lookups = try std.fmt.parseUnsigned(u64, args[2], 10); + if (args.len > 3) { + var items: [max_mmdb_fields][]const u8 = undefined; + + var it = std.mem.splitScalar(u8, args[3], ','); + var i: usize = 0; + while (it.next()) |part| : (i += 1) { + items[i] = part; + } + + fields = items[0..i]; + } + + std.debug.print("Benchmarking with:\n", .{}); + std.debug.print(" Database: {s}\n", .{db_path}); + std.debug.print(" Lookups: {d}\n", .{num_lookups}); + std.debug.print("Opening database...\n", .{}); + + var open_timer = try std.time.Timer.start(); + var db = try maxminddb.Reader.mmap(allocator, db_path, .{ .ipv4_index_first_n_bits = 16 }); + defer db.close(); + const open_time_ms = @as(f64, @floatFromInt(open_timer.read())) / + @as(f64, @floatFromInt(std.time.ns_per_ms)); + std.debug.print("Database opened successfully in {d} ms. Type: {s}\n", .{ + open_time_ms, + db.metadata.database_type, + }); + + var cache: maxminddb.Cache(maxminddb.geolite2.City) = .{}; + defer cache.deinit(); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const arena_allocator = arena.allocator(); + + std.debug.print("Starting benchmark...\n", .{}); + var timer = try std.time.Timer.start(); + var not_found_count: u64 = 0; + var lookup_errors: u64 = 0; + var ip_bytes: [4]u8 = undefined; + + for (0..num_lookups) |_| { + std.crypto.random.bytes(&ip_bytes); + const ip = std.net.Address.initIp4(ip_bytes, 0); + + const result = db.lookup( + arena_allocator, + maxminddb.geolite2.City, + ip, + .{ .only = fields, .cache = &cache }, + ) catch |err| { + std.debug.print("! Lookup error for IP {any}: {any}\n", .{ ip, err }); + lookup_errors += 1; + continue; + }; + if (result == null) { + not_found_count += 1; + continue; + } + + _ = arena.reset(.retain_capacity); + } + + const elapsed_ns = timer.read(); + const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / + @as(f64, @floatFromInt(std.time.ns_per_s)); + const lookups_per_second = if (elapsed_s > 0) + @as(f64, @floatFromInt(num_lookups)) / elapsed_s + else + 0.0; + const successful_lookups = num_lookups - not_found_count - lookup_errors; + + std.debug.print("\n--- Benchmark Finished ---\n", .{}); + std.debug.print("Total Lookups Attempted: {d}\n", .{num_lookups}); + std.debug.print("Successful Lookups: {d}\n", .{successful_lookups}); + std.debug.print("IPs Not Found: {d}\n", .{not_found_count}); + std.debug.print("Lookup Errors: {d}\n", .{lookup_errors}); + std.debug.print("Elapsed Time: {d} s\n", .{elapsed_s}); + std.debug.print("Lookups Per Second (avg):{d}\n", .{lookups_per_second}); +} diff --git a/benchmarks/within.zig b/benchmarks/within.zig new file mode 100644 index 0000000..8366ee1 --- /dev/null +++ b/benchmarks/within.zig @@ -0,0 +1,59 @@ +const std = @import("std"); +const maxminddb = @import("maxminddb"); + +const default_db_path: []const u8 = "GeoLite2-City.mmdb"; + +pub fn main() !void { + const allocator = std.heap.smp_allocator; + + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + var db_path: []const u8 = default_db_path; + if (args.len > 1) db_path = args[1]; + + std.debug.print("Benchmarking with:\n", .{}); + std.debug.print(" Database: {s}\n", .{db_path}); + std.debug.print("Opening database...\n", .{}); + + var open_timer = try std.time.Timer.start(); + var db = try maxminddb.Reader.mmap(allocator, db_path, .{}); + defer db.close(); + const open_time_ms = @as(f64, @floatFromInt(open_timer.read())) / + @as(f64, @floatFromInt(std.time.ns_per_ms)); + std.debug.print("Database opened successfully in {d} ms. Type: {s}\n", .{ + open_time_ms, + db.metadata.database_type, + }); + + const network = if (db.metadata.ip_version == 4) + maxminddb.Network.all_ipv4 + else + maxminddb.Network.all_ipv6; + + std.debug.print("Starting benchmark...\n", .{}); + var timer = try std.time.Timer.start(); + + var it = try db.within(allocator, maxminddb.any.Value, network, .{}); + defer it.deinit(); + + var n: usize = 0; + while (try it.next()) |item| { + n += 1; + item.deinit(); + } + + const elapsed_ns = timer.read(); + const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / + @as(f64, @floatFromInt(std.time.ns_per_s)); + + const records_per_second = if (elapsed_s > 0) + @as(f64, @floatFromInt(n)) / elapsed_s + else + 0.0; + + std.debug.print("\n--- Benchmark Finished ---\n", .{}); + std.debug.print("Records: {d}\n", .{n}); + std.debug.print("Elapsed Time: {d} s\n", .{elapsed_s}); + std.debug.print("Records Per Second: {d}\n", .{records_per_second}); +} diff --git a/benchmarks/within_cache.zig b/benchmarks/within_cache.zig new file mode 100644 index 0000000..46915da --- /dev/null +++ b/benchmarks/within_cache.zig @@ -0,0 +1,66 @@ +const std = @import("std"); +const maxminddb = @import("maxminddb"); + +const default_db_path: []const u8 = "GeoLite2-City.mmdb"; + +pub fn main() !void { + const allocator = std.heap.smp_allocator; + + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + var db_path: []const u8 = default_db_path; + if (args.len > 1) db_path = args[1]; + + std.debug.print("Benchmarking with:\n", .{}); + std.debug.print(" Database: {s}\n", .{db_path}); + std.debug.print("Opening database...\n", .{}); + + var open_timer = try std.time.Timer.start(); + var db = try maxminddb.Reader.mmap(allocator, db_path, .{}); + defer db.close(); + const open_time_ms = @as(f64, @floatFromInt(open_timer.read())) / + @as(f64, @floatFromInt(std.time.ns_per_ms)); + std.debug.print("Database opened successfully in {d} ms. Type: {s}\n", .{ + open_time_ms, + db.metadata.database_type, + }); + + const network = if (db.metadata.ip_version == 4) + maxminddb.Network.all_ipv4 + else + maxminddb.Network.all_ipv6; + + var cache: maxminddb.Cache(maxminddb.any.Value) = .{}; + defer cache.deinit(); + + std.debug.print("Starting benchmark...\n", .{}); + var timer = try std.time.Timer.start(); + + var it = try db.within( + allocator, + maxminddb.any.Value, + network, + .{ .cache = &cache }, + ); + defer it.deinit(); + + var n: usize = 0; + while (try it.next()) |_| { + n += 1; + } + + const elapsed_ns = timer.read(); + const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / + @as(f64, @floatFromInt(std.time.ns_per_s)); + + const records_per_second = if (elapsed_s > 0) + @as(f64, @floatFromInt(n)) / elapsed_s + else + 0.0; + + std.debug.print("\n--- Benchmark Finished ---\n", .{}); + std.debug.print("Records: {d}\n", .{n}); + std.debug.print("Elapsed Time: {d} s\n", .{elapsed_s}); + std.debug.print("Records Per Second: {d}\n", .{records_per_second}); +} diff --git a/build.zig b/build.zig index fc3a1dd..497da4d 100644 --- a/build.zig +++ b/build.zig @@ -30,8 +30,11 @@ pub fn build(b: *std.Build) void { .{ .file = "examples/within.zig", .name = "example_within" }, .{ .file = "examples/inspect.zig", .name = "example_inspect" }, .{ .file = "benchmarks/lookup.zig", .name = "benchmark_lookup" }, + .{ .file = "benchmarks/lookup_cache.zig", .name = "benchmark_lookup_cache" }, .{ .file = "benchmarks/mycity.zig", .name = "benchmark_mycity" }, .{ .file = "benchmarks/inspect.zig", .name = "benchmark_inspect" }, + .{ .file = "benchmarks/within.zig", .name = "benchmark_within" }, + .{ .file = "benchmarks/within_cache.zig", .name = "benchmark_within_cache" }, }; { diff --git a/examples/within.zig b/examples/within.zig index 42c3815..4d64491 100644 --- a/examples/within.zig +++ b/examples/within.zig @@ -19,9 +19,10 @@ pub fn main() !void { var it = try db.within(allocator, maxminddb.geolite2.City, network, .{}); defer it.deinit(); - // The iterator owns the values; each next() call invalidates the previous item. var n: usize = 0; while (try it.next()) |item| { + defer item.deinit(); + const continent = item.value.continent.code; const country = item.value.country.iso_code; var city: []const u8 = ""; diff --git a/src/maxminddb.zig b/src/maxminddb.zig index 5f6e618..9d64c7e 100644 --- a/src/maxminddb.zig +++ b/src/maxminddb.zig @@ -14,7 +14,7 @@ pub const Reader = reader.Reader; pub const Result = reader.Result; pub const Metadata = reader.Metadata; pub const Iterator = reader.Iterator; -pub const LookupCache = reader.Cache; +pub const Cache = reader.Cache; pub const Options = reader.Options; pub const LookupOptions = reader.LookupOptions; pub const WithinOptions = reader.WithinOptions; @@ -887,7 +887,9 @@ test "within returns all networks" { defer it.deinit(); var n: usize = 0; - while (try it.next()) |_| : (n += 1) {} + while (try it.next()) |item| : (n += 1) { + item.deinit(); + } try expectEqual(242, n); } @@ -908,6 +910,7 @@ test "within yields record when query prefix is narrower than record network" { defer it.deinit(); const item = (try it.next()) orelse return error.TestExpectedNotNull; + defer item.deinit(); try expectEqual(17, item.network.prefix_len); var out: [256]u8 = undefined; @@ -915,7 +918,8 @@ test "within yields record when query prefix is narrower than record network" { try item.network.format(&w); try expectEqualStrings("89.160.0.0/17", out[0..w.end]); - if (try it.next()) |_| { + if (try it.next()) |i| { + i.deinit(); return error.TestExpectedNull; } } @@ -933,9 +937,11 @@ test "within yields record when start node is a data pointer" { defer it.deinit(); const item = (try it.next()) orelse return error.TestExpectedNotNull; + defer item.deinit(); try expectEqual(0, item.network.prefix_len); - if (try it.next()) |_| { + if (try it.next()) |i| { + i.deinit(); return error.TestExpectedNull; } } @@ -973,7 +979,9 @@ test "within skips empty records" { defer it.deinit(); var n: usize = 0; - while (try it.next()) |_| : (n += 1) {} + while (try it.next()) |item| : (n += 1) { + item.deinit(); + } try std.testing.expectEqual(571, n); } @@ -985,7 +993,9 @@ test "within skips empty records" { defer it.deinit(); var n: usize = 0; - while (try it.next()) |_| : (n += 1) {} + while (try it.next()) |item| : (n += 1) { + item.deinit(); + } try std.testing.expectEqual(8, n); } } diff --git a/src/reader.zig b/src/reader.zig index d9bf2cf..777eb44 100644 --- a/src/reader.zig +++ b/src/reader.zig @@ -66,10 +66,13 @@ pub fn LookupOptions(comptime T: type) type { }; } -pub const WithinOptions = struct { - only: ?[]const []const u8 = null, - include_empty_values: bool = false, -}; +pub fn WithinOptions(comptime T: type) type { + return struct { + only: ?[]const []const u8 = null, + include_empty_values: bool = false, + cache: ?*Cache(T) = null, + }; +} pub const Reader = struct { metadata: Metadata, @@ -236,12 +239,14 @@ pub const Reader = struct { } /// Iterates over blocks of IP networks. + /// + /// Adjacent networks often share the same record, so using a cache avoids redundant decoding. pub fn within( self: *Reader, allocator: std.mem.Allocator, T: type, network: net.Network, - options: WithinOptions, + options: WithinOptions(T), ) !Iterator(T) { const prefix_len: usize = network.prefix_len; const ip_raw = net.IP.init(network.ip); @@ -290,7 +295,7 @@ pub const Reader = struct { .node_count = node_count, .stack = stack, .allocator = allocator, - .cache = .{}, + .cache = options.cache, .field_names = options.only, .include_empty_values = options.include_empty_values, }; @@ -634,18 +639,15 @@ pub fn Iterator(T: type) type { allocator: std.mem.Allocator, field_names: ?[]const []const u8, include_empty_values: bool, - cache: Cache(T), + cache: ?*Cache(T), const Self = @This(); - pub const Item = struct { - network: net.Network, - value: T, - }; - /// Returns the next network and its value. - /// The iterator owns the value; each call eventually invalidates the previous Item. - pub fn next(self: *Self) !?Item { + /// + /// Without a cache the returned Result owns an arena, so you should call deinit() to free it. + /// Otherwise the cache owns the memory, free it with cache.deinit(). + pub fn next(self: *Self) !?Result(T) { while (self.stack.pop()) |current| { const reader = self.reader; const bit_count = current.ip_bytes.bitCount(); @@ -663,17 +665,17 @@ pub fn Iterator(T: type) type { if (current.node > self.node_count) { const ip_net = current.ip_bytes.network(current.prefix_len); - // Check the ring buffer cache. - // Recently decoded records are reused. - if (self.cache.get(current.node)) |cached_value| { - return Item{ - .network = ip_net, - .value = cached_value, - }; + if (self.cache) |cache| { + if (cache.get(current.node)) |v| { + return .{ + .network = ip_net, + .value = v, + .arena = null, + }; + } } // Skip empty records (map with zero entries) unless requested. - // Checked after cache lookup because skipped records are never decoded or cached. if (!self.include_empty_values and try reader.isEmptyRecord(current.node)) { continue; } @@ -688,15 +690,24 @@ pub fn Iterator(T: type) type { self.field_names, ); - self.cache.insert(.{ - .pointer = current.node, - .value = value, - .arena = entry_arena, - }); + if (self.cache) |cache| { + cache.insert(.{ + .pointer = current.node, + .value = value, + .arena = entry_arena, + }); - return Item{ + return .{ + .network = ip_net, + .value = value, + .arena = null, + }; + } + + return .{ .network = ip_net, .value = value, + .arena = entry_arena, }; } else if (current.node < self.node_count) { // In order traversal of the children on the right (1-bit). @@ -731,7 +742,6 @@ pub fn Iterator(T: type) type { } pub fn deinit(self: *Self) void { - self.cache.deinit(); self.stack.deinit(self.allocator); } }; From 202a3e287f4a9f133a8b7ca96f47b1a08b8a1fa0 Mon Sep 17 00:00:00 2001 From: Marsel Mavletkulov Date: Fri, 20 Mar 2026 17:54:21 -0400 Subject: [PATCH 4/4] Separate methods to use cache, rename within to scan --- .github/workflows/ci.yml | 4 +- README.md | 99 ++++--- benchmarks/inspect.zig | 2 +- benchmarks/lookup.zig | 2 +- benchmarks/lookup_cache.zig | 14 +- benchmarks/mycity.zig | 2 +- benchmarks/{within.zig => scan.zig} | 3 +- .../{within_cache.zig => scan_cache.zig} | 9 +- build.zig | 6 +- examples/inspect.zig | 2 +- examples/lookup.zig | 2 +- examples/{within.zig => scan.zig} | 3 +- src/maxminddb.zig | 80 +++--- src/reader.zig | 248 +++++++++++------- 14 files changed, 282 insertions(+), 194 deletions(-) rename benchmarks/{within.zig => scan.zig} (94%) rename benchmarks/{within_cache.zig => scan_cache.zig} (92%) rename examples/{within.zig => scan.zig} (93%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d15c80e..2bb5aa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: run: zig build test - name: Run lookup example run: zig build example_lookup - - name: Run within example - run: zig build example_within + - name: Run scan example + run: zig build example_scan - name: Run inspect example run: zig build example_inspect diff --git a/README.md b/README.md index 6865a41..a155e76 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,37 @@ var db = try maxminddb.Reader.mmap(allocator, db_path, .{ .ipv4_index_first_n_bi defer db.close(); ``` -Use `ArenaAllocator` for best performance, see [benchmarks](./benchmarks/). +Each `lookup` result owns an arena with all decoded allocations. +Call `deinit()` to free it or use `ArenaAllocator` with `reset()`, +see [benchmarks](./benchmarks/lookup.zig). + +```zig +if (try db.lookup(maxminddb.geolite2.City, allocator, ip, .{})) |result| { + defer result.deinit(); + std.debug.print("{f} {s}\n", .{ result.network, result.value.city.names.?.get("en").? }); +} + +var arena = std.heap.ArenaAllocator.init(allocator); +defer arena.deinit(); + +const arena_allocator = arena.allocator(); +for (ips) |ip| { + if (try db.lookup(maxminddb.geolite2.City, arena_allocator, ip, .{})) |result| { + std.debug.print("{f} {s}\n", .{ result.network, result.value.city.names.?.get("en").? }); + } + + _ = arena.reset(.retain_capacity); +} +``` If you don't need all the fields, use `.only` to decode only the top-level fields you want. ```zig const fields = &.{ "city", "country" }; -const city = try db.lookup(allocator, maxminddb.geolite2.City, ip, .{ .only = fields }); +if (try db.lookup(maxminddb.geolite2.City, allocator, ip, .{ .only = fields })) |result| { + defer result.deinit(); + std.debug.print("{f} {s}\n", .{ result.network, result.value.city.names.?.get("en").? }); +} ``` Alternatively, define your own struct with only the fields you need. @@ -74,36 +98,42 @@ const MyCity = struct { } = .{}, }; -const city = try db.lookup(allocator, MyCity, ip, .{}); +if (try db.lookup(MyCity, allocator, ip, .{})) |result| { + defer result.deinit(); + std.debug.print("{s}\n", .{result.value.city.names.en}); +} ``` Use `any.Value` to decode any record without knowing the schema. ```zig -const result = try db.lookup(allocator, maxminddb.any.Value, ip, .{ .only = fields }); -if (result) |r| { +if (try db.lookup(maxminddb.any.Value, allocator, ip, .{ .only = fields })) |result| { + defer result.deinit(); // Formats as compact JSON. - std.debug.print("{f}\n", .{r.value}); + std.debug.print("{f}\n", .{result.value}); } ``` -Use a `Cache` to skip decoding when different IPs resolve to the same record. +Use `lookupWithCache` to skip decoding when different IPs resolve to the same record. +The cache owns decoded memory, so results don't need to be individually freed. ```zig -var cache: maxminddb.Cache(maxminddb.geolite2.City) = .{}; +var cache = try maxminddb.Cache(maxminddb.geolite2.City).init(allocator, .{}); defer cache.deinit(); -const city = try db.lookup(allocator, maxminddb.geolite2.City, ip, .{ .cache = &cache }); +if (try db.lookupWithCache(maxminddb.geolite2.City, &cache, ip, .{})) |result| { + std.debug.print("{f} {s}\n", .{ result.network, result.value.city.names.?.get("en").? }); +} ``` Here are reference results on Apple M2 Pro (1M random IPv4 lookups against GeoLite2-City with `ipv4_index_first_n_bits = 16`): -| Type | Default | `.only` | `Cache` | -|--- |--- |--- |--- | -| `geolite2.City` | ~1,284,000 | ~1,348,000 | ~1,474,000 | -| `MyCity` | ~1,383,000 | | | -| `any.Value` | ~1,254,000 | ~1,349,000 | | +| Type | Default | `.only` | `Cache` | +|--- |--- |--- |--- | +| `geolite2.City` | ~1,420,000 | ~1,348,000 | ~1,565,000 | +| `MyCity` | ~1,383,000 | | | +| `any.Value` | ~1,254,000 | ~1,349,000 | |
@@ -237,34 +267,37 @@ Lookups Per Second (avg):1315986.2950186788
-Use `within()` to iterate over all networks in the database. -A `Cache` avoids re-decoding networks that share the same record. +Use `scan` to iterate over all networks in the database. ```zig -var cache: maxminddb.Cache(maxminddb.any.Value) = .{}; +var it = try db.scan(maxminddb.any.Value, allocator, maxminddb.Network.all_ipv6, .{}); + +while (try it.next()) |item| { + defer item.deinit(); + std.debug.print("{f} {f}\n", .{ item.network, item.value }); +} +``` + +Use `scanWithCache` to avoid re-decoding networks that share the same record. +The cache owns decoded memory, so results don't need to be individually freed. + +```zig +var cache = try maxminddb.Cache(maxminddb.any.Value).init(allocator, .{}); defer cache.deinit(); -var it = try db.within( - allocator, - maxminddb.any.Value, - maxminddb.Network.all_ipv6, - .{ .cache = &cache }, -); -defer it.deinit(); +var it = try db.scanWithCache(maxminddb.any.Value, &cache, maxminddb.Network.all_ipv6, .{}); while (try it.next()) |item| { - std.debug.print("{f} {f}\n", .{item.network, item.value}); + std.debug.print("{f} {f}\n", .{ item.network, item.value }); } ``` -Without a cache each result owns its memory and must be freed with `item.deinit()`. - Here are reference results on Apple M2 Pro (full GeoLite2-City scan using `any.Value`): -| Mode | Records/sec | -|--- |--- | -| Default | ~1,235,000 | -| `Cache` | ~2,900,000 | +| Mode | Records/sec | +|--- |--- | +| Default | ~1,295,000 | +| `Cache` | ~2,930,000 |
@@ -272,7 +305,7 @@ Here are reference results on Apple M2 Pro (full GeoLite2-City scan using `any.V ```sh $ for i in $(seq 1 10); do - zig build benchmark_within -Doptimize=ReleaseFast -- GeoLite2-City.mmdb \ + zig build benchmark_scan -Doptimize=ReleaseFast -- GeoLite2-City.mmdb \ 2>&1 | grep 'Records Per Second' done @@ -296,7 +329,7 @@ Records Per Second: 1246311.587919839 ```sh $ for i in $(seq 1 10); do - zig build benchmark_within_cache -Doptimize=ReleaseFast -- GeoLite2-City.mmdb \ + zig build benchmark_scan_cache -Doptimize=ReleaseFast -- GeoLite2-City.mmdb \ 2>&1 | grep 'Records Per Second' done diff --git a/benchmarks/inspect.zig b/benchmarks/inspect.zig index 597778d..6fd1d1e 100644 --- a/benchmarks/inspect.zig +++ b/benchmarks/inspect.zig @@ -59,8 +59,8 @@ pub fn main() !void { const ip = std.net.Address.initIp4(ip_bytes, 0); const result = db.lookup( - arena_allocator, maxminddb.any.Value, + arena_allocator, ip, .{ .only = fields }, ) catch |err| { diff --git a/benchmarks/lookup.zig b/benchmarks/lookup.zig index f260157..c5d4508 100644 --- a/benchmarks/lookup.zig +++ b/benchmarks/lookup.zig @@ -59,8 +59,8 @@ pub fn main() !void { const ip = std.net.Address.initIp4(ip_bytes, 0); const result = db.lookup( - arena_allocator, maxminddb.geolite2.City, + arena_allocator, ip, .{ .only = fields }, ) catch |err| { diff --git a/benchmarks/lookup_cache.zig b/benchmarks/lookup_cache.zig index ee1c6d5..e2d83ed 100644 --- a/benchmarks/lookup_cache.zig +++ b/benchmarks/lookup_cache.zig @@ -43,13 +43,9 @@ pub fn main() !void { db.metadata.database_type, }); - var cache: maxminddb.Cache(maxminddb.geolite2.City) = .{}; + var cache = try maxminddb.Cache(maxminddb.geolite2.City).init(allocator, .{}); defer cache.deinit(); - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - const arena_allocator = arena.allocator(); - std.debug.print("Starting benchmark...\n", .{}); var timer = try std.time.Timer.start(); var not_found_count: u64 = 0; @@ -60,11 +56,11 @@ pub fn main() !void { std.crypto.random.bytes(&ip_bytes); const ip = std.net.Address.initIp4(ip_bytes, 0); - const result = db.lookup( - arena_allocator, + const result = db.lookupWithCache( maxminddb.geolite2.City, + &cache, ip, - .{ .only = fields, .cache = &cache }, + .{ .only = fields }, ) catch |err| { std.debug.print("! Lookup error for IP {any}: {any}\n", .{ ip, err }); lookup_errors += 1; @@ -74,8 +70,6 @@ pub fn main() !void { not_found_count += 1; continue; } - - _ = arena.reset(.retain_capacity); } const elapsed_ns = timer.read(); diff --git a/benchmarks/mycity.zig b/benchmarks/mycity.zig index 558ce7a..e9de225 100644 --- a/benchmarks/mycity.zig +++ b/benchmarks/mycity.zig @@ -53,8 +53,8 @@ pub fn main() !void { const ip = std.net.Address.initIp4(ip_bytes, 0); const result = db.lookup( - arena_allocator, MyCity, + arena_allocator, ip, .{}, ) catch |err| { diff --git a/benchmarks/within.zig b/benchmarks/scan.zig similarity index 94% rename from benchmarks/within.zig rename to benchmarks/scan.zig index 8366ee1..13a722b 100644 --- a/benchmarks/within.zig +++ b/benchmarks/scan.zig @@ -34,8 +34,7 @@ pub fn main() !void { std.debug.print("Starting benchmark...\n", .{}); var timer = try std.time.Timer.start(); - var it = try db.within(allocator, maxminddb.any.Value, network, .{}); - defer it.deinit(); + var it = try db.scan(maxminddb.any.Value, allocator, network, .{}); var n: usize = 0; while (try it.next()) |item| { diff --git a/benchmarks/within_cache.zig b/benchmarks/scan_cache.zig similarity index 92% rename from benchmarks/within_cache.zig rename to benchmarks/scan_cache.zig index 46915da..0290d40 100644 --- a/benchmarks/within_cache.zig +++ b/benchmarks/scan_cache.zig @@ -31,19 +31,18 @@ pub fn main() !void { else maxminddb.Network.all_ipv6; - var cache: maxminddb.Cache(maxminddb.any.Value) = .{}; + var cache = try maxminddb.Cache(maxminddb.any.Value).init(allocator, .{}); defer cache.deinit(); std.debug.print("Starting benchmark...\n", .{}); var timer = try std.time.Timer.start(); - var it = try db.within( - allocator, + var it = try db.scanWithCache( maxminddb.any.Value, + &cache, network, - .{ .cache = &cache }, + .{}, ); - defer it.deinit(); var n: usize = 0; while (try it.next()) |_| { diff --git a/build.zig b/build.zig index 497da4d..c386532 100644 --- a/build.zig +++ b/build.zig @@ -27,14 +27,14 @@ pub fn build(b: *std.Build) void { name: []const u8, }{ .{ .file = "examples/lookup.zig", .name = "example_lookup" }, - .{ .file = "examples/within.zig", .name = "example_within" }, .{ .file = "examples/inspect.zig", .name = "example_inspect" }, + .{ .file = "examples/scan.zig", .name = "example_scan" }, .{ .file = "benchmarks/lookup.zig", .name = "benchmark_lookup" }, .{ .file = "benchmarks/lookup_cache.zig", .name = "benchmark_lookup_cache" }, .{ .file = "benchmarks/mycity.zig", .name = "benchmark_mycity" }, .{ .file = "benchmarks/inspect.zig", .name = "benchmark_inspect" }, - .{ .file = "benchmarks/within.zig", .name = "benchmark_within" }, - .{ .file = "benchmarks/within_cache.zig", .name = "benchmark_within_cache" }, + .{ .file = "benchmarks/scan.zig", .name = "benchmark_scan" }, + .{ .file = "benchmarks/scan_cache.zig", .name = "benchmark_scan_cache" }, }; { diff --git a/examples/inspect.zig b/examples/inspect.zig index 8d2f970..824f7f3 100644 --- a/examples/inspect.zig +++ b/examples/inspect.zig @@ -15,8 +15,8 @@ pub fn main() !void { defer db.close(); const result = try db.lookup( - allocator, maxminddb.any.Value, + allocator, try std.net.Address.parseIp(ip, 0), .{}, ) orelse { diff --git a/examples/lookup.zig b/examples/lookup.zig index a36c155..7e87073 100644 --- a/examples/lookup.zig +++ b/examples/lookup.zig @@ -14,7 +14,7 @@ pub fn main() !void { // Note, for better performance use arena allocator and reset it after calling lookup(). // You won't need to call city.deinit() in that case. const ip = try std.net.Address.parseIp("89.160.20.128", 0); - const city = try db.lookup(allocator, maxminddb.geoip2.City, ip, .{}) orelse return; + const city = try db.lookup(maxminddb.geoip2.City, allocator, ip, .{}) orelse return; defer city.deinit(); for (city.value.country.names.?.entries) |e| { diff --git a/examples/within.zig b/examples/scan.zig similarity index 93% rename from examples/within.zig rename to examples/scan.zig index 4d64491..5098761 100644 --- a/examples/within.zig +++ b/examples/scan.zig @@ -16,8 +16,7 @@ pub fn main() !void { else maxminddb.Network.all_ipv6; - var it = try db.within(allocator, maxminddb.geolite2.City, network, .{}); - defer it.deinit(); + var it = try db.scan(maxminddb.geolite2.City, allocator, network, .{}); var n: usize = 0; while (try it.next()) |item| { diff --git a/src/maxminddb.zig b/src/maxminddb.zig index 9d64c7e..0f9c51e 100644 --- a/src/maxminddb.zig +++ b/src/maxminddb.zig @@ -15,9 +15,6 @@ pub const Result = reader.Result; pub const Metadata = reader.Metadata; pub const Iterator = reader.Iterator; pub const Cache = reader.Cache; -pub const Options = reader.Options; -pub const LookupOptions = reader.LookupOptions; -pub const WithinOptions = reader.WithinOptions; pub const Network = net.Network; pub const Map = collection.Map; pub const Array = collection.Array; @@ -147,7 +144,7 @@ test "GeoLite2 Country" { try expectEqual(DatabaseType.geolite_country, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("89.160.20.128", 0); - const got = (try db.lookup(allocator, geolite2.Country, ip, .{})).?; + const got = (try db.lookup(geolite2.Country, allocator, ip, .{})).?; defer got.deinit(); try expectEqualStrings("EU", got.value.continent.code); @@ -180,7 +177,7 @@ test "GeoLite2 Country" { // Verify network masking for an IPv6 lookup. const ipv6 = try std.net.Address.parseIp("2001:218:ffff:ffff:ffff:ffff:ffff:ffff", 0); - const got_v6 = (try db.lookup(allocator, geolite2.Country, ipv6, .{})).?; + const got_v6 = (try db.lookup(geolite2.Country, allocator, ipv6, .{})).?; defer got_v6.deinit(); try expectEqualStrings("JP", got_v6.value.country.iso_code); @@ -201,7 +198,7 @@ test "GeoLite2 City" { try expectEqual(DatabaseType.geolite_city, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("89.160.20.128", 0); - const got = (try db.lookup(allocator, geolite2.City, ip, .{})).?; + const got = (try db.lookup(geolite2.City, allocator, ip, .{})).?; defer got.deinit(); try expectEqual(2694762, got.value.city.geoname_id); @@ -273,7 +270,7 @@ test "GeoLite2 ASN" { try expectEqual(DatabaseType.geolite_asn, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("89.160.20.128", 0); - const got = (try db.lookup(allocator, geolite2.ASN, ip, .{})).?; + const got = (try db.lookup(geolite2.ASN, allocator, ip, .{})).?; defer got.deinit(); const want = geolite2.ASN{ @@ -298,7 +295,7 @@ test "GeoIP2 Country" { try expectEqual(DatabaseType.geoip_country, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("89.160.20.128", 0); - const got = (try db.lookup(allocator, geoip2.Country, ip, .{})).?; + const got = (try db.lookup(geoip2.Country, allocator, ip, .{})).?; defer got.deinit(); try expectEqualStrings("EU", got.value.continent.code); @@ -337,7 +334,7 @@ test "GeoIP2 Country" { ); const ip2 = try std.net.Address.parseIp("214.1.1.0", 0); - const got2 = (try db.lookup(allocator, geoip2.Country, ip2, .{})).?; + const got2 = (try db.lookup(geoip2.Country, allocator, ip2, .{})).?; defer got2.deinit(); try expectEqual(true, got2.value.traits.is_anycast); @@ -352,7 +349,7 @@ test "GeoIP2 Country RepresentedCountry" { defer db.close(); const ip = try std.net.Address.parseIp("202.196.224.0", 0); - const got = (try db.lookup(allocator, geoip2.Country, ip, .{})).?; + const got = (try db.lookup(geoip2.Country, allocator, ip, .{})).?; defer got.deinit(); try expectEqualStrings("AS", got.value.continent.code); @@ -380,7 +377,7 @@ test "GeoIP2 City" { try expectEqual(DatabaseType.geoip_city, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("89.160.20.128", 0); - const got = (try db.lookup(allocator, geoip2.City, ip, .{})).?; + const got = (try db.lookup(geoip2.City, allocator, ip, .{})).?; defer got.deinit(); try expectEqual(2694762, got.value.city.geoname_id); @@ -448,7 +445,7 @@ test "GeoIP2 City" { ); const ip2 = try std.net.Address.parseIp("214.1.1.0", 0); - const got2 = (try db.lookup(allocator, geoip2.City, ip2, .{})).?; + const got2 = (try db.lookup(geoip2.City, allocator, ip2, .{})).?; defer got2.deinit(); try expectEqual(true, got2.value.traits.is_anycast); @@ -465,7 +462,7 @@ test "GeoIP2 Enterprise" { try expectEqual(DatabaseType.geoip_enterprise, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("74.209.24.0", 0); - const got = (try db.lookup(allocator, geoip2.Enterprise, ip, .{})).?; + const got = (try db.lookup(geoip2.Enterprise, allocator, ip, .{})).?; defer got.deinit(); try expectEqual(11, got.value.city.confidence); @@ -548,7 +545,7 @@ test "GeoIP2 Enterprise" { ); const ip2 = try std.net.Address.parseIp("214.1.1.0", 0); - const got2 = (try db.lookup(allocator, geoip2.Enterprise, ip2, .{})).?; + const got2 = (try db.lookup(geoip2.Enterprise, allocator, ip2, .{})).?; defer got2.deinit(); try expectEqual(true, got2.value.traits.is_anycast); @@ -565,7 +562,7 @@ test "GeoIP2 ISP" { try expectEqual(DatabaseType.geoip_isp, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("149.101.100.0", 0); - const got = (try db.lookup(allocator, geoip2.ISP, ip, .{})).?; + const got = (try db.lookup(geoip2.ISP, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.ISP{ @@ -590,7 +587,7 @@ test "GeoIP2 Connection-Type" { try expectEqual(DatabaseType.geoip_connection_type, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("96.1.20.112", 0); - const got = (try db.lookup(allocator, geoip2.ConnectionType, ip, .{})).?; + const got = (try db.lookup(geoip2.ConnectionType, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.ConnectionType{ @@ -610,7 +607,7 @@ test "GeoIP2 Anonymous-IP" { try expectEqual(DatabaseType.geoip_anonymous_ip, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("81.2.69.0", 0); - const got = (try db.lookup(allocator, geoip2.AnonymousIP, ip, .{})).?; + const got = (try db.lookup(geoip2.AnonymousIP, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.AnonymousIP{ @@ -635,7 +632,7 @@ test "GeoIP Anonymous-Plus" { try expectEqual(DatabaseType.geoip_anonymous_plus, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("1.2.0.1", 0); - const got = (try db.lookup(allocator, geoip2.AnonymousPlus, ip, .{})).?; + const got = (try db.lookup(geoip2.AnonymousPlus, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.AnonymousPlus{ @@ -659,7 +656,7 @@ test "GeoIP2 DensityIncome" { try expectEqual(DatabaseType.geoip_densityincome, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("5.83.124.123", 0); - const got = (try db.lookup(allocator, geoip2.DensityIncome, ip, .{})).?; + const got = (try db.lookup(geoip2.DensityIncome, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.DensityIncome{ @@ -680,7 +677,7 @@ test "GeoIP2 Domain" { try expectEqual(DatabaseType.geoip_domain, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("66.92.80.123", 0); - const got = (try db.lookup(allocator, geoip2.Domain, ip, .{})).?; + const got = (try db.lookup(geoip2.Domain, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.Domain{ @@ -700,7 +697,7 @@ test "GeoIP2 IP-Risk" { try expectEqual(DatabaseType.geoip_ip_risk, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("6.1.2.1", 0); - const got = (try db.lookup(allocator, geoip2.IPRisk, ip, .{})).?; + const got = (try db.lookup(geoip2.IPRisk, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.IPRisk{ @@ -714,7 +711,7 @@ test "GeoIP2 IP-Risk" { try expectEqualDeep(want, got.value); const ip2 = try std.net.Address.parseIp("214.2.3.5", 0); - const got2 = (try db.lookup(allocator, geoip2.IPRisk, ip2, .{})).?; + const got2 = (try db.lookup(geoip2.IPRisk, allocator, ip2, .{})).?; defer got2.deinit(); const want2 = geoip2.IPRisk{ @@ -738,7 +735,7 @@ test "GeoIP2 Static-IP-Score" { try expectEqual(DatabaseType.geoip_static_ip_score, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("1.2.3.4", 0); - const got = (try db.lookup(allocator, geoip2.StaticIPScore, ip, .{})).?; + const got = (try db.lookup(geoip2.StaticIPScore, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.StaticIPScore{ @@ -758,7 +755,7 @@ test "GeoIP2 User-Count" { try expectEqual(DatabaseType.geoip_user_count, DatabaseType.new(db.metadata.database_type)); const ip = try std.net.Address.parseIp("1.2.3.4", 0); - const got = (try db.lookup(allocator, geoip2.UserCount, ip, .{})).?; + const got = (try db.lookup(geoip2.UserCount, allocator, ip, .{})).?; defer got.deinit(); const want = geoip2.UserCount{ @@ -779,8 +776,8 @@ test "lookup with field name filtering" { const ip = try std.net.Address.parseIp("89.160.20.128", 0); const got = (try db.lookup( - allocator, geolite2.City, + allocator, ip, .{ .only = &.{ "city", "country" } }, )).?; @@ -816,7 +813,7 @@ test "lookup with custom record" { }; const ip = try std.net.Address.parseIp("89.160.20.128", 0); - const got = (try db.lookup(allocator, MyCity, ip, .{})).?; + const got = (try db.lookup(MyCity, allocator, ip, .{})).?; defer got.deinit(); try expectEqual(2694762, got.value.city.geoname_id); @@ -832,7 +829,7 @@ test "lookup with any.Value" { defer db.close(); const ip = try std.net.Address.parseIp("89.160.20.128", 0); - const got = (try db.lookup(allocator, any.Value, ip, .{})).?; + const got = (try db.lookup(any.Value, allocator, ip, .{})).?; defer got.deinit(); const city = got.value.get("city").?; @@ -856,8 +853,8 @@ test "lookup with any.Value and field name filtering" { const ip = try std.net.Address.parseIp("89.160.20.128", 0); const got = (try db.lookup( - allocator, any.Value, + allocator, ip, .{ .only = &.{ "city", "country" } }, )).?; @@ -875,7 +872,7 @@ test "lookup with any.Value and field name filtering" { try expectEqual(null, got.value.get("location")); } -test "within returns all networks" { +test "scan returns all networks" { var db = try Reader.mmap( allocator, "test-data/test-data/GeoLite2-City-Test.mmdb", @@ -883,8 +880,7 @@ test "within returns all networks" { ); defer db.close(); - var it = try db.within(allocator, geolite2.City, net.Network.all_ipv6, .{}); - defer it.deinit(); + var it = try db.scan(geolite2.City, allocator, net.Network.all_ipv6, .{}); var n: usize = 0; while (try it.next()) |item| : (n += 1) { @@ -894,7 +890,7 @@ test "within returns all networks" { try expectEqual(242, n); } -test "within yields record when query prefix is narrower than record network" { +test "scan yields record when query prefix is narrower than record network" { var db = try Reader.mmap( allocator, "test-data/test-data/GeoLite2-ASN-Test.mmdb", @@ -906,8 +902,7 @@ test "within yields record when query prefix is narrower than record network" { // The iterator must still yield it even though the data record is found // before exhausting the 24-bit prefix. const network = try net.Network.parse("89.160.20.0/24"); - var it = try db.within(allocator, any.Value, network, .{}); - defer it.deinit(); + var it = try db.scan(any.Value, allocator, network, .{}); const item = (try it.next()) orelse return error.TestExpectedNotNull; defer item.deinit(); @@ -924,7 +919,7 @@ test "within yields record when query prefix is narrower than record network" { } } -test "within yields record when start node is a data pointer" { +test "scan yields record when start node is a data pointer" { var db = try Reader.mmap( allocator, "test-data/test-data/MaxMind-DB-no-ipv4-search-tree.mmdb", @@ -933,8 +928,7 @@ test "within yields record when start node is a data pointer" { defer db.close(); const network = try net.Network.parse("0.0.0.0/0"); - var it = try db.within(allocator, any.Value, network, .{}); - defer it.deinit(); + var it = try db.scan(any.Value, allocator, network, .{}); const item = (try it.next()) orelse return error.TestExpectedNotNull; defer item.deinit(); @@ -955,15 +949,15 @@ test "reject IPv6 on IPv4-only database" { defer db.close(); const network = try net.Network.parse("::/0"); - const it = db.within(allocator, any.Value, network, .{}); + const it = db.scan(any.Value, allocator, network, .{}); try std.testing.expectError(error.IPv6AddressInIPv4Database, it); const ip = try std.net.Address.parseIp("2001:db8::1", 0); - const result = db.lookup(allocator, any.Value, ip, .{}); + const result = db.lookup(any.Value, allocator, ip, .{}); try std.testing.expectError(error.IPv6AddressInIPv4Database, result); } -test "within skips empty records" { +test "scan skips empty records" { var db = try Reader.mmap( allocator, "test-data/test-data/GeoIP2-Anonymous-IP-Test.mmdb", @@ -973,10 +967,9 @@ test "within skips empty records" { // All records including empty. { - var it = try db.within(allocator, geoip2.AnonymousIP, net.Network.all_ipv6, .{ + var it = try db.scan(geoip2.AnonymousIP, allocator, net.Network.all_ipv6, .{ .include_empty_values = true, }); - defer it.deinit(); var n: usize = 0; while (try it.next()) |item| : (n += 1) { @@ -987,10 +980,9 @@ test "within skips empty records" { // Only non-empty records. { - var it = try db.within(allocator, geoip2.AnonymousIP, net.Network.all_ipv6, .{ + var it = try db.scan(geoip2.AnonymousIP, allocator, net.Network.all_ipv6, .{ .include_empty_values = false, }); - defer it.deinit(); var n: usize = 0; while (try it.next()) |item| : (n += 1) { diff --git a/src/reader.zig b/src/reader.zig index 777eb44..7e24a63 100644 --- a/src/reader.zig +++ b/src/reader.zig @@ -41,39 +41,6 @@ const max_db_size: usize = if (@sizeOf(usize) >= 8) else 2 * 1024 * 1024 * 1024; -pub const Options = struct { - /// Builds an index of the first N bits of IPv4 addresses to speed up lookups, - /// but not the within() iterator. - /// - /// It adds a one-time build cost of ~1-4ms and uses memory proportional to 2^N. - /// The first open is slower (~10-120ms) because page faults load the tree from disk. - /// Best suited for long-lived Readers with many lookups. - /// - /// Sparse databases such as Anonymous-IP or ISP benefit more (~70%-140%) - /// because tree traversal dominates whereas dense databases (City, Enterprise) - /// benefit less (~12%-18%) because record decoding is the bottleneck. - /// - /// The recommended value is 16 (~320KB, fits L2 cache), or 12 (~20KB) for constrained devices. - /// The valid range is between 0 and 24 where 0 disables the index. - ipv4_index_first_n_bits: u8 = 0, -}; - -pub fn LookupOptions(comptime T: type) type { - return struct { - only: ?[]const []const u8 = null, - include_empty_values: bool = false, - cache: ?*Cache(T) = null, - }; -} - -pub fn WithinOptions(comptime T: type) type { - return struct { - only: ?[]const []const u8 = null, - include_empty_values: bool = false, - cache: ?*Cache(T) = null, - }; -} - pub const Reader = struct { metadata: Metadata, src: []const u8, @@ -93,6 +60,33 @@ pub const Reader = struct { is_mapped: bool, arena: *std.heap.ArenaAllocator, + pub const Options = struct { + /// Builds an index of the first N bits of IPv4 addresses to speed up lookups, + /// but not the scan() iterator. + /// + /// It adds a one-time build cost of ~1-4ms and uses memory proportional to 2^N. + /// The first open is slower (~10-120ms) because page faults load the tree from disk. + /// Best suited for long-lived Readers with many lookups. + /// + /// Sparse databases such as Anonymous-IP or ISP benefit more (~70%-140%) + /// because tree traversal dominates whereas dense databases (City, Enterprise) + /// benefit less (~12%-18%) because record decoding is the bottleneck. + /// + /// The recommended value is 16 (~320KB, fits L2 cache), or 12 (~20KB) for constrained devices. + /// The valid range is between 0 and 24 where 0 disables the index. + ipv4_index_first_n_bits: u8 = 0, + }; + + pub const LookupOptions = struct { + only: ?[]const []const u8 = null, + include_empty_values: bool = false, + }; + + pub const ScanOptions = struct { + only: ?[]const []const u8 = null, + include_empty_values: bool = false, + }; + fn init(arena: *std.heap.ArenaAllocator, src: []const u8, options: Options) !Reader { const metadata = try decodeMetadata(arena.allocator(), src); @@ -182,27 +176,16 @@ pub const Reader = struct { /// Looks up a value by an IP address. /// - /// Without a cache the returned Result owns an arena, so you should call deinit() to free it. - /// Otherwise the cache owns the memory, free it with cache.deinit(). + /// The returned Result owns an arena, so you should call deinit() to free it. pub fn lookup( self: *Reader, - allocator: std.mem.Allocator, T: type, + allocator: std.mem.Allocator, address: std.net.Address, - options: LookupOptions(T), + options: LookupOptions, ) !?Result(T) { const pointer, const network = try self.findAddress(address) orelse return null; - if (options.cache) |cache| { - if (cache.get(pointer)) |v| { - return .{ - .network = network, - .value = v, - .arena = null, - }; - } - } - if (!options.include_empty_values and try self.isEmptyRecord(pointer)) { return null; } @@ -217,36 +200,94 @@ pub const Reader = struct { options.only, ); - if (options.cache) |cache| { - cache.insert(.{ - .pointer = pointer, - .value = value, - .arena = arena, - }); + return .{ + .network = network, + .value = value, + .arena = arena, + }; + } + + /// Looks up a value by an IP address, using a cache. + /// + /// The cache owns the decoded memory, free it with cache.deinit(). + pub fn lookupWithCache( + self: *Reader, + T: type, + cache: *Cache(T), + address: std.net.Address, + options: LookupOptions, + ) !?Result(T) { + const pointer, const network = try self.findAddress(address) orelse return null; + if (cache.get(pointer)) |v| { return .{ .network = network, - .value = value, + .value = v, .arena = null, }; } + if (!options.include_empty_values and try self.isEmptyRecord(pointer)) { + return null; + } + + var arena = std.heap.ArenaAllocator.init(cache.allocator); + errdefer arena.deinit(); + + const value = try self.resolveDataPointerAndDecode( + arena.allocator(), + T, + pointer, + options.only, + ); + + cache.insert(.{ + .pointer = pointer, + .value = value, + .arena = arena, + }); + return .{ .network = network, .value = value, - .arena = arena, + .arena = null, }; } - /// Iterates over blocks of IP networks. + /// Scans networks within the given IP range. + /// + /// Each returned Result owns an arena, so you should call deinit() to free it. + pub fn scan( + self: *Reader, + T: type, + allocator: std.mem.Allocator, + network: net.Network, + options: ScanOptions, + ) !Iterator(T) { + return self.initIterator(allocator, T, network, null, options); + } + + /// Scans networks within the given IP range, using a cache. /// /// Adjacent networks often share the same record, so using a cache avoids redundant decoding. - pub fn within( + /// The cache owns the decoded memory, free it with cache.deinit(). + pub fn scanWithCache( + self: *Reader, + T: type, + cache: *Cache(T), + network: net.Network, + options: ScanOptions, + ) !Iterator(T) { + return self.initIterator(cache.allocator, T, network, cache, options); + } + + fn initIterator( self: *Reader, allocator: std.mem.Allocator, T: type, network: net.Network, - options: WithinOptions(T), + cache: ?*Cache(T), + options: ScanOptions, ) !Iterator(T) { const prefix_len: usize = network.prefix_len; const ip_raw = net.IP.init(network.ip); @@ -262,9 +303,6 @@ pub const Reader = struct { var node = self.startNode(bit_count); const node_count = self.metadata.node_count; - var stack = try std.ArrayList(WithinNode).initCapacity(allocator, bit_count - prefix_len + 1); - errdefer stack.deinit(allocator); - const ip_bytes = ip_raw.mask(prefix_len); // Traverse down the tree to the level that matches the CIDR mark. // Track depth as number of tree edges traversed (becomes the network prefix length). @@ -279,26 +317,27 @@ pub const Reader = struct { } } + var it = Iterator(T){ + .reader = self, + .node_count = node_count, + .allocator = allocator, + .cache = cache, + .field_names = options.only, + .include_empty_values = options.include_empty_values, + }; + // Push the node to the stack unless it's "not found" (equal to node_count). // Data pointer (> node_count) indicates that record's network contains the query prefix. // Internal node (< node_count) indicates that we need to explore its subtree. if (node != node_count) { - stack.appendAssumeCapacity(WithinNode{ + it.push(.{ .node = node, .ip_bytes = ip_bytes.mask(depth), .prefix_len = depth, }); } - return .{ - .reader = self, - .node_count = node_count, - .stack = stack, - .allocator = allocator, - .cache = options.cache, - .field_names = options.only, - .include_empty_values = options.include_empty_values, - }; + return it; } fn findAddress(self: *Reader, address: std.net.Address) !?struct { usize, net.Network } { @@ -559,30 +598,49 @@ pub const Reader = struct { /// Ring buffer cache of recently decoded records. /// The cache owns the memory that backs decoded values, /// so each value is valid until its cache entry is evicted. +/// +/// The default size of 16 is good for most databases. +/// Country databases benefit from larger sizes, e.g., 64 or larger. pub fn Cache(comptime T: type) type { return struct { - entries: [cache_size]Entry = undefined, + entries: []Entry, // Indicates number of entries in the cache. len: usize = 0, // It's an index in the entries array where a new item will be written at. write_pos: usize = 0, + allocator: std.mem.Allocator, - // 16 showed a good tradeoff in DuckDB table scan and random IPv4 lookups, - // see https://github.com/marselester/duckdb-maxmind. - const cache_size = 16; const Self = @This(); - const Entry = struct { - pointer: usize, - value: T, - arena: std.heap.ArenaAllocator, + + pub const Options = struct { + size: usize = 16, }; + pub fn init(allocator: std.mem.Allocator, options: Self.Options) !Self { + if (options.size == 0) { + return error.InvalidCacheSize; + } + + return .{ + .entries = try allocator.alloc(Entry, options.size), + .allocator = allocator, + }; + } + pub fn deinit(self: *Self) void { for (self.entries[0..self.len]) |*e| { e.arena.deinit(); } + + self.allocator.free(self.entries); } + const Entry = struct { + pointer: usize, + value: T, + arena: std.heap.ArenaAllocator, + }; + fn get(self: *Self, pointer: usize) ?T { for (self.entries[0..self.len]) |*e| { if (e.pointer == pointer) { @@ -594,7 +652,7 @@ pub fn Cache(comptime T: type) type { } fn insert(self: *Self, e: Entry) void { - if (self.len < cache_size) { + if (self.len < self.entries.len) { self.entries[self.len] = e; self.len += 1; @@ -604,7 +662,7 @@ pub fn Cache(comptime T: type) type { // Evict the oldest entry and insert the new one. self.entries[self.write_pos].arena.deinit(); self.entries[self.write_pos] = e; - self.write_pos = (self.write_pos + 1) % cache_size; + self.write_pos = (self.write_pos + 1) % self.entries.len; } }; } @@ -625,7 +683,7 @@ pub fn Result(comptime T: type) type { }; } -const WithinNode = struct { +const ScanNode = struct { ip_bytes: net.IP, prefix_len: usize, node: usize, @@ -635,12 +693,16 @@ pub fn Iterator(T: type) type { return struct { reader: *Reader, node_count: usize, - stack: std.ArrayList(WithinNode), + // Fixed-capacity stack for DFS traversal. + stack: [max_stack_size]ScanNode = undefined, + stack_len: usize = 0, allocator: std.mem.Allocator, field_names: ?[]const []const u8, include_empty_values: bool, cache: ?*Cache(T), + // Max depth is bit_count - prefix_len + 1 (129 for IPv6 /0). + const max_stack_size = 129; const Self = @This(); /// Returns the next network and its value. @@ -648,7 +710,7 @@ pub fn Iterator(T: type) type { /// Without a cache the returned Result owns an arena, so you should call deinit() to free it. /// Otherwise the cache owns the memory, free it with cache.deinit(). pub fn next(self: *Self) !?Result(T) { - while (self.stack.pop()) |current| { + while (self.pop()) |current| { const reader = self.reader; const bit_count = current.ip_bytes.bitCount(); @@ -722,7 +784,7 @@ pub fn Iterator(T: type) type { } } - self.stack.appendAssumeCapacity(WithinNode{ + self.push(.{ .node = node, .ip_bytes = right_ip_bytes, .prefix_len = current.prefix_len + 1, @@ -730,7 +792,7 @@ pub fn Iterator(T: type) type { // In order traversal of the children on the left (0-bit). node = reader.readNode(current.node, 0); - self.stack.appendAssumeCapacity(WithinNode{ + self.push(.{ .node = node, .ip_bytes = current.ip_bytes, .prefix_len = current.prefix_len + 1, @@ -741,8 +803,18 @@ pub fn Iterator(T: type) type { return null; } - pub fn deinit(self: *Self) void { - self.stack.deinit(self.allocator); + fn push(self: *Self, node: ScanNode) void { + self.stack[self.stack_len] = node; + self.stack_len += 1; + } + + fn pop(self: *Self) ?ScanNode { + if (self.stack_len == 0) { + return null; + } + + self.stack_len -= 1; + return self.stack[self.stack_len]; } }; }