use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const BUILD_TIMELINE_SCHEMA: u32 = 5;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct BuildTimeline {
pub schema: u32,
pub client_record_id: String,
pub session_id: String,
#[serde(default)]
pub kache_version: String,
pub started_at_ms: u64,
pub finished_at_ms: u64,
#[serde(default)]
pub identity: TimelineIdentity,
#[serde(default)]
pub root_hash: String,
#[serde(default)]
pub context: RunContext,
#[serde(default)]
pub log: LogLimits,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<TimelineSummary>,
#[serde(default)]
pub units: Vec<TimelineUnit>,
#[serde(default)]
pub transfers: Vec<TimelineTransfer>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TimelineIdentity {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lock_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_key: Option<String>,
#[serde(default)]
pub source: IdentitySource,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum IdentitySource {
Explicit,
LockEnv,
#[default]
#[serde(other)]
Absent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct RunContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_attempt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runner_os: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runner_arch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runner_pool: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct LogLimits {
#[serde(default)]
pub event_log_max_size: u64,
#[serde(default)]
pub event_log_keep_lines: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TimelineSummary {
#[serde(default)]
pub incomplete: bool,
#[serde(default)]
pub plan_id: String,
#[serde(default)]
pub plan_source: String,
#[serde(default)]
pub closure_reason: String,
#[serde(default)]
pub started_at_ms: u64,
#[serde(default)]
pub last_activity_ms: u64,
#[serde(default)]
pub candidate_keys: u64,
#[serde(default)]
pub downloaded_keys: u64,
#[serde(default)]
pub downloaded_bytes: u64,
#[serde(default)]
pub used_keys: u64,
#[serde(default)]
pub demanded_keys: u64,
#[serde(default)]
pub demanded_candidate_keys: u64,
#[serde(default)]
pub cancelled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct KeyDemand {
pub cache_key: String,
pub first_demand_at_ms: u64,
#[serde(default)]
pub remote_wait_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TimelineUnit {
pub cache_key: String,
pub crate_name: String,
pub result: String,
pub started_at_ms: u64,
pub finished_at_ms: u64,
#[serde(default)]
pub compile_time_ms: u64,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub key_ms: u64,
#[serde(default)]
pub lookup_ms: u64,
#[serde(default)]
pub restore_ms: u64,
#[serde(default)]
pub store_ms: u64,
#[serde(default)]
pub startup_ms: u64,
#[serde(default)]
pub flight_wait_ms: u64,
#[serde(default)]
pub permit_wait_ms: u64,
#[serde(default)]
pub compiler_runs: u32,
#[serde(default)]
pub event_schema: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub demands: Vec<KeyDemand>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TimelineTransfer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accounting: Option<PrefetchAccounting>,
pub cache_key: String,
#[serde(default)]
pub crate_name: String,
pub direction: TransferDirection,
pub ok: bool,
#[serde(default)]
pub compressed_bytes: u64,
#[serde(default)]
pub original_bytes: u64,
pub started_at_ms: u64,
pub finished_at_ms: u64,
#[serde(default)]
pub network_ms: u64,
#[serde(default)]
pub semaphore_wait_ms: u64,
#[serde(default)]
pub request_count: u32,
#[serde(default)]
pub import_ms: u64,
pub attribution: TransferAttribution,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefetch: Option<PrefetchOrigin>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub outcome: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TransferDirection {
Upload,
#[default]
Download,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct PrefetchAccounting {
pub operation: PrefetchOperation,
pub bytes_complete: bool,
pub requests_complete: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub list_result_count: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<PackedEntryTransfer>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum PrefetchOperation {
#[default]
Get,
List,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct PackedEntryTransfer {
pub cache_key: String,
pub crate_name: String,
pub compressed_bytes: u64,
pub finished_at_ms: u64,
pub outcome: String,
pub prefetch: PrefetchOrigin,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct PrefetchOrigin {
#[serde(default)]
pub session_id: String,
#[serde(default)]
pub plan_id: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub candidate_rank: Option<u64>,
#[serde(default)]
pub candidate_source: crate::CandidateSource,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TransferAttribution {
Session,
Key,
#[default]
Window,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> BuildTimeline {
BuildTimeline {
schema: BUILD_TIMELINE_SCHEMA,
client_record_id: "0123456789abcdef".into(),
session_id: "fedcba9876543210".into(),
kache_version: "0.23.1".into(),
started_at_ms: 1_000,
finished_at_ms: 5_000,
identity: TimelineIdentity {
lock_digest: Some("aaaabbbbccccdddd".into()),
identity_key: None,
source: IdentitySource::Absent,
},
root_hash: "1111222233334444".into(),
context: RunContext {
repository: Some("org/repo".into()),
labels: BTreeMap::from([("phase".into(), "cold".into())]),
..RunContext::default()
},
log: LogLimits {
event_log_max_size: 10 << 20,
event_log_keep_lines: 1000,
},
summary: None,
units: vec![TimelineUnit {
cache_key: "k1".into(),
crate_name: "serde".into(),
result: "local_hit".into(),
demands: vec![KeyDemand {
cache_key: "k1".into(),
first_demand_at_ms: 1_050,
remote_wait_ms: 0,
}],
started_at_ms: 1_000,
finished_at_ms: 1_200,
..TimelineUnit::default()
}],
transfers: vec![TimelineTransfer {
cache_key: "k1".into(),
direction: TransferDirection::Download,
ok: true,
started_at_ms: 900,
finished_at_ms: 990,
attribution: TransferAttribution::Key,
..TimelineTransfer::default()
}],
}
}
#[test]
fn legacy_unit_has_no_demand_observation() {
let unit: TimelineUnit = serde_json::from_str(
r#"{"cache_key":"k","crate_name":"crate","result":"local_hit","started_at_ms":100,"finished_at_ms":200}"#,
).unwrap();
assert!(unit.demands.is_empty());
}
#[test]
fn record_round_trips_through_json() {
let record = sample();
let json = serde_json::to_string(&record).unwrap();
assert_eq!(
serde_json::from_str::<BuildTimeline>(&json).unwrap(),
record
);
}
#[test]
fn empty_optional_context_is_not_serialized() {
let json = serde_json::to_value(sample()).unwrap();
let context = json["context"].as_object().unwrap();
assert_eq!(
context.keys().collect::<Vec<_>>(),
vec!["labels", "repository"]
);
assert!(json.get("summary").is_none());
}
#[test]
fn enums_use_snake_case_names() {
let json = serde_json::to_value(sample()).unwrap();
assert_eq!(json["identity"]["source"], "absent");
assert_eq!(json["transfers"][0]["direction"], "download");
assert_eq!(json["transfers"][0]["attribution"], "key");
assert_eq!(
serde_json::to_value(IdentitySource::LockEnv).unwrap(),
"lock_env"
);
}
#[test]
fn unknown_identity_source_reads_as_absent() {
let source: IdentitySource = serde_json::from_str("\"from_the_future\"").unwrap();
assert_eq!(source, IdentitySource::Absent);
}
#[test]
fn minimal_record_uses_defaults() {
let record: BuildTimeline = serde_json::from_str(
r#"{"schema":1,"client_record_id":"r","session_id":"s","started_at_ms":1,"finished_at_ms":2}"#,
)
.unwrap();
assert!(record.units.is_empty());
assert!(record.transfers.is_empty());
assert_eq!(record.identity.source, IdentitySource::Absent);
}
}