1313
1414Long-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.
3839import urllib .error
3940import urllib .parse
4041import urllib .request
42+ import uuid
4143from dataclasses import dataclass
4244from 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" )
0 commit comments