Skip to content

Commit cf2161b

Browse files
committed
more bullshit
Signed-off-by: vsoch <vsoch@users.noreply.github.com>
1 parent 16c27ca commit cf2161b

3 files changed

Lines changed: 128 additions & 29 deletions

File tree

‎python/fluence/providers/qrmi.py‎

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,26 @@
1818

1919
# qrmi_type values whose ResourceType name cannot be derived from them
2020
_RESOURCE_TYPE_ALIASES = {
21+
"qiskit-runtime-service": "IBMQiskitRuntimeService",
2122
"direct-access": "IBMQuantumSystem", # legacy name for ibm-quantum-system
2223
"ibm-direct-access": "IBMQuantumSystem",
2324
}
2425

26+
# the infix QRMI puts in its variable names for each IBM type, as in
27+
# <backend>_QRMI_IBM_QRS_IAM_APIKEY
28+
_ENV_INFIX = {
29+
"IBMQiskitRuntimeService": "_QRMI_IBM_QRS",
30+
"IBMQuantumSystem": "_QRMI_IBM_QS",
31+
}
32+
33+
# what the infix variables default to, or are derived from, when only the
34+
# IBM_CLOUD_TOKEN and IBM_CLOUD_CRN a workload Secret provides are present.
35+
# The sampler derives the same set for the scout.
36+
_IBM_DEFAULTS = {
37+
"_ENDPOINT": "https://quantum.cloud.ibm.com/api/v1",
38+
"_IAM_ENDPOINT": "https://iam.cloud.ibm.com",
39+
}
40+
2541
# kept upper case when deriving a ResourceType name
2642
_ACRONYMS = {"ibm", "iqm", "qrmi", "id", "api", "hpc"}
2743

@@ -56,8 +72,9 @@ def _pascal(qrmi_type: str, acronyms=True) -> str:
5672

5773
def _resource_type_enum(qrmi_type: str):
5874
"""The QRMI ResourceType for a qrmi_type string. An alias first, then the
59-
name derived from the string. Falls back to the first member with a warning
60-
rather than failing on a QRMI version we have not seen."""
75+
name derived from the string. Raises when nothing matches. A guessed type
76+
reads the wrong credentials and asks the wrong service about the task, so
77+
it is never better than stopping."""
6178
from qrmi import ResourceType
6279

6380
q = (qrmi_type or "").lower()
@@ -70,12 +87,29 @@ def _resource_type_enum(qrmi_type: str):
7087
rt = getattr(ResourceType, name, None)
7188
if rt is not None:
7289
return rt
73-
log(
74-
f"[qrmi] WARNING: no ResourceType matched {qrmi_type!r} "
75-
f"(tried {candidates}); falling back to a default member"
90+
members = [k for k in vars(ResourceType) if not k.startswith("_")]
91+
raise ValueError(
92+
f"no QRMI ResourceType matches {qrmi_type!r}, tried {candidates}, "
93+
f"QRMI has {members}"
7694
)
77-
members = [v for k, v in vars(ResourceType).items() if not k.startswith("_")]
78-
return members[0] if members else None
95+
96+
97+
def _derive_ibm_env(backend: str, rtype) -> None:
98+
"""Fill in <backend><infix>_* from IBM_CLOUD_TOKEN and IBM_CLOUD_CRN,
99+
never overwriting a variable that is already set. The webhook copies the
100+
workload's Secret env onto the sidecar, and that Secret holds the plain
101+
token and CRN, not QRMI's names."""
102+
name = rtype if isinstance(rtype, str) else getattr(rtype, "name", str(rtype))
103+
infix = _ENV_INFIX.get(name.rsplit(".", 1)[-1])
104+
if not infix:
105+
return
106+
values = dict(_IBM_DEFAULTS)
107+
if os.environ.get("IBM_CLOUD_TOKEN"):
108+
values["_IAM_APIKEY"] = os.environ["IBM_CLOUD_TOKEN"]
109+
if os.environ.get("IBM_CLOUD_CRN"):
110+
values["_SERVICE_CRN"] = os.environ["IBM_CLOUD_CRN"]
111+
for suffix, value in values.items():
112+
os.environ.setdefault(backend + infix + suffix, value)
79113

80114

