refactor: internalize vendored cache.zig

This commit is contained in:
Nurul Huda (Apon) 2026-06-14 21:05:13 +06:00
parent fd2bdd0613
commit 42f714673c
No known key found for this signature in database
GPG Key ID: 5D3F1DE2855A2F79
15 changed files with 5 additions and 602 deletions

View File

@ -35,7 +35,6 @@ pub fn build(b: *std.Build) !void {
const tree_sitter_dep = b.dependency("tree_sitter", .{ .target = target, .optimize = optimize });
const tree_sitter_zx_dep = b.dependency("tree_sitter_zx", .{ .target = target, .optimize = optimize, .@"build-shared" = false });
const tree_sitter_mdzx_dep = b.dependency("tree_sitter_mdzx", .{ .target = target, .optimize = optimize, .@"build-shared" = false });
const cachez_dep = b.dependency("cachez", .{ .target = target, .optimize = optimize });
const adapters_dep = b.dependency("adapters", .{ .target = target, .optimize = optimize });
// --- Features Module --- //
@ -65,7 +64,6 @@ pub fn build(b: *std.Build) !void {
}
if (!exclude_core_lang) mod.addImport("zx_core_lang", zx_core_lang_mod);
mod.addImport("cachez", cachez_dep.module("cache"));
mod.addOptions("zx_info", options);
mod.addOptions("zx_module_options", zx_module_options);
}

View File

@ -2,16 +2,12 @@ pub const Cache = @This();
const std = @import("std");
const builtin = @import("builtin");
const cachez = if (builtin.cpu.arch.isWasm())
// This is for satisfying Playground in which we don't have 'cachez' dep available.
cachez_failing
else
@import("cachez");
const zx = @import("../../root.zig");
const Kv = @import("Kv.zig");
const Allocator = std.mem.Allocator;
const Kv = zx.Kv;
pub const cachez = @import("Cache/Mem/cache.zig");
const entry_version: u8 = 1;
const header_len = 9;
@ -355,120 +351,3 @@ pub const failing: Cache = .{
.io = .failing,
.backend = .failing,
};
const cachez_failing = struct {
pub const PutConfig = struct {
ttl: u32 = 300,
size: u32 = 1,
};
pub fn Cache(comptime T: type) type {
return struct {
allocator: Allocator,
const Self = @This();
pub fn init(io: std.Io, allocator: Allocator, _: Config) !Self {
_ = io;
return .{
.allocator = allocator,
};
}
pub fn deinit(_: *Self) void {}
pub fn contains(self: *const Self, key: []const u8) bool {
_ = key;
_ = self;
return false;
}
pub fn get(self: *Self, key: []const u8) ?*Entry(T) {
_ = key;
_ = self;
return null;
}
pub fn getEntry(self: *const Self, key: []const u8) ?*Entry(T) {
_ = key;
_ = self;
return null;
}
pub fn put(self: *Self, key: []const u8, value: T, config: PutConfig) !void {
_ = key;
_ = value;
_ = config;
_ = self;
}
pub fn del(self: *Self, key: []const u8) bool {
_ = key;
_ = self;
return false;
}
pub fn delPrefix(self: *Self, prefix: []const u8) !usize {
_ = prefix;
_ = self;
return 0;
}
pub fn fetch(self: *Self, comptime S: type, key: []const u8, loader: *const fn (loader_state: S, key: []const u8) anyerror!?T, loader_state: S, config: PutConfig) !?*Entry(T) {
_ = key;
_ = loader;
_ = loader_state;
_ = config;
_ = self;
return null;
}
pub fn maxSize(self: Self) usize {
_ = self;
return 0;
}
};
}
pub fn Entry(comptime T: type) type {
return struct {
key: []const u8,
value: T,
expires: u32,
const Self = @This();
pub fn init(allocator: Allocator, key: []const u8, value: T, size: u32, expires: u32) Self {
_ = allocator;
_ = size;
return .{
.key = key,
.value = value,
.expires = expires,
};
}
pub fn expired(self: *Self) bool {
return self.ttl() <= 0;
}
pub fn ttl(self: *Self) i64 {
_ = self;
return 0;
}
pub fn hit(self: *Self) u8 {
_ = self;
return 0;
}
pub fn borrow(self: *Self) void {
_ = self;
}
pub fn release(self: *Self) void {
_ = self;
}
};
}
};

