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}