Skip to main content

znippy_plugin_git/
indexer.rs

1//! The shared index path: **one indexer per account, off the ack path.**
2//!
3//! Every [`ArchiveWrite`] arm uses this. It is not per-impl and it is not part
4//! of the durability contract — it is what happens *after* `append` has already
5//! returned to the pushing client.
6//!
7//! ```text
8//!   push ──► ArchiveWrite::append ──► ack to client
9//!                    │
10//!                    └─ (pack_id, offset, len) ──► account's channel
11//!                                                        │
12//!                                       ┌────────────────┘  worker SLEEPS on
13//!                                       ▼                   recv() when empty
14//!                                  drain a batch
15//!                                       │
16//!                                  gatling fork-join  (NEVER rayon — LAW 3)
17//!                                       │           one pack row per job
18//!                                       ▼
19//!                                  ObjectAbsorb   ── the pack's OBJECT rows,
20//!                                       │            oids and all
21//!                                       ▼
22//!                                  Arrow index tables + "indexed" bit
23//! ```
24//!
25//! # One indexer per account
26//!
27//! Each account gets its **own** [`AccountIndexer`]: its own channel, its own
28//! worker. An account pushing a monorepo cannot delay another account's small
29//! push, because there is no queue and no worker between them to contend for.
30//! The target box is 192 cores / 8 TB; an idle account costs a **parked thread
31//! and nothing else** — the worker blocks in `recv()`, it does not poll and it
32//! does not spin. `idle_indexers_sleep_they_do_not_spin` measures that as CPU
33//! time rather than trusting the sentence.
34//!
35//! (This is deliberately *not* gunnar's `P-004` situation. There the fan-out is
36//! thousands of concurrent short-lived **requests**, which is why the serve path
37//! pins `thread_limit: Some(1)`. Here it is long-lived, mostly-idle **accounts**,
38//! and a parked thread per account is the cheap answer.)
39//!
40//! # Zero-copy handoff
41//!
42//! [`IndexJob`] is `{ pack_id, offset, len }` — 24 bytes, `Copy`, no pointer
43//! into the pack. **Extents cross the channel, never buffers.** The worker
44//! `pread`s the extent back when it gets to it, so a 2 GiB push costs the
45//! channel 24 bytes and the ack path zero copies.
46//!
47//! # Fan-out inside a drain is gatling
48//!
49//! A drained batch is handed to
50//! [`gatling_forkjoin::gatling_for_each`](znippy_zoomies::gatling_forkjoin::gatling_for_each):
51//! N workers self-dispatch off one atomic cursor, no barrier, `std::thread::scope`.
52//! **rayon is banned across this constellation (LAW 3)** and `rayon_free_law.rs`
53//! enforces it.
54//!
55//! # The object-level half
56//!
57//! A pack row is derivable from the extent alone — version, object count, a hash
58//! of the stored bytes. **Object rows are not.** They need the pack walked, its
59//! delta chains applied and every oid hashed, which is git-shaped work this file
60//! must not know how to do. So the drain calls *out*, through
61//! [`ObjectAbsorb`], and [`GitStore`](crate::git_ops::GitStore) implements it.
62//!
63//! The absorber it calls is **the same object a falling-back read calls**, not a
64//! background copy of it (LAW 5): one absorb, one gate, one place the `objects`
65//! table is written. The two paths cannot drift because there is only one of
66//! them.
67//!
68//! Ordering inside one drained job is fixed and it is the whole safety argument:
69//!
70//! ```text
71//!   pack row built  ─►  objects absorbed  ─►  indexed bit set
72//! ```
73//!
74//! The bit goes up **last**, so it cannot be set over an index that does not yet
75//! hold the pack's objects. An absorb that fails leaves the bit clear and the
76//! error counted ([`AccountIndexer::absorb_failures`]) — the pack stays on the
77//! fallback path, which is slow and right, rather than being declared indexed,
78//! which would be fast and wrong.
79//!
80//! # Why an early read cannot be wrong
81//!
82//! The index tables are built **last**. A read arriving between the ack and the
83//! index is therefore reading an archive whose index does not mention the pack.
84//! Rather than let it conclude "absent", every pack carries an **indexed bit**,
85//! which is membership in the published-row map — one structure, so the bit and
86//! the row cannot drift apart. [`AccountIndexer::lookup`] returns
87//! [`Lookup::Indexed`] only when the bit is set, and otherwise hands back the
88//! journal's extent list so the caller falls back to **scanning** — slower,
89//! never wrong. For
90//! [`FastWriter`](crate::archive_write::FastWriter) there is no journal to fall
91//! back to and the answer is [`Lookup::Unknowable`]: one more thing that arm
92//! does not promise.
93
94use std::collections::HashMap;
95use std::fs::File;
96use std::os::unix::fs::FileExt;
97use std::path::{Path, PathBuf};
98use std::sync::atomic::{AtomicU64, Ordering};
99use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
100use std::sync::{Arc, Condvar, Mutex};
101
102use anyhow::{Result, anyhow};
103use sha1::{Digest, Sha1};
104use znippy_common::arrow::array::{ArrayRef, StringArray, UInt32Array, UInt64Array};
105use znippy_common::arrow::datatypes::{DataType, Field, Schema, SchemaRef};
106use znippy_common::arrow::record_batch::RecordBatch;
107use znippy_zoomies::background::Job;
108use znippy_zoomies::gatling_forkjoin::gatling_for_each;
109
110use crate::archive_write::{ArchiveWrite, Extent, read_journal};
111
112/// At most this many jobs are folded into one gatling fan-out. Bounds the
113/// latency an early-arriving job pays behind a burst.
114const MAX_DRAIN_BATCH: usize = 4096;
115
116/// One unit of index work. **Offsets and extents only — never a buffer.**
117///
118/// 24 bytes, `Copy`, `'static`: nothing in it borrows the pushed pack, so the
119/// ack path can drop the client's buffer the instant `append` returns.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct IndexJob {
122    /// Dense id assigned at append time; the key the "indexed" bit is kept under.
123    pub pack_id: u64,
124    /// Where the verbatim pack bytes start in the archive.
125    pub offset: u64,
126    /// How many bytes.
127    pub len: u64,
128}
129
130/// One row of the built index — everything derivable from the pack bytes, which
131/// is exactly the work that was kept off the ack path.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct IndexRow {
134    pub pack_id: u64,
135    pub offset: u64,
136    pub len: u64,
137    /// Packfile format version from the header (`2` or `3`), `0` if unparseable.
138    pub version: u32,
139    /// Object count from the header, `0` if unparseable.
140    pub object_count: u32,
141    /// SHA-1 over the verbatim pack bytes, hex. Not the pack's own trailing
142    /// checksum — a checksum *of what was stored*, so a later scrub can prove
143    /// the archive still holds what was acked.
144    pub sha1: String,
145}
146
147/// The index table's Arrow schema.
148pub fn index_schema() -> SchemaRef {
149    Arc::new(Schema::new(vec![
150        Field::new("pack_id", DataType::UInt64, false),
151        Field::new("blob_offset", DataType::UInt64, false),
152        Field::new("blob_size", DataType::UInt64, false),
153        Field::new("pack_version", DataType::UInt32, false),
154        Field::new("object_count", DataType::UInt32, false),
155        Field::new("pack_sha1", DataType::Utf8, false),
156    ]))
157}
158
159fn rows_to_batch(rows: &[IndexRow]) -> Result<RecordBatch> {
160    let ids: ArrayRef = Arc::new(UInt64Array::from_iter_values(rows.iter().map(|r| r.pack_id)));
161    let offs: ArrayRef = Arc::new(UInt64Array::from_iter_values(rows.iter().map(|r| r.offset)));
162    let lens: ArrayRef = Arc::new(UInt64Array::from_iter_values(rows.iter().map(|r| r.len)));
163    let vers: ArrayRef = Arc::new(UInt32Array::from_iter_values(rows.iter().map(|r| r.version)));
164    let cnts: ArrayRef = Arc::new(UInt32Array::from_iter_values(
165        rows.iter().map(|r| r.object_count),
166    ));
167    let sha: ArrayRef = Arc::new(StringArray::from_iter_values(
168        rows.iter().map(|r| r.sha1.as_str()),
169    ));
170    RecordBatch::try_new(index_schema(), vec![ids, offs, lens, vers, cnts, sha])
171        .map_err(|e| anyhow!("index batch: {e}"))
172}
173
174/// Build one index row by reading the extent back out of the archive.
175///
176/// This is the work that is *not* on the ack path: a `pread` of the whole pack
177/// plus a SHA-1 over it. Runs on a gatling worker.
178fn build_row(archive: &File, job: IndexJob) -> IndexRow {
179    let mut buf = vec![0u8; job.len as usize];
180    if archive.read_exact_at(&mut buf, job.offset).is_err() {
181        return IndexRow {
182            pack_id: job.pack_id,
183            offset: job.offset,
184            len: job.len,
185            version: 0,
186            object_count: 0,
187            sha1: String::new(),
188        };
189    }
190    // P-4: a malformed or hostile entry never panics — a short or non-`PACK`
191    // buffer degrades to zeroes and the row still writes.
192    let (version, object_count) = if buf.len() >= 12 && &buf[0..4] == b"PACK" {
193        (
194            u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]),
195            u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]),
196        )
197    } else {
198        (0, 0)
199    };
200    let mut h = Sha1::new();
201    h.update(&buf);
202    IndexRow {
203        pack_id: job.pack_id,
204        offset: job.offset,
205        len: job.len,
206        version,
207        object_count,
208        sha1: hex::encode(h.finalize()),
209    }
210}
211
212/// **The object-level ingress**, called by the drain and by nothing else here.
213///
214/// The indexer knows extents; it does not know what a git object is, and it must
215/// not learn — the split is what keeps the pack grammar out of this file and the
216/// channel out of the store's. The implementor is
217/// [`GitStore`](crate::git_ops::GitStore)'s absorber, which is the *same* object
218/// a read falls back to, so the background path and the fallback path are one
219/// path (LAW 5).
220///
221/// The argument is an [`IndexJob`] — 24 bytes, `Copy`, no buffer. Zero-copy
222/// handoff survives the extra hop.
223pub trait ObjectAbsorb: Send + Sync {
224    /// Absorb this pack's objects into the object-level index.
225    ///
226    /// Must be **idempotent for a pack already absorbed**: the ack path records a
227    /// pack as pending and hands it to the channel in that order, and a fast
228    /// worker can arrive at either end first.
229    fn absorb(&self, job: IndexJob) -> Result<()>;
230}
231
232/// What a read gets when it asks for a pack.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum Lookup {
235    /// The index has it. O(1), and the row carries everything derived.
236    Indexed(Box<IndexRow>),
237    /// Not indexed **yet**. Here is the journal's extent list — the durable
238    /// record written on the ack path — so the caller can scan. Slower, never
239    /// wrong.
240    ScanJournal(Vec<Extent>),
241    /// Not indexed and there is no journal to scan
242    /// ([`FastWriter`](crate::archive_write::FastWriter)). Nothing can answer.
243    Unknowable,
244}
245
246#[derive(Default)]
247struct Built {
248    /// The index tables, built last.
249    batches: Vec<RecordBatch>,
250    /// **The indexed bit, and the row it publishes — one structure.**
251    ///
252    /// Membership is the bit: present means "the index can answer for this
253    /// pack", absent means "fall back to scanning". It is deliberately *not* a
254    /// separate bitmap beside a row map. Two structures would need a guard
255    /// watching them agree; one structure cannot disagree with itself (LAW 5 —
256    /// fix by construction, do not add a guard over two copies). A pack is
257    /// published by exactly one `insert`, under one lock, after its row is
258    /// complete **and after its objects are absorbed**, so no reader can ever
259    /// see a set bit with no row — or no object — behind it.
260    published: HashMap<u64, IndexRow>,
261    /// Packs whose [`ObjectAbsorb::absorb`] failed. Their bits stayed clear, so
262    /// they are still answerable by the fallback; this is how many times that
263    /// happened, which is otherwise invisible from outside the worker.
264    absorb_failures: u64,
265    /// The most recent absorb error, verbatim. A count with no message is a
266    /// number nobody can act on.
267    last_absorb_error: Option<String>,
268}
269
270struct Progress {
271    /// Jobs submitted minus jobs committed. `0` means the indexer is caught up.
272    outstanding: Mutex<u64>,
273    caught_up: Condvar,
274}
275
276/// One account's indexer: its own channel, its own worker, its own tables.
277pub struct AccountIndexer {
278    account: String,
279    tx: Option<Sender<IndexJob>>,
280    built: Arc<Mutex<Built>>,
281    progress: Arc<Progress>,
282    journal: Option<PathBuf>,
283    worker: Option<Job<()>>,
284}
285
286impl AccountIndexer {
287    /// Start an indexer for `account` reading extents back out of `archive`.
288    /// `journal` is the ack-path durable record a not-yet-indexed read falls
289    /// back to scanning; `None` for a writer that keeps none.
290    ///
291    /// **Pack rows only.** For the object rows as well, see
292    /// [`start_with_absorber`](AccountIndexer::start_with_absorber) — a writer
293    /// bench has no git store behind it and wants exactly this.
294    pub fn start(account: &str, archive: Arc<File>, journal: Option<PathBuf>) -> Self {
295        Self::start_with_absorber(account, archive, journal, None)
296    }
297
298    /// The same indexer with the **object-level half** wired in: each drained
299    /// job's objects are absorbed through `absorber` before the pack's indexed
300    /// bit goes up.
301    pub fn start_with_absorber(
302        account: &str,
303        archive: Arc<File>,
304        journal: Option<PathBuf>,
305        absorber: Option<Arc<dyn ObjectAbsorb>>,
306    ) -> Self {
307        let (tx, rx): (Sender<IndexJob>, Receiver<IndexJob>) = channel();
308        let built = Arc::new(Mutex::new(Built::default()));
309        let progress = Arc::new(Progress {
310            outstanding: Mutex::new(0),
311            caught_up: Condvar::new(),
312        });
313        let w_built = built.clone();
314        let w_progress = progress.clone();
315        // `gatling::background::Job` — the constellation's ONE sanctioned home
316        // for a long-lived background thread outside the engine itself. A raw
317        // `thread::spawn` or `thread::Builder` here trips
318        // `rayon_free_law::only_the_shared_gatling_engine_owns_a_worker_pool`,
319        // and rightly: every thread in this tree originates in gatling. This is
320        // gatling's depth-1 shape — one worker, joined at close — and the
321        // fan-out *inside* it is `gatling_for_each`.
322        let worker = Job::spawn(move || index_worker(archive, rx, w_built, w_progress, absorber));
323        Self {
324            account: account.to_string(),
325            tx: Some(tx),
326            built,
327            progress,
328            journal,
329            worker: Some(worker),
330        }
331    }
332
333    /// The account this indexer belongs to.
334    pub fn account(&self) -> &str {
335        &self.account
336    }
337
338    /// Hand an extent over. Returns immediately: 24 bytes onto a channel.
339    pub fn submit(&self, job: IndexJob) -> Result<()> {
340        {
341            let mut o = self
342                .progress
343                .outstanding
344                .lock()
345                .map_err(|_| anyhow!("indexer progress poisoned"))?;
346            *o += 1;
347        }
348        self.tx
349            .as_ref()
350            .ok_or_else(|| anyhow!("indexer already closed"))?
351            .send(job)
352            .map_err(|_| anyhow!("indexer worker is gone"))
353    }
354
355    /// Block until this account's index has caught up with everything submitted
356    /// so far. Used by tests and by the bench's "with index" column.
357    pub fn wait_caught_up(&self) {
358        let mut o = self.progress.outstanding.lock().unwrap();
359        while *o > 0 {
360            o = self.progress.caught_up.wait(o).unwrap();
361        }
362    }
363
364    /// Is this pack's **indexed bit** set?
365    pub fn is_indexed(&self, pack_id: u64) -> bool {
366        self.built.lock().unwrap().published.contains_key(&pack_id)
367    }
368
369    /// Number of index rows built so far.
370    pub fn rows(&self) -> usize {
371        self.built.lock().unwrap().published.len()
372    }
373
374    /// How many packs failed [`ObjectAbsorb::absorb`] in this indexer. Their
375    /// indexed bits are clear and their reads still fall back.
376    pub fn absorb_failures(&self) -> u64 {
377        self.built.lock().unwrap().absorb_failures
378    }
379
380    /// The most recent absorb error, if any.
381    pub fn last_absorb_error(&self) -> Option<String> {
382        self.built.lock().unwrap().last_absorb_error.clone()
383    }
384
385    /// The built index tables.
386    pub fn tables(&self) -> Vec<RecordBatch> {
387        self.built.lock().unwrap().batches.clone()
388    }
389
390    /// Answer a read, honestly, whether or not the index is ready.
391    pub fn lookup(&self, pack_id: u64) -> Lookup {
392        {
393            // The bit IS the row's presence. One lookup, one truth.
394            let b = self.built.lock().unwrap();
395            if let Some(r) = b.published.get(&pack_id) {
396                return Lookup::Indexed(Box::new(r.clone()));
397            }
398        }
399        match self.journal.as_deref() {
400            Some(p) => match read_journal(p) {
401                Ok(extents) => Lookup::ScanJournal(extents),
402                Err(_) => Lookup::Unknowable,
403            },
404            None => Lookup::Unknowable,
405        }
406    }
407
408    /// Close the channel and join the worker, returning the final tables.
409    pub fn finish(mut self) -> Vec<RecordBatch> {
410        self.tx = None;
411        if let Some(w) = self.worker.take() {
412            let _ = w.join();
413        }
414        self.built.lock().unwrap().batches.clone()
415    }
416}
417
418impl Drop for AccountIndexer {
419    fn drop(&mut self) {
420        self.tx = None;
421        if let Some(w) = self.worker.take() {
422            let _ = w.join();
423        }
424    }
425}
426
427/// The worker. **Sleeps when its channel is empty.**
428///
429/// `recv()` blocks — after a bounded spin `std`'s channel parks the thread on a
430/// futex, so an idle account burns no CPU at all. It wakes on the first job,
431/// then drains everything already queued behind it (`try_recv` until empty, up
432/// to [`MAX_DRAIN_BATCH`]) so a burst costs one fan-out rather than one per job.
433fn index_worker(
434    archive: Arc<File>,
435    rx: Receiver<IndexJob>,
436    built: Arc<Mutex<Built>>,
437    progress: Arc<Progress>,
438    absorber: Option<Arc<dyn ObjectAbsorb>>,
439) {
440    loop {
441        // ── SLEEP HERE ──────────────────────────────────────────────────────
442        let Ok(first) = rx.recv() else { return };
443        let mut batch = Vec::with_capacity(64);
444        batch.push(first);
445        loop {
446            if batch.len() >= MAX_DRAIN_BATCH {
447                break;
448            }
449            match rx.try_recv() {
450                Ok(j) => batch.push(j),
451                Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
452            }
453        }
454
455        // ── gatling fork-join. NEVER rayon (LAW 3). ─────────────────────────
456        let n = batch.len();
457        let arch = archive.as_ref();
458        let jobs = &batch;
459        let rows = gatling_for_each(n, 0, |i| build_row(arch, jobs[i]));
460
461        // ── the OBJECT-level half, out through `ObjectAbsorb` ───────────────
462        //
463        // Serial across packs on purpose: absorbing appends to one `objects`
464        // table and re-folds one commit graph, so the parallelism that pays
465        // here is the gatling fan-out above (one pread + one SHA-1 per pack),
466        // not N threads contending for the same two writers. A pack whose
467        // absorb fails is dropped from `ok` and keeps its bit clear.
468        let mut ok: Vec<IndexRow> = Vec::with_capacity(rows.len());
469        let mut failed: Vec<String> = Vec::new();
470        for (job, row) in batch.iter().zip(rows) {
471            match absorber.as_deref() {
472                Some(a) => match a.absorb(*job) {
473                    Ok(()) => ok.push(row),
474                    Err(e) => failed.push(format!("pack {}: {e:#}", job.pack_id)),
475                },
476                None => ok.push(row),
477            }
478        }
479
480        // ── index tables built LAST, after the bytes are down AND the
481        //    objects are in ───────────────────────────────────────────────────
482        let encoded = if ok.is_empty() {
483            None
484        } else {
485            // A batch that cannot be encoded leaves the bits unset: every one of
486            // its packs stays on the scan path.
487            rows_to_batch(&ok).ok()
488        };
489        {
490            let mut b = built.lock().unwrap();
491            if let Some(rb) = encoded {
492                b.batches.push(rb);
493                for r in ok {
494                    // Publishing the row IS setting the bit — one insert, one
495                    // lock, after the row is complete and its objects are
496                    // absorbed.
497                    b.published.insert(r.pack_id, r);
498                }
499            }
500            b.absorb_failures += failed.len() as u64;
501            if let Some(last) = failed.pop() {
502                b.last_absorb_error = Some(last);
503            }
504        }
505
506        let mut o = progress.outstanding.lock().unwrap();
507        *o = o.saturating_sub(n as u64);
508        if *o == 0 {
509            progress.caught_up.notify_all();
510        }
511    }
512}
513
514/// One indexer per account, created on first push from that account.
515pub struct IndexerPool {
516    archive: Arc<File>,
517    journal: Option<PathBuf>,
518    /// Shared by every account's worker. One store, one object table — the
519    /// per-account split is about *queueing*, not about where the rows land.
520    absorber: Option<Arc<dyn ObjectAbsorb>>,
521    accounts: Mutex<HashMap<String, Arc<AccountIndexer>>>,
522}
523
524impl IndexerPool {
525    /// `archive` is opened read-only by the workers to `pread` extents back.
526    /// Pack rows only; see [`with_absorber`](IndexerPool::with_absorber).
527    pub fn new(archive: &Path, journal: Option<PathBuf>) -> Result<Self> {
528        Self::build(archive, journal, None)
529    }
530
531    /// The same pool with the object-level half wired in.
532    pub fn with_absorber(
533        archive: &Path,
534        journal: Option<PathBuf>,
535        absorber: Arc<dyn ObjectAbsorb>,
536    ) -> Result<Self> {
537        Self::build(archive, journal, Some(absorber))
538    }
539
540    fn build(
541        archive: &Path,
542        journal: Option<PathBuf>,
543        absorber: Option<Arc<dyn ObjectAbsorb>>,
544    ) -> Result<Self> {
545        let f = File::open(archive)
546            .map_err(|e| anyhow!("indexer: open {}: {e}", archive.display()))?;
547        Ok(Self {
548            archive: Arc::new(f),
549            journal,
550            absorber,
551            accounts: Mutex::new(HashMap::new()),
552        })
553    }
554
555    /// This account's indexer, started on first use.
556    pub fn indexer(&self, account: &str) -> Arc<AccountIndexer> {
557        let mut m = self.accounts.lock().unwrap();
558        m.entry(account.to_string())
559            .or_insert_with(|| {
560                Arc::new(AccountIndexer::start_with_absorber(
561                    account,
562                    self.archive.clone(),
563                    self.journal.clone(),
564                    self.absorber.clone(),
565                ))
566            })
567            .clone()
568    }
569
570    /// How many accounts have an indexer.
571    pub fn accounts(&self) -> usize {
572        self.accounts.lock().unwrap().len()
573    }
574
575    /// Block until every account's index has caught up.
576    pub fn wait_caught_up(&self) {
577        let all: Vec<Arc<AccountIndexer>> =
578            self.accounts.lock().unwrap().values().cloned().collect();
579        for a in all {
580            a.wait_caught_up();
581        }
582    }
583}
584
585/// **Where a reopened push path resumes assigning pack ids**: after the packs
586/// this archive has already acked.
587///
588/// A pack id is an **ordinal**, and the journal is what makes it dense: one row
589/// per acked pack, in append order, so row `i` is ordinal `i` and a new push
590/// takes the next one. Starting over at `0` after a restart would hand a fresh
591/// pack the ordinal of one that is already absorbed, and the absorber's whole job
592/// is to skip a pack whose bit is set — so the new pack's objects would be
593/// silently dropped on the floor while its bytes sat durable in the archive. That
594/// is a wrong `absent`, which is the one failure mode this crate does not accept.
595///
596/// Derived rather than passed in, so no caller can forget it: a writer with no
597/// journal ([`FastWriter`](crate::archive_write::FastWriter)) keeps no durable
598/// record of its acks and so starts at `0`, which is the same thing that arm says
599/// about everything else it does not promise.
600///
601/// **Pack rows, not raw rows.** A `gc` appends a tombstone row for a pack whose
602/// every object it found dead
603/// ([`retire_packs`](crate::archive_write::retire_packs)), and that row is not a
604/// pack and holds no ordinal. Counting it would make the next push's id skip one
605/// and disagree with the ordinal the next open derives for the same pack.
606fn packs_already_acked(journal: Option<&Path>) -> Result<u64> {
607    match journal {
608        Some(p) if p.exists() => {
609            Ok(crate::archive_write::acked_packs(&read_journal(p)?).len() as u64)
610        }
611        _ => Ok(0),
612    }
613}
614
615/// A git server's push path: one [`ArchiveWrite`] arm plus the shared indexer.
616///
617/// This is what the bench drives, and the reason the indexer is *shared* rather
618/// than per-impl: swapping the writer changes the durability contract and
619/// nothing else.
620pub struct PushPath {
621    writer: Box<dyn ArchiveWrite>,
622    pool: IndexerPool,
623    next_pack_id: AtomicU64,
624}
625
626impl PushPath {
627    /// Wrap a writer. `archive` must be the file the writer appends to;
628    /// `journal` the ack-path record a pre-index read falls back to, if the
629    /// writer keeps one.
630    pub fn new(writer: Box<dyn ArchiveWrite>, archive: &Path, journal: Option<PathBuf>) -> Result<Self> {
631        Ok(Self {
632            next_pack_id: AtomicU64::new(packs_already_acked(journal.as_deref())?),
633            writer,
634            pool: IndexerPool::new(archive, journal)?,
635        })
636    }
637
638    /// The same push path whose drain also absorbs **object** rows.
639    ///
640    /// This is the one a git store builds: [`push_pack`](PushPath::push_pack)
641    /// still returns after the two fsyncs and nothing about the ack changes, but
642    /// the job that leaves on the channel now ends in oids rather than in one
643    /// pack row.
644    pub fn with_absorber(
645        writer: Box<dyn ArchiveWrite>,
646        archive: &Path,
647        journal: Option<PathBuf>,
648        absorber: Arc<dyn ObjectAbsorb>,
649    ) -> Result<Self> {
650        Ok(Self {
651            next_pack_id: AtomicU64::new(packs_already_acked(journal.as_deref())?),
652            writer,
653            pool: IndexerPool::with_absorber(archive, journal, absorber)?,
654        })
655    }
656
657    /// The arm's name, for a bench row.
658    pub fn name(&self) -> &'static str {
659        self.writer.name()
660    }
661
662    /// What this arm's `append` actually promises.
663    pub fn durability(&self) -> &'static str {
664        self.writer.durability()
665    }
666
667    /// Store a pushed pack verbatim and queue its index job.
668    ///
669    /// Returns `(pack_id, extent)`. Everything expensive about the pack —
670    /// parsing it, hashing it, building the tables — happens after this returns.
671    pub fn push_pack(&self, account: &str, bytes: &[u8]) -> Result<(u64, Extent)> {
672        let (offset, len) = self.writer.append(bytes)?;
673        let pack_id = self.next_pack_id.fetch_add(1, Ordering::SeqCst);
674        self.pool.indexer(account).submit(IndexJob {
675            pack_id,
676            offset,
677            len,
678        })?;
679        Ok((pack_id, (offset, len)))
680    }
681
682    /// This account's indexer.
683    pub fn indexer(&self, account: &str) -> Arc<AccountIndexer> {
684        self.pool.indexer(account)
685    }
686
687    /// The pool, for a caller that wants to drain every account.
688    pub fn pool(&self) -> &IndexerPool {
689        &self.pool
690    }
691}