Skip to content

Add metrics to SimpleScheduler and Worker - #216

Merged
allada merged 1 commit into
masterfrom
prometheus-simple-scheduler
Jul 27, 2023
Merged

allada merged 1 commit into
masterfrom
prometheus-simple-scheduler

Conversation

@allada

@allada allada commented Jul 21, 2023 •

Copy link
Copy Markdown
Contributor

Adds metrics for running tasks and such to prometheus.


This change is Reviewable

@allada allada left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+@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)

@allada
allada force-pushed the prometheus-simple-scheduler branch 2 times, most recently from 6b277e1 to ebcde03 Compare July 21, 2023 03:42
@allada

allada commented Jul 21, 2023

Copy link
Copy Markdown
Contributor Author

cc: @chrisstaite-menlo, this should give you the metrics you were wanting.

@aaronmondal aaronmondal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 allada left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 chrisstaite-menlo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 allada left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_metrics on 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.

@chrisstaite-menlo

Copy link
Copy Markdown
Collaborator

cas/scheduler/default_scheduler_factory.rs line 105 at r1 (raw file):

Previously, allada (Nathan (Blaise) Bruer) wrote…

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.

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 allada left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 chrisstaite-menlo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewable status: all files reviewed, 2 unresolved discussions (waiting on @aaronmondal)

Adds metrics for running tasks and such to prometheus.
@allada
allada force-pushed the prometheus-simple-scheduler branch from ebcde03 to 11c5d0f Compare July 27, 2023 15:24

@allada allada left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 4 of 4 files at r2, all commit messages.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on @aaronmondal)

@allada
allada merged commit b1b212b into master Jul 27, 2023
@allada
allada deleted the prometheus-simple-scheduler branch July 27, 2023 18:36
rejuvenile added a commit to rejuvenile/nativelink that referenced this pull request Aug 19, 2026
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>
rejuvenile added a commit to rejuvenile/nativelink that referenced this pull request Aug 19, 2026
… 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>
rejuvenile added a commit to rejuvenile/nativelink that referenced this pull request Aug 19, 2026
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.
rejuvenile added a commit to rejuvenile/nativelink that referenced this pull request Aug 19, 2026
…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>
rejuvenile added a commit to rejuvenile/nativelink that referenced this pull request Aug 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants