Skip to main content

kache_core/
timeline.rs

1//! Build timeline records: what one build session did, in order, with timings.
2//!
3//! The client assembles a record from the logs it already writes and sends it
4//! to kache-service, which stores it as submitted. Every time is Unix epoch
5//! milliseconds. Records carry no filesystem paths and no environment values
6//! outside the allowlisted run context.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12/// Version of [`BuildTimeline`]. A server rejects a record whose schema it
13/// does not know.
14pub const BUILD_TIMELINE_SCHEMA: u32 = 5;
15
16/// One build session: its compiler invocations and the remote transfers that
17/// belong to it.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
19pub struct BuildTimeline {
20    pub schema: u32,
21    /// Stable across re-submissions of the same session from the same run, so
22    /// a later, more complete submission replaces the earlier one.
23    pub client_record_id: String,
24    pub session_id: String,
25    #[serde(default)]
26    pub kache_version: String,
27    /// Earliest unit start.
28    pub started_at_ms: u64,
29    /// Latest unit finish.
30    pub finished_at_ms: u64,
31    #[serde(default)]
32    pub identity: TimelineIdentity,
33    /// Hash of the build root, so sessions from one tree can be grouped without
34    /// sending its path.
35    #[serde(default)]
36    pub root_hash: String,
37    #[serde(default)]
38    pub context: RunContext,
39    #[serde(default)]
40    pub log: LogLimits,
41    /// Prefetch plan summary for the session, when the daemon had already
42    /// closed it when the record was assembled.
43    #[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/// What build this was, as far as the client could tell.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
53pub struct TimelineIdentity {
54    /// Truncated content hash of the root's `Cargo.lock`.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub lock_digest: Option<String>,
57    /// Prefetch identity key (`id/{lock}/{target}/{profile}`), only when the
58    /// profile was known or the key was set explicitly.
59    #[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    /// Set explicitly by the build environment.
69    Explicit,
70    /// Derived from the lockfile and a profile named in the environment.
71    LockEnv,
72    #[default]
73    #[serde(other)]
74    Absent,
75}
76
77/// Where the build ran. Every field is optional; only allowlisted CI variables
78/// and explicit labels are sent.
79#[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/// Log rotation limits in force on the client. Once the event log passes
108/// `event_log_max_size` only the last `event_log_keep_lines` lines survive, so
109/// a record from a large build may be missing its first units.
110#[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/// The daemon's per-session prefetch plan summary.
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
120pub struct TimelineSummary {
121    /// Some prefetch outcomes were unavailable when the session closed.
122    #[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/// One key requested by a wrapper, including unsuccessful predictions.
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
152pub struct KeyDemand {
153    pub cache_key: String,
154    pub first_demand_at_ms: u64,
155    /// Wall time blocked on remote-check IPC, including daemon admission and
156    /// failed requests. It is not an estimate of time saved by prefetch.
157    #[serde(default)]
158    pub remote_wait_ms: u64,
159}
160
161/// One compiler invocation.
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
163pub struct TimelineUnit {
164    pub cache_key: String,
165    pub crate_name: String,
166    /// Wrapper outcome as logged: `local_hit`, `prefetch_hit`, `remote_hit`,
167    /// `dup`, `miss`, `error`, `passthrough`, `skipped`. A prefetched entry
168    /// consumed from the local store logs `local_hit`.
169    pub result: String,
170    /// When the build started waiting for this unit.
171    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    /// Schema of the wrapper event this unit came from.
194    #[serde(default)]
195    pub event_schema: u32,
196    /// Empty for wrapper events before schema 20, or invocations with no lookup.
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub demands: Vec<KeyDemand>,
199}
200
201/// One remote transfer attributed to the session.
202#[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    /// Immutable origin captured when this candidate was scheduled. Absent
227    /// on demand downloads and logs older than transfer schema 4.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub prefetch: Option<PrefetchOrigin>,
230    /// Operation/import outcome, including neutral `not_found`, `cancelled`,
231    /// and `skipped`; empty for demand downloads, uploads, and old logs.
232    #[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/// Accounting for one physical backend operation. Nested entries describe
245/// payload attribution; their bytes must not be added to the physical total.
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
247pub struct PrefetchAccounting {
248    pub operation: PrefetchOperation,
249    /// True when compressed_bytes covers the complete received body. False
250    /// means partial transport bytes are unavailable (including LIST bodies).
251    pub bytes_complete: bool,
252    /// Counts calls to the backend, not SDK retries or LIST pages.
253    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/// One compressed entry frame within a physical pack body. A successful local
269/// import makes the entry available at finished_at_ms; zero means no import.
270#[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/// The plan that scheduled one speculative download. Kept with the task,
281/// so a later build cannot claim a download that was already in flight.
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
283pub struct PrefetchOrigin {
284    #[serde(default)]
285    pub session_id: String,
286    #[serde(default)]
287    /// Planner-issued ID when available; empty for fallback and unscoped work.
288    pub plan_id: String,
289    /// `advisory`, `fallback`, or `unscoped` (startup or direct requests).
290    #[serde(default)]
291    pub source: String,
292    /// Zero-based order in the request list, before local-hit, in-flight,
293    /// and budget filtering. Planner requests validate their list first;
294    /// direct IPC requests can include rejected keys. None for keys added
295    /// by whole-remote warming.
296    #[serde(default)]
297    pub candidate_rank: Option<u64>,
298    #[serde(default)]
299    pub candidate_source: crate::CandidateSource,
300}
301
302/// How the client tied a transfer to this session.
303#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
304#[serde(rename_all = "snake_case")]
305pub enum TransferAttribution {
306    /// The download task carries this session's immutable plan origin.
307    Session,
308    /// Same cache key as one of the session's units.
309    Key,
310    /// No key match; its time overlaps this session and no other.
311    #[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}