gpu-usage-audit 1.1.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/CHANGELOG.md +11 -0
  2. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/PKG-INFO +1 -1
  3. gpu_usage_audit-1.3.0/docs/work-specs/0002-daemon-cloud-mode.ko.md +43 -0
  4. gpu_usage_audit-1.3.0/docs/work-specs/0003-collection-status-emit.ko.md +50 -0
  5. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/pyproject.toml +1 -1
  6. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/__main__.py +122 -7
  7. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/cloud/snapshot.py +29 -0
  8. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/daemon.py +18 -3
  9. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/nvml.py +16 -0
  10. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_cloud_cli.py +196 -0
  11. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_cloud_snapshot.py +31 -1
  12. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_daemon.py +49 -1
  13. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_nvml.py +35 -0
  14. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/uv.lock +1 -1
  15. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/.github/workflows/ci.yml +0 -0
  16. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/.github/workflows/release.yml +0 -0
  17. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/.gitignore +0 -0
  18. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/LICENSE +0 -0
  19. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/README.ko.md +0 -0
  20. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/README.md +0 -0
  21. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/docs/work-specs/0001-gua-board-cloud-sync.ko.md +0 -0
  22. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/projects/bare-metal-1.0/handoff.ko.md +0 -0
  23. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/projects/bare-metal-1.0/plan.ko.md +0 -0
  24. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/projects/bare-metal-1.0/status.ko.md +0 -0
  25. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/scripts/check-tag-version.py +0 -0
  26. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/scripts/smoke-dist-wheel.sh +0 -0
  27. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/__init__.py +0 -0
  28. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/classify.py +0 -0
  29. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/cloud/__init__.py +0 -0
  30. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/cloud/client.py +0 -0
  31. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/cloud/config.py +0 -0
  32. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/db.py +0 -0
  33. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/doctor.py +0 -0
  34. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/identity.py +0 -0
  35. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/model.py +0 -0
  36. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/paths.py +0 -0
  37. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/render.py +0 -0
  38. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/report.py +0 -0
  39. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/summarize.py +0 -0
  40. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/tier.py +0 -0
  41. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/__init__.py +0 -0
  42. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_classify.py +0 -0
  43. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_cloud_client.py +0 -0
  44. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_cloud_config.py +0 -0
  45. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_db.py +0 -0
  46. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_doctor.py +0 -0
  47. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_identity.py +0 -0
  48. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_render.py +0 -0
  49. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_report.py +0 -0
  50. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_smoke.py +0 -0
  51. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_summarize.py +0 -0
  52. {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_tier.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-06-18
4
+
5
+ - Cloud sync now emits a real `collectionStatus` instead of always reporting
6
+ `ok`. When core GPU metrics are collected but one or more cards' per-process
7
+ list is unavailable (permissions or transient NVML errors), `gua sync-once`
8
+ and `gua daemon --cloud` push `partial` with a `process_list_unavailable`
9
+ error while still sending the GPU data. When NVML initialization fails
10
+ entirely, `gua sync-once` now pushes an `error` heartbeat
11
+ (`nvml_init_failed`, empty GPU inventory) so a host that lost its driver
12
+ still surfaces a non-ok freshness signal on the board, then exits non-zero.
13
+
3
14
  ## 1.1.0 - 2026-06-17
4
15
 
5
16
  - Added optional GUA Board cloud sync. `gua enroll` claims a one-time
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gpu-usage-audit
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Single-host daemon that surfaces 'idle-held' NVIDIA GPU memory — the embarrassing category conventional dashboards miss.
5
5
  Project-URL: Homepage, https://github.com/AI-Ocean/gpu-usage-audit
6
6
  Project-URL: Issues, https://github.com/AI-Ocean/gpu-usage-audit/issues
@@ -0,0 +1,43 @@
1
+ # 0002 Daemon Cloud Mode (continuous push)
2
+
3
+ 상태: draft
4
+ 관련: 0001 (enroll + sync-once)
5
+ 목표: `gua daemon --cloud` — 데몬이 매 틱 local DB 에 기록한 뒤 latest snapshot 을 GUA Board 로 push 한다. 일회성 `sync-once` 를 연속 운영으로 확장(없으면 보드가 stale).
6
+
7
+ ## 배경
8
+
9
+ 0001 에서 enroll + sync-once(1회 수집→local write→push)가 생겼다. 보드가 살아있으려면 호스트가 주기적으로 latest 를 올려야 한다. 기존 `gua daemon` 루프(anti-drift, 시그널 종료, local-write-first)를 재사용해 cloud push 를 얹는다.
10
+
11
+ ## 범위
12
+
13
+ 포함:
14
+
15
+ - `daemon` 에 `--cloud` + `--config` 플래그(두 CLI 파서 모두: `gua`, `gpu-usage-audit`).
16
+ - `daemon.run_daemon`/`_tick` 에 optional `on_tick(snap, ts)` 후크. **daemon 모듈은 cloud 를 import 하지 않는다** — CLI 가 콜백 주입(결합도 분리). 후크는 local write *이후* 호출, 실패해도 로그만 남기고 다음 틱 계속(local-write-first 불변식).
17
+ - `_cmd_daemon`: `--cloud` 면 NVML 열기 *전에* `load_cloud_config` 검증(미enroll → exit 2). push 콜백 = `build_observation_payload` + `post_observation`(0001 재사용).
18
+ - `gua daemon --cloud`(백그라운드)면 spawn 커맨드에 `--cloud --config` 전파.
19
+
20
+ 제외:
21
+
22
+ - pull/명령 채널, 재시도 백오프 정교화, 오프라인 큐잉(실패 틱은 다음 틱이 latest 로 덮음 — replay 안 함).
23
+ - systemd 유닛 패키징(설치 UX 별도).
24
+
25
+ ## Acceptance
26
+
27
+ - `gua daemon --cloud`(enrolled): 매 틱 local 기록 + latest push, 보드에 호스트/GPU 표시.
28
+ - push 실패(네트워크/CloudError/payload ValueError)는 데몬을 멈추지 않고 local 기록도 보존.
29
+ - `--cloud` + 미enroll → exit 2(NVML 열기 전).
30
+ - `--cloud` 없으면 기존 동작 그대로(push 없음).
31
+ - 백그라운드 `gua daemon --cloud` 가 자식 프로세스로 옵션 전파.
32
+
33
+ ## Verification
34
+
35
+ - `tests/test_daemon.py`: `on_tick` 매 틱 local write 이후 호출 + raise 해도 데몬 계속·local 보존.
36
+ - `tests/test_cloud_cli.py`: `--cloud` 미enroll → exit 2(`run \`gua enroll\``); 백그라운드 spawn 커맨드에 `--cloud/--config` 포함.
37
+ - 전체 `pytest` 163 passed, `ruff` clean.
38
+
39
+ ## Implementation Notes
40
+
41
+ - on_tick 후크 타입 `OnTick = Callable[[Snapshot, datetime], None]` (daemon.py).
42
+ - 실패 처리: `_tick` 이 on_tick 을 try/except 로 감싸 `logger.exception` 후 계속(틱 자체는 성공으로 간주).
43
+ - "push latest only, no replay" — 실패 틱을 재전송하지 않고 다음 틱 latest 가 보드를 갱신.
@@ -0,0 +1,50 @@
1
+ # 0003 Collection Status Emit (partial / error)
2
+
3
+ 상태: implemented
4
+ 관련: 0001 (enroll + sync-once, payload builder), 0002 (daemon cloud mode)
5
+ 목표: agent 가 수집이 실제로 저하/실패했을 때 `collectionStatus` 를 `partial`/`error` 로 emit 한다. 지금까지 두 push call site 는 항상 `ok` 만 보냈다. board 측은 partial/error 를 이미 검증·저장하므로 board 변경은 없다 (agent 전용).
6
+
7
+ ## 배경
8
+
9
+ 0001 의 payload builder(`build_observation_payload`)는 `ok`/`partial`/`error` 셋 다 지원·검증한다 (`partial`/`error` 는 errors ≥1, `ok` 는 errors 비어야 함). 하지만 MVP 는 후속으로 미뤄 두 call site(`sync-once`, daemon `push_snapshot`)가 status/errors 를 생략해 항상 `ok` 였다 (0001 Implementation Notes "collectionStatus 매핑").
10
+
11
+ 실제 운영에서 수집은 두 가지로 저하된다:
12
+ - 일부 카드의 process list 가 권한/일시오류로 안 읽힌다 — core GPU metric 은 정상. board 가 idle-held 신호를 *과소평가* 할 수 있으니 `partial` 로 알려야 한다.
13
+ - NVML init 자체가 실패한다(드라이버 손실 등) — GPU inventory 가 아예 없다. crash 로 끝내는 대신 `error` heartbeat 를 보내 board 가 non-ok freshness 로 표시하게 한다.
14
+
15
+ ## 범위
16
+
17
+ 포함:
18
+
19
+ - `NVMLTier.collect()` 가 그 틱에 process list 를 못 읽은 카드를 *틱마다 리셋* 되는 집합으로 추적하고 `last_process_list_unavailable: bool` 로 노출. 기존 누적 warning 집합(반복 로그 억제)과 분리.
20
+ - `cloud.snapshot.derive_collection_status(snapshot, *, process_list_unavailable)` — 수집 결과 → `(status, errors)`. GPU 가 있고 process list 가 비었으면 `partial` + `["process_list_unavailable"]`, 그 외 `ok`. contract 와 같은 곳에 둬 두 call site 가 같은 규칙 공유.
21
+ - 안정적 short error code 상수: `process_list_unavailable`, `nvml_init_failed`.
22
+ - `sync-once`: collect 후 partial 신호를 builder 로 thread. NVML init 실패 시 `error` heartbeat(빈 inventory, `nvml_init_failed`) push 후 non-zero exit. 데이터가 없으므로 local DB 는 쓰지 않는다.
23
+ - daemon `push_snapshot`: 매 틱 `tier.last_process_list_unavailable` 로 partial 도출해 thread (on_tick 은 같은 스레드에서 collect 직후 동기 호출이라 일치).
24
+
25
+ 제외:
26
+
27
+ - daemon 의 `error` heartbeat. daemon 은 NVML 열기 *전에* probe 실패면 즉시 종료하는 구조라(0002 `_cmd_daemon`), error heartbeat 를 끼우려면 cloud config 검증 순서·종료 경로를 재배치해야 한다. 위험 대비 가치가 낮아 보류 — sync-once 의 error heartbeat 로 단발 진단은 가능하다.
28
+ - 새 error code 분류(예: GPU별 temperature/power 실패) — builder 는 음수 sentinel 을 0 으로 눌러 이미 흡수하므로 partial 로 격상하지 않는다.
29
+ - 신규 의존성/version bump/release tag (별도 수동 단계).
30
+
31
+ ## Acceptance
32
+
33
+ - 모든 카드 정상 수집 → `ok`, errors 빈 배열 (기존과 동일).
34
+ - 한 카드라도 process list 가 NVMLError 로 비고 core GPU metric 은 수집됨 → `partial` + `["process_list_unavailable"]`, GPU 데이터는 그대로 push.
35
+ - partial 은 *틱 단위* — 다음 틱에 process list 가 복구되면 `ok` 로 돌아온다.
36
+ - NVML init 실패(`sync-once`) → `error` + `["nvml_init_failed"]` heartbeat push, GPU 빈 배열, local write 없음, non-zero exit. heartbeat push 도 실패하면 두 에러를 모두 보고.
37
+ - 두 call site 가 도출한 status/errors 를 builder 에 넘기며, builder 검증을 그대로 통과(partial/error errors ≥1).
38
+
39
+ ## Verification
40
+
41
+ - `tests/test_nvml.py`: `last_process_list_unavailable` 가 process list 실패 틱에 True, 복구 틱에 False 로 리셋(틱 단위), core GPU metric 유지.
42
+ - `tests/test_cloud_snapshot.py`: `derive_collection_status` ok/partial 도출, GPU 0개면 partial flag 무시(ok), partial 도출값이 builder 검증 통과.
43
+ - `tests/test_cloud_cli.py`: `sync-once` 가 process list 불가 시 `partial` payload + GPU 보존 + local write; NVML init 실패 시 `error` heartbeat(빈 inventory, local write 없음, exit 1); heartbeat push 실패 시 두 에러 모두 보고.
44
+ - 전체 `uv run pytest`: 170 passed (기존 163 + 신규 7). `ruff check` clean, `mypy` clean.
45
+
46
+ ## Implementation Notes
47
+
48
+ - partial 판정은 `derive_collection_status` 한곳 — `sync-once`(`NVMLTier.last_process_list_unavailable`) 와 daemon(같은 tier 인스턴스 closure) 이 공유.
49
+ - error heartbeat 는 `Snapshot()`(빈) 으로 builder 호출 — GPU 0개라 검증 통과하고, errors 비우지 않아 `error` contract 충족. driver_version 은 probe 실패라 `"unknown"`.
50
+ - daemon error heartbeat 는 보류 — 위 "제외" 참조. partial 경로는 daemon·sync-once 양쪽 모두 적용.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gpu-usage-audit"
3
- version = "1.1.0"
3
+ version = "1.3.0"
4
4
  description = "Single-host daemon that surfaces 'idle-held' NVIDIA GPU memory — the embarrassing category conventional dashboards miss."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -34,16 +34,21 @@ from pathlib import Path
34
34
  from . import __version__
35
35
  from .cloud.client import CloudError, claim_enrollment, post_observation
36
36
  from .cloud.config import (
37
+ CloudConfig,
37
38
  CloudConfigError,
38
39
  load_cloud_config,
39
40
  save_cloud_config,
40
41
  )
41
- from .cloud.snapshot import build_observation_payload
42
+ from .cloud.snapshot import (
43
+ ERROR_NVML_INIT_FAILED,
44
+ build_observation_payload,
45
+ derive_collection_status,
46
+ )
42
47
  from .daemon import install_signal_handlers, resolve_proc_identities, run_daemon
43
48
  from .db import open_db, write_snapshot
44
49
  from .doctor import build_doctor_report, doctor_report_to_dict, render_doctor
45
50
  from .identity import system_process_name_lookup, system_user_lookup
46
- from .model import HostMeta
51
+ from .model import HostMeta, Snapshot
47
52
  from .nvml import NVMLNotAvailableError, NVMLTier
48
53
  from .paths import (
49
54
  DEFAULT_CLOUD_CONFIG_PATH,
@@ -121,6 +126,7 @@ def build_parser() -> argparse.ArgumentParser:
121
126
  default=timedelta(seconds=30),
122
127
  help="Tick interval (e.g. 30s, 1m, 200ms) [default: 30s]",
123
128
  )
129
+ _add_cloud_args(p_daemon)
124
130
  p_daemon.set_defaults(func=_cmd_daemon)
125
131
 
126
132
  p_report = sub.add_parser(
@@ -198,6 +204,19 @@ def _add_daemon_args(parser: argparse.ArgumentParser) -> None:
198
204
  )
199
205
 
200
206
 
207
+ def _add_cloud_args(parser: argparse.ArgumentParser) -> None:
208
+ parser.add_argument(
209
+ "--cloud",
210
+ action="store_true",
211
+ help="After each tick, push the latest snapshot to GUA Board (requires `gua enroll`)",
212
+ )
213
+ parser.add_argument(
214
+ "--config",
215
+ default=str(DEFAULT_CLOUD_CONFIG_PATH),
216
+ help=f"Cloud config path (used with --cloud) [default: {DEFAULT_CLOUD_CONFIG_PATH}]",
217
+ )
218
+
219
+
201
220
  def _add_report_args(parser: argparse.ArgumentParser) -> None:
202
221
  parser.add_argument(
203
222
  "--db",
@@ -294,6 +313,7 @@ def build_gua_parser() -> argparse.ArgumentParser:
294
313
  )
295
314
  _add_daemon_args(p_daemon)
296
315
  _add_runtime_file_args(p_daemon)
316
+ _add_cloud_args(p_daemon)
297
317
  p_daemon.add_argument(
298
318
  "--foreground",
299
319
  action="store_true",
@@ -307,6 +327,7 @@ def build_gua_parser() -> argparse.ArgumentParser:
307
327
  )
308
328
  _add_daemon_args(p_start)
309
329
  _add_runtime_file_args(p_start)
330
+ _add_cloud_args(p_start)
310
331
  p_start.set_defaults(func=_cmd_gua_start)
311
332
 
312
333
  p_status = sub.add_parser(
@@ -483,6 +504,8 @@ def _cmd_gua_sync_once(args: argparse.Namespace) -> int:
483
504
  return 2
484
505
 
485
506
  observed_at = datetime.now(UTC)
507
+ hostname = socket.gethostname() or "unknown"
508
+ process_list_unavailable = False
486
509
  if args.fake:
487
510
  tier = FakeTier()
488
511
  driver = tier.probe()
@@ -493,14 +516,19 @@ def _cmd_gua_sync_once(args: argparse.Namespace) -> int:
493
516
  try:
494
517
  driver = nvml_tier.probe()
495
518
  except NVMLNotAvailableError as exc:
496
- print(f"gua sync-once: {exc}", file=sys.stderr)
497
- return 1
519
+ # 드라이버를 잃은 host 도 board 가 non-ok freshness 로 보여줄 수
520
+ # 있게 error heartbeat 를 push 한다 (GPU inventory 없음, local
521
+ # write 도 없음 — 기록할 데이터가 없다).
522
+ return _push_error_heartbeat(config, hostname, observed_at, exc)
498
523
  snap = nvml_tier.collect(observed_at)
524
+ process_list_unavailable = nvml_tier.last_process_list_unavailable
499
525
  resolve_proc_identities(snap.procs, system_user_lookup, system_process_name_lookup)
500
526
  finally:
501
527
  nvml_tier.close()
502
528
 
503
- hostname = socket.gethostname() or "unknown"
529
+ collection_status, errors = derive_collection_status(
530
+ snap, process_list_unavailable=process_list_unavailable
531
+ )
504
532
  host = HostMeta(
505
533
  hostname=hostname,
506
534
  env_kind=LOCAL_ENV_KIND,
@@ -528,6 +556,8 @@ def _cmd_gua_sync_once(args: argparse.Namespace) -> int:
528
556
  observed_at=observed_at,
529
557
  host_id=config.host_id,
530
558
  display_name=config.display_name,
559
+ collection_status=collection_status,
560
+ errors=errors,
531
561
  )
532
562
  except ValueError as exc:
533
563
  print(
@@ -546,14 +576,53 @@ def _cmd_gua_sync_once(args: argparse.Namespace) -> int:
546
576
  )
547
577
  return 1
548
578
 
579
+ status_note = "" if collection_status == "ok" else f" [{collection_status}: {','.join(errors)}]"
549
580
  print(
550
581
  f"gua sync-once: pushed {len(payload['gpus'])} GPUs to "
551
- f"{config.display_name} ({config.server_url})"
582
+ f"{config.display_name} ({config.server_url}){status_note}"
552
583
  )
553
584
  print(f" local snapshot saved to {db_path}")
554
585
  return 0
555
586
 
556
587
 
588
+ def _push_error_heartbeat(
589
+ config: CloudConfig,
590
+ hostname: str,
591
+ observed_at: datetime,
592
+ exc: NVMLNotAvailableError,
593
+ ) -> int:
594
+ """NVML init 실패 시 board 에 `error` heartbeat 만 push 한다 (데이터 없음).
595
+
596
+ GPU inventory 가 비어 있어 local DB 에 쓸 게 없으므로 push 만 한다. push
597
+ 실패는 비치명적 — 에러를 보고하고 non-zero exit 로 신호한다.
598
+ """
599
+ payload = build_observation_payload(
600
+ snapshot=Snapshot(),
601
+ hostname=hostname,
602
+ driver_version="unknown",
603
+ agent_version=__version__,
604
+ observed_at=observed_at,
605
+ host_id=config.host_id,
606
+ display_name=config.display_name,
607
+ collection_status="error",
608
+ errors=[ERROR_NVML_INIT_FAILED],
609
+ )
610
+ try:
611
+ post_observation(config, payload)
612
+ except CloudError as push_exc:
613
+ print(
614
+ f"gua sync-once: {exc}; error heartbeat push also failed: {push_exc}",
615
+ file=sys.stderr,
616
+ )
617
+ return 1
618
+ print(
619
+ f"gua sync-once: {exc}; pushed error heartbeat to "
620
+ f"{config.display_name} ({config.server_url})",
621
+ file=sys.stderr,
622
+ )
623
+ return 1
624
+
625
+
557
626
  def _cmd_gua_daemon(args: argparse.Namespace) -> int:
558
627
  if args.foreground:
559
628
  args.display_command = "gua daemon --foreground"
@@ -598,6 +667,9 @@ def _cmd_gua_start(args: argparse.Namespace) -> int:
598
667
  "--interval",
599
668
  _duration_cli_value(args.interval),
600
669
  ]
670
+ # cloud sync 옵션을 백그라운드 프로세스로 전파한다.
671
+ if getattr(args, "cloud", False):
672
+ command += ["--cloud", "--config", str(args.config)]
601
673
  env = os.environ.copy()
602
674
  env[DISPLAY_COMMAND_ENV] = "gua daemon --foreground"
603
675
  with log_path.open("ab") as log:
@@ -712,6 +784,16 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
712
784
  return 2
713
785
  if is_default_db_path(db_path):
714
786
  db_path.parent.mkdir(parents=True, exist_ok=True)
787
+
788
+ # cloud sync 가 켜졌으면 enroll 설정을 *먼저* 검증한다(미enroll 이면 NVML 열기 전에 중단).
789
+ cloud_config = None
790
+ if getattr(args, "cloud", False):
791
+ try:
792
+ cloud_config = load_cloud_config(args.config)
793
+ except CloudConfigError as exc:
794
+ print(f"{display_command}: {exc}", file=sys.stderr)
795
+ return 2
796
+
715
797
  tier = NVMLTier()
716
798
  try:
717
799
  try:
@@ -721,12 +803,44 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
721
803
  return 1
722
804
  conn = open_db(db_path)
723
805
  try:
806
+ hostname = socket.gethostname() or "unknown"
724
807
  host = HostMeta(
725
- hostname=socket.gethostname() or "unknown",
808
+ hostname=hostname,
726
809
  env_kind=LOCAL_ENV_KIND,
727
810
  driver_version=driver,
728
811
  first_seen=datetime.now(UTC),
729
812
  )
813
+
814
+ # cloud on_tick 후크: local write 이후 latest snapshot 을 board 로 push.
815
+ # 빌드/푸시 실패는 daemon._tick 이 잡아 로그만 남기고 다음 틱을 계속한다.
816
+ on_tick = None
817
+ if cloud_config is not None:
818
+
819
+ def push_snapshot(snap: Snapshot, ts: datetime) -> None:
820
+ # on_tick 은 같은 데몬 스레드에서 tier.collect() 직후 동기로
821
+ # 호출되므로, tier 의 last-collect 상태가 이 snap 과 일치한다.
822
+ collection_status, errors = derive_collection_status(
823
+ snap, process_list_unavailable=tier.last_process_list_unavailable
824
+ )
825
+ payload = build_observation_payload(
826
+ snapshot=snap,
827
+ hostname=hostname,
828
+ driver_version=driver,
829
+ agent_version=__version__,
830
+ observed_at=ts,
831
+ host_id=cloud_config.host_id,
832
+ display_name=cloud_config.display_name,
833
+ collection_status=collection_status,
834
+ errors=errors,
835
+ )
836
+ post_observation(cloud_config, payload)
837
+
838
+ on_tick = push_snapshot
839
+ print(
840
+ f"{display_command}: cloud sync enabled -> "
841
+ f"{cloud_config.display_name} ({cloud_config.server_url})"
842
+ )
843
+
730
844
  stop = threading.Event()
731
845
  install_signal_handlers(stop)
732
846
  run_daemon(
@@ -737,6 +851,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
737
851
  lookup=system_user_lookup,
738
852
  name_lookup=system_process_name_lookup,
739
853
  stop=stop,
854
+ on_tick=on_tick,
740
855
  )
741
856
  total = conn.execute("SELECT COUNT(*) FROM gpu_sample").fetchone()[0]
742
857
  print(f"\n{args.db}: {total} total gpu_sample rows")
@@ -21,6 +21,35 @@ from . import SCHEMA_VERSION
21
21
  CollectionStatus = str # "ok" | "partial" | "error" — 서버가 enum 검증.
22
22
  _UNKNOWN = "unknown"
23
23
 
24
+ # 안정적인 short error code — 서버/board 가 freshness 신호로 표시한다.
25
+ # 새 code 는 여기 한곳에 모아 builder/CLI/daemon 이 같은 문자열을 공유한다.
26
+ ERROR_PROCESS_LIST_UNAVAILABLE = "process_list_unavailable"
27
+ ERROR_NVML_INIT_FAILED = "nvml_init_failed"
28
+
29
+
30
+ def derive_collection_status(
31
+ snapshot: Snapshot,
32
+ *,
33
+ process_list_unavailable: bool = False,
34
+ ) -> tuple[CollectionStatus, list[str]]:
35
+ """수집 결과에서 `(collectionStatus, errors)` 를 도출한다.
36
+
37
+ contract 와 같은 곳에 둬서 CLI(`sync-once`)/daemon 두 call site 가 같은
38
+ 규칙을 공유한다. builder 의 검증(`partial`/`error` 는 errors ≥1, `ok` 는
39
+ errors 비어야 함)과 일관되게 만든다.
40
+
41
+ - GPU 가 하나라도 수집되고 일부 카드의 process list 만 권한/일시오류로
42
+ 비었으면 → `partial` + `process_list_unavailable`. core GPU metric 은
43
+ 그대로 push 한다.
44
+ - 그 외(모든 카드 정상, 또는 애초에 GPU 0개여도 push 흐름 자체는 정상) → `ok`.
45
+
46
+ NVML init 자체가 실패해 GPU inventory 가 아예 없는 `error` heartbeat 는
47
+ 수집을 못 한 상황이라 이 함수가 아니라 call site 에서 직접 구성한다.
48
+ """
49
+ if snapshot.gpus and process_list_unavailable:
50
+ return "partial", [ERROR_PROCESS_LIST_UNAVAILABLE]
51
+ return "ok", []
52
+
24
53
 
25
54
  def build_observation_payload(
26
55
  *,
@@ -22,13 +22,16 @@ from datetime import UTC, datetime, timedelta
22
22
  from typing import TextIO
23
23
 
24
24
  from .db import start_daemon_run, write_snapshot
25
- from .model import HostMeta, ProcSample
25
+ from .model import HostMeta, ProcSample, Snapshot
26
26
  from .summarize import summarize
27
27
  from .tier import Tier
28
28
 
29
29
  logger = logging.getLogger(__name__)
30
30
 
31
31
  UserLookup = Callable[[int], str | None]
32
+ # 틱 후크: local write *이후* 의 부가 작업(예: cloud push). 데몬 모듈은 cloud 를
33
+ # 모르고, CLI 가 콜백을 주입한다 — 결합도 분리.
34
+ OnTick = Callable[[Snapshot, datetime], None]
32
35
 
33
36
 
34
37
  def _noop_lookup(_pid: int) -> str | None:
@@ -78,8 +81,13 @@ def _tick(
78
81
  n: int,
79
82
  out: TextIO,
80
83
  run_id: int,
84
+ on_tick: OnTick | None = None,
81
85
  ) -> None:
82
- """한 틱: tier.collect → loginuid/process_name 해석 → 적재 → 한 줄 로그."""
86
+ """한 틱: tier.collect → loginuid/process_name 해석 → 적재 → 한 줄 로그.
87
+
88
+ on_tick 이 있으면 local write *이후* 호출한다(예: cloud push). 후크 실패는
89
+ 이미 커밋된 local write 와 다음 틱을 막지 않는다 — 로그만 남기고 계속.
90
+ """
83
91
  snap = tier.collect(ts)
84
92
  # ProcSample 이 mutable slots — *제자리* 갱신.
85
93
  resolve_proc_identities(snap.procs, lookup, name_lookup)
@@ -89,6 +97,12 @@ def _tick(
89
97
  ts_short = ts.strftime("%H:%M:%S.") + f"{ts.microsecond // 1000:03d}"
90
98
  print(f"Tick {n} ts={ts_short} {classes}", file=out)
91
99
 
100
+ if on_tick is not None:
101
+ try:
102
+ on_tick(snap, ts)
103
+ except Exception:
104
+ logger.exception("tick %d on_tick hook failed; continuing", n)
105
+
92
106
 
93
107
  def run_daemon(
94
108
  *,
@@ -101,6 +115,7 @@ def run_daemon(
101
115
  stop: threading.Event | None = None,
102
116
  max_ticks: int | None = None,
103
117
  out: TextIO | None = None,
118
+ on_tick: OnTick | None = None,
104
119
  ) -> int:
105
120
  """ctx 캔슬까지 interval 간격으로 한 틱씩 반복. 적재한 틱 총 수 반환.
106
121
 
@@ -135,7 +150,7 @@ def run_daemon(
135
150
  run_id = start_daemon_run(db, datetime.now(UTC), interval)
136
151
 
137
152
  try:
138
- _tick(tier, db, host, lookup, name_lookup, datetime.now(UTC), n, out, run_id)
153
+ _tick(tier, db, host, lookup, name_lookup, datetime.now(UTC), n, out, run_id, on_tick)
139
154
  except Exception:
140
155
  logger.exception("tick %d failed; continuing", n)
141
156
  n += 1
@@ -63,6 +63,10 @@ class NVMLTier:
63
63
  self._nvml: Any | None = None # pynvml ModuleType
64
64
  self._initialized = False
65
65
  self._process_list_warning_uuids: set[str] = set()
66
+ # 가장 최근 collect() 에서 process list 를 읽지 못한 GPU UUID 들.
67
+ # warning 집합(반복 로그 억제용, 누적)과 달리 *틱마다 리셋* 되어
68
+ # 그 틱의 collectionStatus(partial) 판정에 쓰인다.
69
+ self._last_process_list_unavailable_uuids: set[str] = set()
66
70
 
67
71
  def __enter__(self) -> NVMLTier:
68
72
  return self
@@ -90,6 +94,7 @@ class NVMLTier:
90
94
 
91
95
  gpus: list[GPUSample] = []
92
96
  procs: list[ProcSample] = []
97
+ self._last_process_list_unavailable_uuids = set() # 이 틱 기준으로 리셋.
93
98
  count = nvml.nvmlDeviceGetCount()
94
99
  for i in range(count):
95
100
  h = nvml.nvmlDeviceGetHandleByIndex(i)
@@ -114,6 +119,8 @@ class NVMLTier:
114
119
  try:
115
120
  running = nvml.nvmlDeviceGetComputeRunningProcesses(h)
116
121
  except nvml.NVMLError as e:
122
+ # 이 틱의 partial 판정용 — collectionStatus 에 반영된다.
123
+ self._last_process_list_unavailable_uuids.add(uuid)
117
124
  if uuid not in self._process_list_warning_uuids:
118
125
  logger.warning(
119
126
  "NVML process list unavailable for %s; idle-held classification "
@@ -141,6 +148,15 @@ class NVMLTier:
141
148
  )
142
149
  return Snapshot(gpus=gpus, procs=procs)
143
150
 
151
+ @property
152
+ def last_process_list_unavailable(self) -> bool:
153
+ """가장 최근 collect() 에서 한 카드라도 process list 를 못 읽었는지.
154
+
155
+ True 면 core GPU metric 은 수집됐지만 일부 카드의 process 목록이
156
+ 권한/일시오류로 비었다는 뜻 — cloud push 는 `partial` 로 보낸다.
157
+ """
158
+ return bool(self._last_process_list_unavailable_uuids)
159
+
144
160
  @staticmethod
145
161
  def _read_temperature(nvml: Any, handle: Any) -> int | None:
146
162
  """GPU 온도(°C). 일부 카드/드라이버는 미지원 — 실패 시 None (optional metric)."""
@@ -12,6 +12,7 @@ from gpu_usage_audit.__main__ import gua_main
12
12
  from gpu_usage_audit.cloud.client import CloudError
13
13
  from gpu_usage_audit.cloud.config import CloudConfig, load_cloud_config, save_cloud_config
14
14
  from gpu_usage_audit.model import GPUSample, Snapshot
15
+ from gpu_usage_audit.nvml import NVMLNotAvailableError
15
16
 
16
17
 
17
18
  def _config() -> CloudConfig:
@@ -241,6 +242,138 @@ def test_sync_once_unbuildable_payload_keeps_local_write_without_pushing(
241
242
  conn.close()
242
243
 
243
244
 
245
+ def test_sync_once_emits_partial_when_process_list_unavailable(
246
+ monkeypatch: pytest.MonkeyPatch,
247
+ tmp_path: Path,
248
+ capsys: pytest.CaptureFixture[str],
249
+ ) -> None:
250
+ config_path = tmp_path / "cloud.json"
251
+ save_cloud_config(_config(), config_path)
252
+ db_path = tmp_path / "gua.db"
253
+
254
+ class _PartialTier:
255
+ # core GPU metric 은 수집했지만 한 카드의 process list 가 권한 부족.
256
+ def probe(self) -> str:
257
+ return "560.35.05"
258
+
259
+ def collect(self, _ts: object) -> Snapshot:
260
+ return Snapshot(
261
+ gpus=[
262
+ GPUSample(uuid="GPU-0", util_pct=10, index=0, name="GPU", memory_total_mb=1000)
263
+ ]
264
+ )
265
+
266
+ @property
267
+ def last_process_list_unavailable(self) -> bool:
268
+ return True
269
+
270
+ def close(self) -> None:
271
+ pass
272
+
273
+ monkeypatch.setattr("gpu_usage_audit.__main__.NVMLTier", _PartialTier)
274
+ pushed: dict[str, Any] = {}
275
+
276
+ def fake_post(config: CloudConfig, payload: dict[str, Any], **_kw: Any) -> dict[str, Any]:
277
+ pushed["payload"] = payload
278
+ return {}
279
+
280
+ monkeypatch.setattr("gpu_usage_audit.__main__.post_observation", fake_post)
281
+
282
+ # --fake 없이 실 NVML 경로를 탄다 (위에서 NVMLTier 를 monkeypatch).
283
+ rc = gua_main(["sync-once", "--config", str(config_path), "--db", str(db_path)])
284
+
285
+ assert rc == 0
286
+ payload = pushed["payload"]
287
+ assert payload["collectionStatus"] == "partial"
288
+ assert payload["errors"] == ["process_list_unavailable"]
289
+ # 핵심: GPU 데이터는 그대로 push 됐다.
290
+ assert len(payload["gpus"]) == 1
291
+ out = capsys.readouterr().out
292
+ assert "partial: process_list_unavailable" in out
293
+ # local write 도 보존.
294
+ conn = sqlite3.connect(db_path)
295
+ try:
296
+ assert conn.execute("SELECT COUNT(*) FROM gpu_sample").fetchone()[0] == 1
297
+ finally:
298
+ conn.close()
299
+
300
+
301
+ def test_sync_once_pushes_error_heartbeat_when_nvml_init_fails(
302
+ monkeypatch: pytest.MonkeyPatch,
303
+ tmp_path: Path,
304
+ capsys: pytest.CaptureFixture[str],
305
+ ) -> None:
306
+ config_path = tmp_path / "cloud.json"
307
+ save_cloud_config(_config(), config_path)
308
+ db_path = tmp_path / "gua.db"
309
+
310
+ class _DeadTier:
311
+ # 드라이버를 잃은 host — NVML init 자체가 실패.
312
+ def probe(self) -> str:
313
+ raise NVMLNotAvailableError("the NVIDIA driver is not loaded")
314
+
315
+ def collect(self, _ts: object) -> Snapshot: # pragma: no cover - 도달 안 함
316
+ raise AssertionError("collect must not run after probe failure")
317
+
318
+ def close(self) -> None:
319
+ pass
320
+
321
+ monkeypatch.setattr("gpu_usage_audit.__main__.NVMLTier", _DeadTier)
322
+ pushed: dict[str, Any] = {}
323
+
324
+ def fake_post(config: CloudConfig, payload: dict[str, Any], **_kw: Any) -> dict[str, Any]:
325
+ pushed["payload"] = payload
326
+ return {}
327
+
328
+ monkeypatch.setattr("gpu_usage_audit.__main__.post_observation", fake_post)
329
+
330
+ rc = gua_main(["sync-once", "--config", str(config_path), "--db", str(db_path)])
331
+
332
+ # error heartbeat 는 보냈지만 수집 실패라 non-zero exit.
333
+ assert rc == 1
334
+ payload = pushed["payload"]
335
+ assert payload["collectionStatus"] == "error"
336
+ assert payload["errors"] == ["nvml_init_failed"]
337
+ assert payload["gpus"] == []
338
+ err = capsys.readouterr().err
339
+ assert "pushed error heartbeat" in err
340
+ assert "driver is not loaded" in err
341
+ # 데이터가 없으므로 local DB 는 쓰지 않는다.
342
+ assert not db_path.exists()
343
+
344
+
345
+ def test_sync_once_error_heartbeat_push_failure_reports_both(
346
+ monkeypatch: pytest.MonkeyPatch,
347
+ tmp_path: Path,
348
+ capsys: pytest.CaptureFixture[str],
349
+ ) -> None:
350
+ config_path = tmp_path / "cloud.json"
351
+ save_cloud_config(_config(), config_path)
352
+
353
+ class _DeadTier:
354
+ def probe(self) -> str:
355
+ raise NVMLNotAvailableError("the NVIDIA driver is not loaded")
356
+
357
+ def collect(self, _ts: object) -> Snapshot: # pragma: no cover
358
+ raise AssertionError
359
+
360
+ def close(self) -> None:
361
+ pass
362
+
363
+ monkeypatch.setattr("gpu_usage_audit.__main__.NVMLTier", _DeadTier)
364
+
365
+ def fake_post(*_args: Any, **_kw: Any) -> dict[str, Any]:
366
+ raise CloudError("could not reach GUA Board server: connection refused")
367
+
368
+ monkeypatch.setattr("gpu_usage_audit.__main__.post_observation", fake_post)
369
+
370
+ rc = gua_main(["sync-once", "--config", str(config_path), "--db", str(tmp_path / "gua.db")])
371
+
372
+ assert rc == 1
373
+ err = capsys.readouterr().err
374
+ assert "error heartbeat push also failed" in err
375
+
376
+
244
377
  def test_sync_once_creates_missing_db_parent_dir(
245
378
  monkeypatch: pytest.MonkeyPatch,
246
379
  tmp_path: Path,
@@ -273,3 +406,66 @@ def test_sync_once_without_enrollment_exits_2(
273
406
  )
274
407
  assert rc == 2
275
408
  assert "run `gua enroll`" in capsys.readouterr().err
409
+
410
+
411
+ # ── daemon --cloud ───────────────────────────────────────────────
412
+
413
+
414
+ def test_daemon_cloud_without_enrollment_exits_2(
415
+ tmp_path: Path,
416
+ capsys: pytest.CaptureFixture[str],
417
+ ) -> None:
418
+ # --cloud 인데 enroll 안 됨 → NVML 열기 *전에* 설정 검증 실패로 종료.
419
+ rc = gua_main(
420
+ [
421
+ "daemon",
422
+ "--foreground",
423
+ "--cloud",
424
+ "--db",
425
+ str(tmp_path / "gua.db"),
426
+ "--config",
427
+ str(tmp_path / "absent.json"),
428
+ ]
429
+ )
430
+ assert rc == 2
431
+ assert "run `gua enroll`" in capsys.readouterr().err
432
+
433
+
434
+ def test_daemon_start_propagates_cloud_flags_to_background(
435
+ monkeypatch: pytest.MonkeyPatch,
436
+ tmp_path: Path,
437
+ ) -> None:
438
+ # 백그라운드 spawn 커맨드에 --cloud/--config 가 실려야 데몬이 push 한다.
439
+ captured: dict[str, Any] = {}
440
+
441
+ class FakePopen:
442
+ def __init__(self, command: list[str], **_kwargs: Any) -> None:
443
+ captured["command"] = command
444
+ self.pid = 4242
445
+
446
+ def poll(self) -> int | None:
447
+ return None
448
+
449
+ monkeypatch.setattr("gpu_usage_audit.__main__.subprocess.Popen", FakePopen)
450
+ monkeypatch.setattr("gpu_usage_audit.__main__.time.sleep", lambda *_a, **_k: None)
451
+
452
+ config_path = tmp_path / "cloud.json"
453
+ rc = gua_main(
454
+ [
455
+ "daemon",
456
+ "--cloud",
457
+ "--db",
458
+ str(tmp_path / "gua.db"),
459
+ "--config",
460
+ str(config_path),
461
+ "--pid-file",
462
+ str(tmp_path / "daemon.pid"),
463
+ "--log-file",
464
+ str(tmp_path / "daemon.log"),
465
+ ]
466
+ )
467
+ assert rc == 0
468
+ command = captured["command"]
469
+ assert "--cloud" in command
470
+ assert "--config" in command
471
+ assert str(config_path) in command
@@ -7,10 +7,15 @@ from typing import Any
7
7
 
8
8
  import pytest
9
9
 
10
- from gpu_usage_audit.cloud.snapshot import build_observation_payload
10
+ from gpu_usage_audit.cloud.snapshot import (
11
+ build_observation_payload,
12
+ derive_collection_status,
13
+ )
11
14
  from gpu_usage_audit.model import GPUSample, ProcSample, Snapshot
12
15
  from gpu_usage_audit.tier import FakeTier
13
16
 
17
+ _GPU = GPUSample(uuid="GPU-0", util_pct=0, index=0, name="GPU", memory_total_mb=1000)
18
+
14
19
  OBSERVED_AT = datetime(2026, 6, 17, 4, 0, 0, tzinfo=UTC)
15
20
 
16
21
 
@@ -142,6 +147,31 @@ def test_collection_status_error_requires_errors() -> None:
142
147
  _build(Snapshot(), collection_status="ok", errors=["boom"])
143
148
 
144
149
 
150
+ # ── derive_collection_status ─────────────────────────────────────
151
+
152
+
153
+ def test_derive_status_ok_when_no_degradation() -> None:
154
+ snap = Snapshot(gpus=[_GPU])
155
+ assert derive_collection_status(snap) == ("ok", [])
156
+ assert derive_collection_status(snap, process_list_unavailable=False) == ("ok", [])
157
+
158
+
159
+ def test_derive_status_partial_when_process_list_unavailable() -> None:
160
+ snap = Snapshot(gpus=[_GPU])
161
+ status, errors = derive_collection_status(snap, process_list_unavailable=True)
162
+ assert status == "partial"
163
+ assert errors == ["process_list_unavailable"]
164
+ # builder 검증을 그대로 통과해야 한다 (partial 은 errors ≥1).
165
+ payload = _build(snap, collection_status=status, errors=errors)
166
+ assert payload["collectionStatus"] == "partial"
167
+ assert payload["errors"] == ["process_list_unavailable"]
168
+
169
+
170
+ def test_derive_status_partial_flag_ignored_when_no_gpus() -> None:
171
+ # GPU 가 0개면 partial 로 판정할 카드가 없다 — ok.
172
+ assert derive_collection_status(Snapshot(), process_list_unavailable=True) == ("ok", [])
173
+
174
+
145
175
  def test_fake_tier_snapshot_builds_valid_payload() -> None:
146
176
  # FakeTier 출력이 그대로 contract 모양으로 변환되는지 (sync-once --fake 기반).
147
177
  snap = FakeTier().collect(OBSERVED_AT)
@@ -13,7 +13,7 @@ import pytest
13
13
 
14
14
  from gpu_usage_audit.daemon import run_daemon
15
15
  from gpu_usage_audit.db import open_db
16
- from gpu_usage_audit.model import HostMeta
16
+ from gpu_usage_audit.model import HostMeta, Snapshot
17
17
  from gpu_usage_audit.tier import FakeTier
18
18
 
19
19
  INTERVAL = timedelta(milliseconds=20)
@@ -110,3 +110,51 @@ def test_run_daemon_lookup_resolves_loginuid(db: sqlite3.Connection, host: HostM
110
110
  assert 1234 not in lookup_calls
111
111
  assert 5678 not in lookup_calls
112
112
  assert 9999 in lookup_calls
113
+
114
+
115
+ def test_run_daemon_invokes_on_tick_after_local_write(
116
+ db: sqlite3.Connection, host: HostMeta
117
+ ) -> None:
118
+ # on_tick 은 매 틱 local write *이후* 호출된다(cloud push 가 얹히는 자리).
119
+ calls: list[int] = []
120
+
121
+ def on_tick(snap: Snapshot, _ts: datetime) -> None:
122
+ # 콜백 시점엔 이미 이번 틱이 DB 에 기록돼 있어야 한다.
123
+ calls.append(db.execute("SELECT COUNT(*) FROM gpu_sample").fetchone()[0])
124
+ assert len(snap.gpus) == 3 # FakeTier 의 스냅샷이 그대로 전달된다.
125
+
126
+ n = run_daemon(
127
+ tier=FakeTier(),
128
+ db=db,
129
+ host=host,
130
+ interval=INTERVAL,
131
+ max_ticks=3,
132
+ out=io.StringIO(),
133
+ on_tick=on_tick,
134
+ )
135
+ assert n == 3
136
+ # 매 틱 호출되고, 호출 시점의 누적 행 수는 3, 6, 9 (틱당 3 GPU).
137
+ assert calls == [3, 6, 9]
138
+
139
+
140
+ def test_run_daemon_continues_when_on_tick_raises(db: sqlite3.Connection, host: HostMeta) -> None:
141
+ # on_tick(예: cloud push) 실패는 local write 와 다음 틱을 막지 않는다.
142
+ attempts: list[int] = []
143
+
144
+ def boom(_snap: Snapshot, _ts: datetime) -> None:
145
+ attempts.append(1)
146
+ raise RuntimeError("cloud push failed")
147
+
148
+ n = run_daemon(
149
+ tier=FakeTier(),
150
+ db=db,
151
+ host=host,
152
+ interval=INTERVAL,
153
+ max_ticks=3,
154
+ out=io.StringIO(),
155
+ on_tick=boom,
156
+ )
157
+ assert n == 3
158
+ assert len(attempts) == 3 # 매 틱 호출(예외에도 멈추지 않음).
159
+ # local write 는 전부 보존: 3 틱 * 3 GPU = 9 행.
160
+ assert db.execute("SELECT COUNT(*) FROM gpu_sample").fetchone()[0] == 9
@@ -235,6 +235,41 @@ def test_collect_handles_per_device_running_processes_error(
235
235
  assert snap.procs == []
236
236
 
237
237
 
238
+ def test_last_process_list_unavailable_tracks_per_tick(monkeypatch: pytest.MonkeyPatch) -> None:
239
+ fake = _make_mock_pynvml(
240
+ driver="x",
241
+ gpus=[{"uuid": "GPU-x", "util": 0, "procs": [{"pid": 1, "mem": 1024 * 1024}]}],
242
+ )
243
+ monkeypatch.setitem(sys.modules, "pynvml", fake)
244
+
245
+ def _raise(_h: Any) -> Any:
246
+ raise fake.NVMLError("permission denied")
247
+
248
+ def _ok(_h: Any) -> Any:
249
+ return [SimpleNamespace(pid=1, usedGpuMemory=1024 * 1024)]
250
+
251
+ with NVMLTier() as tier:
252
+ tier.probe()
253
+ # 정상 collect → process list 가용.
254
+ tier.collect(TS)
255
+ clean = tier.last_process_list_unavailable
256
+
257
+ # process list 가 실패하는 틱 → True (core GPU metric 은 유지).
258
+ fake.nvmlDeviceGetComputeRunningProcesses = MagicMock(side_effect=_raise)
259
+ snap = tier.collect(TS)
260
+ degraded = tier.last_process_list_unavailable
261
+
262
+ # 다시 정상으로 돌아오면 *틱마다 리셋* 된다.
263
+ fake.nvmlDeviceGetComputeRunningProcesses = MagicMock(side_effect=_ok)
264
+ tier.collect(TS)
265
+ recovered = tier.last_process_list_unavailable
266
+
267
+ assert clean is False
268
+ assert degraded is True # core metric 은 유지된 채 partial 신호만 켜진다.
269
+ assert [g.uuid for g in snap.gpus] == ["GPU-x"]
270
+ assert recovered is False
271
+
272
+
238
273
  def test_probe_translates_nvml_error_to_friendly(monkeypatch: pytest.MonkeyPatch) -> None:
239
274
  fake = _make_mock_pynvml(driver="x", gpus=[])
240
275
  fake.nvmlInit = MagicMock(side_effect=fake.NVMLError("localized message", value=9))
@@ -139,7 +139,7 @@ wheels = [
139
139
 
140
140
  [[package]]
141
141
  name = "gpu-usage-audit"
142
- version = "1.1.0"
142
+ version = "1.3.0"
143
143
  source = { editable = "." }
144
144
  dependencies = [
145
145
  { name = "nvidia-ml-py" },
File without changes