fix(spurd): refresh node inventory and re-register when it changes - #834
fix(spurd): refresh node inventory and re-register when it changes#834powderluv wants to merge 2 commits into
Conversation
Node resources were discovered once at spurd startup and never refreshed; the 30-second heartbeat carried no resource payload, so a device count that changed out of band (a GPU partition-mode switch, a GPU dropping off the bus) never reached the controller, which kept scheduling against hardware that no longer existed. The WireGuard key is already re-read live on every heartbeat; inventory had no equivalent. Add a periodic re-discovery task: rebuild the device registry (re-running CDI / KFD discovery), recompute the resource set, and when it differs from what was reported, swap the shared registry (so GPU injection uses the new set) and re-register with the controller so it converges on the new inventory. Changes: - reporter: resources behind a RwLock; update_resources() reports real changes; register() snapshots the inventory before its await - main: a 60s inventory-refresh task that re-registers on change - agent_server: read the locked resources in the two node-resource paths Closes #800. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The refresh loop can permanently stop retrying re-registration after a transient register() failure, and allocation state appears to remain stale across inventory changes (risking dispatch failures on inventory increases).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR addresses stale node inventory reporting in spurd by periodically re-discovering local resources and re-registering with spurctld when the inventory changes, so the controller stops scheduling against hardware that no longer exists.
Changes:
- Put
NodeReporter.resourcesbehind anRwLockand addupdate_resources()to swap the inventory only when it changes. - Add a 60s background task in
spurdto rebuild the device registry, re-discover resources, and re-register on changes. - Update agent-side resource reads to acquire the
RwLockand add a unit test forupdate_resources()behavior.
File summaries
| File | Description |
|---|---|
| crates/spurd/src/reporter.rs | Wrap resources in RwLock, add update_resources(), snapshot inventory for registration, and add unit test. |
| crates/spurd/src/main.rs | Add periodic inventory rediscovery and re-registration loop; swap shared device registry on change. |
| crates/spurd/src/agent_server.rs | Update resource reads to use the new RwLock-protected inventory. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let allocation = NodeAllocation::new( | ||
| hostname::get() | ||
| .map(|h| h.to_string_lossy().to_string()) | ||
| .unwrap_or_else(|_| "unknown".into()), | ||
| &reporter.resources, | ||
| &reporter.resources.read().unwrap(), |
| tokio::spawn(async move { | ||
| let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); | ||
| ticker.tick().await; // the first tick fires immediately; skip it | ||
| loop { | ||
| ticker.tick().await; | ||
| let rebuilt = init_device_registry(config.as_ref()); | ||
| let fresh = reporter::discover_resources(&rebuilt); | ||
| if reporter.update_resources(fresh) { | ||
| *registry.lock().await = rebuilt; | ||
| match reporter.register().await { | ||
| Ok(()) => { | ||
| info!("node inventory changed; re-registered with the controller") | ||
| } | ||
| Err(e) => warn!(error = %e, "re-register after inventory change failed"), | ||
| } | ||
| } | ||
| } | ||
| }); |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #834 +/- ##
==========================================
+ Coverage 80.06% 80.52% +0.46%
==========================================
Files 184 186 +2
Lines 89592 92017 +2425
==========================================
+ Hits 71731 74092 +2361
- Misses 17861 17925 +64 🚀 New features to boost your workflow:
|
|
Overlap worth flagging: #809 adds a re-registration path as well, on the operator side ( There is no file conflict, the two live in different crates, so both can merge as they are. The concern is the end state: two independent re-registration mechanisms, with different triggers, different retry behaviour and different logging, in the two agents that both register with the same controller. If both land, it is worth consolidating them into one convergence path. One shape that covers both cases is a heartbeat that carries the inventory, so the controller notices a disagreement or a missing record and asks for a re-register, with |
…ange Addresses review on #834: - A re-register that failed after update_resources() had already committed the new inventory was never retried: the next tick saw no further change and skipped it, so a transient controller hiccup permanently lost the update. Track a pending-reregister flag and keep retrying until it lands. - The local NodeAllocation capacity was frozen at startup, so a node whose inventory GREW out of band (e.g. an SPX->CPX partition change exposing more GPUs) would reject launches for the new devices even after the controller re-learned them via re-register. Add NodeAllocation::update_capacity (resizes cpu/mem, re-indexes GPUs by device id, preserves live allocations) and call it from the refresh task via a new allocation handle. The task moved after AgentService construction so it can reach the allocation. + unit test covering a grow-and-preserve-allocations refresh. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
yansun1996
left a comment
There was a problem hiding this comment.
Thanks — #800 is a real gap and the shape here is right. One suggestion plus a few notes.
Could the apply be gated on whether anything is allocated?
| change seen | action |
|---|---|
| discovery errored | skip the tick |
| seen once | wait for a second identical reading |
| confirmed, nothing allocated | swap registry, update capacity, re-register |
| confirmed, something allocated | drain; don't apply, don't report new totals |
Row 4 self-converges: draining stops new work, the jobs finish, the next tick takes row 3. So there's never an apply-while-running path — which removes most of the notes below instead of needing fixes for them, and collapses update_capacity to a rebuild from the fresh ResourceSet.
Why not apply under a live allocation: the controller can't reconcile it. NodeUpdate sets total_resources and stops — alloc_resources isn't pruned, so can_satisfy_with_allocated stops subtracting the vanished devices and over-reports free GPUs.
That's a missing evidence channel, not a missing calculation. The agent only sends RunningJobStatus { job_id, state, exit_code }, so the controller can only diff totals. The end state worth building toward is the agent re-collecting what each running job still holds and the controller reconciling per job — prune what's genuinely gone, keep the rest, leave healthy jobs alone. The agent already has the data (NodeAllocation.owners is job_id -> {cpu_ids, gpu_ids, memory_mb}), just no wire format for it. Until then drain is the placeholder — so it'd help to keep detection separate from policy here, e.g. refresh_once() returning a classified outcome and a small policy fn acting on it. That also makes both paths testable.
Drain and auto-recovery need no new plumbing. register_node already sees the resource diff and alloc_resources, and cluster.drain_node() returns Draining when jobs are running. Set it as a system drain (admin_locked = false) and lift it when the cause clears, the way check_node_health recovers a node from Down — an operator's own drain stays put. Idle nodes never drain; busy ones resume themselves.
Labels (out of diff). register() sends labels captured at startup. Under default permissive auth the agent is treated as privileged, so a re-register silently reverts an admin relabel; under auth.mode = required it fails PermissionDenied, and reregister_pending never clears — a warning every 60s indefinitely.
Admission (out of diff). validate_admission runs on every register_agent. Harmless under the default open mode, but on a token-mode cluster an expired join token means re-registration can never succeed. A bounded backoff would cover both this and the labels case.
Upgrade compat is clean. No proto change, no new persisted field or WAL variant; NodeUpdate preserves Drain/Down, admin_locked, agent_start_time. Raft growth is bounded — discovery ordering is deterministic. One edge: discover_memory_mb returns 0 on a /proc/meminfo read failure, which would flap and write every tick.
| let mut reregister_pending = false; | ||
| loop { | ||
| ticker.tick().await; | ||
| let rebuilt = init_device_registry(config.as_ref()); |
There was a problem hiding this comment.
I'd treat this as blocking, since everything else depends on how far this signal can be trusted. init_device_registry swallows its errors — the CDI cache warns on parse failures and skips unreadable dirs, autodetect returns an empty vec — so "discovery failed" and "no GPUs" are the same value, and it's committed unconditionally. A driver reload rolling across the fleet would report every node as zero-GPU at once. It's also what makes the cons_tres.rs issues likely rather than rare.
Could discovery return a Result so the tick skips on error, and require two consecutive identical readings before acting? Also worth spawn_blocking here — both calls do synchronous sysfs/proc/CDI reads on a runtime worker, and the startup path already wraps comparable work.
| reregister_pending = true; | ||
| } | ||
| if reregister_pending { | ||
| match reporter.register().await { |
There was a problem hiding this comment.
Reporting a growth is always safe; reporting a shrink while a job still holds the vanished devices is what makes the controller's totals disagree with its own alloc_resources. Note the decision needn't live here — register_node already sees both the resource diff and alloc_resources, so the controller could drain instead, with no new plumbing. Longer term this is the seam where per-job evidence replaces the totals diff, so it's worth returning a classified outcome rather than deciding inline.
| let config = config.clone(); | ||
| let allocation = agent_service.allocation_handle(); | ||
| tokio::spawn(async move { | ||
| let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); |
There was a problem hiding this comment.
The 60s cadence is hardcoded while comparable timings (heartbeat_timeout_secs, agent_keepalive_interval_secs, ...) are config fields — worth an inventory_refresh_secs with #[serde(default)], plus a short docs note since inventory changing on its own is new user-visible behaviour. Minor: interval defaults to MissedTickBehavior::Burst, so delayed ticks fire back-to-back.
| self.total_memory_mb = resources.memory_mb; | ||
|
|
||
| let allocated_ids: std::collections::HashSet<u32> = | ||
| self.allocated_gpu_ids().into_iter().collect(); |
There was a problem hiding this comment.
allocated_gpu_ids() is derived from gpus + gpu_allocated, so seeding from it loses any device not in the current list — a GPU that vanishes on one refresh and returns on the next comes back free while its job is running, and the first job's release then clears the second job's bit. Line 79 is the same shape: resize truncates allocated CPU entries on shrink, so free_cpus() over-reports. Both bite a healthy job on a busy node.
Gating on "nothing allocated" makes both impossible. Otherwise, owners is the documented source of truth:
let allocated_ids: HashSet<u32> =
self.owners.values().flat_map(|a| a.gpu_ids.iter().copied()).collect();| self.gpu_allocated = self | ||
| .gpus | ||
| .iter() | ||
| .map(|g| allocated_ids.contains(&g.device_id)) |
There was a problem hiding this comment.
device_id isn't stable identity — assign_device_ids hands out 0..N positionally over the BDF-sorted registry, so a device added with a lower BDF renumbers everything above it and a partition switch re-enumerates wholesale. It isn't only removals; growth renumbers too. Steps compound it, re-resolving cached ids through the swapped registry into build_job_injection_plans, so a running job's srun step can bind to the wrong device. Only applying when nothing is allocated avoids needing a durable key here; PCI BDF would be the key if you want per-job precision later.
| fn test_update_capacity_grows_and_preserves_allocations() { | ||
| // Start with 4 GPUs, allocate two of them, then an inventory refresh | ||
| // reveals 8 (e.g. SPX->CPX). The new GPUs become schedulable while the | ||
| // two already allocated stay allocated (by device id). |
There was a problem hiding this comment.
This covers growth on an idle node — the safe case. The gap is the allocated one: if the refresh becomes gated, assert a confirmed change is a local no-op while a job holds a device and applies once it releases. (Minor: assert_eq!(node.free_memory_mb(), 256_000 - node.allocated_memory_mb) reduces to asserting total_memory_mb == 256_000; asserting that directly reads more clearly.)
| /// job is untouched (it releases normally). Without this the agent's local | ||
| /// capacity stays frozen at startup and rejects launches for GPUs that only | ||
| /// appeared later (e.g. an SPX->CPX partition change), even after the | ||
| /// controller has learned about them via re-register. |
There was a problem hiding this comment.
This runs to 8 lines where the repo keeps comments to 1-2 lines of why, and the last sentence narrates the fix — same text is repeated on allocation_handle(). Two claims are also inaccurate: the id follows the slot, not the device; and while the release itself is fine, the vanish-then-return path can hand the device to a second job first. Same length note for main.rs 425-431 and 440-443 (the latter reflowed into a trailing-comment continuation at column 34), reporter.rs 34-36, agent_server.rs 734-737, and the test comment at cons_tres.rs 354-356.
| } | ||
|
|
||
| #[test] | ||
| fn update_resources_reports_only_real_changes() { |
There was a problem hiding this comment.
This only varies cpus. Since change detection now drives a controller-side write, cases varying gpus alone and generic alone would be worth adding — that's where an unstable discovery order would show up as a re-register every tick.
Summary
Node resources were discovered once at spurd startup and never refreshed, and the 30s heartbeat carried no resource payload — so a device count that changed out of band (a GPU partition-mode switch, a GPU dropping off the bus) never reached the controller, which kept scheduling against hardware that no longer existed (issue #800). The WireGuard key is already re-read live each heartbeat; inventory had no equivalent.
Fix
A periodic (60s) re-discovery task: rebuild the device registry (re-running CDI / KFD discovery), recompute the
ResourceSet, and when it differs from what was reported, swap the shared registry (so GPU injection uses the new set) and re-register with the controller so it converges on the new inventory.resourcesmoves behind aRwLock;update_resources()returns whether it actually changed;register()snapshots the inventory before its await.Testing
update_resourcesreports a real change and no-ops an unchanged set.Closes #800.
🤖 Generated with Claude Code