filter: a reload builds the snapshot one source at a time, and the startup pass reloads only when a source changed or was not enforced
This commit is contained in:
@@ -13,6 +13,7 @@ Sections are written by hand. Nothing here is generated from commit messages: th
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **Blocklists take less memory, and a refresh peaks lower.** A list that names a domain both on its own and with its subdomains, as Adblock-style lists do for every entry, now stores that name once instead of twice. A reload reads one list's compiled files at a time instead of all of them at once, and the startup pass no longer rebuilds the blocklists when no list was due for a refresh.
|
||||||
- **Each database connection keeps a page cache sized for its job** instead of SQLite's 2 MiB default on all seven: 256 KiB for the configuration database and for retention, 1 MiB for the query-history writer and reader.
|
- **Each database connection keeps a page cache sized for its job** instead of SQLite's 2 MiB default on all seven: 256 KiB for the configuration database and for retention, 1 MiB for the query-history writer and reader.
|
||||||
- **The documentation is written for people running nxdns.** It starts from Get started, which downloads a release instead of building from source, and drops the API reference, the troubleshooting page and the performance pages. It is published at <https://nxdns.sh/docs/>, and `INSTALL.md` in the tarball links there.
|
- **The documentation is written for people running nxdns.** It starts from Get started, which downloads a release instead of building from source, and drops the API reference, the troubleshooting page and the performance pages. It is published at <https://nxdns.sh/docs/>, and `INSTALL.md` in the tarball links there.
|
||||||
|
|
||||||
|
|||||||
@@ -915,7 +915,7 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
|
|||||||
|
|
||||||
/// The whole refresh path for one source served by `fixture`, with the status
|
/// The whole refresh path for one source served by `fixture`, with the status
|
||||||
/// table synced first so `refreshSource` has somewhere to record its outcome.
|
/// table synced first so `refreshSource` has somewhere to record its outcome.
|
||||||
fn refreshOnce(env: *Env, url: []const u8) !bool {
|
fn refreshOnce(env: *Env, url: []const u8) !manager.RefreshOutcome {
|
||||||
try env.mgr.reload(env.io());
|
try env.mgr.reload(env.io());
|
||||||
var rows = try listRows(&env.database);
|
var rows = try listRows(&env.database);
|
||||||
defer rows.deinit();
|
defer rows.deinit();
|
||||||
@@ -940,7 +940,7 @@ test "4: a 200 response is fetched, compiled, recorded and served" {
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
try env.mgr.reload(io);
|
try env.mgr.reload(io);
|
||||||
|
|
||||||
var dir = try env.blocklistDir();
|
var dir = try env.blocklistDir();
|
||||||
@@ -997,7 +997,7 @@ test "5: a redirect is followed to the same result" {
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
try env.mgr.reload(io);
|
try env.mgr.reload(io);
|
||||||
|
|
||||||
try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
|
try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
|
||||||
@@ -1040,7 +1040,7 @@ test "6: a 404 leaves the compiled files and the snapshot untouched" {
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
try env.mgr.reload(io);
|
try env.mgr.reload(io);
|
||||||
|
|
||||||
var dir = try env.blocklistDir();
|
var dir = try env.blocklistDir();
|
||||||
@@ -1049,7 +1049,7 @@ test "6: a 404 leaves the compiled files and the snapshot untouched" {
|
|||||||
defer before.deinit(gpa);
|
defer before.deinit(gpa);
|
||||||
|
|
||||||
fixture.setRoute(.not_found);
|
fixture.setRoute(.not_found);
|
||||||
try testing.expect(!try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
|
||||||
|
|
||||||
const failed = try env.status(id);
|
const failed = try env.status(id);
|
||||||
try testing.expectEqual(manager.State.fetch_failed, failed.state);
|
try testing.expectEqual(manager.State.fetch_failed, failed.state);
|
||||||
@@ -1093,7 +1093,7 @@ test "6b: a local failure reports itself, not the previous source's network faul
|
|||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
// First a real network failure, so the fetcher is holding a record.
|
// First a real network failure, so the fetcher is holding a record.
|
||||||
try testing.expect(!try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
|
||||||
try expectFailureText(
|
try expectFailureText(
|
||||||
"HttpStatus (receive, cause HttpStatus, http 404, 0 bytes, ",
|
"HttpStatus (receive, cause HttpStatus, http 404, 0 bytes, ",
|
||||||
(try env.status(id)).errorText(),
|
(try env.status(id)).errorText(),
|
||||||
@@ -1107,7 +1107,7 @@ test "6b: a local failure reports itself, not the previous source's network faul
|
|||||||
try dir.setPermissions(io, .fromMode(0o500));
|
try dir.setPermissions(io, .fromMode(0o500));
|
||||||
defer dir.setPermissions(io, .fromMode(0o700)) catch {};
|
defer dir.setPermissions(io, .fromMode(0o700)) catch {};
|
||||||
|
|
||||||
try testing.expect(!try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
|
||||||
|
|
||||||
const failed = try env.status(id);
|
const failed = try env.status(id);
|
||||||
try testing.expectEqual(manager.State.fetch_failed, failed.state);
|
try testing.expectEqual(manager.State.fetch_failed, failed.state);
|
||||||
@@ -1135,7 +1135,7 @@ test "7: a body over the cap fails the refresh and leaves no temporary file" {
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(!try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
|
||||||
|
|
||||||
const failed = try env.status(id);
|
const failed = try env.status(id);
|
||||||
try testing.expectEqual(manager.State.fetch_failed, failed.state);
|
try testing.expectEqual(manager.State.fetch_failed, failed.state);
|
||||||
@@ -1170,7 +1170,7 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
|
|
||||||
var dir = try env.blocklistDir();
|
var dir = try env.blocklistDir();
|
||||||
defer dir.close(io);
|
defer dir.close(io);
|
||||||
@@ -1194,7 +1194,7 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try testing.expect(!try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.unchanged, try refreshOnce(env, url));
|
||||||
|
|
||||||
// `replace` renames a new file over the old one, so an untouched file keeps
|
// `replace` renames a new file over the old one, so an untouched file keeps
|
||||||
// both its inode and its modification time.
|
// both its inode and its modification time.
|
||||||
@@ -1464,7 +1464,7 @@ const RefreshAllTask = struct {
|
|||||||
const RefreshSourceTask = struct {
|
const RefreshSourceTask = struct {
|
||||||
mgr: *manager.Manager,
|
mgr: *manager.Manager,
|
||||||
row: sources_repo.SourceRow,
|
row: sources_repo.SourceRow,
|
||||||
result: manager.Manager.Error!bool = false,
|
result: manager.Manager.Error!manager.RefreshOutcome = .failed,
|
||||||
|
|
||||||
fn run(self: *RefreshSourceTask, io: std.Io) void {
|
fn run(self: *RefreshSourceTask, io: std.Io) void {
|
||||||
self.result = self.mgr.refreshSource(io, self.row);
|
self.result = self.mgr.refreshSource(io, self.row);
|
||||||
@@ -1589,7 +1589,7 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi
|
|||||||
|
|
||||||
try tasks.await(io);
|
try tasks.await(io);
|
||||||
// True only if the compile found the raw file the download had written.
|
// True only if the compile found the raw file the download had written.
|
||||||
try testing.expect(try task.result);
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try task.result);
|
||||||
|
|
||||||
// The sweep did run, once the refresh was out of the way: the compiled
|
// The sweep did run, once the refresh was out of the way: the compiled
|
||||||
// files of a source with no row are gone, and so is every temporary.
|
// files of a source with no row are gone, and so is every temporary.
|
||||||
@@ -1626,7 +1626,7 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
|||||||
|
|
||||||
// One real download, so the compiled artifacts exist and are named after
|
// One real download, so the compiled artifacts exist and are named after
|
||||||
// the row id the rest of this test is about.
|
// the row id the rest of this test is about.
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
|
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
|
||||||
|
|
||||||
var dir = try env.blocklistDir();
|
var dir = try env.blocklistDir();
|
||||||
@@ -1755,6 +1755,63 @@ fn localTable(gpa: std.mem.Allocator, database: *db.Db) !records.Records {
|
|||||||
return records.Records.build(gpa, rows.items);
|
return records.Records.build(gpa, rows.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "10f: a source the boot load could not read is enforced once its refresh finds the same files" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
const env = try Env.create(gpa);
|
||||||
|
defer env.destroy();
|
||||||
|
const io = env.io();
|
||||||
|
|
||||||
|
var fixture = try HttpFixture.init(io, http_body);
|
||||||
|
defer fixture.deinit(io);
|
||||||
|
var group: std.Io.Group = .init;
|
||||||
|
defer group.cancel(io);
|
||||||
|
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||||
|
|
||||||
|
var url_buf: [64]u8 = undefined;
|
||||||
|
const url = try fixture.url(&url_buf);
|
||||||
|
const id = try seedSource(&env.database, url);
|
||||||
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
|
|
||||||
|
// The restart, with the `.list` file missing for the boot load alone.
|
||||||
|
env.mgr.deinit(io);
|
||||||
|
env.mgr = try manager.Manager.init(
|
||||||
|
gpa,
|
||||||
|
&env.database,
|
||||||
|
.{ .dir = env.tmp.dir },
|
||||||
|
&env.f,
|
||||||
|
.{ .enabled = false },
|
||||||
|
budget,
|
||||||
|
);
|
||||||
|
var dir = try env.blocklistDir();
|
||||||
|
defer dir.close(io);
|
||||||
|
var list_buf: [64]u8 = undefined;
|
||||||
|
const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
|
||||||
|
try dir.rename(list_name, dir, "aside", io);
|
||||||
|
try env.mgr.reload(io);
|
||||||
|
try dir.rename("aside", dir, list_name, io);
|
||||||
|
|
||||||
|
try testing.expect(!(try env.status(id)).loaded);
|
||||||
|
{
|
||||||
|
const decision, _ = try env.evaluate("ads.example.com");
|
||||||
|
try testing.expect(!decision.blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The startup pass refreshes the source, which is not `ok`, and the
|
||||||
|
// download compiles to the files already on disk. That alone rewrites
|
||||||
|
// nothing, so it is the reload after it that puts the source back.
|
||||||
|
env.mgr.schedule_clock = .shutdown_at_first_park;
|
||||||
|
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||||
|
try testing.expectEqual(@as(u32, 2), fixture.accepted.load(.monotonic));
|
||||||
|
|
||||||
|
const status = try env.status(id);
|
||||||
|
try testing.expect(status.loaded);
|
||||||
|
try testing.expectEqual(manager.State.ok, status.state);
|
||||||
|
const decision, _ = try env.evaluate("ads.example.com");
|
||||||
|
try testing.expect(decision.blocked);
|
||||||
|
}
|
||||||
|
|
||||||
test "11: a local A record read from the database answers an A query" {
|
test "11: a local A record read from the database answers an A query" {
|
||||||
if (!build_options.integration) return error.SkipZigTest;
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
@@ -2109,7 +2166,7 @@ test "18: a body streamed in flushed parts survives the fetcher's multi-read pum
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
try env.mgr.reload(io);
|
try env.mgr.reload(io);
|
||||||
|
|
||||||
try testing.expect(fixture.flushed_parts.load(.acquire) >= 3);
|
try testing.expect(fixture.flushed_parts.load(.acquire) >= 3);
|
||||||
@@ -2181,7 +2238,7 @@ test "19: a downloaded list's exceptions lift its own blocks and nothing else" {
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
try env.mgr.reload(io);
|
try env.mgr.reload(io);
|
||||||
|
|
||||||
// The `.allow` body is a third file beside the two, and the row and the
|
// The `.allow` body is a third file beside the two, and the row and the
|
||||||
@@ -2371,7 +2428,7 @@ test "21: an abp list's unsupported lines are counted, persisted and read back"
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
try env.mgr.reload(io);
|
try env.mgr.reload(io);
|
||||||
|
|
||||||
var rows = try listRows(&env.database);
|
var rows = try listRows(&env.database);
|
||||||
@@ -2432,7 +2489,7 @@ test "23: a name moving from the list body to the wild body forces a republish"
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
_ = try seedSource(&env.database, url);
|
_ = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
|
|
||||||
var first_checksum: [64]u8 = undefined;
|
var first_checksum: [64]u8 = undefined;
|
||||||
{
|
{
|
||||||
@@ -2464,7 +2521,7 @@ test "22: a list that changed only its skipped lines still updates both skip cou
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
const id = try seedSource(&env.database, url);
|
const id = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
|
|
||||||
var first_checksum: [64]u8 = undefined;
|
var first_checksum: [64]u8 = undefined;
|
||||||
{
|
{
|
||||||
@@ -2479,7 +2536,7 @@ test "22: a list that changed only its skipped lines still updates both skip cou
|
|||||||
// The edited list. Two more skipped lines and not one written entry moved,
|
// The edited list. Two more skipped lines and not one written entry moved,
|
||||||
// so the refresh finds its stored checksum and rewrites nothing on disk.
|
// so the refresh finds its stored checksum and rewrites nothing on disk.
|
||||||
fixture.setRoute(.changed);
|
fixture.setRoute(.changed);
|
||||||
try testing.expect(!try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.unchanged, try refreshOnce(env, url));
|
||||||
|
|
||||||
{
|
{
|
||||||
var rows = try listRows(&env.database);
|
var rows = try listRows(&env.database);
|
||||||
@@ -2548,7 +2605,7 @@ test "27: a failing refresh opens a blocklist.refresh episode a good one closes"
|
|||||||
const url = try fixture.url(&url_buf);
|
const url = try fixture.url(&url_buf);
|
||||||
_ = try seedSource(&env.database, url);
|
_ = try seedSource(&env.database, url);
|
||||||
|
|
||||||
try testing.expect(!try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
|
||||||
|
|
||||||
try testing.expectEqualStrings("blocklist.refresh", try fx.text(
|
try testing.expectEqualStrings("blocklist.refresh", try fx.text(
|
||||||
"SELECT code FROM operational_events WHERE resolved_at IS NULL",
|
"SELECT code FROM operational_events WHERE resolved_at IS NULL",
|
||||||
@@ -2562,7 +2619,7 @@ test "27: a failing refresh opens a blocklist.refresh episode a good one closes"
|
|||||||
));
|
));
|
||||||
|
|
||||||
fixture.setRoute(.body);
|
fixture.setRoute(.body);
|
||||||
try testing.expect(try refreshOnce(env, url));
|
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
|
||||||
|
|
||||||
try testing.expectEqual(
|
try testing.expectEqual(
|
||||||
@as(i64, 0),
|
@as(i64, 0),
|
||||||
|
|||||||
+288
-61
@@ -892,17 +892,32 @@ pub const Manager = struct {
|
|||||||
defer self.gpa.free(sources);
|
defer self.gpa.free(sources);
|
||||||
const source_ids = try self.gpa.alloc(i64, rows.items.len);
|
const source_ids = try self.gpa.alloc(i64, rows.items.len);
|
||||||
defer self.gpa.free(source_ids);
|
defer self.gpa.free(source_ids);
|
||||||
const compiled = try self.gpa.alloc(?matcher.Snapshot.Compiled, rows.items.len);
|
for (rows.items, sources, source_ids) |row, *source, *source_id| {
|
||||||
defer self.gpa.free(compiled);
|
source_id.* = row.id;
|
||||||
|
// `is_suggested` is a UI hint the snapshot never reads.
|
||||||
// The file contents outlive the header stripping and are freed once the
|
source.* = .{ .url = row.url, .name = row.name, .enabled = row.enabled };
|
||||||
// snapshot has copied what it needs into its own arena.
|
|
||||||
var bodies: std.ArrayList([]u8) = .empty;
|
|
||||||
defer {
|
|
||||||
for (bodies.items) |body| self.gpa.free(body);
|
|
||||||
bodies.deinit(self.gpa);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Query names are attacker-supplied, so a fixed seed would make
|
||||||
|
// probe-chain flooding computable offline.
|
||||||
|
var seed_bytes: [8]u8 = undefined;
|
||||||
|
io.random(&seed_bytes);
|
||||||
|
|
||||||
|
var builder: matcher.Snapshot.Builder = try .init(
|
||||||
|
self.gpa,
|
||||||
|
sources,
|
||||||
|
source_ids,
|
||||||
|
std.mem.readInt(u64, &seed_bytes, .little),
|
||||||
|
);
|
||||||
|
defer builder.deinit();
|
||||||
|
|
||||||
|
// One source's file contents at a time: each is freed once the builder
|
||||||
|
// has copied what it keeps, before the next source is read. A reload
|
||||||
|
// then holds the old snapshot, the new one and one source's bodies.
|
||||||
|
var bodies: std.ArrayList([]u8) = .empty;
|
||||||
|
defer bodies.deinit(self.gpa);
|
||||||
|
defer for (bodies.items) |body| self.gpa.free(body);
|
||||||
|
|
||||||
// What this reload found, per source. It is applied to the status table
|
// What this reload found, per source. It is applied to the status table
|
||||||
// only if the snapshot it describes is published: everything below here
|
// only if the snapshot it describes is published: everything below here
|
||||||
// can still fail, and a status table describing a snapshot nobody
|
// can still fail, and a status table describing a snapshot nobody
|
||||||
@@ -911,24 +926,25 @@ pub const Manager = struct {
|
|||||||
defer self.gpa.free(outcomes);
|
defer self.gpa.free(outcomes);
|
||||||
|
|
||||||
var loaded: usize = 0;
|
var loaded: usize = 0;
|
||||||
for (rows.items, sources, source_ids, compiled, outcomes) |row, *source, *source_id, *slot, *outcome| {
|
for (rows.items, outcomes, 0..) |row, *outcome, index| {
|
||||||
source_id.* = row.id;
|
if (!row.enabled) {
|
||||||
// `is_suggested` is a UI hint the snapshot never reads.
|
outcome.* = .disabled;
|
||||||
source.* = .{ .url = row.url, .name = row.name, .enabled = row.enabled };
|
continue;
|
||||||
outcome.* = if (row.enabled) try self.loadSource(io, dir, row, &bodies) else .disabled;
|
}
|
||||||
switch (outcome.*) {
|
switch (try self.loadSource(io, dir, row, &bodies)) {
|
||||||
.loaded => |body| {
|
.loaded => |compiled| {
|
||||||
slot.* = body;
|
try builder.addSource(index, compiled);
|
||||||
|
outcome.* = .loaded;
|
||||||
loaded += 1;
|
loaded += 1;
|
||||||
},
|
},
|
||||||
// Not loadable and therefore not enforced. Saying so here is
|
// Not loadable and therefore not enforced.
|
||||||
// what keeps `Snapshot.build`'s `MissingCompiledSource` for the
|
.failed => |failure| {
|
||||||
// case it is meant for: a caller that forgot to read a body.
|
builder.skipSource(index);
|
||||||
.disabled, .failed => {
|
outcome.* = .{ .failed = failure };
|
||||||
slot.* = null;
|
|
||||||
source.enabled = false;
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
for (bodies.items) |body| self.gpa.free(body);
|
||||||
|
bodies.clearRetainingCapacity();
|
||||||
}
|
}
|
||||||
|
|
||||||
var groups = try groups_repo.listGroups(self.database, self.gpa);
|
var groups = try groups_repo.listGroups(self.database, self.gpa);
|
||||||
@@ -954,25 +970,16 @@ pub const Manager = struct {
|
|||||||
defer prefixes.deinit(self.gpa);
|
defer prefixes.deinit(self.gpa);
|
||||||
defer clients_repo.freeClientPrefixes(self.gpa, prefixes.items);
|
defer clients_repo.freeClientPrefixes(self.gpa, prefixes.items);
|
||||||
|
|
||||||
// Query names are attacker-supplied, so a fixed seed would make
|
|
||||||
// probe-chain flooding computable offline.
|
|
||||||
var seed_bytes: [8]u8 = undefined;
|
|
||||||
io.random(&seed_bytes);
|
|
||||||
|
|
||||||
const generation = self.generation + 1;
|
const generation = self.generation + 1;
|
||||||
const snapshot = try self.gpa.create(matcher.Snapshot);
|
const snapshot = try self.gpa.create(matcher.Snapshot);
|
||||||
errdefer self.gpa.destroy(snapshot);
|
errdefer self.gpa.destroy(snapshot);
|
||||||
snapshot.* = try matcher.Snapshot.build(self.gpa, .{
|
snapshot.* = try builder.finish(.{
|
||||||
.groups = groups.items,
|
.groups = groups.items,
|
||||||
.group_ids = group_ids,
|
.group_ids = group_ids,
|
||||||
.group_sources = group_sources.items,
|
.group_sources = group_sources.items,
|
||||||
.sources = sources,
|
|
||||||
.source_ids = source_ids,
|
|
||||||
.rules = rule_rows.items,
|
.rules = rule_rows.items,
|
||||||
.clients = clients.items,
|
.clients = clients.items,
|
||||||
.prefixes = prefixes.items,
|
.prefixes = prefixes.items,
|
||||||
.compiled = compiled,
|
|
||||||
.seed = std.mem.readInt(u64, &seed_bytes, .little),
|
|
||||||
.generation = generation,
|
.generation = generation,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1014,7 +1021,7 @@ pub const Manager = struct {
|
|||||||
dir: std.Io.Dir,
|
dir: std.Io.Dir,
|
||||||
row: sources_repo.SourceRow,
|
row: sources_repo.SourceRow,
|
||||||
bodies: *std.ArrayList([]u8),
|
bodies: *std.ArrayList([]u8),
|
||||||
) Error!LoadOutcome {
|
) Error!SourceLoad {
|
||||||
const stored = row.checksum orelse
|
const stored = row.checksum orelse
|
||||||
// No stored checksum means no successful compile has ever
|
// No stored checksum means no successful compile has ever
|
||||||
// happened. A fresh install is here on every source.
|
// happened. A fresh install is here on every source.
|
||||||
@@ -1091,15 +1098,14 @@ pub const Manager = struct {
|
|||||||
/// Downloads, compiles and atomically replaces the compiled files of one
|
/// Downloads, compiles and atomically replaces the compiled files of one
|
||||||
/// source, then writes its runtime columns.
|
/// source, then writes its runtime columns.
|
||||||
///
|
///
|
||||||
/// Returns `true` only when the compiled files were replaced. Unchanged
|
/// The reason for a failure is in the status table, not in the return
|
||||||
/// content (equal checksum) and every recorded failure return `false`; the
|
/// value.
|
||||||
/// reason for a failure is in the status table, not in the return value.
|
|
||||||
///
|
///
|
||||||
/// The status entry is found by row id, so a source added since the last
|
/// The status entry is found by row id, so a source added since the last
|
||||||
/// `reload` has nowhere to record its outcome. `refreshAll` syncs the table
|
/// `reload` has nowhere to record its outcome. `refreshAll` syncs the table
|
||||||
/// before it refreshes anything, which is why `POST /api/blocklists/update`
|
/// before it refreshes anything, which is why `POST /api/blocklists/update`
|
||||||
/// and the scheduler both enter through `refreshAll`.
|
/// and the scheduler both enter through `refreshAll`.
|
||||||
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
|
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!RefreshOutcome {
|
||||||
self.refresh_lock.lockUncancelable(io);
|
self.refresh_lock.lockUncancelable(io);
|
||||||
defer self.refresh_lock.unlock(io);
|
defer self.refresh_lock.unlock(io);
|
||||||
// Registered after the unlock, so it runs before it: a pass drains its
|
// Registered after the unlock, so it runs before it: a pass drains its
|
||||||
@@ -1112,7 +1118,7 @@ pub const Manager = struct {
|
|||||||
|
|
||||||
/// Assumes `refresh_lock`. Takes `writer_lock` itself, for the publish half
|
/// Assumes `refresh_lock`. Takes `writer_lock` itself, for the publish half
|
||||||
/// alone.
|
/// alone.
|
||||||
fn refreshSourceLocked(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
|
fn refreshSourceLocked(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!RefreshOutcome {
|
||||||
// The previous entry describes the compiled files that are still on
|
// The previous entry describes the compiled files that are still on
|
||||||
// disk, and a failed refresh leaves them serving. Starting from a blank
|
// disk, and a failed refresh leaves them serving. Starting from a blank
|
||||||
// status would erase `last_success` and the counters of a list that is
|
// status would erase `last_success` and the counters of a list that is
|
||||||
@@ -1156,9 +1162,9 @@ pub const Manager = struct {
|
|||||||
self.writer_lock.lockUncancelable(io);
|
self.writer_lock.lockUncancelable(io);
|
||||||
defer self.writer_lock.unlock(io);
|
defer self.writer_lock.unlock(io);
|
||||||
|
|
||||||
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, tmp);
|
const outcome = try self.publishRefresh(io, dir, row, &status, prepared, tmp);
|
||||||
self.commitStatus(io, status);
|
self.commitStatus(io, status);
|
||||||
return replaced;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every enabled source, one at a time, then one `reload`. A failing source
|
/// Every enabled source, one at a time, then one `reload`. A failing source
|
||||||
@@ -1260,7 +1266,6 @@ pub const Manager = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Installs what `prepareRefresh` produced and writes the runtime columns.
|
/// 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
|
/// Assumes `writer_lock`. Reading the files on disk belongs here and not in
|
||||||
/// the half above: they are published under this lock.
|
/// the half above: they are published under this lock.
|
||||||
@@ -1272,9 +1277,9 @@ pub const Manager = struct {
|
|||||||
status: *SourceStatus,
|
status: *SourceStatus,
|
||||||
prepared: Prepared,
|
prepared: Prepared,
|
||||||
tmp: TempNames,
|
tmp: TempNames,
|
||||||
) Error!bool {
|
) Error!RefreshOutcome {
|
||||||
const compiled = switch (prepared) {
|
const compiled = switch (prepared) {
|
||||||
.failed => return false,
|
.failed => return .failed,
|
||||||
.compiled => |value| value,
|
.compiled => |value| value,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1308,7 +1313,7 @@ pub const Manager = struct {
|
|||||||
.checksum = stored,
|
.checksum = stored,
|
||||||
});
|
});
|
||||||
status.succeed(now, compiled.result.counts);
|
status.succeed(now, compiled.result.counts);
|
||||||
return false;
|
return .unchanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1324,7 +1329,7 @@ pub const Manager = struct {
|
|||||||
error.Canceled => return error.Canceled,
|
error.Canceled => return error.Canceled,
|
||||||
else => {
|
else => {
|
||||||
self.reportCompileFailure(row, status, err);
|
self.reportCompileFailure(row, status, err);
|
||||||
return false;
|
return .failed;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1338,7 +1343,7 @@ pub const Manager = struct {
|
|||||||
.checksum = &compiled.result.checksum,
|
.checksum = &compiled.result.checksum,
|
||||||
});
|
});
|
||||||
status.succeed(now, compiled.result.counts);
|
status.succeed(now, compiled.result.counts);
|
||||||
return true;
|
return .replaced;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The body goes to a temporary file, never to memory: `max_body_bytes` is
|
/// The body goes to a temporary file, never to memory: `max_body_bytes` is
|
||||||
@@ -1817,10 +1822,13 @@ pub const Manager = struct {
|
|||||||
// `refreshSource`.
|
// `refreshSource`.
|
||||||
defer self.flushDiagnostics(io);
|
defer self.flushDiagnostics(io);
|
||||||
|
|
||||||
// Ahead of the gate on purpose: loading the compiled files that already
|
// Boot already loaded the compiled files before the listeners started,
|
||||||
// exist is a read. A full disk must not cost the household its
|
// and its status table is what the refresh decisions below read. Only
|
||||||
// filtering as well as its downloads.
|
// a boot load that published nothing is retried here, ahead of the
|
||||||
try self.reloadCollecting(io);
|
// gate on purpose: loading the files that already exist is a read, and
|
||||||
|
// a full disk must not cost the household its filtering as well as its
|
||||||
|
// downloads.
|
||||||
|
if (!self.published(io)) try self.reloadCollecting(io);
|
||||||
|
|
||||||
if (self.refreshGated()) return;
|
if (self.refreshGated()) return;
|
||||||
|
|
||||||
@@ -1829,13 +1837,36 @@ pub const Manager = struct {
|
|||||||
defer sources_repo.freeSourceRows(self.gpa, rows.items);
|
defer sources_repo.freeSourceRows(self.gpa, rows.items);
|
||||||
|
|
||||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||||
var refreshed = false;
|
var stale = false;
|
||||||
for (rows.items) |row| {
|
for (rows.items) |row| {
|
||||||
if (!row.enabled) continue;
|
if (!row.enabled) continue;
|
||||||
if (!self.needsRefresh(io, row, now)) continue;
|
if (!self.needsRefresh(io, row, now)) continue;
|
||||||
if (try self.refreshSourceLocked(io, row)) refreshed = true;
|
switch (try self.refreshSourceLocked(io, row)) {
|
||||||
|
.replaced => stale = true,
|
||||||
|
// The files on disk match their checksum, yet the published
|
||||||
|
// snapshot may still lack them: a boot load that could not read
|
||||||
|
// them left the source out, and nothing else would add it back
|
||||||
|
// before the next scheduled pass.
|
||||||
|
.unchanged => if (!self.enforced(io, row.id)) {
|
||||||
|
stale = true;
|
||||||
|
},
|
||||||
|
.failed => {},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (refreshed) try self.reloadCollecting(io);
|
if (stale) try self.reloadCollecting(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforced(self: *Manager, io: std.Io, id: i64) bool {
|
||||||
|
self.lock.lockSharedUncancelable(io);
|
||||||
|
defer self.lock.unlockShared(io);
|
||||||
|
const entry = entryFor(self.statuses, id) orelse return false;
|
||||||
|
return entry.loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn published(self: *Manager, io: std.Io) bool {
|
||||||
|
self.lock.lockSharedUncancelable(io);
|
||||||
|
defer self.lock.unlockShared(io);
|
||||||
|
return self.generation != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needsRefresh(self: *Manager, io: std.Io, row: sources_repo.SourceRow, now: i64) bool {
|
fn needsRefresh(self: *Manager, io: std.Io, row: sources_repo.SourceRow, now: i64) bool {
|
||||||
@@ -2146,12 +2177,32 @@ const LoadOutcome = union(enum) {
|
|||||||
/// The source is switched off. Not in the snapshot, whatever it was before.
|
/// The source is switched off. Not in the snapshot, whatever it was before.
|
||||||
disabled,
|
disabled,
|
||||||
/// Its compiled files are in the snapshot.
|
/// Its compiled files are in the snapshot.
|
||||||
loaded: matcher.Snapshot.Compiled,
|
loaded,
|
||||||
/// It is not in the snapshot, for this reason.
|
/// It is not in the snapshot, for this reason.
|
||||||
failed: struct { state: State, text: []const u8 },
|
failed: LoadFailure,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn loadFailure(row: sources_repo.SourceRow, file_name: []const u8, err: anyerror) LoadOutcome {
|
const LoadFailure = struct { state: State, text: []const u8 };
|
||||||
|
|
||||||
|
/// What one source's refresh did to its compiled files.
|
||||||
|
pub const RefreshOutcome = enum {
|
||||||
|
/// Recorded in the status table; the previous files, if any, still serve.
|
||||||
|
failed,
|
||||||
|
/// The download compiled to the files already on disk, which were left
|
||||||
|
/// alone.
|
||||||
|
unchanged,
|
||||||
|
/// New compiled files were installed.
|
||||||
|
replaced,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What reading one enabled source's compiled files produced. The bodies
|
||||||
|
/// borrow the buffers `loadSource` appended to its caller's list.
|
||||||
|
const SourceLoad = union(enum) {
|
||||||
|
loaded: matcher.Snapshot.Compiled,
|
||||||
|
failed: LoadFailure,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn loadFailure(row: sources_repo.SourceRow, file_name: []const u8, err: anyerror) SourceLoad {
|
||||||
log.warn("blocklist {f}: reading {s} failed: {s}", .{ SourceLabel.of(row), file_name, @errorName(err) });
|
log.warn("blocklist {f}: reading {s} failed: {s}", .{ SourceLabel.of(row), file_name, @errorName(err) });
|
||||||
return .{ .failed = .{ .state = .load_failed, .text = @errorName(err) } };
|
return .{ .failed = .{ .state = .load_failed, .text = @errorName(err) } };
|
||||||
}
|
}
|
||||||
@@ -2608,6 +2659,184 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes one source's compiled files and the checksum and stamp a refresh
|
||||||
|
/// would have recorded, so a reload loads it.
|
||||||
|
fn installCompiledSource(
|
||||||
|
io: std.Io,
|
||||||
|
database: *db.Db,
|
||||||
|
dir: std.Io.Dir,
|
||||||
|
url: []const u8,
|
||||||
|
list_body: []const u8,
|
||||||
|
wild_body: []const u8,
|
||||||
|
last_updated: i64,
|
||||||
|
) !void {
|
||||||
|
try sources_repo.insertBlocklistSource(database, .{ .url = url, .name = url }, .{});
|
||||||
|
var rows = try sources_repo.listSourceRows(database, testing.allocator);
|
||||||
|
defer rows.deinit(testing.allocator);
|
||||||
|
defer sources_repo.freeSourceRows(testing.allocator, rows.items);
|
||||||
|
const id = for (rows.items) |row| {
|
||||||
|
if (std.mem.eql(u8, row.url, url)) break row.id;
|
||||||
|
} else unreachable;
|
||||||
|
|
||||||
|
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 = last_updated,
|
||||||
|
.domain_count = @intCast(std.mem.count(u8, list_body, "\n")),
|
||||||
|
.wildcard_count = @intCast(std.mem.count(u8, wild_body, "\n")),
|
||||||
|
.skipped_regex_count = 0,
|
||||||
|
.skipped_unsupported_count = 0,
|
||||||
|
.exception_count = 0,
|
||||||
|
.checksum = &bodyChecksum(list_body, wild_body, ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards to `child` and records the most bytes it held at once.
|
||||||
|
const PeakAllocator = struct {
|
||||||
|
child: Allocator,
|
||||||
|
live: usize = 0,
|
||||||
|
peak: usize = 0,
|
||||||
|
|
||||||
|
fn allocator(self: *PeakAllocator) Allocator {
|
||||||
|
return .{ .ptr = self, .vtable = &.{
|
||||||
|
.alloc = alloc,
|
||||||
|
.resize = resize,
|
||||||
|
.remap = remap,
|
||||||
|
.free = free,
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn held(self: *PeakAllocator, old_len: usize, new_len: usize) void {
|
||||||
|
self.live = self.live - old_len + new_len;
|
||||||
|
self.peak = @max(self.peak, self.live);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 {
|
||||||
|
const self: *PeakAllocator = @ptrCast(@alignCast(ctx));
|
||||||
|
const ptr = self.child.rawAlloc(len, alignment, ret_addr) orelse return null;
|
||||||
|
self.held(0, len);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) bool {
|
||||||
|
const self: *PeakAllocator = @ptrCast(@alignCast(ctx));
|
||||||
|
if (!self.child.rawResize(memory, alignment, new_len, ret_addr)) return false;
|
||||||
|
self.held(memory.len, new_len);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {
|
||||||
|
const self: *PeakAllocator = @ptrCast(@alignCast(ctx));
|
||||||
|
const ptr = self.child.rawRemap(memory, alignment, new_len, ret_addr) orelse return null;
|
||||||
|
self.held(memory.len, new_len);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret_addr: usize) void {
|
||||||
|
const self: *PeakAllocator = @ptrCast(@alignCast(ctx));
|
||||||
|
self.child.rawFree(memory, alignment, ret_addr);
|
||||||
|
self.held(memory.len, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test "a reload holds one source's bodies at a time, not every source's" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var tmp = testing.tmpDir(.{});
|
||||||
|
defer tmp.cleanup();
|
||||||
|
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
|
||||||
|
defer dir.close(io);
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
// As an ABP list compiles: the same names in `.list` and `.wild`.
|
||||||
|
var body: std.ArrayList(u8) = .empty;
|
||||||
|
defer body.deinit(testing.allocator);
|
||||||
|
for (0..4_000) |i| try body.print(testing.allocator, "n{d:0>5}.example.com\n", .{i});
|
||||||
|
|
||||||
|
const source_count = 6;
|
||||||
|
for (0..source_count) |i| {
|
||||||
|
var url_buf: [64]u8 = undefined;
|
||||||
|
const url = try std.fmt.bufPrint(&url_buf, "https://lists.example/{d}.txt", .{i});
|
||||||
|
try installCompiledSource(io, &database, dir, url, body.items, body.items, 1_700_000_000);
|
||||||
|
}
|
||||||
|
const one_source_bytes = 2 * body.items.len;
|
||||||
|
|
||||||
|
var peak: PeakAllocator = .{ .child = testing.allocator };
|
||||||
|
var f: fetcher.Fetcher = undefined;
|
||||||
|
var mgr: Manager = try .init(peak.allocator(), &database, .{ .dir = tmp.dir }, &f, .{}, .{ .raw = .fromSeconds(30), .clock = .awake });
|
||||||
|
defer mgr.deinit(io);
|
||||||
|
|
||||||
|
try mgr.reload(io);
|
||||||
|
try testing.expectEqual(@as(u64, 1), mgr.generation);
|
||||||
|
|
||||||
|
// What stays live is the snapshot and the status table. Everything above
|
||||||
|
// that was transient, and every source's bodies at once would be six
|
||||||
|
// times `one_source_bytes`. A file read may grow its buffer by copying,
|
||||||
|
// which holds one body twice for a moment, hence two sources' worth.
|
||||||
|
const transient = peak.peak - peak.live;
|
||||||
|
try testing.expect(transient < 2 * one_source_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "the startup pass reuses the boot load when no source is due" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var tmp = testing.tmpDir(.{});
|
||||||
|
defer tmp.cleanup();
|
||||||
|
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
|
||||||
|
defer dir.close(io);
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||||
|
try installCompiledSource(io, &database, dir, "https://lists.example/a.txt", "a.example\n", "", now);
|
||||||
|
|
||||||
|
// Never called: a source refreshed `now` is not due.
|
||||||
|
var f: fetcher.Fetcher = undefined;
|
||||||
|
var mgr = try testManager(&database, &f);
|
||||||
|
defer mgr.deinit(io);
|
||||||
|
mgr.paths = .{ .dir = tmp.dir };
|
||||||
|
|
||||||
|
try mgr.reload(io);
|
||||||
|
try mgr.startupPass(io);
|
||||||
|
try testing.expectEqual(@as(u64, 1), mgr.generation);
|
||||||
|
|
||||||
|
const handle = mgr.acquire(io).?;
|
||||||
|
defer handle.release(io);
|
||||||
|
try testing.expectEqual(@as(usize, 1), handle.snapshot.sources.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "the startup pass loads the snapshot a failed boot load left unpublished" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var tmp = testing.tmpDir(.{});
|
||||||
|
defer tmp.cleanup();
|
||||||
|
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
|
||||||
|
defer dir.close(io);
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||||
|
try installCompiledSource(io, &database, dir, "https://lists.example/a.txt", "a.example\n", "", now);
|
||||||
|
|
||||||
|
var f: fetcher.Fetcher = undefined;
|
||||||
|
var mgr = try testManager(&database, &f);
|
||||||
|
defer mgr.deinit(io);
|
||||||
|
mgr.paths = .{ .dir = tmp.dir };
|
||||||
|
|
||||||
|
try mgr.startupPass(io);
|
||||||
|
try testing.expectEqual(@as(u64, 1), mgr.generation);
|
||||||
|
}
|
||||||
|
|
||||||
test "committing a status for an id the table has no entry for is not silent" {
|
test "committing a status for an id the table has no entry for is not silent" {
|
||||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
defer threaded.deinit();
|
defer threaded.deinit();
|
||||||
@@ -3123,8 +3352,7 @@ test "a source that failed to refresh keeps its failure while its old files serv
|
|||||||
statuses[0].fail(.fetch_failed, "HttpStatus");
|
statuses[0].fail(.fetch_failed, "HttpStatus");
|
||||||
|
|
||||||
const rows = [_]sources_repo.SourceRow{testRow(1, true)};
|
const rows = [_]sources_repo.SourceRow{testRow(1, true)};
|
||||||
const body: matcher.Snapshot.Compiled = .{ .list_body = "", .wild_body = "" };
|
applyLoadOutcomes(&statuses, &rows, &.{.loaded});
|
||||||
applyLoadOutcomes(&statuses, &rows, &.{.{ .loaded = body }});
|
|
||||||
|
|
||||||
try testing.expect(statuses[0].loaded);
|
try testing.expect(statuses[0].loaded);
|
||||||
try testing.expectEqual(State.fetch_failed, statuses[0].state);
|
try testing.expectEqual(State.fetch_failed, statuses[0].state);
|
||||||
@@ -3157,8 +3385,7 @@ test "a load failure is recorded when no refresh failure explains it" {
|
|||||||
test "a load of a source this process never refreshed takes the row counters" {
|
test "a load of a source this process never refreshed takes the row counters" {
|
||||||
var statuses = [_]SourceStatus{.{ .id = 1 }};
|
var statuses = [_]SourceStatus{.{ .id = 1 }};
|
||||||
const rows = [_]sources_repo.SourceRow{testRow(1, true)};
|
const rows = [_]sources_repo.SourceRow{testRow(1, true)};
|
||||||
const body: matcher.Snapshot.Compiled = .{ .list_body = "", .wild_body = "" };
|
applyLoadOutcomes(&statuses, &rows, &.{.loaded});
|
||||||
applyLoadOutcomes(&statuses, &rows, &.{.{ .loaded = body }});
|
|
||||||
|
|
||||||
try testing.expect(statuses[0].loaded);
|
try testing.expect(statuses[0].loaded);
|
||||||
try testing.expectEqual(State.ok, statuses[0].state);
|
try testing.expectEqual(State.ok, statuses[0].state);
|
||||||
|
|||||||
+221
-115
@@ -183,113 +183,233 @@ pub const Snapshot = struct {
|
|||||||
BadClientPrefix,
|
BadClientPrefix,
|
||||||
} || rules.Error;
|
} || rules.Error;
|
||||||
|
|
||||||
/// Builds an immutable snapshot. Every string is copied into the arena, so
|
/// Builds an immutable snapshot from bodies the caller already holds, every
|
||||||
/// the caller may free the repository lists immediately afterwards.
|
/// one at once. A caller reading bodies from disk uses `Builder` instead,
|
||||||
/// Disabled sources are skipped entirely — they cost no memory.
|
/// so it can free each source's bodies before it reads the next.
|
||||||
pub fn build(gpa: Allocator, input: Input) Error!Snapshot {
|
pub fn build(gpa: Allocator, input: Input) Error!Snapshot {
|
||||||
std.debug.assert(input.group_ids.len == input.groups.len);
|
var builder: Builder = try .init(gpa, input.sources, input.source_ids, input.seed);
|
||||||
std.debug.assert(input.source_ids.len == input.sources.len);
|
defer builder.deinit();
|
||||||
|
for (input.sources, 0..) |row, i| {
|
||||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
if (!row.enabled) continue;
|
||||||
errdefer arena_state.deinit();
|
|
||||||
const arena = arena_state.allocator();
|
|
||||||
|
|
||||||
// `position[i]` is where `input.sources[i]` landed in `sources`, or
|
|
||||||
// null when the source is disabled and therefore not loaded.
|
|
||||||
const position = try arena.alloc(?u32, input.sources.len);
|
|
||||||
var enabled: u32 = 0;
|
|
||||||
for (input.sources, position) |row, *slot| {
|
|
||||||
if (!row.enabled) {
|
|
||||||
slot.* = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
slot.* = enabled;
|
|
||||||
enabled += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sources = try arena.alloc(SourceSets, enabled);
|
|
||||||
for (input.sources, input.source_ids, position, 0..) |row, id, slot, i| {
|
|
||||||
const at = slot orelse continue;
|
|
||||||
if (i >= input.compiled.len) return error.MissingCompiledSource;
|
if (i >= input.compiled.len) return error.MissingCompiledSource;
|
||||||
const bodies = input.compiled[i] orelse return error.MissingCompiledSource;
|
try builder.addSource(i, input.compiled[i] orelse return error.MissingCompiledSource);
|
||||||
sources[at] = .{
|
|
||||||
.id = id,
|
|
||||||
.name = try arena.dupe(u8, row.name),
|
|
||||||
.names = try domain_set.DomainSet.buildMerged(arena, bodies.list_body, bodies.wild_body, input.seed),
|
|
||||||
.exceptions = try domain_set.DomainSet.build(arena, bodies.allow_body, input.seed),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return builder.finish(.{
|
||||||
const groups = try arena.alloc(Group, input.groups.len);
|
.groups = input.groups,
|
||||||
var group_rules: std.ArrayList(model.Rule) = .empty;
|
.group_ids = input.group_ids,
|
||||||
defer group_rules.deinit(gpa);
|
.group_sources = input.group_sources,
|
||||||
var group_sources: std.ArrayList(u32) = .empty;
|
.rules = input.rules,
|
||||||
defer group_sources.deinit(gpa);
|
.clients = input.clients,
|
||||||
|
.prefixes = input.prefixes,
|
||||||
for (groups, input.groups, input.group_ids) |*group, row, id| {
|
|
||||||
group_rules.clearRetainingCapacity();
|
|
||||||
for (input.rules) |rule_row| {
|
|
||||||
if (std.mem.eql(u8, rule_row.group, row.name)) try group_rules.append(gpa, rule_row);
|
|
||||||
}
|
|
||||||
|
|
||||||
group_sources.clearRetainingCapacity();
|
|
||||||
for (input.group_sources) |link| {
|
|
||||||
if (!std.mem.eql(u8, link.group, row.name)) continue;
|
|
||||||
const at = try findSource(input.sources, position, link.source_url);
|
|
||||||
// A link to a disabled source is not an error: the source
|
|
||||||
// exists, it is simply not loaded.
|
|
||||||
if (at) |index| try group_sources.append(gpa, index);
|
|
||||||
}
|
|
||||||
std.mem.sort(u32, group_sources.items, {}, std.sort.asc(u32));
|
|
||||||
|
|
||||||
group.* = .{
|
|
||||||
.id = id,
|
|
||||||
.name = try arena.dupe(u8, row.name),
|
|
||||||
.safe_search = row.safe_search,
|
|
||||||
// `arena` retains, `gpa` scratches: an arena reclaims only its
|
|
||||||
// most recent allocation, so a rule build's temporaries taken
|
|
||||||
// from it would outlive the build and go unreported by
|
|
||||||
// `memoryBytes`.
|
|
||||||
.rules = try rules.RuleSet.build(arena, gpa, group_rules.items, input.seed),
|
|
||||||
.sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every `group_sources` row must name a group that exists, whether or
|
|
||||||
// not that group also owns rules.
|
|
||||||
for (input.group_sources) |link| {
|
|
||||||
if (indexOfGroup(groups, link.group) == null) return error.UnknownGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clients = try arena.alloc(ClientEntry, input.clients.len);
|
|
||||||
for (clients, input.clients) |*entry, row| {
|
|
||||||
const addr = address.NetAddress.parse(row.ip) catch return error.BadClientIp;
|
|
||||||
entry.* = .{
|
|
||||||
.key = addr.key(),
|
|
||||||
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefixes = try arena.alloc(PrefixEntry, input.prefixes.len);
|
|
||||||
for (prefixes, input.prefixes) |*entry, row| {
|
|
||||||
entry.* = .{
|
|
||||||
.prefix = address.Prefix.parse(row.prefix) catch return error.BadClientPrefix,
|
|
||||||
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
|
|
||||||
.priority = row.priority,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return .{
|
|
||||||
.arena = arena_state,
|
|
||||||
.groups = groups,
|
|
||||||
.sources = sources,
|
|
||||||
.clients = clients,
|
|
||||||
.prefixes = prefixes,
|
|
||||||
.default_group = indexOfGroup(groups, "default") orelse return error.MissingDefaultGroup,
|
|
||||||
.generation = input.generation,
|
.generation = input.generation,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything but the sources: what `Builder.finish` assigns them to.
|
||||||
|
pub const Assignments = struct {
|
||||||
|
groups: []const model.Group,
|
||||||
|
/// One `groups` row id per `groups[i]`, in the same order.
|
||||||
|
group_ids: []const i64,
|
||||||
|
group_sources: []const model.GroupSource,
|
||||||
|
rules: []const model.Rule,
|
||||||
|
clients: []const model.Client,
|
||||||
|
prefixes: []const model.ClientPrefix,
|
||||||
|
generation: u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A snapshot built one source at a time. Each `addSource` copies what the
|
||||||
|
/// snapshot keeps into its arena, so the caller may free that source's
|
||||||
|
/// bodies as soon as the call returns: a reload then holds at most one
|
||||||
|
/// source's bodies beside the snapshot it is building.
|
||||||
|
///
|
||||||
|
/// Every enabled source row is either added or skipped before `finish`. A
|
||||||
|
/// skipped source is not enforced and costs no memory; an enabled one left
|
||||||
|
/// pending is `error.MissingCompiledSource`, because a silently unenforced
|
||||||
|
/// blocklist is exactly the failure PLAN §1.3 exists to prevent.
|
||||||
|
///
|
||||||
|
/// `deinit` is always safe to call, before or after `finish`.
|
||||||
|
pub const Builder = struct {
|
||||||
|
gpa: Allocator,
|
||||||
|
/// Null once `finish` has handed the arena to the snapshot.
|
||||||
|
arena_state: ?std.heap.ArenaAllocator,
|
||||||
|
rows: []const model.BlocklistSource,
|
||||||
|
row_ids: []const i64,
|
||||||
|
/// One per `rows[i]`.
|
||||||
|
states: []RowState,
|
||||||
|
/// Capacity: the enabled rows. Length: the rows added so far.
|
||||||
|
sources: []SourceSets,
|
||||||
|
added: u32,
|
||||||
|
/// The lowest row `addSource` still accepts.
|
||||||
|
next_row: usize,
|
||||||
|
seed: u64,
|
||||||
|
|
||||||
|
const RowState = union(enum) {
|
||||||
|
pending,
|
||||||
|
skipped,
|
||||||
|
/// Index into `sources`.
|
||||||
|
added: u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `rows` and `row_ids` are borrowed until `finish`. Disabled rows are
|
||||||
|
/// skipped here.
|
||||||
|
pub fn init(gpa: Allocator, rows: []const model.BlocklistSource, row_ids: []const i64, seed: u64) Error!Builder {
|
||||||
|
std.debug.assert(row_ids.len == rows.len);
|
||||||
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||||
|
errdefer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
|
const states = try arena.alloc(RowState, rows.len);
|
||||||
|
var enabled: usize = 0;
|
||||||
|
for (rows, states) |row, *state| {
|
||||||
|
state.* = if (row.enabled) .pending else .skipped;
|
||||||
|
if (row.enabled) enabled += 1;
|
||||||
|
}
|
||||||
|
const sources = try arena.alloc(SourceSets, enabled);
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.gpa = gpa,
|
||||||
|
.arena_state = arena_state,
|
||||||
|
.rows = rows,
|
||||||
|
.row_ids = row_ids,
|
||||||
|
.states = states,
|
||||||
|
.sources = sources,
|
||||||
|
.added = 0,
|
||||||
|
.next_row = 0,
|
||||||
|
.seed = seed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *Builder) void {
|
||||||
|
if (self.arena_state) |*arena| arena.deinit();
|
||||||
|
self.arena_state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds `rows[row]`'s sets from `bodies`. Nothing of `bodies` is
|
||||||
|
/// borrowed once this returns.
|
||||||
|
///
|
||||||
|
/// Rows are added in ascending order. A source's index in the snapshot
|
||||||
|
/// is its position among the added rows, and that index decides which
|
||||||
|
/// of several overlapping sources reports a match.
|
||||||
|
pub fn addSource(self: *Builder, row: usize, bodies: Compiled) Error!void {
|
||||||
|
std.debug.assert(self.states[row] == .pending);
|
||||||
|
std.debug.assert(row >= self.next_row);
|
||||||
|
const arena = self.arena_state.?.allocator();
|
||||||
|
self.sources[self.added] = .{
|
||||||
|
.id = self.row_ids[row],
|
||||||
|
.name = try arena.dupe(u8, self.rows[row].name),
|
||||||
|
.names = try domain_set.DomainSet.buildMerged(arena, bodies.list_body, bodies.wild_body, self.seed),
|
||||||
|
.exceptions = try domain_set.DomainSet.build(arena, bodies.allow_body, self.seed),
|
||||||
|
};
|
||||||
|
self.states[row] = .{ .added = self.added };
|
||||||
|
self.added += 1;
|
||||||
|
self.next_row = row + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An enabled source whose bodies could not be loaded. It is not
|
||||||
|
/// enforced; the caller reports why.
|
||||||
|
pub fn skipSource(self: *Builder, row: usize) void {
|
||||||
|
std.debug.assert(self.states[row] == .pending);
|
||||||
|
self.states[row] = .skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(self: *Builder, assignments: Assignments) Error!Snapshot {
|
||||||
|
std.debug.assert(assignments.group_ids.len == assignments.groups.len);
|
||||||
|
for (self.states) |state| {
|
||||||
|
if (state == .pending) return error.MissingCompiledSource;
|
||||||
|
}
|
||||||
|
const gpa = self.gpa;
|
||||||
|
const arena = self.arena_state.?.allocator();
|
||||||
|
const sources = self.sources[0..self.added];
|
||||||
|
|
||||||
|
const groups = try arena.alloc(Group, assignments.groups.len);
|
||||||
|
var group_rules: std.ArrayList(model.Rule) = .empty;
|
||||||
|
defer group_rules.deinit(gpa);
|
||||||
|
var group_sources: std.ArrayList(u32) = .empty;
|
||||||
|
defer group_sources.deinit(gpa);
|
||||||
|
|
||||||
|
for (groups, assignments.groups, assignments.group_ids) |*group, row, id| {
|
||||||
|
group_rules.clearRetainingCapacity();
|
||||||
|
for (assignments.rules) |rule_row| {
|
||||||
|
if (std.mem.eql(u8, rule_row.group, row.name)) try group_rules.append(gpa, rule_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
group_sources.clearRetainingCapacity();
|
||||||
|
for (assignments.group_sources) |link| {
|
||||||
|
if (!std.mem.eql(u8, link.group, row.name)) continue;
|
||||||
|
const at = try self.findSource(link.source_url);
|
||||||
|
// A link to a disabled or unloaded source is not an error:
|
||||||
|
// the source exists, it is simply not enforced.
|
||||||
|
if (at) |index| try group_sources.append(gpa, index);
|
||||||
|
}
|
||||||
|
std.mem.sort(u32, group_sources.items, {}, std.sort.asc(u32));
|
||||||
|
|
||||||
|
group.* = .{
|
||||||
|
.id = id,
|
||||||
|
.name = try arena.dupe(u8, row.name),
|
||||||
|
.safe_search = row.safe_search,
|
||||||
|
// `arena` retains, `gpa` scratches: an arena reclaims only
|
||||||
|
// its most recent allocation, so a rule build's temporaries
|
||||||
|
// taken from it would outlive the build and go unreported
|
||||||
|
// by `memoryBytes`.
|
||||||
|
.rules = try rules.RuleSet.build(arena, gpa, group_rules.items, self.seed),
|
||||||
|
.sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every `group_sources` row must name a group that exists, whether
|
||||||
|
// or not that group also owns rules.
|
||||||
|
for (assignments.group_sources) |link| {
|
||||||
|
if (indexOfGroup(groups, link.group) == null) return error.UnknownGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = try arena.alloc(ClientEntry, assignments.clients.len);
|
||||||
|
for (clients, assignments.clients) |*entry, row| {
|
||||||
|
const addr = address.NetAddress.parse(row.ip) catch return error.BadClientIp;
|
||||||
|
entry.* = .{
|
||||||
|
.key = addr.key(),
|
||||||
|
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixes = try arena.alloc(PrefixEntry, assignments.prefixes.len);
|
||||||
|
for (prefixes, assignments.prefixes) |*entry, row| {
|
||||||
|
entry.* = .{
|
||||||
|
.prefix = address.Prefix.parse(row.prefix) catch return error.BadClientPrefix,
|
||||||
|
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
|
||||||
|
.priority = row.priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const default_group = indexOfGroup(groups, "default") orelse return error.MissingDefaultGroup;
|
||||||
|
const snapshot: Snapshot = .{
|
||||||
|
.arena = self.arena_state.?,
|
||||||
|
.groups = groups,
|
||||||
|
.sources = sources,
|
||||||
|
.clients = clients,
|
||||||
|
.prefixes = prefixes,
|
||||||
|
.default_group = default_group,
|
||||||
|
.generation = assignments.generation,
|
||||||
|
};
|
||||||
|
self.arena_state = null;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The position in `sources`, or null when the named source exists but
|
||||||
|
/// is not enforced. A URL naming no configured source is an error: the
|
||||||
|
/// group would silently enforce one list fewer than the operator
|
||||||
|
/// configured.
|
||||||
|
fn findSource(self: *const Builder, url: []const u8) error{UnknownSource}!?u32 {
|
||||||
|
for (self.rows, self.states) |row, state| {
|
||||||
|
if (!std.mem.eql(u8, row.url, url)) continue;
|
||||||
|
return switch (state) {
|
||||||
|
.added => |index| index,
|
||||||
|
.pending, .skipped => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return error.UnknownSource;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
pub fn deinit(self: *Snapshot) void {
|
pub fn deinit(self: *Snapshot) void {
|
||||||
self.arena.deinit();
|
self.arena.deinit();
|
||||||
self.* = undefined;
|
self.* = undefined;
|
||||||
@@ -472,20 +592,6 @@ fn indexOfGroup(groups: []const Group, group_name: []const u8) ?u32 {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The position in the loaded `sources` array, or null when the named source
|
|
||||||
/// exists but is disabled. A URL naming no configured source is an error: the
|
|
||||||
/// group would silently enforce one list fewer than the operator configured.
|
|
||||||
fn findSource(
|
|
||||||
rows: []const model.BlocklistSource,
|
|
||||||
position: []const ?u32,
|
|
||||||
url: []const u8,
|
|
||||||
) error{UnknownSource}!?u32 {
|
|
||||||
for (rows, position) |row, slot| {
|
|
||||||
if (std.mem.eql(u8, row.url, url)) return slot;
|
|
||||||
}
|
|
||||||
return error.UnknownSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dedupSorted(items: []u32) []const u32 {
|
fn dedupSorted(items: []u32) []const u32 {
|
||||||
var kept: usize = 0;
|
var kept: usize = 0;
|
||||||
for (items) |item| {
|
for (items) |item| {
|
||||||
|
|||||||
Reference in New Issue
Block a user