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 1aa59f6..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,27 +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 `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 = try maxminddb.Cache(maxminddb.geolite2.City).init(allocator, .{}); +defer cache.deinit(); + +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`): -| 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,420,000 | ~1,348,000 | ~1,565,000 | +| `MyCity` | ~1,383,000 | | | +| `any.Value` | ~1,254,000 | ~1,349,000 | |
@@ -140,6 +179,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 +266,83 @@ Lookups Per Second (avg):1315986.2950186788 ```
+ +Use `scan` to iterate over all networks in the database. + +```zig +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.scanWithCache(maxminddb.any.Value, &cache, maxminddb.Network.all_ipv6, .{}); + +while (try it.next()) |item| { + std.debug.print("{f} {f}\n", .{ item.network, item.value }); +} +``` + +Here are reference results on Apple M2 Pro (full GeoLite2-City scan using `any.Value`): + +| Mode | Records/sec | +|--- |--- | +| Default | ~1,295,000 | +| `Cache` | ~2,930,000 | + +
+ +no cache (any.Value) + +```sh +$ for i in $(seq 1 10); do + zig build benchmark_scan -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_scan_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/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 new file mode 100644 index 0000000..e2d83ed --- /dev/null +++ b/benchmarks/lookup_cache.zig @@ -0,0 +1,91 @@ +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 = try maxminddb.Cache(maxminddb.geolite2.City).init(allocator, .{}); + defer cache.deinit(); + + 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.lookupWithCache( + maxminddb.geolite2.City, + &cache, + ip, + .{ .only = fields }, + ) 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; + } + } + + 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/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/scan.zig b/benchmarks/scan.zig new file mode 100644 index 0000000..13a722b --- /dev/null +++ b/benchmarks/scan.zig @@ -0,0 +1,58 @@ +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.scan(maxminddb.any.Value, allocator, network, .{}); + + 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/scan_cache.zig b/benchmarks/scan_cache.zig new file mode 100644 index 0000000..0290d40 --- /dev/null +++ b/benchmarks/scan_cache.zig @@ -0,0 +1,65 @@ +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 = 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.scanWithCache( + maxminddb.any.Value, + &cache, + network, + .{}, + ); + + 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..c386532 100644 --- a/build.zig +++ b/build.zig @@ -27,11 +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/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 88% rename from examples/within.zig rename to examples/scan.zig index 42c3815..5098761 100644 --- a/examples/within.zig +++ b/examples/scan.zig @@ -16,12 +16,12 @@ 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, .{}); - // 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 8db5363..0f9c51e 100644 --- a/src/maxminddb.zig +++ b/src/maxminddb.zig @@ -14,10 +14,8 @@ pub const Reader = reader.Reader; pub const Result = reader.Result; pub const Metadata = reader.Metadata; pub const Iterator = reader.Iterator; +pub const Cache = reader.Cache; pub const Network = net.Network; -pub const Options = reader.Options; -pub const LookupOptions = reader.LookupOptions; -pub const WithinOptions = reader.WithinOptions; pub const Map = collection.Map; pub const Array = collection.Array; @@ -146,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); @@ -179,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); @@ -200,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); @@ -272,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{ @@ -297,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); @@ -336,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); @@ -351,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); @@ -379,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); @@ -447,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); @@ -464,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); @@ -547,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); @@ -564,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{ @@ -589,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{ @@ -609,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{ @@ -634,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{ @@ -658,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{ @@ -679,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{ @@ -699,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{ @@ -713,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{ @@ -737,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{ @@ -757,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{ @@ -778,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" } }, )).?; @@ -815,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); @@ -831,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").?; @@ -855,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" } }, )).?; @@ -874,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", @@ -882,16 +880,17 @@ 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()) |_| : (n += 1) {} + while (try it.next()) |item| : (n += 1) { + item.deinit(); + } 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", @@ -903,10 +902,10 @@ 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(); try expectEqual(17, item.network.prefix_len); var out: [256]u8 = undefined; @@ -914,12 +913,13 @@ 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; } } -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", @@ -928,13 +928,14 @@ 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(); try expectEqual(0, item.network.prefix_len); - if (try it.next()) |_| { + if (try it.next()) |i| { + i.deinit(); return error.TestExpectedNull; } } @@ -948,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", @@ -966,25 +967,27 @@ 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()) |_| : (n += 1) {} + while (try it.next()) |item| : (n += 1) { + item.deinit(); + } try std.testing.expectEqual(571, n); } // 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()) |_| : (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 856ebf4..7e24a63 100644 --- a/src/reader.zig +++ b/src/reader.zig @@ -41,33 +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 const LookupOptions = struct { - only: ?[]const []const u8 = null, - include_empty_values: bool = false, -}; - -pub const WithinOptions = struct { - only: ?[]const []const u8 = null, - include_empty_values: bool = false, -}; - pub const Reader = struct { metadata: Metadata, src: []const u8, @@ -87,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); @@ -175,36 +175,63 @@ pub const Reader = struct { } /// Looks up a value by an IP address. - /// The returned Result owns an arena with all decoded allocations. + /// + /// 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, ) !?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. + /// + /// 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 = v, + .arena = null, + }; } + if (!options.include_empty_values and try self.isEmptyRecord(pointer)) { return null; } - var arena = std.heap.ArenaAllocator.init(allocator); + var arena = std.heap.ArenaAllocator.init(cache.allocator); errdefer arena.deinit(); const value = try self.resolveDataPointerAndDecode( @@ -214,20 +241,53 @@ 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, + .arena = null, }; } - /// Iterates over blocks of IP networks. - pub fn within( + /// 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. + /// 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: WithinOptions, + 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, + cache: ?*Cache(T), + options: ScanOptions, ) !Iterator(T) { const prefix_len: usize = network.prefix_len; const ip_raw = net.IP.init(network.ip); @@ -243,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). @@ -260,26 +317,49 @@ 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 = .{}, - .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 } { + 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, @@ -515,21 +595,95 @@ 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: []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, + + const Self = @This(); + + 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) { + return e.value; + } + } + + return null; + } + + fn insert(self: *Self, e: Entry) void { + if (self.len < self.entries.len) { + 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) % self.entries.len; + } + }; +} + /// 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(); + } } }; } -const WithinNode = struct { +const ScanNode = struct { ip_bytes: net.IP, prefix_len: usize, node: usize, @@ -539,89 +693,24 @@ 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, - - // 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), + // Max depth is bit_count - prefix_len + 1 (129 for IPv6 /0). + const max_stack_size = 129; 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 { - while (self.stack.pop()) |current| { + /// + /// 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.pop()) |current| { const reader = self.reader; const bit_count = current.ip_bytes.bitCount(); @@ -638,17 +727,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.lookup(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; } @@ -663,11 +752,24 @@ pub fn Iterator(T: type) type { self.field_names, ); - self.cache.insert(current.node, value, entry_arena); + if (self.cache) |cache| { + cache.insert(.{ + .pointer = current.node, + .value = value, + .arena = entry_arena, + }); + + return .{ + .network = ip_net, + .value = value, + .arena = null, + }; + } - return Item{ + 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). @@ -682,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, @@ -690,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, @@ -701,9 +803,18 @@ pub fn Iterator(T: type) type { return null; } - pub fn deinit(self: *Self) void { - self.cache.deinit(); - 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]; } }; }