Skip to content

Commit d473e23

Browse files
Yash0270facebook-github-bot
authored andcommitted
Publish sdiag to perfpipe_fair_sdiag_v2 via graph_api
Summary: Routes Slurm `sdiag` scrapes into a Scuba-backed perfpipe category (`perfpipe_fair_sdiag_v2`, namespace `ai_infra`, oncall `fair_efficiency`) via the existing `graph_api` exporter. Same shape as every other `perfpipe_fair_*` category (node_data, job_data, sacct_running, sacct, scontrol_data): graph_api writes ODS metrics and scribe_logs; scribe routes per-category to a Scuba dataset of the same name. No new exporter. Three code changes: 1. **`Sdiag` dataclass + `SlurmCliClient.sdiag_structured()` extended** to capture the ~50 fields exposed by `sdiag --all --json` on Slurm 23.2+ that were previously dropped: schedule cycle depth/last, backfill cycle/depth/queue-length/table-size, timing metadata (`req_time`, `gettimeofday_latency`, `job_states_ts`, `parts_packed`), RPC blobs (`rpcs_by_message_type_json`, `rpcs_by_user_json`) as flat JSON strings. `bf_active` is `Optional[bool]` so the JSON serializer emits real `true`/`false`. 2. **`sdiag_scribe_category` kwarg threaded through `GraphAPI.__init__`** (alongside the existing `node_scribe_category`, `job_scribe_category`, `statvfs_scribe_category`, `pure_scribe_category`). `DataIdentifier.SDIAG` is registered in `monitoring/sink/protocol.py`; `CliObject.last_sdiag` cache + `collect_sdiag` generator added in `monitoring/cli/slurm_monitor.py`. A second tuple in `data_collection_tasks` schedules sdiag LOG writes on the same 5-min cycle as the existing METRIC tasks, reusing the per-cycle scrape (no extra slurmctld load). `last_sdiag` is cleared before each scrape so a raised scrape cannot republish stale data; the dual-task is no-op when `last_sdiag is None`. 3. **`meta_utils/scribe.py::ScribeErrorWithAcks` surfaces raw reject codes.** Before: `"Failed to write N/N messages"`. After: `"Failed to write N/N messages; reject codes: ['INVALID_CATEGORY']"`. Three lines, no behavior change on success; applies to every graph_api LOG path. Ride-along (`exporters/otel.py`): OTLP handler moved from the `"gcm"` parent logger to a child `"_gcm_otel_emit"` with `propagate=False`, so OTLP-emit validation errors no longer mirror into Scuba LOG rows as `body="Missing field_name=..."`. Standalone hygiene fix. Docs: `website/docs/GCM_Monitoring/collectors/slurm_monitor.md` documents the dual-publish (METRIC->ODS + LOG->scribe via `DataIdentifier.SDIAG`) and the `last_sdiag` single-scrape caching pattern. `exporters/graph_api.md` gains a per-`DataIdentifier` routing table covering all six `*_scribe_category` kwargs plus the unset-category assertion behavior. Reviewed By: yonglimeta Differential Revision: D95971502
1 parent 15b7c36 commit d473e23

15 files changed

Lines changed: 697 additions & 100 deletions

File tree

gcm/exporters/graph_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def __init__(
5656
job_scribe_category: Optional[str] = None,
5757
statvfs_scribe_category: Optional[str] = None,
5858
pure_scribe_category: Optional[str] = None,
59+
sdiag_scribe_category: Optional[str] = None,
5960
ods_entity: Optional[str | int] = None,
6061
scribe_write: ScribeWrite = write_messages,
6162
):
@@ -65,6 +66,7 @@ def __init__(
6566
DataIdentifier.NODE: node_scribe_category,
6667
DataIdentifier.STATVFS: statvfs_scribe_category,
6768
DataIdentifier.PURE: pure_scribe_category,
69+
DataIdentifier.SDIAG: sdiag_scribe_category,
6870
}
6971
self.scribe_write = scribe_write
7072
if ods_entity is None:

