Skip to content

Commit bbe6b26

Browse files
committed
bug: do not release when scout not ready
Signed-off-by: vsoch <vsoch@users.noreply.github.com>
1 parent ffb6ca3 commit bbe6b26

4 files changed

Lines changed: 99 additions & 57 deletions

File tree

‎docs/quantum-scheduling.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ The quantum handler also enforces admission control, which is always on unless t
6363
5. When the task is next in line, or already running, annotate each worker with the job id and the session, then remove the gate.
6464
6. Wait for the workers to reach Succeeded or Failed, write the done marker so the scout's release proceeds, and exit.
6565
66-
If the task is never found or fails, the sidecar ungates anyway so the workers are not stranded, and exits non zero so it shows.
66+
The sidecar ungates the workers only when the scout's task is running or has completed. If the task fails, is cancelled, or is never found, the workers stay gated, the sidecar tells the scout to release its session at once, and it exits non zero so it shows. Delete the job to clear the gated workers. The one exception is a rehearsal, where the scout exits without submitting anything, for example with no credentials. Then the workers are released with no session.
6767
6868
## Priority and preemption
6969

‎python/fluence/providers/qrmi.py‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,10 @@ def _status_name(status) -> str:
8888
return s.rsplit(".", 1)[-1].upper()
8989

9090

91-
# the task has left the queue or already finished
92-
_READY_STATES = {
93-
"RUNNING",
94-
"COMPLETED",
95-
"COMPLETE",
96-
"DONE",
97-
"FAILED",
98-
"CANCELLED",
99-
"CANCELED",
100-
}
91+
# the task is running on the device, or ran to the end
92+
_READY_STATES = {"RUNNING", "COMPLETED", "COMPLETE", "DONE"}
93+
# the task ended with nothing to use
94+
_FAILED_STATES = {"FAILED", "CANCELLED", "CANCELED"}
10195

10296

10397
class QRMIProvider(Provider):
@@ -255,10 +249,15 @@ def _status(self, task: QRMITask) -> "str | None":
255249
def is_ready_to_ungate(self, task: QRMITask, position=None) -> bool:
256250
# QRMI reports no queue position, so position is accepted to match the
257251
# interface and the sidecar's call, and ignored. Ready means the task
258-
# has left the queue.
252+
# is running on the device or has completed. Failed and cancelled are
253+
# not ready, task_failed reports them.
259254
name = self._status(task)
260255
return name in _READY_STATES if name else False
261256

257+
def task_failed(self, task: QRMITask) -> bool:
258+
name = self._status(task)
259+
return name in _FAILED_STATES if name else False
260+
262261
def queue_position(self, task: QRMITask):
263262
# QRMI has no queue position, so report 1 once the task runs and None before
264263
name = self._status(task)

‎python/fluence/sidecar.py‎

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,6 @@ def _poll(provider, task, poll_interval, ungate, position=1):
5050
log(f"{mode} mode: polling queue position, ungating at {position} or closer")
5151
last = object()
5252
# A vendor call can fail for a while and recover, so those are retried.
53-
# A bug in our own code fails the same way every time and never
54-
# recovers, and while it retries a session that already started keeps
55-
# billing with nothing using it. So programming errors stop the poll at
56-
# once, and so does any error that keeps happening.
5753
max_errors = int(os.environ.get("FLUENCE_POLL_MAX_ERRORS", 10))
5854
errors = 0
5955
while True:
@@ -143,19 +139,13 @@ def main():
143139
_hold_for_workers(namespace, gang_group, pod_name)
144140
sys.exit(0)
145141
if task is None:
146-
log("ERROR: could not discover quantum task")
147-
if not observe:
148-
# fail open so the workers are not stranded
149-
ungate_pods(
150-
wait_for_gated_pods(
151-
namespace, gang_group, exclude=pod_name, timeout=ungate_timeout
152-
),
153-
"",
154-
namespace,
155-
backend=placed_backend,
156-
attributes=placed_attrs,
157-
)
158-
_hold_for_workers(namespace, gang_group, pod_name)
142+
# The scout neither published a task nor exited, so something is
143+
# wrong with it. Releasing the workers would start them on a run that
144+
# has no quantum task, so they stay gated. Delete the job to clear them.
145+
log(
146+
"ERROR: could not discover the quantum task. The workers stay gated, "
147+
"delete the job to clear them"
148+
)
159149
sys.exit(1)
160150

161151
job_id = provider.job_id(task)
@@ -168,25 +158,16 @@ def main():
168158
sys.exit(0 if ready else 1)
169159

170160
if not ready:
171-
# fail open so the workers are not stranded. The session goes with
172-
# them: the scout is still holding it, and workers that ran outside
173-
# it would leave it open and idle, which a dedicated session bills
161+
# The task failed, was cancelled, or could not be followed. There is
162+
# nothing for the workers to use, so they stay gated, and the scout is
163+
# told to stop holding its session so it closes now instead of when
164+
# the hold times out. Delete the job to clear the workers.
174165
log(
175-
"ERROR: ungating anyway so the gang is not stranded, but the task "
176-
"produced no result"
177-
)
178-
ungate_pods(
179-
gated_pods_from_env()
180-
or wait_for_gated_pods(
181-
namespace, gang_group, exclude=pod_name, timeout=ungate_timeout
182-
),
183-
job_id,
184-
namespace,
185-
session_id=provider.session_id(task),
186-
backend=placed_backend,
187-
attributes=placed_attrs,
166+
"ERROR: the quantum task is not usable. The workers stay gated and "
167+
"the scout releases its session now. Delete the job to clear them"
188168
)
189-
_hold_for_workers(namespace, gang_group, pod_name)
169+
if not handoff.mark_workers_done():
170+
log("WARNING: could not tell the scout to release its session")
190171
sys.exit(1)
191172

