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::{BuildMode, ImageBuild};
pub(crate) fn retains_ready(spec: &crate::image::ImageSpec) -> bool {
if let Some(dockerfile) = &spec.dockerfile {
return !dockerfile.pinned_from.is_empty();
}
match &spec.oci {
Some(oci) => oci.reference.contains("@sha256:"),
None => true,
}
}
pub(crate) const IMAGE_READY_REFRESH: Duration = Duration::from_mins(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>>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BuildOrigin {
DirectRequest,
StaleCreateRecovery,
}
struct Entry {
id: u64,
started_at: Instant,
origin: BuildOrigin,
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,
origin: BuildOrigin,
mode: BuildMode,
make: impl FnOnce(u64) -> SharedBuild,
) -> Joined {
let mut state = self.state.lock().unwrap();
if let Some(entry) = state
.entries
.get(key)
.filter(|_| mode != BuildMode::ForceBuild)
{
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(),
origin,
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.origin == BuildOrigin::StaleCreateRecovery
&& 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(),
retryable: false,
resolved_oci_ref: String::new(),
dockerfile_pins: None,
}
}
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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img"),
);
assert_led(&first, true);
let second = cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img"),
));
let next = cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img"),
));
cache.settle_success(
"a",
0,
ready_build("img"),
true,
);
match cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img"),
));
cache.settle_success(
"a",
0,
ready_build("img"),
false,
);
let next = cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img"),
));
cache.settle_success(
"a",
0,
ready_build("img"),
true,
);
let next = cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img"),
);
cache.settle_failure("a", 0);
let second = cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img2"),
);
assert_led(&second, true);
cache.settle_failure("a", 0);
let third = cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::StaleCreateRecovery,
BuildMode::ReuseExisting,
|_| pending_build("rec"),
);
cache.invalidate_spec_started_before("a", Instant::now());
let joined = cache.join_or_lead(
"a",
BuildOrigin::StaleCreateRecovery,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::StaleCreateRecovery,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("post"),
);
assert_led(&post, true);
cache.invalidate_spec_started_before("a", cutoff);
let rejoined = cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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}"),
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img"),
));
}
let first_again = cache.join_or_lead(
"first",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| pending_build("img"),
));
cache.settle_success(
"a",
0,
ready_build("img"),
true,
);
let forced = cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ForceBuild,
|_| pending_build("img2"),
);
assert_led(&forced, true);
cache.settle_success(
"a",
1,
ready_build("img2"),
true,
);
match cache.join_or_lead(
"a",
BuildOrigin::DirectRequest,
BuildMode::ReuseExisting,
|_| 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()));
}
#[test]
fn retains_ready_declines_tag_oci_and_unpinned_dockerfile_specs() {
use crate::image::{
BaseImage, DockerfileFromResolution, DockerfileImage, ImageSpec, OciImage,
};
let base = ImageSpec {
base: Some(BaseImage::Debian),
..Default::default()
};
assert!(retains_ready(&base));
let digest = ImageSpec {
oci: Some(OciImage {
reference: format!("docker.io/library/ubuntu@sha256:{}", "a".repeat(64)),
}),
..Default::default()
};
assert!(retains_ready(&digest));
let tag = ImageSpec {
oci: Some(OciImage {
reference: "docker.io/library/ubuntu:24.04".to_string(),
}),
..Default::default()
};
assert!(!retains_ready(&tag));
let dockerfile = ImageSpec {
dockerfile: Some(DockerfileImage {
dockerfile: format!("FROM docker.io/library/ubuntu@sha256:{}", "a".repeat(64)),
..Default::default()
}),
..Default::default()
};
assert!(!retains_ready(&dockerfile));
let pinned = ImageSpec {
dockerfile: Some(DockerfileImage {
dockerfile: "FROM python:3.12".to_string(),
pinned_from: vec![DockerfileFromResolution {
reference: "docker.io/library/python:3.12".to_string(),
digest_ref: format!("docker.io/library/python@sha256:{}", "a".repeat(64)),
}],
..Default::default()
}),
..Default::default()
};
assert!(retains_ready(&pinned));
}
#[test]
fn canonical_spec_key_tracks_every_dockerfile_input() {
use crate::image::{AddLocalDirFile, DockerfileImage, ImageSpec};
let spec = |dockerfile: DockerfileImage| ImageSpec {
dockerfile: Some(dockerfile),
..Default::default()
};
let base = DockerfileImage {
dockerfile: "FROM python:3.12\nRUN true".to_string(),
context_files: vec![AddLocalDirFile {
relative_path: "main.py".to_string(),
content_sha256: "a".repeat(64),
mode: 0o644,
}],
build_args: [("VERSION".to_string(), "1".to_string())].into(),
..Default::default()
};
let key = crate::imagebuild::canonical_spec_key(&spec(base.clone())).unwrap();
assert_ne!(
key,
crate::imagebuild::canonical_spec_key(&ImageSpec::default()).unwrap()
);
let mut text = base.clone();
text.dockerfile.push_str("\nRUN false");
assert_ne!(
key,
crate::imagebuild::canonical_spec_key(&spec(text)).unwrap()
);
let mut content = base.clone();
content.context_files[0].content_sha256 = "b".repeat(64);
assert_ne!(
key,
crate::imagebuild::canonical_spec_key(&spec(content)).unwrap()
);
let mut arg = base.clone();
arg.build_args
.insert("VERSION".to_string(), "2".to_string());
assert_ne!(
key,
crate::imagebuild::canonical_spec_key(&spec(arg)).unwrap()
);
let mut dir = base.clone();
dir.context_dirs.push(crate::image::DockerfileContextDir {
relative_path: "empty".to_string(),
mode: 0o755,
});
assert_ne!(
key,
crate::imagebuild::canonical_spec_key(&spec(dir)).unwrap()
);
let mut link = base.clone();
link.context_symlinks
.push(crate::image::DockerfileContextSymlink {
relative_path: "link.py".to_string(),
target: "main.py".to_string(),
});
assert_ne!(
key,
crate::imagebuild::canonical_spec_key(&spec(link)).unwrap()
);
}
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(),
}
}
}