Skip to content

Commit 81e6293

Browse files
committed
Coordinator replica registry: reportStatus + findDonor + below-retention (M3a/b)
Milestone 3 (coordinator side) of snapshot bootstrapping. The coordinator becomes the peer registry a bootstrapping node queries for a donor — it already sees live replicas from their reads, and now a periodic status heartbeat gives it per-lineage watermarks. - Coordinator vtable: reportStatus(ReplicaStatus) and findDonor(name, gen, after) that returns a live replica able to serve a reader at `after` (file_version >= after), preferring the freshest checkpoint. Types: LineageStatus, ReplicaStatus, DonorInfo. - MemoryCoordinator: a replica table keyed by replica_id (upsert on report, liveness by last_seen within a 30s timeout). read() now returns error.BelowRetention when the reader is below the lineage's retention floor — the signal to bootstrap. The stub floor is test-driven (setRetentionFloor); the PG impl derives it from real retention. - coordinator_server: POST /_status, GET /_donor/:index/:gen?after=. RemoteCoordinator implements both over HTTP. (The WedgedReads test stub delegates them too.) Next (3c): the replica-side status reporter that periodically calls reportStatus. Tests: findDonor selection + below-retention (unit) and a msgpack-over-HTTP endpoint test (cluster e2e). 69 unit + 46 e2e green.
1 parent 7e8a594 commit 81e6293

5 files changed

Lines changed: 382 additions & 0 deletions

File tree

src/Coordinator.zig

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,53 @@ pub const MetaDeleteResponse = struct {
111111
}
112112
};
113113

