milestone 7: serving pipeline, client tracking, pause and lifecycle
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user