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 = 1;
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    #[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/// One compiler invocation.
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
149pub struct TimelineUnit {
150    pub cache_key: String,
151    pub crate_name: String,
152    /// Wrapper outcome as logged: `local_hit`, `prefetch_hit`, `remote_hit`,
153    /// `dup`, `miss`, `error`, `passthrough`, `skipped`. A prefetched entry
154    /// consumed from the local store logs `local_hit`.
155    pub result: String,
156    /// When the build started waiting for this unit.
157    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    /// Schema of the wrapper event this unit came from.
180    #[serde(default)]
181    pub event_schema: u32,
182}
183
184/// One remote transfer attributed to the session.
185#[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/// How a transfer, which carries no session id, was tied to the session.
218#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
219#[serde(rename_all = "snake_case")]
220pub enum TransferAttribution {
221    /// Same cache key as one of the session's units.
222    Key,
223    /// No key match; its time overlaps this session and no other.
224    #[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}