History

feat(gix): throttle object refreshes with a freshness window

Add RefreshMode::AfterDuration as a shared gix-odb freshness window. Repeated misses avoid rescanning packs and alternates until the last successful post-initialization refresh reaches the configured age, while failed refreshes, known missing packs, and explicit stale notification remain immediately retryable. Configure gix object handles with a one-second default and expose gitoxide.objects.refreshAfter in milliseconds. Fetch invalidates freshness after publishing a pack, configuration reloads update active handles, and zero restores strict refresh-on-every-miss behavior. Use the test-support clock for deterministic deadline and contention coverage, keep feature-unified workspace builds and Repository size guards valid with the added configuration, and make the minimal CI dependency assertion portable to images without ripgrep.

Codex committed 3 months ago

Commit ca8be2c1 parent ce90da620 files · +432 −25Browse files

Unified · Split

20 files
  1. etc/scripts/cargo-check-all.sh +1 −1
  2. gix-odb/src/lib.rs +2 −0
  3. gix-odb/src/store_impls/dynamic/find.rs +2 −2
  4. gix-odb/src/store_impls/dynamic/handle.rs +1 −0
  5. gix-odb/src/store_impls/dynamic/header.rs +1 −1
  6. gix-odb/src/store_impls/dynamic/init.rs +17 −2
  7. gix-odb/src/store_impls/dynamic/load_index.rs +28 −5
  8. gix-odb/src/store_impls/dynamic/mod.rs +28 −2
  9. gix-odb/src/store_impls/dynamic/prefix.rs +4 −2
  10. gix-odb/src/store_impls/dynamic/structure.rs +9 −0
  11. gix-odb/tests/odb/store/dynamic_scenarios.rs +254 −5
  12. gix/src/config/cache/init.rs +5 −0
  13. gix/src/config/cache/util.rs +12 −0
  14. gix/src/config/mod.rs +4 −0
  15. gix/src/config/tree/sections/gitoxide.rs +8 −0
  16. gix/src/remote/connection/fetch/receive_pack.rs +1 −0
  17. gix/src/repository/init.rs +1 −1
  18. gix/tests/gix/config/tree.rs +11 −0
  19. gix/tests/gix/repository/mod.rs +3 −4
  20. gix/tests/gix/repository/open.rs +40 −0