81115
def _status_name(status) -> str:
@@ -235,35 +269,37 @@ def _qrmi_resource(self, task: QRMITask):
235269
import qrmi
236270

237271
rt = _resource_type_enum(task.resource_type)
272+
_derive_ibm_env(
273+
task.backend,
274+
_RESOURCE_TYPE_ALIASES.get((task.resource_type or "").lower(), rt),
275+
)
238276
task._resource = qrmi.QuantumResource(task.backend, rt)
239277
return task._resource
240278

241-
def _status(self, task: QRMITask) -> "str | None":
242-
try:
243-
status = self._qrmi_resource(task).task_status(task.job_id)
244-
return _status_name(status)
245-
except Exception as e:
246-
log(f"[qrmi] task_status error (will retry): {e}")
247-
return None
279+
def _status(self, task: QRMITask) -> str:
280+
# Errors are raised, not swallowed. The sidecar's poll loop counts
281+
# them and gives up after FLUENCE_POLL_MAX_ERRORS, which tells the
282+
# scout to release its session. Swallowed, a status call that can
283+
# never work polled forever while the scout held the session.
284+
return _status_name(self._qrmi_resource(task).task_status(task.job_id))
248285

249286
def is_ready_to_ungate(self, task: QRMITask, position=None) -> bool:
250287
# QRMI reports no queue position, so position is accepted to match the
251288
# interface and the sidecar's call, and ignored. Ready means the task
252289
# is running on the device or has completed. Failed and cancelled are
253290
# not ready, task_failed reports them.
254-
name = self._status(task)
255-
return name in _READY_STATES if name else False
291+
return self._status(task) in _READY_STATES
256292

257293
def task_failed(self, task: QRMITask) -> bool:
258-
name = self._status(task)
259-
return name in _FAILED_STATES if name else False
294+
return self._status(task) in _FAILED_STATES
260295

261296
def queue_position(self, task: QRMITask):
262297
# QRMI has no queue position, so report 1 once the task runs and None before
263-
name = self._status(task)
264-
if name and name in _READY_STATES:
265-
return 1
266-
return None
298+
try:
299+
name = self._status(task)
300+
except Exception:
301+
return None
302+
return 1 if name in _READY_STATES else None
267303

268304
def job_id(self, task: QRMITask) -> str:
269305
return task.job_id

‎python/fluence/sidecar.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ def _poll(provider, task, poll_interval, ungate, position=1):
7070
log(f"queue position: {pos}")
7171
last = pos
7272
errors = 0
73-
except (TypeError, AttributeError, NameError) as e:
74-
log(f"ERROR: poll failed with a programming error, not retrying: {e!r}")
73+
except (TypeError, AttributeError, NameError, ValueError) as e:
74+
log(f"ERROR: poll failed with an error retrying cannot fix: {e!r}")
7575
return False
7676
except Exception as e:
7777
errors += 1

‎python/tests/test_qrmi.py‎

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,9 @@ def test_resource_type_derivation_covers_multiple_vendors():
8585
# just a new qrmi_type in config. Verify with a fake ResourceType enum.
8686
from fluence.providers import qrmi as qmod
8787

88+
# the member names QRMI actually has
8889
fake = types.SimpleNamespace(
89-
QiskitRuntimeService="QiskitRuntimeService",
90+
IBMQiskitRuntimeService="IBMQiskitRuntimeService",
9091
IBMQuantumSystem="IBMQuantumSystem",
9192
PasqalCloud="PasqalCloud",
9293
)
@@ -95,15 +96,59 @@ def test_resource_type_derivation_covers_multiple_vendors():
9596
sys.modules["qrmi"] = fake_mod
9697
try:
9798
assert (
98-
qmod._resource_type_enum("qiskit-runtime-service") == "QiskitRuntimeService"
99+
qmod._resource_type_enum("qiskit-runtime-service")
100+
== "IBMQiskitRuntimeService"
99101
)
100102
assert qmod._resource_type_enum("ibm-quantum-system") == "IBMQuantumSystem"
101103
assert qmod._resource_type_enum("direct-access") == "IBMQuantumSystem" # alias
102104
assert qmod._resource_type_enum("pasqal-cloud") == "PasqalCloud"
105+
# an unknown type stops, it is never guessed
106+
try:
107+
qmod._resource_type_enum("no-such-type")
108+
except ValueError:
109+
pass
110+
else:
111+
raise AssertionError("an unknown type was guessed")
103112
finally:
104113
sys.modules.pop("qrmi", None)
105114