View File

@ -886,7 +886,7 @@ fn envVar(name_z: [*:0]const u8) ?[]const u8 {
const std = @import("std");
const builtin = @import("builtin");
const httpz = @import("httpz");
const cachez = @import("cachez");
const cachez = zx.Cache.cachez;
const zx = @import("../../root.zig");
const zx_app = @import("app");
const module_config = @import("zx_info");

View File

@ -994,7 +994,7 @@ pub fn Handler(comptime AppCtxType: type) type {
const std = @import("std");
const builtin = @import("builtin");
const cachez = @import("cachez");
const cachez = zx.Cache.cachez;
const zx_options = @import("zx_options");
const zx = @import("../../root.zig");

View File

@ -1,10 +1,4 @@
{
"cachez": {
"repo": "git@github.com:nurulhudaapon/cachez.git",
"https": "https://github.com/nurulhudaapon/cachez",
"branch": "master",
"commit": "51b9d77bec8d9f3717349e9a007ff689959f3479"
},
"cliz": {
"repo": "git@github.com:nurulhudaapon/cliz.git",
"https": "https://github.com/nurulhudaapon/cliz",

19
vendor/cachez/LICENSE vendored
View File

@ -1,19 +0,0 @@
Copyright (c) 2024 Karl Seguin.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -1,22 +0,0 @@
const std = @import("std");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const cache_module = b.addModule("cache", .{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/cache.zig"),
});
const lib_test = b.addTest(.{
.root_module = cache_module,
.test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
});
const run_test = b.addRunArtifact(lib_test);
run_test.has_side_effects = true;
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_test.step);
}

View File

@ -1,14 +0,0 @@
.{
.name = .cache,
.version = "0.0.0",
.fingerprint = 0x41476be7c0d129c2,
.minimum_zig_version = "0.16.0",
.dependencies = .{},
.paths = .{
"LICENSE",
"build.zig",
"build.zig.zon",
"readme.md",
"src",
},
}

View File

@ -1,126 +0,0 @@
A thread-safe, expiration-aware, LRU(ish) cache for Zig
## Zig Version
This is for Zig 0.16.0. Use the [zig-0.15](https://github.com/karlseguin/cached.zig/tree/zig-0.15) branch for Zig 0.15 or the [dev](https://github.com/karlseguin/cached.zig/tree/dev) which may or may not be up to date with zig dev.
## Example
```zig
// package available using Zig's built-in package manager
const user_cache = @import("cache");
var user_cache = try cache.Cache(User).init(io, allocator, .{.max_size = 10000});
defer user_cache.deinit();
try user_cache.put("user1", user1, .{.ttl = 300});
...
if (user_cache.get("user1")) |entry| {
defer entry.release();
const user1 = entry.value;
} else {
// not in the cache
}
// del will return true if the item existed
_ = user_cache.del("user1");
```
`get` will return `null` if the key is not found, or if the entry associated with the key has expired. If the entry has expired, it will be removed.
`getEntry` can be used to return the entry even if it has expired. While `getEntry` will return en expired item, it will not promote a expired item in the recency list. The main purpose is to allow the caller to serve a stale value while fetching a new one.
In either case, the entry's `ttl(std.Io) i64` method can be used to return the number of seconds until the entry expires. This will be negative if the entry has already expired. The `expired(std.Io) bool` method will return `true` if the entry is expired. (Both of these methods take an `std.Io` parameter as to avoid having to store a reference in every `Entry` instance)
`release` must be called on the returned entry.
## Implementation
This is a typical LRU cache which combines a hashmap to lookup values and doubly linked list to track recency.
To improve throughput, the cache is divided into a configured number of segments (defaults to 8). Locking only happens at the segment level. Furthermore, items are only promoted to the head of the recency linked list after a configured number of gets. This not only reduces the locking on the linked list, but also introduces a frequency bias to the eviction policy (which I think is welcome addition).
The downside of this approach is that size enforcement and the eviction policy is done on a per-segment basis. Given a `max_size` of 8000 and a `segment_count` of 8, each segment will enforce its own `max_size` of 1000 and maintain its own recency list. Should keys be poorly distributed across segments, the cache will only reach a fraction of its configured max size. Only least-recently used items within a segment are considered for eviction.
## Configuration
The 2nd argument to init is a `cache.Config`. It's fields with default values are:
```zig
{
// The max size of the cache. By default, each value has a size of 1. Thus
// given a `max_size` of 5000, the cache can hold up to 5000 items before
// having to evict entries. The size of a value can be set when using
// `cache.put` or `cache.fetch`.
max_size: u32 = 8000,
// The number of segments to use. Must be a power of 2.
// A value of 1 is valid.
segment_count: u16 = 8,
// The number of times get or getEntry must be called on a key before
// it's promoted to the head of the recency list
gets_per_promote: u8 = 5,
// When a segment is full, the ratio of the segment's max_size to free.
shrink_ratio: f32 = 0.2,
}
```
Given the above, each segment will enforce its own `max_size` of 1000 (i.e. `8000 / 8`). When a segment grows beyond 1000, entries will be removed until its size becomes less than or equal to 800 (i.e. `1000 - (1000 * 0.2)`)
## Put
The `cache.put(key: []const u8, value: T, config: cache.PutConfig) !void` has a number of consideration.
First, the key will be cloned and managed by the cache. The caller does not have to guarantee its validity after `put` returns.
The third parameter is a `cache.PutConfig`:
```zig
{
.ttl: u32 = 300,
.size: u32 = 1,
}
```
`ttl` is the length, in seconds, to keep the value in the cache.
`size` is the size of the value. This doesn't have to be the actual memory used by the value being cached. In many cases, the default of `1` is reasonable. However, if enforcement of the memory used by the cache is important, giving an approximate size (as memory usage or as a weighted value) will help. For example, if you're caching a string, the length of the string could make a reasonable argument for `size`.
If `T` defines a **public** method `size() u32`, this value will be used instead of the above configured `size`. This can be particularly useful with the `fetch` method.
## Fetch
`cache.fetch` can be used to combine `get` and `put` by providing a custom function to load a missing value:
```zig
const user = try cache.fetch(FetchState, "user1", loadUser, .{user_id: 1}, .{.ttl = 300});
...
const FetchState = struct {
user_id: u32,
};
fn loadUser(state: FetchState, key: []const u8) !?User {
const user_id = state.user_id
const user = ... // load a user from the DB?
return user;
}
```
Because Zig doesn't have closures, and because your custom function will likely need data to load the missing value, you provide a custom `state` type and value which will be passed to your function. They cache key is also passed, which, in simple cases, might be all your function needs to load data (in such cases, a `void` state can be used).
The last parameter to `fetch` is the same as the last parameter to `put`.
Fetch does not do duplicate function call suppression. Concurrent calls to `fetch` using the same key can result in multiple functions to your callback functions. In other words, fetch is vulnerable to the thundering herd problem. Considering using [singleflight.zig](https://github.com/karlseguin/singleflight.zig) within your fetch callback.
The `size` of the value might not be known until the value is fetched, this makes passing `size` into fetch impossible. If `T` defines a **public** method `size() u32`, then `T.size(value)` will be called to get the size.
## Entry Thread Safety
It's possible for one thread to `get` an entry, while another thread deletes it. This deletion could be explicit (a call to `cache.del` or replacing a value with `cache.put`) or implicit (a call to `cache.put` causing the cache to free memory). To ensure that deleted entries can safely be used by the application, atomic reference counting is used. While a deleted entry is immediately removed from the cache, it remains valid until all references are removed.
This is why `release` must be called on the entry returned by `get` and `getEntry`. Calling `release` multiple times on a single entry will break the cache.
## removedFromCache notification
If `T` defines a **public** method `removedFromCache`, `T.removedFromCache(Allocator)` will be called when all references are removed but before the entry is destroyed. `removedFromCache` will be called regardless of why the entry was removed.
The `Allocator` passed to `removedFromCache` is the `Allocator` that the cache was created with - this may or may not be an allocator that is meaningful to the value.
## delPrefix
`cache.delPrefix` can be used to delete any entry that starts with the specified prefix. This requires an O(N) scan through the cache. However, some optimizations are done to limit the amount of write-lock this places on the cache.

View File

@ -1,287 +0,0 @@
const std = @import("std");
const Io = std.Io;
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const BORDER = "=" ** 80;
// use in custom panic handler
var current_test: ?[]const u8 = null;
pub fn main(init: std.process.Init) !void {
var mem: [8192]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&mem);
const allocator = fba.allocator();
const env = Env.init(init.environ_map);
std.testing.io_instance = .init(init.gpa, .{
.argv0 = .init(init.minimal.args),
.environ = init.minimal.environ,
});
defer std.testing.io_instance.deinit();
const io = std.testing.io;
var slowest = SlowTracker.init(allocator, io, 5);
defer slowest.deinit();
var pass: usize = 0;
var fail: usize = 0;
var skip: usize = 0;
var leak: usize = 0;
Printer.fmt("\r\x1b[0K", .{}); // beginning of line and clear to end of line
for (builtin.test_functions) |t| {
if (isSetup(t)) {
t.func() catch |err| {
Printer.status(.fail, "\nsetup \"{s}\" failed: {}\n", .{ t.name, err });
return err;
};
}
}
for (builtin.test_functions) |t| {
if (isSetup(t) or isTeardown(t)) {
continue;
}
var status = Status.pass;
slowest.startTiming(io);
const is_unnamed_test = isUnnamed(t);
if (env.filter) |f| {
if (!is_unnamed_test and std.mem.indexOf(u8, t.name, f) == null) {
continue;
}
}
const friendly_name = blk: {
const name = t.name;
var it = std.mem.splitScalar(u8, name, '.');
while (it.next()) |value| {
if (std.mem.eql(u8, value, "test")) {
const rest = it.rest();
break :blk if (rest.len > 0) rest else name;
}
}
break :blk name;
};
current_test = friendly_name;
std.testing.allocator_instance = .{};
const result = t.func();
current_test = null;
const ns_taken = slowest.endTiming(io, friendly_name);
if (std.testing.allocator_instance.deinit() == .leak) {
leak += 1;
Printer.status(.fail, "\n{s}\n\"{s}\" - Memory Leak\n{s}\n", .{ BORDER, friendly_name, BORDER });
}
if (result) |_| {
pass += 1;
} else |err| switch (err) {
error.SkipZigTest => {
skip += 1;
status = .skip;
},
else => {
status = .fail;
fail += 1;
Printer.status(.fail, "\n{s}\n\"{s}\" - {s}\n{s}\n", .{ BORDER, friendly_name, @errorName(err), BORDER });
if (@errorReturnTrace()) |trace| {
std.debug.dumpErrorReturnTrace(trace);
}
if (env.fail_first) {
break;
}
},
}
if (env.verbose) {
const ms = @as(f64, @floatFromInt(ns_taken)) / 1_000_000.0;
Printer.status(status, "{s} ({d:.2}ms)\n", .{ friendly_name, ms });
} else {
Printer.status(status, ".", .{});
}
}
for (builtin.test_functions) |t| {
if (isTeardown(t)) {
t.func() catch |err| {
Printer.status(.fail, "\nteardown \"{s}\" failed: {}\n", .{ t.name, err });
return err;
};
}
}
const total_tests = pass + fail;
const status = if (fail == 0) Status.pass else Status.fail;
Printer.status(status, "\n{d} of {d} test{s} passed\n", .{ pass, total_tests, if (total_tests != 1) "s" else "" });
if (skip > 0) {
Printer.status(.skip, "{d} test{s} skipped\n", .{ skip, if (skip != 1) "s" else "" });
}
if (leak > 0) {
Printer.status(.fail, "{d} test{s} leaked\n", .{ leak, if (leak != 1) "s" else "" });
}
Printer.fmt("\n", .{});
try slowest.display();
Printer.fmt("\n", .{});
std.process.exit(if (fail == 0) 0 else 1);
}
const Printer = struct {
fn fmt(comptime format: []const u8, args: anytype) void {
std.debug.print(format, args);
}
fn status(s: Status, comptime format: []const u8, args: anytype) void {
switch (s) {
.pass => std.debug.print("\x1b[32m", .{}),
.fail => std.debug.print("\x1b[31m", .{}),
.skip => std.debug.print("\x1b[33m", .{}),
else => {},
}
std.debug.print(format ++ "\x1b[0m", args);
}
};
const Status = enum {
pass,
fail,
skip,
text,
};
const SlowTracker = struct {
max: usize,
slowest: SlowestQueue,
start: Io.Timestamp,
allocator: Allocator,
const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming);
fn init(allocator: Allocator, io: Io, count: u32) SlowTracker {
const timestamp = Io.Clock.awake.now(io);
var slowest: SlowestQueue = .empty;
slowest.ensureTotalCapacity(allocator, count) catch @panic("OOM");
return .{
.max = count,
.start = timestamp,
.slowest = slowest,
.allocator = allocator,
};
}
const TestInfo = struct {
ns: u64,
name: []const u8,
};
fn deinit(self: *SlowTracker) void {
self.slowest.deinit(self.allocator);
}
fn startTiming(self: *SlowTracker, io: Io) void {
self.start = Io.Clock.awake.now(io);
}
fn endTiming(self: *SlowTracker, io: Io, test_name: []const u8) u64 {
const timestamp = Io.Clock.awake.now(io);
const start = self.start;
self.start = timestamp;
const ns: u64 = @intCast(start.durationTo(timestamp).toNanoseconds());
var slowest = &self.slowest;
if (slowest.count() < self.max) {
// Capacity is fixed to the # of slow tests we want to track
// If we've tracked fewer tests than this capacity, than always add
slowest.push(self.allocator, TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing");
return ns;
}
{
// Optimization to avoid shifting the dequeue for the common case
// where the test isn't one of our slowest.
const fastest_of_the_slow = slowest.peekMin() orelse unreachable;
if (fastest_of_the_slow.ns > ns) {
// the test was faster than our fastest slow test, don't add
return ns;
}
}
// the previous fastest of our slow tests, has been pushed off.
_ = slowest.popMin();
slowest.push(self.allocator, TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing");
return ns;
}
fn display(self: *SlowTracker) !void {
var slowest = self.slowest;
const count = slowest.count();
Printer.fmt("Slowest {d} test{s}: \n", .{ count, if (count != 1) "s" else "" });
while (slowest.popMin()) |info| {
const ms = @as(f64, @floatFromInt(info.ns)) / 1_000_000.0;
Printer.fmt(" {d:.2}ms\t{s}\n", .{ ms, info.name });
}
}
fn compareTiming(context: void, a: TestInfo, b: TestInfo) std.math.Order {
_ = context;
return std.math.order(a.ns, b.ns);
}
};
const Env = struct {
verbose: bool,
fail_first: bool,
filter: ?[]const u8,
fn init(map: *const std.process.Environ.Map) Env {
return .{
.verbose = readEnvBool(map, "TEST_VERBOSE", true),
.fail_first = readEnvBool(map, "TEST_FAIL_FIRST", false),
.filter = readEnv(map, "TEST_FILTER"),
};
}
fn readEnv(map: *const std.process.Environ.Map, key: []const u8) ?[]const u8 {
return map.get(key);
}
fn readEnvBool(map: *const std.process.Environ.Map, key: []const u8, deflt: bool) bool {
const value = readEnv(map, key) orelse return deflt;
return std.ascii.eqlIgnoreCase(value, "true");
}
};
pub const panic = std.debug.FullPanic(struct {
pub fn panicFn(msg: []const u8, first_trace_addr: ?usize) noreturn {
if (current_test) |ct| {
std.debug.print("\x1b[31m{s}\npanic running \"{s}\"\n{s}\x1b[0m\n", .{ BORDER, ct, BORDER });
}
std.debug.defaultPanic(msg, first_trace_addr);
}
}.panicFn);
fn isUnnamed(t: std.builtin.TestFn) bool {
const marker = ".test_";
const test_name = t.name;
const index = std.mem.indexOf(u8, test_name, marker) orelse return false;
_ = std.fmt.parseInt(u32, test_name[index + marker.len ..], 10) catch return false;
return true;
}
fn isSetup(t: std.builtin.TestFn) bool {
return std.mem.endsWith(u8, t.name, "tests:beforeAll");
}
fn isTeardown(t: std.builtin.TestFn) bool {
return std.mem.endsWith(u8, t.name, "tests:afterAll");
}