Skip to content

Commit 111ba45

Browse files
committed
Improve node claiming process
Due state transition limitations, we claim node not by state change, but by defining job_id. Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent 52ea400 commit 111ba45

2 files changed

Lines changed: 81 additions & 23 deletions

File tree

src/kernel_ci_cloud_labs/pull_labs_poller.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
1414
Long-lived service (or one-shot job) that:
1515
1. Polls kernelci-api /events for new pull-lab jobs.
16-
2. Claims each job node (state=running) so other pollers skip it.
16+
2. Claims each job node by recording its data.job_id — kernelci-api has no
17+
node state usable as a "claimed" marker (see _claim_node).
1718
3. Fetches each job's PULL_LABS job_definition JSON.
1819
4. Translates it into a pullab_cloud run config and runs the pipeline.
1920
5. Submits per-test results directly to KCIDB.
@@ -38,6 +39,7 @@
3839
import urllib.error
3940
import urllib.parse
4041
import urllib.request
42+
import uuid
4143
from dataclasses import dataclass
4244
from typing import Any, Callable, Dict, List, Optional, Tuple
4345

@@ -627,12 +629,37 @@ def _node_url(self, node_id: str) -> str:
627629
return f"{self.api_base_uri.rstrip('/')}/node/{node_id}"
628630