106115

116+
def test_sidecar_derives_qrmi_env_from_the_secret():
117+
"""The Secret gives the sidecar IBM_CLOUD_TOKEN and IBM_CLOUD_CRN. QRMI
118+
reads <backend>_QRMI_IBM_QRS_*, so those are derived, and an explicit
119+
value is never overwritten. No fixtures, the file's own runner calls
120+
tests with no arguments."""
121+
from fluence.providers import qrmi as qmod
122+
123+
keys = ("IBM_CLOUD_TOKEN", "IBM_CLOUD_CRN") + tuple(
124+
k for k in os.environ if k.startswith("ibm_fez_QRMI")
125+
)
126+
saved = {k: os.environ.get(k) for k in keys}
127+
try:
128+
for k in keys:
129+
os.environ.pop(k, None)
130+
os.environ["IBM_CLOUD_TOKEN"] = "tok"
131+
os.environ["IBM_CLOUD_CRN"] = "crn"
132+
os.environ["ibm_fez_QRMI_IBM_QRS_ENDPOINT"] = "https://mine"
133+
qmod._derive_ibm_env("ibm_fez", "IBMQiskitRuntimeService")
134+
assert os.environ["ibm_fez_QRMI_IBM_QRS_IAM_APIKEY"] == "tok"
135+
assert os.environ["ibm_fez_QRMI_IBM_QRS_SERVICE_CRN"] == "crn"
136+
assert (
137+
os.environ["ibm_fez_QRMI_IBM_QRS_IAM_ENDPOINT"]
138+
== "https://iam.cloud.ibm.com"
139+
)
140+
assert os.environ["ibm_fez_QRMI_IBM_QRS_ENDPOINT"] == "https://mine"
141+
finally:
142+
for k in list(os.environ):
143+
if k.startswith("ibm_fez_QRMI"):
144+
del os.environ[k]
145+
for k, v in saved.items():
146+
if v is None:
147+
os.environ.pop(k, None)
148+
else:
149+
os.environ[k] = v
150+
151+
107152
def test_pascal_transform_acronym_aware():
108153
from fluence.providers.qrmi import _pascal
109154

@@ -303,19 +348,37 @@ def __str__(self):
303348
assert prov.is_ready_to_ungate(task) is True
304349

305350

306-
def test_status_error_is_not_ready_not_raising():
351+
def test_status_error_reaches_the_poll_loop_and_it_gives_up():
352+
"""A status call that keeps failing must end the poll, so the scout is
353+
told to release its session. Swallowed, it polled forever."""
354+
307355
class Boom:
308356
def task_status(self, job_id):
309-
raise RuntimeError("transient")
357+
raise RuntimeError("ibm_fez_QRMI_IBM_QRS_ENDPOINT is not set")
310358

359+
from fluence import sidecar
311360
from fluence.providers.qrmi import QRMIProvider, QRMITask
312361

313362
prov = QRMIProvider()
314363
task = QRMITask(
315364
job_id="j", backend="ibm_fez", resource_type="qiskit-runtime-service"
316365
)
317366
task._resource = Boom()
318-
assert prov.is_ready_to_ungate(task) is False
367+
try:
368+
prov.is_ready_to_ungate(task)
369+
except RuntimeError:
370+
pass
371+
else:
372+
raise AssertionError("the status error was swallowed")
373+
saved = os.environ.get("FLUENCE_POLL_MAX_ERRORS")
374+
os.environ["FLUENCE_POLL_MAX_ERRORS"] = "3"
375+
try:
376+
assert sidecar._poll(prov, task, 0, ungate=True) is False
377+
finally:
378+
if saved is None:
379+
del os.environ["FLUENCE_POLL_MAX_ERRORS"]
380+
else:
381+
os.environ["FLUENCE_POLL_MAX_ERRORS"] = saved
319382

320383

321384
# ── job id / session id accessors ─────────────────────────────────────────────

0 commit comments

Comments
 (0)