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/historical_resource_scheduler.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
//! Sizes actions from a periodically refreshed hints file by default.
16
//! With learning enabled, sampled worker usage lands in a [`ResourceSketch`]; an action
17
//! is reserved what the sketch has learned for its key, shaped with the
18
//! headroom and the ladder configured here, or by the cold-start policy
19
//! when the sketch has nothing. The sketch is written to a store so a
20
//! restart keeps it; a hints file seeds an empty sketch once.
21
22
use core::time::Duration;
23
use std::collections::{BTreeSet, HashMap, HashSet};
24
use std::fs;
25
use std::sync::Arc;
26
use std::time::{SystemTime, UNIX_EPOCH};
27
28
use async_trait::async_trait;
29
use bytes::Bytes;
30
use nativelink_config::schedulers::{HistoricalResourceSpec, SizeClassSpec, TimeoutClassSpec};
31
use nativelink_error::{Code, Error, ResultExt, make_input_err};
32
use nativelink_metric::{
33
    MetricFieldData, MetricKind, MetricPublishKnownKindData, MetricsComponent, RootMetricsComponent,
34
};
35
use nativelink_util::action_messages::{ActionInfo, OperationId};
36
use nativelink_util::metrics::{
37
    record_hint_resolution, record_hints_family, record_hints_snapshot,
38
    record_hints_snapshot_failure,
39
};
40
use nativelink_util::operation_state_manager::{
41
    ActionStateResult, ActionStateResultStream, ClientStateManager, OperationFilter,
42
};
43
use nativelink_util::origin_event::{BAZEL_METADATA_KEY, request_metadata_from_baggage};
44
use nativelink_util::store_trait::{Store, StoreKey, StoreLike};
45
use nativelink_util::{background_spawn, spawn_blocking};
46
use opentelemetry::baggage::BaggageExt;
47
use opentelemetry::context::Context;
48
use parking_lot::Mutex;
49
use serde::Deserialize;
50
use tracing::{debug, info, warn};
51
use uuid::Uuid;
52
53
use crate::known_platform_property_provider::KnownPlatformPropertyProvider;
54
use crate::resource_sketch::{
55
    Budget, Entry, Family, Policy, ResourceSketch, Shaped, SketchKey, Snapshot, Stats,
56
};
57
58
/// The prefix the sketch snapshots are written under in `snapshot_store`,
59
/// one key per scheduler replica; see [`snapshot_key`].
60
pub const SNAPSHOT_KEY_PREFIX: &str = "historical_resource_sketch/";
61
const SNAPSHOT_KEY_PREFIX_END: &str = "historical_resource_sketch0";
62
const MAX_SNAPSHOT_CANDIDATES: usize = 128;
63
const MAX_REPLICA_ID_BYTES: usize = 256;
64
65
/// The key the store is probed with at start; outside the prefix, so a
66
/// listing never reads it as a snapshot.
67
const SNAPSHOT_PROBE_KEY: &str = "historical_resource_sketch.probe";
68
69
/// The snapshot key of one replica.
70
#[must_use]
71
164
pub fn snapshot_key(replica: &str) -> String {
72
164
    format!("{SNAPSHOT_KEY_PREFIX}{replica}")
73
164
}
74
75
/// The replica this process writes its snapshot as: the pod's hostname,
76
/// else a fresh id. Every replica sees the usage of the workers connected
77
/// to it and no other. A configured identity takes precedence over the hostname.
78
#[must_use]
79
6
pub fn replica_id(configured: &str) -> String {
80
6
    if !configured.is_empty() {
81
2
        return configured.to_string();
82
4
    }
83
4
    std::env::var("HOSTNAME")
84
4
        .ok()
85
4
        .filter(|name| !
name0
.
is_empty0
())
86
4
        .unwrap_or_else(|| Uuid::new_v4().to_string())
87
6
}
88
89
/// One hint of the seed file. The v1 fields keep their meaning; the v2
90
/// fields are optional, so a v1 file still loads. `class` names a point on
91
/// the configured ladder and stands in for any number the hint leaves out.
92
#[derive(Debug, Clone, Deserialize)]
93
struct HistoricalResourceHint {
94
    #[serde(default)]
95
    target_id: Option<String>,
96
    #[serde(default)]
97
    action_mnemonic: Option<String>,
98
    #[serde(default)]
99
    command_digest: Option<String>,
100
    /// Used by file-backed hints. Learning keys by command instead.
101
    #[serde(default)]
102
    action_digest: Option<String>,
103
    #[serde(default)]
104
    cpu_count: Option<u64>,
105
    #[serde(default)]
106
    memory_kb: Option<u64>,
107
    #[serde(default)]
108
    memory_mib: Option<u64>,
109
    #[serde(default)]
110
    disk_kb: Option<u64>,
111
    #[serde(default)]
112
    class: Option<String>,
113
    #[serde(default)]
114
    samples: Option<u64>,
115
    #[serde(default)]
116
    last_seen_s: Option<u64>,
117
}
118
119
/// `hash/size` and `hash-size` both name the same digest.
120
4
fn normalize_digest_key(digest: &str) -> String {
121
4
    digest.trim().replace('/', "-")
122
4
}
123
124
#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
125
struct HintKey {
126
    target_id: Option<String>,
127
    action_mnemonic: Option<String>,
128
    command_digest: Option<String>,
129
    action_digest: Option<String>,
130
}
131
132
impl HistoricalResourceHint {
133
3
    fn keys(&self) -> Vec<HintKey> {
134
3
        let mut keys = Vec::new();
135
3
        if self.target_id.is_some() || 
self.action_mnemonic1
.
is_some1
() {
136
2
            keys.push(HintKey {
137
2
                target_id: self.target_id.clone(),
138
2
                action_mnemonic: self.action_mnemonic.clone(),
139
2
                ..HintKey::default()
140
2
            });
141
2
        
}1
142
6
        for (digest, action) in [
143
3
            (self.action_digest.as_deref(), true),
144
3
            (self.command_digest.as_deref(), false),
145
3
        ] {
146
6
            if let Some(
digest1
) = digest.map(normalize_digest_key).filter(|d| !
d1
.
is_empty1
()) {
147
1
                keys.push(if action {
148
1
                    HintKey {
149
1
                        action_digest: Some(digest),
150
1
                        ..HintKey::default()
151
1
                    }
152
                } else {
153
0
                    HintKey {
154
0
                        command_digest: Some(digest),
155
0
                        ..HintKey::default()
156
0
                    }
157
                });
158
5
            }
159
        }
160
3
        keys
161
3
    }
162
163
10.2k
    fn memory_kb(&self) -> Option<u64> {
164
10.2k
        self.memory_kb.or_else(|| 
{6
165
6
            self.memory_mib
166
6
                .map(|memory_mib| 
memory_mib0
.
saturating_mul0
(1024))
167
6
        })
168
10.2k
    }
169
170
    /// The sketch key this hint seeds: the target, else the mnemonic, else
171
    /// the command digest. `None` for a hint that names only an action
172
    /// digest, which the sketch does not key by.
173
10.2k
    fn sketch_key(&self) -> Option<SketchKey> {
174
10.2k
        let target = self.target_id.as_deref().filter(|s| !
s10.2k
.
is_empty10.2k
());
175
10.2k
        let mnemonic = self.action_mnemonic.as_deref().filter(|s| !
s10.2k
.
is_empty10.2k
());
176
10.2k
        let command = self
177
10.2k
            .command_digest
178
10.2k
            .as_deref()
179
10.2k
            .map(normalize_digest_key)
180
10.2k
            .filter(|d| !
d3
.
is_empty3
());
181
10.2k
        match (target, mnemonic, command) {
182
10.2k
            (Some(target), mnemonic, _) => Some(SketchKey::Target {
183
10.2k
                target: Arc::from(target),
184
10.2k
                mnemonic: mnemonic.map(Arc::from),
185
10.2k
            }),
186
6
            (None, Some(mnemonic), _) => Some(SketchKey::Mnemonic {
187
6
                mnemonic: Arc::from(mnemonic),
188
6
            }),
189
2
            (None, None, Some(digest)) => Some(SketchKey::Command {
190
2
                digest: Arc::from(digest.as_str()),
191
2
            }),
192
2
            (None, None, None) => None,
193
        }
194
10.2k
    }
195
}
196
197
#[derive(Debug, Deserialize)]
198
#[serde(untagged)]
199
enum HistoricalResourceHintFile {
200
    List(Vec<HistoricalResourceHint>),
201
    Object { hints: Vec<HistoricalResourceHint> },
202
}
203
204
impl HistoricalResourceHintFile {
205
17
    fn into_hints(self) -> Vec<HistoricalResourceHint> {
206
17
        match self {
207
17
            Self::List(
hints14
) | Self::Object {
hints3
} => hints,
208
        }
209
17
    }
210
}
211
212
/// The numbers an action leaves `add_action` with, before they become
213
/// minimum properties. Zero means the dimension is not set.
214
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
215
struct Numbers {
216
    cpu_count: u64,
217
    memory_kb: u64,
218
    disk_kb: u64,
219
}
220
221
impl From<&SizeClassSpec> for Numbers {
222
7
    fn from(class: &SizeClassSpec) -> Self {
223
7
        Self {
224
7
            cpu_count: class.cpu_count,
225
7
            memory_kb: class.memory_kb,
226
7
            disk_kb: class.disk_kb,
227
7
        }
228
7
    }
229
}
230
231
impl From<&Shaped> for Numbers {
232
13
    fn from(shaped: &Shaped) -> Self {
233
13
        Self {
234
13
            cpu_count: shaped.cpu_count,
235
13
            memory_kb: shaped.memory_kb,
236
13
            disk_kb: shaped.disk_kb,
237
13
        }
238
13
    }
239
}
240
241
/// The dimensions this fleet reserves: the ones its ladder or its cold
242
/// start names. A measurement may cover any dimension the worker samples,
243
/// and a minimum on a dimension no worker advertises makes the action
244
/// unsatisfiable, so a number on a dimension the fleet does not reserve is
245
/// dropped when the hint is shaped. A configuration with neither a ladder
246
/// nor a cold start reserves whatever was measured, as it always did.
247
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248
struct Reserved {
249
    cpu: bool,
250
    memory: bool,
251
    disk: bool,
252
}
253
254
impl Reserved {
255
50
    fn from_spec(spec: &HistoricalResourceSpec) -> Self {
256
50
        let cold = spec.cold_start.unwrap_or_default();
257
50
        let sized = !spec.classes.is_empty()
258
44
            || cold.cpu_count > 0
259
42
            || cold.memory_kb > 0
260
42
            || cold.disk_kb > 0;
261
50
        if !sized {
262
42
            return Self {
263
42
                cpu: true,
264
42
                memory: true,
265
42
                disk: true,
266
42
            };
267
8
        }
268
        Self {
269
8
            cpu: cold.cpu_count > 0 || 
spec.classes.iter()6
.
any6
(|c|
c.cpu_count6
> 0),
270
8
            memory: cold.memory_kb > 0 || 
spec.classes.iter()7
.
any7
(|c|
c.memory_kb6
> 0),
271
10
            disk: 
cold.disk_kb > 08
||
spec.classes.iter()7
.
any7
(|c| c.disk_kb > 0),
272
        }
273
50
    }
274
}
275
276
51
fn now_s() -> u64 {
277
51
    SystemTime::now()
278
51
        .duration_since(UNIX_EPOCH)
279
51
        .map_or(0, |d| d.as_secs())
280
51
}
281
282
/// A complete file generation is swapped under one short lock. File reads,
283
/// parsing and disposal of the old generation happen outside Execute.
284
struct FileHints {
285
    path: String,
286
    classes: Vec<SizeClassSpec>,
287
    reserved: Reserved,
288
    hints: Mutex<HashMap<HintKey, Numbers>>,
289
}
290
291
impl FileHints {
292
4
    fn refresh(&self) {
293
4
        let parsed = fs::read_to_string(&self.path)
294
4
            .map_err(|e| 
e0
.
to_string0
())
295
4
            .and_then(|contents| {
296
4
                serde_json::from_str::<HistoricalResourceHintFile>(&contents)
297
4
                    .map_err(|e| 
e1
.
to_string1
())
298
4
            });
299
4
        let 
mut hints3
= match parsed {
300
3
            Ok(file) => file.into_hints(),
301
1
            Err(error) => {
302
1
                warn!(%error, "Could not refresh resource hints; keeping previous generation");
303
1
                return;
304
            }
305
        };
306
3
        hints.sort_by_key(|hint| core::cmp::Reverse(
hint.keys()0
.
len0
()));
307
3
        let mut next = HashMap::new();
308
3
        for hint in hints {
309
3
            let class = hint
310
3
                .class
311
3
                .as_ref()
312
3
                .and_then(|name| 
self.classes.iter()0
.
find0
(|c|
&c.name0
==
name0
))
313
3
                .map_or_else(Numbers::default, Numbers::from);
314
3
            let numbers = Numbers {
315
3
                cpu_count: if self.reserved.cpu {
316
3
                    hint.cpu_count.unwrap_or(class.cpu_count)
317
                } else {
318
0
                    0
319
                },
320
3
                memory_kb: if self.reserved.memory {
321
3
                    hint.memory_kb().unwrap_or(class.memory_kb)
322
                } else {
323
0
                    0
324
                },
325
3
                disk_kb: if self.reserved.disk {
326
3
                    hint.disk_kb.unwrap_or(class.disk_kb)
327
                } else {
328
0
                    0
329
                },
330
            };
331
3
            for key in hint.keys() {
332
3
                next.insert(key, numbers);
333
3
            }
334
        }
335
3
        let old = { core::mem::replace(&mut *self.hints.lock(), next) };
336
3
        drop(old);
337
4
    }
338
339
3
    fn new(spec: &HistoricalResourceSpec) -> Arc<Self> {
340
3
        let this = Arc::new(Self {
341
3
            path: spec.hints_file.clone(),
342
3
            classes: spec.classes.clone(),
343
3
            reserved: Reserved::from_spec(spec),
344
3
            hints: Mutex::new(HashMap::new()),
345
3
        });
346
3
        if this.path.is_empty() {
347
1
            return this;
348
2
        }
349
2
        this.refresh();
350
2
        if spec.refresh_interval_s > 0 {
351
1
            let weak = Arc::downgrade(&this);
352
1
            let interval = Duration::from_secs(spec.refresh_interval_s);
353
1
            background_spawn!("historical_resource_file_refresh", async move {
354
                loop {
355
3
                    tokio::time::sleep(interval).await;
356
2
                    let Some(this) = weak.upgrade() else {
357
0
                        break;
358
                    };
359
0
                    if let Err(error) =
360
2
                        spawn_blocking!("refresh_resource_hints", move || this.refresh()).await
361
                    {
362
0
                        warn!(?error, "Resource hints refresh task failed");
363
2
                    }
364
                }
365
0
            });
366
1
        }
367
2
        this
368
3
    }
369
370
53
    fn lookup(
371
53
        &self,
372
53
        action: &ActionInfo,
373
53
        target: Option<&String>,
374
53
        mnemonic: Option<&String>,
375
53
    ) -> Option<Numbers> {
376
53
        let keys = [
377
53
            target.zip(mnemonic).map(|(t, m)| HintKey {
378
52
                target_id: Some(t.clone()),
379
52
                action_mnemonic: Some(m.clone()),
380
52
                ..HintKey::default()
381
52
            }),
382
53
            target.map(|t| HintKey {
383
52
                target_id: Some(t.clone()),
384
52
                ..HintKey::default()
385
52
            }),
386
53
            Some(HintKey {
387
53
                action_digest: Some(action.digest().to_string()),
388
53
                ..HintKey::default()
389
53
            }),
390
53
            Some(HintKey {
391
53
                command_digest: Some(action.command_digest.to_string()),
392
53
                ..HintKey::default()
393
53
            }),
394
53
            mnemonic.map(|m| HintKey {
395
52
                action_mnemonic: Some(m.clone()),
396
52
                ..HintKey::default()
397
52
            }),
398
        ];
399
53
        let hints = self.hints.lock();
400
53
        keys.iter()
401
53
            .flatten()
402
105
            .
find_map53
(|key| hints.get(key).copied())
403
53
    }
404
}
405
406
pub struct HistoricalResourceScheduler {
407
    cpu_property_name: String,
408
    memory_property_name: String,
409
    disk_property_name: String,
410
    cold_start: Numbers,
411
    classes: Vec<SizeClassSpec>,
412
    default_class: Option<String>,
413
    class_by_mnemonic: HashMap<String, String>,
414
    class_by_timeout: Vec<TimeoutClassSpec>,
415
    scheduler: Option<Arc<dyn KnownPlatformPropertyProvider>>,
416
    known_properties: Mutex<HashMap<String, Vec<String>>>,
417
    sketch: Arc<ResourceSketch>,
418
    file_hints: Option<Arc<FileHints>>,
419
}
420
421
impl core::fmt::Debug for HistoricalResourceScheduler {
422
0
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
423
0
        f.debug_struct("HistoricalResourceScheduler")
424
0
            .field("cpu_property_name", &self.cpu_property_name)
425
0
            .field("memory_property_name", &self.memory_property_name)
426
0
            .field("cold_start", &self.cold_start)
427
0
            .field("classes", &self.classes)
428
0
            .field("default_class", &self.default_class)
429
0
            .field("known_properties", &self.known_properties)
430
0
            .field("sketch", &self.sketch)
431
0
            .finish_non_exhaustive()
432
0
    }
