use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use dial9_core::rate_limited;
use futures::stream::{Stream, StreamExt};
use tokio::task::JoinSet;
use crate::ingest::aggregate::{self, AggContext, FoldLimits, Scope};
use crate::storage::ObjectInfo;
const BASELINE_FILES: usize = 4;
const LIST_CONCURRENCY: usize = 24;
const CAP_FRACTION: f64 = 0.05;
const CAP_FILES_PER_CORE: usize = 2;
const CAP_MAX_FILES_MIN: usize = 8;
const CAP_MAX_FILES_MAX: usize = 100;
fn default_cap_max() -> usize {
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
(cores * CAP_FILES_PER_CORE).clamp(CAP_MAX_FILES_MIN, CAP_MAX_FILES_MAX)
}
const CAP_MAX_FILES_OVERRIDE: usize = 2000;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct RefineOpts {
pub max_files: Option<usize>,
}
pub(crate) struct Resolved {
matched: Vec<(String, String)>,
matched_leaf_hosts: HashMap<String, String>,
pub capped: Vec<(String, String)>,
folded: HashSet<String>,
pub files_matched: usize,
pub total_bytes: u64,
pub hosts_matched: usize,
}
impl Resolved {
fn is_folded(&self, full_key: &str) -> bool {
self.folded.contains(&aggregate::part_leaf_of(full_key))
}
pub fn folded(&self) -> &HashSet<String> {
&self.folded
}
pub fn folded_matching_full_keys(&self) -> Vec<String> {
self.matched
.iter()
.filter(|(_, full)| self.is_folded(full))
.map(|(_, full)| full.clone())
.collect()
}
pub fn matched_host_for_leaf(&self, leaf: &str) -> Option<&str> {
self.matched_leaf_hosts.get(leaf).map(String::as_str)
}
pub fn fold_work_cap(&self) -> usize {
self.capped.len()
}
pub fn files_folded_in(&self, folded: &HashSet<String>) -> usize {
self.matched
.iter()
.filter(|(_, full)| folded.contains(&aggregate::part_leaf_of(full)))
.count()
}
pub fn capped_files_folded_in(&self, folded: &HashSet<String>) -> usize {
self.capped
.iter()
.filter(|(_, full)| folded.contains(&aggregate::part_leaf_of(full)))
.count()
}
pub fn capped_folded_hosts(&self, folded: &HashSet<String>) -> usize {
self.capped
.iter()
.filter(|(_, full)| folded.contains(&aggregate::part_leaf_of(full)))
.map(|(_, full)| aggregate::host_of(full))
.collect::<HashSet<_>>()
.len()
}
pub fn unfolded_capped(&self) -> Vec<(String, String)> {
self.capped
.iter()
.filter(|(_, full)| !self.is_folded(full))
.cloned()
.collect()
}
#[cfg(test)]
pub(crate) fn for_test(capped: Vec<(String, String)>, folded: HashSet<String>) -> Self {
let files_matched = capped.len();
let matched_leaf_hosts = capped
.iter()
.map(|(_, full)| (aggregate::part_leaf_of(full), aggregate::host_of(full)))
.collect();
Self {
matched: capped.clone(),
matched_leaf_hosts,
capped,
folded,
files_matched,
total_bytes: 0,
hosts_matched: 0,
}
}
}
pub(crate) struct Folded {
pub raw_key: String,
pub full_key: String,
}
pub(crate) enum FoldOutcome {
Folded(Folded),
Failed {
raw_key: String,
error: String,
},
}
const FOLD_ERROR_MAX_LEN: usize = 300;
#[derive(Default)]
pub(crate) struct FoldErrors {
pub count: usize,
pub sample: Option<String>,
}
impl FoldErrors {
pub fn record(&mut self, raw_key: &str, error: &str) {
self.count += 1;
let key = raw_key.rsplit('/').next().unwrap_or(raw_key);
let msg = if key.is_empty() {
error.to_string()
} else {
format!("{key}: {error}")
};
self.sample = Some(if msg.chars().count() > FOLD_ERROR_MAX_LEN {
let truncated: String = msg.chars().take(FOLD_ERROR_MAX_LEN).collect();
format!("{truncated}…")
} else {
msg
});
}
}
pub(crate) async fn resolve(agg: &AggContext, scope: &Scope, opts: RefineOpts) -> Option<Resolved> {
let listing_prefixes = time_scoped_prefixes(&agg.source_prefixes, scope);
tracing::info!(
listing_prefix_count = listing_prefixes.len(),
sample_listing_prefixes = ?listing_prefixes.iter().take(5).collect::<Vec<_>>(),
"resolve: listing prefixes"
);
let per_prefix: Vec<Vec<ObjectInfo>> = futures::stream::iter(listing_prefixes)
.map(|prefix| async move {
match agg
.source
.list_objects_all(&agg.source_bucket, &prefix)
.await
{
Ok(objs) => {
tracing::info!(
%prefix,
listed = objs.len(),
sample_keys = ?objs.iter().take(3).map(|o| &o.key).collect::<Vec<_>>(),
"resolve: listed source prefix"
);
objs
}
Err(e) => {
tracing::warn!(%prefix, error = %e, "resolve: failed to list source prefix");
Vec::new()
}
}
})
.buffer_unordered(LIST_CONCURRENCY)
.collect()
.await;
let raw_objects: Vec<ObjectInfo> = per_prefix.into_iter().flatten().collect();
let total_listed = raw_objects.len();
let (ordered, total_bytes) = aggregate::ordered_full_keys_with_size(
raw_objects,
scope,
agg.segment_duration_secs,
agg.source_is_local,
&agg.source_bucket,
);
let files_matched = ordered.len();
let hosts_matched = ordered
.iter()
.map(|(_, full)| aggregate::host_of(full))
.collect::<HashSet<_>>()
.len();
tracing::info!(
total_listed,
files_matched,
hosts_matched,
sample_matched = ?ordered.iter().take(3).map(|(k, _)| k.as_str()).collect::<Vec<_>>(),
"resolve: scope filter result"
);
if files_matched == 0 {
return None;
}
let cap = sampling_cap(files_matched, opts.max_files);
let capped: Vec<(String, String)> = ordered.iter().take(cap).cloned().collect();
let folded = aggregate::list_folded_leaves(
&*agg.output,
&agg.output_bucket,
&agg.output_prefix,
&agg.source_bucket,
scope.service.as_deref(),
)
.await;
let matched_leaf_hosts = ordered
.iter()
.map(|(_, full)| (aggregate::part_leaf_of(full), aggregate::host_of(full)))
.collect();
Some(Resolved {
matched: ordered,
matched_leaf_hosts,
capped,
folded,
files_matched,
total_bytes,
hosts_matched,
})
}
pub(crate) fn fold_stream(
agg: Arc<AggContext>,
limits: FoldLimits,
to_fold: Vec<(String, String)>,
) -> impl Stream<Item = FoldOutcome> {
let mut tasks: JoinSet<FoldOutcome> = JoinSet::new();
for (raw_key, full_key) in to_fold {
let agg = Arc::clone(&agg);
let limits = limits.clone();
tasks.spawn(async move {
match aggregate::fold_one(&agg, &raw_key, &limits).await {
Ok(()) => FoldOutcome::Folded(Folded { raw_key, full_key }),
Err(e) => {
rate_limited!(std::time::Duration::from_secs(60), {
tracing::warn!(key = %raw_key, error = %e, "fold_stream: failed to fold source file");
});
FoldOutcome::Failed {
raw_key,
error: e.to_string(),
}
}
}
});
}
futures::stream::unfold(tasks, |mut tasks| async move {
match tasks.join_next().await {
Some(Ok(outcome)) => Some((outcome, tasks)),
Some(Err(e)) => {
rate_limited!(std::time::Duration::from_secs(60), {
tracing::warn!(error = %e, "fold_stream: fold task failed to join");
});
Some((
FoldOutcome::Failed {
raw_key: String::new(),
error: format!("fold task panicked: {e}"),
},
tasks,
))
}
None => None, }
})
}
fn sampling_cap(files_matched: usize, max_files_override: Option<usize>) -> usize {
let target = match max_files_override {
Some(explicit) => explicit.min(CAP_MAX_FILES_OVERRIDE),
None => {
let by_fraction = (files_matched as f64 * CAP_FRACTION).ceil() as usize;
by_fraction.min(default_cap_max())
}
};
target.max(BASELINE_FILES).min(files_matched)
}
fn time_scoped_prefixes(source_prefixes: &[String], scope: &Scope) -> Vec<String> {
let (Some(start_ns), Some(end_ns)) = (scope.start_ns, scope.end_ns) else {
return source_prefixes.to_vec();
};
let start_secs = start_ns / 1_000_000_000;
let end_secs = end_ns / 1_000_000_000;
let span_secs = end_secs - start_secs;
const MINUTE_THRESHOLD_SECS: i64 = 2 * 3600;
const MINUTE_PAD_SECS: i64 = 2 * 60;
const MAX_WINDOW_SECS: i64 = 72 * 3600;
if scope.service.is_some() || span_secs <= MINUTE_THRESHOLD_SECS {
let padded_start = (start_secs - MINUTE_PAD_SECS) / 60 * 60;
let padded_end =
((end_secs + MINUTE_PAD_SECS) / 60 * 60).min(padded_start + MAX_WINDOW_SECS);
let mut prefixes = Vec::new();
for base in source_prefixes {
let base_slash = if base.is_empty() {
String::new()
} else {
format!("{}/", base.trim_end_matches('/'))
};
let mut t = padded_start;
while t <= padded_end {
let (date, hhmm) = epoch_to_date_hour(t);
let minute_prefix = format!("{base_slash}{date}/{hhmm}");
prefixes.push(match scope.service.as_deref() {
Some(service) => format!("{minute_prefix}/{service}/"),
None => minute_prefix,
});
t += 60;
}
}
prefixes
} else {
let start_hour = (start_secs / 3600 - 1) * 3600;
let end_hour = (end_secs / 3600 + 1) * 3600;
let end_hour = end_hour.min(start_hour + MAX_WINDOW_SECS);
let mut prefixes = Vec::new();
for base in source_prefixes {
let base_slash = if base.is_empty() {
String::new()
} else {
format!("{}/", base.trim_end_matches('/'))
};
let mut t = start_hour;
while t <= end_hour {
let (date, hhmm) = epoch_to_date_hour(t);
let hh = &hhmm[..2];
prefixes.push(format!("{base_slash}{date}/{hh}"));
t += 3600;
}
}
prefixes
}
}
fn epoch_to_date_hour(epoch_secs: i64) -> (String, String) {
let secs = epoch_secs.rem_euclid(86400) as u32;
let days = (epoch_secs - secs as i64).div_euclid(86400) as i32;
let z = days + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i32 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
let hh = secs / 3600;
let mm = (secs % 3600) / 60;
(format!("{y:04}-{m:02}-{d:02}"), format!("{hh:02}{mm:02}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sampling_cap_floors_at_baseline_and_clamps_to_matched() {
assert_eq!(sampling_cap(20, None), 4, "max(ceil(0.05*20), 4) = 4");
assert_eq!(sampling_cap(100, None), 5, "ceil(0.05*100) = 5");
assert_eq!(sampling_cap(100_000, None), default_cap_max());
let cap_max = default_cap_max();
assert!(
(CAP_MAX_FILES_MIN..=CAP_MAX_FILES_MAX).contains(&cap_max),
"default cap ceiling stays within its clamp: {cap_max}"
);
assert_eq!(sampling_cap(3, None), 3, "clamped to matched set");
assert_eq!(sampling_cap(100, Some(40)), 40, "explicit override");
assert_eq!(
sampling_cap(100, Some(1)),
4,
"override still floored at baseline"
);
assert_eq!(
sampling_cap(10, Some(50)),
10,
"override clamped to matched"
);
assert_eq!(
sampling_cap(1_000_000, Some(999_999)),
CAP_MAX_FILES_OVERRIDE,
"override clamped to hard ceiling"
);
}
#[test]
fn folded_matching_keys_include_out_of_cap_cache_without_starving_work() {
let full_a = "s3://bucket/2026-01-01/0000/svc/host-a/a.bin".to_string();
let full_b = "s3://bucket/2026-01-01/0000/svc/host-b/b.bin".to_string();
let full_c = "s3://bucket/2026-01-01/0000/svc/host-c/c.bin".to_string();
let outside_cap = "s3://bucket/2026-01-01/0000/svc/host-d/d.bin".to_string();
let matched = vec![
("raw-c".to_string(), full_c.clone()),
("raw-a".to_string(), full_a.clone()),
("raw-b".to_string(), full_b.clone()),
("raw-d".to_string(), outside_cap.clone()),
];
let folded: HashSet<String> = [
aggregate::part_leaf_of(&full_a),
aggregate::part_leaf_of(&full_c),
aggregate::part_leaf_of(&outside_cap),
]
.into_iter()
.collect();
let matched_leaf_hosts = matched
.iter()
.map(|(_, full)| (aggregate::part_leaf_of(full), aggregate::host_of(full)))
.collect();
let resolved = Resolved {
matched,
matched_leaf_hosts,
capped: vec![
("raw-c".to_string(), full_c.clone()),
("raw-a".to_string(), full_a.clone()),
("raw-b".to_string(), full_b.clone()),
],
folded: folded.clone(),
files_matched: 4,
total_bytes: 0,
hosts_matched: 4,
};
assert_eq!(
resolved.folded_matching_full_keys(),
vec![full_c, full_a, outside_cap],
"all folded matching keys are seeded in stable order, even beyond the fold cap"
);
assert_eq!(resolved.files_folded_in(&folded), 3);
assert_eq!(
resolved.unfolded_capped(),
vec![("raw-b".to_string(), full_b)],
"out-of-cap cache does not consume or starve missing in-cap fold work"
);
}
#[test]
fn epoch_to_date_hour_utc() {
assert_eq!(
epoch_to_date_hour(1781874000),
("2026-06-19".to_string(), "1300".to_string())
);
assert_eq!(
epoch_to_date_hour(1767225600),
("2026-01-01".to_string(), "0000".to_string())
);
}
#[test]
fn time_scoped_prefixes_narrow_uses_minutes() {
let start_ns = 1781874000i64 * 1_000_000_000; let end_ns = 1781877600i64 * 1_000_000_000; let scope = Scope {
start_ns: Some(start_ns),
end_ns: Some(end_ns),
service: None,
hosts: vec![],
};
let prefixes = time_scoped_prefixes(&["traces".to_string()], &scope);
assert!(prefixes.contains(&"traces/2026-06-19/1258".to_string())); assert!(prefixes.contains(&"traces/2026-06-19/1300".to_string()));
assert!(prefixes.contains(&"traces/2026-06-19/1359".to_string()));
assert!(prefixes.contains(&"traces/2026-06-19/1402".to_string())); assert!(!prefixes.iter().any(|p| p == "traces/2026-06-19/13"));
}
#[test]
fn time_scoped_prefixes_wide_uses_hours() {
let start_ns = 1781874000i64 * 1_000_000_000; let end_ns = 1781884800i64 * 1_000_000_000; let scope = Scope {
start_ns: Some(start_ns),
end_ns: Some(end_ns),
service: None,
hosts: vec![],
};
let prefixes = time_scoped_prefixes(&["traces".to_string()], &scope);
assert!(prefixes.contains(&"traces/2026-06-19/12".to_string()));
assert!(prefixes.contains(&"traces/2026-06-19/13".to_string()));
assert!(prefixes.contains(&"traces/2026-06-19/16".to_string()));
assert!(prefixes.contains(&"traces/2026-06-19/17".to_string()));
}
#[test]
fn time_scoped_service_prefixes_include_service_for_narrow_window() {
let start_ns = 1781874000i64 * 1_000_000_000;
let end_ns = (1781874000i64 + 60) * 1_000_000_000;
let scope = Scope {
start_ns: Some(start_ns),
end_ns: Some(end_ns),
service: Some("shale".to_string()),
hosts: vec![],
};
let prefixes = time_scoped_prefixes(&["traces".to_string()], &scope);
assert!(
prefixes.iter().all(|prefix| prefix.ends_with("/shale/")),
"service-scoped LIST prefixes must exclude sibling services: {prefixes:?}"
);
assert!(prefixes.contains(&"traces/2026-06-19/1300/shale/".to_string()));
}
#[test]
fn time_scoped_service_prefixes_include_service_for_wide_window() {
let start_ns = 1781874000i64 * 1_000_000_000;
let end_ns = 1781884800i64 * 1_000_000_000;
let scope = Scope {
start_ns: Some(start_ns),
end_ns: Some(end_ns),
service: Some("shale".to_string()),
hosts: vec![],
};
let prefixes = time_scoped_prefixes(&["traces".to_string()], &scope);
assert!(
prefixes.iter().all(|prefix| prefix.ends_with("/shale/")),
"wide service scopes must not fall back to all-service hour prefixes: {prefixes:?}"
);
assert!(prefixes.contains(&"traces/2026-06-19/1300/shale/".to_string()));
assert!(prefixes.contains(&"traces/2026-06-19/1600/shale/".to_string()));
}
#[test]
fn time_scoped_prefixes_empty_base() {
let start_ns = 1781874000i64 * 1_000_000_000; let end_ns = 1781877600i64 * 1_000_000_000; let scope = Scope {
start_ns: Some(start_ns),
end_ns: Some(end_ns),
service: None,
hosts: vec![],
};
let prefixes = time_scoped_prefixes(&["".to_string()], &scope);
assert!(prefixes.contains(&"2026-06-19/1300".to_string()));
}
#[test]
fn time_scoped_prefix_matches_per_minute_dir() {
let start_ns = 1781874000i64 * 1_000_000_000; let end_ns = 1781877600i64 * 1_000_000_000; let scope = Scope {
start_ns: Some(start_ns),
end_ns: Some(end_ns),
service: None,
hosts: vec![],
};
let prefixes = time_scoped_prefixes(&["".to_string()], &scope);
let real_key = "2026-06-19/1340/shale/host-a/boot-1/1781876400-0.bin.gz";
assert!(
prefixes.iter().any(|p| real_key.starts_with(p.as_str())),
"no generated prefix is a prefix of {real_key}; prefixes = {prefixes:?}"
);
}
#[test]
fn time_scoped_single_segment_narrow() {
let start_ns = 1782219780i64 * 1_000_000_000; let end_ns = (1782219780i64 + 60) * 1_000_000_000;
let scope = Scope {
start_ns: Some(start_ns),
end_ns: Some(end_ns),
service: None,
hosts: vec![],
};
let prefixes = time_scoped_prefixes(&["".to_string()], &scope);
assert!(
prefixes.len() <= 7,
"expected ≤7 prefixes for 60s window, got {}",
prefixes.len()
);
let real_key = "2026-06-23/1303/shale/ip-10-2-123-116.us-west-2.compute.internal/kxgw-1/1782219780-18603.bin.gz";
assert!(
prefixes.iter().any(|p| real_key.starts_with(p.as_str())),
"no prefix matches {real_key}; prefixes = {prefixes:?}"
);
}
#[test]
fn time_scoped_no_time_range() {
let scope = Scope {
start_ns: None,
end_ns: None,
service: None,
hosts: vec![],
};
let prefixes = time_scoped_prefixes(&["traces".to_string()], &scope);
assert_eq!(prefixes, vec!["traces".to_string()]);
}
}