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}