1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12pub const BUILD_TIMELINE_SCHEMA: u32 = 5;
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)]
123 pub incomplete: bool,
124 #[serde(default)]
125 pub plan_id: String,
126 #[serde(default)]
127 pub plan_source: String,
128 #[serde(default)]
129 pub closure_reason: String,
130 #[serde(default)]
131 pub started_at_ms: u64,
132 #[serde(default)]
133 pub last_activity_ms: u64,
134 #[serde(default)]
135 pub candidate_keys: u64,
136 #[serde(default)]
137 pub downloaded_keys: u64,
138 #[serde(default)]
139 pub downloaded_bytes: u64,
140 #[serde(default)]
141 pub used_keys: u64,
142 #[serde(default)]
143 pub demanded_keys: u64,
144 #[serde(default)]
145 pub demanded_candidate_keys: u64,
146 #[serde(default)]
147 pub cancelled: bool,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
152pub struct KeyDemand {
153 pub cache_key: String,
154 pub first_demand_at_ms: u64,
155 #[serde(default)]
158 pub remote_wait_ms: u64,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
163pub struct TimelineUnit {
164 pub cache_key: String,
165 pub crate_name: String,
166 pub result: String,
170 pub started_at_ms: u64,
172 pub finished_at_ms: u64,
173 #[serde(default)]
174 pub compile_time_ms: u64,
175 #[serde(default)]
176 pub size: u64,
177 #[serde(default)]
178 pub key_ms: u64,
179 #[serde(default)]
180 pub lookup_ms: u64,
181 #[serde(default)]
182 pub restore_ms: u64,
183 #[serde(default)]
184 pub store_ms: u64,
185 #[serde(default)]
186 pub startup_ms: u64,
187 #[serde(default)]
188 pub flight_wait_ms: u64,
189 #[serde(default)]
190 pub permit_wait_ms: u64,
191 #[serde(default)]
192 pub compiler_runs: u32,
193 #[serde(default)]
195 pub event_schema: u32,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub demands: Vec<KeyDemand>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
203pub struct TimelineTransfer {
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub accounting: Option<PrefetchAccounting>,
206 pub cache_key: String,
207 #[serde(default)]
208 pub crate_name: String,
209 pub direction: TransferDirection,
210 pub ok: bool,
211 #[serde(default)]
212 pub compressed_bytes: u64,
213 #[serde(default)]
214 pub original_bytes: u64,
215 pub started_at_ms: u64,
216 pub finished_at_ms: u64,
217 #[serde(default)]
218 pub network_ms: u64,
219 #[serde(default)]
220 pub semaphore_wait_ms: u64,
221 #[serde(default)]
222 pub request_count: u32,
223 #[serde(default)]
224 pub import_ms: u64,
225 pub attribution: TransferAttribution,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub prefetch: Option<PrefetchOrigin>,
230 #[serde(default, skip_serializing_if = "String::is_empty")]
233 pub outcome: String,
234}
235
236#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
237#[serde(rename_all = "snake_case")]
238pub enum TransferDirection {
239 Upload,
240 #[default]
241 Download,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
247pub struct PrefetchAccounting {
248 pub operation: PrefetchOperation,
249 pub bytes_complete: bool,
252 pub requests_complete: bool,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub list_result_count: Option<u64>,
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub entries: Vec<PackedEntryTransfer>,
258}
259
260#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
261#[serde(rename_all = "snake_case")]
262pub enum PrefetchOperation {
263 #[default]
264 Get,
265 List,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
271pub struct PackedEntryTransfer {
272 pub cache_key: String,
273 pub crate_name: String,
274 pub compressed_bytes: u64,
275 pub finished_at_ms: u64,
276 pub outcome: String,
277 pub prefetch: PrefetchOrigin,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
283pub struct PrefetchOrigin {
284 #[serde(default)]
285 pub session_id: String,
286 #[serde(default)]
287 pub plan_id: String,
289 #[serde(default)]
291 pub source: String,
292 #[serde(default)]
297 pub candidate_rank: Option<u64>,
298 #[serde(default)]
299 pub candidate_source: crate::CandidateSource,
300}
301
302#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
304#[serde(rename_all = "snake_case")]
305pub enum TransferAttribution {
306 Session,
308 Key,
310 #[default]
312 Window,
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn sample() -> BuildTimeline {
320 BuildTimeline {
321 schema: BUILD_TIMELINE_SCHEMA,
322 client_record_id: "0123456789abcdef".into(),
323 session_id: "fedcba9876543210".into(),
324 kache_version: "0.23.1".into(),
325 started_at_ms: 1_000,
326 finished_at_ms: 5_000,
327 identity: TimelineIdentity {
328 lock_digest: Some("aaaabbbbccccdddd".into()),
329 identity_key: None,
330 source: IdentitySource::Absent,
331 },
332 root_hash: "1111222233334444".into(),
333 context: RunContext {
334 repository: Some("org/repo".into()),
335 labels: BTreeMap::from([("phase".into(), "cold".into())]),
336 ..RunContext::default()
337 },
338 log: LogLimits {
339 event_log_max_size: 10 << 20,
340 event_log_keep_lines: 1000,
341 },
342 summary: None,
343 units: vec![TimelineUnit {
344 cache_key: "k1".into(),
345 crate_name: "serde".into(),
346 result: "local_hit".into(),
347 demands: vec![KeyDemand {
348 cache_key: "k1".into(),
349 first_demand_at_ms: 1_050,
350 remote_wait_ms: 0,
351 }],
352 started_at_ms: 1_000,
353 finished_at_ms: 1_200,
354 ..TimelineUnit::default()
355 }],
356 transfers: vec![TimelineTransfer {
357 cache_key: "k1".into(),
358 direction: TransferDirection::Download,
359 ok: true,
360 started_at_ms: 900,
361 finished_at_ms: 990,
362 attribution: TransferAttribution::Key,
363 ..TimelineTransfer::default()
364 }],
365 }
366 }
367
368 #[test]
369 fn legacy_unit_has_no_demand_observation() {
370 let unit: TimelineUnit = serde_json::from_str(
371 r#"{"cache_key":"k","crate_name":"crate","result":"local_hit","started_at_ms":100,"finished_at_ms":200}"#,
372 ).unwrap();
373 assert!(unit.demands.is_empty());
374 }
375
376 #[test]
377 fn record_round_trips_through_json() {
378 let record = sample();
379 let json = serde_json::to_string(&record).unwrap();
380 assert_eq!(
381 serde_json::from_str::<BuildTimeline>(&json).unwrap(),
382 record
383 );
384 }
385
386 #[test]
387 fn empty_optional_context_is_not_serialized() {
388 let json = serde_json::to_value(sample()).unwrap();
389 let context = json["context"].as_object().unwrap();
390 assert_eq!(
391 context.keys().collect::<Vec<_>>(),
392 vec!["labels", "repository"]
393 );
394 assert!(json.get("summary").is_none());
395 }
396
397 #[test]
398 fn enums_use_snake_case_names() {
399 let json = serde_json::to_value(sample()).unwrap();
400 assert_eq!(json["identity"]["source"], "absent");
401 assert_eq!(json["transfers"][0]["direction"], "download");
402 assert_eq!(json["transfers"][0]["attribution"], "key");
403 assert_eq!(
404 serde_json::to_value(IdentitySource::LockEnv).unwrap(),
405 "lock_env"
406 );
407 }
408
409 #[test]
410 fn unknown_identity_source_reads_as_absent() {
411 let source: IdentitySource = serde_json::from_str("\"from_the_future\"").unwrap();
412 assert_eq!(source, IdentitySource::Absent);
413 }
414
415 #[test]
416 fn minimal_record_uses_defaults() {
417 let record: BuildTimeline = serde_json::from_str(
418 r#"{"schema":1,"client_record_id":"r","session_id":"s","started_at_ms":1,"finished_at_ms":2}"#,
419 )
420 .unwrap();
421 assert!(record.units.is_empty());
422 assert!(record.transfers.is_empty());
423 assert_eq!(record.identity.source, IdentitySource::Absent);
424 }
425}