- job_schema: geschlossene Job-Type-Allowlist (C5_SAVE_OBJECT/C5_DELETE_OBJECT), Pfad-/Längen-Validierung - job_state_machine: deterministische States (CREATED/READY/CLAIMED/EXECUTING/SUCCEEDED/FAILED) - job_claim: atomare Claim-/Lease-/Recovery-Logik (kein TOCTOU) - job_store: getrennte SQLite-Inbox-DBs (c5a_save.db/c5a_delete.db), delegiert an job_claim - save_executor_core: SAVE-only, Content-Rekonstruktion, Provenance-Validierung - delete_executor_core: DELETE-only, AUTH.3D-Composition, TOCTOU-Defense (Re-Read nach Claim) - test_job_channel: T1-T40 + adversarial (72 Tests) - test_job_channel_adversarial: adversarial + Substitution + DB-Manipulation - sensitivity_proof_auth3e: Mutationen A-L (12/12 Invarianten PRESENT) - AUTH3B/3C/3D/3E_DESIGN: autoritative Security-Dokumentation (e25 Reconciliation) COMMAND != AUTHORIZATION. Kein generischer Dispatcher. RQ credential-free. Keine produktive Mutation. Keine echten Credentials.
149 lines
4.9 KiB
Python
149 lines
4.9 KiB
Python
"""
|
|
AUTH.3E — job_claim.py
|
|
======================
|
|
Atomarer Claim / Lease / Concurrency für den Executor Command Channel.
|
|
|
|
Garantien:
|
|
* Claim ist ATOMAR (SQLite-Transaktion mit Status-Bedingung) — kein TOCTOU.
|
|
* Zwei SAVE-Worker: nur einer kann denselben Job claimen.
|
|
* Zwei DELETE-Worker: nur einer kann denselben DELETE-Job claimen.
|
|
* Ein bereits gestarteter DELETE wird bei Lease-Expiry NICHT blind wiederholt
|
|
(-> OUTCOME_UNKNOWN, nicht zurück zu READY).
|
|
* Stale Claims werden via Lease-Expiry erkannt.
|
|
|
|
Isoliert implementiert (KEIN produktiver Container).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import time
|
|
import uuid
|
|
from typing import Any, Dict, Optional
|
|
|
|
from job_state_machine import ST_CLAIMED, ST_READY
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fehler
|
|
# ---------------------------------------------------------------------------
|
|
class ClaimError(Exception):
|
|
"""Basis-Fehler für Claim/Lease."""
|
|
|
|
|
|
class JobAlreadyClaimedError(ClaimError):
|
|
"""Job ist nicht claimbar (bereits geclaimt oder Lease aktiv)."""
|
|
|
|
|
|
class JobNotFoundError(ClaimError):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Atomarer Claim
|
|
# ---------------------------------------------------------------------------
|
|
def atomic_claim(
|
|
conn: sqlite3.Connection,
|
|
job_id: str,
|
|
worker_id: str,
|
|
lease_seconds: int = 60,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Führt einen atomaren Claim aus: READY -> CLAIMED, nur wenn Lease abgelaufen
|
|
oder nie gesetzt. Kein TOCTOU (Status-Bedingung in der UPDATE-WHERE-Klausel).
|
|
|
|
Rückgabe: der geclaimte Job (als dict).
|
|
Wirft JobAlreadyClaimedError, wenn der Job nicht claimbar ist.
|
|
"""
|
|
now = int(time.time() * 1000)
|
|
lease_until = now + lease_seconds * 1000
|
|
claim_id = str(uuid.uuid4())
|
|
try:
|
|
cur = conn.execute(
|
|
"""
|
|
UPDATE jobs
|
|
SET state = ?, worker_id = ?, claim_id = ?, claimed_at = ?,
|
|
lease_until = ?, attempt_count = attempt_count + 1, updated_at = ?
|
|
WHERE job_id = ?
|
|
AND state = ?
|
|
AND (lease_until IS NULL OR lease_until < ?)
|
|
""",
|
|
(ST_CLAIMED, worker_id, claim_id, now, lease_until, now,
|
|
job_id, ST_READY, now),
|
|
)
|
|
conn.commit()
|
|
except sqlite3.IntegrityError as e:
|
|
raise ClaimError(f"atomic_claim failed: {e}") from e
|
|
if cur.rowcount == 0:
|
|
raise JobAlreadyClaimedError(f"job not claimable: {job_id}")
|
|
row = conn.execute(
|
|
"SELECT * FROM jobs WHERE job_id = ?", (job_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise JobNotFoundError(f"job not found after claim: {job_id}")
|
|
return dict(row)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lease
|
|
# ---------------------------------------------------------------------------
|
|
def renew_lease(
|
|
conn: sqlite3.Connection,
|
|
job_id: str,
|
|
lease_seconds: int = 60,
|
|
) -> None:
|
|
"""Verlängert die Lease eines CLAIMED/EXECUTING-Jobs."""
|
|
now = int(time.time() * 1000)
|
|
lease_until = now + lease_seconds * 1000
|
|
conn.execute(
|
|
"UPDATE jobs SET lease_until = ?, updated_at = ? WHERE job_id = ?",
|
|
(lease_until, now, job_id),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def is_lease_expired(lease_until: Optional[int], now: Optional[int] = None) -> bool:
|
|
"""True, wenn die Lease abgelaufen ist (oder nie gesetzt)."""
|
|
if lease_until is None:
|
|
return True
|
|
now = now if now is not None else int(time.time() * 1000)
|
|
return lease_until < now
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Crash-Recovery
|
|
# ---------------------------------------------------------------------------
|
|
def recover_stale_claims(
|
|
conn: sqlite3.Connection,
|
|
job_type: str,
|
|
now: Optional[int] = None,
|
|
) -> int:
|
|
"""
|
|
Findet stale CLAIMED-Jobs (Lease abgelaufen) und überführt sie:
|
|
* SAVE: zurück zu READY (wieder claimbar, RETRYABLE)
|
|
* DELETE: zu OUTCOME_UNKNOWN (NICHT blind wiederholen)
|
|
|
|
Rückgabe: Anzahl der recovered Jobs.
|
|
"""
|
|
from job_state_machine import ST_OUTCOME_UNKNOWN
|
|
|
|
now = now if now is not None else int(time.time() * 1000)
|
|
stale = conn.execute(
|
|
"SELECT job_id FROM jobs WHERE state = ? AND lease_until IS NOT NULL AND lease_until < ?",
|
|
(ST_CLAIMED, now),
|
|
).fetchall()
|
|
recovered = 0
|
|
for row in stale:
|
|
job_id = row["job_id"]
|
|
if job_type == "C5_SAVE_OBJECT":
|
|
conn.execute(
|
|
"UPDATE jobs SET state = ?, updated_at = ? WHERE job_id = ?",
|
|
(ST_READY, now, job_id),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"UPDATE jobs SET state = ?, updated_at = ? WHERE job_id = ?",
|
|
(ST_OUTCOME_UNKNOWN, now, job_id),
|
|
)
|
|
recovered += 1
|
|
conn.commit()
|
|
return recovered
|