114+
// ---- Replica registry (peer discovery for snapshot bootstrap) ----
115+
// Replicas periodically report their state; the coordinator is the rendezvous point
116+
// that a bootstrapping node queries for a donor. See notes/bootstrap-design.md.
117+
118+
/// A replica's state for one lineage it holds locally.
119+
pub const LineageStatus = struct {
120+
index_name: []const u8,
121+
generation: u64,
122+
applied: u64, // highest data-feed seq applied (the index version)
123+
file_version: u64, // checkpointed watermark — a snapshot from this replica resumes here
124+
125+
pub fn msgpackFormat() msgpack.StructFormat {
126+
return .{ .as_map = .{ .key = .{ .field_name_prefix = 1 } } };
127+
}
128+
};
129+
130+
/// A replica's periodic heartbeat: who it is, where to fetch its snapshots, and what
131+
/// it holds. Replaces the replica's previous status; absence past a timeout = dead.
132+
pub const ReplicaStatus = struct {
133+
replica_id: []const u8,
134+
advertise_addr: []const u8, // base URL other nodes fetch GET /:index/_snapshot from
135+
lineages: []const LineageStatus,
136+
137+
pub fn msgpackFormat() msgpack.StructFormat {
138+
return .{ .as_map = .{ .key = .{ .field_name_prefix = 1 } } };
139+
}
140+
};
141+
142+
/// Where a bootstrapping node should fetch a snapshot, and the watermark it will land
143+
/// on (so it resumes the data feed from there).
144+
pub const DonorInfo = struct {
145+
advertise_addr: []const u8,
146+
file_version: u64,
147+
148+
pub fn msgpackFormat() msgpack.StructFormat {
149+
return .{ .as_map = .{ .key = .{ .field_name_prefix = 1 } } };
150+
}
151+
};
152+
153+
pub const DonorResponse = struct {
154+
donor: ?DonorInfo = null,
155+
156+
pub fn msgpackFormat() msgpack.StructFormat {
157+
return .{ .as_map = .{ .key = .{ .field_name_prefix = 1 } } };
158+
}
159+
};
160+
114161
/// Runtime-dispatched handle to a changelog implementation.
115162
pub const Coordinator = struct {
116163
ptr: *anyopaque,
@@ -143,6 +190,15 @@ pub const Coordinator = struct {
143190
/// Block until a meta op with pos > `after` exists (or `deadline`), fill
144191
/// `out` in pos order, return the count. Ops are valid until the next call.
145192
readMeta: *const fn (ptr: *anyopaque, after: u64, out: []MetaOp, deadline: zio.Timeout) anyerror!usize,
193+
194+
// Replica registry (peer discovery).
195+
/// Record/refresh a replica's heartbeat (liveness + per-lineage watermarks).
196+
/// The implementation copies what it needs; `status` need not outlive the call.
197+
reportStatus: *const fn (ptr: *anyopaque, status: ReplicaStatus) anyerror!void,
198+
/// Pick a live replica able to donate a snapshot of (`index_name`,
199+
/// `generation`) that a reader at `after` can resume from (file_version >=
200+
/// `after`), or null if none. The returned addr is allocated in `arena`.
201+
findDonor: *const fn (ptr: *anyopaque, arena: std.mem.Allocator, index_name: []const u8, generation: u64, after: u64) anyerror!?DonorInfo,
146202
};
147203

148204
pub fn append(self: Coordinator, index_name: []const u8, generation: u64, changes: []const Change, expected: ?u64) !u64 {
@@ -164,6 +220,14 @@ pub const Coordinator = struct {
164220
pub fn readMeta(self: Coordinator, after: u64, out: []MetaOp, deadline: zio.Timeout) !usize {
165221
return self.vtable.readMeta(self.ptr, after, out, deadline);
166222
}
223+
224+
pub fn reportStatus(self: Coordinator, status: ReplicaStatus) !void {
225+
return self.vtable.reportStatus(self.ptr, status);
226+
}
227+
228+
pub fn findDonor(self: Coordinator, arena: std.mem.Allocator, index_name: []const u8, generation: u64, after: u64) !?DonorInfo {
229+
return self.vtable.findDonor(self.ptr, arena, index_name, generation, after);
230+
}
167231
};
168232

169233
/// In-memory changelog stub. Upholds the invariants above; not durable, not
@@ -176,6 +240,33 @@ pub const MemoryCoordinator = struct {
176240
// Meta feed (index registry), global, never truncated.
177241
meta_ops: std.ArrayListUnmanaged(MetaEntry) = .empty,
178242
next_meta_pos: u64 = 1,
243+
// Replica registry (peer discovery), keyed by replica_id.
244+
replicas: std.ArrayListUnmanaged(ReplicaRecord) = .empty,
245+
// Simulated changelog retention per lineage: seqs <= floor are conceptually
246+
// dropped. The stub never truncates on its own (setRetentionFloor drives it in
247+
// tests); the PG impl computes this from real retention.
248+
retention: std.ArrayListUnmanaged(RetentionFloor) = .empty,
249+
250+
// A replica whose heartbeat is older than this is treated as dead.
251+
const liveness_timeout: zio.Duration = .fromMilliseconds(30_000);
252+
253+
const ReplicaRecord = struct {
254+
replica_id: []const u8, // owned
255+
advertise_addr: []const u8, // owned
256+
lineages: []OwnedLineage, // owned
257+
last_seen: zio.Timestamp,
258+
};
259+
const OwnedLineage = struct {
260+
index_name: []const u8, // owned
261+
generation: u64,
262+
applied: u64,
263+
file_version: u64,
264+
};
265+
const RetentionFloor = struct {
266+
index_name: []const u8, // owned
267+
generation: u64,
268+
floor: u64,
269+
};
179270

180271
const Row = struct {
181272
index_name: []const u8, // owned
@@ -199,6 +290,10 @@ pub const MemoryCoordinator = struct {
199290
self.rows.deinit(self.allocator);
200291
for (self.meta_ops.items) |op| self.allocator.free(op.index_name);
201292
self.meta_ops.deinit(self.allocator);
293+
for (self.replicas.items) |*rec| self.freeReplicaRecord(rec);
294+
self.replicas.deinit(self.allocator);
295+
for (self.retention.items) |r| self.allocator.free(r.index_name);
296+
self.retention.deinit(self.allocator);
202297
self.* = undefined;
203298
}
204299

@@ -212,6 +307,8 @@ pub const MemoryCoordinator = struct {
212307
.createIndex = createIndexImpl,
213308
.deleteIndex = deleteIndexImpl,
214309
.readMeta = readMetaImpl,
310+
.reportStatus = reportStatusImpl,
311+
.findDonor = findDonorImpl,
215312
};
216313

217314
fn appendImpl(ptr: *anyopaque, index_name: []const u8, generation: u64, changes: []const Change, expected: ?u64) anyerror!u64 {
@@ -251,6 +348,10 @@ pub const MemoryCoordinator = struct {
251348
try self.mutex.lock();
252349
defer self.mutex.unlock();
253350

351+
// The reader wants seqs > after, but retention has dropped everything <= floor;
352+
// if after < floor those seqs are gone, so the reader must bootstrap.
353+
if (after < self.retentionFloorLocked(index_name, generation)) return error.BelowRetention;
354+
254355
while (true) {
255356
var n: usize = 0;
256357
for (self.rows.items) |row| { // stored in append order == per-lineage seq order
@@ -286,6 +387,103 @@ pub const MemoryCoordinator = struct {
286387
freeChange(self.allocator, row.change);
287388
}
288389

390+
fn reportStatusImpl(ptr: *anyopaque, status: ReplicaStatus) anyerror!void {
391+
const self: *MemoryCoordinator = @ptrCast(@alignCast(ptr));
392+
try self.mutex.lock();
393+
defer self.mutex.unlock();
394+
395+
// Build the owned replacement first, so a mid-build failure leaves the old
396+
// record intact.
397+
var rec = try self.dupeReplicaRecord(status);
398+
errdefer self.freeReplicaRecord(&rec);
399+
400+
for (self.replicas.items) |*existing| {
401+
if (std.mem.eql(u8, existing.replica_id, status.replica_id)) {
402+
self.freeReplicaRecord(existing);
403+
existing.* = rec;
404+
return;
405+
}
406+
}
407+
try self.replicas.append(self.allocator, rec);
408+
}
409+
410+
fn findDonorImpl(ptr: *anyopaque, arena: std.mem.Allocator, index_name: []const u8, generation: u64, after: u64) anyerror!?DonorInfo {
411+
const self: *MemoryCoordinator = @ptrCast(@alignCast(ptr));
412+
try self.mutex.lock();
413+
defer self.mutex.unlock();
414+
415+
var best_addr: ?[]const u8 = null;
416+
var best_fv: u64 = 0;
417+
for (self.replicas.items) |rec| {
418+
if (rec.last_seen.untilNow(.monotonic).toNanoseconds() > liveness_timeout.toNanoseconds()) continue; // dead
419+
for (rec.lineages) |ls| {
420+
if (ls.generation != generation) continue;
421+
if (!std.mem.eql(u8, ls.index_name, index_name)) continue;
422+
if (ls.file_version < after) continue; // a reader at `after` can't resume from here
423+
if (best_addr == null or ls.file_version > best_fv) {
424+
best_addr = rec.advertise_addr;
425+
best_fv = ls.file_version;
426+
}
427+
}
428+
}
429+
const addr = best_addr orelse return null;
430+
// Copy the addr into the caller's arena — the record may change after unlock.
431+
return .{ .advertise_addr = try arena.dupe(u8, addr), .file_version = best_fv };
432+
}
433+
434+
fn dupeReplicaRecord(self: *MemoryCoordinator, status: ReplicaStatus) !ReplicaRecord {
435+
const id = try self.allocator.dupe(u8, status.replica_id);
436+
errdefer self.allocator.free(id);
437+
const addr = try self.allocator.dupe(u8, status.advertise_addr);
438+
errdefer self.allocator.free(addr);
439+
const lineages = try self.allocator.alloc(OwnedLineage, status.lineages.len);
440+
var n: usize = 0;
441+
errdefer {
442+
for (lineages[0..n]) |l| self.allocator.free(l.index_name);
443+
self.allocator.free(lineages);
444+
}
445+
for (status.lineages) |ls| {
446+
lineages[n] = .{
447+
.index_name = try self.allocator.dupe(u8, ls.index_name),
448+
.generation = ls.generation,
449+
.applied = ls.applied,
450+
.file_version = ls.file_version,
451+
};
452+
n += 1;
453+
}
454+
return .{ .replica_id = id, .advertise_addr = addr, .lineages = lineages, .last_seen = zio.Timestamp.now(.monotonic) };
455+
}
456+
457+
fn freeReplicaRecord(self: *MemoryCoordinator, rec: *ReplicaRecord) void {
458+
self.allocator.free(rec.replica_id);
459+
self.allocator.free(rec.advertise_addr);
460+
for (rec.lineages) |l| self.allocator.free(l.index_name);
461+
self.allocator.free(rec.lineages);
462+
}
463+
464+
fn retentionFloorLocked(self: *MemoryCoordinator, index_name: []const u8, generation: u64) u64 {
465+
for (self.retention.items) |r| {
466+
if (r.generation == generation and std.mem.eql(u8, r.index_name, index_name)) return r.floor;
467+
}
468+
return 0;
469+
}
470+
471+
// Test scaffolding: simulate the changelog having dropped seqs <= `floor` for a
472+
// lineage (the PG impl derives this from real retention).
473+
pub fn setRetentionFloor(self: *MemoryCoordinator, index_name: []const u8, generation: u64, floor: u64) !void {
474+
try self.mutex.lock();
475+
defer self.mutex.unlock();
476+
for (self.retention.items) |*r| {
477+
if (r.generation == generation and std.mem.eql(u8, r.index_name, index_name)) {
478+
r.floor = floor;
479+
return;
480+
}
481+
}
482+
const name_copy = try self.allocator.dupe(u8, index_name);
483+
errdefer self.allocator.free(name_copy);
484+
try self.retention.append(self.allocator, .{ .index_name = name_copy, .generation = generation, .floor = floor });
485+
}
486+
289487
// The generation of `name` if it's currently active (its latest meta op is a
290488
// create), else null.
291489
fn currentGenerationLocked(self: *MemoryCoordinator, name: []const u8) ?u64 {
@@ -545,3 +743,72 @@ test "MemoryCoordinator: meta feed create/delete/create, distinct generations, i
545743
// Deleting a name that isn't active is a no-op: returns the latest meta pos.
546744
try testing.expectEqual(other, try co.deleteIndex("does_not_exist"));
547745
}
746+
747+
test "MemoryCoordinator: findDonor picks a live replica that can serve the reader" {
748+
const rt = try zio.Runtime.init(testing.allocator, .{});
749+
defer rt.deinit();
750+
751+
var cl = MemoryCoordinator.init(testing.allocator);
752+
defer cl.deinit();
753+
const co = cl.coordinator();
754+
755+
var arena = std.heap.ArenaAllocator.init(testing.allocator);
756+
defer arena.deinit();
757+
const a = arena.allocator();
758+
759+
// No replicas yet -> no donor.
760+
try testing.expect((try co.findDonor(a, "main", 1, 0)) == null);
761+
762+
try co.reportStatus(.{ .replica_id = "r1", .advertise_addr = "http://r1", .lineages = &.{
763+
.{ .index_name = "main", .generation = 1, .applied = 10, .file_version = 5 },
764+
} });
765+
try co.reportStatus(.{ .replica_id = "r2", .advertise_addr = "http://r2", .lineages = &.{
766+
.{ .index_name = "main", .generation = 1, .applied = 20, .file_version = 15 },
767+
.{ .index_name = "other", .generation = 3, .applied = 4, .file_version = 4 },
768+
} });
769+
770+
// A reader at after=0: both qualify, prefer the higher checkpoint watermark (r2).
771+
const d = (try co.findDonor(a, "main", 1, 0)).?;
772+
try testing.expectEqualStrings("http://r2", d.advertise_addr);
773+
try testing.expectEqual(@as(u64, 15), d.file_version);
774+
775+
// A reader past r1's watermark (after=10): only r2 (fv 15) can resume it.
776+
try testing.expectEqualStrings("http://r2", (try co.findDonor(a, "main", 1, 10)).?.advertise_addr);
777+
// Past everyone (after=20): no donor.
778+
try testing.expect((try co.findDonor(a, "main", 1, 20)) == null);
779+
// Wrong generation / name -> no donor.
780+
try testing.expect((try co.findDonor(a, "main", 2, 0)) == null);
781+
try testing.expect((try co.findDonor(a, "nope", 1, 0)) == null);
782+
783+
// A replica re-reporting replaces its prior status (no leak; new watermark wins).
784+
try co.reportStatus(.{ .replica_id = "r1", .advertise_addr = "http://r1b", .lineages = &.{
785+
.{ .index_name = "main", .generation = 1, .applied = 40, .file_version = 30 },
786+
} });
787+
const d3 = (try co.findDonor(a, "main", 1, 20)).?;
788+
try testing.expectEqualStrings("http://r1b", d3.advertise_addr);
789+
try testing.expectEqual(@as(u64, 30), d3.file_version);
790+
}
791+
792+
test "MemoryCoordinator: read below the retention floor signals bootstrap" {
793+
const rt = try zio.Runtime.init(testing.allocator, .{});
794+
defer rt.deinit();
795+
796+
var cl = MemoryCoordinator.init(testing.allocator);
797+
defer cl.deinit();
798+
const co = cl.coordinator();
799+
800+
_ = try co.append("main", 1, &.{ ins(1, &.{10}), ins(2, &.{20}), ins(3, &.{30}) }, null);
801+
try cl.setRetentionFloor("main", 1, 2); // seqs <= 2 conceptually dropped
802+
803+
var buf: [8]Entry = undefined;
804+
const zero: zio.Timeout = .{ .duration = .fromMilliseconds(0) };
805+
// A reader below the floor wants seqs that are gone -> must bootstrap.
806+
try testing.expectError(error.BelowRetention, co.read("main", 1, 0, &buf, zero));
807+
try testing.expectError(error.BelowRetention, co.read("main", 1, 1, &buf, zero));
808+
// At the floor: the next wanted seq (3) is retained -> OK.
809+
try testing.expectEqual(@as(usize, 1), try co.read("main", 1, 2, &buf, zero));
810+
try testing.expectEqual(@as(u64, 3), buf[0].id);
811+
// Another lineage is unaffected (its floor is 0).
812+
_ = try co.append("main", 5, &.{ins(9, &.{40})}, null);
813+
try testing.expectEqual(@as(usize, 1), try co.read("main", 5, 0, &buf, zero));
814+
}

src/RemoteCoordinator.zig

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const ReadResponse = changelog_mod.ReadResponse;
2424
const MetaReadResponse = changelog_mod.MetaReadResponse;
2525
const MetaCreateResponse = changelog_mod.MetaCreateResponse;
2626
const MetaDeleteResponse = changelog_mod.MetaDeleteResponse;
27+
const ReplicaStatus = changelog_mod.ReplicaStatus;
28+
const DonorInfo = changelog_mod.DonorInfo;
29+
const DonorResponse = changelog_mod.DonorResponse;
2730

2831
const Self = @This();
2932
// Cap on any single long-poll window (server side may still return sooner). Used
@@ -62,6 +65,8 @@ const vtable: Coordinator.VTable = .{
6265
.createIndex = createIndexImpl,
6366
.deleteIndex = deleteIndexImpl,
6467
.readMeta = readMetaImpl,
68+
.reportStatus = reportStatusImpl,
69+
.findDonor = findDonorImpl,
6570
};
6671

6772
fn appendImpl(ptr: *anyopaque, index_name: []const u8, generation: u64, changes: []const Change, expected: ?u64) anyerror!u64 {
@@ -166,6 +171,43 @@ fn readMetaImpl(ptr: *anyopaque, after: u64, out: []MetaOp, deadline: zio.Timeou
166171
return n;
167172
}
168173

174+
fn reportStatusImpl(ptr: *anyopaque, status: ReplicaStatus) anyerror!void {
175+
const self: *Self = @ptrCast(@alignCast(ptr));
176+
var arena = std.heap.ArenaAllocator.init(self.allocator);
177+
defer arena.deinit();
178+
const a = arena.allocator();
179+
180+
var aw: std.Io.Writer.Allocating = .init(a);
181+
try msgpack.encode(status, &aw.writer);
182+
const url = try std.fmt.allocPrint(a, "{s}/_status", .{self.base_url});
183+
184+
var client = http.Client.init(self.allocator, self.io, .{});
185+
defer client.deinit();
186+
var resp = try client.fetch(url, .{ .method = .post, .body = aw.written() });
187+
defer resp.deinit();
188+
if (resp.status() != .ok) return statusToError(resp.status());
189+
}
190+
191+
fn findDonorImpl(ptr: *anyopaque, arena: std.mem.Allocator, index_name: []const u8, generation: u64, after: u64) anyerror!?DonorInfo {
192+
const self: *Self = @ptrCast(@alignCast(ptr));
193+
var tmp = std.heap.ArenaAllocator.init(self.allocator);
194+
defer tmp.deinit();
195+
const a = tmp.allocator();
196+
197+
const url = try std.fmt.allocPrint(a, "{s}/_donor/{s}/{d}?after={d}", .{ self.base_url, index_name, generation, after });
198+
var client = http.Client.init(self.allocator, self.io, .{});
199+
defer client.deinit();
200+
var resp = try client.fetch(url, .{ .method = .get });
201+
defer resp.deinit();
202+
if (resp.status() != .ok) return statusToError(resp.status());
203+
204+
const body = (try resp.body()) orelse return null;
205+
const dres = try msgpack.decodeFromSliceLeaky(DonorResponse, a, body);
206+
const d = dres.donor orelse return null;
207+
// Copy the addr into the caller's arena; `tmp` (and the decoded body) is freed here.
208+
return .{ .advertise_addr = try arena.dupe(u8, d.advertise_addr), .file_version = d.file_version };
209+
}
210+
169211
// The long-poll window to request from the server. `.none` (block indefinitely)
170212
// maps to the max window; the consumer loops across windows. A `.duration` (e.g.
171213
// the meta catch-up's short deadline) is passed through so the server returns

0 commit comments

Comments
 (0)