Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-scheduler/src/admin.rs
Line
Count
Source
1
// Copyright 2026 Trace Machina, Inc. All rights reserved.
2
//
3
// Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    See LICENSE file for details
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
//! Read-only views for the admin API: what the scheduler has queued and what
16
//! it has connected. A provisioner sizes pods from the first and drains
17
//! through the second, instead of reading the scheduler's store behind its
18
//! back.
19
20
use std::collections::HashMap;
21
use std::time::{SystemTime, UNIX_EPOCH};
22
23
use futures::StreamExt;
24
use nativelink_error::{Error, ResultExt, make_input_err};
25
use nativelink_util::action_messages::ActionStage;
26
use nativelink_util::metrics::record_awaited_action_orphan;
27
use nativelink_util::operation_state_manager::{
28
    ClientStateManager, OperationFilter, OperationStageFlags, OrderDirection,
29
};
30
use serde::{Deserialize, Serialize};
31
use tracing::warn;
32
33
use crate::resource_sketch::{Family, ResourceSketch, SAMPLER_GENERATION, SketchKey};
34
use crate::simple_scheduler_state_manager::is_lost_record;
35
use crate::worker_scheduler::WorkerSummary;
36
37
/// A queued action as the admin API lists it: enough to size a pod for it
38
/// and to tell how long it has waited.
39
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40
pub struct QueuedDemand {
41
    pub operation_id: String,
42
    /// Milliseconds since the epoch when the action was queued.
43
    pub queued_since_ms: u64,
44
    pub priority: i32,
45
    /// The action's properties as the scheduler matches them, reservations
46
    /// included.
47
    pub platform_properties: HashMap<String, String>,
48
    /// Milliseconds since the epoch when a client last checked in on the
49
    /// operation. A queue entry no client has touched for longer than the
50
    /// client timeout is one the scheduler will not dispatch.
51
    pub client_last_seen_ms: Option<u64>,
52
}
53
54
/// Every queued action of the scheduler, highest priority first and then
55
/// oldest, as the store yields them.
56
6
pub async fn queued_demand(scheduler: &dyn ClientStateManager) -> Result<Vec<QueuedDemand>, Error> {
57
6
    let mut stream = scheduler
58
6
        .filter_operations(OperationFilter {
59
6
            stages: OperationStageFlags::Queued,
60
6
            // The Redis backend serves the queue in one direction only, the
61
6
            // matcher's: highest priority first, then oldest.
62
6
            order_by_priority_direction: Some(OrderDirection::Desc),
63
6
            ..Default::default()
64
6
        })
65
6
        .await
66
6
        .err_tip(|| "Listing queued operations for the admin API")
?0
;
67
6
    let mut out = Vec::new();
68
16
    while let Some(
result11
) = stream.next().await {
69
        // On the Redis backend the reads below return the record as the
70
        // listing read it; on the memory one they are live, and the record
71
        // can have moved on or gone in between. One that is gone or cannot
72
        // be decoded is left out and counted, one no longer queued is no
73
        // longer demand, and a store failure is the listing's error.
74
11
        let Some((
state8
, _)) = still_listed(result.as_state().await)
?1
else {
75
2
            continue;
76
        };
77
8
        if !
matches!1
(state.stage, ActionStage::Queued) {
78
1
            continue;
79
7
        }
80
7
        let Some((action_info, _)) = still_listed(result.as_action_info().await)
?0
else {
81
0
            continue;
82
        };
83
7
        let Some(client_last_seen) = still_listed(result.client_last_seen().await)
?0
else {
84
0
            continue;
85
        };
86
7
        let client_last_seen_ms = client_last_seen.map(epoch_ms);
87
7
        out.push(QueuedDemand {
88
7
            operation_id: state.client_operation_id.to_string(),
89
7
            queued_since_ms: epoch_ms(action_info.insert_timestamp),
90
7
            priority: action_info.priority,
91
7
            platform_properties: action_info.platform_properties.clone(),
92
7
            client_last_seen_ms,
93
7
        });
94
    }
95
5
    Ok(out)
96
6
}
97
98
/// A read of a listed record: the value, `None` for a record that is gone
99
/// or cannot be decoded (skipped and counted), or the store's error.
100
25
fn still_listed<T>(read: Result<T, Error>) -> Result<Option<T>, Error> {
101
3
    match read {
102
22
        Ok(value) => Ok(Some(value)),
103
3
        Err(
err2
) if is_lost_record(&err
)2
=> {
104
2
            warn!(
105
                ?err,
106
                "Queued operation listed but its record cannot be read; left out of the demand"
107
            );
108
2
            record_awaited_action_orphan("admin");
109
2
            Ok(None)
110
        }
111
1
        Err(err) => Err(err).err_tip(|| "Reading a queued operation for the admin API"),
112
    }
113
25
}
114
115
11
fn epoch_ms(at: SystemTime) -> u64 {
116
11
    u64::try_from(
117
11
        at.duration_since(UNIX_EPOCH)
118
11
            .unwrap_or_default()
119
11
            .as_millis(),
120
    )
121
11
    .unwrap_or(u64::MAX)
122
11
}
123
124
/// The demand listing as the JSON body the admin API serves.
125
1
pub async fn queued_demand_json(scheduler: &dyn ClientStateManager) -> Result<String, Error> {
126
1
    let demand = queued_demand(scheduler).await
?0
;
127
1
    serde_json::to_string(&demand).map_err(|e| 
{0
128
0
        Error::new(
129
0
            nativelink_error::Code::Internal,
130
0
            format!("Serializing the demand listing: {e}"),
131
        )
132
0
    })
133
1
}
134
135
/// The worker listing as the JSON body the admin API serves.
136
1
pub fn workers_json(workers: &[WorkerSummary]) -> Result<String, Error> {
137
1
    serde_json::to_string(workers).map_err(|e| 
{0
138
0
        Error::new(
139
0
            nativelink_error::Code::Internal,
140
0
            format!("Serializing the worker listing: {e}"),
141
        )
142
0
    })
143
1
}
144
145
/// Counts per sketch family.
146
#[derive(Clone, Copy, Debug, Serialize)]
147
pub struct FamilyCounts {
148
    pub target: u64,
149
    pub mnemonic: u64,
150
    pub command: u64,
151
}
152
153
impl FamilyCounts {
154
0
    fn from_slice<T: Copy + Into<u64>>(values: [T; 3]) -> Self {
155
0
        Self {
156
0
            target: values[0].into(),
157
0
            mnemonic: values[1].into(),
158
0
            command: values[2].into(),
159
0
        }
160
0
    }
161
}
162
163
/// One hint of the listing, in the hints-file shape plus what the sketch
164
/// knows about it.
165
#[derive(Debug, Serialize)]
166
pub struct HintListing {
167
    #[serde(skip_serializing_if = "Option::is_none")]
168
    pub target_id: Option<String>,
169
    #[serde(skip_serializing_if = "Option::is_none")]
170
    pub action_mnemonic: Option<String>,
171
    #[serde(skip_serializing_if = "Option::is_none")]
172
    pub command_digest: Option<String>,
173
    #[serde(skip_serializing_if = "Option::is_none")]
174
    pub class: Option<String>,
175
    #[serde(skip_serializing_if = "is_zero")]
176
    pub cpu_count: u64,
177
    #[serde(skip_serializing_if = "is_zero")]
178
    pub memory_kb: u64,
179
    #[serde(skip_serializing_if = "is_zero")]
180
    pub disk_kb: u64,
181
    pub samples: u32,
182
    pub last_seen_s: u64,
183
    /// `measured`, or `seed` for a hint from the file no attempt has
184
    /// replaced yet.
185
    pub source: &'static str,
186
}
187
188
#[expect(
189
    clippy::trivially_copy_pass_by_ref,
