Skip to main content

objstore_r/mirror/
mod.rs

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