use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use futures::future::{BoxFuture, Shared, WeakShared};
use crate::error::SailError;
use crate::imagebuild::ImageBuild;
pub(crate) const IMAGE_READY_REFRESH: Duration = Duration::from_hours(1);
pub(crate) const MAX_READY_ENTRIES: usize = 64;
pub(crate) type SharedBuild = Shared<BoxFuture<'static, Result<ImageBuild, Arc<SailError>>>>;
type WeakBuild = WeakShared<BoxFuture<'static, Result<ImageBuild, Arc<SailError>>>>;
pub(crate) type CacheKey = (Duration, String);
struct Entry {
id: u64,
started_at: Instant,
recovery: bool,
state: EntryState,
}
enum EntryState {
InFlight(WeakBuild),
Ready {
build: ImageBuild,
resolved_at: Instant,
},
}
#[must_use]
pub(crate) enum Joined {
Ready(ImageBuild),
Pending { build: SharedBuild, led: bool },
}
struct State {
entries: HashMap<CacheKey, Entry>,
order: VecDeque<(CacheKey, u64)>,
next_id: u64,
refresh_window: Duration,
}
pub(crate) struct ImageReadyCache {
state: Mutex<State>,
}
impl ImageReadyCache {
pub(crate) fn new() -> ImageReadyCache {
ImageReadyCache {
state: Mutex::new(State {
entries: HashMap::new(),
order: VecDeque::new(),
next_id: 0,
refresh_window: IMAGE_READY_REFRESH,
}),
}
}
#[cfg(any(test, feature = "test-fakes"))]
pub(crate) fn set_refresh_window(&self, window: Duration) {
self.state.lock().unwrap().refresh_window = window;
}
pub(crate) fn join_or_lead(
&self,
key: &CacheKey,
recovery: bool,
make: impl FnOnce(u64) -> SharedBuild,
) -> Joined {
let mut state = self.state.lock().unwrap();
if let Some(entry) = state.entries.get(key) {
match &entry.state {
EntryState::Ready { build, resolved_at }
if resolved_at.elapsed() <= state.refresh_window =>
{
return Joined::Ready(build.clone());
}
EntryState::InFlight(weak) => {
if let Some(build) = weak.upgrade() {
return Joined::Pending { build, led: false };
}
}
EntryState::Ready { .. } => {}
}
}
let id = state.next_id;
state.next_id += 1;
let build = make(id);
let weak = build
.downgrade()
.expect("a build future cannot complete before it is first polled");
state.entries.insert(
key.clone(),
Entry {
id,
started_at: Instant::now(),
recovery,
state: EntryState::InFlight(weak),
},
);
state.order.push_back((key.clone(), id));
while state.entries.len() > MAX_READY_ENTRIES {
let Some((oldest_key, oldest_id)) = state.order.pop_front() else {
break;
};
if state
.entries
.get(&oldest_key)
.is_some_and(|entry| entry.id == oldest_id)
{
state.entries.remove(&oldest_key);
}
}
if state.order.len() > MAX_READY_ENTRIES * 2 {
let State { entries, order, .. } = &mut *state;
order.retain(|(key, id)| entries.get(key).is_some_and(|entry| entry.id == *id));
}
Joined::Pending { build, led: true }
}
pub(crate) fn settle_success(&self, key: &CacheKey, id: u64, build: ImageBuild) {
let mut state = self.state.lock().unwrap();
if let Some(entry) = state.entries.get_mut(key) {
if entry.id == id {
entry.state = EntryState::Ready {
build,
resolved_at: Instant::now(),
};
}
}
}
pub(crate) fn invalidate_spec_started_before(&self, spec_hash: &str, cutoff: Instant) {
let mut state = self.state.lock().unwrap();
state.entries.retain(|key, entry| {
if key.1 != spec_hash || entry.started_at >= cutoff {
return true;
}
entry.recovery && matches!(entry.state, EntryState::InFlight(_))
});
}
pub(crate) fn settle_failure(&self, key: &CacheKey, id: u64) {
let mut state = self.state.lock().unwrap();
if state.entries.get(key).is_some_and(|entry| entry.id == id) {
state.entries.remove(key);
}
}
#[cfg(test)]
fn order_len(&self) -> usize {
self.state.lock().unwrap().order.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::FutureExt;
fn ready_build(id: &str) -> ImageBuild {
ImageBuild {
image_id: id.to_string(),
status: crate::imagebuild::ImageBuildStatus::Ready,
error_message: String::new(),
}
}
fn pending_build(id: &str) -> SharedBuild {
let build = ready_build(id);
async move { Ok(build) }.boxed().shared()
}
fn key(name: &str) -> CacheKey {
(Duration::from_mins(1), name.to_string())
}
fn assert_led(joined: &Joined, want: bool) {
match joined {
Joined::Pending { led, .. } => assert_eq!(*led, want),
Joined::Ready(_) => panic!("expected a pending build"),
}
}
#[tokio::test]
async fn joins_in_flight_entry_while_a_waiter_holds_it() {
let cache = ImageReadyCache::new();
let first = cache.join_or_lead(
&key("a"),
false,
|_| pending_build("img"),
);
assert_led(&first, true);
let second = cache.join_or_lead(
&key("a"),
false,
|_| panic!("must join"),
);
assert_led(&second, false);
}
#[tokio::test]
async fn abandoned_in_flight_entry_is_replaced() {
let cache = ImageReadyCache::new();
drop(cache.join_or_lead(
&key("a"),
false,
|_| pending_build("img"),
));
let next = cache.join_or_lead(
&key("a"),
false,
|_| pending_build("img2"),
);
assert_led(&next, true);
}
#[tokio::test]
async fn settled_success_serves_without_any_waiter() {
let cache = ImageReadyCache::new();
drop(cache.join_or_lead(
&key("a"),
false,
|_| pending_build("img"),
));
cache.settle_success(&key("a"), 0, ready_build("img"));
match cache.join_or_lead(
&key("a"),
false,
|_| panic!("must serve the value"),
) {
Joined::Ready(build) => assert_eq!(build.image_id, "img"),
Joined::Pending { .. } => panic!("expected the settled value"),
}
}
#[tokio::test]
async fn expired_success_is_replaced() {
let cache = ImageReadyCache::new();
cache.set_refresh_window(Duration::ZERO);
drop(cache.join_or_lead(
&key("a"),
false,
|_| pending_build("img"),
));
cache.settle_success(&key("a"), 0, ready_build("img"));
let next = cache.join_or_lead(
&key("a"),
false,
|_| pending_build("img2"),
);
assert_led(&next, true);
}
#[tokio::test]
async fn failed_entry_is_removed_and_superseded_settle_ignored() {
let cache = ImageReadyCache::new();
let first = cache.join_or_lead(
&key("a"),
false,
|_| pending_build("img"),
);
cache.settle_failure(&key("a"), 0);
let second = cache.join_or_lead(
&key("a"),
false,
|_| pending_build("img2"),
);
assert_led(&second, true);
cache.settle_failure(&key("a"), 0);
let third = cache.join_or_lead(
&key("a"),
false,
|_| panic!("must join"),
);
assert_led(&third, false);
drop((first, second, third));
}
#[tokio::test]
async fn in_flight_recovery_survives_a_later_stale_creates_invalidation() {
let cache = ImageReadyCache::new();
let held = cache.join_or_lead(
&key("a"),
true,
|_| pending_build("rec"),
);
cache.invalidate_spec_started_before("a", Instant::now());
let joined = cache.join_or_lead(&key("a"), true, |_| {
panic!("in-flight recovery must survive")
});
assert_led(&joined, false);
cache.settle_success(&key("a"), 0, ready_build("rec"));
cache.invalidate_spec_started_before("a", Instant::now());
let next = cache.join_or_lead(
&key("a"),
true,
|_| pending_build("rec2"),
);
assert_led(&next, true);
drop((held, joined, next));
}
#[tokio::test]
async fn repeated_replacement_of_one_key_keeps_the_order_queue_bounded() {
let cache = ImageReadyCache::new();
cache.set_refresh_window(Duration::ZERO);
for i in 0..(MAX_READY_ENTRIES * 10) {
let joined = cache.join_or_lead(
&key("hot"),
false,
|_| pending_build("img"),
);
assert_led(&joined, true);
cache.settle_success(&key("hot"), i as u64, ready_build("img"));
}
assert!(
cache.order_len() <= MAX_READY_ENTRIES * 2,
"order queue grew to {}",
cache.order_len()
);
}
#[tokio::test]
async fn invalidation_scopes_by_build_start_time() {
let cache = ImageReadyCache::new();
drop(cache.join_or_lead(
&key("a"),
false,
|_| pending_build("pre"),
));
let in_flight_key = (Duration::from_secs(90), "a".to_string());
let held_pre = cache.join_or_lead(
&in_flight_key,
false,
|_| pending_build("pre2"),
);
let cutoff = Instant::now();
cache.settle_success(&key("a"), 0, ready_build("settled-late"));
cache.invalidate_spec_started_before("a", cutoff);
match cache.join_or_lead(
&key("a"),
false,
|_| pending_build("post"),
) {
Joined::Pending { led, .. } => {
assert!(led, "a pre-cutoff build must be dropped however it settled");
}
Joined::Ready(_) => panic!("a pre-cutoff build must be dropped however it settled"),
}
let post = cache.join_or_lead(
&in_flight_key,
false,
|_| pending_build("post2"),
);
assert_led(&post, true);
cache.invalidate_spec_started_before("a", cutoff);
let rejoined = cache.join_or_lead(&in_flight_key, false, |_| {
panic!("post-cutoff entry must survive")
});
assert_led(&rejoined, false);
drop((held_pre, post, rejoined));
}
#[tokio::test]
async fn evicts_oldest_past_the_cap() {
let cache = ImageReadyCache::new();
drop(cache.join_or_lead(
&key("first"),
false,
|_| pending_build("img"),
));
cache.settle_success(&key("first"), 0, ready_build("img"));
for i in 0..MAX_READY_ENTRIES {
drop(cache.join_or_lead(
&key(&format!("filler-{i}")),
false,
|_| pending_build("img"),
));
}
let first_again = cache.join_or_lead(
&key("first"),
false,
|_| pending_build("img"),
);
assert_led(&first_again, true);
}
#[test]
fn canonical_spec_key_ignores_env_insertion_order() {
use crate::image::ImageSpec;
let mut forward = ImageSpec::default();
forward.env.insert("A_FIRST".to_string(), "1".to_string());
forward.env.insert("B_SECOND".to_string(), "2".to_string());
let mut reverse = ImageSpec::default();
reverse.env.insert("B_SECOND".to_string(), "2".to_string());
reverse.env.insert("A_FIRST".to_string(), "1".to_string());
assert_eq!(
crate::imagebuild::canonical_spec_key(&forward).unwrap(),
crate::imagebuild::canonical_spec_key(&reverse).unwrap()
);
}
}