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.
Unified · Split
| @@ -18,7 +18,7 @@ cargo check -p gix-transport --all-features 2>/dev/null · 17 unmodified lines | ||
| 18 | 18 | ! cargo check -p gitoxide-core --all-features --features gix/sha1 2>/dev/null |
| 19 | 19 | cargo check -p gix-protocol --all-features |
| 20 | 20 | 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' | |
| 22 | 22 | cargo --color=never tree -p gix --no-default-features -e normal -i gix-submodule \ |
| 23 | 23 | 2>&1 >/dev/null | grep '^warning: nothing to print\>' |
| 24 | 24 | cargo --color=never tree -p gix --no-default-features -e normal -i gix-pathspec \ |
| @@ -167,6 +167,8 @@ pub struct Store { · 166 unmodified lines | ||
| 167 | 167 | pub(crate) num_disk_state_consolidations_completed: AtomicUsize, |
| 168 | 168 | /// The most recent failed consolidation, shared only with callers that were already waiting for it. |
| 169 | 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 | 172 | /// If true, we are allowed to use multi-pack indices and they must have the `object_hash` or be ignored. |
| 171 | 173 | use_multi_pack_index: bool, |
| 172 | 174 | /// The hash kind to use for some operations |
| @@ -146,7 +146,7 @@ where · 145 unmodified lines | ||
| 146 | 146 | } |
| 147 | 147 | None => { |
| 148 | 148 | // 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 | 150 | Some(new_snapshot) => { |
| 151 | 151 | *snapshot = new_snapshot; |
| 152 | 152 | self.clear_cache(); |
| @@ -419,7 +419,7 @@ where · 266 unmodified lines | ||
| 419 | 419 | } |
| 420 | 420 | None => { |
| 421 | 421 | // 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 | 423 | Some(new_snapshot) => { |
| 424 | 424 | *snapshot = new_snapshot; |
| 425 | 425 | self.clear_cache(); |
| @@ -339,6 +339,7 @@ where · 338 unmodified lines | ||
| 339 | 339 | pub(crate) fn index_ctx(&self, marker: types::SlotIndexMarker) -> super::IndexCtx { |
| 340 | 340 | super::IndexCtx { |
| 341 | 341 | refresh_mode: self.refresh, |
| 342 | + force_refresh: false, | |
| 342 | 343 | marker, |
| 343 | 344 | loose_compression: self.loose_compression, |
| 344 | 345 | } |
| @@ -60,7 +60,7 @@ where · 59 unmodified lines | ||
| 60 | 60 | } |
| 61 | 61 | None => { |
| 62 | 62 | // 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 | 64 | Some(new_snapshot) => { |
| 65 | 65 | *snapshot = new_snapshot; |
| 66 | 66 | self.clear_cache(); |
| @@ -48,7 +48,7 @@ impl Default for Options { · 47 unmodified lines | ||
| 48 | 48 | /// Deterministic synchronization support for tests of concurrent object-database behavior. |
| 49 | 49 | #[cfg(feature = "test-support")] |
| 50 | 50 | pub mod debug { |
| 51 | − use std::{fmt, sync::Arc}; | |
| 51 | + use std::{fmt, sync::Arc, time::Instant}; | |
| 52 | 52 | |
| 53 | 53 | /// The result of a filesystem-backed load attempt. |
| 54 | 54 | #[non_exhaustive] |
| @@ -143,17 +143,31 @@ pub mod debug { · 88 unmodified lines | ||
| 143 | 143 | #[derive(Clone)] |
| 144 | 144 | pub struct Options { |
| 145 | 145 | hook: Arc<dyn Fn(Point) + Send + Sync>, |
| 146 | + clock: Option<Arc<dyn Fn() -> Instant + Send + Sync>>, | |
| 146 | 147 | } |
| 147 | 148 | |
| 148 | 149 | impl Options { |
| 149 | 150 | /// Create options that invoke `hook` at every configured synchronization point. |
| 150 | 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 | 164 | pub(crate) fn at(&self, point: Point) { |
| 155 | 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 | 173 | impl fmt::Debug for Options { |
| @@ -273,6 +287,7 @@ impl Store { · 113 unmodified lines | ||
| 273 | 287 | num_disk_state_consolidation: Default::default(), |
| 274 | 288 | num_disk_state_consolidations_completed: Default::default(), |
| 275 | 289 | last_disk_state_consolidation_error: Default::default(), |
| 290 | + last_successful_disk_state_consolidation: Default::default(), | |
| 276 | 291 | }) |
| 277 | 292 | } |
| 278 | 293 | } |
| @@ -59,6 +59,7 @@ impl super::Store { · 58 unmodified lines | ||
| 59 | 59 | let mut snapshot = self.collect_snapshot(); |
| 60 | 60 | while let Some(new_snapshot) = self.load_one_index(IndexCtx { |
| 61 | 61 | refresh_mode: RefreshMode::Never, |
| 62 | + force_refresh: false, | |
| 62 | 63 | marker: snapshot.marker, |
| 63 | 64 | loose_compression: self.loose_compression, |
| 64 | 65 | })? { |
| @@ -73,6 +74,7 @@ impl super::Store { · 8 unmodified lines | ||
| 73 | 74 | &self, |
| 74 | 75 | IndexCtx { |
| 75 | 76 | refresh_mode, |
| 77 | + force_refresh, | |
| 76 | 78 | marker, |
| 77 | 79 | loose_compression, |
| 78 | 80 | }: IndexCtx, |
| @@ -99,16 +101,34 @@ impl super::Store { · 20 unmodified lines | ||
| 99 | 101 | // …and if that didn't yield anything new consider refreshing our disk state. |
| 100 | 102 | match refresh_mode { |
| 101 | 103 | 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 | + } | |
| 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 | 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 | 133 | /// 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 | 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 | 562 | Some(self.collect_snapshot()) |
| 543 | 563 | }; |
| 564 | + if !was_uninitialized { | |
| 565 | + *self.last_successful_disk_state_consolidation.lock() = Some(self.now()); | |
| 566 | + } | |
| 544 | 567 | self.finish_refresh(current_completed_refreshes, Ok(snapshot)) |
| 545 | 568 | } |
| 546 | 569 | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | //! 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}; | |
| 3 | 3 | |
| 4 | 4 | use crate::Store; |
| 5 | 5 | |
| @@ -44,17 +44,30 @@ where · 38 unmodified lines | ||
| 44 | 44 | #[derive(Clone, Copy)] |
| 45 | 45 | pub(crate) struct IndexCtx { |
| 46 | 46 | refresh_mode: RefreshMode, |
| 47 | + force_refresh: bool, | |
| 47 | 48 | marker: types::SlotIndexMarker, |
| 48 | 49 | loose_compression: gix_zlib::Compression, |
| 49 | 50 | } |
| 50 | 51 | |
| 52 | +impl IndexCtx { | |
| 53 | + fn force_refresh(mut self) -> Self { | |
| 54 | + self.force_refresh = true; | |
| 55 | + self | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 51 | 59 | /// Decide what happens when all indices are loaded. |
| 52 | −#[derive(Default, Clone, Copy)] | |
| 60 | +#[derive(Default, Clone, Copy, Debug, Eq, PartialEq)] | |
| 53 | 61 | pub enum RefreshMode { |
| 54 | 62 | /// Check for new or changed pack indices (and pack data files) when the last known index is loaded. |
| 55 | 63 | /// During runtime handles configured for stable pack locations retain the corresponding indices and packs. |
| 56 | 64 | #[default] |
| 57 | 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 | 71 | /// Use this if you expect a lot of missing objects that shouldn't trigger refreshes even after all packs are loaded. |
| 59 | 72 | /// This comes at the risk of not learning that the packs have changed in the mean time. |
| 60 | 73 | Never, |
| @@ -94,6 +107,19 @@ impl Store { · 33 unmodified lines | ||
| 94 | 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"))] | |
| 119 | +impl Store { | |
| 120 | + pub(crate) fn now(&self) -> std::time::Instant { | |
| 121 | + std::time::Instant::now() | |
| 122 | + } | |
| 97 | 123 | } |
| 98 | 124 | |
| 99 | 125 | pub(crate) mod handle; |
| @@ -133,8 +133,10 @@ where · 132 unmodified lines | ||
| 133 | 133 | /// |
| 134 | 134 | /// ### Performance Note |
| 135 | 135 | /// |
| 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. | |
| 138 | 140 | /// - Since all objects need to be examined to assure non-ambiguous return values, after calling this method all indices will |
| 139 | 141 | /// be loaded. |
| 140 | 142 | /// - If `candidates` is `Some(…)`, the traversal will continue to obtain all candidates, which takes more time |
| @@ -47,6 +47,15 @@ pub enum IndexState { · 46 unmodified lines | ||
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | 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 | + | |
| 50 | 59 | /// Return information about all files known to us as well as their loading state. |
| 51 | 60 | /// |
| 52 | 61 | /// Note that this call is expensive as it gathers additional information about loose object databases. |
| @@ -343,9 +343,12 @@ fn debug_hooks_coordinate_contending_failed_index_loaders() -> Result { · 342 unmodified lines | ||
| 343 | 343 | #[cfg(feature = "parallel")] |
| 344 | 344 | #[test] |
| 345 | 345 | 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}, | |
| 349 | 352 | }; |
| 350 | 353 | |
| 351 | 354 | use gix_odb::store::init::debug::Point; |
| @@ -354,6 +357,7 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 2 unmodified lines | ||
| 354 | 357 | let (point_tx, point_rx) = crossbeam_channel::unbounded(); |
| 355 | 358 | let (resume_tx, resume_rx) = crossbeam_channel::bounded(0); |
| 356 | 359 | let pause_next_refresh = Arc::new(AtomicBool::new(false)); |
| 360 | + let now = Arc::new(Mutex::new(Instant::now())); | |
| 357 | 361 | let debug = gix_odb::store::init::debug::Options::new({ |
| 358 | 362 | let pause_next_refresh = Arc::clone(&pause_next_refresh); |
| 359 | 363 | move |point| { |
| @@ -364,8 +368,12 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 4 unmodified lines | ||
| 364 | 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 | 377 | fixture.objects_dir(Database::Primary), |
| 370 | 378 | fixture.manifest.object_hash, |
| 371 | 379 | Vec::new(), |
| @@ -420,6 +428,12 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 48 unmodified lines | ||
| 420 | 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 | 437 | point_rx.try_iter().for_each(drop); |
| 424 | 438 | let (first, second, observed) = contended_lookup( |
| 425 | 439 | handle.clone(), |
| @@ -440,7 +454,7 @@ fn debug_hooks_coalesce_successful_refreshes() -> Result { · 14 unmodified lines | ||
| 440 | 454 | .filter(|point| matches!(point, Point::RefreshScanStarted)) |
| 441 | 455 | .count(), |
| 442 | 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 | 459 | assert_eq!( |
| 446 | 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 | 982 | Ok(()) |
| 969 | 983 | } |
| 970 | 984 | |
| 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 | + | |
| 971 | 1220 | #[test] |
| 972 | 1221 | fn a_midx_rewrite_does_not_require_a_spare_slot() -> Result { |
| 973 | 1222 | let mut fixture = OdbFixture::from_script()?; |
| @@ -80,6 +80,7 @@ impl Cache { · 79 unmodified lines | ||
| 80 | 80 | let object_kind_hint = util::disambiguate_hint(&config, lenient_config)?; |
| 81 | 81 | let (static_pack_cache_limit_bytes, pack_cache_bytes, object_cache_bytes, alloc_limit_bytes) = |
| 82 | 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 | 84 | let loose_compression = super::access::loose_compression(&config, lenient_config, filter_config_section)?; |
| 84 | 85 | // NOTE: When adding a new initial cache, consider adjusting `reread_values_and_clear_caches()` as well. |
| 85 | 86 | Ok(Cache { |
| @@ -92,6 +93,7 @@ impl Cache { · 6 unmodified lines | ||
| 92 | 93 | pack_cache_bytes, |
| 93 | 94 | object_cache_bytes, |
| 94 | 95 | alloc_limit_bytes, |
| 96 | + object_refresh_mode, | |
| 95 | 97 | loose_compression, |
| 96 | 98 | reflog, |
| 97 | 99 | refs_namespace, |
| @@ -172,6 +174,8 @@ impl Cache { · 74 unmodified lines | ||
| 172 | 174 | self.object_cache_bytes, |
| 173 | 175 | self.alloc_limit_bytes, |
| 174 | 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 | 179 | let loose_compression = |
| 176 | 180 | super::access::loose_compression(config, self.lenient_config, self.filter_config_section)?; |
| 177 | 181 | self.loose_compression = loose_compression; |
| @@ -383,6 +387,7 @@ impl crate::Repository { · 205 unmodified lines | ||
| 383 | 387 | fn apply_changed_values(&mut self) { |
| 384 | 388 | self.refs.write_reflog = util::reflog_or_default(self.config.reflog, self.workdir().is_some()); |
| 385 | 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 | |
| @@ -98,6 +98,18 @@ pub(crate) fn reflog_or_default( · 97 unmodified lines | ||
| 98 | 98 | |
| 99 | 99 | pub(crate) type ObjectCaches = (Option<usize>, Option<usize>, usize, Option<usize>); |
| 100 | 100 | |
| 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 | + | |
| 101 | 113 | /// Return `(static_pack_cache_limit, pack_cache_bytes, object_cache_bytes, alloc_limit_bytes)` as parsed from gix-config. |
| 102 | 114 | pub(crate) fn parse_object_caches( |
| 103 | 115 | config: &gix_config::File, |
| @@ -88,6 +88,8 @@ pub enum Error { · 87 unmodified lines | ||
| 88 | 88 | #[error(transparent)] |
| 89 | 89 | ConfigBoolean(#[from] boolean::Error), |
| 90 | 90 | #[error(transparent)] |
| 91 | + ConfigDuration(#[from] duration::Error), | |
| 92 | + #[error(transparent)] | |
| 91 | 93 | ConfigUnsigned(#[from] unsigned_integer::Error), |
| 92 | 94 | #[error(transparent)] |
| 93 | 95 | ConfigTypedString(#[from] key::GenericErrorWithValue), |
| @@ -654,6 +656,8 @@ pub(crate) struct Cache { · 560 unmodified lines | ||
| 654 | 656 | pub(crate) object_cache_bytes: usize, |
| 655 | 657 | /// The maximum size of a single allocation caused by user-controlled on-disk packed object data. |
| 656 | 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 | 661 | /// The compression level to use when writing loose objects, from `core.looseCompression` or `core.compression`. |
| 658 | 662 | pub(crate) loose_compression: gix_zlib::Compression, |
| 659 | 663 | /// The amount of bytes we can hold in our static LRU cache. Otherwise, go with the defaults. |
| @@ -497,6 +497,13 @@ mod subsections { · 496 unmodified lines | ||
| 497 | 497 | keys::UnsignedInteger::new_unsigned_integer("allocLimitIfReducedTrust", &Gitoxide::OBJECTS).with_note( |
| 498 | 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 | 507 | /// The `gitoxide.objects.noReplace` key. |
| 501 | 508 | pub const NO_REPLACE: keys::Boolean = keys::Boolean::new_boolean("noReplace", &Gitoxide::OBJECTS); |
| 502 | 509 | /// The `gitoxide.objects.replaceRefBase` key. |
| @@ -515,6 +522,7 @@ mod subsections { · 12 unmodified lines | ||
| 515 | 522 | &Self::CACHE_LIMIT, |
| 516 | 523 | &Self::ALLOC_LIMIT, |
| 517 | 524 | &Self::ALLOC_LIMIT_IF_REDUCED_TRUST, |
| 525 | + &Self::REFRESH_AFTER, | |
| 518 | 526 | &Self::REPLACE_REF_BASE, |
| 519 | 527 | ] |
| 520 | 528 | } |
| @@ -193,6 +193,7 @@ where · 192 unmodified lines | ||
| 193 | 193 | repo.object_hash(), |
| 194 | 194 | write_pack_options, |
| 195 | 195 | )?; |
| 196 | + repo.objects.store_ref().mark_disk_state_stale(); | |
| 196 | 197 | may_read_to_end = true; |
| 197 | 198 | Some(res) |
| 198 | 199 | } else { |
| @@ -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))] | |
| 67 | 66 | pub(crate) fn setup_objects(objects: &mut crate::OdbHandle, config: &crate::config::Cache) { |
| 67 | + objects.refresh = config.object_refresh_mode; | |
| 68 | 68 | #[cfg(feature = "max-performance-safe")] |
| 69 | 69 | { |
| 70 | 70 | match config.pack_cache_bytes { |
| @@ -1138,6 +1138,8 @@ mod gitoxide { · 1137 unmodified lines | ||
| 1138 | 1138 | } |
| 1139 | 1139 | } |
| 1140 | 1140 | mod objects { |
| 1141 | + use std::time::Duration; | |
| 1142 | + | |
| 1141 | 1143 | use gix::config::tree::{Key, gitoxide}; |
| 1142 | 1144 | |
| 1143 | 1145 | #[test] |
| @@ -1157,6 +1159,15 @@ mod gitoxide { · 13 unmodified lines | ||
| 1157 | 1159 | ); |
| 1158 | 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 | |
| @@ -145,10 +145,9 @@ mod dirwalk { · 144 unmodified lines | ||
| 145 | 145 | #[test] |
| 146 | 146 | fn size_in_memory() { |
| 147 | 147 | 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; | |
| 152 | 151 | assert!( |
| 153 | 152 | actual_size <= limit, |
| 154 | 153 | "size of Repository shouldn't change without us noticing, it's meant to be cloned: should have been below {limit:?}, was {actual_size}" |
| @@ -849,6 +849,46 @@ mod pack_alloc_limit_bytes { · 848 unmodified lines | ||
| 849 | 849 | } |
| 850 | 850 | } |
| 851 | 851 | |
| 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 | + | |
| 852 | 892 | mod worktree { |
| 853 | 893 | use gix::open; |
| 854 | 894 | |