fix(observatory): reject duplicate active portable computations atomically

This commit is contained in:
DCCONSTRUCTIONS
2026-09-02 23:15:39 +03:00
parent 941183d624
commit 35c1249631
6 changed files with 226 additions and 3 deletions
@@ -214,6 +214,7 @@ class PortableRecordedQueueBindingService:
return self._queue.submit(
preparation.intent(idempotency_key=idempotency_key),
enqueue=enqueue,
reject_duplicate_computation=True,
)
def _resolve_definition(
+27 -2
View File
@@ -239,6 +239,14 @@ class ObservatoryRecordedQueueConflictError(ObservatoryRecordedQueueError):
"""An idempotency identity or state transition conflicts with history."""
class ObservatoryRecordedQueueDuplicateError(ObservatoryRecordedQueueConflictError):
"""Another request already owns this exact recorded computation."""
def __init__(self, job_id: str) -> None:
self.job_id = job_id
super().__init__("an identical recorded computation already exists")
class ObservatoryRecordedQueueNotFoundError(ObservatoryRecordedQueueError):
"""A requested job or live lease does not exist."""
@@ -1177,6 +1185,7 @@ class ObservatoryRecordedJobQueue:
intent: ObservatoryRecordedJobIntent,
*,
enqueue: bool = False,
reject_duplicate_computation: bool = False,
) -> tuple[ObservatoryRecordedJob, bool]:
"""Accept one immutable identity and optionally queue it atomically."""
@@ -1199,16 +1208,32 @@ class ObservatoryRecordedJobQueue:
)
record = self._get_job(connection, record.job_id)
return record, False
definition = self._definitions.resolve(intent.setup_id, intent.definition_sha256)
identity_sha256 = _job_identity_sha256(intent, definition)
if reject_duplicate_computation:
# This check shares the INSERT transaction: two clients with
# different idempotency keys cannot race into duplicate jobs.
# A sealed result awaiting publication is not a reason to run
# the models again. Published-cache validity is a separate gate.
duplicate = connection.execute(
"SELECT job_id FROM observatory_recorded_jobs "
"WHERE identity_sha256 = ? AND ("
"state IN ('accepted', 'queued', 'claimed', 'running', 'paused', "
"'preemption-pending', 'reconciliation-required') OR "
"(state = 'succeeded' AND publication_state IN ('pending', 'failed'))) "
"ORDER BY created_at_utc DESC, job_id DESC LIMIT 1",
(identity_sha256,),
).fetchone()
if duplicate is not None:
raise ObservatoryRecordedQueueDuplicateError(duplicate["job_id"])
self._require_capacity(
connection,
table="observatory_recorded_jobs",
limit=self._max_jobs,
label="recorded job",
)
definition = self._definitions.resolve(intent.setup_id, intent.definition_sha256)
now = self._timestamp()
job_id = f"observatory-run-{uuid4().hex}"
identity_sha256 = _job_identity_sha256(intent, definition)
receipt_sha256 = _submission_receipt_sha256(
job_id=job_id,
request_sha256=intent.request_sha256,
+9
View File
@@ -53,6 +53,7 @@ from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueCapacityError,
ObservatoryRecordedQueueConflictError,
ObservatoryRecordedQueueDuplicateError,
ObservatoryRecordedQueueError,
ObservatoryRecordedQueueNotFoundError,
)
@@ -929,6 +930,14 @@ def build_observatory_router(
status_code=409,
detail="Portable-привязка источника не прошла проверку целостности.",
) from exc
except ObservatoryRecordedQueueDuplicateError as exc:
raise HTTPException(
status_code=409,
detail=(
"Такой расчёт уже выполняется или ожидает публикации. "
"Обновите список расчётов; повторный запуск не создан."
),
) from exc
except ObservatoryRecordedQueueConflictError as exc:
try:
raced = recorded_job_queue.get_by_idempotency_key(request.idempotency_key)
@@ -18,7 +18,10 @@ from k1link.observatory.portable_run_definitions import (
PortableRunDefinitionUnavailableError,
canonical_sha256,
)
from k1link.observatory.recorded_jobs import ObservatoryRecordedJobQueue
from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueDuplicateError,
)
from k1link.observatory.source_admission import (
PORTABLE_SOURCE_DOCUMENT_DIRECTORY,
PortableRecordedSourceAdmission,
@@ -534,6 +537,16 @@ def test_admit_persists_exact_documents_and_submit_queues_same_identity(
)
assert queue is not None
assert queue.list_jobs() == (job,)
with pytest.raises(ObservatoryRecordedQueueDuplicateError) as caught:
service.submit(
source_session_id=SESSION_ID,
setup_id=definition.setup_id,
definition_sha256=definition.definition_sha256,
expected_check_sha256=checked.check_sha256,
idempotency_key="second-browser",
)
assert caught.value.job_id == job.job_id
assert queue.list_jobs() == (job,)
def test_admit_rejects_catalog_change_since_check_without_writes_or_job(
@@ -242,6 +242,7 @@ class _PortableBinding:
definition_sha256=definition_sha256,
),
enqueue=True,
reject_duplicate_computation=True,
)
@@ -299,6 +300,23 @@ def test_portable_api_check_sha_fences_ready_submission(tmp_path: Path) -> None:
assert submitted.json()["state"] == "queued"
assert submitted.json()["setup"]["setup_id"] == definition.setup_id
assert binding.submit_count == 1
request = {
"schema_version": "missioncore.observatory-recorded-run-submit/v1",
"idempotency_key": "portable:lab-v1:source-005",
"source_session_id": SOURCE_SESSION_ID,
"setup_id": definition.setup_id,
"definition_sha256": definition.definition_sha256,
"check_sha256": binding.check_sha256,
}
repeated = client.post("/api/v1/observatory/runs", json=request)
assert repeated.status_code == 202
assert repeated.json()["job_id"] == submitted.json()["job_id"]
assert binding.submit_count == 1
request["idempotency_key"] = "second-browser"
duplicate = client.post("/api/v1/observatory/runs", json=request)
assert duplicate.status_code == 409
assert "повторный запуск не создан" in duplicate.json()["detail"]
assert len(queue.list_jobs()) == 1
def test_portable_api_rejects_blocked_lab_executor_before_binding_submit(
+157
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from pathlib import Path
from threading import Barrier
import pytest
@@ -17,6 +19,7 @@ from k1link.observatory.recorded_jobs import (
ObservatoryRecordedPreemptionError,
ObservatoryRecordedQueueBusyError,
ObservatoryRecordedQueueConflictError,
ObservatoryRecordedQueueDuplicateError,
ObservatoryRecordedQueueIntegrityError,
ObservatoryRecordedQueueStaleClaimError,
ObservatoryRecordedReconciliationRequest,
@@ -198,6 +201,160 @@ def _reconciliation_request(
)
@pytest.mark.parametrize("enqueue", [False, True])
def test_portable_duplicate_guard_preserves_original_request(
tmp_path: Path,
enqueue: bool,
) -> None:
queue = _queue(tmp_path)
first, created = queue.submit(
_intent(),
enqueue=enqueue,
reject_duplicate_computation=True,
)
assert created
repeated, created = queue.submit(
_intent(),
enqueue=enqueue,
reject_duplicate_computation=True,
)
assert repeated == first and not created
reopened = _queue(tmp_path)
with pytest.raises(ObservatoryRecordedQueueDuplicateError) as caught:
reopened.submit(
_intent(idempotency_key="second-browser"),
enqueue=True,
reject_duplicate_computation=True,
)
assert caught.value.job_id == first.job_id
assert len(reopened.list_jobs()) == 1
# Reject, rather than silently rebinding a second idempotency key.
with pytest.raises(ObservatoryRecordedQueueConflictError):
reopened.submit(
_intent(source_catalog_sha256="0" * 64),
reject_duplicate_computation=True,
)
@pytest.mark.parametrize(
"field",
[
"source_session_id",
"source_catalog_sha256",
"source_bundle_sha256",
"source_capability_manifest_sha256",
"setup_id",
],
)
def test_duplicate_guard_keeps_distinct_sources_and_profiles(
tmp_path: Path,
field: str,
) -> None:
queue = _queue(tmp_path)
queue.submit(_intent(), reject_duplicate_computation=True)
values = {
"source_session_id": "another-source",
"source_catalog_sha256": "0" * 64,
"source_bundle_sha256": "0" * 64,
"source_capability_manifest_sha256": "0" * 64,
"setup_id": "legacy-monolith-v1",
}
changes = {field: values[field]}
if field == "setup_id":
changes["definition_sha256"] = NON_CHECKPOINTABLE_DEFINITION_SHA
distinct = replace(_intent(idempotency_key="distinct-request"), **changes)
_, created = queue.submit(distinct, reject_duplicate_computation=True)
assert created and len(queue.list_jobs()) == 2
@pytest.mark.parametrize("publication_failed", [False, True])
def test_duplicate_guard_never_recomputes_pending_publication(
tmp_path: Path,
publication_failed: bool,
) -> None:
queue = _queue(tmp_path)
job, claim = _running_job(queue)
with pytest.raises(ObservatoryRecordedQueueDuplicateError):
queue.submit(
_intent(idempotency_key="while-running"),
reject_duplicate_computation=True,
)
queue.complete_for_publication(
job.job_id,
claim_token=claim.claim_token,
result_id="portable-result-001",
result_sha256=RESULT_SHA,
)
if publication_failed:
queue.mark_publication_failed(job.job_id, message="temporary publication outage")
with pytest.raises(ObservatoryRecordedQueueDuplicateError):
queue.submit(
_intent(idempotency_key="while-publishing"),
reject_duplicate_computation=True,
)
assert len(queue.list_jobs()) == 1
def test_duplicate_guard_allows_retry_after_computation_failure(tmp_path: Path) -> None:
queue = _queue(tmp_path)
job, claim = _running_job(queue)
queue.fail(
job.job_id,
claim_token=claim.claim_token,
error_code="executor-error",
message="bounded test failure",
)
retried, created = queue.submit(
_intent(idempotency_key="explicit-retry"),
enqueue=True,
reject_duplicate_computation=True,
)
assert created and retried.job_id != job.job_id
def test_duplicate_guard_is_atomic_across_two_queue_instances(tmp_path: Path) -> None:
queues = (_queue(tmp_path), _queue(tmp_path))
barrier = Barrier(2)
def submit(index: int) -> str:
barrier.wait(timeout=5)
try:
job, created = queues[index].submit(
_intent(idempotency_key=f"browser-{index}"),
enqueue=True,
reject_duplicate_computation=True,
)
assert created
return job.job_id
except ObservatoryRecordedQueueDuplicateError as error:
return error.job_id
with ThreadPoolExecutor(max_workers=2) as executor:
results = tuple(executor.map(submit, (0, 1)))
assert results[0] == results[1]
assert len(queues[0].list_jobs()) == 1
def test_duplicate_guard_keeps_new_version_of_same_setup(tmp_path: Path) -> None:
queue = _queue(tmp_path)
queue.submit(_intent(), reject_duplicate_computation=True)
revised = replace(
_definitions().definitions[0],
definition_version=2,
definition_sha256="0" * 64,
)
reopened = ObservatoryRecordedJobQueue(
tmp_path,
definitions=RecordedRunDefinitionRegistry((revised,)),
clock=lambda: NOW,
)
_, created = reopened.submit(
_intent(idempotency_key="new-version", definition_sha256="0" * 64),
reject_duplicate_computation=True,
)
assert created and len(reopened.list_jobs()) == 2
def test_submission_is_durable_exactly_idempotent_and_path_free(tmp_path: Path) -> None:
queue = _queue(tmp_path)
first, first_created = queue.submit(_intent())