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
18! cargo check -p gitoxide-core --all-features --features gix/sha1 2>/dev/null18! cargo check -p gitoxide-core --all-features --features gix/sha1 2>/dev/null
19cargo check -p gix-protocol --all-features19cargo check -p gix-protocol --all-features
20tree="$(cargo --color=never tree -p gix --no-default-features -e normal --prefix none --format '{p}')"20tree="$(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'
22cargo --color=never tree -p gix --no-default-features -e normal -i gix-submodule \22cargo --color=never tree -p gix --no-default-features -e normal -i gix-submodule \
23 2>&1 >/dev/null | grep '^warning: nothing to print\>'23 2>&1 >/dev/null | grep '^warning: nothing to print\>'
24cargo --color=never tree -p gix --no-default-features -e normal -i gix-pathspec \24cargo --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
167 pub(crate) num_disk_state_consolidations_completed: AtomicUsize,167 pub(crate) num_disk_state_consolidations_completed: AtomicUsize,
168 /// The most recent failed consolidation, shared only with callers that were already waiting for it.168 /// The most recent failed consolidation, shared only with callers that were already waiting for it.
169 pub(crate) last_disk_state_consolidation_error: parking_lot::Mutex<Option<(usize, Arc<store::load_index::Error>)>>,169 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>>,
170 /// If true, we are allowed to use multi-pack indices and they must have the `object_hash` or be ignored.172 /// If true, we are allowed to use multi-pack indices and they must have the `object_hash` or be ignored.
171 use_multi_pack_index: bool,173 use_multi_pack_index: bool,
172 /// The hash kind to use for some operations174 /// 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
146 }146 }
147 None => {147 None => {
148 // The pack wasn't available anymore so we are supposed to try another round with a fresh index148 // 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())? {
150 Some(new_snapshot) => {150 Some(new_snapshot) => {
151 *snapshot = new_snapshot;151 *snapshot = new_snapshot;
152 self.clear_cache();152 self.clear_cache();
@@ -419,7 +419,7 @@ where · 266 unmodified lines
419 }419 }
420 None => {420 None => {
421 // The pack wasn't available anymore so we are supposed to try another round with a fresh index421 // 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()? {
423 Some(new_snapshot) => {423 Some(new_snapshot) => {
424 *snapshot = new_snapshot;424 *snapshot = new_snapshot;
425 self.clear_cache();425 self.clear_cache();
gix-odb/src/store_impls/dynamic/handle.rs+1−0
@@ -339,6 +339,7 @@ where · 338 unmodified lines
339 pub(crate) fn index_ctx(&self, marker: types::SlotIndexMarker) -> super::IndexCtx {339 pub(crate) fn index_ctx(&self, marker: types::SlotIndexMarker) -> super::IndexCtx {
340 super::IndexCtx {340 super::IndexCtx {
341 refresh_mode: self.refresh,341 refresh_mode: self.refresh,
342 force_refresh: false,
342 marker,343 marker,
343 loose_compression: self.loose_compression,344 loose_compression: self.loose_compression,
344 }345 }
gix-odb/src/store_impls/dynamic/header.rs+1−1
@@ -60,7 +60,7 @@ where · 59 unmodified lines
60 }60 }
61 None => {61 None => {
62 // The pack wasn't available anymore so we are supposed to try another round with a fresh index62 // 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())? {
64 Some(new_snapshot) => {64 Some(new_snapshot) => {
65 *snapshot = new_snapshot;65 *snapshot = new_snapshot;
66 self.clear_cache();66 self.clear_cache();
gix-odb/src/store_impls/dynamic/init.rs+17−2
@@ -48,7 +48,7 @@ impl Default for Options { · 47 unmodified lines
48/// Deterministic synchronization support for tests of concurrent object-database behavior.48/// Deterministic synchronization support for tests of concurrent object-database behavior.
49#[cfg(feature = "test-support")]49#[cfg(feature = "test-support")]
50pub mod debug {50pub mod debug {
51 use std::{fmt, sync::Arc};51 use std::{fmt, sync::Arc, time::Instant};
52 52
53 /// The result of a filesystem-backed load attempt.53 /// The result of a filesystem-backed load attempt.
54 #[non_exhaustive]54 #[non_exhaustive]
@@ -143,17 +143,31 @@ pub mod debug { · 88 unmodified lines
143 #[derive(Clone)]143 #[derive(Clone)]
144 pub struct Options {144 pub struct Options {
145 hook: Arc<dyn Fn(Point) + Send + Sync>,145 hook: Arc<dyn Fn(Point) + Send + Sync>,
146 clock: Option<Arc<dyn Fn() -> Instant + Send + Sync>>,
146 }147 }
147 148
148 impl Options {149 impl Options {
149 /// Create options that invoke `hook` at every configured synchronization point.150 /// Create options that invoke `hook` at every configured synchronization point.
150 pub fn new(hook: impl Fn(Point) + Send + Sync + 'static) -> Self {151 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
152 }162 }
153 163
154 pub(crate) fn at(&self, point: Point) {164 pub(crate) fn at(&self, point: Point) {
155 (self.hook)(point);165 (self.hook)(point);
156 }166 }
167
168 pub(crate) fn now(&self) -> Instant {
169 self.clock.as_ref().map_or_else(Instant::now, |clock| clock())
170 }
157 }171 }
158 172
159 impl fmt::Debug for Options {173 impl fmt::Debug for Options {
@@ -273,6 +287,7 @@ impl Store { · 113 unmodified lines
273 num_disk_state_consolidation: Default::default(),287 num_disk_state_consolidation: Default::default(),
274 num_disk_state_consolidations_completed: Default::default(),288 num_disk_state_consolidations_completed: Default::default(),
275 last_disk_state_consolidation_error: Default::default(),289 last_disk_state_consolidation_error: Default::default(),
290 last_successful_disk_state_consolidation: Default::default(),
276 })291 })
277 }292 }
278}293}
gix-odb/src/store_impls/dynamic/load_index.rs+28−5
@@ -59,6 +59,7 @@ impl super::Store { · 58 unmodified lines
59 let mut snapshot = self.collect_snapshot();59 let mut snapshot = self.collect_snapshot();
60 while let Some(new_snapshot) = self.load_one_index(IndexCtx {60 while let Some(new_snapshot) = self.load_one_index(IndexCtx {
61 refresh_mode: RefreshMode::Never,61 refresh_mode: RefreshMode::Never,
62 force_refresh: false,
62 marker: snapshot.marker,63 marker: snapshot.marker,
63 loose_compression: self.loose_compression,64 loose_compression: self.loose_compression,
64 })? {65 })? {
@@ -73,6 +74,7 @@ impl super::Store { · 8 unmodified lines
73 &self,74 &self,
74 IndexCtx {75 IndexCtx {
75 refresh_mode,76 refresh_mode,
77 force_refresh,
76 marker,78 marker,
77 loose_compression,79 loose_compression,
78 }: IndexCtx,80 }: IndexCtx,
@@ -99,16 +101,34 @@ impl super::Store { · 20 unmodified lines
99 // …and if that didn't yield anything new consider refreshing our disk state.101 // …and if that didn't yield anything new consider refreshing our disk state.
100 match refresh_mode {102 match refresh_mode {
101 RefreshMode::Never => Ok(None),103 RefreshMode::Never => Ok(None),
102 RefreshMode::AfterAllIndicesLoaded => self.consolidate_with_disk_state(104 RefreshMode::AfterDuration(refresh_after)
103 false, /* needs init */105 if !force_refresh && self.last_refresh_is_younger_than(refresh_after) =>
104 true, /* load one new index */106 {
105 loose_compression,107 Ok(None)
106 ),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 }
107 }116 }
108 }117 }
109 }118 }
110 }119 }
111 120
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
112 /// 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.132 /// 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.
113 /// 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 load133 /// 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
114 /// as our index might already be out-of-date as we try to use it to learn what's next.134 /// 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
541 }561 }
542 Some(self.collect_snapshot())562 Some(self.collect_snapshot())
543 };563 };
564 if !was_uninitialized {
565 *self.last_successful_disk_state_consolidation.lock() = Some(self.now());
566 }
544 self.finish_refresh(current_completed_refreshes, Ok(snapshot))567 self.finish_refresh(current_completed_refreshes, Ok(snapshot))
545 }568 }
546 569
gix-odb/src/store_impls/dynamic/mod.rs+28−2
@@ -1,5 +1,5 @@
1//! The standard object store which should fit all needs.1//! The standard object store which should fit all needs.
2use std::{cell::RefCell, ops::Deref, sync::Arc};2use std::{cell::RefCell, ops::Deref, sync::Arc, time::Duration};
3 3
4use crate::Store;4use crate::Store;
5 5
@@ -44,17 +44,30 @@ where · 38 unmodified lines
44#[derive(Clone, Copy)]44#[derive(Clone, Copy)]
45pub(crate) struct IndexCtx {45pub(crate) struct IndexCtx {
46 refresh_mode: RefreshMode,46 refresh_mode: RefreshMode,
47 force_refresh: bool,
47 marker: types::SlotIndexMarker,48 marker: types::SlotIndexMarker,
48 loose_compression: gix_zlib::Compression,49 loose_compression: gix_zlib::Compression,
49}50}
50 51
52impl IndexCtx {
53 fn force_refresh(mut self) -> Self {
54 self.force_refresh = true;
55 self
56 }
57}
58
51/// Decide what happens when all indices are loaded.59/// Decide what happens when all indices are loaded.
52#[derive(Default, Clone, Copy)]60#[derive(Default, Clone, Copy, Debug, Eq, PartialEq)]
53pub enum RefreshMode {61pub enum RefreshMode {
54 /// Check for new or changed pack indices (and pack data files) when the last known index is loaded.62 /// Check for new or changed pack indices (and pack data files) when the last known index is loaded.
55 /// During runtime handles configured for stable pack locations retain the corresponding indices and packs.63 /// During runtime handles configured for stable pack locations retain the corresponding indices and packs.
56 #[default]64 #[default]
57 AfterAllIndicesLoaded,65 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),
58 /// Use this if you expect a lot of missing objects that shouldn't trigger refreshes even after all packs are loaded.71 /// Use this if you expect a lot of missing objects that shouldn't trigger refreshes even after all packs are loaded.
59 /// This comes at the risk of not learning that the packs have changed in the mean time.72 /// This comes at the risk of not learning that the packs have changed in the mean time.
60 Never,73 Never,
@@ -94,6 +107,19 @@ impl Store { · 33 unmodified lines
94 debug.at(point);107 debug.at(point);
95 }108 }
96 }109 }
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"))]
119impl Store {
120 pub(crate) fn now(&self) -> std::time::Instant {
121 std::time::Instant::now()
122 }
97}123}
98 124
99pub(crate) mod handle;125pub(crate) mod handle;
gix-odb/src/store_impls/dynamic/prefix.rs+4−2
@@ -133,8 +133,10 @@ where · 132 unmodified lines
133 ///133 ///
134 /// ### Performance Note134 /// ### Performance Note
135 ///135 ///
136 /// - Unless the handles refresh mode is set to `Never`, each lookup will trigger a refresh of the object databases files136 /// - With [`RefreshMode::AfterAllIndicesLoaded`](super::RefreshMode::AfterAllIndicesLoaded), each lookup will refresh the
137 /// on disk if the prefix doesn't lead to ambiguous results.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.
138 /// - Since all objects need to be examined to assure non-ambiguous return values, after calling this method all indices will140 /// - Since all objects need to be examined to assure non-ambiguous return values, after calling this method all indices will
139 /// be loaded.141 /// be loaded.
140 /// - If `candidates` is `Some(…)`, the traversal will continue to obtain all candidates, which takes more time142 /// - 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
47}47}
48 48
49impl Store {49impl 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
50 /// Return information about all files known to us as well as their loading state.59 /// Return information about all files known to us as well as their loading state.
51 ///60 ///
52 /// Note that this call is expensive as it gathers additional information about loose object databases.61 /// 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
343#[cfg(feature = "parallel")]343#[cfg(feature = "parallel")]
344#[test]344#[test]
345fn debug_hooks_coalesce_successful_refreshes() -> Result {345fn debug_hooks_coalesce_successful_refreshes() -> Result {
346 use std::sync::{346 use std::{
347 Arc,347 sync::{
348 atomic::{AtomicBool, Ordering},348 Arc, Mutex,
349 atomic::{AtomicBool, Ordering},
350 },
351 time::{Duration, Instant},
349 };352 };
350 353
351 use gix_odb::store::init::debug::Point;354 use gix_odb::store::init::debug::Point;
@@ -354,6 +357,7 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 2 unmodified lines
354 let (point_tx, point_rx) = crossbeam_channel::unbounded();357 let (point_tx, point_rx) = crossbeam_channel::unbounded();
355 let (resume_tx, resume_rx) = crossbeam_channel::bounded(0);358 let (resume_tx, resume_rx) = crossbeam_channel::bounded(0);
356 let pause_next_refresh = Arc::new(AtomicBool::new(false));359 let pause_next_refresh = Arc::new(AtomicBool::new(false));
360 let now = Arc::new(Mutex::new(Instant::now()));
357 let debug = gix_odb::store::init::debug::Options::new({361 let debug = gix_odb::store::init::debug::Options::new({
358 let pause_next_refresh = Arc::clone(&pause_next_refresh);362 let pause_next_refresh = Arc::clone(&pause_next_refresh);
359 move |point| {363 move |point| {
@@ -364,8 +368,12 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 4 unmodified lines
364 resume_rx.recv().expect("the test releases the refresh scan");368 resume_rx.recv().expect("the test releases the refresh scan");
365 }369 }
366 }370 }
371 })
372 .with_clock({
373 let now = Arc::clone(&now);
374 move || *now.lock().expect("the test clock isn't poisoned")
367 });375 });
368 let handle = gix_odb::at_opts(376 let mut handle = gix_odb::at_opts(
369 fixture.objects_dir(Database::Primary),377 fixture.objects_dir(Database::Primary),
370 fixture.manifest.object_hash,378 fixture.manifest.object_hash,
371 Vec::new(),379 Vec::new(),
@@ -420,6 +428,12 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 48 unmodified lines
420 "the changed ODB adds one refresh"428 "the changed ODB adds one refresh"
421 );429 );
422 430
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 }
423 point_rx.try_iter().for_each(drop);437 point_rx.try_iter().for_each(drop);
424 let (first, second, observed) = contended_lookup(438 let (first, second, observed) = contended_lookup(
425 handle.clone(),439 handle.clone(),
@@ -440,7 +454,7 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 14 unmodified lines
440 .filter(|point| matches!(point, Point::RefreshScanStarted))454 .filter(|point| matches!(point, Point::RefreshScanStarted))
441 .count(),455 .count(),
442 1,456 1,
443 "contending misses scan the unchanged ODB once"457 "contending handles scan the unchanged ODB once at the shared deadline"
444 );458 );
445 assert_eq!(459 assert_eq!(
446 handle.store_ref().metrics().num_refreshes,460 handle.store_ref().metrics().num_refreshes,
@@ -968,6 +982,241 @@ fn never_refresh_does_not_retry_failed_initial_scan() -> Result { · 521 unmodified lines
968 Ok(())982 Ok(())
969}983}
970 984
985#[test]
986fn 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]
1062fn 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]
1143fn 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]
1196fn 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
971#[test]1220#[test]
972fn a_midx_rewrite_does_not_require_a_spare_slot() -> Result {1221fn a_midx_rewrite_does_not_require_a_spare_slot() -> Result {
973 let mut fixture = OdbFixture::from_script()?;1222 let mut fixture = OdbFixture::from_script()?;
gix/src/config/cache/init.rs+5−0
@@ -80,6 +80,7 @@ impl Cache { · 79 unmodified lines
80 let object_kind_hint = util::disambiguate_hint(&config, lenient_config)?;80 let object_kind_hint = util::disambiguate_hint(&config, lenient_config)?;
81 let (static_pack_cache_limit_bytes, pack_cache_bytes, object_cache_bytes, alloc_limit_bytes) =81 let (static_pack_cache_limit_bytes, pack_cache_bytes, object_cache_bytes, alloc_limit_bytes) =
82 util::parse_object_caches(&config, lenient_config, filter_config_section)?;82 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)?;
83 let loose_compression = super::access::loose_compression(&config, lenient_config, filter_config_section)?;84 let loose_compression = super::access::loose_compression(&config, lenient_config, filter_config_section)?;
84 // NOTE: When adding a new initial cache, consider adjusting `reread_values_and_clear_caches()` as well.85 // NOTE: When adding a new initial cache, consider adjusting `reread_values_and_clear_caches()` as well.
85 Ok(Cache {86 Ok(Cache {
@@ -92,6 +93,7 @@ impl Cache { · 6 unmodified lines
92 pack_cache_bytes,93 pack_cache_bytes,
93 object_cache_bytes,94 object_cache_bytes,
94 alloc_limit_bytes,95 alloc_limit_bytes,
96 object_refresh_mode,
95 loose_compression,97 loose_compression,
96 reflog,98 reflog,
97 refs_namespace,99 refs_namespace,
@@ -172,6 +174,8 @@ impl Cache { · 74 unmodified lines
172 self.object_cache_bytes,174 self.object_cache_bytes,
173 self.alloc_limit_bytes,175 self.alloc_limit_bytes,
174 ) = util::parse_object_caches(config, self.lenient_config, self.filter_config_section)?;176 ) = 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)?;
175 let loose_compression =179 let loose_compression =
176 super::access::loose_compression(config, self.lenient_config, self.filter_config_section)?;180 super::access::loose_compression(config, self.lenient_config, self.filter_config_section)?;
177 self.loose_compression = loose_compression;181 self.loose_compression = loose_compression;
@@ -383,6 +387,7 @@ impl crate::Repository { · 205 unmodified lines
383 fn apply_changed_values(&mut self) {387 fn apply_changed_values(&mut self) {
384 self.refs.write_reflog = util::reflog_or_default(self.config.reflog, self.workdir().is_some());388 self.refs.write_reflog = util::reflog_or_default(self.config.reflog, self.workdir().is_some());
385 self.refs.namespace.clone_from(&self.config.refs_namespace);389 self.refs.namespace.clone_from(&self.config.refs_namespace);
390 self.objects.refresh = self.config.object_refresh_mode;
386 }391 }
387}392}
388 393
gix/src/config/cache/util.rs+12−0
@@ -98,6 +98,18 @@ pub(crate) fn reflog_or_default( · 97 unmodified lines
98 98
99pub(crate) type ObjectCaches = (Option<usize>, Option<usize>, usize, Option<usize>);99pub(crate) type ObjectCaches = (Option<usize>, Option<usize>, usize, Option<usize>);
100 100
101pub(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
101/// Return `(static_pack_cache_limit, pack_cache_bytes, object_cache_bytes, alloc_limit_bytes)` as parsed from gix-config.113/// Return `(static_pack_cache_limit, pack_cache_bytes, object_cache_bytes, alloc_limit_bytes)` as parsed from gix-config.
102pub(crate) fn parse_object_caches(114pub(crate) fn parse_object_caches(
103 config: &gix_config::File,115 config: &gix_config::File,
gix/src/config/mod.rs+4−0
@@ -88,6 +88,8 @@ pub enum Error { · 87 unmodified lines
88 #[error(transparent)]88 #[error(transparent)]
89 ConfigBoolean(#[from] boolean::Error),89 ConfigBoolean(#[from] boolean::Error),
90 #[error(transparent)]90 #[error(transparent)]
91 ConfigDuration(#[from] duration::Error),
92 #[error(transparent)]
91 ConfigUnsigned(#[from] unsigned_integer::Error),93 ConfigUnsigned(#[from] unsigned_integer::Error),
92 #[error(transparent)]94 #[error(transparent)]
93 ConfigTypedString(#[from] key::GenericErrorWithValue),95 ConfigTypedString(#[from] key::GenericErrorWithValue),
@@ -654,6 +656,8 @@ pub(crate) struct Cache { · 560 unmodified lines
654 pub(crate) object_cache_bytes: usize,656 pub(crate) object_cache_bytes: usize,
655 /// The maximum size of a single allocation caused by user-controlled on-disk packed object data.657 /// The maximum size of a single allocation caused by user-controlled on-disk packed object data.
656 pub(crate) alloc_limit_bytes: Option<usize>,658 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,
657 /// The compression level to use when writing loose objects, from `core.looseCompression` or `core.compression`.661 /// The compression level to use when writing loose objects, from `core.looseCompression` or `core.compression`.
658 pub(crate) loose_compression: gix_zlib::Compression,662 pub(crate) loose_compression: gix_zlib::Compression,
659 /// The amount of bytes we can hold in our static LRU cache. Otherwise, go with the defaults.663 /// 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
497 keys::UnsignedInteger::new_unsigned_integer("allocLimitIfReducedTrust", &Gitoxide::OBJECTS).with_note(497 keys::UnsignedInteger::new_unsigned_integer("allocLimitIfReducedTrust", &Gitoxide::OBJECTS).with_note(
498 "The default allocation limit used for reduced-trust repositories when no explicit allocLimit is configured; set to 0 to disable it",498 "The default allocation limit used for reduced-trust repositories when no explicit allocLimit is configured; set to 0 to disable it",
499 );499 );
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 );
500 /// The `gitoxide.objects.noReplace` key.507 /// The `gitoxide.objects.noReplace` key.
501 pub const NO_REPLACE: keys::Boolean = keys::Boolean::new_boolean("noReplace", &Gitoxide::OBJECTS);508 pub const NO_REPLACE: keys::Boolean = keys::Boolean::new_boolean("noReplace", &Gitoxide::OBJECTS);
502 /// The `gitoxide.objects.replaceRefBase` key.509 /// The `gitoxide.objects.replaceRefBase` key.
@@ -515,6 +522,7 @@ mod subsections { · 12 unmodified lines
515 &Self::CACHE_LIMIT,522 &Self::CACHE_LIMIT,
516 &Self::ALLOC_LIMIT,523 &Self::ALLOC_LIMIT,
517 &Self::ALLOC_LIMIT_IF_REDUCED_TRUST,524 &Self::ALLOC_LIMIT_IF_REDUCED_TRUST,
525 &Self::REFRESH_AFTER,
518 &Self::REPLACE_REF_BASE,526 &Self::REPLACE_REF_BASE,
519 ]527 ]
520 }528 }
gix/src/remote/connection/fetch/receive_pack.rs+1−0
@@ -193,6 +193,7 @@ where · 192 unmodified lines
193 repo.object_hash(),193 repo.object_hash(),
194 write_pack_options,194 write_pack_options,
195 )?;195 )?;
196 repo.objects.store_ref().mark_disk_state_stale();
196 may_read_to_end = true;197 may_read_to_end = true;
197 Some(res)198 Some(res)
198 } else {199 } else {
gix/src/repository/init.rs+1−1
@@ -63,8 +63,8 @@ impl crate::Repository { · 62 unmodified lines
63 }63 }
64}64}
65 65
66#[cfg_attr(not(feature = "max-performance-safe"), allow(unused_variables, unused_mut))]
67pub(crate) fn setup_objects(objects: &mut crate::OdbHandle, config: &crate::config::Cache) {66pub(crate) fn setup_objects(objects: &mut crate::OdbHandle, config: &crate::config::Cache) {
67 objects.refresh = config.object_refresh_mode;
68 #[cfg(feature = "max-performance-safe")]68 #[cfg(feature = "max-performance-safe")]
69 {69 {
70 match config.pack_cache_bytes {70 match config.pack_cache_bytes {
gix/tests/gix/config/tree.rs+11−0
@@ -1138,6 +1138,8 @@ mod gitoxide { · 1137 unmodified lines
1138 }1138 }
1139 }1139 }
1140 mod objects {1140 mod objects {
1141 use std::time::Duration;
1142
1141 use gix::config::tree::{Key, gitoxide};1143 use gix::config::tree::{Key, gitoxide};
1142 1144
1143 #[test]1145 #[test]
@@ -1157,6 +1159,15 @@ mod gitoxide { · 13 unmodified lines
1157 );1159 );
1158 Ok(())1160 Ok(())
1159 }1161 }
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 }
1160 }1171 }
1161}1172}
1162 1173
gix/tests/gix/repository/mod.rs+3−4
@@ -145,10 +145,9 @@ mod dirwalk { · 144 unmodified lines
145#[test]145#[test]
146fn size_in_memory() {146fn size_in_memory() {
147 let actual_size = std::mem::size_of::<Repository>();147 let actual_size = std::mem::size_of::<Repository>();
148 // The selected index path adds one `PathBuf` to the repository.148 // Allow the selected index path, network-client cache, and refresh policy stored in both the ODB handle
149 // Network-client features add protocol permission caching to `Repository::config`,149 // and configuration cache. The largest observed layout is on 64-bit Windows.
150 // which grows the type by one more cached cell.150 let limit = 1528;
151 let limit = 1500;
152 assert!(151 assert!(
153 actual_size <= limit,152 actual_size <= limit,
154 "size of Repository shouldn't change without us noticing, it's meant to be cloned: should have been below {limit:?}, was {actual_size}"153 "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
849 }849 }
850}850}
851 851
852mod 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
852mod worktree {892mod worktree {
853 use gix::open;893 use gix::open;
854 894