433
}
434
435
impl HistoricalResourceScheduler {
436
    /// The sketch a spec describes: its headroom, ladder and reserved
437
    /// dimensions as the shaping policy, its budget and window.
438
    #[must_use]
439
47
    pub fn sketch_from_spec(spec: &HistoricalResourceSpec) -> ResourceSketch {
440
47
        let reserved = Reserved::from_spec(spec);
441
47
        ResourceSketch::new(
442
47
            Policy {
443
47
                headroom_percent: spec.headroom_percent,
444
47
                classes: spec.classes.clone(),
445
47
                reserve_cpu: reserved.cpu,
446
47
                reserve_memory: reserved.memory,
447
47
                reserve_disk: reserved.disk,
448
47
            },
449
47
            if spec.learn_from_workers {
450
43
                Budget::from_bytes(spec.memory_budget_bytes)
451
            } else {
452
                // File-backed mode never receives worker samples. Do not
453
                // preallocate the learned cache when learning is disabled.
454
4
                Budget {
455
4
                    target: 1,
456
4
                    mnemonic: 1,
457
4
                    command: 1,
458
4
                    probation: 1,
459
4
                }
460
            },
461
47
            Duration::from_secs(spec.window_days.saturating_mul(86_400)),
462
        )
463
47
    }
464
465
    /// Wraps `scheduler` with sizing from `sketch`. An empty sketch is
466
    /// seeded from `spec.hints_file` when one is named; a sketch that was
467
    /// loaded from a snapshot keeps what it has.
468
    #[must_use]
469
24
    pub fn new(
470
24
        spec: &HistoricalResourceSpec,
471
24
        scheduler: Arc<dyn KnownPlatformPropertyProvider>,
472
24
        sketch: Arc<ResourceSketch>,
473
24
    ) -> Self {
474
24
        let this = Self {
475
24
            cpu_property_name: spec.cpu_property_name.clone(),
476
24
            memory_property_name: spec.memory_property_name.clone(),
477
24
            disk_property_name: spec.disk_property_name.clone(),
478
24
            cold_start: spec
479
24
                .cold_start
480
24
                .as_ref()
481
24
                .map_or_else(Numbers::default, |c| Numbers {
482
2
                    cpu_count: c.cpu_count,
483
2
                    memory_kb: c.memory_kb,
484
2
                    disk_kb: c.disk_kb,
485
2
                }),
486
24
            classes: spec.classes.clone(),
487
24
            default_class: spec.default_class.clone(),
488
24
            class_by_mnemonic: spec.class_by_mnemonic.clone(),
489
24
            class_by_timeout: spec.class_by_timeout.clone(),
490
24
            scheduler: Some(scheduler),
491
24
            known_properties: Mutex::new(HashMap::new()),
492
24
            sketch,
493
24
            file_hints: (!spec.learn_from_workers).then(|| 
FileHints::new3
(
spec3
)),
494
        };
495
24
        if spec.learn_from_workers && 
!spec.hints_file.is_empty()21
{
496
14
            let stats = this.sketch.stats();
497
14
            if stats.entries.iter().sum::<usize>() == 0 {
498
14
                this.seed_from_file(&spec.hints_file);
499
14
            } else {
500
0
                info!(
501
                    hints_file = %spec.hints_file,
502
                    "A snapshot was loaded; the hints file is not read"
503
                );
504
            }
505
10
        }
506
24
        this
507
24
    }
508
509
    /// What this layer sizes from.
510
    #[must_use]
511
0
    pub const fn sketch(&self) -> &Arc<ResourceSketch> {
512
0
        &self.sketch
513
0
    }
514
515
    /// `snapshot_store` must take a string key: a stack that routes by
516
    /// digest (`size_partitioning`) refuses one. A probe at start writes a
517
    /// key beside the snapshots and reads it back, so such a stack fails at
518
    /// start with a message rather than at the first write a minute later.
519
3
    pub async fn probe_snapshot_store(store: &Store, name: &str) -> Result<(), Error> {
520
        const HELP: &str = "it must take a string key: memory, filesystem, redis, s3, gcs or azure, or verify, compression, shard and fast_slow over one; not size_partitioning";
521
3
        let bytes = Bytes::from_static(b"resource sketch probe");
522
3
        store
523
3
            .update_oneshot(StoreKey::Str(SNAPSHOT_PROBE_KEY.into()), bytes.clone())
524
3
            .await
525
3
            .err_tip(|| 
{2
526
2
                format!(
527
                    "historical_resource.snapshot_store {name:?} refused the probe write; {HELP}"
528
                )
529
2
            })?;
530
1
        let read = store
531
1
            .get_part_unchunked(StoreKey::Str(SNAPSHOT_PROBE_KEY.into()), 0, None)
532
1
            .await
533
1
            .err_tip(|| 
{0
534
0
                format!(
535
                    "historical_resource.snapshot_store {name:?} could not read the probe back; {HELP}"
536
                )
537
0
            })?;
538
1
        if read != bytes {
539
0
            return Err(make_input_err!(
540
0
                "historical_resource.snapshot_store {name:?} read a different value back for the probe key; {HELP}"
541
0
            ));
542
1
        }
543
1
        Ok(())
544
3
    }
545
546
22
    async fn read_snapshot(
547
22
        sketch: &ResourceSketch,
548
22
        store: &Store,
549
22
        key: &str,
550
22
    ) -> Result<Snapshot, Error> {
551
22
        let 
bytes16
= store
552
22
            .get_part_unchunked(
553
22
                StoreKey::Str(key.into()),
554
22
                0,
555
22
                Some(sketch.snapshot_byte_limit()),
556
22
            )
557
22
            .await
?6
;
558
16
        let budget = sketch.budget();
559
16
        Snapshot::decode(&bytes, budget.target + budget.mnemonic + budget.command)
560
22
    }
561
562
    /// Prefer this replica's snapshot. A Deployment replacement has a new
563
    /// hostname: when its key is absent, restore one newest compatible history
564
    /// from the same store prefix. Never merge or add together replicas' samples.
565
16
    pub async fn load_snapshots(sketch: &ResourceSketch, store: &Store, own_key: &str) {
566
16
        match Self::read_snapshot(sketch, store, own_key).await {
567
10
            Ok(snapshot) => {
568
10
                match sketch.load(snapshot, now_s()) {
569
10
                    Ok(entries) => info!(entries, "Restored this replica's resource sketch"),
570
0
                    Err(error) => warn!(?error, "Resource sketch not loaded; using seed"),
571
                }
572
10
                return;
573
            }
574
6
            Err(error) if error.code == Code::NotFound => {}
575
0
            Err(error) => {
576
0
                warn!(
577
                    ?error,
578
                    "Could not read this replica's resource sketch; using seed"
579
                );
580
0
                return;
581
            }
582
        }
583
584
        // Limit both the number and length of retained keys. Redis SCAN may
585
        // repeat keys, so duplicates must not consume the discovery allowance.
586
6
        let mut keys = BTreeSet::new();
587
6
        let mut overflow = false;
588
6
        let listed = store
589
6
            .list(
590
6
                StoreKey::Str(SNAPSHOT_KEY_PREFIX.into())
591
6
                    ..StoreKey::Str(SNAPSHOT_KEY_PREFIX_END.into()),
592
153
                |key| {
593
153
                    if let StoreKey::Str(key) = key
594
153
                        && key.len() <= SNAPSHOT_KEY_PREFIX.len() + MAX_REPLICA_ID_BYTES
595
                    {
596
153
                        keys.insert(key.to_string());
597
153
                        if keys.len() > MAX_SNAPSHOT_CANDIDATES {
598
1
                            overflow = true;
599
1
                            return false;
600
152
                        }
601
0
                    }
602
152
                    true
603
153
                },
604
            )
605
6
            .await;
606
6
        if let Err(
error0
) = listed {
607
0
            warn!(
608
                ?error,
609
                "Cannot discover resource snapshots; use a stable replica_id on stores without listing support. Using seed"
610
            );
611
0
            return;
612
6
        }
613
6
        if overflow {
614
1
            warn!(
615
                "Too many resource snapshots to discover safely; set a stable replica_id or retire old snapshot keys. Using seed"
616
            );
617
1
            return;
618
5
        }
619
5
        let mut candidates = Vec::new();
620
24
        for key in 
keys5
{
621
24
            if let Ok(header) = store
622
24
                .get_part_unchunked(
623
24
                    StoreKey::Str(key.as_str().into()),
624
                    0,
625
24
                    Some(Snapshot::HEADER_BYTES),
626
                )
627
24
                .await
628
24
                && let Ok(
written_at20
) = Snapshot::compatible_written_at(&header)
629
20
            {
630
20
                candidates.push((written_at, key));
631
20
            
}4
632
        }
633
5
        candidates.sort_unstable();
634
6
        for (_, key) in 
candidates5
.
into_iter5
().
rev5
() {
635
6
            let loaded = Self::read_snapshot(sketch, store, &key)
636
6
                .await
637
6
                .and_then(|snapshot| 
sketch5
.
load5
(
snapshot5
,
now_s5
()));
638
6
            match loaded {
639
5
                Ok(entries) => {
640
5
                    info!(
641
                        entries,
642
                        snapshot_key = key,
643
                        "Restored newest compatible resource sketch after replica replacement"
644
                    );
645
5
                    return;
646
                }
647
1
                Err(error) => warn!(
648
                    ?error,
649
                    "Restart snapshot was unreadable; trying the next newest"
650
                ),
651
            }
652
        }
653
16
    }
654
655
    /// Every `interval`: ages the sketch (the two half-windows
656
    /// are enforced here, store or not), writes it to `store` under `key`
657
    /// when there is one, and reports its size. Without a store, or with
658
    /// an interval of zero, the tick is a minute and the write is skipped.
659
4
    pub fn spawn_snapshot_writer(
660
4
        sketch: Arc<ResourceSketch>,
661
4
        store: Option<Store>,
662
4
        interval: Duration,
663
4
        key: String,
664
4
    ) {
665
4
        let write = store.filter(|_| !
interval1
.
is_zero1
());
666
4
        let tick = if interval.is_zero() {
667
4
            Duration::from_mins(1)
668
        } else {
669
0
            interval
670
        };
671
4
        background_spawn!("historical_resource_sketch_snapshot", async move 
{0
672
0
            let mut last_stats = Stats::default();
673
0
            let mut last_written_s: Option<u64> = None;
674
            loop {
675
0
                tokio::time::sleep(tick).await;
676
0
                let now = now_s();
677
0
                let dropped = sketch.age_out(now);
678
0
                if dropped > 0 {
679
0
                    debug!(
680
                        dropped,
681
                        "Resource sketch keys not seen for the window were dropped"
682
                    );
683
0
                }
684
0
                if let Some(store) = &write {
685
0
                    let bytes = sketch.encode_snapshot(now);
686
0
                    let len = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
687
0
                    match store
688
0
                        .update_oneshot(StoreKey::Str(key.as_str().into()), Bytes::from(bytes))
689
0
                        .await
690
                    {
691
0
                        Ok(()) => {
692
0
                            last_written_s = Some(now);
693
0
                            record_hints_snapshot(len, 0);
694
0
                        }
695
0
                        Err(err) => {
696
0
                            warn!(
697
                                ?err,
698
                                key,
699
                                "Could not write the resource sketch snapshot; retrying next interval"
700
                            );
701
0
                            record_hints_snapshot_failure();
702
0
                            record_hints_snapshot(
703
0
                                len,
704
0
                                last_written_s.map_or(0, |at| now.saturating_sub(at)),
705
                            );
706
                        }
707
                    }
708
0
                }
709
0
                let stats = sketch.stats();
710
0
                for family in Family::ALL {
711
0
                    let i = match family {
712
0
                        Family::Target => 0,
713
0
                        Family::Mnemonic => 1,
714
0
                        Family::Command => 2,
715
                    };
716
0
                    record_hints_family(
717
0
                        family.as_str(),
718
0
                        u64::try_from(stats.entries[i]).unwrap_or(u64::MAX),
719
0
                        u64::try_from(stats.capacity[i]).unwrap_or(u64::MAX),
720
0
                        stats.evictions[i].saturating_sub(last_stats.evictions[i]),
721
0
                        stats.samples[i].saturating_sub(last_stats.samples[i]),
722
                    );
723
                }
724
0
                last_stats = stats;
725
            }
726
        });
727
4
    }
728
729
    /// Seeds the sketch from a hints file: one entry per hint, at the
730
    /// numbers the hint states (its class resolved through the ladder),
731
    /// marked seeded so the first real sample replaces it.
732
14
    fn seed_from_file(&self, path: &str) {
733
14
        let contents = match fs::read_to_string(path) {
734
14
            Ok(contents) => contents,
735
0
            Err(err) => {
736
0
                warn!(?err, hints_file = %path, "Could not read the hints file; nothing seeded");
737
0
                return;
738
            }
739
        };
740
14
        let hints = match serde_json::from_str::<HistoricalResourceHintFile>(&contents) {
741
14
            Ok(file) => file.into_hints(),
742
0
            Err(err) => {
743
0
                warn!(?err, hints_file = %path, "Could not parse the hints file; nothing seeded");
744
0
                return;
745
            }
746
        };
747
14
        let now = now_s();
748
14
        let (mut seeded, mut skipped) = (0usize, 0usize);
749
10.2k
        for hint in 
hints14
{
750
10.2k
            let numbers = self.numbers_from_hint(&hint);
751
10.2k
            let Some(
key10.2k
) = hint.sketch_key().filter(|_|
numbers10.2k
!=
Numbers::default()10.2k
) else {
752
2
                skipped += 1;
753
2
                continue;
754
            };
755
10.2k
            let samples = hint
756
10.2k
                .samples
757
10.2k
                .and_then(|n| 
u32::try_from0
(
n0
).
ok0
())
758
10.2k
                .unwrap_or(1);
759
10.2k
            let entry = Entry::seeded(
760
10.2k
                numbers.cpu_count,
761
10.2k
                numbers.memory_kb,
762
10.2k
                numbers.disk_kb,
763
10.2k
                samples,
764
10.2k
                hint.last_seen_s.unwrap_or(now),
765
            );
766
10.2k
            if self.sketch.seed(key, entry) {
767
10.2k
                seeded += 1;
768
10.2k
            
}0
769
        }
770
14
        info!(hints_file = %path, seeded, skipped, "Seeded the resource sketch from the hints file");
771
14
    }
772
773
    /// The Bazel request metadata for the current action, if the client
774
    /// sent any: `(target_id, action_mnemonic)`, each empty string as `None`.
775
75
    fn current_metadata() -> (Option<String>, Option<String>) {
776
75
        let ctx = Context::current();
777
75
        let Some(
metadata65
) = ctx
778
75
            .baggage()
779
75
            .get(BAZEL_METADATA_KEY)
780
75
            .and_then(|value| 
request_metadata_from_baggage65
(
value.as_str()65
).
ok65
())
781
        else {
782
10
            return (None, None);
783
        };
784
65
        (
785
65
            non_empty_string(metadata.target_id),
786
65
            non_empty_string(metadata.action_mnemonic),
787
65
        )
788
75
    }
789
790
    /// The key ladder, most specific first: `(target, mnemonic)`, then
791
    /// `target` alone, then the command digest, then the mnemonic alone.
792
    /// The command digest is what reaches clients that send no
793
    /// `RequestMetadata`, and the same command regardless of who runs it.
794
22
    fn hint_for_action(
795
22
        &self,
796
22
        action_info: &ActionInfo,
797
22
        target_id: Option<&String>,
798
22
        action_mnemonic: Option<&String>,
799
22
    ) -> Option<(Entry, &'static str)> {
800
22
        let ladder = [
801
            (
802
22
                target_id
803
22
                    .zip(action_mnemonic)
804
22
                    .map(|(target, mnemonic)| SketchKey::Target {
805
12
                        target: Arc::from(target.as_str()),
806
12
                        mnemonic: Some(Arc::from(mnemonic.as_str())),
807
12
                    }),
808
                "target",
809
            ),
810
            (
811
22
                target_id.map(|target| SketchKey::Target {
812
12
                    target: Arc::from(target.as_str()),
813
12
                    mnemonic: None,
814
12
                }),
815
22
                "target",
816
            ),
817
22
            (
818
22
                Some(SketchKey::Command {
819
22
                    digest: Arc::from(action_info.command_digest.to_string().as_str()),
820
22
                }),
821
22
                "command_digest",
822
22
            ),
823
            (
824
22
                action_mnemonic.map(|mnemonic| SketchKey::Mnemonic {
825
13
                    mnemonic: Arc::from(mnemonic.as_str()),
826
13
                }),
827
22
                "mnemonic",
828
            ),
829
        ];
830
22
        let now = now_s();
831
72
        for (key, source) in 
ladder22
{
832
72
            if let Some(
key47
) = key
833
47
                && let Some(
entry14
) = self.sketch.lookup(&key, now)
834
            {
835
14
                let source = if entry.seeded { 
"seed"9
} else {
source5
};
836
14
                return Some((entry, source));
837
58
            }
838
        }
839
8
        None
840
22
    }
841
842
9
    fn class(&self, name: &str) -> Option<&SizeClassSpec> {
843
14
        
self.classes.iter()9
.
find9
(|class| class.name == name)
844
9
    }
845
846
    /// A seed hint's numbers: what it states, with its class filling
847
    /// anything it leaves out. An unknown class contributes nothing.
848
10.2k
    fn numbers_from_hint(&self, hint: &HistoricalResourceHint) -> Numbers {
849
10.2k
        let from_class = hint
850
10.2k
            .class
851
10.2k
            .as_deref()
852
10.2k
            .and_then(|name| 
self3
.
class3
(
name3
))
853
10.2k
            .map(Numbers::from)
854
10.2k
            .unwrap_or_default();
855
10.2k
        Numbers {
856
10.2k
            cpu_count: hint.cpu_count.unwrap_or(from_class.cpu_count),
857
10.2k
            memory_kb: hint.memory_kb().unwrap_or(from_class.memory_kb),
858
10.2k
            disk_kb: hint.disk_kb.unwrap_or(from_class.disk_kb),
859
10.2k
        }
860
10.2k
    }
861
862
    /// Raises the action's minimums to `numbers`; never lowers a value the
863
    /// client or an earlier step set.
864
66
    fn apply_minimums(&self, action_info: &mut ActionInfo, numbers: Numbers) {
865
66
        apply_minimum_platform_property(
866
66
            &mut action_info.platform_properties,
867
66
            &self.cpu_property_name,
868
66
            numbers.cpu_count,
869
        );
870
66
        apply_minimum_platform_property(
871
66
            &mut action_info.platform_properties,
872
66
            &self.memory_property_name,
873
66
            numbers.memory_kb,
874
        );
875
66
        apply_minimum_platform_property(
876
66
            &mut action_info.platform_properties,
877
66
            &self.disk_property_name,
878
66
            numbers.disk_kb,
879
        );
880
66
    }
881
882
    /// The cold-start policy for an action the sketch has nothing for: a
883
    /// class by mnemonic, else by timeout, else the default class; without
884
    /// classes, the raw `cold_start` numbers. Only absent properties are
885
    /// filled.
886
9
    fn cold_start_numbers(
887
9
        &self,
888
9
        action_info: &ActionInfo,
889
9
        action_mnemonic: Option<&String>,
890
9
    ) -> Numbers {
891
9
        let by_mnemonic = action_mnemonic.and_then(|m| 
self.class_by_mnemonic4
.
get4
(
m4
));
892
9
        let by_timeout = if action_info.timeout.is_zero() {
893
0
            None
894
        } else {
895
9
            self.class_by_timeout
896
9
                .iter()
897
9
                .filter(|rule| 
Duration::from_secs(rule.min_timeout_s)3
<=
action_info.timeout3
)
898
9
                .max_by_key(|rule| rule.min_timeout_s)
899
9
                .map(|rule| &rule.class)
900
        };
901
        // The rules were checked against the ladder at load; the first
902
        // candidate that names a class on it wins.
903
9
        [by_mnemonic, by_timeout, self.default_class.as_ref()]
904
9
            .into_iter()
905
9
            .flatten()
906
9
            .find_map(|name| 
self6
.
class6
(
name6
))
907
9
            .map_or(self.cold_start, Numbers::from)
908
9
    }
909
910
    /// The cold-start rules and the default class must name classes on the
911
    /// ladder, or the action they were written for gets nothing. The budget
912
    /// must hold at least a few keys.
913
7
    pub fn validate(spec: &HistoricalResourceSpec) -> Result<(), Error> {
914
7
        if spec.replica_id.len() > MAX_REPLICA_ID_BYTES {
915
1
            return Err(make_input_err!(
916
1
                "historical_resource.replica_id must be at most {MAX_REPLICA_ID_BYTES} bytes"
917
1
            ));
918
6
        }
919
6
        let known = |name: &str| 
spec.classes.iter()1
.
any1
(|class|
class.name1
==
name1
);
920
6
        let mut rules = spec
921
6
            .class_by_mnemonic
922
6
            .iter()
923
6
            .map(|(mnemonic, class)| (
format!1
("class_by_mnemonic[{mnemonic}]"),
class1
.
as_str1
()))
924
6
            .chain(spec.class_by_timeout.iter().map(|rule| 
{0
925
0
                (
926
0
                    format!("class_by_timeout[{}]", rule.min_timeout_s),
927
0
                    rule.class.as_str(),
928
0
                )
929
0
            }))
930
6
            .chain(
931
6
                spec.default_class
932
6
                    .iter()
933
6
                    .map(|class| (
"default_class"0
.
to_string0
(),
class0
.
as_str0
())),
934
            );
935
6
        if let Some((
rule1
,
class1
)) = rules.find(|(_, class)| !
known(class)1
) {
936
1
            return Err(make_input_err!(
937
1
                "historical_resource.{rule} names size class {class:?}, which classes does not define"
938
1
            ));
939
5
        }
940
5
        if spec.memory_budget_bytes < 1 << 20 {
941
0
            return Err(make_input_err!(
942
0
                "historical_resource.memory_budget_bytes must be at least 1 MiB, got {}",
943
0
                spec.memory_budget_bytes
944
0
            ));
945
5
        }
946
5
        if spec.window_days == 0 {
947
0
            return Err(make_input_err!(
948
0
                "historical_resource.window_days must be above zero"
949
0
            ));
950
5
        }
951
5
        Ok(())
952
7
    }
953
954
7
    fn apply_defaults(&self, action_info: &mut ActionInfo, numbers: Numbers) {
955
7
        apply_default_platform_property(
956
7
            &mut action_info.platform_properties,
957
7
            &self.cpu_property_name,
958
7
            numbers.cpu_count,
959
        );
960
7
        apply_default_platform_property(
961
7
            &mut action_info.platform_properties,
962
7
            &self.memory_property_name,
963
7
            numbers.memory_kb,
964
        );
965
7
        apply_default_platform_property(
966
7
            &mut action_info.platform_properties,
967
7
            &self.disk_property_name,
968
7
            numbers.disk_kb,
969
        );
970
7
    }
971
972
0
    async fn inner_get_known_properties(&self, instance_name: &str) -> Result<Vec<String>, Error> {
973
        {
974
0
            let known_properties = self.known_properties.lock();
975
0
            if let Some(property_manager) = known_properties.get(instance_name) {
976
0
                return Ok(property_manager.clone());
977
0
            }
978
        }
979
0
        let known_platform_property_provider = self
980
0
            .scheduler
981
0
            .as_ref()
982
0
            .err_tip(|| "Inner scheduler does not implement KnownPlatformPropertyProvider for HistoricalResourceScheduler")?;
983
0
        let mut known_properties = HashSet::<String>::from_iter(
984
0
            known_platform_property_provider
985
0
                .get_known_properties(instance_name)
986
0
                .await?,
987
        );
988
0
        known_properties.insert(self.cpu_property_name.clone());
989
0
        known_properties.insert(self.memory_property_name.clone());
990
0
        known_properties.insert(self.disk_property_name.clone());
991
0
        let final_known_properties: Vec<String> = known_properties.into_iter().collect();
992
0
        self.known_properties
993
0
            .lock()
994
0
            .insert(instance_name.to_string(), final_known_properties.clone());
995
0
        Ok(final_known_properties)
996
0
    }
997
}
998
999
130
fn non_empty_string(value: String) -> Option<String> {
1000
130
    if value.is_empty() { 
None1
} else {
Some(value)129
}
1001
130
}
1002
1003
198
fn apply_minimum_platform_property(
1004
198
    platform_properties: &mut HashMap<String, String>,
1005
198
    property_name: &str,
1006
198
    minimum_value: u64,
1007
198
) {
1008
198
    if minimum_value == 0 {
1009
121
        return;
1010
77
    }
1011
77
    let should_update = platform_properties
1012
77
        .get(property_name)
1013
77
        .and_then(|value| 
value.parse::<u64>()5
.
ok5
())
1014
77
        .is_none_or(|current_value| 
current_value5
<
minimum_value5
);
1015
77
    if should_update {
1016
76
        platform_properties.insert(property_name.to_string(), minimum_value.to_string());
1017
76
    
}1
1018
198
}
1019
1020
21
fn apply_default_platform_property(
1021
21
    platform_properties: &mut HashMap<String, String>,
1022
21
    property_name: &str,
1023
21
    default_value: u64,
1024
21
) {
1025
21
    if default_value == 0 || 
platform_properties17
.
contains_key17
(
property_name17
) {
1026
6
        return;
1027
15
    }
1028
15
    platform_properties.insert(property_name.to_string(), default_value.to_string());
1029
21
}
1030
1031
#[async_trait]
1032
impl KnownPlatformPropertyProvider for HistoricalResourceScheduler {
1033
0
    async fn get_known_properties(&self, instance_name: &str) -> Result<Vec<String>, Error> {
1034
        self.inner_get_known_properties(instance_name).await
1035
0
    }
1036
}
1037
1038
#[async_trait]
1039
impl ClientStateManager for HistoricalResourceScheduler {
1040
    async fn add_action(
1041
        &self,
1042
        client_operation_id: OperationId,
1043
        mut action_info: Arc<ActionInfo>,
1044
75
    ) -> Result<Box<dyn ActionStateResult>, Error> {
1045
        let (target_id, action_mnemonic) = Self::current_metadata();
1046
        // An entry that shapes to all zero (nothing measured on a dimension
1047
        // the fleet reserves) reserves nothing, so it is no hint: the
1048
        // cold-start policy applies and the metric says so.
1049
        let hint = if let Some(file) = &self.file_hints {
1050
            file.lookup(&action_info, target_id.as_ref(), action_mnemonic.as_ref())
1051
53
                .map(|numbers| (numbers, "file"))
1052
        } else {
1053
            self.hint_for_action(&action_info, target_id.as_ref(), action_mnemonic.as_ref())
1054
14
                .map(|(entry, source)| (self.sketch.shape(&entry), source))
1055
14
                .filter(|(shaped, _)| !shaped.is_empty())
1056
13
                .map(|(shaped, source)| (Numbers::from(&shaped), source))
1057
        }
1058
66
        .filter(|(numbers, _)| *numbers != Numbers::default());
1059
        if let Some((shaped, source)) = hint {
1060
            self.apply_minimums(Arc::make_mut(&mut action_info), shaped);
1061
            record_hint_resolution(source);
1062
        } else {
1063
            let numbers = self.cold_start_numbers(&action_info, action_mnemonic.as_ref());
1064
            if numbers == Numbers::default() {
1065
                record_hint_resolution("none");
1066
            } else {
1067
                self.apply_defaults(Arc::make_mut(&mut action_info), numbers);
1068
                record_hint_resolution("cold_start");
1069
            }
1070
        }
1071
        self.scheduler
1072
            .as_ref()
1073
            .err_tip(|| "Inner scheduler not available for HistoricalResourceScheduler")?
1074
            .add_action(client_operation_id, action_info)
1075
            .await
1076
75
    }
1077
1078
    async fn filter_operations<'a>(
1079
        &'a self,
1080
        filter: OperationFilter,
1081
0
    ) -> Result<ActionStateResultStream<'a>, Error> {
1082
        self.scheduler
1083
            .as_ref()
1084
            .err_tip(|| "Inner scheduler not available for HistoricalResourceScheduler")?
1085
            .filter_operations(filter)
1086
            .await
1087
0
    }
1088
}
1089
1090
impl MetricsComponent for HistoricalResourceScheduler {
1091
0
    fn publish(
1092
0
        &self,
1093
0
        kind: MetricKind,
1094
0
        field_metadata: MetricFieldData,
1095
0
    ) -> Result<MetricPublishKnownKindData, nativelink_metric::Error> {
1096
0
        match &self.scheduler {
1097
0
            Some(scheduler) => scheduler.publish(kind, field_metadata),
1098
0
            None => Ok(MetricPublishKnownKindData::Component),
1099
        }
1100
0
    }
1101
}
1102
1103
impl RootMetricsComponent for HistoricalResourceScheduler {}