Repository navigation
Add metrics to SimpleScheduler and Worker - #216
Conversation
allada
left a comment
There was a problem hiding this comment.
+@aaronmondal do you want to take a swing at reviewing this one? Might be the biggest one of the batch.
Reviewable status: 0 of 10 files reviewed, all discussions resolved (waiting on @aaronmondal)
6b277e1 to
ebcde03
Compare
|
cc: @chrisstaite-menlo, this should give you the metrics you were wanting. |
aaronmondal
left a comment
There was a problem hiding this comment.
Only got through the first half. Will get back to you on the rest in a bit.
Reviewed 6 of 10 files at r1, all commit messages.
Reviewable status: 6 of 10 files reviewed, 2 unresolved discussions (waiting on @allada)
cas/scheduler/default_scheduler_factory.rs line 102 at r1 (raw file):
// has already been visited, not just the trait. `Any` could be used, but that'd require some rework // of all the schedulers. This is the most simple way to do it. Rust's uintptr_t is usize. let action_scheduler_uintptr: usize = Arc::as_ptr(action_scheduler) as *const () as usize;
I get why it's done this way but we should probably track this in an issue if there isn't one already.
cas/scheduler/simple_scheduler.rs line 237 at r1 (raw file):
// Check to see if the action is running, if it is and cacheable, merge the actions. if let Some(running_action) = self.active_actions.get_mut(&action_info) { self.metrics.add_action_joined_running_action.inc();
Nit: I think it would be nice to name this increment. I wasn't sure whether this was something like "include" and had to look up the implementation 😅
cas/scheduler/simple_scheduler.rs line 686 at r1 (raw file):
} fn get_inner_lock(&self) -> MutexGuard<'_, SimpleSchedulerImpl> {
I'll get back to you on this one and the wrapping calls. I need to think about those for a bit.
cas/scheduler/simple_scheduler.rs line 832 at r1 (raw file):
} impl MetricsComponent for SimpleScheduler {
Since this code is so "uniform" would it make sense to move all those MetricsComponent impls into a separate file? I like the way it's done in general a lot though.
allada
left a comment
There was a problem hiding this comment.
Reviewable status: 6 of 10 files reviewed, 2 unresolved discussions (waiting on @aaronmondal)
cas/scheduler/default_scheduler_factory.rs line 102 at r1 (raw file):
Previously, aaronmondal (Aaron Siddhartha Mondal) wrote…
I get why it's done this way but we should probably track this in an issue if there isn't one already.
It's not really an issue. I'm not aware of a way to do this without using the raw pointers because they are two completely different interfaces, but might be the same object.
cas/scheduler/simple_scheduler.rs line 237 at r1 (raw file):
Previously, aaronmondal (Aaron Siddhartha Mondal) wrote…
Nit: I think it would be nice to name this
increment. I wasn't sure whether this was something like "include" and had to look up the implementation 😅
You could make the argument .inc() could mean .include(). I thought about .measure(), but i liked how short this function is to reduce the chance it'd get put on 2 lines.
cas/scheduler/simple_scheduler.rs line 686 at r1 (raw file):
Previously, aaronmondal (Aaron Siddhartha Mondal) wrote…
I'll get back to you on this one and the wrapping calls. I need to think about those for a bit.
FWIW, 99% of the time those wrappers should get inlined.
cas/scheduler/simple_scheduler.rs line 832 at r1 (raw file):
Previously, aaronmondal (Aaron Siddhartha Mondal) wrote…
Since this code is so "uniform" would it make sense to move all those MetricsComponent impls into a separate file? I like the way it's done in general a lot though.
I thought about it, but then I realized that because the code is so uniform it is easy for people to realize they are in metrics publication code and scroll back to where they want.
I like the idea of moving it to a new file, but I also don't like the idea of making people question where the metrics for this file are. This file is touched a lot, so we will see if the developer experience sucks because of this. I personally use cold-folding IDEs, so it is easy for me to ignore large sections of code.
chrisstaite-menlo
left a comment
There was a problem hiding this comment.
Reviewed 10 of 10 files at r1, all commit messages.
Reviewable status: all files reviewed, 3 unresolved discussions (waiting on @aaronmondal and @allada)
cas/scheduler/default_scheduler_factory.rs line 105 at r1 (raw file):
if !visited_schedulers.contains(&action_scheduler_uintptr) { visited_schedulers.insert(action_scheduler_uintptr); action_scheduler.clone().register_metrics(scheduler_metrics);
You could just call register_metrics on the scheduler when it's constructed above instead of all of this... That wouldn't require a map or anything.
allada
left a comment
There was a problem hiding this comment.
Reviewable status: all files reviewed, 3 unresolved discussions (waiting on @aaronmondal and @chrisstaite-menlo)
cas/scheduler/default_scheduler_factory.rs line 105 at r1 (raw file):
Previously, chrisstaite-menlo (Chris Staite) wrote…
You could just call
register_metricson the scheduler when it's constructed above instead of all of this... That wouldn't require a map or anything.
Yeah, I did consider that. The major reason I liked doing it this way is because it is more standard to how large projects manage it. Because there's a decent chance in the future we may make unregister_metrics() to allow temporary attachment of metrics for debugging. It's more about long-term flexibility.
|
Previously, allada (Nathan (Blaise) Bruer) wrote…
Is it worth complexity now for possible future expansion? I don't mind either way, and it's only during start-up, so it's not like it's a major issue. |
allada
left a comment
There was a problem hiding this comment.
Reviewable status: all files reviewed, 2 unresolved discussions (waiting on @aaronmondal)
cas/scheduler/default_scheduler_factory.rs line 105 at r1 (raw file):
Previously, chrisstaite-menlo (Chris Staite) wrote…
Is it worth complexity now for possible future expansion? I don't mind either way, and it's only during start-up, so it's not like it's a major issue.
Yeah I'd rather keep it this way, as it feels like this is how most past projects I've worked on do it. I just don't feel like this is one of those cases that it is wroth questioning "why everyone does it this way", when it's just a simple callback vs constructor argument.
chrisstaite-menlo
left a comment
There was a problem hiding this comment.
Reviewable status: all files reviewed, 2 unresolved discussions (waiting on @aaronmondal)
Adds metrics for running tasks and such to prometheus.
ebcde03 to
11c5d0f
Compare
allada
left a comment
There was a problem hiding this comment.
Reviewed 4 of 4 files at r2, all commit messages.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on @aaronmondal)
Two doc-only edits in `api_worker_scheduler.rs` addressing reviewer findings on the TraceMachina#214 BIS replay buffer cap: * `bis_replay_buffer_overflow_drops` doc comment claimed the counter is "the operator-visible signal." Reality: `SchedulerMetrics` lacks `#[derive(MetricsComponent)]` so the counter is internal-only. Operator visibility today is the per-(endpoint, broadcast_id) `warn!` log; the counter is reachable via tests / debug only. Filed TraceMachina#231 to wire the metrics exporter. * `BIS_REPLAY_BUFFER_MAX_CHUNKS` doc cited "16 GiB per worker" based on wire-encoded chunk size. Decoded heap representation is closer to ~38 GiB at the cap (96 B/Digest × 4096 × 100K plus prost framing). With 80 GiB MemoryMax, single-misbehaving-worker defense holds; two simultaneous misbehavers get tight. Comment now points to TraceMachina#215 + TraceMachina#216 for full defense-in-depth. Pure documentation; no behavior change. HEAD `b9befaf1` is unchanged in semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… allowlist Adds an OPT-IN deployment-integrity diagnostic. Workers compute SHA-256 of their own running binary at startup (`nativelink_util::build_sha`, 16-hex-char prefix = 64 bits) and report it via `ConnectWorkerRequest.build_sha` (proto field 8). The scheduler validates against the new opt-in `WorkerApiConfig .compatible_build_shas: Option<Vec<String>>` allowlist; on mismatch the connect is rejected with `Code::FailedPrecondition`, a WARN log naming the worker, and a `stale_workers_rejected_total` counter increment. **Default OFF.** `compatible_build_shas: None` (the default) DISABLES validation entirely — every worker is accepted, no behavior change versus the previous protocol. The feature is intended as operator-actionable visibility into "this worker is running an older binary than the server expects" — surface drift BEFORE it manifests as silent runtime errors / reconnect storms — not as an auto-remediation mechanism. ## What this is NOT This is NOT the worker-03 incident remediation. The actual fix for the storm that killed the server (114k errored UpdateForWorker frames + 9 OOM kills in 37 hours) was the graceful unknown-variant handler at `local_worker.rs:1689` (commit e7411e6), which terminates a stale worker the moment it sees an unrecognized scheduler→worker frame. That fix is already on main and is what stops the storm in production. TraceMachina#216 is a complementary diagnostic: it lets operators OPT IN to hard-rejecting mismatched builds during a coordinated rollout, catching drift at connect time instead of at the first incompatible frame downstream. A lighter-weight metric-only alternative is tracked separately (TraceMachina#236); if that ships, TraceMachina#216's hard-rejection mechanism may be redundant for everyday use and remain only as a safety net for high-stakes rollouts. ## Why hash the binary, not a build-time git SHA? Hashing the running binary catches every cause of binary drift: incomplete deploys, codesign-without-rebuild, stale launchd cached-load, manual scp from a developer machine. A git SHA from a build script would catch only intra-repo drift. Failure to read the binary degrades gracefully to the empty string, which is matched against the allowlist verbatim — operators who want to admit legacy workers must include the empty string in the list explicitly. An empty list (`[]`) is treated identically to `None` (validation disabled) — documented as a footgun in the config comment. ## Performance / safety properties * `build_sha()` reads the running binary via a 64 KiB chunked `BufReader` (NOT `std::fs::read`) so peak working set is 64 KiB regardless of binary size. * Cached in a `OnceLock<String>`; first call ~270 ms on a 67 MiB release binary, every subsequent call returns in nanoseconds. * The first call is **eager from `src/bin/nativelink.rs::main` BEFORE the tokio runtime starts** (right after the dump-signal handler install) so no async caller ever pays the I/O on a worker thread. * The `inner_connect_worker` hello path bounds string fields at 256 bytes each (defense-in-depth against log-injection / unbounded memory) before any validation runs — rejected with `Code::InvalidArgument`, distinct from the FailedPrecondition used for legitimate stale-SHA mismatch. ## Tests (TDD, mutation-verified) `nativelink-util` build_sha unit tests — 4 pass, including `compute_build_sha_production_path_matches_independent_hash` which exercises `std::env::current_exe()` + the chunked BufReader against the actual test binary on disk and cross-checks against a single- shot hash of the same bytes (catches Linux symlink-resolution and streaming-hash bugs the synthetic-bytes tests cannot see). `nativelink-service/tests/worker_api_build_sha_test.rs` — 10 production-composition tests (real WorkerApiServer + ApiWorkerScheduler + tokio::time::timeout deadlock detector + specific `.expect(...)` messages naming the contract): * allowlist_none_accepts_every_worker (backward compat) * allowlist_match_accepts_worker * connect_rejects_stale_worker_with_failed_precondition (regression — flips on mutation) * allowlist_with_legacy_marker_accepts_empty_sha * allowlist_without_legacy_marker_rejects_empty_sha (new — guards against future "treat empty as match-all" regression) * empty_allowlist_collapses_to_disabled * rejection_increments_stale_workers_rejected_total * oversized_hello_string_is_rejected_with_invalid_argument (new — guards the 256-byte field bound) * worker_api_config_round_trip_default_is_none (new — serde default contract) * worker_api_config_round_trip_populated_list_preserves_entries (new — serde round-trip preserves order + content) `nativelink-worker/tests/worker_utils_test.rs` — adds `make_connect_worker_request_populates_build_sha` asserting the hello frame ships a real 16-hex-char lowercase SHA (not "" — the wire-side half of the contract that the scheduler-side allowlist depends on). Mutation steps recorded in /tmp/216-fixup-mutation-{1..4}.log. Each mutation produced the bespoke `.expect(...)` message in the specific test that guards the corresponding contract. ## Deferred follow-up * TraceMachina#237: per-worker reconnect exponential backoff in `local_worker.rs`. TraceMachina#216 makes the rejection LOUD; it doesn't change the 2 Hz retry rate. Backoff is orthogonal and owns `local_worker.rs` (off-limits for TraceMachina#216 fixup). See `.claude/audits/237-worker-reconnect-exponential-backoff.md`. File ownership: `worker_api.proto` + generated `.pb.rs`, `worker_api_server.rs`, `cas_server.rs`, `worker_utils.rs`, `build_sha.rs`, `lib.rs`, `src/bin/nativelink.rs` (eager pre-warm), plus the affected test files. Off-limits files (`local_worker.rs`, `api_worker_scheduler.rs`, chunked code, `worker_proxy_store.rs`, `grpc_store.rs`) are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ConnectWorkerRequest gained build_sha in de0f83a (TraceMachina#216 deployment-drift diagnostic) but local_worker_test.rs was never updated. The test has been broken since TraceMachina#216 landed; fixed by normalizing build_sha = "" (mirroring the boot_epoch_id pattern at the same site) and adding the field to the expected literal.
…epoch reconnects
Adds a sliding-window flap detector to `WorkerApiServer::inner_connect_worker`
that fires `warn!("worker reconnect storm ...")` and bumps the new
`worker_flap_warns_total` counter when the same `cas_endpoint` undergoes
`FLAP_THRESHOLD = 3` boot_epoch changes within `FLAP_WINDOW = 300s`.
`FLAP_COOLDOWN = 60s` suppresses re-warn so a sustained flap doesn't drown
the log.
Mechanism: extends `EndpointState` (`worker_api_server.rs:223-260`) with
`epoch_changes: VecDeque<Duration>` + `last_flap_warn_at: Option<Duration>`.
Both fields share the pre-existing `endpoint_state: parking_lot::Mutex<...>`
already held across the `TraceMachina#141` wipe path (`:520-660`) — zero new lock cost.
Timestamps come from the server's injectable `now_fn`, not `SystemTime::now()`,
so tests drive the cooldown/window logic deterministically.
The flap deque only pushes inside the `needs_wipe == true` branch, so
same-epoch reconnects (transient stream drops) don't inflate the count.
Window-eviction at push-time keeps the deque bounded at FLAP_THRESHOLD entries
per endpoint for a flapping worker (UNBOUNDED-OK: not attacker-controlled —
`TraceMachina#216` build_sha allowlist gates connects).
Counter rides on `WorkerApiMetrics` (already wired via
`#[metric(group = "worker_api")]` at `:139-140`), so it's scrapable today —
unlike `TraceMachina#386` which can only lean on `warn!` until `TraceMachina#380` lands. The `warn!`
is still the load-bearing operator signal in journald.
Tests (`worker_api_server_test.rs`):
- `worker_flap_detection_fires_warn_and_respects_cooldown_test`: 3 flips
within window fire exactly one warn; 4th within cooldown does NOT re-warn;
flip past cooldown re-warns. Mutation step: commenting out the warn+counter
red-fails with bespoke "worker-flap warn missing — operator-blind to
whole-process restart loop" assertion (verified).
- `worker_flap_window_evicts_stale_entries_test`: 10 flips at 400s intervals
(beyond FLAP_WINDOW=300s) never accumulate to threshold — sliding-window
eviction.
- `worker_flap_same_epoch_reconnects_dont_count_test`: 5 reconnects at the
SAME epoch never count as flaps.
All 3 new tests pass; 6 existing `TraceMachina#141` boot_epoch tests still green.
Audit: `.claude/audits/384-exec-log-2026-05-10/server-worker-oom-detection.md`
Q2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tFound (TraceMachina#428 / TraceMachina#410 root-cause) The worker's NotFound→FailedPrecondition predicate at `local_worker.rs:2500` previously keyed on a substring match (`err_msg.contains("not found in")`). This is fragile to upstream error-message changes — any store-layer NotFound whose chained message doesn't contain that exact substring slipped through as `InternalError`, which the scheduler's state-manager retry gate at `simple_scheduler_state_manager.rs:836` does NOT treat as terminal, re-queueing the action up to `max_job_retries` (=3) times. The visible symptom (audit `.claude/audits/410-scheduler-stall-investigation-20260512.md` §3): 20:07:04 stalled_count=3 total_queued=1007 executing=0 20:10:34 stalled_count=51 total_queued=55 executing=0 The queue drains 1007→55 (95%) while the 60s-old `Queued` tail grows 3→51. Workers complete other actions fine; what stays stuck is the single action whose 50.2 MiB blob (`1ea493ea...-52680784`) returns NotFound on every input-fetch attempt. Without a detail-bearing re-tag, the scheduler can't tell the NotFound from a transient failure and keeps re-queueing. Composite invariant: a worker input-fetch NotFound carrying a structural `google.rpc.PreconditionFailure` MISSING violation MUST round-trip as `Code::FailedPrecondition` so the scheduler's `missing_inputs` terminal gate fires on attempt 1. The PreconditionFailure detail is attached at exactly two REAPI-mandated sites (`running_actions_manager.rs:1828` input fetch, `:2735` command fetch — both via `make_precondition_failure_any`), so its presence is a positive identifier of "CAS blob missing for this action." Fix: extract the predicate into `is_cas_blob_miss(err)` which fires on EITHER the structural detail OR the legacy substring (preserved for backward compat per Chesterton's Fence — commit `5b0cb9e8` deliberately narrowed the predicate to avoid translating non-CAS NotFounds like missing binary or output file; both alternatives here uphold that intent because neither produces a PF detail nor the substring). The "cap" in the task title is achieved structurally: once the worker emits `FailedPrecondition`, the scheduler closes the action terminal on attempt 1 instead of cycling `Queued`→assigned→input-fetch-fail →`Queued` up to N times. There is no in-fetch retry loop on the worker to cap; the per-RPC `Retrier` already classifies NotFound as non-retryable (`retry.rs:115`), and `populate_and_hardlink`'s 3-attempt loop guards only post-populate eviction races (not NotFound). The action-level retry budget lives in the scheduler. Seams crossed by the new test: 1. Producer attaches detail: `running_actions_manager.rs:1828` / `:2735` (already shipped pre-TraceMachina#428). 2. Worker re-tag: `local_worker.rs::is_cas_blob_miss` (this commit). 3. Wire-side classifier: `Error::from(tonic::Status)` round-trip via `grpc-status-details-bin` trailer (already shipped). Test + mutation: * New test `not_found_with_precondition_detail_translates_without_substring` crafts a NotFound that carries the PF detail but whose message intentionally omits "not found in" — exercises the structural arm in isolation. Asserts the worker emits an `ExecuteResponse` with `status.code == FailedPrecondition` (not `InternalError`), so the scheduler's `missing_inputs` gate fires. * Mutation step verified: commenting out the `has_pf_detail` arm red-fails the test with the bespoke message "input-fetch NotFound with PreconditionFailure detail must translate to FailedPrecondition for the scheduler missing_inputs terminal gate; got InternalError=... action would re-queue and stall TraceMachina#410 scheduler-tail observed in production". * Existing tests pass unchanged: `cas_not_found_returns_failed_precondition_test` (substring path), `cas_not_found_translation_preserves_details_test`, `worker_translates_not_found_to_failed_precondition_test`, `non_cas_not_found_returns_internal_error_test` (negative case — NotFound without detail or substring stays InternalError). Also: `assert_default_connect_request` test helper updated to strip `build_sha` from the comparison. The field is populated from the running binary's actual SHA (TraceMachina#216) and was breaking all `expect_connect_worker`-using tests in this run environment. Mirrors the strip already present at line ~134 in `new_local_worker_creates_work_directory_test`. Sibling-bug audit (for follow-up, not fixed here): * `nativelink-store/src/grpc_store.rs`: per-RPC Retrier explicitly skips NotFound (`retry.rs:115`); no retry loop to cap. * `nativelink-store/src/existence_cache_store.rs`: NotFound is treated as cache-miss + cleanup; no loop. * `nativelink-store/src/worker_proxy_store.rs:3118`: 2-attempt `for attempt in 0..2` for transient connection errors; does NOT treat NotFound as retryable. * `nativelink-store/src/fast_slow_store.rs::populate_fast_store_unchecked`: 1 retry post-eviction race, then surfaces `Code::Aborted` (not NotFound); does not loop on NotFound. No sibling fixes required; the per-RPC layer already classifies NotFound as terminal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds metrics for running tasks and such to prometheus.
This change is