git_internal/internal/pack/encode/mod.rs
1//! Streaming Git pack encoder.
2//!
3//! The encoder has two modes:
4//!
5//! - `window_size == 0`: encode objects independently with zlib. Object compression is
6//! parallelized with Rayon and input order is preserved.
7//! - `window_size > 0`: group and sort objects, search a sliding window for a suitable delta
8//! base, encode the target as an offset delta when profitable, then zlib-compress the payload.
9//!
10//! Rabin fingerprinting is the default delta engine. Building without `diff_rabin` falls back to
11//! Myers (`diff_mydrs`) or Patience when neither diff feature is enabled.
12//!
13//! Pack bytes are sent through an async channel instead of being written directly. This keeps
14//! object preparation, CPU-heavy compression, and file or network output decoupled. When an index
15//! sender is configured, the encoder also records each object's hash, CRC32, and pack offset so an
16//! `.idx` v2 file can be generated after the pack is complete.
17
18mod delta_search;
19mod header;
20mod parallel;
21mod sort;
22
23pub mod output;
24pub use output::encode_and_output_to_files;
25
26#[cfg(test)]
27mod tests;
28
29use header::encode_header;
30use rayon::prelude::*;
31use sort::magic_sort;
32use tokio::{sync::mpsc, task::JoinHandle};
33
34use crate::{
35 errors::GitError,
36 hash::ObjectHash,
37 internal::{
38 metadata::{EntryMeta, MetaAttached},
39 object::types::ObjectType,
40 pack::{entry::Entry, index_entry::IndexEntry, pack_index::IdxBuilder},
41 },
42 utils::HashAlgorithm,
43};
44
45/// Stateful encoder for one Git pack stream.
46///
47/// A `PackEncoder` is single-use. It tracks the current pack offset and checksum while encoded
48/// chunks are sent to `pack_sender`. If `idx_sender` is present, index records are accumulated and
49/// can be emitted with [`PackEncoder::encode_idx_file`] after pack encoding finishes.
50pub struct PackEncoder {
51 /// Object count written into the 12-byte pack header.
52 object_number: usize,
53 /// Number of objects consumed by the no-delta path.
54 process_index: usize,
55 /// Zero selects independent encoding; a non-zero value enables delta search.
56 window_size: usize,
57 /// Destination for pack byte chunks. Dropping it signals end-of-stream to the consumer.
58 pack_sender: Option<mpsc::Sender<Vec<u8>>>,
59 /// Optional destination for `.idx` byte chunks.
60 idx_sender: Option<mpsc::Sender<Vec<u8>>>,
61 /// Index metadata collected while pack entries are written.
62 idx_entries: Option<Vec<IndexEntry>>,
63 /// Absolute byte offset where the next pack entry will begin.
64 inner_offset: usize,
65 /// Running checksum of the pack, excluding the checksum trailer itself.
66 inner_hash: HashAlgorithm,
67 /// Final pack checksum, available only after all entries have been encoded.
68 final_hash: Option<ObjectHash>,
69 /// Guards against starting a second encoding operation on the same instance.
70 start_encoding: bool,
71 /// Skip the cheap similarity pre-filter and score every otherwise valid window candidate.
72 ///
73 /// This can improve compression at the cost of more delta computation. The default Rabin path
74 /// disables the pre-filter to follow Git's candidate-search behavior.
75 pub disable_prefilter: bool,
76}
77
78impl PackEncoder {
79 /// Create an encoder that streams pack bytes but does not build an index.
80 pub fn new(object_number: usize, window_size: usize, sender: mpsc::Sender<Vec<u8>>) -> Self {
81 PackEncoder {
82 object_number,
83 window_size,
84 process_index: 0,
85 pack_sender: Some(sender),
86 idx_sender: None,
87 idx_entries: None,
88 inner_offset: 12, // The first entry begins immediately after the pack header.
89 inner_hash: HashAlgorithm::new(),
90 final_hash: None,
91 start_encoding: false,
92 disable_prefilter: false,
93 }
94 }
95
96 /// Create an encoder that streams pack bytes and retains metadata for a later `.idx` stream.
97 pub fn new_with_idx(
98 object_number: usize,
99 window_size: usize,
100 pack_sender: mpsc::Sender<Vec<u8>>,
101 idx_sender: mpsc::Sender<Vec<u8>>,
102 ) -> Self {
103 PackEncoder {
104 object_number,
105 window_size,
106 process_index: 0,
107 pack_sender: Some(pack_sender),
108 idx_sender: Some(idx_sender),
109 idx_entries: None,
110 inner_offset: 12, // The first entry begins immediately after the pack header.
111 inner_hash: HashAlgorithm::new(),
112 final_hash: None,
113 start_encoding: false,
114 disable_prefilter: false,
115 }
116 }
117
118 /// Close the pack output stream by dropping the encoder's final sender.
119 pub fn drop_sender(&mut self) {
120 self.pack_sender.take();
121 }
122
123 /// Send one already-encoded byte chunk to the pack consumer.
124 pub async fn send_data(&mut self, data: Vec<u8>) {
125 if let Some(sender) = &self.pack_sender {
126 sender.send(data).await.unwrap();
127 }
128 }
129
130 /// Return the pack checksum after encoding has completed.
131 pub fn get_hash(&self) -> Option<ObjectHash> {
132 self.final_hash
133 }
134
135 /// Consume an entry stream and emit one complete pack stream.
136 ///
137 /// With `window_size == 0`, entries are independently zlib-compressed in parallel. With a
138 /// non-zero window, entries are collected, sorted for delta locality, and passed through
139 /// sliding-window base selection.
140 ///
141 /// When `diff_rabin` is enabled, the default delta engine is Rabin and all otherwise valid
142 /// candidates are scored. Without that feature, the Myers/Patience engine is used after the
143 /// configured similarity pre-filter.
144 pub async fn encode(
145 &mut self,
146 entry_rx: mpsc::Receiver<MetaAttached<Entry, EntryMeta>>,
147 ) -> Result<(), GitError> {
148 if self.window_size == 0 {
149 self.parallel_encode(entry_rx).await
150 } else {
151 #[cfg(feature = "diff_rabin")]
152 {
153 self.inner_encode(entry_rx, false, true, true).await
154 }
155 #[cfg(not(feature = "diff_rabin"))]
156 {
157 self.inner_encode(entry_rx, false, false, self.disable_prefilter)
158 .await
159 }
160 }
161 }
162
163 /// Encode with zstdelta payloads while retaining the same sorting and base-selection pipeline.
164 pub async fn encode_with_zstdelta(
165 &mut self,
166 entry_rx: mpsc::Receiver<MetaAttached<Entry, EntryMeta>>,
167 ) -> Result<(), GitError> {
168 self.inner_encode(entry_rx, true, false, self.disable_prefilter)
169 .await
170 }
171
172 /// Collect, order, delta-search, and write entries for the windowed encoding path.
173 ///
174 /// This method deliberately separates CPU-heavy delta discovery from ordered pack output:
175 ///
176 /// 1. Drain the input channel and partition entries by Git object type.
177 /// 2. Sort each type so likely-related objects are close enough to share a delta window.
178 /// 3. Search for delta bases on blocking worker threads.
179 /// 4. Restore deterministic chunk order and write encoded entries serially, assigning their
180 /// absolute pack offsets.
181 /// 5. Append the pack checksum and close the output channel.
182 ///
183 /// Candidate-selection rules are based on Git's pack heuristics:
184 /// <https://github.com/git/git/blob/master/Documentation/technical/pack-heuristics.adoc>.
185 async fn inner_encode(
186 &mut self,
187 mut entry_rx: mpsc::Receiver<MetaAttached<Entry, EntryMeta>>,
188 enable_zstdelta: bool,
189 enable_rabin: bool,
190 disable_prefilter: bool,
191 ) -> Result<(), GitError> {
192 // The trailer checksum covers the header and every encoded entry, but not the trailer.
193 let head = encode_header(self.object_number);
194 self.send_data(head.clone()).await;
195 self.inner_hash.update(&head);
196
197 // Reusing the same encoder would corrupt its running offset and checksum state.
198 if self.start_encoding {
199 return Err(GitError::PackEncodeError(
200 "encoding operation is already in progress".to_string(),
201 ));
202 }
203
204 // Delta bases must have the same object type. Keeping types separate makes that invariant
205 // explicit and prevents unrelated types from occupying one another's search windows.
206 let mut commits: Vec<MetaAttached<Entry, EntryMeta>> = Vec::new();
207 let mut trees: Vec<MetaAttached<Entry, EntryMeta>> = Vec::new();
208 let mut blobs: Vec<MetaAttached<Entry, EntryMeta>> = Vec::new();
209 let mut tags: Vec<MetaAttached<Entry, EntryMeta>> = Vec::new();
210 while let Some(entry) = entry_rx.recv().await {
211 match entry.inner.obj_type {
212 ObjectType::Commit => {
213 commits.push(entry);
214 }
215 ObjectType::Tree => {
216 trees.push(entry);
217 }
218 ObjectType::Blob => {
219 blobs.push(entry);
220 }
221 ObjectType::Tag => {
222 tags.push(entry);
223 }
224 _ => {
225 return Err(GitError::PackEncodeError(format!(
226 "object type `{}` is not supported by delta-window pack encoding",
227 entry.inner.obj_type
228 )));
229 }
230 }
231 }
232
233 // Sorting is the compression heuristic: nearby entries become eligible delta bases.
234 commits.sort_by(magic_sort);
235 trees.sort_by(magic_sort);
236 blobs.sort_by(magic_sort);
237 tags.sort_by(magic_sort);
238 tracing::info!(
239 "numbers : commits: {:?} trees: {:?} blobs:{:?} tag :{:?}",
240 commits.len(),
241 trees.len(),
242 blobs.len(),
243 tags.len()
244 );
245
246 // Delta search is CPU-bound, so it must not run on Tokio's async executor threads.
247 // All entries (commits, trees, tags, and contiguous blob chunks) are processed in a
248 // single Rayon batch inside one spawn_blocking task. Rayon's work-stealing balances
249 // load across heterogeneous work items without the manual-queue and mixed-thread-pool
250 // machinery used previously.
251
252 // Paths are needed only for sorting. Delta generation works on the underlying entries.
253 let commit_entries: Vec<Entry> = commits.into_iter().map(|e| e.inner).collect();
254 let tree_entries: Vec<Entry> = trees.into_iter().map(|e| e.inner).collect();
255 let tag_entries: Vec<Entry> = tags.into_iter().map(|e| e.inner).collect();
256 let mut blob_entries: Vec<Entry> = blobs.into_iter().map(|e| e.inner).collect();
257
258 // ── Build the work-item list ──────────────────────────────────────────
259 // Each item holds a contiguous segment of sorted entries. The `order` field
260 // determines final pack layout: commits → trees → blob chunks → tags.
261 struct WorkItem {
262 order: usize,
263 entries: Vec<Entry>,
264 }
265
266 let mut work_items: Vec<WorkItem> = Vec::new();
267 work_items.push(WorkItem {
268 order: 0,
269 entries: commit_entries,
270 });
271 work_items.push(WorkItem {
272 order: 1,
273 entries: tree_entries,
274 });
275
276 // Preserve contiguous portions of the sorted blob list: splitting objects
277 // arbitrarily would destroy the locality created by magic_sort and reduce
278 // delta quality. Creating many chunks exposes enough parallelism for
279 // Rayon's work-stealing scheduler.
280 let total_blob_entries = blob_entries.len();
281 let num_threads = rayon::current_num_threads();
282 let chunks_per_thread: usize = 20;
283 let mut blob_chunk_count = if num_threads > 1 && total_blob_entries > (num_threads * 20) {
284 num_threads * chunks_per_thread
285 } else {
286 1
287 };
288 let mut entries_per_chunk = total_blob_entries.div_ceil(blob_chunk_count);
289
290 // Prevent over-fragmentation on high-core-count machines: each chunk must
291 // contain enough entries for the sliding window to find meaningful delta
292 // bases. Without this guard, a 128-core grading server would split objects
293 // into 2560 tiny chunks (~29 entries each), destroying cross-chunk delta
294 // locality and inflating pack size by 3+% compared to a typical laptop.
295 let min_entries_per_chunk = (self.window_size * 10).max(50);
296 if entries_per_chunk < min_entries_per_chunk {
297 blob_chunk_count = (total_blob_entries / min_entries_per_chunk).max(1);
298 entries_per_chunk = total_blob_entries.div_ceil(blob_chunk_count);
299 }
300
301 // split_off removes from the end in O(1). Reverse afterward to recover
302 // global sort order.
303 let mut blob_chunks: Vec<Vec<Entry>> = Vec::with_capacity(blob_chunk_count);
304 for _ in 0..blob_chunk_count {
305 let take = entries_per_chunk.min(blob_entries.len());
306 if take == 0 {
307 break;
308 }
309 let chunk = blob_entries.split_off(blob_entries.len() - take);
310 blob_chunks.push(chunk);
311 }
312 blob_chunks.reverse();
313
314 let actual_blob_chunks = blob_chunks.len();
315 let blob_base_order = 2usize;
316 for (i, chunk) in blob_chunks.into_iter().enumerate() {
317 work_items.push(WorkItem {
318 order: blob_base_order + i,
319 entries: chunk,
320 });
321 }
322
323 let tag_order = blob_base_order + actual_blob_chunks;
324 work_items.push(WorkItem {
325 order: tag_order,
326 entries: tag_entries,
327 });
328
329 tracing::info!(
330 total_work_items = work_items.len(),
331 blob_chunks = actual_blob_chunks,
332 threads = num_threads,
333 "dispatching delta search to Rayon"
334 );
335
336 // Offload all delta search to Rayon inside a single spawn_blocking task.
337 // This keeps CPU work off the async runtime while Rayon's work-stealing
338 // balances load across the heterogeneous work items.
339 type ChunkResult = (usize, Result<Vec<(Vec<u8>, IndexEntry)>, GitError>);
340
341 let ez = enable_zstdelta;
342 let er = enable_rabin;
343 let dp = disable_prefilter;
344
345 let run_delta_search = move || -> Vec<ChunkResult> {
346 work_items
347 .into_par_iter()
348 .map(|item| {
349 (
350 item.order,
351 Self::try_as_offset_delta(item.entries, 10, ez, er, dp),
352 )
353 })
354 .collect()
355 };
356
357 let mut chunk_results: Vec<ChunkResult> =
358 // When PACK_THREADS is set, build a dedicated Rayon pool with the
359 // requested thread count and run the delta search on it. Otherwise
360 // use the global Rayon pool (which respects RAYON_NUM_THREADS).
361 if let Some(n) = std::env::var("PACK_THREADS")
362 .ok()
363 .and_then(|s| s.parse::<usize>().ok())
364 {
365 let pool = rayon::ThreadPoolBuilder::new()
366 .num_threads(n)
367 .build()
368 .map_err(|e| GitError::PackEncodeError(format!(
369 "failed to build Rayon thread pool: {e}"
370 )))?;
371 tokio::task::spawn_blocking(move || pool.install(run_delta_search))
372 .await
373 .map_err(|e| GitError::PackEncodeError(format!(
374 "delta search task panicked: {e}"
375 )))?
376 } else {
377 tokio::task::spawn_blocking(run_delta_search)
378 .await
379 .map_err(|e| GitError::PackEncodeError(format!(
380 "delta search task panicked: {e}"
381 )))?
382 };
383
384 // Parallel search may finish out of order; restore the chosen pack order.
385 chunk_results.sort_by_key(|(order, _)| *order);
386
387 let mut all_res: Vec<Vec<(Vec<u8>, IndexEntry)>> = Vec::with_capacity(chunk_results.len());
388 for (_order, res) in chunk_results {
389 all_res.push(res?);
390 }
391
392 // Writing is serialized so offsets, the running pack hash, and index records all describe
393 // exactly the same byte order.
394 let total_entries = all_res.iter().map(Vec::len).sum();
395 let mut idx_entries = Vec::with_capacity(total_entries);
396 for res in &mut all_res {
397 for (encoded_bytes, mut idx_entry) in res.drain(..) {
398 idx_entry.offset = self.inner_offset as u64;
399 self.write_owned_and_update(encoded_bytes).await;
400 idx_entries.push(idx_entry);
401 }
402 }
403
404 self.idx_entries = Some(idx_entries);
405
406 // The checksum is both the pack trailer and the identifier used in pack-<hash>.pack.
407 let hash_result = self.inner_hash.clone().finalize();
408 self.final_hash = Some(ObjectHash::from_bytes(&hash_result).unwrap());
409 self.send_data(hash_result).await;
410
411 self.drop_sender();
412 Ok(())
413 }
414
415 /// Account for an encoded chunk, then forward it to the pack consumer.
416 ///
417 /// The caller must invoke this in final pack order because both `inner_offset` and `inner_hash`
418 /// are order-sensitive.
419 async fn write_owned_and_update(&mut self, data: Vec<u8>) {
420 self.inner_hash.update(&data);
421 self.inner_offset += data.len();
422 self.send_data(data).await;
423 }
424
425 /// Build the `.idx` stream from metadata captured during pack encoding.
426 async fn generate_idx_file(&mut self) -> Result<(), GitError> {
427 let final_hash = self.final_hash.ok_or(GitError::PackEncodeError(
428 "final_hash is missing,The pack file must be generated before the index file is produced."
429 .into(),
430 ))?;
431 let idx_entries = self.idx_entries.clone().ok_or(GitError::PackEncodeError(
432 "The pack file must be generated before the index file is produced.".into(),
433 ))?;
434 let mut idx_builder = IdxBuilder::new(
435 self.object_number,
436 self.idx_sender.clone().unwrap(),
437 final_hash,
438 );
439 idx_builder.write_idx(idx_entries).await?;
440 Ok(())
441 }
442
443 /// Spawn pack encoding as a Tokio task and return its join handle.
444 ///
445 /// This consumes the encoder so the spawned task owns all channel senders and state. The task
446 /// chooses the same zero-window versus delta-window path as [`PackEncoder::encode`].
447 ///
448 /// Encoding errors currently panic inside the spawned task and are therefore reported as a
449 /// `JoinError` when the returned handle is awaited.
450 pub async fn encode_async(
451 mut self,
452 rx: mpsc::Receiver<MetaAttached<Entry, EntryMeta>>,
453 ) -> Result<JoinHandle<()>, GitError> {
454 Ok(tokio::spawn(async move {
455 if self.window_size == 0 {
456 self.parallel_encode(rx).await.unwrap()
457 } else {
458 self.encode(rx).await.unwrap()
459 }
460 }))
461 }
462
463 /// Spawn zstdelta pack encoding as a Tokio task.
464 ///
465 /// zstdelta always uses the windowed path because independent encoding has no base from which
466 /// to produce a delta.
467 pub async fn encode_async_with_zstdelta(
468 mut self,
469 rx: mpsc::Receiver<MetaAttached<Entry, EntryMeta>>,
470 ) -> Result<JoinHandle<()>, GitError> {
471 Ok(tokio::spawn(async move {
472 self.encode_with_zstdelta(rx).await.unwrap()
473 }))
474 }
475
476 /// Emit the `.idx` stream after the pack has been finalized.
477 ///
478 /// Pack encoding must run first because the index trailer contains the pack checksum and each
479 /// record needs its final absolute pack offset.
480 pub async fn encode_idx_file(&mut self) -> Result<(), GitError> {
481 if self.idx_sender.is_none() {
482 return Err(GitError::PackEncodeError(String::from(
483 "idx sender is none",
484 )));
485 }
486 self.generate_idx_file().await?;
487 // Closing the final sender tells the index writer that no more chunks are coming.
488 self.idx_sender.take();
489 Ok(())
490 }
491}