190
    reason = "serde's skip_serializing_if takes a reference"
191
)]
192
0
const fn is_zero(value: &u64) -> bool {
193
0
    *value == 0
194
0
}
195
196
/// The hints listing: a header with the sketch's size and the shaped hints,
197
/// most recently used first.
198
#[derive(Debug, Serialize)]
199
pub struct HintsListing {
200
    pub sampler_generation: u32,
201
    pub window_days: u64,
202
    pub headroom_percent: u64,
203
    pub entries: FamilyCounts,
204
    pub capacity: FamilyCounts,
205
    pub evictions: FamilyCounts,
206
    pub samples: FamilyCounts,
207
    pub probation: u64,
208
    pub hints: Vec<HintListing>,
209
}
210
211
/// The hints listing as the JSON body the admin API serves. Its `hints`
212
/// are a valid `hints_file`, so a new fleet can be seeded from an old one.
213
/// `family` narrows to `target`, `mnemonic` or `command`; `limit` caps the
214
/// rows, 100,000 by default.
215
0
pub fn hints_json(
216
0
    sketch: &ResourceSketch,
217
0
    family: Option<&str>,
218
0
    limit: Option<usize>,
219
0
) -> Result<String, Error> {
220
0
    let family = match family {
221
0
        None | Some("") => None,
222
0
        Some("target") => Some(Family::Target),
223
0
        Some("mnemonic") => Some(Family::Mnemonic),
224
0
        Some("command") => Some(Family::Command),
225
0
        Some(other) => {
226
0
            return Err(make_input_err!(
227
0
                "family must be target, mnemonic or command, not {other:?}"
228
0
            ));
229
        }
230
    };
231
0
    let stats = sketch.stats();
232
0
    let hints = sketch
233
0
        .hints(family, limit.unwrap_or(100_000))
234
0
        .into_iter()
235
0
        .map(|row| {
236
0
            let (target_id, action_mnemonic, command_digest) = match row.key {
237
0
                SketchKey::Target { target, mnemonic } => (
238
0
                    Some(target.to_string()),
239
0
                    mnemonic.map(|m| m.to_string()),
240
0
                    None,
241
                ),
242
0
                SketchKey::Mnemonic { mnemonic } => (None, Some(mnemonic.to_string()), None),
243
0
                SketchKey::Command { digest } => (None, None, Some(digest.to_string())),
244
            };
245
            HintListing {
246
0
                target_id,
247
0
                action_mnemonic,
248
0
                command_digest,
249
0
                class: row.shaped.class,
250
0
                cpu_count: row.shaped.cpu_count,
251
0
                memory_kb: row.shaped.memory_kb,
252
0
                disk_kb: row.shaped.disk_kb,
253
0
                samples: row.samples,
254
0
                last_seen_s: row.last_seen_s,
255
0
                source: if row.seeded { "seed" } else { "measured" },
256
            }
257
0
        })
258
0
        .collect();
259
0
    let listing = HintsListing {
260
        sampler_generation: SAMPLER_GENERATION,
261
0
        window_days: sketch.window().as_secs() / 86_400,
262
0
        headroom_percent: sketch.policy().headroom_percent,
263
0
        entries: FamilyCounts::from_slice(
264
0
            stats.entries.map(|n| u64::try_from(n).unwrap_or(u64::MAX)),
265
        ),
266
0
        capacity: FamilyCounts::from_slice(
267
0
            stats.capacity.map(|n| u64::try_from(n).unwrap_or(u64::MAX)),
268
        ),
269
0
        evictions: FamilyCounts::from_slice(stats.evictions),
270
0
        samples: FamilyCounts::from_slice(stats.samples),
271
0
        probation: u64::try_from(stats.probation).unwrap_or(u64::MAX),
272
0
        hints,
273
    };
274
0
    serde_json::to_string(&listing).map_err(|e| {
275
0
        Error::new(
276
0
            nativelink_error::Code::Internal,
277
0
            format!("Serializing the hints listing: {e}"),
278
        )
279
0
    })
280
0
}