gcm/exporters/otel.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,15 @@ def __init__(
119119
self._logger_provider = otel_log_init(
120120
log_resource_attributes, endpoint + "/v1/logs", timeout
121121
)
122-
self.otel_logger = logging.getLogger("gcm")
122+
# Use a dedicated, isolated logger for sink emits. Attaching the
123+
# LoggingHandler to "gcm" makes EVERY `gcm.*` log record (e.g.
124+
# `gcm.monitoring.dataclass_utils.logger.warning(...)`) propagate up
125+
# and fire the otel handler, polluting the target Scuba table with
126+
# null-data log rows. The leaf logger name + propagate=False keeps
127+
# only explicit `self.otel_logger.info("", extra=...)` emits in the
128+
# otel pipeline.
129+
self.otel_logger = logging.getLogger("_gcm_otel_emit")
130+
self.otel_logger.propagate = False
123131
otel_handler = LoggingHandler(
124132
level=logging._nameToLevel["INFO"], logger_provider=self._logger_provider
125133
)

gcm/monitoring/cli/slurm_monitor.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,14 @@
4040
ClockImpl,
4141
unixtime_to_pacific_datetime,
4242
)
43-
from gcm.monitoring.sink.protocol import DataType, SinkAdditionalParams, SinkImpl
43+
from gcm.monitoring.sink.protocol import (
44+
DataIdentifier,
45+
DataType,
46+
SinkAdditionalParams,
47+
SinkImpl,
48+
)
4449
from gcm.monitoring.sink.utils import Factory, HasRegistry
45-
from gcm.monitoring.slurm.client import SlurmCliClient
50+
from gcm.monitoring.slurm.client import SlurmCliClient, SlurmClient
4651
from gcm.monitoring.slurm.constants import RUNNING_JOB_STATES
4752
from gcm.monitoring.slurm.derived_cluster import get_derived_cluster
4853
from gcm.monitoring.slurm.sacct import parse_slurm_jobs
@@ -66,6 +71,7 @@
6671
)
6772
from gcm.monitoring.utils.monitor import run_data_collection_loop
6873
from gcm.schemas.slurm.sdiag import Sdiag
74+
from gcm.schemas.slurm.sdiag_log import SdiagLog
6975
from gcm.schemas.slurm.sinfo import Sinfo
7076
from gcm.schemas.slurm.sinfo_cpus_gpus import SinfoCpusGpus
7177
from gcm.schemas.slurm.sinfo_node import SinfoNode
@@ -242,11 +248,22 @@ def clock(self) -> Clock: ...
242248

243249
def cluster(self) -> str: ...
244250

251+
@property
252+
def slurm_client(self) -> SlurmClient: ...
253+
254+
# Per-cycle cache populated by collect_slurm and consumed by collect_sdiag
255+
# so the sdiag scrape is shared (no double-scrape, no reset race).
256+
last_sdiag: Optional[Sdiag]
257+
245258

246259
@dataclass
247260
class CliObjectImpl:
248261
clock: Clock = field(default_factory=ClockImpl)
262+
slurm_client: SlurmClient = field(default_factory=SlurmCliClient)
249263
registry: Mapping[str, Factory[SinkImpl]] = field(default_factory=lambda: registry)
264+
# Set by collect_slurm at the start of each cycle, read by collect_sdiag.
265+
# Single-cluster per process, so no per-cluster keying needed.
266+
last_sdiag: Optional[Sdiag] = None
250267

