lib/zen/src/site/catalog/storage.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3
4 const capacity_mod = @import("capacity.zig");
5 const model = @import("model.zig");
6 const plan_mod = @import("plan.zig");
7
8 pub const Status = struct {
9 phase: alloc_phase.capacity.Phase,
10 sealed: bool,
11 storage_bytes: usize,
12 max_pages: usize,
13 max_text_bytes: usize,
14 pages: usize,
15 text_bytes: usize,
16 rejected_entry_count: u64,
17 };
18
19 pub const Storage = struct {
20 phase: alloc_phase.capacity.Phase,
21 capacity: capacity_mod.Capacity,
22 bytes: []align(capacity_mod.storage_alignment) u8,
23 entries: []model.Entry,
24 text: []u8,
25 pages: usize = 0,
26 text_bytes: usize = 0,
27 rejected_entry_count: u64 = 0,
28 sealed: bool = false,
29
30 pub const Limits: type = capacity_mod.Limits;
31 pub const Capacity: type = capacity_mod.Capacity;
32 pub const Exhaustion: type = model.Exhaustion;
33 pub const InitError = std.mem.Allocator.Error || capacity_mod.DeriveError;
34 pub const AppendError: type = model.Error;
35
36 pub const claim: alloc_phase.capacity.Declaration = .{
37 .source = .{
38 .id = "zen.site_catalog_storage",
39 .kind = .phase_static,
40 .limit_source = .caller,
41 .storage = .{
42 .covered = &.{
43 .{
44 .id = "retained_site_catalog_entry_descriptors",
45 .lifetime = .steady,
46 .detail = "retained site catalog entry descriptors",
47 },
48 .{
49 .id = "retained_path_url_title_and_date_bytes",
50 .lifetime = .steady,
51 .detail = "retained path, URL, title, and date bytes",
52 },
53 },
54 .excluded = &.{
55 "caller-owned limits, source paths, Markdown page bytes, and frontmatter scratch",
56 "filesystem walkers, rendered pages, theme output, page-list expansion, and assets",
57 },
58 },
59 .capacity = .{
60 .inputs = &.{
61 alloc_phase.capacity.bindInput(Limits, "max_pages", "max_pages"),
62 alloc_phase.capacity.bindInput(Limits, "max_text_bytes", "max_text_bytes"),
63 },
64 .type_selectors = &.{
65 alloc_phase.capacity.bindType(model.Entry, "entry"),
66 },
67 .nodes = &.{
68 .{ .input = 0 },
69 .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 0 } } },
70 .{ .alignment = .{ .node = 1, .alignment = .{ .literal = 16 } } },
71 .{ .input = 1 },
72 .{ .add = .{ .left = 2, .right = 3 } },
73 },
74 .assertions = &.{.{
75 .scope = .closure_total,
76 .measure = .retained,
77 .relation = .exact,
78 .expression = 4,
79 }},
80 },
81 .overload = .{
82 .kind = .reject_before_mutation,
83 .detail = "entry demand rejects before mutation; rejection telemetry advances",
84 },
85 .risks = .{
86 .transitive = .{
87 .status = .witnessed,
88 .detail = "append, sort, and borrow use only exact preacquired storage",
89 },
90 .foreign = .{
91 .status = .excluded,
92 .detail = "directory walks and page reads are excluded site-build effects",
93 },
94 },
95 .obligations = &.{
96 .{ .key = "zen_site_catalog_capacity", .role = .capacity_model },
97 .{ .key = "zen_site_catalog_acquisition", .role = .custom },
98 .{ .key = "zen_site_catalog_oom", .role = .custom },
99 .{ .key = "zen_site_catalog_boundaries", .role = .overload },
100 .{ .key = "zen_site_catalog_atomic_rejection", .role = .overload },
101 .{ .key = "zen_site_catalog_sealed", .role = .transitive_risk },
102 .{ .key = "zen_site_catalog_root", .role = .custom },
103 .{ .key = "zen_site_catalog_consumer", .role = .foreign_risk },
104 },
105 },
106 .bindings = .{
107 .owner = @This(),
108 .seal = .{
109 .family = alloc_phase.capacity.selector(@This().activate),
110 .premise = .{
111 .class = .checked_semantic_fact,
112 .authority = .checker,
113 },
114 },
115 .teardown = .{
116 .family = alloc_phase.capacity.selector(@This().deinit),
117 .premise = .{
118 .class = .checked_semantic_fact,
119 .authority = .checker,
120 },
121 },
122 },
123 };
124
125 pub fn init(allocator: std.mem.Allocator, limits: Limits) InitError!Storage {
126 const capacity = try Capacity.derive(limits);
127 const bytes = try allocator.alignedAlloc(
128 u8,
129 .fromByteUnits(capacity_mod.storage_alignment),
130 capacity.storage_bytes,
131 );
132 std.debug.assert(bytes.len == capacity.storage_bytes);
133 const storage = Storage{
134 .phase = .initialization,
135 .capacity = capacity,
136 .bytes = bytes,
137 .entries = typedSlice(model.Entry, bytes, capacity.entries_offset, limits.max_pages),
138 .text = bytes[capacity.text_offset..][0..limits.max_text_bytes],
139 };
140 storage.assertStorage();
141 std.debug.assert(storage.pages == 0);
142 std.debug.assert(storage.text_bytes == 0);
143 return storage;
144 }
145
146 pub fn activate(self: *Storage) void {
147 std.debug.assert(self.phase == .initialization);
148 std.debug.assert(!self.sealed);
149 std.debug.assert(self.pages == 0);
150 std.debug.assert(self.text_bytes == 0);
151 self.assertStorage();
152 self.phase = .steady;
153 }
154
155 pub fn append(self: *Storage, input: model.EntryInput) AppendError!*const model.Entry {
156 std.debug.assert(self.phase == .steady);
157 std.debug.assert(!self.sealed);
158 std.debug.assert(self.pages <= self.entries.len);
159 std.debug.assert(self.text_bytes <= self.text.len);
160 const demand = plan_mod.Demand.fromInput(input);
161 const demand_bytes = demand.textBytes() catch |err| return self.reject(err);
162 std.debug.assert(demand_bytes >= input.relative_path.len);
163 const next_pages = std.math.add(usize, self.pages, 1) catch
164 return self.reject(error.CapacityOverflow);
165 if (next_pages > self.entries.len) {
166 return self.reject(error.SiteCatalogPageCapacityExceeded);
167 }
168 const next_text = std.math.add(usize, self.text_bytes, demand_bytes) catch
169 return self.reject(error.CapacityOverflow);
170 if (next_text > self.text.len) {
171 return self.reject(error.SiteCatalogTextCapacityExceeded);
172 }
173
174 var cursor = self.text_bytes;
175 const relative_path = self.appendText(&cursor, input.relative_path);
176 const url = self.appendText(&cursor, input.url);
177 const title = self.appendText(&cursor, input.title);
178 const date = self.appendText(&cursor, input.date);
179 const type_label = self.appendText(&cursor, input.type_label);
180 std.debug.assert(cursor == next_text);
181 self.entries[self.pages] = .{
182 .relative_path = relative_path,
183 .url = url,
184 .title = title,
185 .date = date,
186 .section = model.section(relative_path),
187 .type_label = type_label,
188 .dispatch = input.dispatch,
189 .draft = input.draft,
190 .slop = input.slop,
191 };
192 self.pages = next_pages;
193 self.text_bytes = next_text;
194 self.assertStorage();
195 const entry = &self.entries[self.pages - 1];
196 std.debug.assert(entry.relative_path.len == input.relative_path.len);
197 std.debug.assert(entry.url.len == input.url.len);
198 std.debug.assert(entry.title.len == input.title.len);
199 std.debug.assert(entry.date.len == input.date.len);
200 std.debug.assert(entry.type_label.len == input.type_label.len);
201 std.debug.assert(entry.dispatch == input.dispatch);
202 return entry;
203 }
204
205 pub fn seal(self: *Storage, base: []const u8) model.Catalog {
206 std.debug.assert(self.phase == .steady);
207 std.debug.assert(!self.sealed);
208 std.mem.sort(model.Entry, self.entries[0..self.pages], {}, model.lessThan);
209 self.sealed = true;
210 self.assertStorage();
211 for (self.entries[0..self.pages], 0..) |entry, index| {
212 if (index != 0) {
213 std.debug.assert(!model.lessThan({}, entry, self.entries[index - 1]));
214 }
215 }
216 return .{ .base = base, .pages = self.entries[0..self.pages] };
217 }
218
219 pub fn status(self: *const Storage) Status {
220 return .{
221 .phase = self.phase,
222 .sealed = self.sealed,
223 .storage_bytes = self.capacity.storage_bytes,
224 .max_pages = self.capacity.limits.max_pages,
225 .max_text_bytes = self.capacity.limits.max_text_bytes,
226 .pages = self.pages,
227 .text_bytes = self.text_bytes,
228 .rejected_entry_count = self.rejected_entry_count,
229 };
230 }
231
232 pub fn deinit(self: *Storage, allocator: std.mem.Allocator) void {
233 std.debug.assert(self.phase != .teardown);
234 self.assertStorage();
235 self.phase = .teardown;
236 allocator.free(self.bytes);
237 self.bytes = &.{};
238 self.entries = &.{};
239 self.text = &.{};
240 self.pages = 0;
241 self.text_bytes = 0;
242 }
243
244 fn appendText(self: *Storage, cursor: *usize, source: []const u8) []const u8 {
245 std.debug.assert(cursor.* <= self.text.len);
246 std.debug.assert(source.len <= self.text.len - cursor.*);
247 const start = cursor.*;
248 @memcpy(self.text[start..][0..source.len], source);
249 cursor.* += source.len;
250 return self.text[start..cursor.*];
251 }
252
253 fn reject(self: *Storage, err: model.Error) model.Error {
254 std.debug.assert(self.phase == .steady);
255 std.debug.assert(!self.sealed);
256 self.assertStorage();
257 self.rejected_entry_count +|= 1;
258 return err;
259 }
260
261 fn assertStorage(self: *const Storage) void {
262 std.debug.assert(self.bytes.len == self.capacity.storage_bytes);
263 std.debug.assert(self.entries.len == self.capacity.limits.max_pages);
264 std.debug.assert(self.text.len == self.capacity.limits.max_text_bytes);
265 std.debug.assert(self.pages <= self.entries.len);
266 std.debug.assert(self.text_bytes <= self.text.len);
267 std.debug.assert(@intFromPtr(self.bytes.ptr) % capacity_mod.storage_alignment == 0);
268 if (self.sealed) std.debug.assert(self.phase == .steady);
269 }
270 };
271
272 fn typedSlice(
273 comptime T: type,
274 bytes: []align(capacity_mod.storage_alignment) u8,
275 offset: usize,
276 count: usize,
277 ) []T {
278 const byte_count = count * @sizeOf(T);
279 const region: []align(@alignOf(T)) u8 = @alignCast(bytes[offset..][0..byte_count]);
280 return std.mem.bytesAsSlice(T, region);
281 }
282
283 fn checkInitFailures(allocator: std.mem.Allocator) !void {
284 var storage = try Storage.init(allocator, .{ .max_pages = 3, .max_text_bytes = 71 });
285 storage.deinit(allocator);
286 }
287
288 test "site catalog storage acquires one exact aligned region" {
289 comptime {
290 @stardustClaim(
291 @import("alloc_phase").capacity.witness(Storage, "zen_site_catalog_acquisition"),
292 null,
293 null,
294 null,
295 null,
296 null,
297 null,
298 );
299 }
300
301 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
302 const limits = capacity_mod.Limits{ .max_pages = 32, .max_text_bytes = 5041 };
303 const capacity = try capacity_mod.Capacity.derive(limits);
304 var storage = try Storage.init(counting.allocator(), limits);
305 defer storage.deinit(counting.allocator());
306 try std.testing.expectEqual(@as(usize, 1), counting.alloc_index);
307 try std.testing.expectEqual(capacity.storage_bytes, counting.allocated_bytes);
308 try std.testing.expectEqual(@intFromPtr(storage.bytes.ptr), @intFromPtr(storage.entries.ptr));
309 storage.activate();
310 }
311
312 test "site catalog storage retries after every allocation failure" {
313 comptime {
314 @stardustClaim(
315 @import("alloc_phase").capacity.witness(Storage, "zen_site_catalog_oom"),
316 null,
317 null,
318 null,
319 null,
320 null,
321 null,
322 );
323 }
324
325 try std.testing.checkAllAllocationFailures(std.testing.allocator, checkInitFailures, .{});
326 }
327
328 comptime {
329 alloc_phase.capacity.requireAllocatorRejectingOwnerShape(Storage);
330 }