milestone 16: behavioral fixes for silent failures, locks, counters and the query log
CI / test (push) Failing after 11s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s

This commit is contained in:
2026-08-07 01:54:40 +02:00
parent 5802148887
commit 25455e5ae2
31 changed files with 2054 additions and 297 deletions
+333 -80
View File
@@ -24,16 +24,28 @@
//! socket and no file, so the uncancelable lock forms are used: a lock this
//! code takes is always released within a few instructions.
//!
//! A second lock, `writer_lock`, serializes the writers against each other:
//! `reload`, `refreshSource`, `refreshAll`, the startup pass and
//! `pruneOrphans`. Two concurrent reloads would otherwise compute the same
//! generation and each destroy a snapshot the other had just published, and two
//! concurrent refreshes would share the fetcher's buffers and, for one source,
//! the same `.raw.tmp` / `.list.tmp` / `.wild.tmp` paths. It is held across
//! downloads and compiles, so it is a plain mutex rather than the RCU lock:
//! readers must never wait behind a refresh. The public entry points take it;
//! the `*Locked` bodies assume it and never take it again, because it is not
//! reentrant.
//! Two more locks serialize the writers, and they divide the work by how long
//! it takes.
//!
//! `writer_lock` covers what a writer does to the *published* state: build a
//! snapshot, install the compiled files, write the runtime columns, record a
//! status. Two concurrent reloads would otherwise compute the same generation
//! and each destroy a snapshot the other had just published. Every section it
//! guards is bounded by local work — a read of the compiled files at worst —
//! so a web mutation that ends in `reload` never waits out a download.
//!
//! `refresh_lock` covers what a writer does *before* it has anything to
//! publish: the download of one source, at up to 300 s each, and the compile
//! that follows it. It also covers blocklist-directory maintenance, because
//! those stages are the only writers of `.raw.tmp` / `.list.tmp` /
//! `.wild.tmp` and `pruneOrphans` must not sweep the temporaries of a refresh
//! that is still running. Two concurrent refreshes would share the fetcher's
//! buffers and, for one source, the same temporary paths.
//!
//! **Lock ordering: `refresh_lock` is never acquired while `writer_lock` is
//! held.** A path that needs both takes `refresh_lock` first. The public entry
//! points take what they need; the `*Locked` bodies assume it and never take
//! it again, because neither mutex is reentrant.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -251,8 +263,21 @@ pub const Manager = struct {
total_budget: std.Io.Clock.Duration,
lock: std.Io.RwLock,
/// Serializes the writers against each other. Never taken by a reader.
/// Serializes everything that changes the published state — the snapshot,
/// the compiled files, the runtime columns, the status table — against
/// every other writer. Never taken by a reader.
///
/// Lock ordering: `refresh_lock` is never acquired while this is held. A
/// caller that needs both takes `refresh_lock` first.
writer_lock: std.Io.Mutex,
/// Serializes refresh passes against each other, and against the
/// blocklist-directory maintenance in `pruneOrphans`. Held across a
/// download and a compile, which `writer_lock` deliberately is not, so an
/// unrelated `reload` never waits out a 300-second fetch.
///
/// Lock ordering: this is taken first, and never while `writer_lock` is
/// held.
refresh_lock: std.Io.Mutex,
current: ?*matcher.Snapshot,
generation: u64,
statuses: []SourceStatus,
@@ -301,6 +326,7 @@ pub const Manager = struct {
.total_budget = total_budget,
.lock = .init,
.writer_lock = .init,
.refresh_lock = .init,
.current = null,
.generation = 0,
.statuses = &.{},
@@ -538,14 +564,21 @@ pub const Manager = struct {
// failing append: `bodies` owns each one from the moment it is read.
try bodies.ensureUnusedCapacity(self.gpa, 2);
// `error.Canceled` is the one-shot signal that this task is being torn
// down, and it is consumed by whoever catches it. Recording it as a
// load failure would spend it on a status row that reads "Canceled",
// publish a snapshot with this source missing, and let the shutdown
// carry on as if nothing had asked it to stop.
const list_bytes = dir.readFileAlloc(io, list_name, self.gpa, .limited(max_compiled_bytes)) catch |err| {
if (err == error.OutOfMemory) return error.OutOfMemory;
if (err == error.Canceled) return error.Canceled;
return loadFailure(row, list_name, err);
};
bodies.appendAssumeCapacity(list_bytes);
const wild_bytes = dir.readFileAlloc(io, wild_name, self.gpa, .limited(max_compiled_bytes)) catch |err| {
if (err == error.OutOfMemory) return error.OutOfMemory;
if (err == error.Canceled) return error.Canceled;
return loadFailure(row, wild_name, err);
};
bodies.appendAssumeCapacity(wild_bytes);
@@ -580,11 +613,13 @@ pub const Manager = struct {
/// before it refreshes anything, which is why it is the entry point Phase 8
/// and the scheduler use.
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io);
self.refresh_lock.lockUncancelable(io);
defer self.refresh_lock.unlock(io);
return self.refreshSourceLocked(io, row);
}
/// Assumes `refresh_lock`. Takes `writer_lock` itself, for the publish half
/// alone.
fn refreshSourceLocked(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
// The previous entry describes the compiled files that are still on
// disk, and a failed refresh leaves them serving. Starting from a blank
@@ -594,41 +629,6 @@ pub const Manager = struct {
status.setUrl(row.url);
status.last_attempt = std.Io.Clock.real.now(io).toSeconds();
const replaced = try self.refreshOne(io, row, &status);
self.commitStatus(io, status);
return replaced;
}
/// Every enabled source, one at a time, then one `reload`. A failing source
/// never stops the pass: it would hide every source behind it.
///
/// This returns an error only when nothing could be done at all — out of
/// memory, an unreachable database, an unusable blocklist directory. A
/// source that failed to download or compile is a successful pass with a
/// non-`ok` status.
pub fn refreshAll(self: *Manager, io: std.Io) Error!void {
self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io);
var rows = try sources_repo.listSourceRows(self.database, self.gpa);
defer rows.deinit(self.gpa);
defer sources_repo.freeSourceRows(self.gpa, rows.items);
try self.syncStatuses(io, rows.items);
for (rows.items) |row| {
if (!row.enabled) continue;
_ = try self.refreshSourceLocked(io, row);
}
return self.reloadLocked(io);
}
fn refreshOne(
self: *Manager,
io: std.Io,
row: sources_repo.SourceRow,
status: *SourceStatus,
) Error!bool {
var dir = try self.openDir(io, .{});
defer dir.close(io);
@@ -647,12 +647,80 @@ pub const Manager = struct {
defer self.deleteQuietly(io, dir, list_tmp);
defer self.deleteQuietly(io, dir, wild_tmp);
// The half that takes the time: one download of up to `total_budget`
// and one compile of everything it returned. `refresh_lock` alone is
// held here, so a rule save, a settings change or any other web
// mutation that ends in `reload` runs beside it instead of behind it.
const prepared = try self.prepareRefresh(io, dir, row, &status, raw_name, list_tmp, wild_tmp);
// The half that publishes. The compiled files, the runtime columns and
// the status entry land under one `writer_lock`, so a reload never
// reads new files beside a status entry describing the previous ones.
self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io);
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, list_tmp, wild_tmp);
self.commitStatus(io, status);
return replaced;
}
/// Every enabled source, one at a time, then one `reload`. A failing source
/// never stops the pass: it would hide every source behind it.
///
/// This returns an error only when nothing could be done at all — out of
/// memory, an unreachable database, an unusable blocklist directory. A
/// source that failed to download or compile is a successful pass with a
/// non-`ok` status.
pub fn refreshAll(self: *Manager, io: std.Io) Error!void {
self.refresh_lock.lockUncancelable(io);
defer self.refresh_lock.unlock(io);
var rows = try sources_repo.listSourceRows(self.database, self.gpa);
defer rows.deinit(self.gpa);
defer sources_repo.freeSourceRows(self.gpa, rows.items);
try self.syncStatuses(io, rows.items);
for (rows.items) |row| {
if (!row.enabled) continue;
_ = try self.refreshSourceLocked(io, row);
}
// `reload` takes `writer_lock`, which the pass has been careful not to
// hold: the order is `refresh_lock` first, always.
return self.reload(io);
}
/// What the fetch-and-compile half of a refresh produced. `.failed` needs
/// no publish and has already recorded why in the status entry.
const Prepared = union(enum) {
failed,
compiled: struct {
format: parsers.Format,
result: compiler.Result,
},
};
/// Downloads one source and compiles it into the temporary files.
///
/// Assumes `refresh_lock` and must not be called with `writer_lock` held:
/// this is the part that takes seconds, and nothing here touches the
/// published state.
fn prepareRefresh(
self: *Manager,
io: std.Io,
dir: std.Io.Dir,
row: sources_repo.SourceRow,
status: *SourceStatus,
raw_name: []const u8,
list_tmp: []const u8,
wild_tmp: []const u8,
) Error!Prepared {
self.download(io, dir, raw_name, row) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Canceled => return error.Canceled,
else => {
self.reportFetchFailure(row, status, err);
return false;
return .failed;
},
};
@@ -661,7 +729,7 @@ pub const Manager = struct {
error.Canceled => return error.Canceled,
else => {
self.reportCompileFailure(row, status, err);
return false;
return .failed;
},
};
@@ -670,15 +738,38 @@ pub const Manager = struct {
error.Canceled => return error.Canceled,
else => {
self.reportCompileFailure(row, status, err);
return false;
return .failed;
},
};
if (rejectedWithoutEntries(result.counts)) {
self.reportEmptyCompile(row, status, result.counts);
return false;
return .failed;
}
return .{ .compiled = .{ .format = format, .result = result } };
}
/// Installs what `prepareRefresh` produced and writes the runtime columns.
/// Returns `true` only when the compiled files were replaced.
///
/// Assumes `writer_lock`. Reading the files on disk belongs here and not in
/// the half above: they are published under this lock.
fn publishRefresh(
self: *Manager,
io: std.Io,
dir: std.Io.Dir,
row: sources_repo.SourceRow,
status: *SourceStatus,
prepared: Prepared,
list_tmp: []const u8,
wild_tmp: []const u8,
) Error!bool {
const compiled = switch (prepared) {
.failed => return false,
.compiled => |value| value,
};
const now = std.Io.Clock.real.now(io).toSeconds();
// Recompiling identical content into new files would invalidate the
@@ -688,7 +779,9 @@ pub const Manager = struct {
// them, and skipping the rewrite here would leave filtering off for
// good.
if (row.checksum) |stored| {
if (std.mem.eql(u8, stored, &result.checksum) and self.diskBodiesMatch(io, dir, row.id, stored)) {
if (std.mem.eql(u8, stored, &compiled.result.checksum) and
self.diskBodiesMatch(io, dir, row.id, stored))
{
try sources_repo.updateSourceStats(self.database, row.id, .{
.last_updated = now,
.domain_count = row.domain_count,
@@ -696,17 +789,17 @@ pub const Manager = struct {
.skipped_regex_count = row.skipped_regex_count,
.checksum = stored,
});
status.succeed(now, result.counts);
status.succeed(now, compiled.result.counts);
return false;
}
}
const header: Header = .{
.url = row.url,
.format = format,
.format = compiled.format,
.fetched_at = now,
.counts = result.counts,
.checksum = &result.checksum,
.counts = compiled.result.counts,
.checksum = &compiled.result.checksum,
};
self.publish(io, dir, row.id, header, list_tmp, wild_tmp) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
@@ -719,12 +812,12 @@ pub const Manager = struct {
try sources_repo.updateSourceStats(self.database, row.id, .{
.last_updated = now,
.domain_count = result.counts.domains,
.wildcard_count = result.counts.wildcards,
.skipped_regex_count = result.counts.skipped_regex,
.checksum = &result.checksum,
.domain_count = compiled.result.counts.domains,
.wildcard_count = compiled.result.counts.wildcards,
.skipped_regex_count = compiled.result.counts.skipped_regex,
.checksum = &compiled.result.checksum,
});
status.succeed(now, result.counts);
status.succeed(now, compiled.result.counts);
return true;
}
@@ -1034,8 +1127,8 @@ pub const Manager = struct {
/// it — let alone the server. Cancellation is the one outcome that
/// propagates, because it means shutdown.
///
/// Taken from outside every `*Locked` body: `pruneOrphans` takes
/// `writer_lock` itself and the mutex is not reentrant.
/// Taken from outside every `*Locked` body: `pruneOrphans` takes both
/// writer mutexes itself and neither is reentrant.
fn sweepOrphans(self: *Manager, io: std.Io) std.Io.Cancelable!void {
self.pruneOrphans(io) catch |err| switch (err) {
error.Canceled => return error.Canceled,
@@ -1069,13 +1162,13 @@ pub const Manager = struct {
}
fn startupPass(self: *Manager, io: std.Io) Error!void {
self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io);
self.refresh_lock.lockUncancelable(io);
defer self.refresh_lock.unlock(io);
// Ahead of the gate on purpose: loading the compiled files that already
// exist is a read. A full disk must not cost the household its
// filtering as well as its downloads.
try self.reloadLocked(io);
try self.reload(io);
if (self.refreshGated()) return;
@@ -1090,7 +1183,7 @@ pub const Manager = struct {
if (!self.needsRefresh(io, row, now)) continue;
if (try self.refreshSourceLocked(io, row)) refreshed = true;
}
if (refreshed) try self.reloadLocked(io);
if (refreshed) try self.reload(io);
}
fn needsRefresh(self: *Manager, io: std.Io, row: sources_repo.SourceRow, now: i64) bool {
@@ -1128,10 +1221,19 @@ pub const Manager = struct {
/// `<data_dir>/blocklists/` if nothing has yet, and an empty directory
/// sweeps to nothing.
pub fn pruneOrphans(self: *Manager, io: std.Io) Error!void {
// Every path that writes a temporary holds this lock too, so the sweep
// never reads a directory a refresh is halfway through. The temporaries
// it can see therefore belong to a finished or a dead refresh, and only
// those of a source with no row are removed.
// `refresh_lock` first, and for the reason it exists: the download and
// the compile are the only writers of `.raw.tmp`, `.list.tmp` and
// `.wild.tmp`, and they hold it for as long as they run. Without it
// here, a source deleted through the API would sweep the temporaries of
// a refresh still writing them — the row is gone, so nothing else in
// this function would spare them — and the pass would fail on a raw
// file that vanished under it.
//
// `writer_lock` second, in the one order this file ever takes them,
// because the rows this reads and the compiled files it deletes are
// what a reload is reading.
self.refresh_lock.lockUncancelable(io);
defer self.refresh_lock.unlock(io);
self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io);
@@ -1247,6 +1349,12 @@ pub const Manager = struct {
entry.loaded = loaded;
return;
}
// No entry of that id: the source was added or deleted between the
// table this pass started from and this commit. The outcome is lost
// either way — the next reload rebuilds the table from the rows — but
// a failure that disappears without a line is the one thing milestone
// 5 says never happens.
log.warn("blocklist source {d}: no status entry to record the refresh outcome in", .{status.id});
}
/// One id per group, in `listGroups` order.
@@ -1485,10 +1593,10 @@ const source_file_suffixes = [_][]const u8{ ".list.tmp", ".wild.tmp", ".raw.tmp"
/// The three temporaries count. A refresh that dies between writing one and
/// renaming it leaves a file no later refresh reuses and no `defer` reaches, so
/// excluding them from the sweep means nothing ever removes them. Matching them
/// is safe because `pruneOrphans` holds `writer_lock` for its whole body: every
/// path that creates a temporary runs under that same lock, so no refresh is in
/// flight while the sweep reads the directory, and a temporary the sweep sees
/// belonging to a source that still has a row is kept regardless.
/// is safe because `pruneOrphans` holds `refresh_lock` for its whole body:
/// every path that creates a temporary runs under that same lock, so no refresh
/// is in flight while the sweep reads the directory, and a temporary the sweep
/// sees belonging to a source that still has a row is kept regardless.
fn sourceFileId(file_name: []const u8) ?i64 {
for (source_file_suffixes) |suffix| {
if (!std.mem.endsWith(u8, file_name, suffix)) continue;
@@ -1509,8 +1617,9 @@ fn containsId(rows: []const sources_repo.SourceRow, id: i64) bool {
// tests
// ---------------------------------------------------------------------------
//
// Everything here runs against a `:memory:` database and touches no file. Real
// files, real HTTP and real swaps are the integration suite's (S9).
// Everything here runs against a `:memory:` database. Only the cancellation
// cases below reach a file, and they reach a `testing.tmpDir` — real HTTP and
// real swaps under load are the integration suite's (S9).
const testing = std.testing;
const migrations = @import("../storage/migrations.zig");
@@ -1527,8 +1636,9 @@ fn testManager(database: *db.Db, fetcher_ptr: *fetcher.Fetcher) !Manager {
return Manager.init(
testing.allocator,
database,
// No test in this file reaches the filesystem: `acquire` answers before
// any directory is touched, and the header helpers are pure.
// `acquire` answers before any directory is touched and the header
// helpers are pure, so this directory is never opened unless a test
// replaces it with one of its own.
.{ .dir = std.Io.Dir.cwd() },
fetcher_ptr,
.{},
@@ -1623,6 +1733,149 @@ test "statusSnapshot on an empty manager copies nothing" {
try testing.expectEqual(@as(usize, 0), manager.statusSnapshot(io, &out));
}
// ---------------------------------------------------------------------------
// a canceled read of a compiled file
// ---------------------------------------------------------------------------
/// The one file open a `cancelingIo` turns into `error.Canceled`, and the io it
/// hands every other open to.
///
/// A `std.Io` carries its implementation's `userdata`, so a patched vtable entry
/// cannot smuggle a receiver of its own through it and has to read its
/// configuration from here. The test runner runs the tests of one binary in
/// sequence, so one instance is enough.
var canceling_read: struct {
inner: std.Io = undefined,
/// The file-name suffix whose open is canceled.
suffix: []const u8 = "",
} = .{};
/// `inner` with the open of every file whose name ends in `suffix` replaced by
/// `error.Canceled`. `vtable` is the caller's storage for the patched copy and
/// must outlive the returned io.
fn cancelingIo(inner: std.Io, suffix: []const u8, vtable: *std.Io.VTable) std.Io {
canceling_read = .{ .inner = inner, .suffix = suffix };
vtable.* = inner.vtable.*;
vtable.dirOpenFile = cancelingOpenFile;
return .{ .userdata = inner.userdata, .vtable = vtable };
}
fn cancelingOpenFile(
userdata: ?*anyopaque,
dir: std.Io.Dir,
sub_path: []const u8,
options: std.Io.Dir.OpenFileOptions,
) std.Io.File.OpenError!std.Io.File {
if (std.mem.endsWith(u8, sub_path, canceling_read.suffix)) return error.Canceled;
return canceling_read.inner.vtable.dirOpenFile(userdata, dir, sub_path, options);
}
test "a canceled compiled-file read cancels the reload instead of recording it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var database = try openMigrated();
defer database.close();
var f: fetcher.Fetcher = undefined;
var mgr = try testManager(&database, &f);
defer mgr.deinit(io);
mgr.paths = .{ .dir = tmp.dir };
const url = "https://lists.example/hosts.txt";
try sources_repo.insertBlocklistSource(&database, .{ .url = url, .name = "example" }, .{});
var rows = try sources_repo.listSourceRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer sources_repo.freeSourceRows(testing.allocator, rows.items);
const id = rows.items[0].id;
const list_body = "aaa.example.com\n";
const wild_body = "";
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
defer dir.close(io);
var list_buf: [name_buf_len]u8 = undefined;
var wild_buf: [name_buf_len]u8 = undefined;
try dir.writeFile(io, .{ .sub_path = compiledName(&list_buf, id, ".list"), .data = list_body });
try dir.writeFile(io, .{ .sub_path = compiledName(&wild_buf, id, ".wild"), .data = wild_body });
try sources_repo.updateSourceStats(&database, id, .{
.last_updated = 1_700_000_000,
.domain_count = 1,
.wildcard_count = 0,
.skipped_regex_count = 0,
.checksum = &bodyChecksum(list_body, wild_body),
});
// The baseline every assertion below is against: one clean reload, one
// status entry that says so.
try mgr.reload(io);
var out: [4]SourceStatus = undefined;
try testing.expectEqual(@as(usize, 1), mgr.statusSnapshot(io, &out));
try testing.expectEqual(State.ok, out[0].state);
try testing.expect(out[0].loaded);
const published = mgr.generation;
// Both catch sites, in the order `loadSource` reads the two files. A
// cancellation is consumed by whoever catches it, so folding it into a load
// failure would spend the shutdown signal and leave a status row reading
// "Canceled" behind.
for ([_][]const u8{ ".list", ".wild" }) |suffix| {
var vtable: std.Io.VTable = undefined;
const canceling = cancelingIo(io, suffix, &vtable);
try testing.expectError(error.Canceled, mgr.reload(canceling));
try testing.expectEqual(@as(usize, 1), mgr.statusSnapshot(io, &out));
try testing.expectEqual(State.ok, out[0].state);
try testing.expectEqual(@as(usize, 0), out[0].errorText().len);
try testing.expect(out[0].loaded);
// Nothing was published either: the snapshot the reload never built
// cannot have replaced the one still serving.
try testing.expectEqual(published, mgr.generation);
}
}
test "committing a status for an id the table has no entry for is not silent" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var f: fetcher.Fetcher = undefined;
var mgr = try testManager(&database, &f);
defer mgr.deinit(io);
const rows = [_]sources_repo.SourceRow{testRow(1, true)};
try mgr.syncStatuses(io, &rows);
// The unknown id: a source inserted through the API after this pass built
// its table, or deleted before the pass reached its commit. The outcome
// has nowhere to go, and the warning is the only trace it leaves. The log
// sink cannot be installed under the test runner — it would eat the
// harness's own output — so what is asserted here is that the miss is
// survivable and changes nothing.
var stranger: SourceStatus = .{ .id = 42 };
stranger.fail(.fetch_failed, "Timeout");
mgr.commitStatus(io, stranger);
var out: [4]SourceStatus = undefined;
try testing.expectEqual(@as(usize, 1), mgr.statusSnapshot(io, &out));
try testing.expectEqual(@as(i64, 1), out[0].id);
try testing.expectEqual(State.never_fetched, out[0].state);
// The same commit against an id the table does know still lands.
var known: SourceStatus = .{ .id = 1 };
known.fail(.fetch_failed, "Timeout");
mgr.commitStatus(io, known);
_ = mgr.statusSnapshot(io, &out);
try testing.expectEqual(State.fetch_failed, out[0].state);
try testing.expectEqualStrings("Timeout", out[0].errorText());
}
test "the header writer produces the documented text" {
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);