Skip to main content

objstore_r/mirror/
mod.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! # graph-r-mirror
5//!
6//! Keep a [graph-r](graph_r) store directory in sync with an object store —
7//! Google Cloud Storage, a local directory, or memory — through
8//! this crate's [`ObjectStore`] trait.
9//!
10//! graph-r stays exactly as it is: synchronous, file-based, mmapped. The
11//! mirror moves *its files*, byte for byte, so a store can live on tmpfs on a
12//! stateless instance and be durable in a bucket:
13//!
14//! ```text
15//! {prefix}HEAD                                  manifest, compare-and-swap only
16//! {prefix}base/{generation:016x}.base           graph.base, create-only
17//! {prefix}wal/{generation:016x}/{a:016x}-{b:016x}.seg   graph.wal bytes [a, b), create-only
18//! ```
19//!
20//! - [`Mirror::pull_into`] rebuilds `graph.base` and `graph.wal` in a
21//!   directory (every byte checksummed, the base validated end to end); open
22//!   it with the unchanged [`graph_r::Store::open`].
23//! - [`Mirror::publish`] ships what the store committed since the last
24//!   publish: only the new WAL frames while the generation is unchanged, the
25//!   new base after a compaction. Long segment runs are coalesced. Uploads
26//!   are create-only, so a retried publish reuses what already landed.
27//! - [`Mirror::refresh_into`] keeps a read-only replica current: one
28//!   metadata request when nothing changed, appended frames when the log
29//!   grew, a new base after a compaction; then
30//!   [`graph_r::Store::reload_if_stale`].
31//! - **One writer, enforced remotely.** `HEAD` is only ever written with a
32//!   generation precondition, so of two instances publishing from the same
33//!   state exactly one wins and the other gets [`Error::Fenced`]. An optional
34//!   lease ([`Mirror::acquire_lease`]) says *who* should be writing.
35//!
36//! Futures are unboxed (`impl Future` over borrowed state); no `Arc`, no
37//! `dyn`. The internal `Mutex` is never held across an `.await`.
38
39mod fsio;
40mod manifest;
41
42pub use crate::ObjectStore;
43pub use manifest::{Lease, Segment};
44
45use crate::{fenced, Bytes};
46use graph_r::format::{base, wal};
47use graph_r::fsio::now_ms;
48use graph_r::hash::xxh3_64;
49use graph_r::store::{BASE_FILE, WAL_FILE};
50use graph_r::Store;
51use manifest::Manifest;
52use std::future::Future;
53use std::io::{Read, Seek, SeekFrom};
54use std::path::Path;
55use std::sync::{Mutex, MutexGuard, PoisonError};
56
57/// Coalesce the log into one segment once a generation has this many.
58pub const COALESCE_SEGMENTS: usize = 32;
59
60#[doc(hidden)]
61pub use manifest::fuzz as fuzz_manifest;
62
63/// Result alias.
64pub type Result<T, E = Error> = std::result::Result<T, E>;
65
66/// What can go wrong.
67#[derive(Debug, thiserror::Error)]
68#[non_exhaustive]
69pub enum Error {
70    /// The local store failed.
71    #[error(transparent)]
72    Graph(#[from] graph_r::Error),
73    /// The object store failed.
74    #[error("object store: {0}")]
75    Store(crate::Error),
76    /// Another writer published first, or the local store diverges from the
77    /// published one. Stop writing; reopen read-only or pull again.
78    #[error("fenced: {reason}")]
79    Fenced {
80        /// Why.
81        reason: &'static str,
82    },
83    /// Someone else holds an unexpired writer lease.
84    #[error("lease held by {holder} until {expires_ms}")]
85    Leased {
86        /// The holder.
87        holder: String,
88        /// When it lapses, Unix ms.
89        expires_ms: u64,
90    },
91    /// Published data failed verification.
92    #[error("corrupt remote store: {message}")]
93    Corrupt {
94        /// What was wrong.
95        message: String,
96    },
97    /// Local I/O.
98    #[error("i/o: {0}")]
99    Io(#[from] std::io::Error),
100}
101
102fn corrupt(message: impl Into<String>) -> Error {
103    Error::Corrupt { message: message.into() }
104}
105
106/// The fenced-publish outcomes keep their own variants.
107impl From<crate::Error> for Error {
108    fn from(e: crate::Error) -> Self {
109        match e {
110            crate::Error::Fenced { reason } => Self::Fenced { reason },
111            crate::Error::Corrupt { message } => Self::Corrupt { message },
112            other => Self::Store(other),
113        }
114    }
115}
116
117/// What [`Mirror::pull_into`] rebuilt.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub struct Pulled {
120    /// The graph-r generation.
121    pub generation: u64,
122    /// Bytes written (base + log).
123    pub bytes: u64,
124}
125
126/// What [`Mirror::publish`] did.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub struct Published {
129    /// The published generation.
130    pub generation: u64,
131    /// Published log length.
132    pub wal_len: u64,
133    /// Bytes uploaded by this call (0: nothing new).
134    pub uploaded: u64,
135    /// A new base was uploaded (the store compacted since the last publish).
136    pub new_base: bool,
137}
138
139/// The published state as last read or written.
140/// What a replica must apply to catch up.
141enum Change {
142    /// Log bytes appended since the replica's state.
143    Tail(Vec<Bytes>),
144    /// A whole store: base and log segments.
145    Full(Bytes, Vec<Bytes>),
146}
147
148#[derive(Clone, Debug)]
149struct Remote {
150    /// Object generation of `HEAD` (the CAS token).
151    head_gen: u64,
152    manifest: Manifest,
153    /// The local directory is known to hold exactly the published bytes
154    /// (after a pull, a refresh or our own publish).
155    local_verified: bool,
156}
157
158#[derive(Debug)]
159struct Holder {
160    name: String,
161    ttl_ms: u64,
162}
163
164#[derive(Debug, Default)]
165struct State {
166    remote: Option<Remote>,
167    holder: Option<Holder>,
168    /// The log image behind a memory-only replica ([`Mirror::load_image`]).
169    image_wal: Vec<u8>,
170}
171
172/// The local cut a publish ships, taken under the store's writer lock.
173enum Cut {
174    Nothing { generation: u64, wal_len: u64 },
175    Tail { generation: u64, start: u64, frames: Vec<u8> },
176    Coalesce { generation: u64, wal: Vec<u8> },
177    Full { generation: u64, base: Vec<u8>, wal: Vec<u8> },
178}
179
180/// A store directory ⇄ object store link. See the crate docs.
181#[derive(Debug)]
182pub struct Mirror<'o, O: ObjectStore> {
183    objects: &'o O,
184    prefix: String,
185    state: Mutex<State>,
186}
187
188impl<'o, O: ObjectStore> Mirror<'o, O> {
189    /// Mirror under `prefix` (empty, or a key prefix such as `"kb/runbooks"`;
190    /// a trailing `/` is implied).
191    pub fn new(objects: &'o O, prefix: &str) -> Result<Self> {
192        let prefix = fenced::prefix(prefix)?;
193        Ok(Self { objects, prefix, state: Mutex::new(State::default()) })
194    }
195
196    /// The normalized prefix (empty or ending in `/`).
197    #[must_use]
198    pub fn prefix(&self) -> &str {
199        &self.prefix
200    }
201
202    /// The lease recorded in the last `HEAD` this mirror saw.
203    #[must_use]
204    pub fn lease(&self) -> Option<Lease> {
205        self.lock().remote.as_ref().and_then(|r| r.manifest.lease.clone())
206    }
207
208    fn lock(&self) -> MutexGuard<'_, State> {
209        self.state.lock().unwrap_or_else(PoisonError::into_inner)
210    }
211
212    fn head_key(&self) -> String {
213        format!("{}HEAD", self.prefix)
214    }
215    fn base_key(&self, generation: u64) -> String {
216        format!("{}base/{generation:016x}.base", self.prefix)
217    }
218    fn gen_dir(&self, generation: u64) -> String {
219        format!("{}wal/{generation:016x}/", self.prefix)
220    }
221    fn seg_key(&self, generation: u64, s: &Segment) -> String {
222        format!("{}{:016x}-{:016x}.seg", self.gen_dir(generation), s.start, s.end())
223    }
224
225    /// Read `HEAD`; keeps the verified flag when it is unchanged.
226    async fn fetch_remote(&self) -> Result<Option<Remote>> {
227        let Some(obj) = self.objects.get(&self.head_key()).await? else {
228            self.lock().remote = None;
229            return Ok(None);
230        };
231        let manifest = Manifest::decode(&obj.bytes)?;
232        let mut st = self.lock();
233        let local_verified =
234            st.remote.as_ref().is_some_and(|r| r.local_verified && r.head_gen == obj.generation);
235        let remote = Remote { head_gen: obj.generation, manifest, local_verified };
236        st.remote = Some(remote.clone());
237        Ok(Some(remote))
238    }
239
240    fn remember(&self, remote: Option<Remote>) {
241        self.lock().remote = remote;
242    }
243
244    /// Write `next` as `HEAD` if it still is `prev`; any failure leaves
245    /// `HEAD` unknown, so it is re-read before the next attempt.
246    async fn cas_head(&self, prev: Option<&Remote>, next: &Manifest) -> Result<u64> {
247        let body = Bytes::from(next.encode());
248        match fenced::cas(self.objects, &self.head_key(), prev.map(|r| r.head_gen), body).await {
249            Ok(g) => Ok(g),
250            Err(e) => {
251                self.remember(None);
252                Err(e.into())
253            }
254        }
255    }
256
257    /// Download and verify everything `m` names — the base and every
258    /// segment concurrently.
259    async fn fetch_all(&self, m: &Manifest) -> Result<(Bytes, Vec<Bytes>)> {
260        let base_key = self.base_key(m.generation);
261        let base = async {
262            Ok(fenced::fetch_verified(self.objects, &base_key, m.base_len, m.base_sum).await?)
263        };
264        let segs = self.fetch_segments(m, m.segments.iter());
265        let (base, segs) = futures_util::future::try_join(base, segs).await?;
266        // The bytes were checked against the manifest's checksum; the store
267        // validates them once when it opens them. Only the generation here.
268        let generation = base::generation_of(&base).map_err(|e| corrupt(format!("base: {e}")))?;
269        if generation != m.generation {
270            return Err(corrupt("base generation differs from the manifest"));
271        }
272        Ok((base, segs))
273    }
274
275    /// Fetch and verify segments concurrently, in manifest order.
276    async fn fetch_segments<'s>(
277        &'s self,
278        m: &'s Manifest,
279        wanted: impl Iterator<Item = &'s Segment>,
280    ) -> Result<Vec<Bytes>> {
281        let keys: Vec<(String, Segment)> =
282            wanted.map(|s| (self.seg_key(m.generation, s), *s)).collect();
283        futures_util::future::try_join_all(keys.iter().map(|(key, s)| async move {
284            let b = fenced::fetch_verified(self.objects, key, s.len, s.sum).await?;
285            check_framing(&b, s.start, m.generation)?;
286            Ok::<_, Error>(b)
287        }))
288        .await
289    }
290
291    /// Serve the published store straight from memory — no directory, no
292    /// file writes: the base is used in place ([`Store::open_image`]). `None`
293    /// when nothing has been published. Keep it current with
294    /// [`Mirror::refresh_image`].
295    pub fn load_image(&self) -> impl Future<Output = Result<Option<Store>>> + '_ {
296        async move {
297            let Some(remote) = self.fetch_remote().await? else { return Ok(None) };
298            if remote.manifest.generation == 0 {
299                return Ok(None);
300            }
301            let (base, segs) = self.fetch_all(&remote.manifest).await?;
302            let wal = concat(&segs, remote.manifest.wal_len());
303            let store = Store::open_image(base, &wal)?;
304            let mut st = self.lock();
305            st.image_wal = wal;
306            st.remote = Some(Remote { local_verified: true, ..remote });
307            Ok(Some(store))
308        }
309    }
310
311    /// Keep a [`Mirror::load_image`] replica current: one metadata request
312    /// when nothing changed, only the new log bytes when the log grew, the
313    /// new base after a compaction. Returns `true` when anything new became
314    /// visible.
315    pub fn refresh_image<'a>(
316        &'a self,
317        store: &'a Store,
318    ) -> impl Future<Output = Result<bool>> + 'a {
319        async move {
320            let cached = self.lock().remote.clone();
321            let Some((remote, change)) = self.changes(cached.as_ref(), false, |_| true).await?
322            else {
323                return Ok(false);
324            };
325            let changed = match change {
326                Change::Tail(tail) => {
327                    let mut st = self.lock();
328                    let have = cached.as_ref().map_or(0, |c| c.manifest.wal_len());
329                    if st.image_wal.len() as u64 != have {
330                        return Err(corrupt("replica log image out of step with the manifest"));
331                    }
332                    for b in &tail {
333                        st.image_wal.extend_from_slice(b);
334                    }
335                    store.reload_image(None, &st.image_wal)?
336                }
337                Change::Full(base, segs) => {
338                    let wal = concat(&segs, remote.manifest.wal_len());
339                    let changed = store.reload_image(Some(base), &wal)?;
340                    self.lock().image_wal = wal;
341                    changed
342                }
343            };
344            self.remember(Some(Remote { local_verified: true, ..remote }));
345            Ok(changed)
346        }
347    }
348
349    /// What was published past `cached`: nothing (one `HEAD`) when it did not
350    /// move; only the log bytes `cached` lacks when it is a prefix of the
351    /// published state and `local_ok` accepts it; else the whole store.
352    /// `local` also requires the cached state to be verified on disk.
353    async fn changes(
354        &self,
355        cached: Option<&Remote>,
356        local: bool,
357        local_ok: impl Fn(&Remote) -> bool,
358    ) -> Result<Option<(Remote, Change)>> {
359        let meta = self.objects.head(&self.head_key()).await?;
360        match (&meta, cached) {
361            (None, _) => return Ok(None),
362            (Some(m), Some(c)) if m.generation == c.head_gen && (!local || c.local_verified) => {
363                return Ok(None)
364            }
365            _ => {}
366        }
367        let Some(remote) = self.fetch_remote().await? else { return Ok(None) };
368        let m = &remote.manifest;
369        if m.generation == 0 {
370            return Ok(None);
371        }
372        let prefix = cached.filter(|c| {
373            c.manifest.generation == m.generation
374                && c.manifest.base_sum == m.base_sum
375                && m.wal_len() >= c.manifest.wal_len()
376                && local_ok(c)
377        });
378        let change = if let Some(c) = prefix {
379            let have = c.manifest.wal_len();
380            let wanted = || m.segments.iter().filter(|s| s.end() > have);
381            let segs = self.fetch_segments(m, wanted()).await?;
382            Change::Tail(
383                wanted()
384                    .zip(segs)
385                    .map(|(s, b)| {
386                        let skip =
387                            usize::try_from(have.saturating_sub(s.start)).unwrap_or(usize::MAX);
388                        b.slice(skip.min(b.len())..)
389                    })
390                    .collect(),
391            )
392        } else {
393            let (base, segs) = self.fetch_all(m).await?;
394            Change::Full(base, segs)
395        };
396        Ok(Some((remote, change)))
397    }
398
399    /// Rebuild `graph.base` and `graph.wal` in `dir` from the published
400    /// store, then open it with [`graph_r::Store::open`]. `None` (and `dir`
401    /// untouched) when nothing has been published. Refused with
402    /// `graph_r::Error::Locked` while a writable store has `dir` open.
403    pub fn pull_into<'a>(
404        &'a self,
405        dir: &'a Path,
406    ) -> impl Future<Output = Result<Option<Pulled>>> + 'a {
407        async move {
408            let Some(remote) = self.fetch_remote().await? else { return Ok(None) };
409            if remote.manifest.generation == 0 {
410                return Ok(None);
411            }
412            let _lock = fsio::lock_dir(dir)?;
413            let (base, segs) = self.fetch_all(&remote.manifest).await?;
414            write_store(dir, &base, &segs)?;
415            let bytes = base.len() as u64 + remote.manifest.wal_len();
416            let generation = remote.manifest.generation;
417            self.remember(Some(Remote { local_verified: true, ..remote }));
418            Ok(Some(Pulled { generation, bytes }))
419        }
420    }
421
422    /// Keep a read-only replica current: `store` must have been opened with
423    /// [`graph_r::Store::open_read_only`] on a directory this mirror pulled.
424    /// Returns `true` when anything new became visible.
425    pub fn refresh_into<'a>(&'a self, store: &'a Store) -> impl Future<Output = Result<bool>> + 'a {
426        async move {
427            let cached = self.lock().remote.clone();
428            let dir = store.dir();
429            let wal_path = dir.join(WAL_FILE);
430            let _lock = fsio::lock_dir(dir)?;
431            let local_len = std::fs::metadata(&wal_path).map(|md| md.len()).ok();
432            let local_ok = |c: &Remote| c.local_verified && local_len == Some(c.manifest.wal_len());
433            let Some((remote, change)) = self.changes(cached.as_ref(), true, local_ok).await?
434            else {
435                return Ok(false);
436            };
437            match change {
438                Change::Tail(tail) => {
439                    let parts: Vec<&[u8]> = tail.iter().map(AsRef::as_ref).collect();
440                    fsio::append(&wal_path, &parts)?;
441                }
442                Change::Full(base, segs) => write_store(dir, &base, &segs)?,
443            }
444            self.remember(Some(Remote { local_verified: true, ..remote }));
445            store.reload_if_stale()?;
446            Ok(true)
447        }
448    }
449
450    /// Ship what `store` committed since the last publish. See the crate
451    /// docs; [`Error::Fenced`] means another writer owns the published store.
452    pub fn publish<'a>(&'a self, store: &'a Store) -> impl Future<Output = Result<Published>> + 'a {
453        async move {
454            let cached = self.lock().remote.clone();
455            let remote = match cached {
456                Some(r) => Some(r),
457                None => self.fetch_remote().await?,
458            };
459            let lease = self.lease_for_write(remote.as_ref())?;
460            let cut = local_cut(store, remote.as_ref())?;
461            let prev_segments =
462                remote.as_ref().map(|r| r.manifest.segments.clone()).unwrap_or_default();
463            let prev_gen = remote.as_ref().map_or(0, |r| r.manifest.generation);
464            let mut next = remote.as_ref().map(|r| r.manifest.clone()).unwrap_or_default();
465            next.lease = lease;
466            let (uploaded, new_base, stale): (u64, bool, Vec<String>) = match cut {
467                Cut::Nothing { generation, wal_len } => {
468                    if let Some(r) = remote {
469                        self.remember(Some(Remote { local_verified: true, ..r }));
470                    }
471                    return Ok(Published { generation, wal_len, uploaded: 0, new_base: false });
472                }
473                Cut::Tail { generation, start, frames } => {
474                    let s = Segment { start, len: frames.len() as u64, sum: xxh3_64(&frames) };
475                    fenced::put_immutable(
476                        self.objects,
477                        &self.seg_key(generation, &s),
478                        Bytes::from(frames),
479                    )
480                    .await?;
481                    next.segments.push(s);
482                    (s.len, false, Vec::new())
483                }
484                Cut::Coalesce { generation, wal } => {
485                    let s = Segment { start: 0, len: wal.len() as u64, sum: xxh3_64(&wal) };
486                    fenced::put_immutable(
487                        self.objects,
488                        &self.seg_key(generation, &s),
489                        Bytes::from(wal),
490                    )
491                    .await?;
492                    let stale = prev_segments.iter().map(|p| self.seg_key(generation, p)).collect();
493                    next.segments = vec![s];
494                    (s.len, false, stale)
495                }
496                Cut::Full { generation, base, wal } => {
497                    let base_sum = xxh3_64(&base);
498                    let base_len = base.len() as u64;
499                    fenced::put_immutable(
500                        self.objects,
501                        &self.base_key(generation),
502                        Bytes::from(base),
503                    )
504                    .await?;
505                    let s = Segment { start: 0, len: wal.len() as u64, sum: xxh3_64(&wal) };
506                    fenced::put_immutable(
507                        self.objects,
508                        &self.seg_key(generation, &s),
509                        Bytes::from(wal),
510                    )
511                    .await?;
512                    next.generation = generation;
513                    next.base_len = base_len;
514                    next.base_sum = base_sum;
515                    next.segments = vec![s];
516                    let stale = if prev_gen != 0 && prev_gen != generation {
517                        vec![self.base_key(prev_gen), self.gen_dir(prev_gen)]
518                    } else {
519                        Vec::new()
520                    };
521                    (base_len + s.len, true, stale)
522                }
523            };
524            let head_gen = self.cas_head(remote.as_ref(), &next).await?;
525            let published = Published {
526                generation: next.generation,
527                wal_len: next.wal_len(),
528                uploaded,
529                new_base,
530            };
531            self.remember(Some(Remote { head_gen, manifest: next, local_verified: true }));
532            self.cleanup(&stale).await;
533            Ok(published)
534        }
535    }
536
537    /// Best-effort removal of objects no manifest names any more (a bucket
538    /// lifecycle rule is the backstop). Keys ending in `/` are directories.
539    async fn cleanup(&self, stale: &[String]) {
540        for key in stale {
541            if key.ends_with('/') {
542                if let Ok(list) = self.objects.list(key).await {
543                    for m in list {
544                        let _ = self.objects.delete(&m.key).await;
545                    }
546                }
547            } else {
548                let _ = self.objects.delete(key).await;
549            }
550        }
551    }
552
553    /// The lease to write with the next manifest, or why writing is refused.
554    fn lease_for_write(&self, remote: Option<&Remote>) -> Result<Option<Lease>> {
555        let st = self.lock();
556        let current = remote.and_then(|r| r.manifest.lease.clone());
557        let now = now_ms();
558        match (&st.holder, current) {
559            (Some(h), Some(l)) if l.holder != h.name && l.expires_ms > now => {
560                Err(Error::Leased { holder: l.holder, expires_ms: l.expires_ms })
561            }
562            (Some(h), _) => {
563                Ok(Some(Lease { holder: h.name.clone(), expires_ms: now.saturating_add(h.ttl_ms) }))
564            }
565            (None, Some(l)) if l.expires_ms > now => {
566                Err(Error::Leased { holder: l.holder, expires_ms: l.expires_ms })
567            }
568            (None, other) => Ok(other.filter(|l| l.expires_ms > now)),
569        }
570    }
571
572    /// Become (or stay) the designated writer for `ttl_ms`. Every
573    /// [`Mirror::publish`] renews it. Refused with [`Error::Leased`] while
574    /// someone else's lease is unexpired. Clocks of competing instances are
575    /// assumed to agree to well within `ttl_ms`.
576    pub fn acquire_lease<'a>(
577        &'a self,
578        holder: &'a str,
579        ttl_ms: u64,
580    ) -> impl Future<Output = Result<Lease>> + 'a {
581        async move {
582            if holder.is_empty() || holder.len() > manifest::MAX_HOLDER {
583                return Err(corrupt("lease holder must be 1..=256 bytes"));
584            }
585            let remote = self.fetch_remote().await?;
586            let now = now_ms();
587            if let Some(l) = remote.as_ref().and_then(|r| r.manifest.lease.as_ref()) {
588                if l.holder != holder && l.expires_ms > now {
589                    return Err(Error::Leased {
590                        holder: l.holder.clone(),
591                        expires_ms: l.expires_ms,
592                    });
593                }
594            }
595            let mut next = remote.as_ref().map(|r| r.manifest.clone()).unwrap_or_default();
596            let lease = Lease { holder: holder.to_owned(), expires_ms: now.saturating_add(ttl_ms) };
597            next.lease = Some(lease.clone());
598            let head_gen = self.cas_head(remote.as_ref(), &next).await?;
599            let verified = remote.as_ref().is_some_and(|r| r.local_verified);
600            let mut st = self.lock();
601            st.remote = Some(Remote { head_gen, manifest: next, local_verified: verified });
602            st.holder = Some(Holder { name: holder.to_owned(), ttl_ms });
603            Ok(lease)
604        }
605    }
606
607    /// Give the lease up (no-op unless this mirror holds it).
608    pub fn release_lease(&self) -> impl Future<Output = Result<()>> + '_ {
609        async move {
610            let Some(name) = self.lock().holder.take().map(|h| h.name) else { return Ok(()) };
611            let Some(remote) = self.fetch_remote().await? else { return Ok(()) };
612            if remote.manifest.lease.as_ref().is_none_or(|l| l.holder != name) {
613                return Ok(());
614            }
615            let mut next = remote.manifest.clone();
616            next.lease = None;
617            let head_gen = self.cas_head(Some(&remote), &next).await?;
618            self.remember(Some(Remote {
619                head_gen,
620                manifest: next,
621                local_verified: remote.local_verified,
622            }));
623            Ok(())
624        }
625    }
626}
627
628/// Segment bytes must be whole committed frames (and the first must carry a
629/// header for `generation`).
630fn check_framing(b: &[u8], start: u64, generation: u64) -> Result<()> {
631    let ok = if start == 0 {
632        wal::parse_header(b).ok() == Some(generation) && wal::committed_len(b) == b.len()
633    } else {
634        wal::committed_frames_len(b) == b.len()
635    };
636    if ok {
637        Ok(())
638    } else {
639        Err(corrupt("segment is not a run of committed frames"))
640    }
641}
642
643/// Join segments into one log image (replay needs it contiguous).
644fn concat(segs: &[Bytes], len: u64) -> Vec<u8> {
645    let mut wal = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
646    for s in segs {
647        wal.extend_from_slice(s);
648    }
649    wal
650}
651
652/// Base first, then the log — graph-r's own compaction order, so a reader in
653/// between sees the new base with a stale (ignored) log, never the reverse.
654fn write_store(dir: &Path, base: &Bytes, segs: &[Bytes]) -> Result<()> {
655    fsio::write_atomic(&dir.join(BASE_FILE), &[base])?;
656    let parts: Vec<&[u8]> = segs.iter().map(AsRef::as_ref).collect();
657    fsio::write_atomic(&dir.join(WAL_FILE), &parts)
658}
659
660/// Take a consistent local cut under the store's writer lock (no commit or
661/// compaction can interleave). Only file reads happen here.
662fn local_cut(store: &Store, remote: Option<&Remote>) -> Result<Cut> {
663    let _writer = store.writer()?;
664    let dir = store.dir();
665    let wal_path = dir.join(WAL_FILE);
666    let mut file = std::fs::File::open(&wal_path)?;
667    let mut header = [0u8; wal::HEADER_LEN];
668    file.read_exact(&mut header)?;
669    let generation = wal::parse_header(&header)?;
670    let published = remote.filter(|r| r.manifest.generation == generation);
671    if let Some(r) = remote {
672        if r.manifest.generation > generation {
673            return Err(Error::Fenced {
674                reason: "the published store is newer; pull before writing",
675            });
676        }
677    }
678    let Some(r) = published else {
679        let base = std::fs::read(dir.join(BASE_FILE))?;
680        if base::generation_of(&base)? != generation {
681            return Err(graph_r::Error::corrupt("base and log generations differ").into());
682        }
683        let mut wal = Vec::new();
684        file.seek(SeekFrom::Start(0))?;
685        file.read_to_end(&mut wal)?;
686        wal.truncate(wal::committed_len(&wal));
687        return Ok(Cut::Full { generation, base, wal });
688    };
689    let have = r.manifest.wal_len();
690    let file_len = file.metadata()?.len();
691    if file_len < have {
692        return Err(Error::Fenced {
693            reason: "the local log is shorter than the published one; pull first",
694        });
695    }
696    let coalesce = r.manifest.segments.len() >= COALESCE_SEGMENTS;
697    if r.local_verified && !coalesce {
698        let mut frames = Vec::with_capacity(usize::try_from(file_len - have).unwrap_or(0));
699        file.seek(SeekFrom::Start(have))?;
700        file.read_to_end(&mut frames)?;
701        frames.truncate(wal::committed_frames_len(&frames));
702        return Ok(if frames.is_empty() {
703            Cut::Nothing { generation, wal_len: have }
704        } else {
705            Cut::Tail { generation, start: have, frames }
706        });
707    }
708    let mut wal = Vec::with_capacity(usize::try_from(file_len).unwrap_or(0));
709    file.seek(SeekFrom::Start(0))?;
710    file.read_to_end(&mut wal)?;
711    if !r.local_verified {
712        // First publish from a directory this mirror did not produce: the
713        // published prefix must be exactly our prefix.
714        for s in &r.manifest.segments {
715            let (a, b) = (s.start as usize, s.end() as usize);
716            if wal.get(a..b).is_none_or(|bytes| xxh3_64(bytes) != s.sum) {
717                return Err(Error::Fenced {
718                    reason: "the local log diverges from the published one",
719                });
720            }
721        }
722    }
723    let committed = wal::HEADER_LEN + wal::committed_frames_len(&wal[wal::HEADER_LEN..]);
724    wal.truncate(committed);
725    if committed as u64 == have {
726        return Ok(Cut::Nothing { generation, wal_len: have });
727    }
728    if coalesce {
729        return Ok(Cut::Coalesce { generation, wal });
730    }
731    let frames = wal.split_off(have as usize);
732    Ok(Cut::Tail { generation, start: have, frames })
733}