192173
# ungate the workers, stamping the job id and the session first

‎python/tests/test_qrmi.py‎

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -470,39 +470,101 @@ def queue_position(self, task):
470470
assert sidecar._poll(Recovering(), object(), 0, ungate=True) is True
471471

472472

473-
def test_fail_open_hands_the_workers_the_session(monkeypatch):
474-
"""When the poll fails the workers are released anyway. They must get
475-
the session id, or they run outside a session the scout still holds."""
473+
def _sidecar_with(monkeypatch, task, ready, scout_exited=False):
474+
"""Run the sidecar's main with a fake provider. Returns what it did."""
476475
from fluence import sidecar
477476

478-
calls = {}
479-
monkeypatch.setattr(sidecar, "_poll", lambda *a, **k: False)
477+
did = {"ungated": None, "marked": False}
478+
monkeypatch.setattr(sidecar, "_poll", lambda *a, **k: ready)
480479
monkeypatch.setattr(sidecar, "_hold_for_workers", lambda *a, **k: None)
481480
monkeypatch.setattr(sidecar, "gated_pods_from_env", lambda: ["worker-0"])
481+
monkeypatch.setattr(sidecar, "wait_for_gated_pods", lambda *a, **k: ["worker-0"])
482482
monkeypatch.setattr(
483483
sidecar,
484484
"ungate_pods",
485-
lambda pods, job_id, ns, **kw: calls.update(kw) or len(pods),
485+
lambda pods, job_id, ns, **kw: did.update(ungated={"job_id": job_id, **kw})
486+
or len(pods),
486487
)
487488
monkeypatch.setattr(sidecar, "placement_from_env", lambda: ("ibm_x", {}))
488489
monkeypatch.setattr(sidecar, "namespace_from_env", lambda: "default")
490+
monkeypatch.setattr(
491+
sidecar.handoff,
492+
"mark_workers_done",
493+
lambda *a, **k: did.update(marked=True) or True,
494+
)
495+
monkeypatch.setattr(sidecar.handoff, "scout_exited", lambda *a, **k: scout_exited)
489496

490497
class P:
491498
name = "fake"
492499

493500
def find_my_task(self, uid, backend, timeout):
494-
return "task"
501+
return task
495502

496-
def job_id(self, task):
503+
def job_id(self, t):
497504
return "job-1"
498505

499-
def session_id(self, task):
506+
def session_id(self, t):
500507
return "sess-1"
501508

502509
monkeypatch.setattr(sidecar, "resolve_from_env", lambda: P())
503510
monkeypatch.setenv("FLUENCE_OBSERVE", "false")
511+
code = 0
504512
try:
505513
sidecar.main()
506514
except SystemExit as e:
507-
assert e.code == 1
508-
assert calls.get("session_id") == "sess-1"
515+
code = e.code
516+
did["code"] = code
517+
return did
518+
519+
520+
def test_a_task_that_is_not_ready_leaves_the_workers_gated(monkeypatch):
521+
"""A failed or cancelled task has nothing for the workers. Releasing them
522+
would only spend money on a run that already failed."""
523+
did = _sidecar_with(monkeypatch, task="task", ready=False)
524+
assert did["ungated"] is None
525+
assert did["code"] == 1
526+
527+
528+
def test_a_task_that_is_not_ready_tells_the_scout_to_release(monkeypatch):
529+
"""The scout holds its session until the workers are done. They will
530+
never run, so the marker is written now and the session closes."""
531+
did = _sidecar_with(monkeypatch, task="task", ready=False)
532+
assert did["marked"] is True
533+
534+
535+
def test_a_task_that_cannot_be_found_leaves_the_workers_gated(monkeypatch):
536+
did = _sidecar_with(monkeypatch, task=None, ready=False, scout_exited=False)
537+
assert did["ungated"] is None
538+
assert did["code"] == 1
539+
540+
541+
def test_a_rehearsal_still_releases_the_workers(monkeypatch):
542+
"""The scout exited with nothing submitted, no credentials. That is the
543+
mock run, and the workers go with no session."""
544+
did = _sidecar_with(monkeypatch, task=None, ready=False, scout_exited=True)
545+
assert did["ungated"] is not None
546+
assert did["ungated"]["job_id"] == ""
547+
assert not did["ungated"].get("session_id")
548+
assert did["code"] == 0
549+
550+
551+
def test_a_ready_task_releases_the_workers_with_the_session(monkeypatch):
552+
did = _sidecar_with(monkeypatch, task="task", ready=True)
553+
assert did["ungated"]["job_id"] == "job-1"
554+
assert did["ungated"]["session_id"] == "sess-1"
555+
556+
557+
def test_qrmi_ready_means_running_or_completed(monkeypatch):
558+
from fluence.providers import qrmi as q
559+
560+
prov = q.QRMIProvider()
561+
for name, ready, failed in [
562+
("QUEUED", False, False),
563+
("RUNNING", True, False),
564+
("COMPLETED", True, False),
565+
("FAILED", False, True),
566+
("CANCELLED", False, True),
567+
]:
568+
monkeypatch.setattr(prov, "_status", lambda task, n=name: n)
569+
assert prov.is_ready_to_ungate(object()) is ready, name
570+
assert prov.task_failed(object()) is failed, name

0 commit comments

Comments
 (0)