1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12pub const BUILD_TIMELINE_SCHEMA: u32 = 1;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
19pub struct BuildTimeline {
20 pub schema: u32,
21 pub client_record_id: String,
24 pub session_id: String,
25 #[serde(default)]
26 pub kache_version: String,
27 pub started_at_ms: u64,
29 pub finished_at_ms: u64,
31 #[serde(default)]
32 pub identity: TimelineIdentity,
33 #[serde(default)]
36 pub root_hash: String,
37 #[serde(default)]
38 pub context: RunContext,
39 #[serde(default)]
40 pub log: LogLimits,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub summary: Option<TimelineSummary>,
45 #[serde(default)]
46 pub units: Vec<TimelineUnit>,
47 #[serde(default)]
48 pub transfers: Vec<TimelineTransfer>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
53pub struct TimelineIdentity {
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub lock_digest: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub identity_key: Option<String>,
61 #[serde(default)]
62 pub source: IdentitySource,
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
66#[serde(rename_all = "snake_case")]
67pub enum IdentitySource {
68 Explicit,
70 LockEnv,
72 #[default]
73 #[serde(other)]
74 Absent,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
80pub struct RunContext {
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub repository: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub workflow: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub job: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub run_id: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub run_attempt: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub event: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub git_ref: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub commit: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub runner_os: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub runner_arch: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub runner_pool: Option<String>,
103 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
104 pub labels: BTreeMap<String, String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
111pub struct LogLimits {
112 #[serde(default)]
113 pub event_log_max_size: u64,
114 #[serde(default)]
115 pub event_log_keep_lines: u64,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
120pub struct TimelineSummary {
121 #[serde(default)]
122 pub plan_id: String,
123 #[serde(default)]
124 pub plan_source: String,
125 #[serde(default)]
126 pub closure_reason: String,
127 #[serde(default)]
128 pub started_at_ms: u64,
129 #[serde(default)]
130 pub last_activity_ms: u64,
131 #[serde(default)]
132 pub candidate_keys: u64,
133 #[serde(default)]
134 pub downloaded_keys: u64,
135 #[serde(default)]
136 pub downloaded_bytes: u64,
137 #[serde(default)]
138 pub used_keys: u64,
139 #[serde(default)]
140 pub demanded_keys: u64,
141 #[serde(default)]
142 pub demanded_candidate_keys: u64,
143 #[serde(default)]
144 pub cancelled: bool,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
149pub struct TimelineUnit {
150 pub cache_key: String,
151 pub crate_name: String,
152 pub result: String,
156 pub started_at_ms: u64,
158 pub finished_at_ms: u64,
159 #[serde(default)]
160 pub compile_time_ms: u64,
161 #[serde(default)]
162 pub size: u64,
163 #[serde(default)]
164 pub key_ms: u64,
165 #[serde(default)]
166 pub lookup_ms: u64,
167 #[serde(default)]
168 pub restore_ms: u64,
169 #[serde(default)]
170 pub store_ms: u64,
171 #[serde(default)]
172 pub startup_ms: u64,
173 #[serde(default)]
174 pub flight_wait_ms: u64,
175 #[serde(default)]
176 pub permit_wait_ms: u64,
177 #[serde(default)]
178 pub compiler_runs: u32,
179 #[serde(default)]
181 pub event_schema: u32,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
186pub struct TimelineTransfer {
187 pub cache_key: String,
188 #[serde(default)]
189 pub crate_name: String,
190 pub direction: TransferDirection,
191 pub ok: bool,
192 #[serde(default)]
193 pub compressed_bytes: u64,
194 #[serde(default)]
195 pub original_bytes: u64,
196 pub started_at_ms: u64,
197 pub finished_at_ms: u64,
198 #[serde(default)]
199 pub network_ms: u64,
200 #[serde(default)]
201 pub semaphore_wait_ms: u64,
202 #[serde(default)]
203 pub request_count: u32,
204 #[serde(default)]
205 pub import_ms: u64,
206 pub attribution: TransferAttribution,
207}
208
209#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
210#[serde(rename_all = "snake_case")]
211pub enum TransferDirection {
212 Upload,
213 #[default]
214 Download,
215}
216
217#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
219#[serde(rename_all = "snake_case")]
220pub enum TransferAttribution {
221 Key,
223 #[default]
225 Window,
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn sample() -> BuildTimeline {
233 BuildTimeline {
234 schema: BUILD_TIMELINE_SCHEMA,
235 client_record_id: "0123456789abcdef".into(),
236 session_id: "fedcba9876543210".into(),
237 kache_version: "0.23.1".into(),
238 started_at_ms: 1_000,
239 finished_at_ms: 5_000,
240 identity: TimelineIdentity {
241 lock_digest: Some("aaaabbbbccccdddd".into()),
242 identity_key: None,
243 source: IdentitySource::Absent,
244 },
245 root_hash: "1111222233334444".into(),
246 context: RunContext {
247 repository: Some("org/repo".into()),
248 labels: BTreeMap::from([("phase".into(), "cold".into())]),
249 ..RunContext::default()
250 },
251 log: LogLimits {
252 event_log_max_size: 10 << 20,
253 event_log_keep_lines: 1000,
254 },
255 summary: None,
256 units: vec![TimelineUnit {
257 cache_key: "k1".into(),
258 crate_name: "serde".into(),
259 result: "local_hit".into(),
260 started_at_ms: 1_000,
261 finished_at_ms: 1_200,
262 ..TimelineUnit::default()
263 }],
264 transfers: vec![TimelineTransfer {
265 cache_key: "k1".into(),
266 direction: TransferDirection::Download,
267 ok: true,
268 started_at_ms: 900,
269 finished_at_ms: 990,
270 attribution: TransferAttribution::Key,
271 ..TimelineTransfer::default()
272 }],
273 }
274 }
275
276 #[test]
277 fn record_round_trips_through_json() {
278 let record = sample();
279 let json = serde_json::to_string(&record).unwrap();
280 assert_eq!(
281 serde_json::from_str::<BuildTimeline>(&json).unwrap(),
282 record
283 );
284 }
285
286 #[test]
287 fn empty_optional_context_is_not_serialized() {
288 let json = serde_json::to_value(sample()).unwrap();
289 let context = json["context"].as_object().unwrap();
290 assert_eq!(
291 context.keys().collect::<Vec<_>>(),
292 vec!["labels", "repository"]
293 );
294 assert!(json.get("summary").is_none());
295 }
296
297 #[test]
298 fn enums_use_snake_case_names() {
299 let json = serde_json::to_value(sample()).unwrap();
300 assert_eq!(json["identity"]["source"], "absent");
301 assert_eq!(json["transfers"][0]["direction"], "download");
302 assert_eq!(json["transfers"][0]["attribution"], "key");
303 assert_eq!(
304 serde_json::to_value(IdentitySource::LockEnv).unwrap(),
305 "lock_env"
306 );
307 }
308
309 #[test]
310 fn unknown_identity_source_reads_as_absent() {
311 let source: IdentitySource = serde_json::from_str("\"from_the_future\"").unwrap();
312 assert_eq!(source, IdentitySource::Absent);
313 }
314
315 #[test]
316 fn minimal_record_uses_defaults() {
317 let record: BuildTimeline = serde_json::from_str(
318 r#"{"schema":1,"client_record_id":"r","session_id":"s","started_at_ms":1,"finished_at_ms":2}"#,
319 )
320 .unwrap();
321 assert!(record.units.is_empty());
322 assert!(record.transfers.is_empty());
323 assert_eq!(record.identity.source, IdentitySource::Absent);
324 }
325}