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>>>>;
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<String, Entry>,
order: VecDeque<(String, 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: &str,
recovery: bool,
force: bool,
make: impl FnOnce(u64) -> SharedBuild,
) -> Joined {
let mut state = self.state.lock().unwrap();
if let Some(entry) = state.entries.get(key).filter(|_| !force) {
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.to_string(),
Entry {
id,
started_at: Instant::now(),
recovery,
state: EntryState::InFlight(weak),
},
);
state.order.push_back((key.to_string(), 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: &str, id: u64, build: ImageBuild, retain: bool) {
let mut state = self.state.lock().unwrap();
if state.entries.get(key).is_some_and(|entry| entry.id == id) {
if retain {
let entry = state
.entries
.get_mut(key)
.expect("the matching entry was just observed");
entry.state = EntryState::Ready {
build,
resolved_at: Instant::now(),
};
} else {
state.entries.remove(key);
}
}
}
pub(crate) fn invalidate_spec_started_before(&self, spec_hash: &str, cutoff: Instant) {
let mut state = self.state.lock().unwrap();
let Some(entry) = state.entries.get(spec_hash) else {
return;
};
let suspect = entry.started_at < cutoff
&& !(entry.recovery && matches!(entry.state, EntryState::InFlight(_)));
if suspect {
state.entries.remove(spec_hash);
}
}
pub(crate) fn settle_failure(&self, key: &str, 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(),
resolved_oci_ref: String::new(),
}
}
fn pending_build(id: &str) -> SharedBuild {
let build = ready_build(id);
async move { Ok(build) }.boxed().shared()
}
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(
"a",
false,
false,
|_| pending_build("img"),
);
assert_led(&first, true);
let second = cache.join_or_lead(
"a",
false,
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(
"a",
false,
false,
|_| pending_build("img"),
));
let next = cache.join_or_lead(
"a",
false,
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(
"a",
false,
false,
|_| pending_build("img"),
));
cache.settle_success(
"a",
0,
ready_build("img"),
true,
);
match cache.join_or_lead(
"a",
false,
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 nonretained_success_is_revalidated_by_the_next_caller() {
let cache = ImageReadyCache::new();
drop(cache.join_or_lead(
"a",
false,
false,
|_| pending_build("img"),
));
cache.settle_success(
"a",
0,
ready_build("img"),
false,
);
let next = cache.join_or_lead(
"a",
false,
false,
|_| pending_build("img2"),
);
assert_led(&next, true);
}
#[tokio::test]
async fn expired_success_is_replaced() {
let cache = ImageReadyCache::new();
cache.set_refresh_window(Duration::ZERO);
drop(cache.join_or_lead(
"a",
false,
false,
|_| pending_build("img"),
));
cache.settle_success(
"a",
0,
ready_build("img"),
true,
);
let next = cache.join_or_lead(
"a",
false,
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(
"a",
false,
false,
|_| pending_build("img"),
);
cache.settle_failure("a", 0);
let second = cache.join_or_lead(
"a",
false,
false,
|_| pending_build("img2"),
);
assert_led(&second, true);
cache.settle_failure("a", 0);
let third = cache.join_or_lead(
"a",
false,
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(
"a",
true,
false,
|_| pending_build("rec"),
);
cache.invalidate_spec_started_before("a", Instant::now());
let joined =
cache.join_or_lead("a", true, false, |_| {
panic!("in-flight recovery must survive")
});
assert_led(&joined, false);
cache.settle_success(
"a",
0,
ready_build("rec"),
true,
);
cache.invalidate_spec_started_before("a", Instant::now());
let next = cache.join_or_lead(
"a",
true,
false,
|_| 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(
"hot",
false,
false,
|_| pending_build("img"),
);
assert_led(&joined, true);
cache.settle_success(
"hot",
i as u64,
ready_build("img"),
true,
);
}
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(
"a",
false,
false,
|_| pending_build("pre"),
));
let cutoff = Instant::now();
cache.settle_success(
"a",
0,
ready_build("settled-late"),
true,
);
cache.invalidate_spec_started_before("a", cutoff);
let post = cache.join_or_lead(
"a",
false,
false,
|_| pending_build("post"),
);
assert_led(&post, true);
cache.invalidate_spec_started_before("a", cutoff);
let rejoined = cache.join_or_lead(
"a",
false,
false,
|_| panic!("post-cutoff entry must survive"),
);
assert_led(&rejoined, false);
drop((post, rejoined));
}
#[tokio::test]
async fn evicts_oldest_past_the_cap() {
let cache = ImageReadyCache::new();
drop(cache.join_or_lead(
"first",
false,
false,
|_| pending_build("img"),
));
cache.settle_success(
"first",
0,
ready_build("img"),
true,
);
for i in 0..MAX_READY_ENTRIES {
drop(cache.join_or_lead(
&format!("filler-{i}"),
false,
false,
|_| pending_build("img"),
));
}
let first_again = cache.join_or_lead(
"first",
false,
false,
|_| pending_build("img"),
);
assert_led(&first_again, true);
}
#[tokio::test]
async fn a_forced_build_replaces_a_success_the_window_would_still_serve() {
let cache = ImageReadyCache::new();
drop(cache.join_or_lead(
"a",
false,
false,
|_| pending_build("img"),
));
cache.settle_success(
"a",
0,
ready_build("img"),
true,
);
let forced = cache.join_or_lead(
"a",
false,
true,
|_| pending_build("img2"),
);
assert_led(&forced, true);
cache.settle_success(
"a",
1,
ready_build("img2"),
true,
);
match cache.join_or_lead(
"a",
false,
false,
|_| panic!("must serve the forced build's result"),
) {
Joined::Ready(build) => assert_eq!(build.image_id, "img2"),
Joined::Pending { .. } => panic!("expected the forced build's result"),
}
drop(forced);
}
#[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()
);
}
#[test]
fn canonical_spec_key_is_computed_from_sorted_json() {
use crate::image::ImageSpec;
let mut spec = ImageSpec::default();
for key in ["Z_LAST", "A_FIRST", "M_MIDDLE"] {
spec.env.insert(key.to_string(), "v".to_string());
}
let key = crate::imagebuild::canonical_spec_key(&spec).unwrap();
let value = serde_json::to_value(&spec).unwrap();
let sorted = serde_json::to_string(&sorted_for_test(&value)).unwrap();
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(sorted.as_bytes());
assert_eq!(key, format!("{:x}", hasher.finalize()));
}
fn sorted_for_test(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut out = serde_json::Map::new();
for key in keys {
out.insert(key.clone(), sorted_for_test(&map[key]));
}
serde_json::Value::Object(out)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(sorted_for_test).collect())
}
other => other.clone(),
}
}
}