retention.py
python
sha256:ca1d0e687bff8686b37126eb8e4fb6d38b40e189352a4920d66ae89c7274e340
Add T4 job cancellation retry and validation
Human
minor
⚠ breaking
41 days ago
| 1 | """Retention policy classes and expiry evaluation for fixture artifacts.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from dataclasses import dataclass |
| 6 | from datetime import UTC, datetime, timedelta |
| 7 | from enum import Enum |
| 8 | from types import MappingProxyType |
| 9 | from typing import Mapping |
| 10 | |
| 11 | from scooling_lab.errors import ApiError, ErrorCode |
| 12 | |
| 13 | |
| 14 | class RetentionPolicyClass(str, Enum): |
| 15 | """Supported artifact retention classes for synthetic fixture jobs.""" |
| 16 | |
| 17 | EPHEMERAL = "ephemeral" |
| 18 | STANDARD = "standard" |
| 19 | EXTENDED = "extended" |
| 20 | |
| 21 | |
| 22 | @dataclass(frozen=True) |
| 23 | class RetentionBounds: |
| 24 | """Default and allowed TTL range for one retention class.""" |
| 25 | |
| 26 | default_ttl_seconds: int |
| 27 | min_ttl_seconds: int |
| 28 | max_ttl_seconds: int |
| 29 | |
| 30 | |
| 31 | @dataclass(frozen=True) |
| 32 | class RetentionPolicy: |
| 33 | """Validated retention policy for one training job request.""" |
| 34 | |
| 35 | policy_class: RetentionPolicyClass |
| 36 | ttl_seconds: int |
| 37 | |
| 38 | def to_public_dict(self) -> dict[str, str | int]: |
| 39 | """Serialize the policy without private paths, text, or credentials.""" |
| 40 | |
| 41 | return { |
| 42 | "policyClass": self.policy_class.value, |
| 43 | "ttlSeconds": self.ttl_seconds, |
| 44 | } |
| 45 | |
| 46 | |
| 47 | RETENTION_BOUNDS: MappingProxyType[RetentionPolicyClass, RetentionBounds] = MappingProxyType( |
| 48 | { |
| 49 | RetentionPolicyClass.EPHEMERAL: RetentionBounds( |
| 50 | default_ttl_seconds=3_600, |
| 51 | min_ttl_seconds=60, |
| 52 | max_ttl_seconds=86_400, |
| 53 | ), |
| 54 | RetentionPolicyClass.STANDARD: RetentionBounds( |
| 55 | default_ttl_seconds=2_592_000, |
| 56 | min_ttl_seconds=3_600, |
| 57 | max_ttl_seconds=7_776_000, |
| 58 | ), |
| 59 | RetentionPolicyClass.EXTENDED: RetentionBounds( |
| 60 | default_ttl_seconds=31_536_000, |
| 61 | min_ttl_seconds=86_400, |
| 62 | max_ttl_seconds=63_072_000, |
| 63 | ), |
| 64 | } |
| 65 | ) |
| 66 | |
| 67 | |
| 68 | def default_retention_policy() -> RetentionPolicy: |
| 69 | """Return the default artifact retention policy for fixture jobs.""" |
| 70 | |
| 71 | bounds = RETENTION_BOUNDS[RetentionPolicyClass.STANDARD] |
| 72 | return RetentionPolicy( |
| 73 | policy_class=RetentionPolicyClass.STANDARD, |
| 74 | ttl_seconds=bounds.default_ttl_seconds, |
| 75 | ) |
| 76 | |
| 77 | |
| 78 | def retention_policy_from_mapping(value: object) -> RetentionPolicy: |
| 79 | """Validate a request retention policy or return the default policy.""" |
| 80 | |
| 81 | if value is None: |
| 82 | return default_retention_policy() |
| 83 | if not isinstance(value, Mapping): |
| 84 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 85 | if set(value).difference({"policyClass", "ttlSeconds"}): |
| 86 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 87 | |
| 88 | policy_class_value = value.get("policyClass") |
| 89 | if not isinstance(policy_class_value, str): |
| 90 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 91 | try: |
| 92 | policy_class = RetentionPolicyClass(policy_class_value) |
| 93 | except ValueError as exc: |
| 94 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) from exc |
| 95 | |
| 96 | bounds = RETENTION_BOUNDS[policy_class] |
| 97 | ttl_value = value.get("ttlSeconds", bounds.default_ttl_seconds) |
| 98 | if not isinstance(ttl_value, int) or isinstance(ttl_value, bool): |
| 99 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 100 | if not bounds.min_ttl_seconds <= ttl_value <= bounds.max_ttl_seconds: |
| 101 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 102 | |
| 103 | return RetentionPolicy(policy_class=policy_class, ttl_seconds=ttl_value) |
| 104 | |
| 105 | |
| 106 | def parse_utc_timestamp(value: str) -> datetime: |
| 107 | """Parse a public UTC timestamp emitted by Scooling Lab.""" |
| 108 | |
| 109 | if not value.endswith("Z"): |
| 110 | raise ApiError(ErrorCode.INTERNAL_ERROR, 500) |
| 111 | return datetime.fromisoformat(value.removesuffix("Z") + "+00:00").astimezone(UTC) |
| 112 | |
| 113 | |
| 114 | def utc_timestamp(value: datetime) -> str: |
| 115 | """Format a UTC datetime for public API metadata.""" |
| 116 | |
| 117 | return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") |
| 118 | |
| 119 | |
| 120 | def expires_at(created_at: str, policy: RetentionPolicy) -> str: |
| 121 | """Return the expiry timestamp for an artifact under a retention policy.""" |
| 122 | |
| 123 | expires = parse_utc_timestamp(created_at) + timedelta(seconds=policy.ttl_seconds) |
| 124 | return utc_timestamp(expires) |
| 125 | |
| 126 | |
| 127 | def is_expired(created_at: str, policy: RetentionPolicy, now: datetime) -> bool: |
| 128 | """Return whether an artifact is expired at the supplied UTC instant.""" |
| 129 | |
| 130 | expiry = parse_utc_timestamp(expires_at(created_at, policy)) |
| 131 | return now.astimezone(UTC) >= expiry |
File History
1 commit
sha256:ca1d0e687bff8686b37126eb8e4fb6d38b40e189352a4920d66ae89c7274e340
Add T4 job cancellation retry and validation
Human
minor
⚠
41 days ago