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::{AtomicBool, 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    // **Streamed, never the whole pack in one buffer.** This was
180    // `vec![0u8; job.len]` + one `read_exact_at` — for torvalds/linux an
181    // 8.12 GiB allocation on the 16 GB appliance, held while the SHA-1 ran,
182    // on top of everything the absorb holds. One reused 1 MiB window now.
183    const WINDOW: usize = 1 << 20;
184    let unreadable = IndexRow {
185        pack_id: job.pack_id,
186        offset: job.offset,
187        len: job.len,
188        version: 0,
189        object_count: 0,
190        sha1: String::new(),
191    };
192    let mut buf = vec![0u8; WINDOW.min(job.len as usize).max(1)];
193    let mut h = Sha1::new();
194    let mut head = [0u8; 12];
195    let mut done = 0u64;
196    while done < job.len {
197        let want = ((job.len - done) as usize).min(buf.len());
198        if archive.read_exact_at(&mut buf[..want], job.offset + done).is_err() {
199            return unreadable;
200        }
201        if done < 12 {
202            let n = (12 - done as usize).min(want);
203            head[done as usize..done as usize + n].copy_from_slice(&buf[..n]);
204        }
205        h.update(&buf[..want]);
206        done += want as u64;
207    }
208    // P-4: a malformed or hostile entry never panics — a short or non-`PACK`
209    // buffer degrades to zeroes and the row still writes.
210    let (version, object_count) = if job.len >= 12 && &head[0..4] == b"PACK" {
211        (
212            u32::from_be_bytes([head[4], head[5], head[6], head[7]]),
213            u32::from_be_bytes([head[8], head[9], head[10], head[11]]),
214        )
215    } else {
216        (0, 0)
217    };
218    IndexRow {
219        pack_id: job.pack_id,
220        offset: job.offset,
221        len: job.len,
222        version,
223        object_count,
224        sha1: hex::encode(h.finalize()),
225    }
226}
227
228/// **The object-level ingress**, called by the drain and by nothing else here.
229///
230/// The indexer knows extents; it does not know what a git object is, and it must
231/// not learn — the split is what keeps the pack grammar out of this file and the
232/// channel out of the store's. The implementor is
233/// [`GitStore`](crate::git_ops::GitStore)'s absorber, which is the *same* object
234/// a read falls back to, so the background path and the fallback path are one
235/// path (LAW 5).
236///
237/// The argument is an [`IndexJob`] — 24 bytes, `Copy`, no buffer. Zero-copy
238/// handoff survives the extra hop.
239pub trait ObjectAbsorb: Send + Sync {
240    /// Absorb this pack's objects into the object-level index.
241    ///
242    /// Must be **idempotent for a pack already absorbed**: the ack path records a
243    /// pack as pending and hands it to the channel in that order, and a fast
244    /// worker can arrive at either end first.
245    fn absorb(&self, job: IndexJob) -> Result<()>;
246}
247
248/// What a read gets when it asks for a pack.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum Lookup {
251    /// The index has it. O(1), and the row carries everything derived.
252    Indexed(Box<IndexRow>),
253    /// Not indexed **yet**. Here is the journal's extent list — the durable
254    /// record written on the ack path — so the caller can scan. Slower, never
255    /// wrong.
256    ScanJournal(Vec<Extent>),
257    /// Not indexed and there is no journal to scan
258    /// ([`FastWriter`](crate::archive_write::FastWriter)). Nothing can answer.
259    Unknowable,
260}
261
262#[derive(Default)]
263struct Built {
264    /// The index tables, built last.
265    batches: Vec<RecordBatch>,
266    /// **The indexed bit, and the row it publishes — one structure.**
267    ///
268    /// Membership is the bit: present means "the index can answer for this
269    /// pack", absent means "fall back to scanning". It is deliberately *not* a
270    /// separate bitmap beside a row map. Two structures would need a guard
271    /// watching them agree; one structure cannot disagree with itself (LAW 5 —
272    /// fix by construction, do not add a guard over two copies). A pack is
273    /// published by exactly one `insert`, under one lock, after its row is
274    /// complete **and after its objects are absorbed**, so no reader can ever
275    /// see a set bit with no row — or no object — behind it.
276    published: HashMap<u64, IndexRow>,
277    /// Packs whose [`ObjectAbsorb::absorb`] failed. Their bits stayed clear, so
278    /// they are still answerable by the fallback; this is how many times that
279    /// happened, which is otherwise invisible from outside the worker.
280    absorb_failures: u64,
281    /// The most recent absorb error, verbatim. A count with no message is a
282    /// number nobody can act on.
283    last_absorb_error: Option<String>,
284}
285
286struct Progress {
287    /// Jobs submitted minus jobs committed. `0` means the indexer is caught up.
288    outstanding: Mutex<u64>,
289    caught_up: Condvar,
290}
291
292/// One account's indexer: its own channel, its own worker, its own tables.
293pub struct AccountIndexer {
294    account: String,
295    tx: Option<Sender<IndexJob>>,
296    built: Arc<Mutex<Built>>,
297    progress: Arc<Progress>,
298    journal: Option<PathBuf>,
299    worker: Option<Job<()>>,
300    /// The repository is being deleted: the worker drops every queued job
301    /// unabsorbed and `Drop` joins it in milliseconds rather than hours.
302    cancel: Arc<AtomicBool>,
303}
304
305impl AccountIndexer {
306    /// Start an indexer for `account` reading extents back out of `archive`.
307    /// `journal` is the ack-path durable record a not-yet-indexed read falls
308    /// back to scanning; `None` for a writer that keeps none.
309    ///
310    /// **Pack rows only.** For the object rows as well, see
311    /// [`start_with_absorber`](AccountIndexer::start_with_absorber) — a writer
312    /// bench has no git store behind it and wants exactly this.
313    pub fn start(account: &str, archive: Arc<File>, journal: Option<PathBuf>) -> Self {
314        Self::start_with_absorber(account, archive, journal, None)
315    }
316
317    /// The same indexer with the **object-level half** wired in: each drained
318    /// job's objects are absorbed through `absorber` before the pack's indexed
319    /// bit goes up.
320    pub fn start_with_absorber(
321        account: &str,
322        archive: Arc<File>,
323        journal: Option<PathBuf>,
324        absorber: Option<Arc<dyn ObjectAbsorb>>,
325    ) -> Self {
326        let (tx, rx): (Sender<IndexJob>, Receiver<IndexJob>) = channel();
327        let built = Arc::new(Mutex::new(Built::default()));
328        let progress = Arc::new(Progress {
329            outstanding: Mutex::new(0),
330            caught_up: Condvar::new(),
331        });
332        let w_built = built.clone();
333        let w_progress = progress.clone();
334        let cancel = Arc::new(AtomicBool::new(false));
335        let w_cancel = cancel.clone();
336        // `gatling::background::Job` — the constellation's ONE sanctioned home
337        // for a long-lived background thread outside the engine itself. A raw
338        // `thread::spawn` or `thread::Builder` here trips
339        // `rayon_free_law::only_the_shared_gatling_engine_owns_a_worker_pool`,
340        // and rightly: every thread in this tree originates in gatling. This is
341        // gatling's depth-1 shape — one worker, joined at close — and the
342        // fan-out *inside* it is `gatling_for_each`.
343        let worker =
344            Job::spawn(move || index_worker(archive, rx, w_built, w_progress, absorber, w_cancel));
345        Self {
346            account: account.to_string(),
347            tx: Some(tx),
348            built,
349            progress,
350            journal,
351            worker: Some(worker),
352            cancel,
353        }
354    }
355
356    /// The account this indexer belongs to.
357    pub fn account(&self) -> &str {
358        &self.account
359    }
360
361    /// Hand an extent over. Returns immediately: 24 bytes onto a channel.
362    pub fn submit(&self, job: IndexJob) -> Result<()> {
363        {
364            let mut o = self
365                .progress
366                .outstanding
367                .lock()
368                .map_err(|_| anyhow!("indexer progress poisoned"))?;
369            *o += 1;
370        }
371        self.tx
372            .as_ref()
373            .ok_or_else(|| anyhow!("indexer already closed"))?
374            .send(job)
375            .map_err(|_| anyhow!("indexer worker is gone"))
376    }
377
378    /// Tell the worker to stop: every job still queued is dropped unabsorbed
379    /// (its pack keeps a clear bit — nothing is declared indexed), and the
380    /// absorber's own cancel flag is what aborts the pack in flight.
381    pub fn cancel(&self) {
382        self.cancel.store(true, Ordering::SeqCst);
383    }
384
385    pub fn is_cancelled(&self) -> bool {
386        self.cancel.load(Ordering::SeqCst)
387    }
388
389    /// Block until this account's index has caught up with everything submitted
390    /// so far. Used by tests and by the bench's "with index" column.
391    pub fn wait_caught_up(&self) {
392        let mut o = self.progress.outstanding.lock().unwrap();
393        while *o > 0 {
394            o = self.progress.caught_up.wait(o).unwrap();
395        }
396    }
397
398    /// Is this pack's **indexed bit** set?
399    pub fn is_indexed(&self, pack_id: u64) -> bool {
400        self.built.lock().unwrap().published.contains_key(&pack_id)
401    }
402
403    /// Number of index rows built so far.
404    pub fn rows(&self) -> usize {
405        self.built.lock().unwrap().published.len()
406    }
407
408    /// How many packs failed [`ObjectAbsorb::absorb`] in this indexer. Their
409    /// indexed bits are clear and their reads still fall back.
410    pub fn absorb_failures(&self) -> u64 {
411        self.built.lock().unwrap().absorb_failures
412    }
413
414    /// The most recent absorb error, if any.
415    pub fn last_absorb_error(&self) -> Option<String> {
416        self.built.lock().unwrap().last_absorb_error.clone()
417    }
418
419    /// The built index tables.
420    pub fn tables(&self) -> Vec<RecordBatch> {
421        self.built.lock().unwrap().batches.clone()
422    }
423
424    /// Answer a read, honestly, whether or not the index is ready.
425    pub fn lookup(&self, pack_id: u64) -> Lookup {
426        {
427            // The bit IS the row's presence. One lookup, one truth.
428            let b = self.built.lock().unwrap();
429            if let Some(r) = b.published.get(&pack_id) {
430                return Lookup::Indexed(Box::new(r.clone()));
431            }
432        }
433        match self.journal.as_deref() {
434            Some(p) => match read_journal(p) {
435                Ok(extents) => Lookup::ScanJournal(extents),
436                Err(_) => Lookup::Unknowable,
437            },
438            None => Lookup::Unknowable,
439        }
440    }
441
442    /// Close the channel and join the worker, returning the final tables.
443    pub fn finish(mut self) -> Vec<RecordBatch> {
444        self.tx = None;
445        if let Some(w) = self.worker.take() {
446            let _ = w.join();
447        }
448        self.built.lock().unwrap().batches.clone()
449    }
450}
451
452impl Drop for AccountIndexer {
453    /// **Cancel, then join — never join a worker that may be hours into a
454    /// pack or parked by the memory watchdog.** The queued jobs are dropped
455    /// unabsorbed and the pack in flight is aborted at its next checkpoint;
456    /// their bits stay clear, so the next open re-queues them from the
457    /// journal (`resumed 0%`). Nobody waits on the indexer, and that includes
458    /// the process shutting down and a `repo delete`.
459    fn drop(&mut self) {
460        self.cancel.store(true, Ordering::SeqCst);
461        self.tx = None;
462        if let Some(w) = self.worker.take() {
463            let _ = w.join();
464        }
465    }
466}
467
468/// The worker. **Sleeps when its channel is empty.**
469///
470/// `recv()` blocks — after a bounded spin `std`'s channel parks the thread on a
471/// futex, so an idle account burns no CPU at all. It wakes on the first job,
472/// then drains everything already queued behind it (`try_recv` until empty, up
473/// to [`MAX_DRAIN_BATCH`]) so a burst costs one fan-out rather than one per job.
474fn index_worker(
475    archive: Arc<File>,
476    rx: Receiver<IndexJob>,
477    built: Arc<Mutex<Built>>,
478    progress: Arc<Progress>,
479    absorber: Option<Arc<dyn ObjectAbsorb>>,
480    cancel: Arc<AtomicBool>,
481) {
482    loop {
483        // ── SLEEP HERE ──────────────────────────────────────────────────────
484        let Ok(first) = rx.recv() else { return };
485        let mut batch = Vec::with_capacity(64);
486        batch.push(first);
487        loop {
488            if batch.len() >= MAX_DRAIN_BATCH {
489                break;
490            }
491            match rx.try_recv() {
492                Ok(j) => batch.push(j),
493                Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
494            }
495        }
496
497        // ── cancelled: the repository is going. Count the batch off and
498        //    absorb nothing — a clear bit is the honest state of a pack whose
499        //    directory is about to be removed. ──────────────────────────────
500        if cancel.load(Ordering::SeqCst) {
501            let mut o = progress.outstanding.lock().unwrap();
502            *o = o.saturating_sub(batch.len() as u64);
503            if *o == 0 {
504                progress.caught_up.notify_all();
505            }
506            continue;
507        }
508
509        // The memory watchdog (crate::memguard) is the absorber's: it checks
510        // before every pack it absorbs and every few thousand objects inside
511        // one. This worker holds nothing between packs, so there is nothing
512        // for it to flush and nothing for it to check.
513
514        // ── gatling fork-join. NEVER rayon (LAW 3). ─────────────────────────
515        let n = batch.len();
516        let arch = archive.as_ref();
517        let jobs = &batch;
518        let rows = gatling_for_each(n, 0, |i| build_row(arch, jobs[i]));
519
520        // ── the OBJECT-level half, out through `ObjectAbsorb` ───────────────
521        //
522        // Serial across packs on purpose: absorbing appends to one `objects`
523        // table and re-folds one commit graph, so the parallelism that pays
524        // here is the gatling fan-out above (one pread + one SHA-1 per pack),
525        // not N threads contending for the same two writers. A pack whose
526        // absorb fails is dropped from `ok` and keeps its bit clear.
527        let mut ok: Vec<IndexRow> = Vec::with_capacity(rows.len());
528        let mut failed: Vec<String> = Vec::new();
529        for (job, row) in batch.iter().zip(rows) {
530            match absorber.as_deref() {
531                Some(a) => match a.absorb(*job) {
532                    Ok(()) => ok.push(row),
533                    Err(e) => failed.push(format!("pack {}: {e:#}", job.pack_id)),
534                },
535                None => ok.push(row),
536            }
537        }
538
539        // ── index tables built LAST, after the bytes are down AND the
540        //    objects are in ───────────────────────────────────────────────────
541        let encoded = if ok.is_empty() {
542            None
543        } else {
544            // A batch that cannot be encoded leaves the bits unset: every one of
545            // its packs stays on the scan path.
546            rows_to_batch(&ok).ok()
547        };
548        {
549            let mut b = built.lock().unwrap();
550            if let Some(rb) = encoded {
551                b.batches.push(rb);
552                for r in ok {
553                    // Publishing the row IS setting the bit — one insert, one
554                    // lock, after the row is complete and its objects are
555                    // absorbed.
556                    b.published.insert(r.pack_id, r);
557                }
558            }
559            b.absorb_failures += failed.len() as u64;
560            if let Some(last) = failed.pop() {
561                b.last_absorb_error = Some(last);
562            }
563        }
564
565        let mut o = progress.outstanding.lock().unwrap();
566        *o = o.saturating_sub(n as u64);
567        if *o == 0 {
568            progress.caught_up.notify_all();
569        }
570    }
571}
572
573/// One indexer per account, created on first push from that account.
574pub struct IndexerPool {
575    archive: Arc<File>,
576    journal: Option<PathBuf>,
577    /// Shared by every account's worker. One store, one object table — the
578    /// per-account split is about *queueing*, not about where the rows land.
579    absorber: Option<Arc<dyn ObjectAbsorb>>,
580    accounts: Mutex<HashMap<String, Arc<AccountIndexer>>>,
581}
582
583impl IndexerPool {
584    /// `archive` is opened read-only by the workers to `pread` extents back.
585    /// Pack rows only; see [`with_absorber`](IndexerPool::with_absorber).
586    pub fn new(archive: &Path, journal: Option<PathBuf>) -> Result<Self> {
587        Self::build(archive, None, journal, None)
588    }
589
590    /// The same pool with the object-level half wired in.
591    ///
592    /// `shared` is the writer's own open file description on the same blob file
593    /// ([`ArchiveWrite::blobs_shared`]). Passing it is what keeps an open store
594    /// at **one** descriptor on `objects.pack` instead of three: the workers
595    /// only ever `read_exact_at`, so a description they share with the writer
596    /// and with the absorber behaves identically to one of their own. `None`
597    /// falls back to opening it, which is what a bench with no store does.
598    pub fn with_absorber(
599        archive: &Path,
600        shared: Option<Arc<File>>,
601        journal: Option<PathBuf>,
602        absorber: Arc<dyn ObjectAbsorb>,
603    ) -> Result<Self> {
604        Self::build(archive, shared, journal, Some(absorber))
605    }
606
607    fn build(
608        archive: &Path,
609        shared: Option<Arc<File>>,
610        journal: Option<PathBuf>,
611        absorber: Option<Arc<dyn ObjectAbsorb>>,
612    ) -> Result<Self> {
613        let f = match shared {
614            Some(f) => f,
615            None => Arc::new(
616                File::open(archive)
617                    .map_err(|e| anyhow!("indexer: open {}: {e}", archive.display()))?,
618            ),
619        };
620        Ok(Self {
621            archive: f,
622            journal,
623            absorber,
624            accounts: Mutex::new(HashMap::new()),
625        })
626    }
627
628    /// This account's indexer, started on first use.
629    pub fn indexer(&self, account: &str) -> Arc<AccountIndexer> {
630        let mut m = self.accounts.lock().unwrap();
631        m.entry(account.to_string())
632            .or_insert_with(|| {
633                Arc::new(AccountIndexer::start_with_absorber(
634                    account,
635                    self.archive.clone(),
636                    self.journal.clone(),
637                    self.absorber.clone(),
638                ))
639            })
640            .clone()
641    }
642
643    /// How many accounts have an indexer.
644    pub fn accounts(&self) -> usize {
645        self.accounts.lock().unwrap().len()
646    }
647
648    /// Block until every account's index has caught up.
649    pub fn wait_caught_up(&self) {
650        let all: Vec<Arc<AccountIndexer>> =
651            self.accounts.lock().unwrap().values().cloned().collect();
652        for a in all {
653            a.wait_caught_up();
654        }
655    }
656
657    /// Cancel every account's worker; see [`AccountIndexer::cancel`].
658    pub fn cancel_all(&self) {
659        let all: Vec<Arc<AccountIndexer>> =
660            self.accounts.lock().unwrap().values().cloned().collect();
661        for a in all {
662            a.cancel();
663        }
664    }
665}
666
667/// **Where a reopened push path resumes assigning pack ids**: after the packs
668/// this archive has already acked.
669///
670/// A pack id is an **ordinal**, and the journal is what makes it dense: one row
671/// per acked pack, in append order, so row `i` is ordinal `i` and a new push
672/// takes the next one. Starting over at `0` after a restart would hand a fresh
673/// pack the ordinal of one that is already absorbed, and the absorber's whole job
674/// is to skip a pack whose bit is set — so the new pack's objects would be
675/// silently dropped on the floor while its bytes sat durable in the archive. That
676/// is a wrong `absent`, which is the one failure mode this crate does not accept.
677///
678/// Derived rather than passed in, so no caller can forget it: a writer with no
679/// journal ([`FastWriter`](crate::archive_write::FastWriter)) keeps no durable
680/// record of its acks and so starts at `0`, which is the same thing that arm says
681/// about everything else it does not promise.
682///
683/// **Pack rows, not raw rows.** A `gc` appends a tombstone row for a pack whose
684/// every object it found dead
685/// ([`retire_packs`](crate::archive_write::retire_packs)), and that row is not a
686/// pack and holds no ordinal. Counting it would make the next push's id skip one
687/// and disagree with the ordinal the next open derives for the same pack.
688fn packs_already_acked(journal: Option<&Path>) -> Result<u64> {
689    match journal {
690        Some(p) if p.exists() => {
691            Ok(crate::archive_write::acked_packs(&read_journal(p)?).len() as u64)
692        }
693        _ => Ok(0),
694    }
695}
696
697/// A git server's push path: one [`ArchiveWrite`] arm plus the shared indexer.
698///
699/// This is what the bench drives, and the reason the indexer is *shared* rather
700/// than per-impl: swapping the writer changes the durability contract and
701/// nothing else.
702pub struct PushPath {
703    writer: Box<dyn ArchiveWrite>,
704    pool: IndexerPool,
705    next_pack_id: AtomicU64,
706}
707
708impl PushPath {
709    /// Wrap a writer. `archive` must be the file the writer appends to;
710    /// `journal` the ack-path record a pre-index read falls back to, if the
711    /// writer keeps one.
712    pub fn new(writer: Box<dyn ArchiveWrite>, archive: &Path, journal: Option<PathBuf>) -> Result<Self> {
713        Ok(Self {
714            next_pack_id: AtomicU64::new(packs_already_acked(journal.as_deref())?),
715            writer,
716            pool: IndexerPool::new(archive, journal)?,
717        })
718    }
719
720    /// The same push path whose drain also absorbs **object** rows.
721    ///
722    /// This is the one a git store builds: [`push_pack`](PushPath::push_pack)
723    /// still returns after the two fsyncs and nothing about the ack changes, but
724    /// the job that leaves on the channel now ends in oids rather than in one
725    /// pack row.
726    /// **The pool reads through the writer's own descriptor.** Asked here
727    /// rather than passed in by the caller, so no caller can forget it and
728    /// silently put a third handle on `objects.pack`
729    /// (`tests/fd_per_store.rs` counts them).
730    pub fn with_absorber(
731        writer: Box<dyn ArchiveWrite>,
732        archive: &Path,
733        journal: Option<PathBuf>,
734        absorber: Arc<dyn ObjectAbsorb>,
735    ) -> Result<Self> {
736        let shared = writer.blobs_shared();
737        Ok(Self {
738            next_pack_id: AtomicU64::new(packs_already_acked(journal.as_deref())?),
739            writer,
740            pool: IndexerPool::with_absorber(archive, shared, journal, absorber)?,
741        })
742    }
743
744    /// The arm's name, for a bench row.
745    pub fn name(&self) -> &'static str {
746        self.writer.name()
747    }
748
749    /// What this arm's `append` actually promises.
750    pub fn durability(&self) -> &'static str {
751        self.writer.durability()
752    }
753
754    /// Store a pushed pack verbatim and queue its index job.
755    ///
756    /// Returns `(pack_id, extent)`. Everything expensive about the pack —
757    /// parsing it, hashing it, building the tables — happens after this returns.
758    pub fn push_pack(&self, account: &str, bytes: &[u8]) -> Result<(u64, Extent)> {
759        let (offset, len) = self.writer.append(bytes)?;
760        let pack_id = self.next_pack_id.fetch_add(1, Ordering::SeqCst);
761        self.pool.indexer(account).submit(IndexJob {
762            pack_id,
763            offset,
764            len,
765        })?;
766        Ok((pack_id, (offset, len)))
767    }
768
769    /// This account's indexer.
770    pub fn indexer(&self, account: &str) -> Arc<AccountIndexer> {
771        self.pool.indexer(account)
772    }
773
774    /// The pool, for a caller that wants to drain every account.
775    pub fn pool(&self) -> &IndexerPool {
776        &self.pool
777    }
778}