feat: component level caching

This commit is contained in:
Nurul Huda (Apon) 2025-12-31 17:01:54 +06:00
parent 8ba9493706
commit 8aba678826
No known key found for this signature in database
GPG key ID: 5D3F1DE2855A2F79
7 changed files with 117 additions and 100 deletions

View file

@ -1,10 +1,18 @@
var visit_count: usize = 0;
pub fn Page(ctx: zx.PageContext) zx.Component {
const allocator = ctx.arena;
if (visit_count == 3) {
visit_count = 0;
_ = zx.cache.delPrefix("cmp:time");
} else {
visit_count += 1;
}
return (
<div @allocator={allocator}>
<h1>Time:</h1>
<Time @caching="10s:time" />
<h1>Time: {visit_count}</h1>
<Time @caching="3s:time" />
</div>
);
}
@ -21,7 +29,7 @@ fn Time(allocator: zx.Allocator) zx.Component {
pub const options = zx.PageOptions{
.rendering = .server,
// .caching = .tag("10s:time"),
.caching = .{ .seconds = 1 },
};
const zx = @import("zx");

View file

@ -194,6 +194,18 @@ const PageCache = struct {
}
return null;
}
/// Delete a specific page from the cache by exact path
/// Example: del("/users/123")
pub fn del(self: *PageCache, path: []const u8) bool {
return self.cache.del(path);
}
/// Delete all pages matching a path prefix
/// Example: delPath("/users") deletes /users, /users/1, /users/2, etc.
pub fn delPath(self: *PageCache, path_prefix: []const u8) usize {
return self.cache.delPrefix(path_prefix) catch 0;
}
};
pub const Handler = struct {
@ -202,10 +214,12 @@ pub const Handler = struct {
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, meta: *App.Meta, cache_config: CacheConfig) !Handler {
// Initialize component-level cache
_ = zx.ComponentCache.init(allocator);
// Initialize unified component cache
try zx.cache.init(allocator, .{
.max_size = cache_config.max_size,
});
return .{
return Handler{
.meta = meta,
.allocator = allocator,
.page_cache = try PageCache.init(allocator, cache_config),

View file

@ -4,83 +4,68 @@
const std = @import("std");
pub const Ast = @import("zx/Ast.zig");
pub const Parse = @import("zx/Parse.zig");
const cachez = @import("cachez");
/// Client component rendering type - available on all targets
/// Component-level cache for rendered HTML fragments
pub const ComponentCache = struct {
const Entry = struct {
/// Global cache for components and pages
pub const cache = struct {
const CacheEntry = struct {
html: []const u8,
expires_at: i64,
pub fn removedFromCache(self: CacheEntry, allocator: std.mem.Allocator) void {
allocator.free(self.html);
}
};
entries: std.StringHashMap(Entry),
mutex: std.Thread.Mutex,
allocator: std.mem.Allocator,
var inner: ?cachez.Cache(CacheEntry) = null;
var alloc: ?std.mem.Allocator = null;
var instance: ?*ComponentCache = null;
pub fn init(allocator: std.mem.Allocator) *ComponentCache {
if (instance) |i| return i;
const cache = allocator.create(ComponentCache) catch @panic("OOM");
cache.* = .{
.entries = std.StringHashMap(Entry).init(allocator),
.mutex = .{},
.allocator = allocator,
};
instance = cache;
return cache;
/// Initialize the cache (called once at app startup)
pub fn init(allocator: std.mem.Allocator, config: cachez.Config) !void {
if (inner != null) return;
alloc = allocator;
inner = try cachez.Cache(CacheEntry).init(allocator, config);
}
pub fn global() ?*ComponentCache {
return instance;
/// Deinitialize the cache
pub fn deinit() void {
if (inner) |*c| {
c.deinit();
inner = null;
}
}
pub fn get(self: *ComponentCache, key: []const u8) ?[]const u8 {
self.mutex.lock();
defer self.mutex.unlock();
if (self.entries.get(key)) |entry| {
const now = std.time.timestamp();
if (now < entry.expires_at) {
return entry.html;
}
// Expired - remove it
if (self.entries.fetchRemove(key)) |kv| {
self.allocator.free(kv.key);
self.allocator.free(kv.value.html);
/// Get cached HTML by key
pub fn get(key: []const u8) ?[]const u8 {
if (inner) |*c| {
if (c.get(key)) |entry| {
defer entry.release();
return entry.value.html;
}
}
return null;
}
pub fn put(self: *ComponentCache, key: []const u8, html: []const u8, ttl_seconds: u32) void {
self.mutex.lock();
defer self.mutex.unlock();
const now = std.time.timestamp();
const expires_at = now + @as(i64, @intCast(ttl_seconds));
// Copy key and html
const key_copy = self.allocator.dupe(u8, key) catch return;
const html_copy = self.allocator.dupe(u8, html) catch {
self.allocator.free(key_copy);
return;
};
// Remove old entry if exists
if (self.entries.fetchRemove(key)) |old| {
self.allocator.free(old.key);
self.allocator.free(old.value.html);
/// Store HTML in cache with TTL
pub fn put(key: []const u8, html: []const u8, ttl_seconds: u32) void {
if (inner) |*c| {
const allocator = alloc orelse return;
const html_copy = allocator.dupe(u8, html) catch return;
c.put(key, .{ .html = html_copy }, .{ .ttl = ttl_seconds }) catch {
allocator.free(html_copy);
};
}
}
self.entries.put(key_copy, .{
.html = html_copy,
.expires_at = expires_at,
}) catch {
self.allocator.free(key_copy);
self.allocator.free(html_copy);
};
/// Delete cache entry by exact key
pub fn del(key: []const u8) bool {
if (inner) |*c| return c.del(key);
return false;
}
/// Delete all cache entries matching a prefix
pub fn delPrefix(prefix: []const u8) usize {
if (inner) |*c| return c.delPrefix(prefix) catch 0;
return 0;
}
};
@ -472,7 +457,6 @@ pub const Component = union(enum) {
async_mode: BuiltinAttribute.Async = .sync,
fallback: ?*const Component = null,
caching: ?BuiltinAttribute.Caching = null,
cache_key: ?[]const u8 = null,
pub fn init(comptime func: anytype, allocator: Allocator, props: anytype) ComponentFn {
const FuncInfo = @typeInfo(@TypeOf(func));
@ -693,41 +677,41 @@ pub const Component = union(enum) {
// Check for component-level caching
if (func.caching) |caching| {
if (caching.seconds > 0) {
// Generate cache key from function pointer + props pointer
var key_buf: [64]u8 = undefined;
const cache_key = if (func.cache_key) |ck|
ck
else blk: {
const key_len = std.fmt.bufPrint(&key_buf, "cmp:{x}:{x}", .{
// Generate cache key from function pointer + props pointer + optional custom key
var key_buf: [128]u8 = undefined;
const generated_key = if (caching.key) |custom_key|
std.fmt.bufPrint(&key_buf, "cmp:{s}:{x}:{x}", .{
custom_key,
@intFromPtr(func.callFn),
@intFromPtr(func.propsPtr),
}) catch break :blk null;
break :blk key_buf[0..key_len.len];
};
}) catch null
else
std.fmt.bufPrint(&key_buf, "cmp:{x}:{x}", .{
@intFromPtr(func.callFn),
@intFromPtr(func.propsPtr),
}) catch null;
if (cache_key) |key| {
if (generated_key) |key| {
// Try to get from cache
if (ComponentCache.global()) |cache| {
if (cache.get(key)) |cached_html| {
try writer.writeAll(cached_html);
return;
}
// Render to buffer for caching
var buf_writer = std.io.Writer.Allocating.init(func.allocator);
const component = func.call() catch |err| {
std.debug.print("Error rendering component: {}\n", .{err});
return err;
};
try component.renderInner(&buf_writer.writer, options);
const rendered = buf_writer.written();
cache.put(key, rendered, caching.seconds);
// Write to actual output
try writer.writeAll(rendered);
if (cache.get(key)) |cached_html| {
try writer.writeAll(cached_html);
return;
}
// Render to buffer for caching
var buf_writer = std.io.Writer.Allocating.init(func.allocator);
const component = func.call() catch |err| {
std.debug.print("Error rendering component: {}\n", .{err});
return err;
};
try component.renderInner(&buf_writer.writer, options);
const rendered = buf_writer.written();
cache.put(key, rendered, caching.seconds);
// Write to actual output
try writer.writeAll(rendered);
return;
}
}
}

View file

@ -1 +1 @@
<main><button>Custom Button</button></main>
<main><button>Custom Button</button><button>Custom Button</button></main>

View file

@ -10,6 +10,11 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.{ .caching = comptime .tag("10s:button") },
.{ .title = "Custom Button" },
),
_zx.cmp(
Button,
.{ .caching = comptime .tag("10s") },
.{ .title = "Custom Button" },
),
},
},
);

View file

@ -2,6 +2,9 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<Button @caching="10s:button" title="Custom Button" />
// <Button @caching={.{ .seconds = 10, .key = "button" }} title="Custom Button" />
// <Button @caching={.{ .seconds = 10 }} title="Custom Button" />
<Button @caching="10s" title="Custom Button" />
</main>
);
}

View file

@ -265,8 +265,11 @@ pub fn Segment(comptime T: type) type {
var lookup = &self.lookup;
self.mutex.lock();
for (entries) |entry| {
self.size -= entry._size;
_ = lookup.remove(entry.key);
// Use saturating subtraction to handle race condition where
// entry was already deleted between shared and exclusive lock
if (lookup.remove(entry.key)) {
self.size = self.size -| entry._size;
}
}
self.mutex.unlock();