123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349(** WAL-based physical replication to object store (Litestream-style).
Implementation of the apply primitive and cold restore. *)openLwt.SyntaxmoduleWal=Granary_storage.WalmodulePager=Granary_storage.Pagertypereplicated_frame={epoch:int64;frame_idx:int;page_id:int64;is_commit:bool;page:Cstruct.t;checksum:int64;source_salt:int64;source_seed:int64}typeframe_sink=replicated_framelist->unitLwt.t(* ------------------------------------------------------------------ *)(* Helpers *)(* ------------------------------------------------------------------ *)(** Group frames into commit batches, splitting at each [is_commit]
boundary. Trailing non-commit frames are silently dropped. *)letgroup_into_batchesframes=letrecgoaccbatch=function|[]->(* Only include batches that end with a commit frame.
Trailing non-commit frames are silently ignored. *)ifbatch=[]thenList.revaccelse(letlast_is_commit=(List.hd(List.revbatch)).is_commitiniflast_is_committhenList.rev(List.revbatch::acc)elseList.revacc)|f::rest->iff.is_committhengo(List.rev(f::batch)::acc)[]restelsegoacc(f::batch)restingo[][]frames;;letverify_checksumf=letflags=iff.is_committhen1Lelse0Linletcomputed=Wal.frame_checksum~salt:f.source_salt~seed:f.source_seed~page_id:f.page_id~flags~page:f.pageinInt64.equalcomputedf.checksum;;(* ------------------------------------------------------------------ *)(* Apply primitive *)(* ------------------------------------------------------------------ *)letapply_frames~wal~pagerframes=(* 1. Verify transport checksums before any side effects *)letall_valid=List.for_allverify_checksumframesinifnotall_validthenLwt.return_error(`Apply_error"transport checksum verification failed")else((* 2. Grow the device if any page_id exceeds current capacity *)letmax_pid=List.fold_left(funaccf->Int64.maxaccf.page_id)0Lframesinletcurrent_pages=Pager.n_pagespagerin(* Page ids are 0-indexed and n_pages is a count: to hold page
[max_pid] we need at least [max_pid + 1] pages. *)letneeded=Int64.succmax_pidinifneeded>current_pagesthenPager.set_n_pagespagerneeded;(* 3. Group frames into commit batches *)letbatches=group_into_batchesframesin(* 4. Apply each batch via append_commit *)letrecapply=function|[]->Lwt.return_ok()|batch::rest->letentries=List.map(funf->f.page_id,f.page)batchinlet*r=Wal.append_commitwalentriesin(matchrwith|Errore->Lwt.return_error(`Apply_error(Format.asprintf"append_commit: %a"Wal.pp_errore))|Ok()->applyrest)inapplybatches);;(* ------------------------------------------------------------------ *)(* Cold restore *)(* ------------------------------------------------------------------ *)letcold_restore~read_at~write_at~sync~wal_size_bytes~pager~base_snapshot_path:_~wal_frames()=(* Open a WAL over the provided callbacks *)let*wal_r=Wal.open_~read_at~write_at~sync~size_bytes:wal_size_bytes()inmatchwal_rwith|Errore->Lwt.return_error(`Restore_error(Format.asprintf"Wal.open_: %a"Wal.pp_errore))|Okwal->(* Accumulate frames into commit batches, apply each batch *)letrecloopacc=let*next=Lwt_stream.getwal_framesinmatchnextwith|None->(* End of stream: apply any trailing batch that ends with commit *)ifacc<>[]&&(List.hd(List.revacc)).is_committhenlet*r=apply_frames~wal~pageraccinmatchrwith|Ok()->Lwt.return_ok()|Error(`Apply_errormsg)->Lwt.return_error(`Restore_errormsg)elseLwt.return_ok()|Someframes->(* Flush at the first commit boundary; carry remainder forward.
Note: apply_frames internally calls group_into_batches which
re-splits multi-commit batches, so we don't need to split
further here — we just hand the batch off at each commit. *)letrectake_until_commitbuf=function|[]->List.revbuf,[]|f::rest->iff.is_committhenList.rev(f::buf),restelsetake_until_commit(f::buf)restinletcommitted,remaining=take_until_commit[]framesinifcommitted=[]thenloop(acc@frames)else(letbatch=acc@committedinlet*r=apply_frames~wal~pagerbatchinmatchrwith|Error(`Apply_errormsg)->Lwt.return_error(`Restore_errormsg)|Ok()->loopremaining)inloop[];;(* ------------------------------------------------------------------ *)(* Epoch-aware apply for standby (#172) *)(* ------------------------------------------------------------------ *)(** A no-op reader gate: returns immediately without waiting. Use for
paths that serve no concurrent readers (e.g. cold restore, direct
test invocations). *)letno_reader_gate~target:_=Lwt.return_unit(** Migrate the latest version of every page in the WAL index to the main
DB, sync, then reset the WAL (bumping its epoch). Mirrors the engine's
[Store.checkpoint_unlocked] spine: [iter -> flush -> sync -> gate -> reset].
Unlike [checkpoint_unlocked] the reader gate is called {e after} the main
flush and sync rather than before it. This is safe because during the
flush window the WAL index stays intact --- a concurrent RO snapshot
resolves pages from the WAL, not from the freshly-flushed main page. Only
[Wal.reset] (which {e is} gated) switches resolution to main. The ordering
divergence from [checkpoint_unlocked] is benign provided the gate always
fires before [Wal.reset].
The following engine concerns from [checkpoint_unlocked] are intentionally
omitted for a leaf standby (revisit for cascading replication #208):
- No {!Store.close} abort (#338): [~reader_gate]'s implementation
([Store.wait_for_readers_past]) already short-circuits on [st.closing],
so the gate yields immediately during teardown and reset proceeds.
If the gate is a no-op (e.g. [no_reader_gate]) the caller must ensure
no concurrent reader holds stale references before calling this function.
- No [ckpt_io_in_flight] tracking: a leaf standby has no concurrent close
that needs to drain in-flight checkpoint I/O.
- No sink-ship drain (#337): a leaf standby has no replication sink
shipping frames lazily.
- No floor re-pinning (#207/#265): a leaf standby is not a replication
source, so no downstream floor to re-pin after reset.
[~reader_gate] is called before [Wal.reset] with the current
[Wal.committed_frames] as target, ensuring no in-flight RO snapshot
references WAL frame indices about to be recycled (#263). *)letcheckpoint_wal_to_main~wal~pager~reader_gate=letpairs=ref[]inWal.iter_indexwal(funpididx->pairs:=(pid,idx)::!pairs);letrecwrite_each=function|[]->Lwt.return_ok()|(pid,idx)::rest->let*r=Wal.read_framewalidxin(matchrwith|Errore->Lwt.return_error(`Apply_error(Format.asprintf"checkpoint read: %a"Wal.pp_errore))|Okpage->let*wr=Pager.flush_one_to_mainpager~page_id:pid~buf:pagein(matchwrwith|Errore->Lwt.return_error(`Apply_error(Format.asprintf"checkpoint write: %a"Pager.pp_errore))|Ok()->write_eachrest))inlet*r=write_each!pairsinmatchrwith|Error(`Apply_error_)ase->Lwt.returne|Ok()->let*sr=Pager.flush_sync_mainpagerin(matchsrwith|Errore->Lwt.return_error(`Apply_error(Format.asprintf"checkpoint sync: %a"Pager.pp_errore))|Ok()->let*()=reader_gate~target:(Wal.committed_frameswal)inWal.resetwal;Lwt.return_ok());;(** Split a frame list into maximal runs of consecutive same-epoch frames,
preserving order. [[]] -> [[]]; a single-epoch list -> one run. *)letsplit_by_epochframes=letrecgoacccurcur_epoch=function|[]->List.rev(List.revcur::acc)|f::rest->ifInt64.equalf.epochcur_epochthengoacc(f::cur)cur_epochrestelsego(List.revcur::acc)[f]f.epochrestinmatchframeswith|[]->[]|f::rest->go[][f]f.epochrest;;letapply_frames_epoch_aware~wal~pager~last_epoch~last_idx~reader_gateframes=(* A single batch may bundle frames from more than one master epoch if the
master checkpointed mid-stream (#209). Split into maximal single-epoch
runs and feed each through the epoch-transition logic in order: whenever
a run's epoch differs from the epoch currently materialized in the local
WAL, checkpoint (drain + reset) {e before} appending the run, so the
intervening checkpoint is never skipped and recycled frame indices cannot
collide. An empty batch yields no runs and leaves the position
unchanged. *)letruns=split_by_epochframesinletrecapply_runscurrent_epoch=function|[]->Lwt.return_ok()|run::rest->letrun_epoch=(List.hdrun).epochinlet*r=ifInt64.equalrun_epochcurrent_epochthenapply_frames~wal~pagerrunelselet*cr=checkpoint_wal_to_main~wal~pager~reader_gateinmatchcrwith|Error(`Apply_error_)ase->Lwt.returne|Ok()->apply_frames~wal~pagerrunin(matchrwith|Error_ase->Lwt.returne(* Advance [current_epoch] to the run we just materialized even if it
committed nothing, so a later same-epoch run does not re-checkpoint
a WAL that was already reset for it. *)|Ok()->apply_runsrun_epochrest)inlet*result=apply_runslast_epochrunsinmatchresultwith|Error_ase->Lwt.returne|Ok()->(* Report the position of the {e last committed} frame actually applied —
not just the tail frame. [apply_frames] drops trailing non-commit
frames, so a batch ending in non-commit frames still durably applied
the commit frames before them; keying on the tail would under-report
the acked position. *)letlast_committed=List.fold_left(funaccf->iff.is_committhenf.epoch,f.frame_idxelseacc)(last_epoch,last_idx)framesinLwt.return_oklast_committed;;(* ------------------------------------------------------------------ *)(* Incremental restore (#265) *)(* ------------------------------------------------------------------ *)(** Convert a {!Granary_store.Store.backup_frame} to a
{!replicated_frame} for use with {!apply_frames_epoch_aware}. *)letbackup_frame_to_replicated(bf:Granary_store.Store.backup_frame):replicated_frame={epoch=bf.epoch;frame_idx=bf.frame_idx;page_id=bf.page_id;is_commit=bf.is_commit;page=bf.page;checksum=bf.checksum;source_salt=bf.source_salt;source_seed=bf.source_seed};;letincremental_restore~read_at~write_at~sync~wal_size_bytes~pager~(incremental_sets:Granary_store.Store.backup_framelistlist)()=let*wal_r=Wal.open_~read_at~write_at~sync~size_bytes:wal_size_bytes()inmatchwal_rwith|Errore->Lwt.return_error(`Restore_error(Format.asprintf"Wal.open_: %a"Wal.pp_errore))|Okwal->letinitial_epoch=List.find_map(function|[]->None|(f:Granary_store.Store.backup_frame)::_->Somef.epoch)incremental_sets|>Option.value~default:0Linletrecapply_sets(last_epoch,last_idx)=function|[]->Lwt.return_ok(last_epoch,last_idx)|frames::rest->letreplicated=List.mapbackup_frame_to_replicatedframesinlet*r=apply_frames_epoch_aware~wal~pager~last_epoch~last_idx~reader_gate:no_reader_gatereplicatedin(matchrwith|Error(`Apply_errormsg)->Lwt.return_error(`Restore_errormsg)|Ok(epoch,idx)->apply_sets(epoch,idx)rest)inapply_sets(initial_epoch,-1)incremental_sets;;[@@@ai_disclosure"ai-generated"][@@@ai_model"claude-opus-4-7"][@@@ai_provider"Anthropic"]