Coverage for scripts / live_release_validation / models.py: 100.00%
441 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Serializable run, checkpoint, action, and report models."""
3from __future__ import annotations
5import copy
6import json
7import os
8import time
9import traceback as traceback_module
10from collections.abc import Callable, Mapping
11from dataclasses import asdict, dataclass, field, is_dataclass
12from datetime import UTC, datetime
13from pathlib import Path
14from threading import RLock
15from typing import Any, ClassVar, Literal, cast
17from .artifact_io import (
18 REPORT_FILENAMES as _REPORT_FILENAMES,
19)
20from .artifact_io import (
21 atomic_write_text,
22 ensure_private_directory,
23 ensure_private_run_directory,
24)
25from .artifact_io import (
26 read_private_text as _read_private_text,
27)
28from .inference_contract import (
29 INFERENCE_OWNER_LABEL,
30 MOONCAKE_CONFIG_DIGEST_ANNOTATION,
31 MOONCAKE_STORE_RECONFIGURED_LOCAL_BUFFER_BYTES,
32 STRUCTURED_DECISION_PATH,
33 STRUCTURED_DECISION_QUESTIONS,
34 TRITON_INPUT_DATA,
35 TRITON_MODEL_NAME,
36 TRITON_MODEL_VERSION,
37 EndpointRole,
38 Framework,
39 InferenceRuntimeSpec,
40 expected_endpoint_count,
41 inference_deploy_extra_args,
42 inference_framework_env,
43 inference_health_path,
44 inference_identity_fields,
45 inference_mooncake_block,
46 inference_request_body,
47 inference_structured_decision_body,
48 validate_inference_settings,
49 write_triton_model_repository,
50)
51from .json_utils import loads_without_duplicate_keys
53__all__ = [
54 "INFERENCE_OWNER_LABEL",
55 "MOONCAKE_CONFIG_DIGEST_ANNOTATION",
56 "MOONCAKE_STORE_RECONFIGURED_LOCAL_BUFFER_BYTES",
57 "STRUCTURED_DECISION_PATH",
58 "STRUCTURED_DECISION_QUESTIONS",
59 "TRITON_INPUT_DATA",
60 "TRITON_MODEL_NAME",
61 "TRITON_MODEL_VERSION",
62 "ActionFailure",
63 "ActionResult",
64 "EndpointRole",
65 "Framework",
66 "InferenceRuntimeSpec",
67 "RunCheckpoint",
68 "RunContext",
69 "RunSettings",
70 "ValidationReport",
71 "atomic_write_json",
72 "atomic_write_text",
73 "ensure_private_directory",
74 "ensure_private_run_directory",
75 "utc_now",
76 "write_triton_model_repository",
77]
79SCHEMA_VERSION = 2
80ActionStatus = Literal["passed", "failed", "skipped"]
82_LIVE_VALIDATION_DISABLE_EFS_BACKUPS_CONTEXT = "gco_live_validation_disable_efs_automatic_backups"
85def utc_now() -> str:
86 """Return an RFC 3339-compatible UTC timestamp."""
87 return datetime.now(UTC).isoformat()
90class ActionFailure(RuntimeError):
91 """An action failure that keeps the evidence gathered before it failed.
93 The runner records ``details`` on the failed :class:`ActionResult`, so the
94 report carries what a passing run would have returned (for example every
95 example's result) instead of an empty object. The mapping is deep-copied:
96 callers often raise with a structure that also lives in ``checkpoint.state``.
97 """
99 def __init__(self, message: str, details: Mapping[str, Any]) -> None:
100 super().__init__(message)
101 self.details: dict[str, Any] = copy.deepcopy(dict(details))
104def to_jsonable(value: Any) -> Any:
105 """Convert report values to stable JSON-compatible primitives."""
106 if is_dataclass(value) and not isinstance(value, type):
107 return to_jsonable(asdict(value))
108 if isinstance(value, datetime):
109 return value.astimezone(UTC).isoformat()
110 if isinstance(value, Path):
111 return str(value)
112 if isinstance(value, dict):
113 return {str(key): to_jsonable(item) for key, item in value.items()}
114 if isinstance(value, (list, tuple)):
115 return [to_jsonable(item) for item in value]
116 if isinstance(value, (set, frozenset)):
117 return sorted(to_jsonable(item) for item in value)
118 if value is None or isinstance(value, (str, int, float, bool)):
119 return value
120 return str(value)
123def atomic_write_json(path: Path, value: Any) -> None:
124 """Atomically persist owner-only JSON inside a validated private directory."""
125 content = json.dumps(to_jsonable(value), indent=2, sort_keys=True) + "\n"
126 atomic_write_text(path, content)
129@dataclass(frozen=True)
130class RunSettings:
131 """Immutable operator inputs for one live validation run."""
133 #: Whether this harness may record and honor run-tag adoption authority
134 #: for stacks (``ownership/stacks.py``). Only the upgrade harness, whose
135 #: stacks are deployed by a ``gco`` subprocess rather than change sets the
136 #: harness prepares, opts in; a class attribute, so no flag can enable it.
137 allows_run_tag_adoption: ClassVar[bool] = False
139 run_id: str
140 repo_root: Path
141 report_dir: Path
142 checkpoint_path: Path
143 expected_account: str
144 expected_sha: str
145 expected_branch: str
146 profile: str
147 requested_actions: tuple[str, ...]
148 protected_stack_names: tuple[str, ...] = ("CDKToolkit", "GCOGitHubOIDCStack")
149 max_workers: int = 4
150 job_timeout_seconds: int = 1800
151 queue_timeout_seconds: int = 900
152 poll_interval_seconds: int = 10
153 destroy_attempts: int = 3
154 destroy_retry_delay_seconds: int = 30
155 confirm_kms_key_deletion: bool = False
156 resume: bool = False
157 #: Free-space floor (GiB) preflight enforces on the checkout, the report
158 #: directory, and the home volume before ``deploy`` builds container
159 #: images. A host that runs out of space mid-deploy fails the image
160 #: build, then fails to persist the checkpoint, which also aborts the
161 #: guaranteed cleanup — so the floor is checked before anything is
162 #: created. ``0`` disables the check.
163 min_free_disk_gib: int = 20
164 #: Off-by-default schedulers force-enabled for this run's deploy (threaded
165 #: to CDK as the ``helm_enabled_overrides`` context; see the ``schedulers``
166 #: action). Part of the resume identity: a resumed run must deploy and
167 #: validate the same chart set it started with.
168 optional_schedulers: tuple[str, ...] = ()
169 #: Canonical JSON of the ``eks_capabilities_overrides`` CDK context this
170 #: run deploys with (``--eks-capabilities``; see the ``eks-capabilities``
171 #: action), or ``""`` for none. Part of the resume identity (through
172 #: ``extra_cdk_context``) like the scheduler overrides.
173 eks_capabilities_overrides_json: str = ""
174 #: Canonical JSON of the ``tracing_overrides`` CDK context (deep-merged
175 #: over cdk.json ``tracing``), or ``""`` for none. The release entry point
176 #: always sets ``{"sample_ratio":1.0}`` so the ``tracing`` action finds a
177 #: span for every request it drives; part of the resume identity.
178 tracing_overrides_json: str = ""
180 # First-class inference action contract. ``inference_enabled`` is explicit
181 # because sibling harnesses reuse RunSettings with their own ``all`` action.
182 inference_enabled: bool = False
183 selected_region: str = ""
184 inference_runtimes: tuple[InferenceRuntimeSpec, ...] = ()
185 request_prompt: str = "Reply with a short deterministic validation response."
186 request_max_tokens: int = 8
187 namespace: str = "gco-inference"
188 health_path: str = "/health"
189 gpu_count: int = 0
190 baseline_replicas: int = 1
191 autoscale_initial_replicas: int = 1
192 hpa_min_replicas: int = 2
193 hpa_max_replicas: int = 2
194 hpa_cpu_target: int = 70
195 #: Scenarios in the matrix. ``None`` derives it from the runtimes (every
196 #: role of every runtime); an explicit value must match, so a checkpoint
197 #: or caller that still assumes the old four-scenario matrix fails loudly.
198 endpoint_count: int | None = None
199 proxy_tls_cpu_request: str = "100m"
200 proxy_tls_cpu_target: int = 70
201 command_timeout_seconds: int = 300
202 readiness_timeout_seconds: int = 1800
203 hpa_timeout_seconds: int = 900
204 deletion_timeout_seconds: int = 900
205 monitor_interval_seconds: int = 15
206 hpa_stability_intervals: int = 2
207 consent: bool = False
209 def __post_init__(self) -> None:
210 """Normalize output paths and validate the selected action contracts."""
211 report_dir = Path(os.path.abspath(os.fspath(self.report_dir)))
212 checkpoint_path = Path(os.path.abspath(os.fspath(self.checkpoint_path)))
213 object.__setattr__(self, "report_dir", report_dir)
214 object.__setattr__(self, "checkpoint_path", checkpoint_path)
215 if checkpoint_path.parent != report_dir:
216 raise ValueError("Checkpoint must be a direct child of the report directory")
217 if checkpoint_path.name in _REPORT_FILENAMES:
218 raise ValueError(
219 f"Checkpoint filename is reserved for a validation report: {checkpoint_path.name}"
220 )
221 if self.inference_enabled:
222 if self.endpoint_count is None:
223 object.__setattr__(
224 self, "endpoint_count", expected_endpoint_count(self.inference_runtimes)
225 )
226 validate_inference_settings(self)
228 def request_body(self, runtime: InferenceRuntimeSpec) -> dict[str, Any]:
229 """Return the deterministic body for one runtime in the matrix."""
230 return inference_request_body(self, runtime)
232 @staticmethod
233 def structured_decision_body(runtime: InferenceRuntimeSpec) -> dict[str, Any]:
234 """Return the ``/v1/systemone`` body the vLLM baseline scenario sends."""
235 return inference_structured_decision_body(runtime)
237 @staticmethod
238 def framework_env(runtime: InferenceRuntimeSpec) -> dict[str, str]:
239 return inference_framework_env(runtime)
241 @staticmethod
242 def deploy_extra_args(
243 runtime: InferenceRuntimeSpec, *, endpoint_name: str = "<endpoint>"
244 ) -> tuple[str, ...]:
245 return inference_deploy_extra_args(runtime, endpoint_name=endpoint_name)
247 def runtime_health_path(self, runtime: InferenceRuntimeSpec) -> str:
248 """The readiness path the deploy probes for ``runtime``."""
249 return inference_health_path(self, runtime)
251 @staticmethod
252 def mooncake_block(role: EndpointRole, *, reconfigured: bool = False) -> dict[str, Any] | None:
253 """The ``spec.mooncake`` block a scenario persists, or ``None``."""
254 return inference_mooncake_block(role, reconfigured=reconfigured)
256 @property
257 def kubeconfig_path(self) -> Path:
258 """Return the isolated kubeconfig path without touching the filesystem."""
259 return self.report_dir / "kubeconfig"
261 def _inference_identity_fields(self) -> dict[str, Any]:
262 return inference_identity_fields(self)
264 def extra_cdk_context(self) -> dict[str, str]:
265 """Extra ``--context`` pairs every CDK invocation of this run must carry."""
266 context = {_LIVE_VALIDATION_DISABLE_EFS_BACKUPS_CONTEXT: "true"}
267 if self.optional_schedulers:
268 context["helm_enabled_overrides"] = ",".join(self.optional_schedulers)
269 if self.eks_capabilities_overrides_json:
270 context["eks_capabilities_overrides"] = self.eks_capabilities_overrides_json
271 if self.tracing_overrides_json:
272 context["tracing_overrides"] = self.tracing_overrides_json
273 return context
275 def identity(self) -> dict[str, Any]:
276 """Return fields that must remain identical across resume attempts."""
277 identity = {
278 "run_id": self.run_id,
279 "repo_root": str(self.repo_root.resolve()),
280 "expected_account": self.expected_account,
281 "expected_sha": self.expected_sha,
282 "expected_branch": self.expected_branch,
283 "profile": self.profile,
284 "requested_actions": list(self.requested_actions),
285 "protected_stack_names": list(self.protected_stack_names),
286 "confirm_kms_key_deletion": self.confirm_kms_key_deletion,
287 "optional_schedulers": list(self.optional_schedulers),
288 "extra_cdk_context": self.extra_cdk_context(),
289 }
290 if self.inference_enabled:
291 identity["inference"] = self._inference_identity_fields()
292 return identity
295@dataclass
296class ActionResult:
297 """One action's durable report entry."""
299 name: str
300 description: str
301 status: ActionStatus
302 started_at: str
303 ended_at: str
304 duration_seconds: float
305 details: dict[str, Any] = field(default_factory=dict)
306 error: str | None = None
307 traceback: str | None = None
309 @classmethod
310 def passed(
311 cls,
312 *,
313 name: str,
314 description: str,
315 started_at: str,
316 started_monotonic: float,
317 ended_monotonic: float,
318 details: dict[str, Any] | None = None,
319 ) -> ActionResult:
320 return cls(
321 name=name,
322 description=description,
323 status="passed",
324 started_at=started_at,
325 ended_at=utc_now(),
326 duration_seconds=round(ended_monotonic - started_monotonic, 3),
327 details=details or {},
328 )
330 @classmethod
331 def failed(
332 cls,
333 *,
334 name: str,
335 description: str,
336 started_at: str,
337 started_monotonic: float,
338 ended_monotonic: float,
339 error: BaseException,
340 details: dict[str, Any] | None = None,
341 ) -> ActionResult:
342 return cls(
343 name=name,
344 description=description,
345 status="failed",
346 started_at=started_at,
347 ended_at=utc_now(),
348 duration_seconds=round(ended_monotonic - started_monotonic, 3),
349 details=details or {},
350 error=f"{type(error).__name__}: {error}",
351 traceback="".join(
352 traceback_module.format_exception(type(error), error, error.__traceback__)
353 ),
354 )
356 @classmethod
357 def from_dict(cls, value: dict[str, Any]) -> ActionResult:
358 return cls(
359 name=str(value["name"]),
360 description=str(value.get("description", "")),
361 status=value["status"],
362 started_at=str(value.get("started_at", "")),
363 ended_at=str(value.get("ended_at", "")),
364 duration_seconds=float(value.get("duration_seconds", 0.0)),
365 details=dict(value.get("details") or {}),
366 error=value.get("error"),
367 traceback=value.get("traceback"),
368 )
371@dataclass
372class RunCheckpoint:
373 """Crash-safe state used to resume and prove resource ownership."""
375 identity: dict[str, Any]
376 created_at: str = field(default_factory=utc_now)
377 updated_at: str = field(default_factory=utc_now)
378 completed_actions: list[str] = field(default_factory=list)
379 action_results: dict[str, ActionResult] = field(default_factory=dict)
380 deployment_attempted: bool = False
381 destroyed: bool = False
382 baseline: dict[str, Any] | None = None
383 state: dict[str, Any] = field(default_factory=dict)
384 schema_version: int = SCHEMA_VERSION
386 def to_dict(self) -> dict[str, Any]:
387 self.updated_at = utc_now()
388 serialized = to_jsonable(self)
389 if not isinstance(serialized, dict):
390 raise TypeError("RunCheckpoint did not serialize to an object")
391 return serialized
393 @classmethod
394 def from_path(cls, path: Path) -> RunCheckpoint:
395 try:
396 checkpoint_text = _read_private_text(path)
397 except (OSError, UnicodeError) as exc:
398 raise ValueError(f"Unable to read checkpoint {path}: {exc}") from exc
399 try:
400 raw = loads_without_duplicate_keys(checkpoint_text)
401 except ValueError as exc:
402 raise ValueError(f"Unable to read checkpoint {path}: {exc}") from exc
403 if not isinstance(raw, dict) or raw.get("schema_version") != SCHEMA_VERSION:
404 raise ValueError(f"Checkpoint {path} does not use supported schema {SCHEMA_VERSION}")
405 results_raw = raw.get("action_results") or {}
406 if not isinstance(results_raw, dict):
407 raise ValueError(f"Checkpoint {path} has invalid action_results")
408 state_raw = raw.get("state", {})
409 if not isinstance(state_raw, dict):
410 raise ValueError(f"Checkpoint {path} state must be an object")
411 return cls(
412 identity=dict(raw.get("identity") or {}),
413 created_at=str(raw.get("created_at") or utc_now()),
414 updated_at=str(raw.get("updated_at") or utc_now()),
415 completed_actions=[str(item) for item in raw.get("completed_actions") or []],
416 action_results={
417 str(name): ActionResult.from_dict(value)
418 for name, value in results_raw.items()
419 if isinstance(value, dict)
420 },
421 deployment_attempted=bool(raw.get("deployment_attempted", False)),
422 destroyed=bool(raw.get("destroyed", False)),
423 baseline=dict(raw["baseline"]) if isinstance(raw.get("baseline"), dict) else None,
424 state=dict(state_raw),
425 schema_version=SCHEMA_VERSION,
426 )
429@dataclass
430class ValidationReport:
431 """Local JSON/Markdown evidence for one run; contains account-specific identifiers."""
433 run_id: str
434 identity: dict[str, Any]
435 selected_actions: list[str]
436 started_at: str
437 ended_at: str | None = None
438 status: Literal["running", "passed", "partial", "failed", "interrupted"] = "running"
439 action_results: list[ActionResult] = field(default_factory=list)
440 cleanup: dict[str, Any] = field(default_factory=dict)
441 baseline: dict[str, Any] | None = None
442 final_inventory: dict[str, Any] | None = None
443 fatal_error: str | None = None
444 schema_version: int = SCHEMA_VERSION
445 #: Markdown heading and report-filename stem; sibling harnesses override
446 #: both (e.g. "GCO Example Job Validation" / "example-job-validation").
447 title: str = "GCO Live Release Validation"
448 report_stem: str = "live-release-validation"
450 def to_dict(self) -> dict[str, Any]:
451 serialized = to_jsonable(self)
452 if not isinstance(serialized, dict):
453 raise TypeError("ValidationReport did not serialize to an object")
454 return serialized
456 def write(self, directory: Path) -> tuple[Path, Path]:
457 """Write both report formats and return their paths."""
458 ensure_private_directory(directory)
459 json_path = directory / f"{self.report_stem}.json"
460 markdown_path = directory / f"{self.report_stem}.md"
461 atomic_write_json(json_path, self.to_dict())
462 atomic_write_text(markdown_path, self.to_markdown())
463 return json_path, markdown_path
465 def to_markdown(self) -> str:
466 """Render a compact human-reviewable report."""
467 identity = self.identity
468 selected_scope = ", ".join(f"`{name}`" for name in self.selected_actions) or "_none_"
469 lines = [
470 f"# {self.title}",
471 "",
472 f"- **Run:** `{self.run_id}`",
473 f"- **Status:** **{self.status.upper()}**",
474 f"- **Account:** `{identity.get('expected_account', 'unknown')}`",
475 f"- **Commit:** `{identity.get('expected_sha', 'unknown')}`",
476 f"- **Branch:** `{identity.get('expected_branch', 'unknown')}`",
477 f"- **Profile:** `{identity.get('profile', 'unknown')}`",
478 f"- **Selected action scope:** {selected_scope}",
479 f"- **Started:** `{self.started_at}`",
480 f"- **Ended:** `{self.ended_at or 'in progress'}`",
481 "",
482 "## Actions",
483 "",
484 "| Action | Status | Duration | Error |",
485 "|---|---:|---:|---|",
486 ]
487 for result in self.action_results:
488 error = (result.error or "").replace("|", "\\|").replace("\n", " ")
489 lines.append(
490 f"| `{result.name}` | {result.status} | {result.duration_seconds:.3f}s | {error} |"
491 )
492 if not self.action_results:
493 lines.append("| _none_ | skipped | 0s | |")
495 lines.extend(["", "## Cleanup", "", "```json"])
496 lines.append(json.dumps(to_jsonable(self.cleanup), indent=2, sort_keys=True))
497 lines.append("```")
499 final_summary = (self.final_inventory or {}).get("summary", {})
500 lines.extend(["", "## Final inventory", "", "```json"])
501 lines.append(json.dumps(to_jsonable(final_summary), indent=2, sort_keys=True))
502 lines.append("```")
504 failures = [result for result in self.action_results if result.status == "failed"]
505 if self.fatal_error or failures:
506 lines.extend(["", "## Failures", ""])
507 if self.fatal_error:
508 lines.extend(["```text", self.fatal_error, "```", ""])
509 for result in failures:
510 lines.append(f"### `{result.name}`")
511 lines.extend(
512 ["", "```text", result.traceback or result.error or "unknown", "```", ""]
513 )
515 lines.append("")
516 return "\n".join(lines)
519@dataclass
520class RunContext:
521 """Mutable dependencies and durable state shared by action handlers."""
523 settings: RunSettings
524 checkpoint: RunCheckpoint
525 report: ValidationReport
526 cdk_context: dict[str, Any]
527 deployment_regions: tuple[str, ...]
528 config: Any
529 session: Any
530 stack_manager: Any
531 aws_client: Any
532 job_manager: Any
533 persist_callback: Callable[[RunCheckpoint], None]
534 state_lock: RLock = field(default_factory=RLock, repr=False)
536 def persist(self) -> None:
537 with self.state_lock:
538 self.persist_callback(self.checkpoint)
540 def register_job(
541 self,
542 *,
543 name: str,
544 namespace: str,
545 region: str,
546 path: str,
547 run_label: str,
548 transport_region: str | None,
549 ) -> dict[str, Any]:
550 """Checkpoint one deterministic Job before submitting it.
552 The record is not destructive authority until an exact Kubernetes UID
553 has been observed together with the expected run/path labels.
554 """
555 with self.state_lock:
556 raw_jobs = self.checkpoint.state.setdefault("jobs", [])
557 if not isinstance(raw_jobs, list) or any(
558 not isinstance(item, dict) for item in raw_jobs
559 ):
560 raise RuntimeError("Checkpoint jobs must be a list of objects")
561 jobs = cast(list[dict[str, Any]], raw_jobs)
562 matches = [
563 item
564 for item in jobs
565 if item.get("name") == name
566 and item.get("namespace") == namespace
567 and item.get("region") == region
568 and item.get("path") == path
569 ]
570 if len(matches) > 1:
571 raise RuntimeError(
572 f"Checkpoint contains duplicate Job records for {region}:{namespace}/{name}"
573 )
574 expected = {
575 "name": name,
576 "namespace": namespace,
577 "region": region,
578 "path": path,
579 "run_label": run_label,
580 "transport_region": transport_region,
581 }
582 if matches:
583 record = matches[0]
584 for key, value in expected.items():
585 if record.get(key) != value:
586 raise RuntimeError(
587 f"Checkpoint Job identity changed for {region}:{namespace}/{name}: {key}"
588 )
589 record.setdefault(
590 "submission_state",
591 "appeared" if record.get("uid") else "registered",
592 )
593 else:
594 record = {
595 **expected,
596 "uid": None,
597 "deleted": False,
598 "submission_state": "registered",
599 }
600 jobs.append(record)
601 self.persist_callback(self.checkpoint)
602 return record
604 def prepare_job_submission(
605 self,
606 record: dict[str, Any],
607 *,
608 envelope: dict[str, Any],
609 resumable: bool,
610 ) -> None:
611 """Persist one immutable canonical envelope before any submission attempt."""
612 canonical = to_jsonable(envelope)
613 if not isinstance(canonical, dict):
614 raise RuntimeError("A Job submission envelope must be a JSON object")
615 with self.state_lock:
616 state = str(record.get("submission_state") or "registered")
617 previous = record.get("submission_envelope")
618 if previous is not None and previous != canonical:
619 raise RuntimeError("Checkpointed Job submission envelope changed")
620 previous_resumable = record.get("submission_resumable")
621 if previous_resumable is not None and bool(previous_resumable) != resumable:
622 raise RuntimeError("Checkpointed Job resumability contract changed")
623 if state == "registered":
624 record["submission_state"] = "prepared"
625 elif state not in {
626 "prepared",
627 "submitting",
628 "submitted",
629 "appeared",
630 "deleted",
631 "blocked",
632 "not_submitted",
633 }:
634 raise RuntimeError(f"Cannot prepare Job submission from state {state!r}")
635 record["submission_envelope"] = canonical
636 record["submission_resumable"] = resumable
637 self.persist_callback(self.checkpoint)
639 def begin_job_submission(
640 self,
641 record: dict[str, Any],
642 *,
643 reconciliation_timeout_seconds: int,
644 ) -> None:
645 """Persist the ambiguous check/use boundary immediately before submission."""
646 with self.state_lock:
647 state = str(record.get("submission_state") or "registered")
648 resumable = bool(record.get("submission_resumable", False))
649 if state != "prepared" and not (state == "submitting" and resumable):
650 raise RuntimeError(f"Cannot begin Job submission from state {state!r}")
651 if not isinstance(record.get("submission_envelope"), dict):
652 raise RuntimeError("Cannot submit a Job without a checkpointed envelope")
653 now = time.time()
654 record["submission_state"] = "submitting"
655 record["submission_started_at"] = now
656 record["submission_reconcile_deadline"] = now + reconciliation_timeout_seconds
657 record["submission_attempts"] = int(record.get("submission_attempts") or 0) + 1
658 self.persist_callback(self.checkpoint)
660 def finish_job_submission(
661 self,
662 record: dict[str, Any],
663 submission: dict[str, Any],
664 *,
665 appearance_timeout_seconds: int,
666 ) -> None:
667 """Persist an acknowledgement and begin a fresh bounded appearance window."""
668 with self.state_lock:
669 state = str(record.get("submission_state") or "")
670 if state not in {"submitting", "submitted", "appeared"}:
671 raise RuntimeError(f"Cannot finish Job submission from state {state!r}")
672 acknowledged_at = time.time()
673 if state != "appeared":
674 record["submission_state"] = "submitted"
675 record["submission"] = to_jsonable(submission)
676 record["submission_acknowledged_at"] = acknowledged_at
677 record["appearance_deadline"] = acknowledged_at + appearance_timeout_seconds
678 self.persist_callback(self.checkpoint)
680 def block_job_submission(self, record: dict[str, Any], reason: str) -> None:
681 """Fail closed when a non-idempotent submission cannot be reconciled."""
682 with self.state_lock:
683 record["submission_state"] = "blocked"
684 record["submission_blocked_reason"] = reason
685 record["submission_blocked_at"] = time.time()
686 self.persist_callback(self.checkpoint)
688 def mark_job_not_submitted(self, record: dict[str, Any]) -> None:
689 """Record authoritative absence only before a side effect could escape."""
690 with self.state_lock:
691 state = str(record.get("submission_state") or "registered")
692 if state not in {"registered", "prepared", "not_submitted"}:
693 raise RuntimeError(f"Cannot mark Job not submitted from state {state!r}")
694 record["submission_state"] = "not_submitted"
695 record["not_submitted_at"] = time.time()
696 self.persist_callback(self.checkpoint)
698 def mark_central_job_cancelled_before_claim(
699 self,
700 record: dict[str, Any],
701 *,
702 job_id: str,
703 ) -> None:
704 """Record terminal queue proof that a central Job never reached a worker."""
705 if not job_id:
706 raise RuntimeError("Central cancellation proof requires a queue Job ID")
707 with self.state_lock:
708 state = str(record.get("submission_state") or "registered")
709 previous_job_id = record.get("central_cancelled_before_claim_job_id")
710 if state == "not_submitted" and previous_job_id == job_id:
711 return
712 if state not in {"submitting", "submitted"}:
713 raise RuntimeError(f"Cannot apply central cancellation proof from state {state!r}")
714 if record.get("path") != "dynamodb" or record.get("uid"):
715 raise RuntimeError(
716 "Central cancellation proof cannot replace immutable Kubernetes UID evidence"
717 )
718 record["submission_state"] = "not_submitted"
719 record["central_cancelled_before_claim_job_id"] = job_id
720 record["central_cancelled_before_claim_at"] = time.time()
721 self.persist_callback(self.checkpoint)
723 def mark_central_job_not_created_by_worker(
724 self,
725 record: dict[str, Any],
726 *,
727 job_id: str,
728 ) -> None:
729 """Record explicit worker proof that Kubernetes mutation never began."""
730 if not job_id:
731 raise RuntimeError("Central worker no-workload proof requires a queue Job ID")
732 with self.state_lock:
733 state = str(record.get("submission_state") or "registered")
734 previous_job_id = record.get("central_worker_not_created_job_id")
735 if state == "not_submitted" and previous_job_id == job_id:
736 return
737 if state not in {"submitting", "submitted"}:
738 raise RuntimeError(
739 f"Cannot apply central worker no-workload proof from state {state!r}"
740 )
741 central_identity = (
742 record.get("k8s_job_name"),
743 record.get("k8s_job_namespace"),
744 record.get("k8s_job_uid"),
745 )
746 if (
747 record.get("path") != "dynamodb"
748 or record.get("uid") is not None
749 or any(value is not None for value in central_identity)
750 ):
751 raise RuntimeError(
752 "Central worker no-workload proof cannot replace Kubernetes identity evidence"
753 )
754 if record.get("central_cancelled_before_claim_job_id") is not None:
755 raise RuntimeError(
756 "Central worker no-workload proof conflicts with cancellation proof"
757 )
758 record["submission_state"] = "not_submitted"
759 record["central_worker_not_created_job_id"] = job_id
760 record["central_worker_not_created_at"] = time.time()
761 self.persist_callback(self.checkpoint)
763 def bind_central_job_identity(
764 self,
765 record: dict[str, Any],
766 *,
767 job_id: str,
768 name: str,
769 namespace: str,
770 uid: str,
771 appearance_timeout_seconds: int,
772 ) -> bool:
773 """Bind a requested central-queue record to its immutable Kubernetes Job.
775 The requested name and namespace remain canonical submission/replay
776 identity. The worker-persisted identity is a separate destructive
777 authority and starts one fresh workload-appearance window when first
778 observed.
779 """
780 if record.get("path") != "dynamodb":
781 raise RuntimeError("Central Kubernetes identity requires a DynamoDB workload record")
782 if not all((job_id, name, namespace, uid)):
783 raise RuntimeError("Central Kubernetes identity fields must all be non-empty")
784 if appearance_timeout_seconds <= 0:
785 raise RuntimeError("Central workload appearance timeout must be positive")
787 immutable = {
788 "central_queue_job_id": job_id,
789 "k8s_job_name": name,
790 "k8s_job_namespace": namespace,
791 "k8s_job_uid": uid,
792 }
793 with self.state_lock:
794 present = {key: record.get(key) for key in immutable}
795 populated = [value is not None for value in present.values()]
796 if any(populated):
797 if not all(populated):
798 raise RuntimeError("Checkpoint contains a partial central Kubernetes identity")
799 for key, value in immutable.items():
800 if present[key] != value:
801 raise RuntimeError(
802 f"Central Kubernetes identity changed for {job_id}: {key}"
803 )
804 if record.get("uid") != uid:
805 raise RuntimeError(
806 "Central Kubernetes UID disagrees with checkpoint ownership authority"
807 )
808 return False
810 previous_uid = record.get("uid")
811 if previous_uid is not None and previous_uid != uid:
812 raise RuntimeError(
813 f"Kubernetes Job UID changed from {previous_uid!r} to {uid!r}; "
814 "refusing central ownership"
815 )
816 bound_at = time.time()
817 was_deleted = bool(record.get("deleted"))
818 record.update(immutable)
819 record["uid"] = uid
820 record["central_identity_bound_at"] = bound_at
821 record["appearance_deadline"] = bound_at + appearance_timeout_seconds
822 if was_deleted:
823 record["requested_identity_deletion_superseded_at"] = bound_at
824 record.pop("deleted_at", None)
825 record["deleted"] = False
826 record["submission_state"] = "appeared"
827 self.persist_callback(self.checkpoint)
828 return True
830 def record_job_uid(self, record: dict[str, Any], uid: str) -> None:
831 """Bind a pending Job record to one immutable Kubernetes UID."""
832 if not uid:
833 raise RuntimeError("Cannot checkpoint an empty Kubernetes Job UID")
834 with self.state_lock:
835 persisted_central_uid = record.get("k8s_job_uid")
836 if persisted_central_uid is not None and persisted_central_uid != uid:
837 raise RuntimeError(
838 "Observed Kubernetes Job UID differs from persisted central worker identity"
839 )
840 previous = record.get("uid")
841 if previous == uid:
842 return
843 if previous is not None:
844 raise RuntimeError(
845 f"Kubernetes Job UID changed from {previous!r} to {uid!r}; refusing ownership"
846 )
847 record["uid"] = uid
848 record["submission_state"] = "appeared"
849 self.persist_callback(self.checkpoint)
851 def mark_job_deleted(self, record: dict[str, Any]) -> None:
852 with self.state_lock:
853 record["deleted"] = True
854 record["submission_state"] = "deleted"
855 record["deleted_at"] = time.time()
856 self.persist_callback(self.checkpoint)