251268
def cluster(self) -> str:
252269
return clusterscope.cluster()
@@ -265,10 +282,18 @@ def collect_slurm(
265282
"""Collect all the relevant slurm metrics that will be stored on ODS"""
266283
end_time = unixtime_to_pacific_datetime(obj.clock.unixtime())
267284
start_time = end_time - timedelta(seconds=interval)
268-
slurm_client = SlurmCliClient()
285+
slurm_client = obj.slurm_client
286+
287+
# Invalidate the cache up-front so a scrape failure below cannot let
288+
# collect_sdiag re-publish a stale prior-cycle sdiag as a fresh row.
289+
obj.last_sdiag = None
269290

270291
sinfo = slurm_client.sinfo_structured()
271292
sdiag = slurm_client.sdiag_structured()
293+
# Stash for collect_sdiag (same cycle, runs after this task). sdiag is a
294+
# cluster-wide slurmctld stat -- one value per scrape, no per-partition
295+
# variation -- so a single attr is sufficient.
296+
obj.last_sdiag = sdiag
272297

273298
if heterogeneous_cluster_v1:
274299
nodes_per_partition: dict[str, list[SinfoNode]] = defaultdict(list)
@@ -309,6 +334,28 @@ def collect_slurm(
309334
yield from slurm_log
310335

311336

337+
def collect_sdiag(
338+
obj: CliObject,
339+
cluster: str,
340+
logger: logging.Logger,
341+
) -> Generator[SdiagLog, None, None]:
342+
"""Project the most recent sdiag scrape (cached on the CliObject by
343+
`collect_slurm`) to an SdiagLog row for the Scuba `perfpipe_fair_sdiag_v2` dataset.
344+
345+
Single sdiag scrape per cycle is shared with `collect_slurm` -- no
346+
double-scrape, no reset-counter race. sdiag is a cluster-wide slurmctld
347+
stat so we yield exactly one SdiagLog per cycle (not one per partition).
348+
"""
349+
sdiag = obj.last_sdiag
350+
if sdiag is None:
351+
# collect_slurm hasn't populated the cache yet (first cycle race or
352+
# collect_slurm raised). Skip rather than crash the loop -- the next
353+
# cycle will recover.
354+
logger.debug("collect_sdiag: no cached sdiag scrape available, skipping")
355+
return
356+
yield SdiagLog(cluster=cluster, derived_cluster=cluster, **asdict(sdiag))
357+
358+
312359
@click_default_cmd(
313360
context_settings={
314361
"obj": _default_obj,
@@ -356,6 +403,11 @@ def collect_slurm_callable(
356403
heterogeneous_cluster_v1=heterogeneous_cluster_v1,
357404
)
358405

406+
def collect_sdiag_callable(
407+
cluster: str, interval: int, logger: logging.Logger
408+
) -> Generator[SdiagLog, None, None]:
409+
return collect_sdiag(obj=obj, cluster=cluster, logger=logger)
410+
359411
run_data_collection_loop(
360412
logger_name=LOGGER_NAME,
361413
log_folder=log_folder,
@@ -366,13 +418,26 @@ def collect_slurm_callable(
366418
once=once,
367419
interval=interval,
368420
data_collection_tasks=[
421+
# Task A: SLURMLog -> METRIC -> ODS via graph_api._write_metric.
422+
# Populates obj.last_sdiag for Task B.
369423
(
370424
collect_slurm_callable,
371425
SinkAdditionalParams(
372426
data_type=DataType.METRIC,
373427
heterogeneous_cluster_v1=heterogeneous_cluster_v1,
374428
),
375429
),
430+
# Task B: SdiagLog -> LOG -> scribe (perfpipe_fair_sdiag_v2) ->
431+
# Scuba (perfpipe_fair_sdiag_v2) via graph_api._write_log. Reads cached
432+
# sdiag from Task A. Mirrors the slurm_job_monitor pattern of
433+
# per-task DataIdentifier-driven scribe routing.
434+
(
435+
collect_sdiag_callable,
436+
SinkAdditionalParams(
437+
data_type=DataType.LOG,
438+
data_identifier=DataIdentifier.SDIAG,
439+
),
440+
),
376441
],
377442
sink=sink,
378443
sink_opts=sink_opts,

gcm/monitoring/meta_utils/scribe.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,10 @@ def try_write_logs(
108108
num_failed = sum(not ack for ack in acks)
109109
if num_failed == 0:
110110
return
111+
unique_codes = sorted({code for code in response_codes.values() if code != "OK"})
111112
raise ScribeErrorWithAcks(
112-
f"Failed to write {num_failed}/{len(logs)} messages", acks=acks
113+
f"Failed to write {num_failed}/{len(logs)} messages; reject codes: {unique_codes}",
114+
acks=acks,
113115
)
114116

115117

gcm/monitoring/sink/protocol.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class DataIdentifier(Enum):
2222
GENERIC = auto()
2323
K8S_POD = auto()
2424
K8S_NODE = auto()
25+
SDIAG = auto()
2526

2627

2728
@dataclass

gcm/monitoring/slurm/client.py

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import (
1212
Any,
1313
Callable,
14+
cast,
1415
Generator,
1516
Hashable,
1617
Iterable,
@@ -199,47 +200,122 @@ def sdiag_structured(self) -> Sdiag:
199200
subprocess.check_output(["sdiag", "--all", "--json"], text=True)
200201
)
201202
stats = sdiag_output["statistics"]
203+
204+
# Extract nested objects (use `or {}` to handle both missing keys
205+
# and explicit None values from sdiag).
202206
schedule_exit = stats.get("schedule_exit") or {}
203207
bf_exit = stats.get("bf_exit") or {}
204208

209+
# Extract timestamp fields (they have {set, infinite, number} structure).
210+
# Use `or {}` to handle both missing keys AND explicit `null` values
211+
# from sdiag JSON; `.get(key, {})` alone returns None when the key
212+
# is present but null, and `.get("set")` then raises AttributeError.
213+
req_time_obj = stats.get("req_time") or {}
214+
req_time_start_obj = stats.get("req_time_start") or {}
215+
job_states_ts_obj = stats.get("job_states_ts") or {}
216+
bf_when_last_cycle_obj = stats.get("bf_when_last_cycle") or {}
217+
218+
# Serialize RPCs to JSON strings
219+
rpcs_by_message_type = stats.get("rpcs_by_message_type", [])
220+
rpcs_by_user = stats.get("rpcs_by_user", [])
221+
205222
result = Sdiag(
223+
# Required fields
206224
server_thread_count=stats.get("server_thread_count"),
207225
agent_queue_size=stats.get("agent_queue_size"),
208226
agent_count=stats.get("agent_count"),
209227
agent_thread_count=stats.get("agent_thread_count"),
210228
dbd_agent_queue_size=stats.get("dbd_agent_queue_size"),
229+
# Schedule cycle
211230
schedule_cycle_max=stats.get("schedule_cycle_max"),
212231
schedule_cycle_mean=stats.get("schedule_cycle_mean"),
213232
schedule_cycle_sum=stats.get("schedule_cycle_sum"),
214233
schedule_cycle_total=stats.get("schedule_cycle_total"),
215234
schedule_cycle_per_minute=stats.get("schedule_cycle_per_minute"),
216235
schedule_queue_length=stats.get("schedule_queue_length"),
236+
schedule_cycle_last=stats.get("schedule_cycle_last"),
237+
schedule_cycle_mean_depth=stats.get("schedule_cycle_mean_depth"),
238+
schedule_cycle_depth=stats.get("schedule_cycle_depth"),
239+
# Schedule exit
240+
schedule_exit_end_job_queue=schedule_exit.get("end_job_queue"),
241+
schedule_exit_default_queue_depth=schedule_exit.get(
242+
"default_queue_depth"
243+
),
244+
schedule_exit_max_job_start=schedule_exit.get("max_job_start"),
245+
schedule_exit_max_rpc_cnt=schedule_exit.get("max_rpc_cnt"),
246+
schedule_exit_max_sched_time=schedule_exit.get("max_sched_time"),
247+
schedule_exit_licenses=schedule_exit.get("licenses"),
248+
# Job stats
217249
sdiag_jobs_submitted=stats.get("jobs_submitted"),
218250
sdiag_jobs_started=stats.get("jobs_started"),
219251
sdiag_jobs_completed=stats.get("jobs_completed"),
220252
sdiag_jobs_canceled=stats.get("jobs_canceled"),
221253
sdiag_jobs_failed=stats.get("jobs_failed"),
222254
sdiag_jobs_pending=stats.get("jobs_pending"),
223255
sdiag_jobs_running=stats.get("jobs_running"),
256+
# Backfill stats
224257
bf_backfilled_jobs=stats.get("bf_backfilled_jobs"),
258+
bf_last_backfilled_jobs=stats.get("bf_last_backfilled_jobs"),
259+
bf_backfilled_het_jobs=stats.get("bf_backfilled_het_jobs"),
260+
bf_cycle_counter=stats.get("bf_cycle_counter"),
225261
bf_cycle_mean=stats.get("bf_cycle_mean"),
226262
bf_cycle_sum=stats.get("bf_cycle_sum"),
227263
bf_cycle_max=stats.get("bf_cycle_max"),
264+
bf_cycle_last=stats.get("bf_cycle_last"),
265+
bf_depth_mean=stats.get("bf_depth_mean"),
266+
bf_depth_mean_try=stats.get("bf_depth_mean_try"),
267+
bf_depth_sum=stats.get("bf_depth_sum"),
268+
bf_depth_try_sum=stats.get("bf_depth_try_sum"),
269+
bf_last_depth=stats.get("bf_last_depth"),
270+
bf_last_depth_try=stats.get("bf_last_depth_try"),
228271
bf_queue_len=stats.get("bf_queue_len"),
229-
schedule_exit_end_job_queue=schedule_exit.get("end_job_queue"),
230-
schedule_exit_default_queue_depth=schedule_exit.get(
231-
"default_queue_depth"
272+
bf_queue_len_mean=stats.get("bf_queue_len_mean"),
273+
bf_queue_len_sum=stats.get("bf_queue_len_sum"),
274+
bf_table_size=stats.get("bf_table_size"),
275+
bf_table_size_sum=stats.get("bf_table_size_sum"),
276+
bf_table_size_mean=stats.get("bf_table_size_mean"),
277+
bf_when_last_cycle=(
278+
bf_when_last_cycle_obj.get("number")
279+
if bf_when_last_cycle_obj.get("set")
280+
else None
232281
),
233-
schedule_exit_max_job_start=schedule_exit.get("max_job_start"),
234-
schedule_exit_max_rpc_cnt=schedule_exit.get("max_rpc_cnt"),
235-
schedule_exit_max_sched_time=schedule_exit.get("max_sched_time"),
236-
schedule_exit_licenses=schedule_exit.get("licenses"),
282+
bf_active=(
283+
bool(stats.get("bf_active"))
284+
if stats.get("bf_active") is not None
285+
else None
286+
),
287+
# Backfill exit
237288
bf_exit_end_job_queue=bf_exit.get("end_job_queue"),
238289
bf_exit_max_job_start=bf_exit.get("bf_max_job_start"),
239290
bf_exit_max_job_test=bf_exit.get("bf_max_job_test"),
240291
bf_exit_max_time=bf_exit.get("bf_max_time"),
241292
bf_exit_node_space_size=bf_exit.get("bf_node_space_size"),
242293
bf_exit_state_changed=bf_exit.get("state_changed"),
294+
# Timing
295+
req_time=(
296+
req_time_obj.get("number") if req_time_obj.get("set") else None
297+
),
298+
req_time_start=(
299+
req_time_start_obj.get("number")
300+
if req_time_start_obj.get("set")
301+
else None
302+
),
303+
gettimeofday_latency=stats.get("gettimeofday_latency"),
304+
job_states_ts=(
305+
job_states_ts_obj.get("number")
306+
if job_states_ts_obj.get("set")
307+
else None
308+
),
309+
parts_packed=stats.get("parts_packed"),
310+
# JSON blobs
311+
rpcs_by_message_type_json=(
312+
json.dumps(rpcs_by_message_type)
313+
if rpcs_by_message_type is not None
314+
else "[]"
315+
),
316+
rpcs_by_user_json=(
317+
json.dumps(rpcs_by_user) if rpcs_by_user is not None else "[]"
318+
),
243319
)
244320

245321
# Reset sdiag counters after collection
@@ -255,6 +331,9 @@ def sdiag_structured(self) -> Sdiag:
255331
"Agent thread count:": "agent_thread_count",
256332
"DBD Agent queue size:": "dbd_agent_queue_size",
257333
}
334+
# Legacy (slurm < 23.2) text-parse path only emits int/None values;
335+
# the new JSON-blob and string-typed Sdiag fields are only populated
336+
# via the slurm >= 23.2 JSON branch above. Keep the type narrow.
258337
data: dict[str, Optional[int]] = {
259338
"server_thread_count": 0,
260339
"agent_queue_size": 0,
@@ -296,10 +375,17 @@ def sdiag_structured(self) -> Sdiag:
296375
else:
297376
data[name] = None
298377

378+
# Cast to Any for the splat: mypy cannot reconcile dict[str, int|None]
379+
# values against Sdiag's mixed int|bool|str|None fields without per-key
380+
# checks. At runtime this path only emits int/None values; non-int
381+
# Sdiag fields (bf_active, rpcs_by_*_json) fall back to their None
382+
# defaults.
383+
result = Sdiag(**cast(dict[str, Any], data))
384+
299385
# Reset sdiag counters after collection
300386
self._reset_sdiag_counters()
301387

302-
return Sdiag(**data)
388+
return result
303389

304390
def _reset_sdiag_counters(self) -> None:
305391
"""Reset sdiag counters after collection.

0 commit comments

Comments
 (0)