@@ -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.
115162pub 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+ }
0 commit comments