milestone 7: serving pipeline, client tracking, pause and lifecycle

This commit is contained in:
2026-08-01 21:43:52 +02:00
parent 8c50b6617f
commit a8092bb1b9
17 changed files with 5916 additions and 131 deletions
+165 -1
View File
@@ -5,7 +5,8 @@
//! not appear in an export. `countClients` counts **all** rows, because S5's
//! "has this database ever been configured" predicate needs the true count.
//!
//! Only list / insert / deleteAll / count exist.
//! Only list / insert / deleteAll / count, plus the two runtime calls
//! `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -81,6 +82,51 @@ pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db
try stmt.exec();
}
const upsert_seen_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, NULL, (SELECT id FROM groups WHERE name = 'default'), 0, ?2, ?2)
\\ON CONFLICT(ip) DO UPDATE SET last_seen = excluded.last_seen
;
/// Materialises a client seen in live traffic (PLAN §7.2), or touches
/// `last_seen` on the row that already holds `ip`.
///
/// The conflict target is `clients.ip`, which the schema declares UNIQUE. Only
/// `last_seen` is updated: `name`, `group_id` and `hand_edited` are the
/// operator's, and a device that keeps querying must not overwrite them. A
/// hand-edited row is touched too, so the operator sees liveness for the
/// clients they named.
///
/// A new row lands in the `default` group. `groupForClient` resolves the real
/// group from the prefix rules at query time, so the column here only decides
/// what the operator sees before they assign the device themselves.
///
/// A database with no group named `default` fails the insert with
/// `error.Constraint` rather than writing a dangling row. `validate.zig`
/// rejects such a configuration long before a server serves from it.
pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void {
var stmt = try database.prepare(upsert_seen_sql);
defer stmt.deinit();
try stmt.bindText(1, ip);
try stmt.bindInt(2, now_s);
try stmt.exec();
}
/// Deletes the auto-materialised clients whose last query predates `cutoff_s`,
/// and returns how many rows went. `hand_edited = 1` rows are configuration and
/// survive any silence.
///
/// `last_seen` is the only signal, because §3.6 forbids joining `config.db`
/// against the query log.
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 {
var stmt = try database.prepare("DELETE FROM clients WHERE hand_edited = 0 AND last_seen < ?1");
defer stmt.deinit();
try stmt.bindInt(1, cutoff_s);
try stmt.exec();
const deleted = database.changes();
return @intCast(@min(deleted, std.math.maxInt(u32)));
}
pub fn deleteAllClients(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM clients;");
}
@@ -281,6 +327,124 @@ test "listClients is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
}
fn seenRow(database: *db.Db, ip: []const u8) !struct { hand_edited: i64, first_seen: i64, last_seen: i64, group_id: i64 } {
var stmt = try database.prepare(
"SELECT hand_edited, first_seen, last_seen, group_id FROM clients WHERE ip = ?1",
);
defer stmt.deinit();
try stmt.bindText(1, ip);
try testing.expect(try stmt.step());
return .{
.hand_edited = stmt.columnInt(0),
.first_seen = stmt.columnInt(1),
.last_seen = stmt.columnInt(2),
.group_id = stmt.columnInt(3),
};
}
test "upsertSeen materialises an unseen client in the default group" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.50", 1700000000);
try testing.expectEqual(@as(i64, 1), try countClients(&database));
const row = try seenRow(&database, "192.168.1.50");
try testing.expectEqual(@as(i64, 0), row.hand_edited);
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
try testing.expectEqual(@as(i64, 1700000000), row.last_seen);
try testing.expectEqual(@as(i64, 1), row.group_id);
// Materialised clients are runtime state, so an export must not see them.
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 0), items.items.len);
}
test "upsertSeen touches last_seen and leaves first_seen alone" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.50", 1700000000);
try upsertSeen(&database, "192.168.1.50", 1700000600);
try testing.expectEqual(@as(i64, 1), try countClients(&database));
const row = try seenRow(&database, "192.168.1.50");
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
try testing.expectEqual(@as(i64, 1700000600), row.last_seen);
}
test "upsertSeen keeps a hand-edited row's name, group and flag" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try upsertSeen(&database, "192.168.1.20", 1700009999);
try testing.expectEqual(@as(i64, 3), try countClients(&database));
const row = try seenRow(&database, "192.168.1.20");
try testing.expectEqual(@as(i64, 1), row.hand_edited);
try testing.expectEqual(@as(i64, 2), row.group_id);
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
try testing.expectEqual(@as(i64, 1700009999), row.last_seen);
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("laptop", items.items[1].name);
try testing.expectEqualStrings("kids", items.items[1].group);
}
test "upsertSeen reports a database with no default group" {
var database = try openMigrated();
defer database.close();
try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");
try testing.expectError(error.Constraint, upsertSeen(&database, "192.168.1.50", 1700000000));
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "pruneStale removes only stale auto-materialised rows" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
// A hand-edited row far older than the cutoff.
try seedClients(&database, &ids);
try upsertSeen(&database, "10.0.0.1", 1700000000);
try upsertSeen(&database, "10.0.0.2", 1700000199);
// Exactly at the cutoff: the comparison is strict, so it stays.
try upsertSeen(&database, "10.0.0.3", 1700000200);
try upsertSeen(&database, "10.0.0.4", 1700000300);
try testing.expectEqual(@as(u32, 2), try pruneStale(&database, 1700000200));
try testing.expectEqual(@as(i64, 5), try countClients(&database));
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT count(*) FROM clients WHERE ip IN ('10.0.0.1', '10.0.0.2')"),
);
try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM clients WHERE hand_edited = 1"));
// A second pass over the same cutoff finds nothing left to do.
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1700000200));
}
test "pruneStale spares hand-edited rows however stale" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
try testing.expectEqual(@as(i64, 3), try countClients(&database));
}
test "client_prefixes round-trip in prefix order with group names resolved" {
var database = try openMigrated();
defer database.close();