Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd8214ef2
|
||
|
|
64c0d723a6
|
@@ -6,6 +6,11 @@ Sections are written by hand. Nothing here is generated from commit messages: th
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **An upstream success rate no longer rounds up to 100.0% while failures stand.** One decimal place cannot hold 12,696 successes out of 12,698 attempts: it rounded to `100.0%`, so the row claimed perfect reliability next to a failure count of 2. Neither end of the scale is reachable by rounding any more — `100.0%` needs an actual absence of failures and `0.0%` an actual absence of successes, and a rate a hair off either end shows `99.9%` or `0.1%` instead.
|
||||||
|
- **A query log set aside by a schema change is no longer named `corrupt`.** Every recreate wrote the old file to `querylog.db.corrupt-<unix seconds>`, whatever sent it there — including the fingerprint mismatch an upgrade causes, where the file is a healthy database this build simply cannot read. The name is the only account of the reason that outlives the log line, so it read as an accusation and invited operators to delete an intact file. The name now says which of the four cases it hit: `querylog.db.corrupt-…`, `.not-a-database-…`, `.quick-check-failed-…` or `.schema-changed-…`. The 0.0.6 upgrade produces `schema-changed`. Nothing else about the recreate changed, and no existing aside file is renamed.
|
||||||
|
|
||||||
## [0.0.6] - 2026-08-17
|
## [0.0.6] - 2026-08-17
|
||||||
|
|
||||||
The period picker now scopes the whole dashboard. The upstream table was the last widget that ignored it, and fixing that meant recording upstream outcomes over time instead of counting them since boot. Read the query-log note below before you upgrade.
|
The period picker now scopes the whole dashboard. The upstream table was the last widget that ignored it, and fixing that meant recording upstream outcomes over time instead of counting them since boot. Read the query-log note below before you upgrade.
|
||||||
|
|||||||
@@ -149,3 +149,32 @@ test("an empty pool says so instead of drawing a table", () => {
|
|||||||
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
|
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
|
||||||
expect(screen.queryByRole("table")).toBeNull();
|
expect(screen.queryByRole("table")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a rate a hair under perfect never rounds up to 100.0% while failures stand", () => {
|
||||||
|
// The real row that produced this: 12,698 attempts, 2 failures, 99.984%.
|
||||||
|
renderTable([
|
||||||
|
entry({
|
||||||
|
period: period({ attempts: 12_698, successes: 12_696, failures: 2, success_rate: 12_696 / 12_698 }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(screen.queryByText("100.0%")).toBeNull();
|
||||||
|
expect(screen.getByText("99.9%")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rate a hair above nothing never rounds down to 0.0% while successes stand", () => {
|
||||||
|
renderTable([
|
||||||
|
entry({
|
||||||
|
period: period({ attempts: 12_698, successes: 2, failures: 12_696, success_rate: 2 / 12_698 }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(screen.queryByText("0.0%")).toBeNull();
|
||||||
|
expect(screen.getByText("0.1%")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a window with no failures at all still reads 100.0%", () => {
|
||||||
|
renderTable([entry({ period: period({ attempts: 500, successes: 500, failures: 0, success_rate: 1 }) })]);
|
||||||
|
|
||||||
|
expect(screen.getByText("100.0%")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,9 +122,18 @@ function statusNow(upstream: UpstreamHealthEntry): "Available" | "Backing off" |
|
|||||||
/**
|
/**
|
||||||
* `success_rate` is null exactly when the window holds no attempt, and that must
|
* `success_rate` is null exactly when the window holds no attempt, and that must
|
||||||
* not read as perfect reliability — hence the em-dash rather than `100.0%`.
|
* not read as perfect reliability — hence the em-dash rather than `100.0%`.
|
||||||
|
*
|
||||||
|
* One decimal place cannot hold 12,696 of 12,698: it rounds to `100.0%`, and the
|
||||||
|
* row then claims perfection beside a failure count of 2. Neither endpoint may
|
||||||
|
* be reached by rounding — only by actually having no failure, or no success.
|
||||||
*/
|
*/
|
||||||
function successRate(period: UpstreamPeriodStats): string {
|
function successRate(period: UpstreamPeriodStats): string {
|
||||||
return period.success_rate === null ? "—" : `${(period.success_rate * 100).toFixed(1)}%`;
|
if (period.success_rate === null) return "—";
|
||||||
|
|
||||||
|
const rounded = period.success_rate * 100;
|
||||||
|
if (rounded > 99.9 && period.failures > 0) return "99.9%";
|
||||||
|
if (rounded < 0.1 && period.successes > 0) return "0.1%";
|
||||||
|
return `${rounded.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and `nx
|
|||||||
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
|
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
|
||||||
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
|
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
|
||||||
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
|
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
|
||||||
| `querylog.db.corrupt-<unix-seconds>` | A `querylog.db` that could not be used, moved aside before an empty one was created in its place. Kept, never overwritten. | Whatever the renamed file had — no chmod reaches it |
|
| `querylog.db.<reason>-<unix-seconds>` | A `querylog.db` this build could not use, moved aside before an empty one was created in its place. Kept, never overwritten. `<reason>` is one of `corrupt`, `not-a-database`, `quick-check-failed` or `schema-changed`; see [why a query log is moved aside](#why-a-query-log-is-moved-aside). | Whatever the renamed file had — no chmod reaches it |
|
||||||
| `querylog.db.corrupt-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 1 and rises until the name is free. Two recreates within one second is the case it exists for. | The same |
|
| `querylog.db.<reason>-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 1 and rises until the name is free. Two recreates within one second is the case it exists for. | The same |
|
||||||
| `blocklists/` | Compiled blocklist snapshots, one subdirectory of the data directory. | 0700 |
|
| `blocklists/` | Compiled blocklist snapshots, one subdirectory of the data directory. | 0700 |
|
||||||
| `blocklists/<id>.list` | Exact domains for blocklist source `<id>`, one per line, behind a header. | 0600 |
|
| `blocklists/<id>.list` | Exact domains for blocklist source `<id>`, one per line, behind a header. | 0600 |
|
||||||
| `blocklists/<id>.wild` | Wildcard entries for the same source. | 0600 |
|
| `blocklists/<id>.wild` | Wildcard entries for the same source. | 0600 |
|
||||||
@@ -50,7 +50,20 @@ A failed sweep is a warning, not an outage — leftover bytes do not justify los
|
|||||||
|
|
||||||
The temporaries of a source that still exists are cleaned by the refresh that owns them rather than by the sweep: each refresh deletes its own `.raw.tmp`, `.list.tmp`, `.wild.tmp` and `.allow.tmp` as it finishes, successfully or not.
|
The temporaries of a source that still exists are cleaned by the refresh that owns them rather than by the sweep: each refresh deletes its own `.raw.tmp`, `.list.tmp`, `.wild.tmp` and `.allow.tmp` as it finishes, successfully or not.
|
||||||
|
|
||||||
A `querylog.db` is moved aside when it is missing nothing but usability: SQLite reports it corrupt or not a database, `PRAGMA quick_check` does not answer `ok`, or its `user_version` fingerprint does not match the schema. Only the main file is renamed — its `-wal` and `-shm` are deleted, because a stale WAL would be replayed into the fresh database. A missing `querylog.db` is created without any aside file. The rename happens inside `querylog_schema.open`, before the 0600 chmod, and that chmod names `querylog.db` and its two sidecars only — so an aside file keeps the mode the file had at rename time, which for a `querylog.db` nxdns itself created is 0600 and for one an operator put there is whatever they left it at. Nothing prunes the aside files; they accumulate until an operator removes them, and each one holds the same browsing history the live query log holds.
|
### Why a query log is moved aside
|
||||||
|
|
||||||
|
A `querylog.db` is moved aside when it is missing nothing but usability, and the name it is given says which of the four cases it hit:
|
||||||
|
|
||||||
|
| `<reason>` | What happened |
|
||||||
|
| --- | --- |
|
||||||
|
| `corrupt` | SQLite reported the file as damaged. |
|
||||||
|
| `not-a-database` | The file is not a SQLite database at all. |
|
||||||
|
| `quick-check-failed` | `PRAGMA quick_check` did not answer `ok`. |
|
||||||
|
| `schema-changed` | Nothing is wrong with the file. Its `user_version` fingerprint does not match this build's schema, so this build cannot read it. Upgrades that touch the query-log schema produce this one, and the file they set aside is a healthy database. |
|
||||||
|
|
||||||
|
Only the main file is renamed — its `-wal` and `-shm` are deleted, because a stale WAL would be replayed into the fresh database. A missing `querylog.db` is created without any aside file. The rename happens inside `querylog_schema.open`, before the 0600 chmod, and that chmod names `querylog.db` and its two sidecars only — so an aside file keeps the mode the file had at rename time, which for a `querylog.db` nxdns itself created is 0600 and for one an operator put there is whatever they left it at. Nothing prunes the aside files; they accumulate until an operator removes them, and each one holds the same browsing history the live query log holds.
|
||||||
|
|
||||||
|
### Why the databases are 0600
|
||||||
|
|
||||||
The 0600 modes are not cosmetic. `config.db` holds the argon2id password hash and `querylog.db` holds the browsing history of every client on the LAN, so both are as sensitive as each other, and a WAL file holds the same rows as the database it belongs to. SQLite creates the main database at `0644 & ~umask`; nxdns chmods it to 0600 before enabling WAL, so the sidecars inherit 0600 rather than being created world-readable.
|
The 0600 modes are not cosmetic. `config.db` holds the argon2id password hash and `querylog.db` holds the browsing history of every client on the LAN, so both are as sensitive as each other, and a WAL file holds the same rows as the database it belongs to. SQLite creates the main database at `0644 & ~umask`; nxdns chmods it to 0600 before enabling WAL, so the sidecars inherit 0600 rather than being created world-readable.
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
|
|||||||
const aside: ?[]const u8 = if (cause == .missing)
|
const aside: ?[]const u8 = if (cause == .missing)
|
||||||
null
|
null
|
||||||
else
|
else
|
||||||
try renameAside(io, dir, path, &aside_buf);
|
try renameAside(io, dir, path, cause, &aside_buf);
|
||||||
|
|
||||||
// Not optional: a stale WAL left beside the renamed database would be
|
// Not optional: a stale WAL left beside the renamed database would be
|
||||||
// replayed into the freshly created file and corrupt it immediately. Any
|
// replayed into the freshly created file and corrupt it immediately. Any
|
||||||
@@ -178,20 +178,37 @@ fn quickCheck(database: *db.Db) db.Error!bool {
|
|||||||
return std.ascii.eqlIgnoreCase(stmt.columnText(0), "ok");
|
return std.ascii.eqlIgnoreCase(stmt.columnText(0), "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the aside file's name calls the reason it was set aside.
|
||||||
|
///
|
||||||
|
/// The name is the only account of the reason an operator gets: the log line
|
||||||
|
/// naming it scrolls away, the file stays for months. `fingerprint_mismatch` is
|
||||||
|
/// a database with nothing wrong with it — this build's DDL moved — so calling
|
||||||
|
/// its file "corrupt" invites the operator to delete evidence of a healthy file.
|
||||||
|
fn asideTag(reason: RecreateReason) []const u8 {
|
||||||
|
return switch (reason) {
|
||||||
|
.missing => unreachable, // there is no file to rename
|
||||||
|
.corrupt => "corrupt",
|
||||||
|
.not_a_database => "not-a-database",
|
||||||
|
.quick_check_failed => "quick-check-failed",
|
||||||
|
.fingerprint_mismatch => "schema-changed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Renames the unusable file out of the way and returns the name it now has.
|
/// Renames the unusable file out of the way and returns the name it now has.
|
||||||
///
|
///
|
||||||
/// `renamePreserve` is `RENAME_NOREPLACE`: it returns `error.PathAlreadyExists`
|
/// `renamePreserve` is `RENAME_NOREPLACE`: it returns `error.PathAlreadyExists`
|
||||||
/// instead of overwriting. A previously saved corrupt file must never be
|
/// instead of overwriting. A previously saved file must never be destroyed by
|
||||||
/// destroyed by the next recreate, and two recreates in the same second are not
|
/// the next recreate, and two recreates in the same second are not hypothetical
|
||||||
/// hypothetical on a boot loop — hence the uniquifying retries.
|
/// on a boot loop — hence the uniquifying retries.
|
||||||
fn renameAside(io: std.Io, dir: std.Io.Dir, path: []const u8, buf: []u8) Error![]const u8 {
|
fn renameAside(io: std.Io, dir: std.Io.Dir, path: []const u8, reason: RecreateReason, buf: []u8) Error![]const u8 {
|
||||||
|
const tag = asideTag(reason);
|
||||||
const seconds = std.Io.Clock.real.now(io).toSeconds();
|
const seconds = std.Io.Clock.real.now(io).toSeconds();
|
||||||
var attempt: u32 = 0;
|
var attempt: u32 = 0;
|
||||||
while (attempt < 100) : (attempt += 1) {
|
while (attempt < 100) : (attempt += 1) {
|
||||||
const aside = if (attempt == 0)
|
const aside = if (attempt == 0)
|
||||||
std.fmt.bufPrint(buf, "{s}.corrupt-{d}", .{ path, seconds }) catch return error.NameTooLong
|
std.fmt.bufPrint(buf, "{s}.{s}-{d}", .{ path, tag, seconds }) catch return error.NameTooLong
|
||||||
else
|
else
|
||||||
std.fmt.bufPrint(buf, "{s}.corrupt-{d}-{d}", .{ path, seconds, attempt }) catch return error.NameTooLong;
|
std.fmt.bufPrint(buf, "{s}.{s}-{d}-{d}", .{ path, tag, seconds, attempt }) catch return error.NameTooLong;
|
||||||
|
|
||||||
dir.renamePreserve(path, dir, aside, io) catch |e| switch (e) {
|
dir.renamePreserve(path, dir, aside, io) catch |e| switch (e) {
|
||||||
error.PathAlreadyExists => continue,
|
error.PathAlreadyExists => continue,
|
||||||
@@ -287,6 +304,13 @@ test "recreatable is a whitelist and never selects a resource error" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "the aside name says why, and a healthy file is never called corrupt" {
|
||||||
|
try testing.expectEqualStrings("corrupt", asideTag(.corrupt));
|
||||||
|
try testing.expectEqualStrings("not-a-database", asideTag(.not_a_database));
|
||||||
|
try testing.expectEqualStrings("quick-check-failed", asideTag(.quick_check_failed));
|
||||||
|
try testing.expectEqualStrings("schema-changed", asideTag(.fingerprint_mismatch));
|
||||||
|
}
|
||||||
|
|
||||||
test "recreatable selects exactly two of db.Error's members" {
|
test "recreatable selects exactly two of db.Error's members" {
|
||||||
// Exhaustive over the whole set, so a variant added to `db.Error` later
|
// Exhaustive over the whole set, so a variant added to `db.Error` later
|
||||||
// defaults to propagate. The list above only proves the named errors are
|
// defaults to propagate. The list above only proves the named errors are
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ fn countLines(text: []const u8) usize {
|
|||||||
// querylog aside files
|
// querylog aside files
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const aside_prefix = "querylog.db.corrupt-";
|
const aside_prefix = "querylog.db.";
|
||||||
|
|
||||||
const Names = struct {
|
const Names = struct {
|
||||||
items: std.ArrayList([]u8),
|
items: std.ArrayList([]u8),
|
||||||
@@ -412,6 +412,10 @@ test "S7 case 3: a wrong user_version recreates and keeps the old file aside" {
|
|||||||
defer asides.deinit();
|
defer asides.deinit();
|
||||||
try testing.expectEqual(@as(usize, 1), asides.items.items.len);
|
try testing.expectEqual(@as(usize, 1), asides.items.items.len);
|
||||||
|
|
||||||
|
// The file was healthy: this build's schema moved, the database did not rot.
|
||||||
|
// An operator who reads "corrupt" here deletes a file that was never broken.
|
||||||
|
try testing.expect(std.mem.startsWith(u8, asides.items.items[0], "querylog.db.schema-changed-"));
|
||||||
|
|
||||||
const kept = try f.read(asides.items.items[0]);
|
const kept = try f.read(asides.items.items[0]);
|
||||||
defer testing.allocator.free(kept);
|
defer testing.allocator.free(kept);
|
||||||
try testing.expectEqualSlices(u8, original, kept);
|
try testing.expectEqualSlices(u8, original, kept);
|
||||||
@@ -441,6 +445,12 @@ test "S7 case 4: a garbage file recreates and the garbage is preserved" {
|
|||||||
defer asides.deinit();
|
defer asides.deinit();
|
||||||
try testing.expectEqual(@as(usize, 1), asides.items.items.len);
|
try testing.expectEqual(@as(usize, 1), asides.items.items.len);
|
||||||
|
|
||||||
|
const expected: []const u8 = if (reason == .corrupt)
|
||||||
|
"querylog.db.corrupt-"
|
||||||
|
else
|
||||||
|
"querylog.db.not-a-database-";
|
||||||
|
try testing.expect(std.mem.startsWith(u8, asides.items.items[0], expected));
|
||||||
|
|
||||||
const kept = try f.read(asides.items.items[0]);
|
const kept = try f.read(asides.items.items[0]);
|
||||||
defer testing.allocator.free(kept);
|
defer testing.allocator.free(kept);
|
||||||
try testing.expectEqualSlices(u8, &garbage, kept);
|
try testing.expectEqualSlices(u8, &garbage, kept);
|
||||||
|
|||||||
Reference in New Issue
Block a user