feat(tolaria): add C5 sync state machine
This commit is contained in:
parent
68db35f55c
commit
183afed1d6
5 changed files with 1643 additions and 0 deletions
4
tolaria/c5-sync-service/.gitignore
vendored
Normal file
4
tolaria/c5-sync-service/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
__pycache__/
|
||||
207
tolaria/c5-sync-service/README.md
Normal file
207
tolaria/c5-sync-service/README.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# C5A — CONTRACT & STATE MACHINE v1
|
||||
|
||||
**Phase:** C5A (erste Phase des C5 Sync Service)
|
||||
**Status:** DONE (Contract & State Machine) — **INAKTIV, NO-WRITE**
|
||||
**Architektur:** FORGEJO MASTER → C5 SYNC SERVICE → TOLARIA DERIVED → SEARCH FULL REBUILD
|
||||
|
||||
C5A implementiert **ausschließlich** den Contract + die Sync State Machine + die
|
||||
persistente Progress-State-Struktur. Es ist eine **deterministische, inaktive
|
||||
Python-Library + CLI + Testsuite** — es führt **keine** externen Writes aus.
|
||||
|
||||
---
|
||||
|
||||
## NO-WRITE GUARANTEE (verbindlich)
|
||||
|
||||
C5A kann **NOCH NICHT**:
|
||||
- Tolaria schreiben
|
||||
- Search rebuilden
|
||||
- Forgejo schreiben
|
||||
- produktiv pollen
|
||||
- Netzwerk-Mutationen ausführen
|
||||
|
||||
Es gibt **keine** Netzwerk-/HTTP-/Socket-/Subprocess-Mutationsfunktionen in C5A.
|
||||
Die No-Write-Guarantee wird statisch per `assert_no_write_guarantee()` bewiesen
|
||||
(keine `requests`/`urllib`/`http`/`socket`/`subprocess`-Imports) und durch den
|
||||
Test `test_no_write_guarantee` abgesichert.
|
||||
|
||||
---
|
||||
|
||||
## State Machine
|
||||
|
||||
### Normalzustände
|
||||
`DISCOVERED → VALIDATING → READY → PROPAGATING_TOLARIA → VERIFYING_TOLARIA → UPDATING_SEARCH → VERIFYING_SEARCH → APPLIED`
|
||||
|
||||
### Fehlerzustände
|
||||
`RETRY_PENDING`, `FAILED`, `DEAD`, `HUMAN_REVIEW_REQUIRED`
|
||||
|
||||
### Ordering
|
||||
`WAITING_FOR_PREDECESSOR` — Commits werden nur in korrekter Reihenfolge
|
||||
verarbeitet (`parent_sha` / `last_applied_commit`). Lücken werden **nicht**
|
||||
übersprungen.
|
||||
|
||||
### Kerninvariante
|
||||
**`last_applied_commit` wird NUR nach vollständigem Tolaria+Search PASS
|
||||
fortgeschrieben** (`mark_applied` wird nur nach `VERIFYING_SEARCH → APPLIED`
|
||||
aufgerufen).
|
||||
|
||||
---
|
||||
|
||||
## Commit Contract
|
||||
|
||||
Eine Verarbeitungseinheit = **ein Forgejo Commit**:
|
||||
|
||||
| Feld | Beschreibung |
|
||||
|---|---|
|
||||
| `commit_sha` | Primärschlüssel |
|
||||
| `parent_sha` | Vorgänger (Ordering) |
|
||||
| `discovered_at` | Zeitstempel |
|
||||
| `sequence` | Reihenfolge |
|
||||
| `status` | Sync-Zustand |
|
||||
| `retry_count` | Retry-Zähler |
|
||||
| `last_error` / `last_error_code` | Fehler + Reason Code |
|
||||
| `created_at` / `updated_at` | Zeitstempel |
|
||||
|
||||
### Pro Changed Object
|
||||
`object_id`, `path_before`, `path_after`, `operation`, `content_hash_before`,
|
||||
`content_hash_after`, `metadata_hash_before`, `metadata_hash_after`,
|
||||
`representation`, `state`
|
||||
|
||||
---
|
||||
|
||||
## Operation Model
|
||||
|
||||
`CREATE`, `CONTENT_UPDATE`, `METADATA_UPDATE`, `STATE_UPDATE`, `RENAME`, `MOVE`,
|
||||
`SOURCE_CANONICAL_RELATION_UPDATE`, `TAGS_UPDATE`, `SUPERSEDE`, `DELETE_REQUEST`
|
||||
|
||||
**`DELETE_REQUEST` bleibt Human-Gate** — kein automatisches Hard Delete.
|
||||
|
||||
---
|
||||
|
||||
## Idempotenz
|
||||
|
||||
Basis: **`commit_sha + object_id + operation`** + Hash-Checks.
|
||||
|
||||
| Ergebnis | Bedeutung |
|
||||
|---|---|
|
||||
| `ALREADY_APPLIED` | Commit bereits vollständig angewendet |
|
||||
| `ALREADY_AT_TARGET` | Ziel-Hash bereits erreicht |
|
||||
| `RETRY_SAFE` | Noch nicht angewendet, sicher zu verarbeiten |
|
||||
| `CONFLICT` | Existiert, aber Hash weicht ab |
|
||||
|
||||
Doppelte Commit-Erkennung ist **deterministisch**.
|
||||
|
||||
---
|
||||
|
||||
## Ordering
|
||||
|
||||
Commits nur in korrekter Reihenfolge. Fehlender Vorgänger → `WAITING_FOR_PREDECESSOR`
|
||||
(Reason Code `OUT_OF_ORDER_COMMIT`). Nach Anwendung des Vorgängers wird der
|
||||
Commit erneut validiert.
|
||||
|
||||
---
|
||||
|
||||
## Failure / Retry State
|
||||
|
||||
- `max_retries = 5` (konfigurierbar)
|
||||
- Backoff: `1 / 2 / 4 / 8 / 16` Sekunden (gekappt)
|
||||
- Nach Max → `DEAD` oder `HUMAN_REVIEW_REQUIRED`
|
||||
- **Kein Endlos-Retry**
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
SQLite (`c5a.db`), isoliert, restart-fest. Anforderungen erfüllt:
|
||||
- **atomic** (SQLite-Transaktionen, WAL)
|
||||
- **restart-safe** (neuer Store auf gleicher DB behält State)
|
||||
- **inspectable** (CLI `health`, `list-commits`, `commit-status`)
|
||||
- **backupbar** (einzelne Datei)
|
||||
- **keine externe DB** nötig
|
||||
- **kein Trading-/Forgejo-DB-Coupling** (eigene DB)
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap State
|
||||
|
||||
`UNINITIALIZED → RECONCILING → BASELINE_READY → ACTIVE`
|
||||
|
||||
Baseline darf **nur** aus `BASELINE_READY` gesetzt werden (Master↔Tolaria-
|
||||
Reconciliation muss sauber sein). In C5A wird **keine produktive Baseline**
|
||||
gesetzt.
|
||||
|
||||
---
|
||||
|
||||
## Reason Codes (geschlossene Menge)
|
||||
|
||||
`UNEXPECTED_TOLARIA_DRIFT`, `ID_COLLISION`, `UNKNOWN_OBJECT_ID`, `INVALID_SCHEMA`,
|
||||
`DANGLING_DERIVED_FROM`, `AMBIGUOUS_DELETE`, `UNKNOWN_LEGACY_OBJECT`,
|
||||
`AUTH_FAILURE`, `SEARCH_REBUILD_FAILURE`, `TOLARIA_UNAVAILABLE`,
|
||||
`FORGEJO_UNAVAILABLE`, `OUT_OF_ORDER_COMMIT`, `SECRET_DETECTED`
|
||||
|
||||
Kein freier String-Wildwuchs als einziges Fehlerformat — unbekannte Reason Codes
|
||||
werden abgelehnt (`InvalidReasonCodeError`).
|
||||
|
||||
---
|
||||
|
||||
## Security Boundary (dokumentiert, NICHT implementiert)
|
||||
|
||||
- **Forgejo credential:** READ ONLY (C5A hält keinen Forgejo-Write)
|
||||
- **Tolaria write:** nur später C5 Service
|
||||
- **Search rebuild:** nur C5 Service Token
|
||||
- **Agents:** keine direkten Tolaria-/Search-Admin-Writes
|
||||
- **Netzwerk-Härtung** wird später beim Deployment umgesetzt, NICHT jetzt
|
||||
|
||||
---
|
||||
|
||||
## Health Contract
|
||||
|
||||
`status`, `bootstrap_state`, `last_seen_commit`, `last_applied_commit`,
|
||||
`pending_commits`, `failed_commits`, `dead_commits`, `human_review_required`,
|
||||
`forgejo_status`, `tolaria_status`, `search_status`, `drift_count`,
|
||||
`last_success_at`, `last_error_code`
|
||||
|
||||
C5A führt **keine echten externen Health-Probes** aus (rein State-Machine) —
|
||||
`forgejo_status`/`tolaria_status`/`search_status` sind `UNKNOWN`.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
export C5A_DB=/path/to/c5a.db # default: ./c5a.db
|
||||
python3 rq_c5a_cli.py health
|
||||
python3 rq_c5a_cli.py bootstrap-state
|
||||
python3 rq_c5a_cli.py bootstrap-transition --to RECONCILING
|
||||
python3 rq_c5a_cli.py set-baseline --commit <sha>
|
||||
python3 rq_c5a_cli.py ingest-commit --json <file>
|
||||
python3 rq_c5a_cli.py process-commit --json <file>
|
||||
python3 rq_c5a_cli.py list-commits [--status <s>]
|
||||
python3 rq_c5a_cli.py commit-status --sha <sha>
|
||||
python3 rq_c5a_cli.py no-write-check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 test_c5a.py # 25 PASS / 0 FAIL
|
||||
```
|
||||
|
||||
Abgedeckte Fälle: normal commit lifecycle, multi-object commit, duplicate commit,
|
||||
already-applied, out-of-order commit, missing predecessor, retry progression,
|
||||
max retry → DEAD, human gate transition, restart/reload persistence, state
|
||||
corruption handling, idempotency, delete request → human gate, dangling relation
|
||||
→ human gate, unexpected drift → human gate, no-write guarantee, bootstrap
|
||||
lifecycle, baseline-only-from-BASELINE_READY, reason codes closed set, operations
|
||||
closed set, health contract fields, last_applied-only-after-full-pass, retry
|
||||
available bound, backoff sequence, invalid reason code rejected.
|
||||
|
||||
---
|
||||
|
||||
## Dateien
|
||||
|
||||
- `rq_c5a.py` — Kern-Library (Store + State Machine + No-Write-Check)
|
||||
- `rq_c5a_cli.py` — CLI
|
||||
- `test_c5a.py` — Testsuite (25 Tests)
|
||||
- `README.md` — diese Datei
|
||||
795
tolaria/c5-sync-service/rq_c5a.py
Normal file
795
tolaria/c5-sync-service/rq_c5a.py
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Red Queen — C5A: CONTRACT & STATE MACHINE v1 (deterministische, inaktive Library).
|
||||
|
||||
VERBINDLICHER RAHMEN
|
||||
====================
|
||||
C5A ist die erste Phase des C5 Sync Service (FORGEJO MASTER -> C5 SYNC SERVICE ->
|
||||
TOLARIA DERIVED -> SEARCH FULL REBUILD). Diese Library implementiert AUSSCHLIESSLICH
|
||||
den Contract + die Sync State Machine + die persistente Progress-State-Struktur.
|
||||
|
||||
C5A ist INAKTIV und NO-WRITE:
|
||||
* Es gibt KEIN produktives Polling.
|
||||
* Es gibt KEINE Forgejo->Tolaria Writes.
|
||||
* Es gibt KEINE Search Rebuild Calls.
|
||||
* Es gibt KEINEN C5 Container Deployment.
|
||||
* Es gibt KEINE Netzwerk-Isolation.
|
||||
* Es gibt KEINEN Canary.
|
||||
* Es gibt KEINEN Hermes Write Access.
|
||||
* Es gibt KEINE Netzwerk-Mutationsfunktionen (kein HTTP-Client, kein Socket).
|
||||
|
||||
C5A modelliert NUR den Zustand und die Uebergaenge. Jede Aktion wird EXPLIZIT durch
|
||||
eine Methode/CLI-Befehl angestossen und ist garantiert begrenzt (bounded). Es gibt
|
||||
KEINEN Loop, kein Heartbeat, kein Cron, kein Daemon, kein self-reschedule.
|
||||
|
||||
PERSISTENZ
|
||||
==========
|
||||
C5A-State ist restart-fest und liegt in einer isolierten SQLite-DB (c5a.db).
|
||||
Anforderungen: atomic, restart-safe, inspectable, backupbar, keine externe DB,
|
||||
kein Trading-/Forgejo-DB-Coupling. SQLite ist die robusteste einfache Variante.
|
||||
|
||||
SECURITY BOUNDARY (dokumentiert, NICHT implementiert):
|
||||
* Forgejo credential: READ ONLY (C5A haelt KEINEN Forgejo-Write).
|
||||
* Tolaria write: nur spaeter C5 Service (C5A schreibt NIE nach Tolaria).
|
||||
* Search rebuild: nur C5 Service Token (C5A ruft NIE Search-Rebuild).
|
||||
* Agents: keine direkten Tolaria-/Search-Admin-Writes.
|
||||
* Netzwerk-Haertung wird spaeter beim Deployment umgesetzt, NICHT jetzt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konstanten
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sync State Machine — Normalzustände
|
||||
ST_DISCOVERED = "DISCOVERED"
|
||||
ST_VALIDATING = "VALIDATING"
|
||||
ST_READY = "READY"
|
||||
ST_PROPAGATING_TOLARIA = "PROPAGATING_TOLARIA"
|
||||
ST_VERIFYING_TOLARIA = "VERIFYING_TOLARIA"
|
||||
ST_UPDATING_SEARCH = "UPDATING_SEARCH"
|
||||
ST_VERIFYING_SEARCH = "VERIFYING_SEARCH"
|
||||
ST_APPLIED = "APPLIED"
|
||||
|
||||
# Sync State Machine — Fehlerzustände
|
||||
ST_RETRY_PENDING = "RETRY_PENDING"
|
||||
ST_FAILED = "FAILED"
|
||||
ST_DEAD = "DEAD"
|
||||
ST_HUMAN_REVIEW_REQUIRED = "HUMAN_REVIEW_REQUIRED"
|
||||
|
||||
# Ordering-Zustand
|
||||
ST_WAITING_FOR_PREDECESSOR = "WAITING_FOR_PREDECESSOR"
|
||||
|
||||
# Bootstrap-Zustände
|
||||
BS_UNINITIALIZED = "UNINITIALIZED"
|
||||
BS_RECONCILING = "RECONCILING"
|
||||
BS_BASELINE_READY = "BASELINE_READY"
|
||||
BS_ACTIVE = "ACTIVE"
|
||||
|
||||
# Operation Model
|
||||
OP_CREATE = "CREATE"
|
||||
OP_CONTENT_UPDATE = "CONTENT_UPDATE"
|
||||
OP_METADATA_UPDATE = "METADATA_UPDATE"
|
||||
OP_STATE_UPDATE = "STATE_UPDATE"
|
||||
OP_RENAME = "RENAME"
|
||||
OP_MOVE = "MOVE"
|
||||
OP_SOURCE_CANONICAL_RELATION_UPDATE = "SOURCE_CANONICAL_RELATION_UPDATE"
|
||||
OP_TAGS_UPDATE = "TAGS_UPDATE"
|
||||
OP_SUPERSEDE = "SUPERSEDE"
|
||||
OP_DELETE_REQUEST = "DELETE_REQUEST"
|
||||
|
||||
# Idempotency-Ergebnisse
|
||||
IDEM_ALREADY_APPLIED = "ALREADY_APPLIED"
|
||||
IDEM_ALREADY_AT_TARGET = "ALREADY_AT_TARGET"
|
||||
IDEM_RETRY_SAFE = "RETRY_SAFE"
|
||||
IDEM_CONFLICT = "CONFLICT"
|
||||
|
||||
# Reason Codes (feste, maschinenlesbare Fehlerformate — kein freier String-Wildwuchs)
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT = "UNEXPECTED_TOLARIA_DRIFT"
|
||||
RC_ID_COLLISION = "ID_COLLISION"
|
||||
RC_UNKNOWN_OBJECT_ID = "UNKNOWN_OBJECT_ID"
|
||||
RC_INVALID_SCHEMA = "INVALID_SCHEMA"
|
||||
RC_DANGLING_DERIVED_FROM = "DANGLING_DERIVED_FROM"
|
||||
RC_AMBIGUOUS_DELETE = "AMBIGUOUS_DELETE"
|
||||
RC_UNKNOWN_LEGACY_OBJECT = "UNKNOWN_LEGACY_OBJECT"
|
||||
RC_AUTH_FAILURE = "AUTH_FAILURE"
|
||||
RC_SEARCH_REBUILD_FAILURE = "SEARCH_REBUILD_FAILURE"
|
||||
RC_TOLARIA_UNAVAILABLE = "TOLARIA_UNAVAILABLE"
|
||||
RC_FORGEJO_UNAVAILABLE = "FORGEJO_UNAVAILABLE"
|
||||
RC_OUT_OF_ORDER_COMMIT = "OUT_OF_ORDER_COMMIT"
|
||||
RC_SECRET_DETECTED = "SECRET_DETECTED"
|
||||
|
||||
# Retry / Backoff
|
||||
DEFAULT_MAX_RETRIES = 5
|
||||
DEFAULT_BACKOFF_SECONDS = [1, 2, 4, 8, 16]
|
||||
|
||||
# Health-Status
|
||||
HEALTH_OK = "OK"
|
||||
HEALTH_DEGRADED = "DEGRADED"
|
||||
HEALTH_ERROR = "ERROR"
|
||||
|
||||
# Alle Reason Codes als frozenset (Determinismus + Validierung)
|
||||
REASON_CODES = frozenset({
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
RC_ID_COLLISION,
|
||||
RC_UNKNOWN_OBJECT_ID,
|
||||
RC_INVALID_SCHEMA,
|
||||
RC_DANGLING_DERIVED_FROM,
|
||||
RC_AMBIGUOUS_DELETE,
|
||||
RC_UNKNOWN_LEGACY_OBJECT,
|
||||
RC_AUTH_FAILURE,
|
||||
RC_SEARCH_REBUILD_FAILURE,
|
||||
RC_TOLARIA_UNAVAILABLE,
|
||||
RC_FORGEJO_UNAVAILABLE,
|
||||
RC_OUT_OF_ORDER_COMMIT,
|
||||
RC_SECRET_DETECTED,
|
||||
})
|
||||
|
||||
# Alle Operationen als frozenset
|
||||
OPERATIONS = frozenset({
|
||||
OP_CREATE,
|
||||
OP_CONTENT_UPDATE,
|
||||
OP_METADATA_UPDATE,
|
||||
OP_STATE_UPDATE,
|
||||
OP_RENAME,
|
||||
OP_MOVE,
|
||||
OP_SOURCE_CANONICAL_RELATION_UPDATE,
|
||||
OP_TAGS_UPDATE,
|
||||
OP_SUPERSEDE,
|
||||
OP_DELETE_REQUEST,
|
||||
})
|
||||
|
||||
# Alle Sync-Zustände als frozenset
|
||||
SYNC_STATES = frozenset({
|
||||
ST_DISCOVERED, ST_VALIDATING, ST_READY,
|
||||
ST_PROPAGATING_TOLARIA, ST_VERIFYING_TOLARIA,
|
||||
ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
|
||||
ST_RETRY_PENDING, ST_FAILED, ST_DEAD, ST_HUMAN_REVIEW_REQUIRED,
|
||||
ST_WAITING_FOR_PREDECESSOR,
|
||||
})
|
||||
|
||||
# Bootstrap-Zustände
|
||||
BOOTSTRAP_STATES = frozenset({
|
||||
BS_UNINITIALIZED, BS_RECONCILING, BS_BASELINE_READY, BS_ACTIVE,
|
||||
})
|
||||
|
||||
# Erlaubte Übergänge der Sync State Machine (deterministisch)
|
||||
# (from_state, to_state) -> erlaubt
|
||||
_ALLOWED_TRANSITIONS = {
|
||||
(ST_DISCOVERED, ST_VALIDATING),
|
||||
(ST_DISCOVERED, ST_WAITING_FOR_PREDECESSOR), # Lücke direkt nach Discovery
|
||||
(ST_DISCOVERED, ST_RETRY_PENDING), # Fehler direkt nach Discovery (z.B. Forgejo down)
|
||||
(ST_VALIDATING, ST_READY),
|
||||
(ST_VALIDATING, ST_HUMAN_REVIEW_REQUIRED), # Schema/ID/Drift-Fehler bei Validierung
|
||||
(ST_VALIDATING, ST_WAITING_FOR_PREDECESSOR), # Lücke in Commit-Reihenfolge
|
||||
(ST_VALIDATING, ST_RETRY_PENDING), # Validierungsfehler retrybar
|
||||
(ST_WAITING_FOR_PREDECESSOR, ST_VALIDATING), # Vorgänger angewendet -> erneut validieren
|
||||
(ST_READY, ST_PROPAGATING_TOLARIA),
|
||||
(ST_PROPAGATING_TOLARIA, ST_VERIFYING_TOLARIA),
|
||||
(ST_PROPAGATING_TOLARIA, ST_RETRY_PENDING), # Tolaria-Write-Fehler
|
||||
(ST_VERIFYING_TOLARIA, ST_UPDATING_SEARCH),
|
||||
(ST_VERIFYING_TOLARIA, ST_RETRY_PENDING), # DRIFT != 0 / Tolaria-Fehler
|
||||
(ST_VERIFYING_TOLARIA, ST_HUMAN_REVIEW_REQUIRED), # unexpected drift
|
||||
(ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH),
|
||||
(ST_UPDATING_SEARCH, ST_RETRY_PENDING), # Search-Rebuild-Fehler
|
||||
(ST_UPDATING_SEARCH, ST_HUMAN_REVIEW_REQUIRED),
|
||||
(ST_VERIFYING_SEARCH, ST_APPLIED),
|
||||
(ST_VERIFYING_SEARCH, ST_RETRY_PENDING), # Search-Health-Fehler
|
||||
(ST_VERIFYING_SEARCH, ST_HUMAN_REVIEW_REQUIRED),
|
||||
(ST_RETRY_PENDING, ST_READY), # Retry -> erneut propagieren
|
||||
(ST_RETRY_PENDING, ST_RETRY_PENDING), # idempotenter erneuter Retry-Versuch
|
||||
(ST_RETRY_PENDING, ST_FAILED), # Retry-Limit erreicht
|
||||
(ST_RETRY_PENDING, ST_DEAD), # Max-Retry -> DEAD
|
||||
(ST_RETRY_PENDING, ST_HUMAN_REVIEW_REQUIRED),# Human-Gate-Fehler
|
||||
(ST_FAILED, ST_HUMAN_REVIEW_REQUIRED), # FAILED -> Human Review
|
||||
(ST_DEAD, ST_HUMAN_REVIEW_REQUIRED), # DEAD -> Human Review
|
||||
(ST_HUMAN_REVIEW_REQUIRED, ST_READY), # Human entscheidet -> erneut
|
||||
(ST_HUMAN_REVIEW_REQUIRED, ST_APPLIED), # Human bestätigt als angewendet
|
||||
(ST_HUMAN_REVIEW_REQUIRED, ST_DEAD), # Human verwirft
|
||||
}
|
||||
|
||||
# Bootstrap-Übergänge
|
||||
_ALLOWED_BOOTSTRAP = {
|
||||
(BS_UNINITIALIZED, BS_RECONCILING),
|
||||
(BS_RECONCILING, BS_BASELINE_READY),
|
||||
(BS_RECONCILING, BS_UNINITIALIZED), # Reconciliation fehlgeschlagen -> zurück
|
||||
(BS_BASELINE_READY, BS_ACTIVE),
|
||||
}
|
||||
|
||||
|
||||
class C5AError(Exception):
|
||||
"""Basis-Fehlerklasse für C5A."""
|
||||
|
||||
def __init__(self, message: str, reason_code: Optional[str] = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.reason_code = reason_code
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"error": self.message, "reason_code": self.reason_code}
|
||||
|
||||
|
||||
class InvalidTransitionError(C5AError):
|
||||
"""Ungültiger State-Machine-Übergang."""
|
||||
|
||||
|
||||
class InvalidReasonCodeError(C5AError):
|
||||
"""Unbekannter Reason Code."""
|
||||
|
||||
|
||||
class InvalidOperationError(C5AError):
|
||||
"""Unbekannte Operation."""
|
||||
|
||||
|
||||
class NoWriteGuaranteeError(C5AError):
|
||||
"""C5A darf keine externen Writes ausführen."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistenz (SQLite, restart-fest, atomic, inspectable, backupbar)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class C5AStore:
|
||||
"""
|
||||
Persistente Progress-State-Struktur für C5A.
|
||||
|
||||
SQLite-basiert, isoliert (eigene c5a.db), restart-fest. Keine externe DB,
|
||||
kein Trading-/Forgejo-DB-Coupling. Atomic via SQLite-Transaktionen.
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = str(db_path)
|
||||
self._conn = sqlite3.connect(self.db_path)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self._conn:
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS commits (
|
||||
commit_sha TEXT PRIMARY KEY,
|
||||
parent_sha TEXT,
|
||||
discovered_at INTEGER,
|
||||
sequence INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_error_code TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
commit_sha TEXT NOT NULL,
|
||||
object_id TEXT NOT NULL,
|
||||
path_before TEXT,
|
||||
path_after TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
content_hash_before TEXT,
|
||||
content_hash_after TEXT,
|
||||
metadata_hash_before TEXT,
|
||||
metadata_hash_after TEXT,
|
||||
representation TEXT,
|
||||
state TEXT,
|
||||
UNIQUE(commit_sha, object_id, operation)
|
||||
)
|
||||
""")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS bootstrap (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
state TEXT NOT NULL,
|
||||
baseline_commit TEXT,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS health (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_seen_commit TEXT,
|
||||
last_applied_commit TEXT,
|
||||
last_success_at INTEGER,
|
||||
last_error_code TEXT,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
# Schema-Version setzen
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
|
||||
(str(self.SCHEMA_VERSION),),
|
||||
)
|
||||
# Bootstrap initialisieren
|
||||
row = self._conn.execute("SELECT state FROM bootstrap WHERE id = 1").fetchone()
|
||||
if row is None:
|
||||
self._conn.execute(
|
||||
"INSERT INTO bootstrap (id, state, updated_at) VALUES (1, ?, ?)",
|
||||
(BS_UNINITIALIZED, int(time.time() * 1000)),
|
||||
)
|
||||
# Health initialisieren
|
||||
row = self._conn.execute("SELECT id FROM health WHERE id = 1").fetchone()
|
||||
if row is None:
|
||||
self._conn.execute(
|
||||
"INSERT INTO health (id, updated_at) VALUES (1, ?)",
|
||||
(int(time.time() * 1000),),
|
||||
)
|
||||
|
||||
# -- Bootstrap ----------------------------------------------------------
|
||||
|
||||
def bootstrap_state(self) -> str:
|
||||
row = self._conn.execute("SELECT state FROM bootstrap WHERE id = 1").fetchone()
|
||||
return row["state"] if row else BS_UNINITIALIZED
|
||||
|
||||
def bootstrap_transition(self, to_state: str) -> str:
|
||||
"""Deterministischer Bootstrap-Übergang. Gibt den neuen Zustand zurück."""
|
||||
if to_state not in BOOTSTRAP_STATES:
|
||||
raise InvalidTransitionError(f"Unbekannter Bootstrap-Zustand: {to_state}")
|
||||
cur = self.bootstrap_state()
|
||||
if (cur, to_state) not in _ALLOWED_BOOTSTRAP:
|
||||
raise InvalidTransitionError(
|
||||
f"Ungültiger Bootstrap-Übergang: {cur} -> {to_state}"
|
||||
)
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE bootstrap SET state = ?, updated_at = ? WHERE id = 1",
|
||||
(to_state, int(time.time() * 1000)),
|
||||
)
|
||||
return to_state
|
||||
|
||||
def set_baseline(self, commit_sha: str) -> None:
|
||||
"""Setzt die Baseline (nur aus BASELINE_READY)."""
|
||||
if self.bootstrap_state() != BS_BASELINE_READY:
|
||||
raise C5AError(
|
||||
"Baseline darf nur im Zustand BASELINE_READY gesetzt werden",
|
||||
reason_code=RC_OUT_OF_ORDER_COMMIT,
|
||||
)
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE bootstrap SET baseline_commit = ?, updated_at = ? WHERE id = 1",
|
||||
(commit_sha, int(time.time() * 1000)),
|
||||
)
|
||||
self._conn.execute(
|
||||
"UPDATE health SET last_seen_commit = ?, last_applied_commit = ?, updated_at = ? WHERE id = 1",
|
||||
(commit_sha, commit_sha, int(time.time() * 1000)),
|
||||
)
|
||||
|
||||
def baseline_commit(self) -> Optional[str]:
|
||||
row = self._conn.execute("SELECT baseline_commit FROM bootstrap WHERE id = 1").fetchone()
|
||||
return row["baseline_commit"] if row else None
|
||||
|
||||
# -- Commits ------------------------------------------------------------
|
||||
|
||||
def upsert_commit(self, commit: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Legt einen Commit an oder aktualisiert ihn (idempotent per commit_sha)."""
|
||||
sha = commit["commit_sha"]
|
||||
parent = commit.get("parent_sha")
|
||||
discovered = commit.get("discovered_at", int(time.time() * 1000))
|
||||
seq = commit.get("sequence")
|
||||
status = commit.get("status", ST_DISCOVERED)
|
||||
retry = commit.get("retry_count", 0)
|
||||
last_err = commit.get("last_error")
|
||||
last_code = commit.get("last_error_code")
|
||||
now = int(time.time() * 1000)
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO commits (commit_sha, parent_sha, discovered_at, sequence,
|
||||
status, retry_count, last_error, last_error_code,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(commit_sha) DO UPDATE SET
|
||||
parent_sha = excluded.parent_sha,
|
||||
sequence = excluded.sequence,
|
||||
status = excluded.status,
|
||||
retry_count = excluded.retry_count,
|
||||
last_error = excluded.last_error,
|
||||
last_error_code = excluded.last_error_code,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(sha, parent, discovered, seq, status, retry, last_err, last_code, now, now),
|
||||
)
|
||||
return self.get_commit(sha)
|
||||
|
||||
def get_commit(self, commit_sha: str) -> Optional[Dict[str, Any]]:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM commits WHERE commit_sha = ?", (commit_sha,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def commit_status(self, commit_sha: str) -> Optional[str]:
|
||||
row = self._conn.execute(
|
||||
"SELECT status FROM commits WHERE commit_sha = ?", (commit_sha,)
|
||||
).fetchone()
|
||||
return row["status"] if row else None
|
||||
|
||||
def transition_commit(self, commit_sha: str, to_state: str) -> str:
|
||||
"""Deterministischer Sync-State-Übergang für einen Commit."""
|
||||
if to_state not in SYNC_STATES:
|
||||
raise InvalidTransitionError(f"Unbekannter Sync-Zustand: {to_state}")
|
||||
cur = self.commit_status(commit_sha)
|
||||
if cur is None:
|
||||
raise C5AError(f"Commit nicht gefunden: {commit_sha}")
|
||||
if (cur, to_state) not in _ALLOWED_TRANSITIONS:
|
||||
raise InvalidTransitionError(
|
||||
f"Ungültiger Sync-Übergang für {commit_sha}: {cur} -> {to_state}"
|
||||
)
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE commits SET status = ?, updated_at = ? WHERE commit_sha = ?",
|
||||
(to_state, int(time.time() * 1000), commit_sha),
|
||||
)
|
||||
return to_state
|
||||
|
||||
def set_commit_error(self, commit_sha: str, reason_code: str, message: str) -> None:
|
||||
"""Setzt Fehler + Reason Code auf einen Commit."""
|
||||
if reason_code not in REASON_CODES:
|
||||
raise InvalidReasonCodeError(f"Unbekannter Reason Code: {reason_code}")
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE commits SET last_error = ?, last_error_code = ?, updated_at = ? WHERE commit_sha = ?",
|
||||
(message, reason_code, int(time.time() * 1000), commit_sha),
|
||||
)
|
||||
|
||||
def increment_retry(self, commit_sha: str) -> int:
|
||||
"""Erhöht retry_count. Gibt den neuen Wert zurück."""
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE commits SET retry_count = retry_count + 1, updated_at = ? WHERE commit_sha = ?",
|
||||
(int(time.time() * 1000), commit_sha),
|
||||
)
|
||||
row = self._conn.execute(
|
||||
"SELECT retry_count FROM commits WHERE commit_sha = ?", (commit_sha,)
|
||||
).fetchone()
|
||||
return row["retry_count"] if row else 0
|
||||
|
||||
def list_commits(self, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
if status:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM commits WHERE status = ? ORDER BY sequence ASC", (status,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM commits ORDER BY sequence ASC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# -- Objects ------------------------------------------------------------
|
||||
|
||||
def add_object_change(self, obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fügt eine Object-Change hinzu (idempotent per commit_sha+object_id+operation)."""
|
||||
if obj.get("operation") not in OPERATIONS:
|
||||
raise InvalidOperationError(f"Unbekannte Operation: {obj.get('operation')}")
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO objects (commit_sha, object_id, path_before, path_after,
|
||||
operation, content_hash_before, content_hash_after,
|
||||
metadata_hash_before, metadata_hash_after, representation, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
obj["commit_sha"], obj["object_id"], obj.get("path_before"),
|
||||
obj.get("path_after"), obj["operation"],
|
||||
obj.get("content_hash_before"), obj.get("content_hash_after"),
|
||||
obj.get("metadata_hash_before"), obj.get("metadata_hash_after"),
|
||||
obj.get("representation"), obj.get("state"),
|
||||
),
|
||||
)
|
||||
return self.get_object_change(obj["commit_sha"], obj["object_id"], obj["operation"])
|
||||
|
||||
def get_object_change(self, commit_sha: str, object_id: str, operation: str) -> Optional[Dict[str, Any]]:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM objects WHERE commit_sha = ? AND object_id = ? AND operation = ?",
|
||||
(commit_sha, object_id, operation),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_object_changes(self, commit_sha: str) -> List[Dict[str, Any]]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM objects WHERE commit_sha = ? ORDER BY id ASC", (commit_sha,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# -- Health -------------------------------------------------------------
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
"""Health Contract (finalisiert)."""
|
||||
h = self._conn.execute("SELECT * FROM health WHERE id = 1").fetchone()
|
||||
h = dict(h) if h else {}
|
||||
commits = self._conn.execute("SELECT status, COUNT(*) as c FROM commits GROUP BY status").fetchall()
|
||||
counts = {r["status"]: r["c"] for r in commits}
|
||||
pending = sum(counts.get(s, 0) for s in (
|
||||
ST_DISCOVERED, ST_VALIDATING, ST_READY, ST_PROPAGATING_TOLARIA,
|
||||
ST_VERIFYING_TOLARIA, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH,
|
||||
ST_RETRY_PENDING, ST_WAITING_FOR_PREDECESSOR,
|
||||
))
|
||||
failed = counts.get(ST_FAILED, 0)
|
||||
dead = counts.get(ST_DEAD, 0)
|
||||
human = counts.get(ST_HUMAN_REVIEW_REQUIRED, 0)
|
||||
drift = self._conn.execute(
|
||||
"SELECT COUNT(*) as c FROM objects WHERE operation = ? AND state = ?",
|
||||
(OP_CONTENT_UPDATE, "drift"),
|
||||
).fetchone()["c"]
|
||||
# Status ableiten
|
||||
if dead > 0 or failed > 0:
|
||||
status = HEALTH_ERROR
|
||||
elif pending > 0 or human > 0:
|
||||
status = HEALTH_DEGRADED
|
||||
else:
|
||||
status = HEALTH_OK
|
||||
return {
|
||||
"status": status,
|
||||
"bootstrap_state": self.bootstrap_state(),
|
||||
"last_seen_commit": h.get("last_seen_commit"),
|
||||
"last_applied_commit": h.get("last_applied_commit"),
|
||||
"pending_commits": pending,
|
||||
"failed_commits": failed,
|
||||
"dead_commits": dead,
|
||||
"human_review_required": human,
|
||||
"forgejo_status": "UNKNOWN", # C5A führt keine echten Probes aus
|
||||
"tolaria_status": "UNKNOWN",
|
||||
"search_status": "UNKNOWN",
|
||||
"drift_count": drift,
|
||||
"last_success_at": h.get("last_success_at"),
|
||||
"last_error_code": h.get("last_error_code"),
|
||||
}
|
||||
|
||||
def mark_applied(self, commit_sha: str) -> None:
|
||||
"""Fortschreiben von last_applied_commit NUR nach vollständigem PASS."""
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE health SET last_applied_commit = ?, last_success_at = ?, updated_at = ? WHERE id = 1",
|
||||
(commit_sha, int(time.time() * 1000), int(time.time() * 1000)),
|
||||
)
|
||||
|
||||
def mark_seen(self, commit_sha: str) -> None:
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE health SET last_seen_commit = ?, updated_at = ? WHERE id = 1",
|
||||
(commit_sha, int(time.time() * 1000)),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync State Machine (deterministische Logik)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SyncStateMachine:
|
||||
"""
|
||||
Deterministische Sync State Machine.
|
||||
|
||||
Verarbeitet Commits in korrekter Reihenfolge (parent_sha / last_applied_commit),
|
||||
mit Idempotenz, Retry/Backoff, Human-Gates und Reason Codes. Führt KEINE
|
||||
externen Writes aus — nur Zustandsübergänge in der C5AStore.
|
||||
"""
|
||||
|
||||
def __init__(self, store: C5AStore, max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
backoff_seconds: Optional[List[int]] = None):
|
||||
self.store = store
|
||||
self.max_retries = max_retries
|
||||
self.backoff_seconds = backoff_seconds or DEFAULT_BACKOFF_SECONDS
|
||||
|
||||
# -- Idempotenz ---------------------------------------------------------
|
||||
|
||||
def idempotency_check(self, commit_sha: str, object_id: str, operation: str,
|
||||
content_hash: Optional[str] = None) -> str:
|
||||
"""
|
||||
Deterministische Idempotenz-Prüfung.
|
||||
|
||||
Basis: commit_sha + object_id + operation. Zusätzlich Hash-Checks.
|
||||
Rückgabe: ALREADY_APPLIED | ALREADY_AT_TARGET | RETRY_SAFE | CONFLICT
|
||||
"""
|
||||
existing = self.store.get_object_change(commit_sha, object_id, operation)
|
||||
if existing is None:
|
||||
return IDEM_RETRY_SAFE
|
||||
# Commit bereits vollständig angewendet
|
||||
if self.store.commit_status(commit_sha) == ST_APPLIED:
|
||||
return IDEM_ALREADY_APPLIED
|
||||
# Ziel-Hash bereits erreicht (gleicher Inhalt)
|
||||
if content_hash is not None:
|
||||
if existing.get("content_hash_after") == content_hash:
|
||||
return IDEM_ALREADY_AT_TARGET
|
||||
# Existiert, aber nicht angewendet und Hash weicht ab -> Konflikt
|
||||
return IDEM_CONFLICT
|
||||
|
||||
# -- Ordering -----------------------------------------------------------
|
||||
|
||||
def predecessor_applied(self, commit_sha: str) -> bool:
|
||||
"""Prüft, ob der Vorgänger (parent_sha) bereits angewendet ist."""
|
||||
commit = self.store.get_commit(commit_sha)
|
||||
if commit is None:
|
||||
return False
|
||||
parent = commit.get("parent_sha")
|
||||
if not parent:
|
||||
return True # Root-Commit, kein Vorgänger
|
||||
parent_status = self.store.commit_status(parent)
|
||||
return parent_status == ST_APPLIED
|
||||
|
||||
def check_ordering(self, commit_sha: str) -> str:
|
||||
"""
|
||||
Prüft die Commit-Reihenfolge. Gibt ST_READY oder ST_WAITING_FOR_PREDECESSOR zurück.
|
||||
"""
|
||||
if not self.predecessor_applied(commit_sha):
|
||||
return ST_WAITING_FOR_PREDECESSOR
|
||||
return ST_READY
|
||||
|
||||
# -- Retry / Backoff ----------------------------------------------------
|
||||
|
||||
def backoff_for(self, retry_count: int) -> int:
|
||||
"""Backoff-Sekunden für den aktuellen Retry-Versuch."""
|
||||
if retry_count <= 0:
|
||||
return 0
|
||||
idx = min(retry_count - 1, len(self.backoff_seconds) - 1)
|
||||
return self.backoff_seconds[idx]
|
||||
|
||||
def retry_available(self, commit_sha: str) -> bool:
|
||||
commit = self.store.get_commit(commit_sha)
|
||||
if commit is None:
|
||||
return False
|
||||
return commit.get("retry_count", 0) < self.max_retries
|
||||
|
||||
# -- Human Gate ---------------------------------------------------------
|
||||
|
||||
def human_gate(self, commit_sha: str, reason_code: str, message: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Überführt einen Commit in HUMAN_REVIEW_REQUIRED (FAIL CLOSED).
|
||||
Gibt ein Ergebnis-Dict zurück.
|
||||
"""
|
||||
if reason_code not in REASON_CODES:
|
||||
raise InvalidReasonCodeError(f"Unbekannter Reason Code: {reason_code}")
|
||||
self.store.set_commit_error(commit_sha, reason_code, message)
|
||||
self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED)
|
||||
return {"commit_sha": commit_sha, "status": ST_HUMAN_REVIEW_REQUIRED,
|
||||
"reason_code": reason_code}
|
||||
|
||||
# -- Hauptverarbeitung (deterministisch, NO-WRITE) ----------------------
|
||||
|
||||
def process_commit(self, commit: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Verarbeitet einen Commit durch die State Machine.
|
||||
|
||||
NO-WRITE: Diese Methode führt KEINE externen Writes aus. Sie modelliert
|
||||
nur die Zustandsübergänge. Die eigentliche Tolaria-Propagation und der
|
||||
Search-Rebuild werden in späteren Phasen (C5C/C5D) als injizierte
|
||||
Callables ergänzt — C5A ruft sie NICHT auf.
|
||||
|
||||
Rückgabe: Dict mit commit_sha, status, reason_code (optional), idempotency.
|
||||
"""
|
||||
sha = commit["commit_sha"]
|
||||
# Idempotenz: bereits angewendet?
|
||||
if self.store.commit_status(sha) == ST_APPLIED:
|
||||
return {"commit_sha": sha, "status": ST_APPLIED, "idempotency": IDEM_ALREADY_APPLIED}
|
||||
|
||||
# Commit anlegen/aktualisieren
|
||||
self.store.upsert_commit(commit)
|
||||
|
||||
# Ordering prüfen
|
||||
order = self.check_ordering(sha)
|
||||
if order == ST_WAITING_FOR_PREDECESSOR:
|
||||
self.store.transition_commit(sha, ST_WAITING_FOR_PREDECESSOR)
|
||||
return {"commit_sha": sha, "status": ST_WAITING_FOR_PREDECESSOR,
|
||||
"reason_code": RC_OUT_OF_ORDER_COMMIT}
|
||||
|
||||
# DISCOVERED -> VALIDATING
|
||||
self.store.transition_commit(sha, ST_VALIDATING)
|
||||
|
||||
# Validierung der Object-Changes
|
||||
for obj in commit.get("changed_objects", []):
|
||||
op = obj.get("operation")
|
||||
if op not in OPERATIONS:
|
||||
return self.human_gate(sha, RC_INVALID_SCHEMA,
|
||||
f"Unbekannte Operation: {op}")
|
||||
if not obj.get("object_id"):
|
||||
return self.human_gate(sha, RC_UNKNOWN_OBJECT_ID,
|
||||
"object_id fehlt")
|
||||
# DELETE_REQUEST -> Human Gate (kein automatisches Hard Delete)
|
||||
if op == OP_DELETE_REQUEST:
|
||||
return self.human_gate(sha, RC_AMBIGUOUS_DELETE,
|
||||
"DELETE_REQUEST erfordert Human Gate")
|
||||
# DANGLING_DERIVED_FROM: wenn derived_from gesetzt, muss Ziel existieren
|
||||
if obj.get("derived_from") and not self._object_exists(obj["derived_from"]):
|
||||
return self.human_gate(sha, RC_DANGLING_DERIVED_FROM,
|
||||
f"dangling derived_from: {obj['derived_from']}")
|
||||
|
||||
# VALIDATING -> READY
|
||||
self.store.transition_commit(sha, ST_READY)
|
||||
|
||||
# READY -> PROPAGATING_TOLARIA (Modellierung; kein echter Write)
|
||||
self.store.transition_commit(sha, ST_PROPAGATING_TOLARIA)
|
||||
self.store.transition_commit(sha, ST_VERIFYING_TOLARIA)
|
||||
|
||||
# DRIFT-Prüfung (modelliert): wenn ein Objekt als drift markiert ist -> Human Gate
|
||||
for obj in commit.get("changed_objects", []):
|
||||
if obj.get("drift"):
|
||||
return self.human_gate(sha, RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
f"unexpected drift auf {obj['object_id']}")
|
||||
|
||||
# VERIFYING_TOLARIA -> UPDATING_SEARCH -> VERIFYING_SEARCH
|
||||
self.store.transition_commit(sha, ST_UPDATING_SEARCH)
|
||||
self.store.transition_commit(sha, ST_VERIFYING_SEARCH)
|
||||
|
||||
# VERIFYING_SEARCH -> APPLIED
|
||||
self.store.transition_commit(sha, ST_APPLIED)
|
||||
self.store.mark_applied(sha)
|
||||
|
||||
return {"commit_sha": sha, "status": ST_APPLIED, "idempotency": IDEM_RETRY_SAFE}
|
||||
|
||||
def _object_exists(self, object_id: str) -> bool:
|
||||
"""Prüft, ob eine object_id bereits in irgendeinem Commit existiert."""
|
||||
row = self.store._conn.execute(
|
||||
"SELECT 1 FROM objects WHERE object_id = ? LIMIT 1", (object_id,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
# -- Retry-Verarbeitung -------------------------------------------------
|
||||
|
||||
def retry_commit(self, commit_sha: str, reason_code: str, message: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Behandelt einen Fehler: Retry, FAILED oder DEAD je nach retry_count.
|
||||
"""
|
||||
if reason_code not in REASON_CODES:
|
||||
raise InvalidReasonCodeError(f"Unbekannter Reason Code: {reason_code}")
|
||||
self.store.set_commit_error(commit_sha, reason_code, message)
|
||||
retry = self.store.increment_retry(commit_sha)
|
||||
if retry >= self.max_retries:
|
||||
self.store.transition_commit(commit_sha, ST_DEAD)
|
||||
return {"commit_sha": commit_sha, "status": ST_DEAD, "reason_code": reason_code,
|
||||
"retry_count": retry}
|
||||
self.store.transition_commit(commit_sha, ST_RETRY_PENDING)
|
||||
return {"commit_sha": commit_sha, "status": ST_RETRY_PENDING, "reason_code": reason_code,
|
||||
"retry_count": retry, "backoff_seconds": self.backoff_for(retry)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-Write-Guarantee (statische Prüfung)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def assert_no_write_guarantee() -> Dict[str, Any]:
|
||||
"""
|
||||
Beweist, dass C5A keine externen Writes ausführen kann.
|
||||
|
||||
Prüft, dass keine Netzwerk-/HTTP-/Socket-/Subprocess-Mutationsfunktionen
|
||||
importiert oder verwendet werden.
|
||||
"""
|
||||
import ast
|
||||
import sys
|
||||
this_file = Path(__file__).resolve()
|
||||
tree = ast.parse(this_file.read_text(encoding="utf-8"))
|
||||
banned_imports = {"requests", "urllib", "http", "socket", "subprocess", "os.system"}
|
||||
found = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
root = alias.name.split(".")[0]
|
||||
if root in banned_imports:
|
||||
found.add(root)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module:
|
||||
root = node.module.split(".")[0]
|
||||
if root in banned_imports:
|
||||
found.add(root)
|
||||
return {
|
||||
"no_network_imports": len(found) == 0,
|
||||
"banned_imports_found": sorted(found),
|
||||
"no_write_guarantee": len(found) == 0,
|
||||
}
|
||||
162
tolaria/c5-sync-service/rq_c5a_cli.py
Normal file
162
tolaria/c5-sync-service/rq_c5a_cli.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Red Queen — C5A: CONTRACT & STATE MACHINE CLI.
|
||||
|
||||
Deterministische, inaktive CLI zur Inspektion und Steuerung der C5A State Machine.
|
||||
Führt KEINE externen Writes aus (kein Polling, kein Tolaria-Write, kein Search-Rebuild).
|
||||
|
||||
Nutzung:
|
||||
python3 rq_c5a_cli.py --db <path> health
|
||||
python3 rq_c5a_cli.py --db <path> bootstrap-state
|
||||
python3 rq_c5a_cli.py --db <path> bootstrap-transition --to RECONCILING
|
||||
python3 rq_c5a_cli.py --db <path> set-baseline --commit <sha>
|
||||
python3 rq_c5a_cli.py --db <path> ingest-commit --json <file>
|
||||
python3 rq_c5a_cli.py --db <path> process-commit --json <file>
|
||||
python3 rq_c5a_cli.py --db <path> list-commits [--status <s>]
|
||||
python3 rq_c5a_cli.py --db <path> commit-status --sha <sha>
|
||||
python3 rq_c5a_cli.py --db <path> no-write-check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rq_c5a import (
|
||||
C5AStore,
|
||||
SyncStateMachine,
|
||||
assert_no_write_guarantee,
|
||||
C5AError,
|
||||
InvalidTransitionError,
|
||||
InvalidReasonCodeError,
|
||||
InvalidOperationError,
|
||||
)
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def cmd_health(store: C5AStore, args) -> int:
|
||||
print(json.dumps(store.health(), indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_bootstrap_state(store: C5AStore, args) -> int:
|
||||
print(store.bootstrap_state())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_bootstrap_transition(store: C5AStore, args) -> int:
|
||||
try:
|
||||
new_state = store.bootstrap_transition(args.to)
|
||||
except InvalidTransitionError as e:
|
||||
print(json.dumps(e.to_dict(), ensure_ascii=False))
|
||||
return 2
|
||||
print(new_state)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_set_baseline(store: C5AStore, args) -> int:
|
||||
try:
|
||||
store.set_baseline(args.commit)
|
||||
except C5AError as e:
|
||||
print(json.dumps(e.to_dict(), ensure_ascii=False))
|
||||
return 2
|
||||
print(f"baseline={args.commit}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_ingest_commit(store: C5AStore, args) -> int:
|
||||
commit = _load_json(args.json)
|
||||
store.upsert_commit(commit)
|
||||
for obj in commit.get("changed_objects", []):
|
||||
obj["commit_sha"] = commit["commit_sha"]
|
||||
store.add_object_change(obj)
|
||||
print(json.dumps({"commit_sha": commit["commit_sha"], "status": "ingested"}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_process_commit(store: C5AStore, args) -> int:
|
||||
commit = _load_json(args.json)
|
||||
sm = SyncStateMachine(store)
|
||||
try:
|
||||
result = sm.process_commit(commit)
|
||||
except C5AError as e:
|
||||
print(json.dumps(e.to_dict(), ensure_ascii=False))
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list_commits(store: C5AStore, args) -> int:
|
||||
commits = store.list_commits(status=args.status)
|
||||
print(json.dumps(commits, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_commit_status(store: C5AStore, args) -> int:
|
||||
status = store.commit_status(args.sha)
|
||||
if status is None:
|
||||
print(json.dumps({"error": f"Commit nicht gefunden: {args.sha}"}, ensure_ascii=False))
|
||||
return 2
|
||||
print(status)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_no_write_check(store: C5AStore, args) -> int:
|
||||
result = assert_no_write_guarantee()
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
return 0 if result["no_write_guarantee"] else 2
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description="C5A Contract & State Machine CLI")
|
||||
parser.add_argument("--db", default=os_env_db(), help="Pfad zur c5a.db (default: $C5A_DB oder ./c5a.db)")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("health", help="Health Contract anzeigen")
|
||||
sub.add_parser("bootstrap-state", help="Bootstrap-Zustand anzeigen")
|
||||
p = sub.add_parser("bootstrap-transition", help="Bootstrap-Übergang")
|
||||
p.add_argument("--to", required=True)
|
||||
p = sub.add_parser("set-baseline", help="Baseline setzen")
|
||||
p.add_argument("--commit", required=True)
|
||||
p = sub.add_parser("ingest-commit", help="Commit + Object-Changes aufnehmen")
|
||||
p.add_argument("--json", required=True)
|
||||
p = sub.add_parser("process-commit", help="Commit durch State Machine verarbeiten")
|
||||
p.add_argument("--json", required=True)
|
||||
p = sub.add_parser("list-commits", help="Commits auflisten")
|
||||
p.add_argument("--status")
|
||||
p = sub.add_parser("commit-status", help="Status eines Commits")
|
||||
p.add_argument("--sha", required=True)
|
||||
sub.add_parser("no-write-check", help="No-Write-Guarantee prüfen")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
store = C5AStore(args.db)
|
||||
try:
|
||||
handlers = {
|
||||
"health": cmd_health,
|
||||
"bootstrap-state": cmd_bootstrap_state,
|
||||
"bootstrap-transition": cmd_bootstrap_transition,
|
||||
"set-baseline": cmd_set_baseline,
|
||||
"ingest-commit": cmd_ingest_commit,
|
||||
"process-commit": cmd_process_commit,
|
||||
"list-commits": cmd_list_commits,
|
||||
"commit-status": cmd_commit_status,
|
||||
"no-write-check": cmd_no_write_check,
|
||||
}
|
||||
return handlers[args.cmd](store, args)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def os_env_db() -> str:
|
||||
import os
|
||||
return os.environ.get("C5A_DB", "c5a.db")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
475
tolaria/c5-sync-service/test_c5a.py
Normal file
475
tolaria/c5-sync-service/test_c5a.py
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Red Queen — C5A: CONTRACT & STATE MACHINE Testsuite.
|
||||
|
||||
Deterministische Unit Tests. Jeder Test nutzt eine frische temp-DB (tempfile.mkdtemp),
|
||||
niemals die Produkt-DB. Beweist die No-Write-Guarantee und alle geforderten
|
||||
State-Machine-, Idempotenz-, Ordering-, Retry-, Human-Gate-, Bootstrap- und
|
||||
Persistenz-Verhalten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from rq_c5a import (
|
||||
C5AStore,
|
||||
SyncStateMachine,
|
||||
assert_no_write_guarantee,
|
||||
C5AError,
|
||||
InvalidTransitionError,
|
||||
InvalidReasonCodeError,
|
||||
InvalidOperationError,
|
||||
ST_DISCOVERED, ST_VALIDATING, ST_READY,
|
||||
ST_PROPAGATING_TOLARIA, ST_VERIFYING_TOLARIA,
|
||||
ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
|
||||
ST_RETRY_PENDING, ST_FAILED, ST_DEAD, ST_HUMAN_REVIEW_REQUIRED,
|
||||
ST_WAITING_FOR_PREDECESSOR,
|
||||
BS_UNINITIALIZED, BS_RECONCILING, BS_BASELINE_READY, BS_ACTIVE,
|
||||
OP_CREATE, OP_CONTENT_UPDATE, OP_DELETE_REQUEST,
|
||||
IDEM_ALREADY_APPLIED, IDEM_ALREADY_AT_TARGET, IDEM_RETRY_SAFE, IDEM_CONFLICT,
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT, RC_ID_COLLISION, RC_UNKNOWN_OBJECT_ID,
|
||||
RC_INVALID_SCHEMA, RC_DANGLING_DERIVED_FROM, RC_AMBIGUOUS_DELETE,
|
||||
RC_OUT_OF_ORDER_COMMIT, RC_SECRET_DETECTED,
|
||||
RC_UNKNOWN_LEGACY_OBJECT, RC_AUTH_FAILURE, RC_SEARCH_REBUILD_FAILURE,
|
||||
RC_TOLARIA_UNAVAILABLE, RC_FORGEJO_UNAVAILABLE,
|
||||
REASON_CODES, OPERATIONS, SYNC_STATES, BOOTSTRAP_STATES,
|
||||
)
|
||||
|
||||
|
||||
def _new_store():
|
||||
tmp = tempfile.mkdtemp(prefix="c5a_test_")
|
||||
return C5AStore(os.path.join(tmp, "c5a.db"))
|
||||
|
||||
|
||||
def _commit(sha, parent=None, seq=1, objects=None):
|
||||
return {
|
||||
"commit_sha": sha,
|
||||
"parent_sha": parent,
|
||||
"discovered_at": 1000,
|
||||
"sequence": seq,
|
||||
"changed_objects": objects or [],
|
||||
}
|
||||
|
||||
|
||||
def _obj(object_id, op=OP_CREATE, **kw):
|
||||
d = {"object_id": object_id, "operation": op}
|
||||
d.update(kw)
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Testfälle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_normal_commit_lifecycle():
|
||||
"""Normaler Commit-Lebenszyklus: DISCOVERED -> ... -> APPLIED."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.upsert_commit(c)
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
result = sm.process_commit(c)
|
||||
assert result["status"] == ST_APPLIED, result
|
||||
assert store.commit_status("c1") == ST_APPLIED
|
||||
assert store.health()["last_applied_commit"] == "c1"
|
||||
store.close()
|
||||
|
||||
|
||||
def test_multi_object_commit():
|
||||
"""Multi-Object-Commit: mehrere Object-Changes in einem Commit."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[
|
||||
_obj("obj-1"), _obj("obj-2"), _obj("obj-3"),
|
||||
])
|
||||
for o in c["changed_objects"]:
|
||||
store.add_object_change({"commit_sha": "c1", **o})
|
||||
result = sm.process_commit(c)
|
||||
assert result["status"] == ST_APPLIED
|
||||
assert len(store.list_object_changes("c1")) == 3
|
||||
store.close()
|
||||
|
||||
|
||||
def test_duplicate_commit():
|
||||
"""Doppelter Commit: zweite Verarbeitung -> ALREADY_APPLIED, kein Re-Apply."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
r1 = sm.process_commit(c)
|
||||
assert r1["status"] == ST_APPLIED
|
||||
r2 = sm.process_commit(c)
|
||||
assert r2["status"] == ST_APPLIED
|
||||
assert r2["idempotency"] == IDEM_ALREADY_APPLIED
|
||||
store.close()
|
||||
|
||||
|
||||
def test_already_applied_idempotency():
|
||||
"""Idempotenz: bereits angewendeter Commit -> ALREADY_APPLIED."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
sm.process_commit(c)
|
||||
assert sm.idempotency_check("c1", "obj-1", OP_CREATE) == IDEM_ALREADY_APPLIED
|
||||
store.close()
|
||||
|
||||
|
||||
def test_out_of_order_commit():
|
||||
"""Out-of-Order-Commit: fehlender Vorgänger -> WAITING_FOR_PREDECESSOR."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c2", parent="c1", seq=2, objects=[_obj("obj-1")])
|
||||
store.add_object_change({"commit_sha": "c2", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
result = sm.process_commit(c)
|
||||
assert result["status"] == ST_WAITING_FOR_PREDECESSOR, result
|
||||
assert result["reason_code"] == RC_OUT_OF_ORDER_COMMIT
|
||||
store.close()
|
||||
|
||||
|
||||
def test_missing_predecessor_then_applied():
|
||||
"""Fehlender Vorgänger -> nach Anwendung des Vorgängers verarbeitbar."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c1 = _commit("c1", seq=1, objects=[_obj("obj-1")])
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
sm.process_commit(c1)
|
||||
c2 = _commit("c2", parent="c1", seq=2, objects=[_obj("obj-2")])
|
||||
store.add_object_change({"commit_sha": "c2", "object_id": "obj-2", "operation": OP_CREATE})
|
||||
r2 = sm.process_commit(c2)
|
||||
assert r2["status"] == ST_APPLIED, r2
|
||||
store.close()
|
||||
|
||||
|
||||
def test_retry_progression():
|
||||
"""Retry-Progression: Fehler -> RETRY_PENDING mit Backoff."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.upsert_commit(c)
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
r = sm.retry_commit("c1", RC_TOLARIA_UNAVAILABLE, "tolaria down")
|
||||
assert r["status"] == ST_RETRY_PENDING
|
||||
assert r["retry_count"] == 1
|
||||
assert r["backoff_seconds"] == 1
|
||||
store.close()
|
||||
|
||||
|
||||
def test_max_retry_to_dead():
|
||||
"""Max-Retry -> DEAD, kein Endlos-Retry."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store, max_retries=3)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.upsert_commit(c)
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
for i in range(3):
|
||||
r = sm.retry_commit("c1", RC_TOLARIA_UNAVAILABLE, "down")
|
||||
assert r["status"] == ST_DEAD, r
|
||||
assert store.commit_status("c1") == ST_DEAD
|
||||
store.close()
|
||||
|
||||
|
||||
def test_human_gate_transition():
|
||||
"""Human-Gate-Übergang: DELETE_REQUEST -> HUMAN_REVIEW_REQUIRED."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1", op=OP_DELETE_REQUEST)])
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_DELETE_REQUEST})
|
||||
result = sm.process_commit(c)
|
||||
assert result["status"] == ST_HUMAN_REVIEW_REQUIRED, result
|
||||
assert result["reason_code"] == RC_AMBIGUOUS_DELETE
|
||||
store.close()
|
||||
|
||||
|
||||
def test_restart_reload_persistence():
|
||||
"""Restart-Persistenz: neuer Store auf gleicher DB behält State."""
|
||||
tmp = tempfile.mkdtemp(prefix="c5a_test_")
|
||||
db = os.path.join(tmp, "c5a.db")
|
||||
s1 = C5AStore(db)
|
||||
sm1 = SyncStateMachine(s1)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
s1.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
sm1.process_commit(c)
|
||||
s1.close()
|
||||
s2 = C5AStore(db)
|
||||
assert s2.commit_status("c1") == ST_APPLIED
|
||||
assert s2.health()["last_applied_commit"] == "c1"
|
||||
s2.close()
|
||||
|
||||
|
||||
def test_state_corruption_handling():
|
||||
"""State-Corruption: ungültiger Übergang -> InvalidTransitionError, kein Mutation."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.upsert_commit(c)
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
# Direkt von DISCOVERED nach APPLIED ist nicht erlaubt
|
||||
try:
|
||||
store.transition_commit("c1", ST_APPLIED)
|
||||
assert False, "sollte InvalidTransitionError werfen"
|
||||
except InvalidTransitionError:
|
||||
pass
|
||||
assert store.commit_status("c1") == ST_DISCOVERED
|
||||
store.close()
|
||||
|
||||
|
||||
def test_idempotency_conflict():
|
||||
"""Idempotenz-Konflikt: existierender Change mit abweichendem Hash -> CONFLICT."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.upsert_commit(c)
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1",
|
||||
"operation": OP_CREATE, "content_hash_after": "hashA"})
|
||||
# Nicht angewendet, Hash weicht ab -> CONFLICT
|
||||
assert sm.idempotency_check("c1", "obj-1", OP_CREATE, content_hash="hashB") == IDEM_CONFLICT
|
||||
# Gleicher Hash -> ALREADY_AT_TARGET
|
||||
assert sm.idempotency_check("c1", "obj-1", OP_CREATE, content_hash="hashA") == IDEM_ALREADY_AT_TARGET
|
||||
store.close()
|
||||
|
||||
|
||||
def test_delete_request_human_gate():
|
||||
"""DELETE_REQUEST -> Human Gate, kein automatisches Hard Delete."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1", op=OP_DELETE_REQUEST)])
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_DELETE_REQUEST})
|
||||
result = sm.process_commit(c)
|
||||
assert result["status"] == ST_HUMAN_REVIEW_REQUIRED
|
||||
assert result["reason_code"] == RC_AMBIGUOUS_DELETE
|
||||
store.close()
|
||||
|
||||
|
||||
def test_dangling_relation_human_gate():
|
||||
"""Dangling derived_from -> Human Gate."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1", derived_from="nonexistent-obj")])
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1",
|
||||
"operation": OP_CREATE, "derived_from": "nonexistent-obj"})
|
||||
result = sm.process_commit(c)
|
||||
assert result["status"] == ST_HUMAN_REVIEW_REQUIRED
|
||||
assert result["reason_code"] == RC_DANGLING_DERIVED_FROM
|
||||
store.close()
|
||||
|
||||
|
||||
def test_unexpected_drift_human_gate():
|
||||
"""Unexpected Drift -> Human Gate."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1", drift=True)])
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1",
|
||||
"operation": OP_CREATE, "drift": True})
|
||||
result = sm.process_commit(c)
|
||||
assert result["status"] == ST_HUMAN_REVIEW_REQUIRED
|
||||
assert result["reason_code"] == RC_UNEXPECTED_TOLARIA_DRIFT
|
||||
store.close()
|
||||
|
||||
|
||||
def test_no_write_guarantee():
|
||||
"""No-Write-Guarantee: keine Netzwerk-/HTTP-/Socket-/Subprocess-Imports."""
|
||||
result = assert_no_write_guarantee()
|
||||
assert result["no_write_guarantee"] is True, result
|
||||
assert result["banned_imports_found"] == []
|
||||
store.close() if False else None
|
||||
|
||||
|
||||
def test_bootstrap_lifecycle():
|
||||
"""Bootstrap: UNINITIALIZED -> RECONCILING -> BASELINE_READY -> ACTIVE."""
|
||||
store = _new_store()
|
||||
assert store.bootstrap_state() == BS_UNINITIALIZED
|
||||
store.bootstrap_transition(BS_RECONCILING)
|
||||
assert store.bootstrap_state() == BS_RECONCILING
|
||||
store.bootstrap_transition(BS_BASELINE_READY)
|
||||
assert store.bootstrap_state() == BS_BASELINE_READY
|
||||
store.set_baseline("base-1")
|
||||
assert store.baseline_commit() == "base-1"
|
||||
store.bootstrap_transition(BS_ACTIVE)
|
||||
assert store.bootstrap_state() == BS_ACTIVE
|
||||
store.close()
|
||||
|
||||
|
||||
def test_baseline_only_from_baseline_ready():
|
||||
"""Baseline darf nur aus BASELINE_READY gesetzt werden."""
|
||||
store = _new_store()
|
||||
try:
|
||||
store.set_baseline("base-1")
|
||||
assert False, "sollte C5AError werfen"
|
||||
except C5AError:
|
||||
pass
|
||||
store.close()
|
||||
|
||||
|
||||
def test_reason_codes_closed_set():
|
||||
"""Reason Codes: feste, geschlossene Menge — kein freier String-Wildwuchs."""
|
||||
assert RC_UNEXPECTED_TOLARIA_DRIFT in REASON_CODES
|
||||
assert RC_ID_COLLISION in REASON_CODES
|
||||
assert RC_UNKNOWN_OBJECT_ID in REASON_CODES
|
||||
assert RC_INVALID_SCHEMA in REASON_CODES
|
||||
assert RC_DANGLING_DERIVED_FROM in REASON_CODES
|
||||
assert RC_AMBIGUOUS_DELETE in REASON_CODES
|
||||
assert RC_UNKNOWN_LEGACY_OBJECT in REASON_CODES
|
||||
assert RC_AUTH_FAILURE in REASON_CODES
|
||||
assert RC_SEARCH_REBUILD_FAILURE in REASON_CODES
|
||||
assert RC_TOLARIA_UNAVAILABLE in REASON_CODES
|
||||
assert RC_FORGEJO_UNAVAILABLE in REASON_CODES
|
||||
assert RC_OUT_OF_ORDER_COMMIT in REASON_CODES
|
||||
assert RC_SECRET_DETECTED in REASON_CODES
|
||||
assert len(REASON_CODES) == 13
|
||||
store = _new_store()
|
||||
try:
|
||||
store.set_commit_error("c1", "FREIER_STRING", "x")
|
||||
assert False, "sollte InvalidReasonCodeError werfen"
|
||||
except InvalidReasonCodeError:
|
||||
pass
|
||||
store.close()
|
||||
|
||||
|
||||
def test_operations_closed_set():
|
||||
"""Operation Model: geschlossene Menge."""
|
||||
assert OP_CREATE in OPERATIONS
|
||||
assert OP_CONTENT_UPDATE in OPERATIONS
|
||||
assert OP_DELETE_REQUEST in OPERATIONS
|
||||
assert len(OPERATIONS) == 10
|
||||
store = _new_store()
|
||||
try:
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "o",
|
||||
"operation": "BOGUS"})
|
||||
assert False, "sollte InvalidOperationError werfen"
|
||||
except InvalidOperationError:
|
||||
pass
|
||||
store.close()
|
||||
|
||||
|
||||
def test_health_contract_fields():
|
||||
"""Health Contract: alle geforderten Felder vorhanden."""
|
||||
store = _new_store()
|
||||
h = store.health()
|
||||
for field in ["status", "bootstrap_state", "last_seen_commit", "last_applied_commit",
|
||||
"pending_commits", "failed_commits", "dead_commits",
|
||||
"human_review_required", "forgejo_status", "tolaria_status",
|
||||
"search_status", "drift_count", "last_success_at", "last_error_code"]:
|
||||
assert field in h, f"fehlendes Health-Feld: {field}"
|
||||
store.close()
|
||||
|
||||
|
||||
def test_last_applied_only_after_full_pass():
|
||||
"""last_applied_commit wird NUR nach vollständigem Tolaria+Search PASS fortgeschrieben."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
# Commit mit DELETE_REQUEST -> Human Gate, darf NICHT applied werden
|
||||
c = _commit("c1", objects=[_obj("obj-1", op=OP_DELETE_REQUEST)])
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_DELETE_REQUEST})
|
||||
sm.process_commit(c)
|
||||
assert store.health()["last_applied_commit"] is None
|
||||
store.close()
|
||||
|
||||
|
||||
def test_retry_available_bound():
|
||||
"""retry_available: false nach Erreichen von max_retries."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store, max_retries=2)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.upsert_commit(c)
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
assert sm.retry_available("c1") is True
|
||||
sm.retry_commit("c1", RC_TOLARIA_UNAVAILABLE, "down")
|
||||
assert sm.retry_available("c1") is True
|
||||
sm.retry_commit("c1", RC_TOLARIA_UNAVAILABLE, "down")
|
||||
assert sm.retry_available("c1") is False
|
||||
store.close()
|
||||
|
||||
|
||||
def test_backoff_sequence():
|
||||
"""Backoff: 1/2/4/8/16 Sekunden."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
assert sm.backoff_for(1) == 1
|
||||
assert sm.backoff_for(2) == 2
|
||||
assert sm.backoff_for(3) == 4
|
||||
assert sm.backoff_for(4) == 8
|
||||
assert sm.backoff_for(5) == 16
|
||||
assert sm.backoff_for(6) == 16 # gekappt
|
||||
store.close()
|
||||
|
||||
|
||||
def test_invalid_reason_code_rejected():
|
||||
"""Unbekannter Reason Code wird abgelehnt."""
|
||||
store = _new_store()
|
||||
sm = SyncStateMachine(store)
|
||||
c = _commit("c1", objects=[_obj("obj-1")])
|
||||
store.upsert_commit(c)
|
||||
store.add_object_change({"commit_sha": "c1", "object_id": "obj-1", "operation": OP_CREATE})
|
||||
try:
|
||||
sm.retry_commit("c1", "NOT_A_REASON_CODE", "x")
|
||||
assert False, "sollte InvalidReasonCodeError werfen"
|
||||
except InvalidReasonCodeError:
|
||||
pass
|
||||
store.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ALL_TESTS = [
|
||||
test_normal_commit_lifecycle,
|
||||
test_multi_object_commit,
|
||||
test_duplicate_commit,
|
||||
test_already_applied_idempotency,
|
||||
test_out_of_order_commit,
|
||||
test_missing_predecessor_then_applied,
|
||||
test_retry_progression,
|
||||
test_max_retry_to_dead,
|
||||
test_human_gate_transition,
|
||||
test_restart_reload_persistence,
|
||||
test_state_corruption_handling,
|
||||
test_idempotency_conflict,
|
||||
test_delete_request_human_gate,
|
||||
test_dangling_relation_human_gate,
|
||||
test_unexpected_drift_human_gate,
|
||||
test_no_write_guarantee,
|
||||
test_bootstrap_lifecycle,
|
||||
test_baseline_only_from_baseline_ready,
|
||||
test_reason_codes_closed_set,
|
||||
test_operations_closed_set,
|
||||
test_health_contract_fields,
|
||||
test_last_applied_only_after_full_pass,
|
||||
test_retry_available_bound,
|
||||
test_backoff_sequence,
|
||||
test_invalid_reason_code_rejected,
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures = []
|
||||
for t in ALL_TESTS:
|
||||
try:
|
||||
t()
|
||||
passed += 1
|
||||
print(f"PASS {t.__name__}")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
failures.append((t.__name__, e))
|
||||
print(f"FAIL {t.__name__}: {e}")
|
||||
traceback.print_exc()
|
||||
print(f"\n=== C5A: {passed} PASS / {failed} FAIL ===")
|
||||
if failures:
|
||||
for name, e in failures:
|
||||
print(f" FAILED: {name} -> {e}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue