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        let generation = base::generation_of(&base).map_err(|e| corrupt(format!("base: {e}")))?;
288        if generation != m.generation {
289            return Err(corrupt("base generation differs from the manifest"));
290        }
291        Ok((base, segs))
292    }
293
294    /// Fetch and verify segments concurrently, in manifest order.
295    async fn fetch_segments<'s>(
296        &'s self,
297        m: &'s Manifest,
298        wanted: impl Iterator<Item = &'s Segment>,
299    ) -> Result<Vec<Bytes>> {
300        let keys: Vec<(String, Segment)> =
301            wanted.map(|s| (self.seg_key(m.generation, s), *s)).collect();
302        futures_util::future::try_join_all(keys.iter().map(|(key, s)| async move {
303            let b = fenced::fetch_verified(self.objects, key, s.len, s.sum).await?;
304            check_framing(&b, s.start, m.generation)?;
305            Ok::<_, Error>(b)
306        }))
307        .await
308    }
309
310    /// Serve the published store straight from memory — no directory, no
311    /// file writes: the base is used in place ([`Store::open_image`]). `None`
312    /// when nothing has been published. Keep it current with
313    /// [`Mirror::refresh_image`].
314    pub fn load_image(&self) -> impl Future<Output = Result<Option<Store>>> + '_ {
315        async move {
316            let Some(remote) = self.fetch_remote().await? else { return Ok(None) };
317            if remote.manifest.generation == 0 {
318                return Ok(None);
319            }
320            let (base, segs) = self.fetch_all(&remote.manifest).await?;
321            let wal = concat(&segs, remote.manifest.wal_len());
322            let store = Store::open_image(base, &wal)?;
323            let mut st = self.lock();
324            st.image_wal = wal;
325            st.remote = Some(Remote { local_verified: true, ..remote });
326            Ok(Some(store))
327        }
328    }
329
330    /// Keep a [`Mirror::load_image`] replica current: one metadata request
331    /// when nothing changed, only the new log bytes when the log grew, the
332    /// new base after a compaction. Returns `true` when anything new became
333    /// visible.
334    pub fn refresh_image<'a>(
335        &'a self,
336        store: &'a Store,
337    ) -> impl Future<Output = Result<bool>> + 'a {
338        async move {
339            let cached = self.lock().remote.clone();
340            let Some((remote, change)) = self.changes(cached.as_ref(), false, |_| true).await?
341            else {
342                return Ok(false);
343            };
344            let changed = match change {
345                Change::Tail(tail) => {
346                    let mut st = self.lock();
347                    let have = cached.as_ref().map_or(0, |c| c.manifest.wal_len());
348                    if st.image_wal.len() as u64 != have {
349                        return Err(corrupt("replica log image out of step with the manifest"));
350                    }
351                    for b in &tail {
352                        st.image_wal.extend_from_slice(b);
353                    }
354                    store.reload_image(None, &st.image_wal)?
355                }
356                Change::Full(base, segs) => {
357                    let wal = concat(&segs, remote.manifest.wal_len());
358                    let changed = store.reload_image(Some(base), &wal)?;
359                    self.lock().image_wal = wal;
360                    changed
361                }
362            };
363            self.remember(Some(Remote { local_verified: true, ..remote }));
364            Ok(changed)
365        }
366    }
367
368    /// What was published past `cached`: nothing (one `HEAD`) when it did not
369    /// move; only the log bytes `cached` lacks when it is a prefix of the
370    /// published state and `local_ok` accepts it; else the whole store.
371    /// `local` also requires the cached state to be verified on disk.
372    async fn changes(
373        &self,
374        cached: Option<&Remote>,
375        local: bool,
376        local_ok: impl Fn(&Remote) -> bool,
377    ) -> Result<Option<(Remote, Change)>> {
378        let meta = self.objects.head(&self.head_key()).await?;
379        match (&meta, cached) {
380            (None, _) => return Ok(None),
381            (Some(m), Some(c)) if m.generation == c.head_gen && (!local || c.local_verified) => {
382                return Ok(None)
383            }
384            _ => {}
385        }
386        let Some(remote) = self.fetch_remote().await? else { return Ok(None) };
387        let m = &remote.manifest;
388        if m.generation == 0 {
389            return Ok(None);
390        }
391        let prefix = cached.filter(|c| {
392            c.manifest.generation == m.generation
393                && c.manifest.base_sum == m.base_sum
394                && m.wal_len() >= c.manifest.wal_len()
395                && local_ok(c)
396        });
397        let change = if let Some(c) = prefix {
398            let have = c.manifest.wal_len();
399            let wanted = || m.segments.iter().filter(|s| s.end() > have);
400            let segs = self.fetch_segments(m, wanted()).await?;
401            Change::Tail(
402                wanted()
403                    .zip(segs)
404                    .map(|(s, b)| {
405                        let skip =
406                            usize::try_from(have.saturating_sub(s.start)).unwrap_or(usize::MAX);
407                        b.slice(skip.min(b.len())..)
408                    })
409                    .collect(),
410            )
411        } else {
412            let (base, segs) = self.fetch_all(m).await?;
413            Change::Full(base, segs)
414        };
415        Ok(Some((remote, change)))
416    }
417
418    /// Rebuild `graph.base` and `graph.wal` in `dir` from the published
419    /// store, then open it with [`graph_r::Store::open`]. `None` (and `dir`
420    /// untouched) when nothing has been published. Refused with
421    /// `graph_r::Error::Locked` while a writable store has `dir` open.
422    pub fn pull_into<'a>(
423        &'a self,
424        dir: &'a Path,
425    ) -> impl Future<Output = Result<Option<Pulled>>> + 'a {
426        async move {
427            let Some(remote) = self.fetch_remote().await? else { return Ok(None) };
428            if remote.manifest.generation == 0 {
429                return Ok(None);
430            }
431            let _lock = fsio::lock_dir(dir)?;
432            let (base, segs) = self.fetch_all(&remote.manifest).await?;
433            write_store(dir, &base, &segs)?;
434            let bytes = base.len() as u64 + remote.manifest.wal_len();
435            let generation = remote.manifest.generation;
436            self.remember(Some(Remote { local_verified: true, ..remote }));
437            Ok(Some(Pulled { generation, bytes }))
438        }
439    }
440
441    /// Keep a read-only replica current: `store` must have been opened with
442    /// [`graph_r::Store::open_read_only`] on a directory this mirror pulled.
443    /// Returns `true` when anything new became visible.
444    pub fn refresh_into<'a>(&'a self, store: &'a Store) -> impl Future<Output = Result<bool>> + 'a {
445        async move {
446            let cached = self.lock().remote.clone();
447            let dir = store.dir();
448            let wal_path = dir.join(WAL_FILE);
449            let _lock = fsio::lock_dir(dir)?;
450            let local_len = std::fs::metadata(&wal_path).map(|md| md.len()).ok();
451            let local_ok = |c: &Remote| c.local_verified && local_len == Some(c.manifest.wal_len());
452            let Some((remote, change)) = self.changes(cached.as_ref(), true, local_ok).await?
453            else {
454                return Ok(false);
455            };
456            match change {
457                Change::Tail(tail) => {
458                    let parts: Vec<&[u8]> = tail.iter().map(AsRef::as_ref).collect();
459                    fsio::append(&wal_path, &parts)?;
460                }
461                Change::Full(base, segs) => write_store(dir, &base, &segs)?,
462            }
463            self.remember(Some(Remote { local_verified: true, ..remote }));
464            store.reload_if_stale()?;
465            Ok(true)
466        }
467    }
468
469    /// Ship what `store` committed since the last publish. See the crate
470    /// docs; [`Error::Fenced`] means another writer owns the published store.
471    pub fn publish<'a>(&'a self, store: &'a Store) -> impl Future<Output = Result<Published>> + 'a {
472        async move {
473            let cached = self.lock().remote.clone();
474            let remote = match cached {
475                Some(r) => Some(r),
476                None => self.fetch_remote().await?,
477            };
478            let lease = self.lease_for_write(remote.as_ref())?;
479            let cut = local_cut(store, remote.as_ref())?;
480            let prev_segments =
481                remote.as_ref().map(|r| r.manifest.segments.clone()).unwrap_or_default();
482            let prev_gen = remote.as_ref().map_or(0, |r| r.manifest.generation);
483            let mut next = remote.as_ref().map(|r| r.manifest.clone()).unwrap_or_default();
484            next.lease = lease;
485            let (uploaded, new_base, stale): (u64, bool, Vec<String>) = match cut {
486                Cut::Nothing { generation, wal_len } => {
487                    if let Some(r) = remote {
488                        self.remember(Some(Remote { local_verified: true, ..r }));
489                    }
490                    return Ok(Published { generation, wal_len, uploaded: 0, new_base: false });
491                }
492                Cut::Tail { generation, start, frames } => {
493                    let s = Segment { start, len: frames.len() as u64, sum: xxh3_64(&frames) };
494                    fenced::put_immutable(
495                        self.objects,
496                        &self.seg_key(generation, &s),
497                        Bytes::from(frames),
498                    )
499                    .await?;
500                    next.segments.push(s);
501                    (s.len, false, Vec::new())
502                }
503                Cut::Coalesce { generation, wal } => {
504                    let s = Segment { start: 0, len: wal.len() as u64, sum: xxh3_64(&wal) };
505                    fenced::put_immutable(
506                        self.objects,
507                        &self.seg_key(generation, &s),
508                        Bytes::from(wal),
509                    )
510                    .await?;
511                    let stale = prev_segments.iter().map(|p| self.seg_key(generation, p)).collect();
512                    next.segments = vec![s];
513                    (s.len, false, stale)
514                }
515                Cut::Full { generation, base, wal } => {
516                    let base_sum = xxh3_64(&base);
517                    let base_len = base.len() as u64;
518                    let (key, base) = (self.base_key(generation), Bytes::from(base));
519                    match self.base_encoding {
520                        None => fenced::put_immutable(self.objects, &key, base).await?,
521                        Some(e) => {
522                            fenced::put_immutable_encoded(self.objects, &key, base, e).await?;
523                        }
524                    }
525                    let s = Segment { start: 0, len: wal.len() as u64, sum: xxh3_64(&wal) };
526                    fenced::put_immutable(
527                        self.objects,
528                        &self.seg_key(generation, &s),
529                        Bytes::from(wal),
530                    )
531                    .await?;
532                    next.generation = generation;
533                    next.base_len = base_len;
534                    next.base_sum = base_sum;
535                    next.segments = vec![s];
536                    let stale = if prev_gen != 0 && prev_gen != generation {
537                        vec![self.base_key(prev_gen), self.gen_dir(prev_gen)]
538                    } else {
539                        Vec::new()
540                    };
541                    (base_len + s.len, true, stale)
542                }
543            };
544            let head_gen = self.cas_head(remote.as_ref(), &next).await?;
545            let published = Published {
546                generation: next.generation,
547                wal_len: next.wal_len(),
548                uploaded,
549                new_base,
550            };
551            self.remember(Some(Remote { head_gen, manifest: next, local_verified: true }));
552            self.cleanup(&stale).await;
553            Ok(published)
554        }
555    }
556
557    /// Best-effort removal of objects no manifest names any more (a bucket
558    /// lifecycle rule is the backstop). Keys ending in `/` are directories.
559    async fn cleanup(&self, stale: &[String]) {
560        for key in stale {
561            if key.ends_with('/') {
562                if let Ok(list) = self.objects.list(key).await {
563                    for m in list {
564                        let _ = self.objects.delete(&m.key).await;
565                    }
566                }
567            } else {
568                let _ = self.objects.delete(key).await;
569            }
570        }
571    }
572
573    /// The lease to write with the next manifest, or why writing is refused.
574    fn lease_for_write(&self, remote: Option<&Remote>) -> Result<Option<Lease>> {
575        let st = self.lock();
576        let current = remote.and_then(|r| r.manifest.lease.clone());
577        let now = now_ms();
578        match (&st.holder, current) {
579            (Some(h), Some(l)) if l.holder != h.name && l.expires_ms > now => {
580                Err(Error::Leased { holder: l.holder, expires_ms: l.expires_ms })
581            }
582            (Some(h), _) => {
583                Ok(Some(Lease { holder: h.name.clone(), expires_ms: now.saturating_add(h.ttl_ms) }))
584            }
585            (None, Some(l)) if l.expires_ms > now => {
586                Err(Error::Leased { holder: l.holder, expires_ms: l.expires_ms })
587            }
588            (None, other) => Ok(other.filter(|l| l.expires_ms > now)),
589        }
590    }
591
592    /// Become (or stay) the designated writer for `ttl_ms`. Every
593    /// [`Mirror::publish`] renews it. Refused with [`Error::Leased`] while
594    /// someone else's lease is unexpired. Clocks of competing instances are
595    /// assumed to agree to well within `ttl_ms`.
596    pub fn acquire_lease<'a>(
597        &'a self,
598        holder: &'a str,
599        ttl_ms: u64,
600    ) -> impl Future<Output = Result<Lease>> + 'a {
601        async move {
602            if holder.is_empty() || holder.len() > manifest::MAX_HOLDER {
603                return Err(corrupt("lease holder must be 1..=256 bytes"));
604            }
605            let remote = self.fetch_remote().await?;
606            let now = now_ms();
607            if let Some(l) = remote.as_ref().and_then(|r| r.manifest.lease.as_ref()) {
608                if l.holder != holder && l.expires_ms > now {
609                    return Err(Error::Leased {
610                        holder: l.holder.clone(),
611                        expires_ms: l.expires_ms,
612                    });
613                }
614            }
615            let mut next = remote.as_ref().map(|r| r.manifest.clone()).unwrap_or_default();
616            let lease = Lease { holder: holder.to_owned(), expires_ms: now.saturating_add(ttl_ms) };
617            next.lease = Some(lease.clone());
618            let head_gen = self.cas_head(remote.as_ref(), &next).await?;
619            let verified = remote.as_ref().is_some_and(|r| r.local_verified);
620            let mut st = self.lock();
621            st.remote = Some(Remote { head_gen, manifest: next, local_verified: verified });
622            st.holder = Some(Holder { name: holder.to_owned(), ttl_ms });
623            Ok(lease)
624        }
625    }
626
627    /// Give the lease up (no-op unless this mirror holds it).
628    pub fn release_lease(&self) -> impl Future<Output = Result<()>> + '_ {
629        async move {
630            let Some(name) = self.lock().holder.take().map(|h| h.name) else { return Ok(()) };
631            let Some(remote) = self.fetch_remote().await? else { return Ok(()) };
632            if remote.manifest.lease.as_ref().is_none_or(|l| l.holder != name) {
633                return Ok(());
634            }
635            let mut next = remote.manifest.clone();
636            next.lease = None;
637            let head_gen = self.cas_head(Some(&remote), &next).await?;
638            self.remember(Some(Remote {
639                head_gen,
640                manifest: next,
641                local_verified: remote.local_verified,
642            }));
643            Ok(())
644        }
645    }
646}
647
648/// Segment bytes must be whole committed frames (and the first must carry a
649/// header for `generation`).
650fn check_framing(b: &[u8], start: u64, generation: u64) -> Result<()> {
651    let ok = if start == 0 {
652        wal::parse_header(b).ok() == Some(generation) && wal::committed_len(b) == b.len()
653    } else {
654        wal::committed_frames_len(b) == b.len()
655    };
656    if ok {
657        Ok(())
658    } else {
659        Err(corrupt("segment is not a run of committed frames"))
660    }
661}
662
663/// Join segments into one log image (replay needs it contiguous).
664fn concat(segs: &[Bytes], len: u64) -> Vec<u8> {
665    let mut wal = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
666    for s in segs {
667        wal.extend_from_slice(s);
668    }
669    wal
670}
671
672/// Base first, then the log — graph-r's own compaction order, so a reader in
673/// between sees the new base with a stale (ignored) log, never the reverse.
674fn write_store(dir: &Path, base: &Bytes, segs: &[Bytes]) -> Result<()> {
675    fsio::write_atomic(&dir.join(BASE_FILE), &[base])?;
676    let parts: Vec<&[u8]> = segs.iter().map(AsRef::as_ref).collect();
677    fsio::write_atomic(&dir.join(WAL_FILE), &parts)
678}
679
680/// Take a consistent local cut under the store's writer lock (no commit or
681/// compaction can interleave). Only file reads happen here.
682fn local_cut(store: &Store, remote: Option<&Remote>) -> Result<Cut> {
683    let _writer = store.writer()?;
684    let dir = store.dir();
685    let wal_path = dir.join(WAL_FILE);
686    let mut file = std::fs::File::open(&wal_path)?;
687    let mut header = [0u8; wal::HEADER_LEN];
688    file.read_exact(&mut header)?;
689    let generation = wal::parse_header(&header)?;
690    let published = remote.filter(|r| r.manifest.generation == generation);
691    if let Some(r) = remote {
692        if r.manifest.generation > generation {
693            return Err(Error::Fenced {
694                reason: "the published store is newer; pull before writing",
695            });
696        }
697    }
698    let Some(r) = published else {
699        let base = std::fs::read(dir.join(BASE_FILE))?;
700        if base::generation_of(&base)? != generation {
701            return Err(graph_r::Error::corrupt("base and log generations differ").into());
702        }
703        let mut wal = Vec::new();
704        file.seek(SeekFrom::Start(0))?;
705        file.read_to_end(&mut wal)?;
706        wal.truncate(wal::committed_len(&wal));
707        return Ok(Cut::Full { generation, base, wal });
708    };
709    let have = r.manifest.wal_len();
710    let file_len = file.metadata()?.len();
711    if file_len < have {
712        return Err(Error::Fenced {
713            reason: "the local log is shorter than the published one; pull first",
714        });
715    }
716    let coalesce = r.manifest.segments.len() >= COALESCE_SEGMENTS;
717    if r.local_verified && !coalesce {
718        let mut frames = Vec::with_capacity(usize::try_from(file_len - have).unwrap_or(0));
719        file.seek(SeekFrom::Start(have))?;
720        file.read_to_end(&mut frames)?;
721        frames.truncate(wal::committed_frames_len(&frames));
722        return Ok(if frames.is_empty() {
723            Cut::Nothing { generation, wal_len: have }
724        } else {
725            Cut::Tail { generation, start: have, frames }
726        });
727    }
728    let mut wal = Vec::with_capacity(usize::try_from(file_len).unwrap_or(0));
729    file.seek(SeekFrom::Start(0))?;
730    file.read_to_end(&mut wal)?;
731    if !r.local_verified {
732        // First publish from a directory this mirror did not produce: the
733        // published prefix must be exactly our prefix.
734        for s in &r.manifest.segments {
735            let (a, b) = (s.start as usize, s.end() as usize);
736            if wal.get(a..b).is_none_or(|bytes| xxh3_64(bytes) != s.sum) {
737                return Err(Error::Fenced {
738                    reason: "the local log diverges from the published one",
739                });
740            }
741        }
742    }
743    let committed = wal::HEADER_LEN + wal::committed_frames_len(&wal[wal::HEADER_LEN..]);
744    wal.truncate(committed);
745    if committed as u64 == have {
746        return Ok(Cut::Nothing { generation, wal_len: have });
747    }
748    if coalesce {
749        return Ok(Cut::Coalesce { generation, wal });
750    }
751    let frames = wal.split_off(have as usize);
752    Ok(Cut::Tail { generation, start: have, frames })
753}