etc/scripts/cargo-check-all.sh+1−1
@@ -18,7 +18,7 @@ cargo check -p gix-transport --all-features 2>/dev/null · 17 unmodified lines
1818 ! cargo check -p gitoxide-core --all-features --features gix/sha1 2>/dev/null
1919 cargo check -p gix-protocol --all-features
2020 tree="$(cargo --color=never tree -p gix --no-default-features -e normal --prefix none --format '{p}')"
21−! printf '%s\n' "$tree" | rg -q '^gix-imara-diff(-01)? v'
21+! printf '%s\n' "$tree" | grep -Eq '^gix-imara-diff(-01)? v'
2222 cargo --color=never tree -p gix --no-default-features -e normal -i gix-submodule \
2323 2>&1 >/dev/null | grep '^warning: nothing to print\>'
2424 cargo --color=never tree -p gix --no-default-features -e normal -i gix-pathspec \
gix-odb/src/lib.rs+2−0
@@ -167,6 +167,8 @@ pub struct Store { · 166 unmodified lines
167167 pub(crate) num_disk_state_consolidations_completed: AtomicUsize,
168168 /// The most recent failed consolidation, shared only with callers that were already waiting for it.
169169 pub(crate) last_disk_state_consolidation_error: parking_lot::Mutex<Option<(usize, Arc<store::load_index::Error>)>>,
170+ /// The last successful post-initialization disk-state consolidation, used to throttle miss-triggered refreshes.
171+ pub(crate) last_successful_disk_state_consolidation: parking_lot::Mutex<Option<std::time::Instant>>,
170172 /// If true, we are allowed to use multi-pack indices and they must have the `object_hash` or be ignored.
171173 use_multi_pack_index: bool,
172174 /// The hash kind to use for some operations
gix-odb/src/store_impls/dynamic/find.rs+2−2
@@ -146,7 +146,7 @@ where · 145 unmodified lines
146146 }
147147 None => {
148148 // The pack wasn't available anymore so we are supposed to try another round with a fresh index
149− match self.store.load_one_index(self.index_ctx(marker))? {
149+ match self.store.load_one_index(self.index_ctx(marker).force_refresh())? {
150150 Some(new_snapshot) => {
151151 *snapshot = new_snapshot;
152152 self.clear_cache();
@@ -419,7 +419,7 @@ where · 266 unmodified lines
419419 }
420420 None => {
421421 // The pack wasn't available anymore so we are supposed to try another round with a fresh index
422− match self.store.load_one_index(self.index_ctx(marker)).ok()? {
422+ match self.store.load_one_index(self.index_ctx(marker).force_refresh()).ok()? {
423423 Some(new_snapshot) => {
424424 *snapshot = new_snapshot;
425425 self.clear_cache();
gix-odb/src/store_impls/dynamic/handle.rs+1−0
@@ -339,6 +339,7 @@ where · 338 unmodified lines
339339 pub(crate) fn index_ctx(&self, marker: types::SlotIndexMarker) -> super::IndexCtx {
340340 super::IndexCtx {
341341 refresh_mode: self.refresh,
342+ force_refresh: false,
342343 marker,
343344 loose_compression: self.loose_compression,
344345 }
gix-odb/src/store_impls/dynamic/header.rs+1−1
@@ -60,7 +60,7 @@ where · 59 unmodified lines
6060 }
6161 None => {
6262 // The pack wasn't available anymore so we are supposed to try another round with a fresh index
63− match self.store.load_one_index(self.index_ctx(marker))? {
63+ match self.store.load_one_index(self.index_ctx(marker).force_refresh())? {
6464 Some(new_snapshot) => {
6565 *snapshot = new_snapshot;
6666 self.clear_cache();
gix-odb/src/store_impls/dynamic/init.rs+17−2
@@ -48,7 +48,7 @@ impl Default for Options { · 47 unmodified lines
4848 /// Deterministic synchronization support for tests of concurrent object-database behavior.
4949 #[cfg(feature = "test-support")]
5050 pub mod debug {
51− use std::{fmt, sync::Arc};
51+ use std::{fmt, sync::Arc, time::Instant};
5252
5353 /// The result of a filesystem-backed load attempt.
5454 #[non_exhaustive]
@@ -143,17 +143,31 @@ pub mod debug { · 88 unmodified lines
143143 #[derive(Clone)]
144144 pub struct Options {
145145 hook: Arc<dyn Fn(Point) + Send + Sync>,
146+ clock: Option<Arc<dyn Fn() -> Instant + Send + Sync>>,
146147 }
147148
148149 impl Options {
149150 /// Create options that invoke `hook` at every configured synchronization point.
150151 pub fn new(hook: impl Fn(Point) + Send + Sync + 'static) -> Self {
151− Options { hook: Arc::new(hook) }
152+ Options {
153+ hook: Arc::new(hook),
154+ clock: None,
155+ }
156+ }
157+
158+ /// Use `clock` instead of the system monotonic clock for refresh throttling.
159+ pub fn with_clock(mut self, clock: impl Fn() -> Instant + Send + Sync + 'static) -> Self {
160+ self.clock = Some(Arc::new(clock));
161+ self
152162 }
153163
154164 pub(crate) fn at(&self, point: Point) {
155165 (self.hook)(point);
156166 }
167+
168+ pub(crate) fn now(&self) -> Instant {
169+ self.clock.as_ref().map_or_else(Instant::now, |clock| clock())
170+ }
157171 }
158172
159173 impl fmt::Debug for Options {
@@ -273,6 +287,7 @@ impl Store { · 113 unmodified lines
273287 num_disk_state_consolidation: Default::default(),
274288 num_disk_state_consolidations_completed: Default::default(),
275289 last_disk_state_consolidation_error: Default::default(),
290+ last_successful_disk_state_consolidation: Default::default(),
276291 })
277292 }
278293 }
gix-odb/src/store_impls/dynamic/load_index.rs+28−5
@@ -59,6 +59,7 @@ impl super::Store { · 58 unmodified lines
5959 let mut snapshot = self.collect_snapshot();
6060 while let Some(new_snapshot) = self.load_one_index(IndexCtx {
6161 refresh_mode: RefreshMode::Never,
62+ force_refresh: false,
6263 marker: snapshot.marker,
6364 loose_compression: self.loose_compression,
6465 })? {
@@ -73,6 +74,7 @@ impl super::Store { · 8 unmodified lines
7374 &self,
7475 IndexCtx {
7576 refresh_mode,
77+ force_refresh,
7678 marker,
7779 loose_compression,
7880 }: IndexCtx,
@@ -99,16 +101,34 @@ impl super::Store { · 20 unmodified lines
99101 // …and if that didn't yield anything new consider refreshing our disk state.
100102 match refresh_mode {
101103 RefreshMode::Never => Ok(None),
102− RefreshMode::AfterAllIndicesLoaded => self.consolidate_with_disk_state(
103− false, /* needs init */
104− true, /* load one new index */
105− loose_compression,
106− ),
104+ RefreshMode::AfterDuration(refresh_after)
105+ if !force_refresh && self.last_refresh_is_younger_than(refresh_after) =>
106+ {
107+ Ok(None)
108+ }
109+ RefreshMode::AfterAllIndicesLoaded | RefreshMode::AfterDuration(_) => {
110+ self.consolidate_with_disk_state(
111+ false, /* needs init */
112+ true, /* load one new index */
113+ loose_compression,
114+ )
115+ }
107116 }
108117 }
109118 }
110119 }
111120
121+ fn last_refresh_is_younger_than(&self, max_age: std::time::Duration) -> bool {
122+ let now = self.now();
123+ self.last_successful_disk_state_consolidation
124+ .lock()
125+ .as_ref()
126+ .is_some_and(|last| {
127+ now.checked_duration_since(*last)
128+ .is_none_or(|elapsed| elapsed < max_age)
129+ })
130+ }
131+
112132 /// load a new index (if not yet loaded), and return true if one was indeed loaded (leading to a `state_id()` change) of the current index.
113133 /// Note that interacting with the slot-map is inherently racy and we have to deal with it, being conservative in what we even try to load
114134 /// as our index might already be out-of-date as we try to use it to learn what's next.
@@ -541,6 +561,9 @@ impl super::Store { · 426 unmodified lines
541561 }
542562 Some(self.collect_snapshot())
543563 };
564+ if !was_uninitialized {
565+ *self.last_successful_disk_state_consolidation.lock() = Some(self.now());
566+ }
544567 self.finish_refresh(current_completed_refreshes, Ok(snapshot))
545568 }
546569
gix-odb/src/store_impls/dynamic/mod.rs+28−2
@@ -1,5 +1,5 @@
11 //! The standard object store which should fit all needs.
2−use std::{cell::RefCell, ops::Deref, sync::Arc};
2+use std::{cell::RefCell, ops::Deref, sync::Arc, time::Duration};
33
44 use crate::Store;
55
@@ -44,17 +44,30 @@ where · 38 unmodified lines
4444 #[derive(Clone, Copy)]
4545 pub(crate) struct IndexCtx {
4646 refresh_mode: RefreshMode,
47+ force_refresh: bool,
4748 marker: types::SlotIndexMarker,
4849 loose_compression: gix_zlib::Compression,
4950 }
5051
52+impl IndexCtx {
53+ fn force_refresh(mut self) -> Self {
54+ self.force_refresh = true;
55+ self
56+ }
57+}
58+
5159 /// Decide what happens when all indices are loaded.
52−#[derive(Default, Clone, Copy)]
60+#[derive(Default, Clone, Copy, Debug, Eq, PartialEq)]
5361 pub enum RefreshMode {
5462 /// Check for new or changed pack indices (and pack data files) when the last known index is loaded.
5563 /// During runtime handles configured for stable pack locations retain the corresponding indices and packs.
5664 #[default]
5765 AfterAllIndicesLoaded,
66+ /// Check for new or changed pack indices only if the last successful refresh is at least this old.
67+ ///
68+ /// This throttles filesystem scans caused by repeated misses. A duration of zero behaves like
69+ /// [`AfterAllIndicesLoaded`](Self::AfterAllIndicesLoaded).
70+ AfterDuration(Duration),
5871 /// Use this if you expect a lot of missing objects that shouldn't trigger refreshes even after all packs are loaded.
5972 /// This comes at the risk of not learning that the packs have changed in the mean time.
6073 Never,
@@ -94,6 +107,19 @@ impl Store { · 33 unmodified lines
94107 debug.at(point);
95108 }
96109 }
110+
111+ pub(crate) fn now(&self) -> std::time::Instant {
112+ self.debug
113+ .as_ref()
114+ .map_or_else(std::time::Instant::now, init::debug::Options::now)
115+ }
116+}
117+
118+#[cfg(not(feature = "test-support"))]
119+impl Store {
120+ pub(crate) fn now(&self) -> std::time::Instant {
121+ std::time::Instant::now()
122+ }
97123 }
98124
99125 pub(crate) mod handle;
gix-odb/src/store_impls/dynamic/prefix.rs+4−2
@@ -133,8 +133,10 @@ where · 132 unmodified lines
133133 ///
134134 /// ### Performance Note
135135 ///
136− /// - Unless the handles refresh mode is set to `Never`, each lookup will trigger a refresh of the object databases files
137− /// on disk if the prefix doesn't lead to ambiguous results.
136+ /// - With [`RefreshMode::AfterAllIndicesLoaded`](super::RefreshMode::AfterAllIndicesLoaded), each lookup will refresh the
137+ /// object database files on disk if the prefix doesn't lead to ambiguous results. Use
138+ /// [`RefreshMode::AfterDuration`](super::RefreshMode::AfterDuration) to throttle these scans, or
139+ /// [`RefreshMode::Never`](super::RefreshMode::Never) to disable them.
138140 /// - Since all objects need to be examined to assure non-ambiguous return values, after calling this method all indices will
139141 /// be loaded.
140142 /// - If `candidates` is `Some(…)`, the traversal will continue to obtain all candidates, which takes more time
gix-odb/src/store_impls/dynamic/structure.rs+9−0
@@ -47,6 +47,15 @@ pub enum IndexState { · 46 unmodified lines
4747 }
4848
4949 impl Store {
50+ /// Mark the known disk state stale after the caller changed the object database.
51+ ///
52+ /// This only makes the next miss eligible for a refresh; it performs no I/O.
53+ /// An ongoing refresh is allowed to finish first so it cannot overwrite this invalidation.
54+ pub fn mark_disk_state_stale(&self) {
55+ let _write = self.write.lock();
56+ *self.last_successful_disk_state_consolidation.lock() = None;
57+ }
58+
5059 /// Return information about all files known to us as well as their loading state.
5160 ///
5261 /// Note that this call is expensive as it gathers additional information about loose object databases.
gix-odb/tests/odb/store/dynamic_scenarios.rs+254−5
@@ -343,9 +343,12 @@ fn debug_hooks_coordinate_contending_failed_index_loaders() -> Result { · 342 unmodified lines
343343 #[cfg(feature = "parallel")]
344344 #[test]
345345 fn debug_hooks_coalesce_successful_refreshes() -> Result {
346− use std::sync::{
347− Arc,
348− atomic::{AtomicBool, Ordering},
346+ use std::{
347+ sync::{
348+ Arc, Mutex,
349+ atomic::{AtomicBool, Ordering},
350+ },
351+ time::{Duration, Instant},
349352 };
350353
351354 use gix_odb::store::init::debug::Point;
@@ -354,6 +357,7 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 2 unmodified lines
354357 let (point_tx, point_rx) = crossbeam_channel::unbounded();
355358 let (resume_tx, resume_rx) = crossbeam_channel::bounded(0);
356359 let pause_next_refresh = Arc::new(AtomicBool::new(false));
360+ let now = Arc::new(Mutex::new(Instant::now()));
357361 let debug = gix_odb::store::init::debug::Options::new({
358362 let pause_next_refresh = Arc::clone(&pause_next_refresh);
359363 move |point| {
@@ -364,8 +368,12 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 4 unmodified lines
364368 resume_rx.recv().expect("the test releases the refresh scan");
365369 }
366370 }
371+ })
372+ .with_clock({
373+ let now = Arc::clone(&now);
374+ move || *now.lock().expect("the test clock isn't poisoned")
367375 });
368− let handle = gix_odb::at_opts(
376+ let mut handle = gix_odb::at_opts(
369377 fixture.objects_dir(Database::Primary),
370378 fixture.manifest.object_hash,
371379 Vec::new(),
@@ -420,6 +428,12 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 48 unmodified lines
420428 "the changed ODB adds one refresh"
421429 );
422430
431+ let refresh_after = Duration::from_secs(1);
432+ handle.refresh = gix_odb::store::RefreshMode::AfterDuration(refresh_after);
433+ {
434+ let mut now = now.lock().expect("the test clock isn't poisoned");
435+ *now += refresh_after;
436+ }
423437 point_rx.try_iter().for_each(drop);
424438 let (first, second, observed) = contended_lookup(
425439 handle.clone(),
@@ -440,7 +454,7 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 14 unmodified lines
440454 .filter(|point| matches!(point, Point::RefreshScanStarted))
441455 .count(),
442456 1,
443− "contending misses scan the unchanged ODB once"
457+ "contending handles scan the unchanged ODB once at the shared deadline"
444458 );
445459 assert_eq!(
446460 handle.store_ref().metrics().num_refreshes,
@@ -968,6 +982,241 @@ fn never_refresh_does_not_retry_failed_initial_scan() -> Result { · 521 unmodified lines
968982 Ok(())
969983 }
970984
985+#[test]
986+fn refresh_after_duration_is_shared_by_handles_until_the_deadline() -> Result {
987+ use std::{
988+ sync::{Arc, Mutex},
989+ time::{Duration, Instant},
990+ };
991+
992+ let mut fixture = OdbFixture::from_script()?;
993+ let now = Arc::new(Mutex::new(Instant::now()));
994+ let debug = gix_odb::store::init::debug::Options::new(|_| {}).with_clock({
995+ let now = Arc::clone(&now);
996+ move || *now.lock().expect("the test clock isn't poisoned")
997+ });
998+ let mut first = gix_odb::at_opts(
999+ fixture.objects_dir(Database::Primary),
1000+ fixture.manifest.object_hash,
1001+ Vec::new(),
1002+ gix_odb::store::init::Options {
1003+ slots: gix_odb::store::init::Slots::Growable { initial: 1 },
1004+ debug: Some(debug),
1005+ ..Default::default()
1006+ },
1007+ )?;
1008+ let refresh_after = Duration::from_secs(1);
1009+ first.refresh = gix_odb::store::RefreshMode::AfterDuration(refresh_after);
1010+ let second = first.clone();
1011+ let id = fixture.manifest.pack(Pack::A).object_ids[0];
1012+
1013+ assert_eq!(
1014+ first.packed_object_count()?,
1015+ 0,
1016+ "the empty store initializes without packed objects"
1017+ );
1018+ assert_eq!(
1019+ first.store_ref().metrics().num_refreshes,
1020+ 1,
1021+ "initialization scans the shared store once"
1022+ );
1023+ fixture.install_pack(Database::Primary, Pack::A)?;
1024+ assert_object(&second, &id)?;
1025+ assert_eq!(
1026+ first.store_ref().metrics().num_refreshes,
1027+ 2,
1028+ "initialization leaves the first miss-triggered refresh available to discover published objects"
1029+ );
1030+ assert_missing(&first, &fixture.manifest.missing_id())?;
1031+ assert_eq!(
1032+ first.store_ref().metrics().num_refreshes,
1033+ 2,
1034+ "the other handle shares the freshness window established by the refresh"
1035+ );
1036+ fixture.install_pack(Database::Primary, Pack::B)?;
1037+ let newly_published = fixture.manifest.pack(Pack::B).object_ids[0];
1038+ assert_missing(&second, &newly_published)?;
1039+ first.store_ref().mark_disk_state_stale();
1040+ assert_object(&second, &newly_published)?;
1041+ assert_eq!(
1042+ first.store_ref().metrics().num_refreshes,
1043+ 3,
1044+ "marking a known mutation stale makes the next lookup refresh without performing eager I/O"
1045+ );
1046+
1047+ {
1048+ let mut now = now.lock().expect("the test clock isn't poisoned");
1049+ *now += refresh_after;
1050+ }
1051+ assert_missing(&second, &fixture.manifest.missing_id())?;
1052+ assert_eq!(
1053+ first.store_ref().metrics().num_refreshes,
1054+ 4,
1055+ "the first handle to miss at the deadline refreshes the shared store"
1056+ );
1057+ Ok(())
1058+}
1059+
1060+#[cfg(feature = "parallel")]
1061+#[test]
1062+fn invalidation_during_refresh_preserves_newly_published_packs() -> Result {
1063+ use std::{
1064+ sync::{
1065+ Arc,
1066+ atomic::{AtomicBool, Ordering},
1067+ },
1068+ time::{Duration, Instant},
1069+ };
1070+
1071+ use gix_odb::store::init::debug::Point;
1072+
1073+ let mut fixture = OdbFixture::from_script()?;
1074+ let (published_tx, published_rx) = crossbeam_channel::bounded(1);
1075+ let (resume_tx, resume_rx) = crossbeam_channel::bounded(1);
1076+ let pause_next_publication = Arc::new(AtomicBool::new(false));
1077+ let now = Instant::now();
1078+ let debug = gix_odb::store::init::debug::Options::new({
1079+ let pause_next_publication = Arc::clone(&pause_next_publication);
1080+ move |point| {
1081+ if matches!(point, Point::IndexStatePublished) && pause_next_publication.swap(false, Ordering::SeqCst) {
1082+ published_tx
1083+ .send(())
1084+ .expect("the test observes publication after scanning");
1085+ resume_rx.recv().expect("the test resumes the refresh");
1086+ }
1087+ }
1088+ })
1089+ .with_clock(move || now);
1090+ let store = Arc::new(gix_odb::Store::at_opts(
1091+ fixture.objects_dir(Database::Primary),
1092+ fixture.manifest.object_hash,
1093+ &mut std::iter::empty(),
1094+ gix_odb::store::init::Options {
1095+ debug: Some(debug),
1096+ ..Default::default()
1097+ },
1098+ )?);
1099+ store.structure()?;
1100+ let mut first = store.to_handle_arc();
1101+ first.refresh = gix_odb::store::RefreshMode::AfterDuration(Duration::from_secs(1));
1102+ let second = first.clone();
1103+ fixture.install_pack(Database::Primary, Pack::A)?;
1104+ let object_id = fixture.manifest.pack(Pack::A).object_ids[0];
1105+ pause_next_publication.store(true, Ordering::SeqCst);
1106+ let refresh = std::thread::spawn(move || {
1107+ first
1108+ .try_find(&object_id, &mut Vec::new())
1109+ .map(|object| object.is_some())
1110+ });
1111+ published_rx.recv_timeout(Duration::from_secs(10))?;
1112+
1113+ fixture.install_pack(Database::Primary, Pack::B)?;
1114+ let (started_tx, started_rx) = crossbeam_channel::bounded(1);
1115+ let (invalidated_tx, invalidated_rx) = crossbeam_channel::bounded(1);
1116+ let invalidation = std::thread::spawn(move || {
1117+ started_tx.send(()).expect("the test observes the invalidating thread");
1118+ store.mark_disk_state_stale();
1119+ invalidated_tx
1120+ .send(())
1121+ .expect("the test observes completed invalidation");
1122+ });
1123+ started_rx.recv_timeout(Duration::from_secs(10))?;
1124+ // An uncoordinated invalidation can finish while the scan is paused; a coordinated one waits for its release.
1125+ let _ = invalidated_rx.recv_timeout(Duration::from_secs(1));
1126+ resume_tx
1127+ .send(())
1128+ .expect("the refresh can complete before checking invalidation");
1129+ assert!(
1130+ refresh.join().expect("the refresh does not panic")?,
1131+ "the refresh sees the pack published before its scan"
1132+ );
1133+ invalidation.join().expect("invalidation does not panic");
1134+ let object_id = fixture.manifest.pack(Pack::B).object_ids[0];
1135+ assert!(
1136+ second.try_find(&object_id, &mut Vec::new())?.is_some(),
1137+ "invalidation after the directory scan survives refresh completion without advancing the clock"
1138+ );
1139+ Ok(())
1140+}
1141+
1142+#[test]
1143+fn a_failed_refresh_does_not_renew_the_freshness_window() -> Result {
1144+ use std::{
1145+ sync::{Arc, Mutex},
1146+ time::{Duration, Instant},
1147+ };
1148+
1149+ let mut fixture = OdbFixture::from_script()?;
1150+ let now = Arc::new(Mutex::new(Instant::now()));
1151+ let debug = gix_odb::store::init::debug::Options::new(|_| {}).with_clock({
1152+ let now = Arc::clone(&now);
1153+ move || *now.lock().expect("the test clock isn't poisoned")
1154+ });
1155+ let mut handle = gix_odb::at_opts(
1156+ fixture.objects_dir(Database::Primary),
1157+ fixture.manifest.object_hash,
1158+ Vec::new(),
1159+ gix_odb::store::init::Options {
1160+ slots: gix_odb::store::init::Slots::Limit(1),
1161+ debug: Some(debug),
1162+ ..Default::default()
1163+ },
1164+ )?;
1165+ let refresh_after = Duration::from_secs(1);
1166+ handle.refresh = gix_odb::store::RefreshMode::AfterDuration(refresh_after);
1167+ assert_eq!(
1168+ handle.packed_object_count()?,
1169+ 0,
1170+ "the empty store initializes without packed objects"
1171+ );
1172+
1173+ fixture.install_pack(Database::Primary, Pack::A)?;
1174+ fixture.install_pack(Database::Primary, Pack::B)?;
1175+ let mut buffer = Vec::new();
1176+ handle
1177+ .try_find(&fixture.manifest.missing_id(), &mut buffer)
1178+ .expect_err("one slot cannot hold both newly discovered indices");
1179+ assert_eq!(
1180+ handle.store_ref().metrics().num_refreshes,
1181+ 2,
1182+ "the failed refresh is attempted once"
1183+ );
1184+
1185+ fixture.remove_pack(Database::Primary, Pack::B)?;
1186+ assert_object(&handle, &fixture.manifest.pack(Pack::A).object_ids[0])?;
1187+ assert_eq!(
1188+ handle.store_ref().metrics().num_refreshes,
1189+ 3,
1190+ "the next lookup retries because failure did not renew freshness"
1191+ );
1192+ Ok(())
1193+}
1194+
1195+#[test]
1196+fn a_missing_known_pack_bypasses_the_refresh_deadline() -> Result {
1197+ use std::time::Duration;
1198+
1199+ let mut fixture = OdbFixture::from_script()?;
1200+ fixture.install_pack(Database::Primary, Pack::A)?;
1201+ let mut handle = open(&fixture, 1)?;
1202+ handle.refresh = gix_odb::store::RefreshMode::AfterDuration(Duration::MAX);
1203+ let id = fixture.manifest.pack(Pack::A).object_ids[0];
1204+ assert_eq!(
1205+ handle.packed_object_count()?,
1206+ fixture.manifest.pack(Pack::A).object_ids.len() as u64,
1207+ "the index is known while its pack remains unopened"
1208+ );
1209+ fixture.remove(Database::Primary, Pack::A, Component::Pack)?;
1210+
1211+ assert_missing(&handle, &id)?;
1212+ assert_eq!(
1213+ handle.store_ref().metrics().num_refreshes,
1214+ 2,
1215+ "losing a pack named by a known index forces structural reconciliation"
1216+ );
1217+ Ok(())
1218+}
1219+
9711220 #[test]
9721221 fn a_midx_rewrite_does_not_require_a_spare_slot() -> Result {
9731222 let mut fixture = OdbFixture::from_script()?;
gix/src/config/cache/init.rs+5−0
@@ -80,6 +80,7 @@ impl Cache { · 79 unmodified lines
8080 let object_kind_hint = util::disambiguate_hint(&config, lenient_config)?;
8181 let (static_pack_cache_limit_bytes, pack_cache_bytes, object_cache_bytes, alloc_limit_bytes) =
8282 util::parse_object_caches(&config, lenient_config, filter_config_section)?;
83+ let object_refresh_mode = util::parse_object_refresh_mode(&config, lenient_config, filter_config_section)?;
8384 let loose_compression = super::access::loose_compression(&config, lenient_config, filter_config_section)?;
8485 // NOTE: When adding a new initial cache, consider adjusting `reread_values_and_clear_caches()` as well.
8586 Ok(Cache {
@@ -92,6 +93,7 @@ impl Cache { · 6 unmodified lines
9293 pack_cache_bytes,
9394 object_cache_bytes,
9495 alloc_limit_bytes,
96+ object_refresh_mode,
9597 loose_compression,
9698 reflog,
9799 refs_namespace,
@@ -172,6 +174,8 @@ impl Cache { · 74 unmodified lines
172174 self.object_cache_bytes,
173175 self.alloc_limit_bytes,
174176 ) = util::parse_object_caches(config, self.lenient_config, self.filter_config_section)?;
177+ self.object_refresh_mode =
178+ util::parse_object_refresh_mode(config, self.lenient_config, self.filter_config_section)?;
175179 let loose_compression =
176180 super::access::loose_compression(config, self.lenient_config, self.filter_config_section)?;
177181 self.loose_compression = loose_compression;
@@ -383,6 +387,7 @@ impl crate::Repository { · 205 unmodified lines
383387 fn apply_changed_values(&mut self) {
384388 self.refs.write_reflog = util::reflog_or_default(self.config.reflog, self.workdir().is_some());
385389 self.refs.namespace.clone_from(&self.config.refs_namespace);
390+ self.objects.refresh = self.config.object_refresh_mode;
386391 }
387392 }
388393
gix/src/config/cache/util.rs+12−0
@@ -98,6 +98,18 @@ pub(crate) fn reflog_or_default( · 97 unmodified lines
9898
9999 pub(crate) type ObjectCaches = (Option<usize>, Option<usize>, usize, Option<usize>);
100100
101+pub(crate) fn parse_object_refresh_mode(
102+ config: &gix_config::File,
103+ lenient: bool,
104+ mut filter_config_section: fn(&gix_config::file::Metadata) -> bool,
105+) -> Result<gix_odb::store::RefreshMode, Error> {
106+ let refresh_after = gitoxide::Objects::REFRESH_AFTER
107+ .try_into_duration(config.integer_filter("gitoxide.objects.refreshAfter", &mut filter_config_section))
108+ .with_leniency(lenient)?
109+ .unwrap_or(gitoxide::Objects::REFRESH_AFTER_DEFAULT);
110+ Ok(gix_odb::store::RefreshMode::AfterDuration(refresh_after))
111+}
112+
101113 /// Return `(static_pack_cache_limit, pack_cache_bytes, object_cache_bytes, alloc_limit_bytes)` as parsed from gix-config.
102114 pub(crate) fn parse_object_caches(
103115 config: &gix_config::File,
gix/src/config/mod.rs+4−0
@@ -88,6 +88,8 @@ pub enum Error { · 87 unmodified lines
8888 #[error(transparent)]
8989 ConfigBoolean(#[from] boolean::Error),
9090 #[error(transparent)]
91+ ConfigDuration(#[from] duration::Error),
92+ #[error(transparent)]
9193 ConfigUnsigned(#[from] unsigned_integer::Error),
9294 #[error(transparent)]
9395 ConfigTypedString(#[from] key::GenericErrorWithValue),
@@ -654,6 +656,8 @@ pub(crate) struct Cache { · 560 unmodified lines
654656 pub(crate) object_cache_bytes: usize,
655657 /// The maximum size of a single allocation caused by user-controlled on-disk packed object data.
656658 pub(crate) alloc_limit_bytes: Option<usize>,
659+ /// The policy for filesystem refreshes caused by object misses.
660+ pub(crate) object_refresh_mode: gix_odb::store::RefreshMode,
657661 /// The compression level to use when writing loose objects, from `core.looseCompression` or `core.compression`.
658662 pub(crate) loose_compression: gix_zlib::Compression,
659663 /// The amount of bytes we can hold in our static LRU cache. Otherwise, go with the defaults.
gix/src/config/tree/sections/gitoxide.rs+8−0
@@ -497,6 +497,13 @@ mod subsections { · 496 unmodified lines
497497 keys::UnsignedInteger::new_unsigned_integer("allocLimitIfReducedTrust", &Gitoxide::OBJECTS).with_note(
498498 "The default allocation limit used for reduced-trust repositories when no explicit allocLimit is configured; set to 0 to disable it",
499499 );
500+ /// The default minimum age of the last successful object database refresh before another miss may refresh it.
501+ pub const REFRESH_AFTER_DEFAULT: std::time::Duration = std::time::Duration::from_secs(1);
502+ /// The `gitoxide.objects.refreshAfter` key.
503+ pub const REFRESH_AFTER: keys::DurationInMilliseconds =
504+ keys::DurationInMilliseconds::new_duration("refreshAfter", &Gitoxide::OBJECTS).with_note(
505+ "The minimum age of the last successful object database refresh before another object miss may refresh it, in milliseconds; 0 refreshes strictly on every miss",
506+ );
500507 /// The `gitoxide.objects.noReplace` key.
501508 pub const NO_REPLACE: keys::Boolean = keys::Boolean::new_boolean("noReplace", &Gitoxide::OBJECTS);
502509 /// The `gitoxide.objects.replaceRefBase` key.
@@ -515,6 +522,7 @@ mod subsections { · 12 unmodified lines
515522 &Self::CACHE_LIMIT,
516523 &Self::ALLOC_LIMIT,
517524 &Self::ALLOC_LIMIT_IF_REDUCED_TRUST,
525+ &Self::REFRESH_AFTER,
518526 &Self::REPLACE_REF_BASE,
519527 ]
520528 }
gix/src/remote/connection/fetch/receive_pack.rs+1−0
@@ -193,6 +193,7 @@ where · 192 unmodified lines
193193 repo.object_hash(),
194194 write_pack_options,
195195 )?;
196+ repo.objects.store_ref().mark_disk_state_stale();
196197 may_read_to_end = true;
197198 Some(res)
198199 } else {
gix/src/repository/init.rs+1−1
@@ -63,8 +63,8 @@ impl crate::Repository { · 62 unmodified lines
6363 }
6464 }
6565
66−#[cfg_attr(not(feature = "max-performance-safe"), allow(unused_variables, unused_mut))]
6766 pub(crate) fn setup_objects(objects: &mut crate::OdbHandle, config: &crate::config::Cache) {
67+ objects.refresh = config.object_refresh_mode;
6868 #[cfg(feature = "max-performance-safe")]
6969 {
7070 match config.pack_cache_bytes {
gix/tests/gix/config/tree.rs+11−0
@@ -1138,6 +1138,8 @@ mod gitoxide { · 1137 unmodified lines
11381138 }
11391139 }
11401140 mod objects {
1141+ use std::time::Duration;
1142+
11411143 use gix::config::tree::{Key, gitoxide};
11421144
11431145 #[test]
@@ -1157,6 +1159,15 @@ mod gitoxide { · 13 unmodified lines
11571159 );
11581160 Ok(())
11591161 }
1162+
1163+ #[test]
1164+ fn refresh_after() -> crate::Result {
1165+ assert_eq!(
1166+ gitoxide::Objects::REFRESH_AFTER.validated_assignment_fmt(&Duration::from_secs(1).as_millis())?,
1167+ "gitoxide.objects.refreshAfter=1000"
1168+ );
1169+ Ok(())
1170+ }
11601171 }
11611172 }
11621173
gix/tests/gix/repository/mod.rs+3−4
@@ -145,10 +145,9 @@ mod dirwalk { · 144 unmodified lines
145145 #[test]
146146 fn size_in_memory() {
147147 let actual_size = std::mem::size_of::<Repository>();
148− // The selected index path adds one `PathBuf` to the repository.
149− // Network-client features add protocol permission caching to `Repository::config`,
150− // which grows the type by one more cached cell.
151− let limit = 1500;
148+ // Allow the selected index path, network-client cache, and refresh policy stored in both the ODB handle
149+ // and configuration cache. The largest observed layout is on 64-bit Windows.
150+ let limit = 1528;
152151 assert!(
153152 actual_size <= limit,
154153 "size of Repository shouldn't change without us noticing, it's meant to be cloned: should have been below {limit:?}, was {actual_size}"
gix/tests/gix/repository/open.rs+40−0
@@ -849,6 +849,46 @@ mod pack_alloc_limit_bytes { · 848 unmodified lines
849849 }
850850 }
851851
852+mod object_refresh {
853+ use std::time::Duration;
854+
855+ use gix_odb::store::RefreshMode;
856+
857+ use crate::util::repo_opts;
858+
859+ #[test]
860+ fn defaults_to_a_configurable_freshness_window() -> crate::Result {
861+ let mut default = repo_opts("make_packed_and_loose.sh", crate::util::restricted())?.to_thread_local();
862+ assert_eq!(
863+ default.objects.refresh_mode(),
864+ RefreshMode::AfterDuration(Duration::from_secs(1)),
865+ "gix avoids repeated miss-triggered filesystem scans by default"
866+ );
867+ {
868+ let mut config = default.config_snapshot_mut();
869+ config.set_raw_value("gitoxide.objects.refreshAfter", "50")?;
870+ config.commit()?;
871+ }
872+ assert_eq!(
873+ default.objects.refresh_mode(),
874+ RefreshMode::AfterDuration(Duration::from_millis(50)),
875+ "committing configuration updates the active object handle"
876+ );
877+
878+ let mut configured = repo_opts(
879+ "make_packed_and_loose.sh",
880+ gix::open::Options::isolated().config_overrides(["gitoxide.objects.refreshAfter=250"]),
881+ )?
882+ .to_thread_local();
883+ assert_eq!(
884+ configured.objects.refresh_mode(),
885+ RefreshMode::AfterDuration(Duration::from_millis(250)),
886+ "gitoxide.objects.refreshAfter configures the freshness window"
887+ );
888+ Ok(())
889+ }
890+}
891+
852892 mod worktree {
853893 use gix::open;
854894