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 }