Skip to content

Commit 774818c

Browse files
committed
[act.dirac.σ.D8+] pueue could serialize GPU work but could not be the recovery or quota authority Dirac needs
Kueue now owns only resource admission and placement; Dirac retains Attempt, retry, fencing, Artifact, and scientific identity. Mutable images, arbitrary entrypoints, secret-like environment values, and GPU quota without proven NVML health are rejected. The original positive GPU quota was wrong while the host had a driver/library mismatch, so it is now gated at zero until all runtime checks pass.
1 parent 7a480bd commit 774818c

11 files changed

Lines changed: 1063 additions & 1 deletion

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ build/
33
# unit files that must ship (a supervision story nobody can read is no story).
44
deploy/*
55
!deploy/systemd/
6+
!deploy/kubernetes/
67
!deploy/README.md
78
!deploy/Caddyfile.remote.example
89
!deploy/dirac-tokens.example.json
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Durable PostgreSQL mirror for external scheduler allocations."""
2+
from __future__ import annotations
3+
4+
import json
5+
from typing import Any, Callable
6+
7+
from execution_control.protocol import AllocationStatus, EventPage, SchedulerAdapter
8+
9+
10+
_DATABASE_STATES = {
11+
"created": "created",
12+
"submitted": "submitted",
13+
"pending": "pending",
14+
"suspended": "pending",
15+
"running": "running",
16+
"succeeded": "succeeded",
17+
"failed": "failed",
18+
"cancelled": "cancelled",
19+
"unknown": "lost",
20+
"lost": "lost",
21+
}
22+
_TERMINAL = {"succeeded", "failed", "cancelled", "lost"}
23+
24+
25+
class PostgresAllocationStore:
26+
"""Persist scheduler identity without making it Dirac's public Job identity."""
27+
28+
def __init__(self, connect: Callable[[], Any], *, site: str) -> None:
29+
if not site:
30+
raise ValueError("site is required so scheduler identity is globally unambiguous")
31+
self._connect = connect
32+
self.site = site
33+
34+
def record_submission(self, request: dict[str, Any],
35+
status: AllocationStatus, *, backend: str) -> str:
36+
state = _database_state(status.state)
37+
with self._connect() as conn, conn.cursor() as cur:
38+
cur.execute(
39+
"INSERT INTO app.execution_allocation "
40+
"(attempt_id,backend,site,scheduler_identifier,state,resource_request,"
41+
" placement,scheduler_summary,submitted_at,started_at,finished_at) "
42+
"VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s::jsonb,now(),"
43+
" CASE WHEN %s IN ('running','succeeded','failed','cancelled','lost') THEN now() END,"
44+
" CASE WHEN %s IN ('succeeded','failed','cancelled','lost') THEN now() END) "
45+
"ON CONFLICT (backend,site,scheduler_identifier) DO UPDATE SET "
46+
"state=excluded.state, scheduler_summary=excluded.scheduler_summary,"
47+
"started_at=coalesce(app.execution_allocation.started_at,excluded.started_at),"
48+
"finished_at=coalesce(app.execution_allocation.finished_at,excluded.finished_at) "
49+
"WHERE app.execution_allocation.attempt_id=excluded.attempt_id "
50+
"RETURNING id",
51+
(
52+
request["attempt_id"], backend, self.site, status.allocation_id, state,
53+
json.dumps(request["resource_request"], sort_keys=True),
54+
json.dumps(request["placement"], sort_keys=True),
55+
json.dumps(status.scheduler_summary, sort_keys=True), state, state,
56+
),
57+
)
58+
row = cur.fetchone()
59+
if row is None:
60+
raise RuntimeError("scheduler allocation identity already belongs to another attempt")
61+
return str(row[0])
62+
63+
def update_status(self, status: AllocationStatus, *, backend: str) -> bool:
64+
state = _database_state(status.state)
65+
terminal = state in _TERMINAL
66+
with self._connect() as conn, conn.cursor() as cur:
67+
cur.execute(
68+
"UPDATE app.execution_allocation SET state=%s,"
69+
" scheduler_summary=%s::jsonb,"
70+
" started_at=CASE WHEN %s='running' OR %s THEN "
71+
" coalesce(started_at,submitted_at,now()) ELSE started_at END,"
72+
" finished_at=CASE WHEN %s THEN coalesce(finished_at,now()) ELSE NULL END "
73+
"WHERE backend=%s AND site=%s AND scheduler_identifier=%s "
74+
"AND state NOT IN ('succeeded','failed','cancelled','lost') RETURNING id",
75+
(state, json.dumps(status.scheduler_summary, sort_keys=True),
76+
state, terminal, terminal, backend, self.site, status.allocation_id),
77+
)
78+
return cur.fetchone() is not None
79+
80+
def active_scheduler_ids(self, *, backend: str) -> tuple[str, ...]:
81+
with self._connect() as conn, conn.cursor() as cur:
82+
cur.execute(
83+
"SELECT scheduler_identifier FROM app.execution_allocation "
84+
"WHERE backend=%s AND site=%s AND state IN "
85+
"('created','submitted','pending','running') ORDER BY created_at,id",
86+
(backend, self.site),
87+
)
88+
return tuple(str(row[0]) for row in cur.fetchall())
89+
90+
91+
class DurableSchedulerAdapter:
92+
"""SchedulerAdapter decorator that mirrors every observable transition."""
93+
94+
def __init__(self, adapter: SchedulerAdapter, store: PostgresAllocationStore) -> None:
95+
self.adapter = adapter
96+
self.store = store
97+
self.kind = adapter.kind
98+
99+
def admit(self, request: dict[str, Any]):
100+
return self.adapter.admit(request)
101+
102+
def submit(self, request: dict[str, Any]) -> AllocationStatus:
103+
status = self.adapter.submit(request)
104+
try:
105+
self.store.record_submission(request, status, backend=self.kind)
106+
except Exception:
107+
# Never leave expensive scheduler work running without its durable
108+
# Dirac allocation identity. Kubernetes deletion is idempotent.
109+
self.adapter.request_cancel(status.allocation_id, grace_seconds=0)
110+
raise
111+
return status
112+
113+
def inspect(self, allocation_id: str) -> AllocationStatus:
114+
status = self.adapter.inspect(allocation_id)
115+
self.store.update_status(status, backend=self.kind)
116+
return status
117+
118+
def request_cancel(self, allocation_id: str, *, grace_seconds: int) -> None:
119+
self.adapter.request_cancel(allocation_id, grace_seconds=grace_seconds)
120+
self.store.update_status(
121+
AllocationStatus(allocation_id, "cancelled", {
122+
"reason": "cancel_requested", "grace_seconds": grace_seconds,
123+
}),
124+
backend=self.kind,
125+
)
126+
127+
def suspend(self, allocation_id: str) -> None:
128+
self.adapter.suspend(allocation_id)
129+
self.inspect(allocation_id)
130+
131+
def resume(self, allocation_id: str) -> None:
132+
self.adapter.resume(allocation_id)
133+
self.inspect(allocation_id)
134+
135+
def collect_events(self, cursor: str | None) -> EventPage:
136+
return self.adapter.collect_events(cursor)
137+
138+
def reconcile(self, allocation_id: str) -> AllocationStatus:
139+
status = self.adapter.reconcile(allocation_id)
140+
self.store.update_status(status, backend=self.kind)
141+
return status
142+
143+
def reconcile_active(self) -> tuple[AllocationStatus, ...]:
144+
return tuple(self.reconcile(allocation_id) for allocation_id in
145+
self.store.active_scheduler_ids(backend=self.kind))
146+
147+
148+
def _database_state(state: str) -> str:
149+
try:
150+
return _DATABASE_STATES[state]
151+
except KeyError as error:
152+
raise ValueError(f"unknown scheduler state {state!r}") from error
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Fail-closed routing from one ExecutionRequest contract to scheduler adapters."""
2+
from __future__ import annotations
3+
4+
from typing import Iterable
5+
6+
from execution_control.protocol import AllocationStatus, SchedulerAdapter, validate_execution_request
7+
8+
9+
class SchedulerRouter:
10+
def __init__(self, adapters: Iterable[SchedulerAdapter]) -> None:
11+
self._adapters: dict[str, SchedulerAdapter] = {}
12+
for adapter in adapters:
13+
if adapter.kind in self._adapters:
14+
raise ValueError(f"duplicate scheduler backend {adapter.kind!r}")
15+
self._adapters[adapter.kind] = adapter
16+
if not self._adapters:
17+
raise ValueError("at least one scheduler adapter is required")
18+
19+
@property
20+
def backends(self) -> tuple[str, ...]:
21+
return tuple(sorted(self._adapters))
22+
23+
def submit(self, request: dict) -> AllocationStatus:
24+
validate_execution_request(request)
25+
adapter = self._require(request["placement"]["backend"])
26+
return adapter.submit(request)
27+
28+
def inspect(self, backend: str, allocation_id: str) -> AllocationStatus:
29+
return self._require(backend).inspect(allocation_id)
30+
31+
def cancel(self, backend: str, allocation_id: str, *, grace_seconds: int) -> None:
32+
self._require(backend).request_cancel(allocation_id, grace_seconds=grace_seconds)
33+
34+
def suspend(self, backend: str, allocation_id: str) -> None:
35+
self._require(backend).suspend(allocation_id)
36+
37+
def resume(self, backend: str, allocation_id: str) -> None:
38+
self._require(backend).resume(allocation_id)
39+
40+
def reconcile_active(self) -> tuple[AllocationStatus, ...]:
41+
statuses: list[AllocationStatus] = []
42+
for adapter in self._adapters.values():
43+
reconcile = getattr(adapter, "reconcile_active", None)
44+
if reconcile is not None:
45+
statuses.extend(reconcile())
46+
return tuple(statuses)
47+
48+
def _require(self, backend: str) -> SchedulerAdapter:
49+
try:
50+
return self._adapters[backend]
51+
except KeyError as error:
52+
raise ValueError(
53+
f"scheduler backend {backend!r} is not configured; available={self.backends}"
54+
) from error

‎backend/executors/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Scheduler adapters for fixed, versioned Dirac worker entrypoints."""
22

33
from .local_process import LocalProcessAdapter
4+
from .kubernetes_kueue import KubernetesKueueAdapter, KubernetesKueueConfig
45

5-
__all__ = ["LocalProcessAdapter"]
6+
__all__ = ["KubernetesKueueAdapter", "KubernetesKueueConfig", "LocalProcessAdapter"]

0 commit comments

Comments
 (0)