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 another holder has the directory's
422    /// writer lock. A writable store with `dir` open either holds that lock
423    /// for its life (graph-r 0.4) or takes it per commit (0.5), in which case
424    /// its next commit builds on the pulled state.
425    pub fn pull_into<'a>(
426        &'a self,
427        dir: &'a Path,
428    ) -> impl Future<Output = Result<Option<Pulled>>> + 'a {
429        async move {
430            let Some(remote) = self.fetch_remote().await? else { return Ok(None) };
431            if remote.manifest.generation == 0 {
432                return Ok(None);
433            }
434            let _lock = fsio::lock_dir(dir)?;
435            let (base, segs) = self.fetch_all(&remote.manifest).await?;
436            write_store(dir, &base, &segs)?;
437            let bytes = base.len() as u64 + remote.manifest.wal_len();
438            let generation = remote.manifest.generation;
439            self.remember(Some(Remote { local_verified: true, ..remote }));
440            Ok(Some(Pulled { generation, bytes }))
441        }
442    }
443
444    /// Keep a read-only replica current: `store` must have been opened with
445    /// [`graph_r::Store::open_read_only`] on a directory this mirror pulled.
446    /// Returns `true` when anything new became visible.
447    pub fn refresh_into<'a>(&'a self, store: &'a Store) -> impl Future<Output = Result<bool>> + 'a {
448        async move {
449            let cached = self.lock().remote.clone();
450            let dir = store.dir();
451            let wal_path = dir.join(WAL_FILE);
452            let _lock = fsio::lock_dir(dir)?;
453            let local_len = std::fs::metadata(&wal_path).map(|md| md.len()).ok();
454            let local_ok = |c: &Remote| c.local_verified && local_len == Some(c.manifest.wal_len());
455            let Some((remote, change)) = self.changes(cached.as_ref(), true, local_ok).await?
456            else {
457                return Ok(false);
458            };
459            match change {
460                Change::Tail(tail) => {
461                    let parts: Vec<&[u8]> = tail.iter().map(AsRef::as_ref).collect();
462                    fsio::append(&wal_path, &parts)?;
463                }
464                Change::Full(base, segs) => write_store(dir, &base, &segs)?,
465            }
466            self.remember(Some(Remote { local_verified: true, ..remote }));
467            store.reload_if_stale()?;
468            Ok(true)
469        }
470    }
471
472    /// Ship what `store` committed since the last publish. See the crate
473    /// docs; [`Error::Fenced`] means another writer owns the published store.
474    pub fn publish<'a>(&'a self, store: &'a Store) -> impl Future<Output = Result<Published>> + 'a {
475        async move {
476            let cached = self.lock().remote.clone();
477            let remote = match cached {
478                Some(r) => Some(r),
479                None => self.fetch_remote().await?,
480            };
481            let lease = self.lease_for_write(remote.as_ref())?;
482            let cut = local_cut(store, remote.as_ref())?;
483            let prev_segments =
484                remote.as_ref().map(|r| r.manifest.segments.clone()).unwrap_or_default();
485            let prev_gen = remote.as_ref().map_or(0, |r| r.manifest.generation);
486            let mut next = remote.as_ref().map(|r| r.manifest.clone()).unwrap_or_default();
487            next.lease = lease;
488            let (uploaded, new_base, stale): (u64, bool, Vec<String>) = match cut {
489                Cut::Nothing { generation, wal_len } => {
490                    if let Some(r) = remote {
491                        self.remember(Some(Remote { local_verified: true, ..r }));
492                    }
493                    return Ok(Published { generation, wal_len, uploaded: 0, new_base: false });
494                }
495                Cut::Tail { generation, start, frames } => {
496                    let s = Segment { start, len: frames.len() as u64, sum: xxh3_64(&frames) };
497                    fenced::put_immutable(
498                        self.objects,
499                        &self.seg_key(generation, &s),
500                        Bytes::from(frames),
501                    )
502                    .await?;
503                    next.segments.push(s);
504                    (s.len, false, Vec::new())
505                }
506                Cut::Coalesce { generation, wal } => {
507                    let s = Segment { start: 0, len: wal.len() as u64, sum: xxh3_64(&wal) };
508                    fenced::put_immutable(
509                        self.objects,
510                        &self.seg_key(generation, &s),
511                        Bytes::from(wal),
512                    )
513                    .await?;
514                    let stale = prev_segments.iter().map(|p| self.seg_key(generation, p)).collect();
515                    next.segments = vec![s];
516                    (s.len, false, stale)
517                }
518                Cut::Full { generation, base, wal } => {
519                    let base_sum = xxh3_64(&base);
520                    let base_len = base.len() as u64;
521                    let (key, base) = (self.base_key(generation), Bytes::from(base));
522                    match self.base_encoding {
523                        None => fenced::put_immutable(self.objects, &key, base).await?,
524                        Some(e) => {
525                            fenced::put_immutable_encoded(self.objects, &key, base, e).await?;
526                        }
527                    }
528                    let s = Segment { start: 0, len: wal.len() as u64, sum: xxh3_64(&wal) };
529                    fenced::put_immutable(
530                        self.objects,
531                        &self.seg_key(generation, &s),
532                        Bytes::from(wal),
533                    )
534                    .await?;
535                    next.generation = generation;
536                    next.base_len = base_len;
537                    next.base_sum = base_sum;
538                    next.segments = vec![s];
539                    let stale = if prev_gen != 0 && prev_gen != generation {
540                        vec![self.base_key(prev_gen), self.gen_dir(prev_gen)]
541                    } else {
542                        Vec::new()
543                    };
544                    (base_len + s.len, true, stale)
545                }
546            };
547            let head_gen = self.cas_head(remote.as_ref(), &next).await?;
548            let published = Published {
549                generation: next.generation,
550                wal_len: next.wal_len(),
551                uploaded,
552                new_base,
553            };
554            self.remember(Some(Remote { head_gen, manifest: next, local_verified: true }));
555            self.cleanup(&stale).await;
556            Ok(published)
557        }
558    }
559
560    /// Best-effort removal of objects no manifest names any more (a bucket
561    /// lifecycle rule is the backstop). Keys ending in `/` are directories.
562    async fn cleanup(&self, stale: &[String]) {
563        for key in stale {
564            if key.ends_with('/') {
565                if let Ok(list) = self.objects.list(key).await {
566                    for m in list {
567                        let _ = self.objects.delete(&m.key).await;
568                    }
569                }
570            } else {
571                let _ = self.objects.delete(key).await;
572            }
573        }
574    }
575
576    /// The lease to write with the next manifest, or why writing is refused.
577    fn lease_for_write(&self, remote: Option<&Remote>) -> Result<Option<Lease>> {
578        let st = self.lock();
579        let current = remote.and_then(|r| r.manifest.lease.clone());
580        let now = now_ms();
581        match (&st.holder, current) {
582            (Some(h), Some(l)) if l.holder != h.name && l.expires_ms > now => {
583                Err(Error::Leased { holder: l.holder, expires_ms: l.expires_ms })
584            }
585            (Some(h), _) => {
586                Ok(Some(Lease { holder: h.name.clone(), expires_ms: now.saturating_add(h.ttl_ms) }))
587            }
588            (None, Some(l)) if l.expires_ms > now => {
589                Err(Error::Leased { holder: l.holder, expires_ms: l.expires_ms })
590            }
591            (None, other) => Ok(other.filter(|l| l.expires_ms > now)),
592        }
593    }
594
595    /// Become (or stay) the designated writer for `ttl_ms`. Every
596    /// [`Mirror::publish`] renews it. Refused with [`Error::Leased`] while
597    /// someone else's lease is unexpired. Clocks of competing instances are
598    /// assumed to agree to well within `ttl_ms`.
599    pub fn acquire_lease<'a>(
600        &'a self,
601        holder: &'a str,
602        ttl_ms: u64,
603    ) -> impl Future<Output = Result<Lease>> + 'a {
604        async move {
605            if holder.is_empty() || holder.len() > manifest::MAX_HOLDER {
606                return Err(corrupt("lease holder must be 1..=256 bytes"));
607            }
608            let remote = self.fetch_remote().await?;
609            let now = now_ms();
610            if let Some(l) = remote.as_ref().and_then(|r| r.manifest.lease.as_ref()) {
611                if l.holder != holder && l.expires_ms > now {
612                    return Err(Error::Leased {
613                        holder: l.holder.clone(),
614                        expires_ms: l.expires_ms,
615                    });
616                }
617            }
618            let mut next = remote.as_ref().map(|r| r.manifest.clone()).unwrap_or_default();
619            let lease = Lease { holder: holder.to_owned(), expires_ms: now.saturating_add(ttl_ms) };
620            next.lease = Some(lease.clone());
621            let head_gen = self.cas_head(remote.as_ref(), &next).await?;
622            let verified = remote.as_ref().is_some_and(|r| r.local_verified);
623            let mut st = self.lock();
624            st.remote = Some(Remote { head_gen, manifest: next, local_verified: verified });
625            st.holder = Some(Holder { name: holder.to_owned(), ttl_ms });
626            Ok(lease)
627        }
628    }
629
630    /// Give the lease up (no-op unless this mirror holds it).
631    pub fn release_lease(&self) -> impl Future<Output = Result<()>> + '_ {
632        async move {
633            let Some(name) = self.lock().holder.take().map(|h| h.name) else { return Ok(()) };
634            let Some(remote) = self.fetch_remote().await? else { return Ok(()) };
635            if remote.manifest.lease.as_ref().is_none_or(|l| l.holder != name) {
636                return Ok(());
637            }
638            let mut next = remote.manifest.clone();
639            next.lease = None;
640            let head_gen = self.cas_head(Some(&remote), &next).await?;
641            self.remember(Some(Remote {
642                head_gen,
643                manifest: next,
644                local_verified: remote.local_verified,
645            }));
646            Ok(())
647        }
648    }
649}
650
651/// Segment bytes must be whole committed frames (and the first must carry a
652/// header for `generation`).
653fn check_framing(b: &[u8], start: u64, generation: u64) -> Result<()> {
654    let ok = if start == 0 {
655        wal::parse_header(b).ok() == Some(generation) && wal::committed_len(b) == b.len()
656    } else {
657        wal::committed_frames_len(b) == b.len()
658    };
659    if ok {
660        Ok(())
661    } else {
662        Err(corrupt("segment is not a run of committed frames"))
663    }
664}
665
666/// Join segments into one log image (replay needs it contiguous).
667fn concat(segs: &[Bytes], len: u64) -> Vec<u8> {
668    let mut wal = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
669    for s in segs {
670        wal.extend_from_slice(s);
671    }
672    wal
673}
674
675/// Base first, then the log — graph-r's own compaction order, so a reader in
676/// between sees the new base with a stale (ignored) log, never the reverse.
677fn write_store(dir: &Path, base: &Bytes, segs: &[Bytes]) -> Result<()> {
678    fsio::write_atomic(&dir.join(BASE_FILE), &[base])?;
679    let parts: Vec<&[u8]> = segs.iter().map(AsRef::as_ref).collect();
680    fsio::write_atomic(&dir.join(WAL_FILE), &parts)
681}
682
683/// Take a consistent local cut under the store's writer lock (no commit or
684/// compaction can interleave). Only file reads happen here.
685fn local_cut(store: &Store, remote: Option<&Remote>) -> Result<Cut> {
686    let _writer = store.writer()?;
687    let dir = store.dir();
688    let wal_path = dir.join(WAL_FILE);
689    let mut file = std::fs::File::open(&wal_path)?;
690    let mut header = [0u8; wal::HEADER_LEN];
691    file.read_exact(&mut header)?;
692    let generation = wal::parse_header(&header)?;
693    let published = remote.filter(|r| r.manifest.generation == generation);
694    if let Some(r) = remote {
695        if r.manifest.generation > generation {
696            return Err(Error::Fenced {
697                reason: "the published store is newer; pull before writing",
698            });
699        }
700    }
701    let Some(r) = published else {
702        let base = std::fs::read(dir.join(BASE_FILE))?;
703        if base::generation_of(&base)? != generation {
704            return Err(graph_r::Error::corrupt("base and log generations differ").into());
705        }
706        let mut wal = Vec::new();
707        file.seek(SeekFrom::Start(0))?;
708        file.read_to_end(&mut wal)?;
709        wal.truncate(wal::committed_len(&wal));
710        return Ok(Cut::Full { generation, base, wal });
711    };
712    let have = r.manifest.wal_len();
713    let file_len = file.metadata()?.len();
714    if file_len < have {
715        return Err(Error::Fenced {
716            reason: "the local log is shorter than the published one; pull first",
717        });
718    }
719    let coalesce = r.manifest.segments.len() >= COALESCE_SEGMENTS;
720    if r.local_verified && !coalesce {
721        let mut frames = Vec::with_capacity(usize::try_from(file_len - have).unwrap_or(0));
722        file.seek(SeekFrom::Start(have))?;
723        file.read_to_end(&mut frames)?;
724        frames.truncate(wal::committed_frames_len(&frames));
725        return Ok(if frames.is_empty() {
726            Cut::Nothing { generation, wal_len: have }
727        } else {
728            Cut::Tail { generation, start: have, frames }
729        });
730    }
731    let mut wal = Vec::with_capacity(usize::try_from(file_len).unwrap_or(0));
732    file.seek(SeekFrom::Start(0))?;
733    file.read_to_end(&mut wal)?;
734    if !r.local_verified {
735        // First publish from a directory this mirror did not produce: the
736        // published prefix must be exactly our prefix.
737        for s in &r.manifest.segments {
738            let (a, b) = (s.start as usize, s.end() as usize);
739            if wal.get(a..b).is_none_or(|bytes| xxh3_64(bytes) != s.sum) {
740                return Err(Error::Fenced {
741                    reason: "the local log diverges from the published one",
742                });
743            }
744        }
745    }
746    let committed = wal::HEADER_LEN + wal::committed_frames_len(&wal[wal::HEADER_LEN..]);
747    wal.truncate(committed);
748    if committed as u64 == have {
749        return Ok(Cut::Nothing { generation, wal_len: have });
750    }
751    if coalesce {
752        return Ok(Cut::Coalesce { generation, wal });
753    }
754    let frames = wal.split_off(have as usize);
755    Ok(Cut::Tail { generation, start: have, frames })
756}