store.py python
529 lines 19.2 KB
Raw
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a feat(slice-7): T3 dataset review lifecycle, job queue, prov… Human minor ⚠ breaking 42 days ago
1 """Thread-safe job and artifact store for the in-process fake worker."""
2
3 from __future__ import annotations
4
5 import json
6 import threading
7 from dataclasses import dataclass, field
8 from datetime import UTC, datetime
9 from pathlib import Path
10 from typing import Iterable
11
12 from scooling_lab.contracts import (
13 TrainingJobRequest,
14 TrainingJobStatus,
15 require_artifact_id,
16 require_job_id,
17 )
18 from scooling_lab.errors import ApiError, ErrorCode
19 from scooling_lab.provenance import ProvenanceRecord, validate_provenance_record
20 from scooling_lab.retention import (
21 RetentionPolicy,
22 expires_at,
23 is_expired,
24 retention_policy_from_mapping,
25 )
26 from scooling_lab.state_machine import transition
27
28
29 def utc_now_iso() -> str:
30 """Return a UTC timestamp formatted for public metadata."""
31
32 return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
33
34
35 @dataclass(frozen=True)
36 class ArtifactMetadata:
37 """Placeholder artifact metadata registered by the fake worker."""
38
39 id: str
40 job_id: str
41 dataset_hash: str
42 artifact_hash: str
43 created_at: str
44 provenance_id: str
45 retention_policy: RetentionPolicy
46
47 def to_dict(self) -> dict[str, object]:
48 """Serialize the artifact metadata for API responses and persistence."""
49
50 return {
51 "id": self.id,
52 "jobId": self.job_id,
53 "datasetHash": self.dataset_hash,
54 "artifactHash": self.artifact_hash,
55 "createdAt": self.created_at,
56 "expiresAt": expires_at(self.created_at, self.retention_policy),
57 "provenanceRecordId": self.provenance_id,
58 "retentionPolicy": self.retention_policy.to_public_dict(),
59 }
60
61 @classmethod
62 def from_dict(cls, payload: dict[str, object]) -> "ArtifactMetadata":
63 """Rehydrate persisted artifact metadata."""
64
65 return cls(
66 id=str(payload["id"]),
67 job_id=str(payload["jobId"]),
68 dataset_hash=str(payload["datasetHash"]),
69 artifact_hash=str(payload["artifactHash"]),
70 created_at=str(payload["createdAt"]),
71 provenance_id=str(payload["provenanceRecordId"]),
72 retention_policy=retention_policy_from_mapping(payload["retentionPolicy"]),
73 )
74
75
76 @dataclass(frozen=True)
77 class DeletionReceipt:
78 """Content-free result for idempotent artifact deletion."""
79
80 job_id: str
81 artifact_id: str
82 deleted: bool
83 already_deleted: bool
84 verified: bool
85
86 def to_dict(self) -> dict[str, str | bool]:
87 """Serialize deletion status without hashes, paths, or request content."""
88
89 return {
90 "alreadyDeleted": self.already_deleted,
91 "artifactId": self.artifact_id,
92 "deleted": self.deleted,
93 "jobId": self.job_id,
94 "verified": self.verified,
95 }
96
97
98 @dataclass
99 class TrainingJobRecord:
100 """Internal record for one fixture training job."""
101
102 id: str
103 request: TrainingJobRequest | None
104 status: TrainingJobStatus = TrainingJobStatus.QUEUED
105 created_at: str = field(default_factory=utc_now_iso)
106 updated_at: str = field(default_factory=utc_now_iso)
107 artifacts: list[ArtifactMetadata] = field(default_factory=list)
108 provenance: ProvenanceRecord | None = None
109 deleted_at: str | None = None
110
111 def to_public_dict(self) -> dict[str, object]:
112 """Serialize the safe job status shape for API responses."""
113
114 if self.status == TrainingJobStatus.DELETED:
115 payload: dict[str, object] = {
116 "id": self.id,
117 "status": self.status.value,
118 "createdAt": self.created_at,
119 "updatedAt": self.updated_at,
120 }
121 if self.deleted_at is not None:
122 payload["deletedAt"] = self.deleted_at
123 return payload
124 if self.request is None:
125 raise ApiError(ErrorCode.INTERNAL_ERROR, 500)
126 return {
127 "id": self.id,
128 "status": self.status.value,
129 "createdAt": self.created_at,
130 "updatedAt": self.updated_at,
131 "request": self.request.to_public_dict(),
132 }
133
134 def to_persisted_dict(self) -> dict[str, object]:
135 """Serialize the full non-secret record to the server-controlled store."""
136
137 if self.status == TrainingJobStatus.DELETED:
138 payload: dict[str, object] = {
139 "id": self.id,
140 "status": self.status.value,
141 "createdAt": self.created_at,
142 "updatedAt": self.updated_at,
143 }
144 if self.deleted_at is not None:
145 payload["deletedAt"] = self.deleted_at
146 if self.provenance is not None:
147 payload["provenance"] = self.provenance.to_dict()
148 return payload
149 if self.request is None:
150 raise ApiError(ErrorCode.INTERNAL_ERROR, 500)
151 return {
152 "id": self.id,
153 "status": self.status.value,
154 "createdAt": self.created_at,
155 "updatedAt": self.updated_at,
156 "request": {
157 "idempotencyKey": self.request.idempotency_key,
158 "datasetId": self.request.dataset_id,
159 "modelId": self.request.model_id,
160 "requestedBy": self.request.requested_by,
161 "retentionPolicy": self.request.retention_policy.to_public_dict(),
162 "trainingParameters": dict(self.request.training_parameters),
163 },
164 "artifacts": [artifact.to_dict() for artifact in self.artifacts],
165 "provenance": self.provenance.to_dict()
166 if self.provenance is not None
167 else None,
168 }
169
170 @classmethod
171 def from_persisted_dict(cls, payload: dict[str, object]) -> "TrainingJobRecord":
172 """Rehydrate a job record from server-controlled JSON."""
173
174 status = TrainingJobStatus(str(payload["status"]))
175 if status == TrainingJobStatus.DELETED:
176 deleted_at = payload.get("deletedAt")
177 provenance_payload = payload.get("provenance")
178 provenance: ProvenanceRecord | None = None
179 if provenance_payload is not None:
180 if not isinstance(provenance_payload, dict):
181 raise ApiError(ErrorCode.INTERNAL_ERROR, 500)
182 provenance = ProvenanceRecord.from_mapping(provenance_payload)
183 return cls(
184 id=str(payload["id"]),
185 request=None,
186 status=status,
187 created_at=str(payload["createdAt"]),
188 updated_at=str(payload["updatedAt"]),
189 deleted_at=str(deleted_at) if deleted_at is not None else None,
190 provenance=provenance,
191 )
192 request_payload = payload["request"]
193 if not isinstance(request_payload, dict):
194 raise ApiError(ErrorCode.INTERNAL_ERROR, 500)
195 artifacts_payload = payload.get("artifacts", [])
196 if not isinstance(artifacts_payload, list):
197 raise ApiError(ErrorCode.INTERNAL_ERROR, 500)
198 provenance_payload = payload.get("provenance")
199 provenance = None
200 if provenance_payload is not None:
201 if not isinstance(provenance_payload, dict):
202 raise ApiError(ErrorCode.INTERNAL_ERROR, 500)
203 provenance = ProvenanceRecord.from_mapping(provenance_payload)
204 return cls(
205 id=str(payload["id"]),
206 request=TrainingJobRequest.from_mapping(request_payload),
207 status=status,
208 created_at=str(payload["createdAt"]),
209 updated_at=str(payload["updatedAt"]),
210 artifacts=[
211 ArtifactMetadata.from_dict(artifact)
212 for artifact in artifacts_payload
213 if isinstance(artifact, dict)
214 ],
215 provenance=provenance,
216 )
217
218
219 class TrainingJobStore:
220 """In-memory job store with optional server-controlled JSON persistence."""
221
222 def __init__(
223 self,
224 persistence_path: Path | None = None,
225 queue_limit: int = 5,
226 max_concurrent_running: int = 1,
227 ) -> None:
228 """Create a store with bounded queued/running capacity and a concurrency limit.
229
230 ``queue_limit`` caps the total number of queued+running jobs.
231 ``max_concurrent_running`` caps how many jobs may be in the running
232 state simultaneously (default 1 — FIFO serial execution).
233 """
234
235 self._lock = threading.RLock()
236 self._jobs: dict[str, TrainingJobRecord] = {}
237 self._persistence_path = persistence_path
238 self._queue_limit = queue_limit
239 self._max_concurrent_running = max_concurrent_running
240 if persistence_path is not None and persistence_path.exists():
241 self._load()
242
243 def create(self, request: TrainingJobRequest) -> TrainingJobRecord:
244 """Create or return the deterministic duplicate job for a request."""
245
246 with self._lock:
247 job_id = request.stable_job_id()
248 existing = self._jobs.get(job_id)
249 if existing is not None:
250 return existing
251 if self.active_count() >= self._queue_limit:
252 raise ApiError(ErrorCode.QUEUE_LIMIT_EXCEEDED, 429)
253 record = TrainingJobRecord(id=job_id, request=request)
254 self._jobs[job_id] = record
255 self._save()
256 return record
257
258 def get(self, job_id: str) -> TrainingJobRecord:
259 """Return one job or raise a safe not-found error."""
260
261 with self._lock:
262 record = self._jobs.get(job_id)
263 if record is None:
264 raise ApiError(ErrorCode.NOT_FOUND, 404)
265 return record
266
267 def update_status(
268 self, job_id: str, target_status: TrainingJobStatus
269 ) -> TrainingJobRecord:
270 """Apply the state machine and persist the updated job."""
271
272 with self._lock:
273 record = self.get(job_id)
274 next_status = transition(record.status, target_status)
275 if next_status != record.status:
276 record.status = next_status
277 record.updated_at = utc_now_iso()
278 self._save()
279 return record
280
281 def register_artifact(
282 self, job_id: str, artifact: ArtifactMetadata, provenance: ProvenanceRecord
283 ) -> TrainingJobRecord:
284 """Attach a placeholder artifact once, preserving retry stability."""
285
286 require_job_id(job_id)
287 require_artifact_id(artifact.id)
288 validate_provenance_record(provenance)
289 with self._lock:
290 record = self.get(job_id)
291 if all(existing.id != artifact.id for existing in record.artifacts):
292 record.artifacts.append(artifact)
293 record.provenance = provenance
294 record.updated_at = utc_now_iso()
295 self._save()
296 return record
297
298 def list_artifacts(self, job_id: str) -> list[ArtifactMetadata]:
299 """Return artifacts for one job with no cross-job scan exposure."""
300
301 require_job_id(job_id)
302 with self._lock:
303 record = self.get(job_id)
304 if record.status == TrainingJobStatus.DELETED:
305 return []
306 return list(record.artifacts)
307
308 def get_provenance(self, job_id: str) -> ProvenanceRecord:
309 """Return the validated provenance record for one completed job.
310
311 Provenance is readable even for deleted tombstones when the deletion
312 was triggered by retention expiry (the record retains its provenance).
313 Explicitly deleted artifacts have provenance wiped and return NOT_FOUND.
314 """
315
316 require_job_id(job_id)
317 with self._lock:
318 record = self.get(job_id)
319 if record.provenance is None:
320 raise ApiError(ErrorCode.NOT_FOUND, 404)
321 return record.provenance
322
323 def evaluate_expiry(
324 self, job_id: str, now: datetime | None = None
325 ) -> TrainingJobRecord:
326 """Evaluate a job's artifact expiry and delete it when TTL has elapsed."""
327
328 require_job_id(job_id)
329 with self._lock:
330 record = self.get(job_id)
331 self._delete_expired_artifact_locked(record, now or datetime.now(UTC))
332 return record
333
334 def sweep_expired(self, now: datetime | None = None) -> dict[str, object]:
335 """Delete every expired artifact and return a content-free sweep summary."""
336
337 deleted_job_ids: list[str] = []
338 sweep_time = now or datetime.now(UTC)
339 with self._lock:
340 for record in sorted(self._jobs.values(), key=lambda item: item.id):
341 receipt = self._delete_expired_artifact_locked(record, sweep_time)
342 if receipt is not None and receipt.deleted:
343 deleted_job_ids.append(record.id)
344 return {
345 "deletedJobIds": deleted_job_ids,
346 "deletedCount": len(deleted_job_ids),
347 }
348
349 def delete_artifact(self, job_id: str, artifact_id: str) -> DeletionReceipt:
350 """Delete an artifact, provenance, and job content as an idempotent cascade."""
351
352 require_job_id(job_id)
353 require_artifact_id(artifact_id)
354 with self._lock:
355 record = self.get(job_id)
356 if record.status == TrainingJobStatus.DELETED:
357 return DeletionReceipt(
358 job_id=job_id,
359 artifact_id=artifact_id,
360 deleted=True,
361 already_deleted=True,
362 verified=True,
363 )
364 artifact = self._find_artifact(record, artifact_id)
365 if artifact is None:
366 raise ApiError(ErrorCode.NOT_FOUND, 404)
367 return self._delete_artifact_locked(record, artifact)
368
369 def verify_hash_absence(self, hash_values: Iterable[str]) -> bool:
370 """Verify supplied hashes are absent from every store serialization."""
371
372 hashes = tuple(value for value in hash_values if value)
373 if not hashes:
374 return False
375 with self._lock:
376 output = json.dumps(
377 {
378 "artifacts": {
379 record.id: [artifact.to_dict() for artifact in record.artifacts]
380 for record in self._jobs.values()
381 },
382 "jobs": [
383 record.to_public_dict()
384 for record in sorted(self._jobs.values(), key=lambda item: item.id)
385 ],
386 "persisted": [
387 record.to_persisted_dict()
388 for record in sorted(self._jobs.values(), key=lambda item: item.id)
389 ],
390 "provenance": {
391 record.id: record.provenance.to_dict()
392 for record in self._jobs.values()
393 if record.provenance is not None
394 },
395 },
396 sort_keys=True,
397 )
398 return all(hash_value not in output for hash_value in hashes)
399
400 def active_count(self) -> int:
401 """Count queued and running jobs for quota enforcement."""
402
403 return sum(
404 1
405 for record in self._jobs.values()
406 if record.status
407 in {TrainingJobStatus.QUEUED, TrainingJobStatus.RUNNING}
408 )
409
410 def running_count(self) -> int:
411 """Count jobs currently in the running state."""
412
413 return sum(
414 1
415 for record in self._jobs.values()
416 if record.status == TrainingJobStatus.RUNNING
417 )
418
419 def can_run_now(self) -> bool:
420 """Return True when the running-job slot is available."""
421
422 return self.running_count() < self._max_concurrent_running
423
424 def queue_state(self) -> dict[str, object]:
425 """Return a content-free snapshot of the job queue state."""
426
427 with self._lock:
428 queued = sum(
429 1
430 for r in self._jobs.values()
431 if r.status == TrainingJobStatus.QUEUED
432 )
433 running = self.running_count()
434 return {
435 "activeCount": queued + running,
436 "maxConcurrentRunning": self._max_concurrent_running,
437 "queueLimit": self._queue_limit,
438 "queuedCount": queued,
439 "runningCount": running,
440 }
441
442 def _delete_expired_artifact_locked(
443 self, record: TrainingJobRecord, now: datetime
444 ) -> DeletionReceipt | None:
445 if record.status != TrainingJobStatus.SUCCEEDED:
446 return None
447 for artifact in record.artifacts:
448 if is_expired(artifact.created_at, artifact.retention_policy, now):
449 return self._delete_artifact_locked(
450 record, artifact, verify=False, retain_provenance=True
451 )
452 return None
453
454 def _delete_artifact_locked(
455 self,
456 record: TrainingJobRecord,
457 artifact: ArtifactMetadata,
458 verify: bool = True,
459 retain_provenance: bool = False,
460 ) -> DeletionReceipt:
461 hashes = self._hashes_for_artifact(record, artifact)
462 record.status = transition(record.status, TrainingJobStatus.DELETED)
463 record.request = None
464 record.artifacts = []
465 if not retain_provenance:
466 record.provenance = None
467 record.deleted_at = utc_now_iso()
468 record.updated_at = record.deleted_at
469 self._save()
470 return DeletionReceipt(
471 job_id=record.id,
472 artifact_id=artifact.id,
473 deleted=True,
474 already_deleted=False,
475 verified=self.verify_hash_absence(hashes) if verify else True,
476 )
477
478 def _find_artifact(
479 self, record: TrainingJobRecord, artifact_id: str
480 ) -> ArtifactMetadata | None:
481 for artifact in record.artifacts:
482 if artifact.id == artifact_id:
483 return artifact
484 return None
485
486 def _hashes_for_artifact(
487 self, record: TrainingJobRecord, artifact: ArtifactMetadata
488 ) -> tuple[str, ...]:
489 hashes = [artifact.dataset_hash, artifact.artifact_hash]
490 if record.provenance is not None:
491 hashes.extend(
492 [
493 record.provenance.dataset_hash,
494 record.provenance.artifact_hash,
495 record.provenance.training_config_hash,
496 ]
497 )
498 return tuple(hashes)
499
500 def _save(self) -> None:
501 if self._persistence_path is None:
502 return
503 payload = {
504 "jobs": [
505 record.to_persisted_dict()
506 for record in sorted(self._jobs.values(), key=lambda item: item.id)
507 ]
508 }
509 self._persistence_path.parent.mkdir(parents=True, exist_ok=True)
510 self._persistence_path.write_text(
511 json.dumps(payload, indent=2, sort_keys=True) + "\n",
512 encoding="utf-8",
513 )
514
515 def _load(self) -> None:
516 if self._persistence_path is None:
517 return
518 payload = json.loads(self._persistence_path.read_text(encoding="utf-8"))
519 jobs = payload.get("jobs", [])
520 if not isinstance(jobs, list):
521 raise ApiError(ErrorCode.INTERNAL_ERROR, 500)
522 self._jobs = {
523 record.id: record
524 for record in (
525 TrainingJobRecord.from_persisted_dict(job)
526 for job in jobs
527 if isinstance(job, dict)
528 )
529 }
File History 1 commit
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a feat(slice-7): T3 dataset review lifecycle, job queue, prov… Human minor 42 days ago