629631
def _claim_node(self, node: Dict[str, Any]) -> bool:
630-
"""Claim a job node by transitioning it to state=running.
631-
632-
Re-reads the node first: if it is no longer "available", another
633-
poller has already taken it, so we skip it. This narrows -- but,
634-
without an atomic compare-and-set in kernelci-api, cannot fully
635-
close -- the window for two pollers claiming the same job.
632+
"""Claim a job node by recording this poller's job id on it.
633+
634+
kernelci-api has no node *state* that can serve as a "claimed"
635+
marker. Its state machine (kernelci-core,
636+
Node.validate_node_state_transition) only permits::
637+
638+
running -> available, closing, done
639+
available -> closing, done
640+
closing -> done
641+
642+
so a job node polled in "available" state cannot be moved to
643+
"running" -- the API rejects it with HTTP 400 "Transition not allowed
644+
with state: running". The only intermediate state reachable from
645+
"available" is "closing", and that is unusable too: kernelci-pipeline
646+
(src/timeout.py, Closing handler) auto-transitions any "closing" node
647+
with no running descendants to "done" -- with no result -- within
648+
~60s, which would finish a multi-minute boot job out from under us.
649+
650+
Instead we claim by writing data.job_id -- the "Runtime job ID" field
651+
of the node's data model (kernelci-core TestData). The pull-lab
652+
poller *is* the runtime, so this is the semantically correct field;
653+
the node stays "available" (available -> available is a no-op
654+
transition) and the value persists because job_id is a declared
655+
field. A node that already carries a data.job_id has been claimed.
656+
657+
The claim is best effort: kernelci-api has no compare-and-set, so the
658+
PUT is a full-document overwrite and two pollers that both read the
659+
node before either writes can each claim it. Parallel pollers must
660+
therefore be partitioned by platform (KERNELCI_PLATFORMS) so they
661+
never compete for the same node; this claim only skips a node already
662+
taken or finished, it cannot guarantee exclusion.
636663
637664
Returns True only if this poller now owns the node.
638665
"""
@@ -648,16 +675,30 @@ def _claim_node(self, node: Dict[str, Any]) -> bool:
648675
return False
649676
state = current.get("state")
650677
if state != "available":
651-
logger.info("Skipping node %s: already claimed (state=%s)", node_id, state)
678+
logger.info(
679+
"Skipping node %s: no longer available (state=%s)", node_id, state
680+
)
681+
return False
682+
data = current.get("data") or {}
683+
existing = data.get("job_id")
684+
if existing:
685+
logger.info(
686+
"Skipping node %s: already claimed (data.job_id=%s)",
687+
node_id, existing,
688+
)
652689
return False
653-
current["state"] = "running"
690+
job_id = f"{self.runtime_name}:{uuid.uuid4().hex}"
691+
data["job_id"] = job_id
692+
current["data"] = data
654693
payload = {k: v for k, v in current.items() if k not in NODE_READ_ONLY_FIELDS}
655694
try:
695+
# HTTPError is a URLError subclass, so a 400/422 from the PUT is
696+
# caught here too: a failed claim just skips the node.
656697
_http_put_json(url, payload, token=self.api_token)
657698
except (urllib.error.URLError, json.JSONDecodeError) as e:
658-
logger.error("Failed to claim node %s (PUT state=running): %s", node_id, e)
699+
logger.error("Failed to claim node %s (PUT data.job_id): %s", node_id, e)
659700
return False
660-
logger.info("Claimed node %s (state=running)", node_id)
701+
logger.info("Claimed node %s (data.job_id=%s)", node_id, job_id)
661702
return True
662703

663704
def _finish_node(self, node_id: str, outcome: NodeOutcome) -> bool:
@@ -705,11 +746,11 @@ def _finish_node(self, node_id: str, outcome: NodeOutcome) -> bool:
705746
def process_event(self, event: Dict[str, Any]) -> bool:
706747
"""Process one event end to end. Returns True on success.
707748
708-
The job node is claimed (state=running) before any work starts and
709-
finished (state=done + result, plus error_code/error_msg on an
710-
infrastructure failure) afterwards, whatever the outcome. A node we
711-
fail to claim -- already taken, or an API error -- is skipped
712-
without being run or submitted.
749+
The job node is claimed (data.job_id recorded) before any work
750+
starts and finished (state=done + result, plus error_code/error_msg
751+
on an infrastructure failure) afterwards, whatever the outcome. A
752+
node we cannot claim -- already taken, finished, or an API error --
753+
is skipped without being run or submitted.
713754
"""
714755
node = event.get("node") or {}
715756
node_id = node.get("id")

tests/test_pull_labs_poller.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -371,21 +371,38 @@ def test_never_returns_incomplete(self):
371371

372372

373373
class TestNodeStateUpdates:
374-
"""_claim_node() / _finish_node() PUT node state to kernelci-api."""
374+
"""_claim_node() records data.job_id; _finish_node() PUTs state=done."""
375375

376-
def test_claim_available_node_puts_running(self):
376+
def test_claim_available_node_records_job_id(self):
377+
# kernelci-api has no claimable *state*, so claiming writes the
378+
# node's data.job_id ("Runtime job ID") and leaves state=available.
377379
p = PullLabsPoller(_minimal_kc())
378380
puts = []
379-
with patch(_GET, return_value={"id": "n1", "state": "available"}), \
381+
with patch(_GET, return_value={"id": "n1", "state": "available", "data": {}}), \
380382
patch(_PUT, side_effect=lambda url, payload, **kw: puts.append((url, payload))):
381383
assert p._claim_node({"id": "n1"}) is True
382384
assert len(puts) == 1
383385
assert puts[0][0].endswith("/node/n1")
384-
assert puts[0][1]["state"] == "running"
386+
# state untouched (available -> available is a no-op transition);
387+
# the claim lives in data.job_id.
388+
assert puts[0][1]["state"] == "available"
389+
assert puts[0][1]["data"]["job_id"]
385390

386-
def test_claim_skips_already_claimed_node(self):
391+
def test_claim_skips_node_already_claimed(self):
392+
# A node that already carries a data.job_id has been picked up.
387393
p = PullLabsPoller(_minimal_kc())
388-
with patch(_GET, return_value={"id": "n1", "state": "running"}), \
394+
with patch(_GET, return_value={
395+
"id": "n1", "state": "available",
396+
"data": {"job_id": "other-poller:abc123"}}), \
397+
patch(_PUT) as put:
398+
assert p._claim_node({"id": "n1"}) is False
399+
put.assert_not_called()
400+
401+
def test_claim_skips_node_no_longer_available(self):
402+
# A node that has moved on from "available" (already finished by the
403+
# pipeline or another poller) is skipped -- without any PUT.
404+
p = PullLabsPoller(_minimal_kc())
405+
with patch(_GET, return_value={"id": "n1", "state": "done"}), \
389406
patch(_PUT) as put:
390407
assert p._claim_node({"id": "n1"}) is False
391408
put.assert_not_called()
@@ -397,7 +414,7 @@ def test_claim_skips_on_get_error(self):
397414

398415
def test_claim_skips_on_put_error(self):
399416
p = PullLabsPoller(_minimal_kc())
400-
with patch(_GET, return_value={"id": "n1", "state": "available"}), \
417+
with patch(_GET, return_value={"id": "n1", "state": "available", "data": {}}), \
401418
patch(_PUT, side_effect=urllib.error.URLError("boom")):
402419
assert p._claim_node({"id": "n1"}) is False
403420

0 commit comments

Comments
 (0)