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 | ! cargo check -p gitoxide-core --all-features --features gix/sha1 2>/dev/null | 18 | ! cargo check -p gitoxide-core --all-features --features gix/sha1 2>/dev/null |
| 19 | cargo check -p gix-protocol --all-features | 19 | cargo check -p gix-protocol --all-features |
| 20 | tree="$(cargo --color=never tree -p gix --no-default-features -e normal --prefix none --format '{p}')" | 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 | cargo --color=never tree -p gix --no-default-features -e normal -i gix-submodule \ | 22 | cargo --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\>' |
| 24 | cargo --color=never tree -p gix --no-default-features -e normal -i gix-pathspec \ | 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 | 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 operations | 174 | /// The hash kind to use for some operations |
| @@ -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 index | 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 | 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 index | 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 | Some(new_snapshot) => { | 423 | Some(new_snapshot) => { |
| 424 | *snapshot = new_snapshot; | 424 | *snapshot = new_snapshot; |
| 425 | self.clear_cache(); | 425 | self.clear_cache(); |
| @@ -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 | } |
| @@ -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 index | 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 | Some(new_snapshot) => { | 64 | Some(new_snapshot) => { |
| 65 | *snapshot = new_snapshot; | 65 | *snapshot = new_snapshot; |
| 66 | self.clear_cache(); | 66 | self.clear_cache(); |
| @@ -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")] |
| 50 | pub mod debug { | 50 | pub 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 | } |
| @@ -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 load | 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 | /// 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 | ||
| @@ -1,5 +1,5 @@ | |||
| 1 | //! The standard object store which should fit all needs. | 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 | use crate::Store; | 4 | use crate::Store; |
| 5 | 5 | ||
| @@ -44,17 +44,30 @@ where · 38 unmodified lines | |||
| 44 | #[derive(Clone, Copy)] | 44 | #[derive(Clone, Copy)] |
| 45 | pub(crate) struct IndexCtx { | 45 | pub(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 | ||
| 52 | impl 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)] |
| 53 | pub enum RefreshMode { | 61 | pub 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"))] | ||
| 119 | impl Store { | ||
| 120 | pub(crate) fn now(&self) -> std::time::Instant { | ||
| 121 | std::time::Instant::now() | ||
| 122 | } | ||
| 97 | } | 123 | } |
| 98 | 124 | ||
| 99 | pub(crate) mod handle; | 125 | pub(crate) mod handle; |
| @@ -133,8 +133,10 @@ where · 132 unmodified lines | |||
| 133 | /// | 133 | /// |
| 134 | /// ### Performance Note | 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 | 136 | /// - 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 will | 140 | /// - 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 time | 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 | impl Store { | 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 | /// 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. |
| @@ -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] |
| 345 | fn debug_hooks_coalesce_successful_refreshes() -> Result { | 345 | fn 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] | ||
| 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 | #[test] | 1220 | #[test] |
| 972 | fn a_midx_rewrite_does_not_require_a_spare_slot() -> Result { | 1221 | fn a_midx_rewrite_does_not_require_a_spare_slot() -> Result { |
| 973 | let mut fixture = OdbFixture::from_script()?; | 1222 | let mut fixture = OdbFixture::from_script()?; |
| @@ -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 | ||
| @@ -98,6 +98,18 @@ pub(crate) fn reflog_or_default( · 97 unmodified lines | |||
| 98 | 98 | ||
| 99 | pub(crate) type ObjectCaches = (Option<usize>, Option<usize>, usize, Option<usize>); | 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 | /// 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. |
| 102 | pub(crate) fn parse_object_caches( | 114 | pub(crate) fn parse_object_caches( |
| 103 | config: &gix_config::File, | 115 | config: &gix_config::File, |
| @@ -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. |
| @@ -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 | } |
| @@ -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 { |
| @@ -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 | pub(crate) fn setup_objects(objects: &mut crate::OdbHandle, config: &crate::config::Cache) { | 66 | pub(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 { |
| @@ -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 | ||
| @@ -145,10 +145,9 @@ mod dirwalk { · 144 unmodified lines | |||
| 145 | #[test] | 145 | #[test] |
| 146 | fn size_in_memory() { | 146 | fn 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}" |
| @@ -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 | mod worktree { | 892 | mod worktree { |
| 853 | use gix::open; | 893 | use gix::open; |
| 854 | 894 | ||