diff --git a/docs/architecture.md b/docs/architecture.md index b9972fac7f..aeecf6f2c2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -390,15 +390,14 @@ obligation ledger and transport contract for Sinex-backed evidence mode: a mirroring `polylogue.toml` `[sinex] mode` / config key `sinex_mode`, default `off`) gates whether a normalized-session material revision (`polylogue/material_protocol/v1/`) gets a durable `source.db` obligation row -and an attempted publish through an injected `SinexTransport`; off mode -performs zero transport work and writes zero obligation rows. -**`sinex_mode` itself is not yet consumed anywhere**: no ingest, daemon, or -CLI call site constructs a `PublicationService` from that config value, so -setting `mode = "mirror"`/`"primary"` in `polylogue.toml` today has no -observable effect on real archive writes (`polylogue config --format json` -surfaces this as a `sinex_mode_not_yet_wired` diagnostic). See -[docs/sinex-interop.md](sinex-interop.md) for the full design, current scope, -and the cross-repo blocker on live Sinex transport. +and exact replay bytes. The daemon drains those obligations through an +injected `SinexTransport`; `primary` holds only the affected local projections +until an allowed durable receipt, while `mirror` exposes lag without blocking +local reads. Off mode performs zero transport work and writes zero obligation +rows. Backed modes require deployment composition to register a concrete +transport; `LocalReferenceTransport` is never selected for deployment. See +[docs/sinex-interop.md](sinex-interop.md) for the full design and the +cross-repo blocker on live Sinex settlement. ## Placement Rules diff --git a/docs/sinex-interop.md b/docs/sinex-interop.md index 86b5ebd5d4..5729615996 100644 --- a/docs/sinex-interop.md +++ b/docs/sinex-interop.md @@ -6,7 +6,7 @@ The full Sinex-backed architecture described here is a target, not the current i Polylogue is, and permanently remains, SQLite-native: standalone SQLite is a first-class, supported product mode, not a deprecated migration path (operator directive, 2026-07-13). `polylogue.toml`'s `[sinex] mode` selects the Sinex-backed authority profile and defaults to `off`, which performs zero Sinex transport work and creates no durable publication obligations. -`polylogue.sinex` (polylogue-303r.2) is the first real Polylogue-side publication producer: a durable `source.db` obligation ledger (`sinex_publication_obligations`, mode `mirror`/`primary`, idempotent by protocol version + revision id + manifest digest), a transport contract modeled on Sinex's documented `DurableEmissionReceipt` (sinex-r6d.11) and `RawEnvelopeSettlement` (sinex-r6d.12) primitives, and a `PublicationService` that stages obligations in the same transaction as the evidence they cover and only reports a revision confirmed when a receipt actually unlocks progress. Its tests exercise the service against an in-process, contract-faithful reference transport (`LocalReferenceTransport`) rather than live Sinex JetStream: as of this package landing, Sinex's own consumer implementation for this exact contract (sinex-4j2.1.1) has not merged, and sinex-r6d.11 itself — the receipt primitive this contract targets — is still open upstream. **No production code path is wired to either transport yet**: setting `[sinex] mode = "mirror"` (or `"primary"`) in `polylogue.toml` today has zero effect on real archive writes — no ingest, daemon, or CLI call site constructs a `PublicationService` from that setting — and `polylogue config --format json` surfaces this explicitly as a `sinex_mode_not_yet_wired` diagnostic. Wiring a real deployment to either the reference transport or live Sinex transport is follow-up work, not something this package's landing completes unilaterally. See `polylogue/sinex/__init__.py` for the full scope note and `tests/unit/sinex/` for the durability/idempotency/receipt-barrier proof this package carries today. +`polylogue.sinex` (polylogue-303r.2.1) wires the local publication producer into production ingest and daemon convergence: mirror/primary ingest encodes and verifies the accepted normalized revision, then atomically records its exact manifest/segment bytes and durable `source.db` obligation. The daemon drains that outbox through a deployment-injected transport, persists bounded retry/receipt state, and recovers from source-tier bytes after restart or ops reset. `primary` blocks only the affected newest local projection until an allowed durable receipt; `mirror` reports exact lag while local reads continue. `off` performs no encoding, outbox write, service construction, or transport work. `LocalReferenceTransport` remains an in-process contract double used only by tests. A backed daemon with no deployment-registered transport fails explicitly; this change does not infer credentials/endpoints or present the test double as a deployed Sinex adapter. The real material/JetStream/settlement integration remains `polylogue-303r.2.2`, pending the corresponding Sinex capabilities. Before `polylogue.sinex` landed, Polylogue could at most emit a low-volume bridge event to Sinex containing session metadata such as identity, origin, content hash, message count, model, and optional cost — the current package supersedes that as the intended producer, though it does not yet make Sinex the authority for complete transcript content or Polylogue user state end-to-end (that requires the remaining 303r.1/.4/.5/.6 phases plus the live Sinex counterpart above). diff --git a/polylogue/config.py b/polylogue/config.py index 7c4dd376a9..350e12ba85 100644 --- a/polylogue/config.py +++ b/polylogue/config.py @@ -677,17 +677,16 @@ def effective_path(self) -> str: toml_path="sinex.mode", env_var="POLYLOGUE_SINEX_MODE", owner_class="network-security", - reload_behavior="unwired", + reload_behavior="daemon-startup", description=( "Sinex-backed evidence-mode authority profile: off (default; SQLite is " "canonical, zero Sinex transport work), mirror (durable local commit plus " "a best-effort publication obligation), or primary (local projection " - "advance waits for a confirming Sinex receipt). NOT YET CONSUMED: no " - "ingest, daemon, or CLI code path currently reads this value to construct " - "a PublicationService (Ref polylogue-303r.2) -- setting mirror/primary " - "today only surfaces a `sinex_mode_not_yet_wired` config diagnostic, it " - "does not create publication obligations or call any transport. See " - "docs/sinex-interop.md and polylogue/sinex/__init__.py." + "advance waits for an allowed durable Sinex receipt: confirmed persistence, " + "durable debt, or lossless spool acceptance). Mirror/primary are wired " + "through ingest and daemon convergence, and require deployment composition " + "to register a concrete Sinex transport; no reference transport is selected " + "automatically. See docs/sinex-interop.md and polylogue/sinex/__init__.py." ), ), ConfigInventoryEntry( @@ -1654,15 +1653,7 @@ def config_diagnostics(cfg: PolylogueConfig | None = None) -> list[dict[str, obj def _sinex_mode_diagnostics(resolved: PolylogueConfig) -> list[dict[str, object]]: - """Diagnose ``sinex_mode``: unrecognized values, and the current unwired state. - - ``sinex_mode`` is a real, tested authority-profile selector - (``polylogue.sinex``, design polylogue-303r.2), but as of this diagnostic - landing no ingest, daemon, or CLI code path reads it to construct a - ``PublicationService``. Configuring ``mirror``/``primary`` must never be a - silent no-op (the package's own design principle) -- surface that gap - here instead of leaving an operator to discover it by absence of effect. - """ + """Report invalid Sinex authority-mode values before daemon startup.""" mode = resolved.sinex_mode if mode not in _KNOWN_SINEX_MODES: return [ @@ -1675,28 +1666,7 @@ def _sinex_mode_diagnostics(resolved: PolylogueConfig) -> list[dict[str, object] cfg=resolved, ) ] - if mode == "off": - return [] - return [ - _config_diagnostic( - code="sinex_mode_not_yet_wired", - severity="warning", - key="sinex_mode", - message=( - f"sinex_mode={mode!r} is configured but no ingest, daemon, or CLI code path " - "currently constructs a PublicationService from it -- this setting has zero " - "observable effect on real archive writes today." - ), - next_action=( - "The durable obligation ledger and transport contract exist and are tested " - "(polylogue/sinex/, Ref polylogue-303r.2), but hot-path wiring into " - "ingest/daemon/CLI is deferred follow-up work, and live Sinex transport is " - "additionally blocked on unmerged upstream Sinex work (sinex-4j2.1.1, " - "sinex-r6d.11). Leave sinex_mode=off until that wiring lands." - ), - cfg=resolved, - ) - ] + return [] def effective_config_payload( diff --git a/polylogue/daemon/convergence.py b/polylogue/daemon/convergence.py index 2a24159532..235fe5fee9 100644 --- a/polylogue/daemon/convergence.py +++ b/polylogue/daemon/convergence.py @@ -15,7 +15,7 @@ from __future__ import annotations import time -from collections.abc import Callable, Iterable, Iterator, Sequence +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from concurrent.futures import ProcessPoolExecutor from dataclasses import dataclass, field from enum import Enum @@ -81,6 +81,14 @@ class ConvergenceStage: # Some stages intentionally return False after doing bounded successful # work so the remaining backlog is retried as convergence debt. false_means_pending: bool = False + # A primary-authority stage may block every later projection stage for the + # affected subjects until its durable receipt predicate is satisfied. + blocks_following_stages: bool = False + barrier_check: Callable[[Path], bool] | None = None + barrier_check_many: Callable[[Sequence[Path]], set[Path]] | None = None + barrier_check_sessions: Callable[[Sequence[str]], set[str]] | None = None + # Optional bounded, secret-safe operator status payload. + status: Callable[[], Mapping[str, object]] | None = None @dataclass(slots=True) @@ -182,6 +190,94 @@ def stage_names(self) -> list[str]: def _has_cpu_bound_stage(self) -> bool: return any(stage.cpu_bound for stage in self._stages.values()) + def stage_status(self) -> dict[str, dict[str, object]]: + """Return bounded stage-owned status without propagating secret detail.""" + result: dict[str, dict[str, object]] = {} + for stage_name, stage in self._stages.items(): + if stage.status is None: + continue + try: + result[stage_name] = dict(stage.status()) + except Exception: + logger.warning("converger: status probe failed stage=%s", stage_name, exc_info=True) + result[stage_name] = {"state": "unavailable"} + return result + + @staticmethod + def _mark_barrier_failure( + state: FileState | SessionState, + *, + stage_name: str, + ) -> None: + state.stages[stage_name] = StageState.FAILED + state.error_count += 1 + state.last_error = f"stage {stage_name} barrier check failed" + + def _path_barrier_blocked( + self, + stage_name: str, + stage: ConvergenceStage, + path: Path, + ) -> bool: + if not stage.blocks_following_stages: + return False + state = self._file_states[path] + if stage.barrier_check is None: + return state.stages.get(stage_name) is not StageState.DONE + try: + return bool(stage.barrier_check(path)) + except Exception: + logger.warning( + "converger: barrier check failed for %s stage=%s", + path, + stage_name, + exc_info=True, + ) + self._mark_barrier_failure(state, stage_name=stage_name) + return True + + def _path_barriers_blocked( + self, + stage_name: str, + stage: ConvergenceStage, + paths: Sequence[Path], + ) -> set[Path]: + if not stage.blocks_following_stages or not paths: + return set() + if stage.barrier_check_many is None: + return {path for path in paths if self._path_barrier_blocked(stage_name, stage, path)} + try: + blocked = set(stage.barrier_check_many(paths)) + except Exception: + logger.warning("converger: batch barrier check failed stage=%s", stage_name, exc_info=True) + for path in paths: + self._mark_barrier_failure(self._file_states[path], stage_name=stage_name) + return set(paths) + return blocked.intersection(paths) + + def _session_barriers_blocked( + self, + stage_name: str, + stage: ConvergenceStage, + session_ids: Sequence[str], + ) -> set[str]: + if not stage.blocks_following_stages or not session_ids: + return set() + if stage.barrier_check_sessions is None: + return { + session_id + for session_id in session_ids + if self._session_states[session_id].stages.get(stage_name) is not StageState.DONE + } + try: + blocked = set(stage.barrier_check_sessions(session_ids)) + except Exception: + logger.warning("converger: session barrier check failed stage=%s", stage_name, exc_info=True) + for session_id in session_ids: + self._mark_barrier_failure(self._session_states[session_id], stage_name=stage_name) + return set(session_ids) + return blocked.intersection(session_ids) + async def start(self) -> None: if self._executor is not None: return @@ -206,72 +302,71 @@ async def stop(self) -> None: logger.info("converger: stopped") def converge_file(self, path: Path) -> FileState: - """Converge a single file through all stages. - - Returns the final :class:`FileState`. - """ + """Converge one file while honoring durable stage barriers.""" if path not in self._file_states: self._file_states[path] = FileState(path=path) state = self._file_states[path] state.last_stage_times.clear() + downstream_blocked = False for stage_name, stage in self._stages.items(): - current = state.stages.get(stage_name) - if current == StageState.DONE: - continue - - try: - needs_work = stage.check(path) - except Exception: - logger.warning( - "converger: check failed for %s stage=%s", - path, - stage_name, - exc_info=True, - ) - state.stages[stage_name] = StageState.FAILED - state.error_count += 1 - continue - - if not needs_work: - state.stages[stage_name] = StageState.DONE + if downstream_blocked: + state.stages[stage_name] = StageState.PENDING continue - state.stages[stage_name] = StageState.IN_PROGRESS - - t_stage = time.perf_counter() - try: - if stage.cpu_bound and self._executor is not None: - future = self._executor.submit(stage.execute, path) - execute_result = future.result() + current = state.stages.get(stage_name) + if current is not StageState.DONE: + try: + needs_work = stage.check(path) + except Exception: + logger.warning( + "converger: check failed for %s stage=%s", + path, + stage_name, + exc_info=True, + ) + state.stages[stage_name] = StageState.FAILED + state.error_count += 1 else: - execute_result = stage.execute(path) - except Exception as exc: - logger.warning( - "converger: execute failed for %s stage=%s: %s", - path, - stage_name, - exc, - ) - state.stages[stage_name] = StageState.FAILED - state.error_count += 1 - state.last_error = str(exc) - continue - - elapsed = time.perf_counter() - t_stage - success, extra_stage_timings_s = _coerce_execute_result(execute_result) - state.stage_times[stage_name] = elapsed - state.last_stage_times[stage_name] = elapsed - for name, value in extra_stage_timings_s.items(): - state.stage_times[name] = value - state.last_stage_times[name] = value - _record_execute_result( - state, - stage_name=stage_name, - stage=stage, - success=success, - scope="stage", - ) + if not needs_work: + state.stages[stage_name] = StageState.DONE + else: + state.stages[stage_name] = StageState.IN_PROGRESS + t_stage = time.perf_counter() + try: + if stage.cpu_bound and self._executor is not None: + future = self._executor.submit(stage.execute, path) + execute_result = future.result() + else: + execute_result = stage.execute(path) + except Exception as exc: + logger.warning( + "converger: execute failed for %s stage=%s: %s", + path, + stage_name, + exc, + ) + state.stages[stage_name] = StageState.FAILED + state.error_count += 1 + state.last_error = str(exc) + else: + elapsed = time.perf_counter() - t_stage + success, extra_stage_timings_s = _coerce_execute_result(execute_result) + state.stage_times[stage_name] = elapsed + state.last_stage_times[stage_name] = elapsed + for name, value in extra_stage_timings_s.items(): + state.stage_times[name] = value + state.last_stage_times[name] = value + _record_execute_result( + state, + stage_name=stage_name, + stage=stage, + success=success, + scope="stage", + ) + + if self._path_barrier_blocked(stage_name, stage, path): + downstream_blocked = True return state @@ -296,7 +391,7 @@ def _evict_converged_sessions(self, session_ids: Iterable[str]) -> None: del self._session_states[session_id] def converge_batch(self, files: Iterable[Path]) -> tuple[dict[Path, FileState], dict[str, float]]: - """Converge a changed source batch and return per-stage batch timings.""" + """Converge a changed source batch with per-subject stage barriers.""" paths = tuple(dict.fromkeys(files)) if not paths: return {}, {} @@ -309,9 +404,16 @@ def converge_batch(self, files: Iterable[Path]) -> tuple[dict[Path, FileState], state.last_stage_times.clear() batch_stage_times: dict[str, float] = {} + blocked_paths: set[Path] = set() for stage_name, stage in self._stages.items(): + for path in blocked_paths: + self._file_states[path].stages[stage_name] = StageState.PENDING + active_paths = tuple(path for path in paths if path not in blocked_paths) + if not active_paths: + continue + if stage.check_many is None or stage.execute_many is None or stage.cpu_bound: - for path in paths: + for path in active_paths: state = self._file_states[path] try: needs_work = stage.check(path) @@ -361,52 +463,74 @@ def converge_batch(self, files: Iterable[Path]) -> tuple[dict[Path, FileState], success=success, scope="stage", ) - continue - - try: - batch_needs_work = stage.check_many(paths) - except Exception: - logger.warning("converger: batch check failed stage=%s", stage_name, exc_info=True) - for path in paths: - state = self._file_states[path] - state.stages[stage_name] = StageState.FAILED - state.error_count += 1 - continue - - for path in paths: - if path not in batch_needs_work: - self._file_states[path].stages[stage_name] = StageState.DONE - - if not batch_needs_work: - continue - - for path in batch_needs_work: - self._file_states[path].stages[stage_name] = StageState.IN_PROGRESS - - t_stage = time.perf_counter() - try: - execute_result = stage.execute_many(tuple(batch_needs_work)) - except Exception as exc: - logger.warning("converger: batch execute failed stage=%s: %s", stage_name, exc) - execute_result = False - - elapsed = time.perf_counter() - t_stage - success, extra_stage_timings_s = _coerce_execute_result(execute_result) - _record_stage_times(batch_stage_times, stage_name, elapsed, extra_stage_timings_s) - for path in batch_needs_work: - state = self._file_states[path] - state.stage_times[stage_name] = elapsed - state.last_stage_times[stage_name] = elapsed - for name, value in extra_stage_timings_s.items(): - state.stage_times[name] = value - state.last_stage_times[name] = value - _record_execute_result( - state, - stage_name=stage_name, - stage=stage, - success=success, - scope="batch", - ) + else: + try: + batch_needs_work = set(stage.check_many(active_paths)).intersection(active_paths) + except Exception: + logger.warning("converger: batch check failed stage=%s", stage_name, exc_info=True) + for path in active_paths: + state = self._file_states[path] + state.stages[stage_name] = StageState.FAILED + state.error_count += 1 + else: + for path in active_paths: + if path not in batch_needs_work: + self._file_states[path].stages[stage_name] = StageState.DONE + + if batch_needs_work: + for path in batch_needs_work: + self._file_states[path].stages[stage_name] = StageState.IN_PROGRESS + + t_stage = time.perf_counter() + try: + execute_result = stage.execute_many(tuple(batch_needs_work)) + except Exception as exc: + logger.warning("converger: batch execute failed stage=%s: %s", stage_name, exc) + for path in batch_needs_work: + state = self._file_states[path] + state.stages[stage_name] = StageState.FAILED + state.error_count += 1 + state.last_error = str(exc) + else: + elapsed = time.perf_counter() - t_stage + success, extra_stage_timings_s = _coerce_execute_result(execute_result) + remaining_needs_work: set[Path] | None = None + if not success and stage.false_means_pending: + try: + remaining_needs_work = set(stage.check_many(tuple(batch_needs_work))).intersection( + batch_needs_work + ) + except Exception: + logger.warning( + "converger: batch recheck failed stage=%s", + stage_name, + exc_info=True, + ) + _record_stage_times( + batch_stage_times, + stage_name, + elapsed, + extra_stage_timings_s, + ) + for path in batch_needs_work: + state = self._file_states[path] + state.stage_times[stage_name] = elapsed + state.last_stage_times[stage_name] = elapsed + for name, value in extra_stage_timings_s.items(): + state.stage_times[name] = value + state.last_stage_times[name] = value + path_success = success + if remaining_needs_work is not None: + path_success = path not in remaining_needs_work + _record_execute_result( + state, + stage_name=stage_name, + stage=stage, + success=path_success, + scope="batch", + ) + + blocked_paths.update(self._path_barriers_blocked(stage_name, stage, active_paths)) results = {path: self._file_states[path] for path in paths} self._evict_converged_files(paths) @@ -416,7 +540,7 @@ def converge_sessions( self, session_ids: Iterable[str], ) -> tuple[dict[str, SessionState], dict[str, float]]: - """Converge derived state for known session IDs without source-path lookup.""" + """Converge known session subjects while honoring primary barriers.""" ids = tuple(dict.fromkeys(str(session_id) for session_id in session_ids if session_id)) if not ids: return {}, {} @@ -429,70 +553,89 @@ def converge_sessions( state.last_stage_times.clear() batch_stage_times: dict[str, float] = {} + blocked_ids: set[str] = set() for stage_name, stage in self._stages.items(): - if stage.check_sessions is None or stage.execute_sessions is None or stage.cpu_bound: - for session_id in ids: - state = self._session_states[session_id] - state.stages[stage_name] = StageState.SKIPPED - continue - - try: - batch_needs_work = stage.check_sessions(ids) - except Exception: - logger.warning("converger: session batch check failed stage=%s", stage_name, exc_info=True) - for session_id in ids: - state = self._session_states[session_id] - state.stages[stage_name] = StageState.FAILED - state.error_count += 1 - continue - - for session_id in ids: - if session_id not in batch_needs_work: - self._session_states[session_id].stages[stage_name] = StageState.DONE - - if not batch_needs_work: + for session_id in blocked_ids: + self._session_states[session_id].stages[stage_name] = StageState.PENDING + active_ids = tuple(session_id for session_id in ids if session_id not in blocked_ids) + if not active_ids: continue - for session_id in batch_needs_work: - self._session_states[session_id].stages[stage_name] = StageState.IN_PROGRESS - - t_stage = time.perf_counter() - try: - execute_result = stage.execute_sessions(tuple(batch_needs_work)) - except Exception as exc: - logger.warning("converger: session batch execute failed stage=%s: %s", stage_name, exc) - execute_result = False - - elapsed = time.perf_counter() - t_stage - success, extra_stage_timings_s = _coerce_execute_result(execute_result) - remaining_needs_work: set[str] | None = None - if not success and stage.false_means_pending: + if stage.check_sessions is None or stage.execute_sessions is None or stage.cpu_bound: + for session_id in active_ids: + self._session_states[session_id].stages[stage_name] = StageState.SKIPPED + else: try: - remaining_needs_work = set(stage.check_sessions(tuple(batch_needs_work))) + batch_needs_work = set(stage.check_sessions(active_ids)).intersection(active_ids) except Exception: - logger.warning( - "converger: session batch recheck failed stage=%s", - stage_name, - exc_info=True, - ) - _record_stage_times(batch_stage_times, stage_name, elapsed, extra_stage_timings_s) - for session_id in batch_needs_work: - state = self._session_states[session_id] - state.stage_times[stage_name] = elapsed - state.last_stage_times[stage_name] = elapsed - for name, value in extra_stage_timings_s.items(): - state.stage_times[name] = value - state.last_stage_times[name] = value - session_success = success - if remaining_needs_work is not None: - session_success = session_id not in remaining_needs_work - _record_execute_result( - state, - stage_name=stage_name, - stage=stage, - success=session_success, - scope="session", - ) + logger.warning("converger: session batch check failed stage=%s", stage_name, exc_info=True) + for session_id in active_ids: + state = self._session_states[session_id] + state.stages[stage_name] = StageState.FAILED + state.error_count += 1 + else: + for session_id in active_ids: + if session_id not in batch_needs_work: + self._session_states[session_id].stages[stage_name] = StageState.DONE + + if batch_needs_work: + for session_id in batch_needs_work: + self._session_states[session_id].stages[stage_name] = StageState.IN_PROGRESS + + t_stage = time.perf_counter() + try: + execute_result = stage.execute_sessions(tuple(batch_needs_work)) + except Exception as exc: + logger.warning( + "converger: session batch execute failed stage=%s: %s", + stage_name, + exc, + ) + for session_id in batch_needs_work: + state = self._session_states[session_id] + state.stages[stage_name] = StageState.FAILED + state.error_count += 1 + state.last_error = str(exc) + else: + elapsed = time.perf_counter() - t_stage + success, extra_stage_timings_s = _coerce_execute_result(execute_result) + remaining_needs_work: set[str] | None = None + if not success and stage.false_means_pending: + try: + remaining_needs_work = set( + stage.check_sessions(tuple(batch_needs_work)) + ).intersection(batch_needs_work) + except Exception: + logger.warning( + "converger: session batch recheck failed stage=%s", + stage_name, + exc_info=True, + ) + _record_stage_times( + batch_stage_times, + stage_name, + elapsed, + extra_stage_timings_s, + ) + for session_id in batch_needs_work: + state = self._session_states[session_id] + state.stage_times[stage_name] = elapsed + state.last_stage_times[stage_name] = elapsed + for name, value in extra_stage_timings_s.items(): + state.stage_times[name] = value + state.last_stage_times[name] = value + session_success = success + if remaining_needs_work is not None: + session_success = session_id not in remaining_needs_work + _record_execute_result( + state, + stage_name=stage_name, + stage=stage, + success=session_success, + scope="session", + ) + + blocked_ids.update(self._session_barriers_blocked(stage_name, stage, active_ids)) results = {session_id: self._session_states[session_id] for session_id in ids} self._evict_converged_sessions(ids) diff --git a/polylogue/daemon/convergence_stages.py b/polylogue/daemon/convergence_stages.py index 40a75e4819..d87d40f202 100644 --- a/polylogue/daemon/convergence_stages.py +++ b/polylogue/daemon/convergence_stages.py @@ -34,7 +34,8 @@ from polylogue.storage.table_existence import table_exists as _table_exists if TYPE_CHECKING: - pass + from polylogue.sinex.service import PublicationService + from polylogue.sinex.transport import SinexTransport logger = get_logger(__name__) @@ -556,16 +557,163 @@ def execute_sessions(session_ids: Sequence[str]) -> StageExecuteReturn: ) -def make_default_convergence_stages(db_path: Path) -> tuple[ConvergenceStage, ...]: - """Build the daemon's default post-ingest convergence stage set.""" - from polylogue.archive.query.production_evaluator import ArchiveCanonicalPlanEvaluator +def _sinex_session_ids_for_paths( + db_path: Path, + paths: Sequence[Path], +) -> dict[Path, list[str]]: + normalized = tuple(dict.fromkeys(Path(path) for path in paths)) + if not normalized: + return {} + lookup_db = _active_archive_index_path(db_path) or db_path + if not lookup_db.exists(): + return {path: [] for path in normalized} + conn = sqlite3.connect(f"file:{lookup_db}?mode=ro", uri=True, timeout=5.0) + try: + return _schema_archive_session_ids_for_source_paths(conn, normalized) + finally: + conn.close() + + +def make_sinex_publication_stage( + db_path: Path, + service: PublicationService, +) -> ConvergenceStage: + """Drain the durable source-tier outbox before primary projections advance.""" + from polylogue.sinex.models import PublicationMode + + def ids_for_path(path: Path) -> list[str]: + return _sinex_session_ids_for_paths(db_path, (path,)).get(path, []) + + def check(path: Path) -> bool: + return bool(service.unresolved_object_ids(ids_for_path(path))) + + def execute(path: Path) -> StageExecuteReturn: + session_ids = ids_for_path(path) + if not session_ids: + return True + summary = service.drain_once(object_ids=session_ids, limit=service.max_batch) + logger.info( + "sinex_publication: drain attempted=%d confirmed=%d debt=%d rejected=%d " + "transport_failures=%d payload_failures=%d remaining=%d", + summary.attempted, + summary.confirmed, + summary.durable_debt, + summary.rejected, + summary.transport_failures, + summary.payload_failures, + summary.remaining_lag, + ) + return not service.unresolved_object_ids(session_ids) + + def check_many(paths: Sequence[Path]) -> set[Path]: + by_path = _sinex_session_ids_for_paths(db_path, paths) + all_ids = tuple(dict.fromkeys(session_id for values in by_path.values() for session_id in values)) + unresolved = service.unresolved_object_ids(all_ids) + return {path for path, values in by_path.items() if unresolved.intersection(values)} + + def execute_many(paths: Sequence[Path]) -> StageExecuteReturn: + by_path = _sinex_session_ids_for_paths(db_path, paths) + all_ids = tuple(dict.fromkeys(session_id for values in by_path.values() for session_id in values)) + if not all_ids: + return True + summary = service.drain_once(object_ids=all_ids, limit=service.max_batch) + logger.info( + "sinex_publication: batch drain subjects=%d attempted=%d confirmed=%d debt=%d rejected=%d " + "transport_failures=%d payload_failures=%d remaining=%d", + len(all_ids), + summary.attempted, + summary.confirmed, + summary.durable_debt, + summary.rejected, + summary.transport_failures, + summary.payload_failures, + summary.remaining_lag, + ) + return not service.unresolved_object_ids(all_ids) + + def check_sessions(session_ids: Sequence[str]) -> set[str]: + return service.unresolved_object_ids(session_ids) - return ( - make_fts_stage(db_path), - make_embed_stage(db_path), - make_insights_stage(db_path), - make_standing_query_stage(db_path, evaluator=ArchiveCanonicalPlanEvaluator(db_path)), + def execute_sessions(session_ids: Sequence[str]) -> StageExecuteReturn: + if not session_ids: + return True + summary = service.drain_once(object_ids=session_ids, limit=service.max_batch) + logger.info( + "sinex_publication: session drain subjects=%d attempted=%d confirmed=%d debt=%d rejected=%d " + "transport_failures=%d payload_failures=%d remaining=%d", + len(tuple(dict.fromkeys(session_ids))), + summary.attempted, + summary.confirmed, + summary.durable_debt, + summary.rejected, + summary.transport_failures, + summary.payload_failures, + summary.remaining_lag, + ) + return not service.unresolved_object_ids(session_ids) + + def barrier(path: Path) -> bool: + return bool(service.blocking_object_ids(ids_for_path(path))) + + def barrier_many(paths: Sequence[Path]) -> set[Path]: + by_path = _sinex_session_ids_for_paths(db_path, paths) + all_ids = tuple(dict.fromkeys(session_id for values in by_path.values() for session_id in values)) + blocked = service.blocking_object_ids(all_ids) + return {path for path, values in by_path.items() if blocked.intersection(values)} + + return ConvergenceStage( + name="sinex_publication", + description="Drain exact accepted revisions through the configured Sinex transport", + check=check, + execute=execute, + check_many=check_many, + execute_many=execute_many, + check_sessions=check_sessions, + execute_sessions=execute_sessions, + cpu_bound=False, + false_means_pending=True, + blocks_following_stages=service.mode is PublicationMode.PRIMARY, + barrier_check=barrier, + barrier_check_many=barrier_many, + barrier_check_sessions=service.blocking_object_ids, + status=lambda: service.status().as_dict(), + ) + + +def make_default_convergence_stages( + db_path: Path, + *, + sinex_transport: SinexTransport | None = None, +) -> tuple[ConvergenceStage, ...]: + """Build daemon stages, failing explicitly when backed mode lacks transport.""" + from polylogue.archive.query.production_evaluator import ArchiveCanonicalPlanEvaluator + from polylogue.sinex.models import PublicationMode + from polylogue.sinex.service import PublicationService + from polylogue.sinex.transport import resolve_configured_transport + + mode = PublicationMode.from_string(load_polylogue_config().sinex_mode) + stages: list[ConvergenceStage] = [] + if mode is not PublicationMode.OFF: + transport = sinex_transport if sinex_transport is not None else resolve_configured_transport() + stages.append( + make_sinex_publication_stage( + db_path, + PublicationService( + source_db_path=db_path.parent / "source.db", + mode=mode, + transport=transport, + ), + ) + ) + stages.extend( + ( + make_fts_stage(db_path), + make_embed_stage(db_path), + make_insights_stage(db_path), + make_standing_query_stage(db_path, evaluator=ArchiveCanonicalPlanEvaluator(db_path)), + ) ) + return tuple(stages) # ── Helpers ──────────────────────────────────────────────────────── @@ -1646,5 +1794,6 @@ def _archive_insights_execute_ids(conn: sqlite3.Connection, session_ids: Sequenc "make_embed_stage", "make_fts_stage", "make_insights_stage", + "make_sinex_publication_stage", "make_standing_query_stage", ] diff --git a/polylogue/pipeline/services/ingest_batch/_core.py b/polylogue/pipeline/services/ingest_batch/_core.py index 68a51e2fce..e98022c05c 100644 --- a/polylogue/pipeline/services/ingest_batch/_core.py +++ b/polylogue/pipeline/services/ingest_batch/_core.py @@ -21,7 +21,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Protocol, cast from polylogue.archive.ingest_flags import DOM_FALLBACK_INGEST_FLAG, NATIVE_BROWSER_CAPTURE_INGEST_FLAG from polylogue.archive.write_gateway import ArchiveWriteGateway, WriteOperation @@ -40,6 +40,15 @@ ingest_record, ) from polylogue.pipeline.services.process_pool import process_pool_executor +from polylogue.sinex.material_adapter import ( + PublicationBackpressureError, + PublicationEncodingError, + encode_parsed_session_publication, +) +from polylogue.sinex.models import PublicationMode, PublicationPayload +from polylogue.sinex.obligations import AsyncSqlConnection, stage_payload_async +from polylogue.sinex.service import PublicationService +from polylogue.sinex.transport import resolve_configured_transport from polylogue.sources.parsers.base import ParsedMessage, ParsedSession from polylogue.storage.blob_publication import ArchiveBlobPublisher, consume_blob_publication_receipt from polylogue.storage.raw.models import RawSessionStateUpdate @@ -80,6 +89,7 @@ ) from polylogue.pipeline.services.ingest_batch._models import ( _DEFAULT_INGEST_WORKER_LIMIT, + _SINEX_STAGED_PAYLOAD_LIMIT_BYTES, _BulkConnectionBackendLike, _ConnectionBackendLike, _IngestBatchSummary, @@ -984,12 +994,50 @@ def _record_failed_ingest_result(summary: _IngestBatchSummary, ir: IngestRecordR summary.failed_raw_ids[ir.raw_id] = (ir.error or "unknown worker failure")[:500] +def _prepare_publication_payloads( + ir: IngestRecordResult, + *, + summary: _IngestBatchSummary, + publication_mode: PublicationMode = PublicationMode.OFF, +) -> tuple[PublicationPayload, ...]: + """Encode one raw record before any of its index rows are written. + + Encoding first makes protocol reconciliation/backpressure an explicit raw + failure rather than leaving an accepted source revision without an outbox + payload. The index transaction is still rebuildable pre-work; source-tier + acceptance and these exact bytes are committed together later. + """ + if publication_mode is PublicationMode.OFF: + return () + payloads: list[PublicationPayload] = [] + payload_bytes = 0 + for cdata in ir.sessions: + remaining_bytes = _SINEX_STAGED_PAYLOAD_LIMIT_BYTES - summary.publication_payload_bytes - payload_bytes + payload = encode_parsed_session_publication( + cdata.parsed_session, + session_id=cdata.session_id, + max_payload_bytes=remaining_bytes, + ) + projected_bytes = summary.publication_payload_bytes + payload_bytes + payload.size_bytes + if projected_bytes > _SINEX_STAGED_PAYLOAD_LIMIT_BYTES: + raise PublicationBackpressureError( + "Sinex exact-payload staging budget exceeded: " + f"projected_bytes={projected_bytes} limit_bytes={_SINEX_STAGED_PAYLOAD_LIMIT_BYTES}" + ) + payloads.append(payload) + payload_bytes += payload.size_bytes + return tuple(payloads) + + def _drain_ingest_result( conn: sqlite3.Connection, ir: IngestRecordResult, *, summary: _IngestBatchSummary, materialized_ids: set[str], + publication_mode: PublicationMode = PublicationMode.OFF, + primary_publication_service: PublicationService | None = None, + ensure_index_transaction: Callable[[], None] | None = None, force_write: bool = False, blob_publisher: ArchiveBlobPublisher | None = None, pending_attachment_receipts: list[tuple[str, bytes]] | None = None, @@ -1005,6 +1053,41 @@ def _drain_ingest_result( summary.skipped_raw_ids.add(ir.raw_id) return + try: + publication_payloads = _prepare_publication_payloads( + ir, + summary=summary, + publication_mode=publication_mode, + ) + except PublicationEncodingError as exc: + logger.error( + "Sinex publication payload rejected before accepted-revision write", + raw_id=ir.raw_id, + error_code=type(exc).__name__, + ) + summary.parse_failures += 1 + summary.failed_raw_ids[ir.raw_id] = f"{type(exc).__name__}: {exc}"[:500] + return + + if publication_mode is PublicationMode.PRIMARY: + if primary_publication_service is None: + raise PublicationEncodingError("primary ingest requires a pre-index publication service") + object_ids = [payload.object_id for payload in publication_payloads] + for payload in publication_payloads: + primary_publication_service.stage_payload(payload) + primary_publication_service.drain_once(object_ids=object_ids, limit=len(object_ids)) + if primary_publication_service.projection_blocked(object_ids): + summary.publication_deferred_raw_ids.add(ir.raw_id) + logger.info( + "Sinex primary receipt deferred index projection", + raw_id=ir.raw_id, + object_count=len(object_ids), + ) + return + + if ensure_index_transaction is not None: + ensure_index_transaction() + drain_started = time.perf_counter() written_count = _drain_ready_session_entries( conn, @@ -1017,6 +1100,13 @@ def _drain_ingest_result( ) if written_count == 0: summary.skipped_raw_ids.add(ir.raw_id) + # Keep the reconciled payload for both changed and duplicate revisions. + # The source-tier raw acceptance transaction restages duplicates + # idempotently, which also provides a safe backfill path when an operator + # enables mirror/primary after an earlier off-mode ingest. + if publication_payloads: + summary.publication_payloads_by_raw_id[ir.raw_id] = list(publication_payloads) + summary.publication_payload_bytes += sum(payload.size_bytes for payload in publication_payloads) summary.drain_elapsed_s += time.perf_counter() - drain_started _observe_current_rss(summary) @@ -1028,6 +1118,8 @@ def _consume_ingest_results( worker_request: _IngestWorkerRequest, summary: _IngestBatchSummary, materialized_ids: set[str], + publication_mode: PublicationMode, + primary_publication_service: PublicationService | None = None, force_write: bool = False, heartbeat: IngestHeartbeat | None = None, progress: _WorkerProgress | None = None, @@ -1050,6 +1142,20 @@ def _consume_ingest_results( ) ) transaction_started = False + + def ensure_index_transaction() -> None: + nonlocal transaction_started + if transaction_started: + return + if suspend_fts_triggers: + conn.execute("PRAGMA foreign_keys = OFF") + conn.execute("BEGIN IMMEDIATE") + if suspend_fts_triggers: + from polylogue.storage.fts.fts_lifecycle import suspend_fts_triggers_sync + + suspend_fts_triggers_sync(conn, mark_stale=mark_fts_stale_on_suspend) + transaction_started = True + while True: wait_started = time.perf_counter() try: @@ -1060,20 +1166,14 @@ def _consume_ingest_results( summary.result_wait_s += time.perf_counter() - wait_started release_after_drain = ingest_result_needs_memory_release(ir) try: - if not transaction_started: - if suspend_fts_triggers: - conn.execute("PRAGMA foreign_keys = OFF") - conn.execute("BEGIN IMMEDIATE") - if suspend_fts_triggers: - from polylogue.storage.fts.fts_lifecycle import suspend_fts_triggers_sync - - suspend_fts_triggers_sync(conn, mark_stale=mark_fts_stale_on_suspend) - transaction_started = True _drain_ingest_result( conn, ir, summary=summary, materialized_ids=materialized_ids, + publication_mode=publication_mode, + primary_publication_service=primary_publication_service, + ensure_index_transaction=ensure_index_transaction, force_write=force_write, blob_publisher=blob_publisher, pending_attachment_receipts=pending_attachment_receipts, @@ -1132,6 +1232,7 @@ def _process_ingest_batch_sync( validation_mode: str, ingest_workers: int | None, measure_ingest_result_size: bool, + publication_mode: PublicationMode = PublicationMode.OFF, force_write: bool = False, repair_message_fts: bool = True, heartbeat: IngestHeartbeat | None = None, @@ -1150,11 +1251,20 @@ def _process_ingest_batch_sync( measure_ingest_result_size=measure_ingest_result_size, ) t_start = time.perf_counter() + archive_root = Path(archive_root_str) + primary_publication_service = ( + PublicationService( + archive_root / "source.db", + PublicationMode.PRIMARY, + resolve_configured_transport(), + ) + if publication_mode is PublicationMode.PRIMARY + else None + ) setup_started = time.perf_counter() conn = _open_sync_connection(db_path) summary.setup_elapsed_s = time.perf_counter() - setup_started materialized_ids: set[str] = set() - archive_root = Path(archive_root_str) blob_publisher = ArchiveBlobPublisher(archive_root / "source.db", archive_root / "blob") pending_attachment_receipts: list[tuple[str, bytes]] = [] _observe_current_rss(summary) @@ -1166,6 +1276,8 @@ def _process_ingest_batch_sync( worker_request=worker_request, summary=summary, materialized_ids=materialized_ids, + publication_mode=publication_mode, + primary_publication_service=primary_publication_service, force_write=force_write, heartbeat=heartbeat, progress=progress, @@ -1303,8 +1415,13 @@ async def process_ingest_batch( rss_start_mb = read_current_rss_mb() peak_rss_self_start_mb = read_peak_rss_self_mb() - # Get validation mode from environment + # Get validation mode from environment and Sinex authority mode from the + # canonical config layer. Off mode is passed through so the sync writer + # performs no protocol encoding or outbox work. validation_mode = os.environ.get("POLYLOGUE_SCHEMA_VALIDATION", "advisory") + from polylogue.config import load_polylogue_config + + publication_mode = PublicationMode.from_string(load_polylogue_config().sinex_mode) batch_summary = await asyncio.to_thread( _process_ingest_batch_sync, @@ -1315,6 +1432,7 @@ async def process_ingest_batch( validation_mode=validation_mode, ingest_workers=service.ingest_workers, measure_ingest_result_size=service.measure_ingest_result_size, + publication_mode=publication_mode, force_write=force_write, repair_message_fts=repair_message_fts, ingest_result_chunk_size=ingest_result_chunk_size, @@ -1368,7 +1486,11 @@ async def process_ingest_batch( skipped_raw_ids=batch_summary.skipped_raw_ids, failed_raw_ids=batch_summary.failed_raw_ids, validation_mode=validation_mode, + publication_mode=publication_mode, + publication_payloads_by_raw_id=batch_summary.publication_payloads_by_raw_id, ) + batch_summary.publication_payloads_by_raw_id.clear() + batch_summary.publication_payload_bytes = 0 elapsed_s = time.perf_counter() - batch_started rss_end_mb = read_current_rss_mb() @@ -1456,13 +1578,52 @@ async def _persist_batch_raw_state_updates( skipped_raw_ids: set[str], failed_raw_ids: dict[str, str], validation_mode: str, + publication_mode: PublicationMode = PublicationMode.OFF, + publication_payloads_by_raw_id: Mapping[str, Sequence[PublicationPayload]] | None = None, ) -> float: now_iso = datetime.now(timezone.utc).isoformat() raw_state_update_started = time.perf_counter() - source_backend = getattr(service.repository, "_source_backend", None) + source_backend = service.repository.source_backend + if publication_mode is not PublicationMode.OFF and source_backend is None: + raise PublicationEncodingError( + "mirror/primary acceptance requires the durable source-tier backend; " + "refusing to stage a publication obligation through index.db" + ) + + now_ms = 0 + + async def stage_accepted_payloads( + raw_state_conn: AsyncSqlConnection, + rid: str, + *, + required: bool, + ) -> None: + if publication_mode is PublicationMode.OFF: + return + payloads = tuple((publication_payloads_by_raw_id or {}).get(rid, ())) + if required and not payloads: + raise PublicationEncodingError(f"accepted raw revision {rid!r} has no reconciled Sinex publication payload") + for payload in payloads: + await stage_payload_async( + raw_state_conn, + payload=payload, + mode=publication_mode, + now_ms=now_ms, + ) + async with AsyncExitStack() as stack: raw_state_backend = source_backend if source_backend is not None else backend + # bulk_connection() owns BEGIN IMMEDIATE but deliberately yields None. + # connection() then reuses that backend-local active connection. await stack.enter_async_context(raw_state_backend.bulk_connection()) + now_ms = int(time.time() * 1000) + raw_state_conn: AsyncSqlConnection | None = None + if publication_mode is not PublicationMode.OFF: + assert source_backend is not None + raw_state_conn = cast( + AsyncSqlConnection, + await stack.enter_async_context(source_backend.connection()), + ) for rid in succeeded_raw_ids: if rid in skipped_raw_ids: continue @@ -1474,6 +1635,11 @@ async def _persist_batch_raw_state_updates( validation_mode=validation_mode, ), ) + # update_raw_state reuses source_backend's active bulk connection; + # staging on the yielded connection therefore shares its BEGIN + # IMMEDIATE/commit/rollback boundary. + if raw_state_conn is not None: + await stage_accepted_payloads(raw_state_conn, rid, required=True) for rid in skipped_raw_ids: if rid in failed_raw_ids: continue @@ -1485,6 +1651,10 @@ async def _persist_batch_raw_state_updates( validation_mode=validation_mode, ), ) + # Empty parse results have no payload. Content-identical duplicate + # revisions do, and are restaged idempotently in this transaction. + if raw_state_conn is not None: + await stage_accepted_payloads(raw_state_conn, rid, required=False) for rid, error in failed_raw_ids.items(): await service.repository.update_raw_state( rid, diff --git a/polylogue/pipeline/services/ingest_batch/_models.py b/polylogue/pipeline/services/ingest_batch/_models.py index e833fb6fdf..c3ac307407 100644 --- a/polylogue/pipeline/services/ingest_batch/_models.py +++ b/polylogue/pipeline/services/ingest_batch/_models.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Protocol from polylogue.pipeline.services.ingest_worker import SessionWritePayload +from polylogue.sinex.models import PublicationPayload from polylogue.storage.raw.models import RawSessionStateUpdate if TYPE_CHECKING: @@ -16,9 +17,13 @@ _INGEST_SOFT_BLOB_LIMIT_BYTES = 256 * 1024 * 1024 _INGEST_HIGH_BLOB_LIMIT_BYTES = 512 * 1024 * 1024 _INGEST_EXTREME_BLOB_LIMIT_BYTES = 2048 * 1024 * 1024 +_SINEX_STAGED_PAYLOAD_LIMIT_BYTES = 256 * 1024 * 1024 class _RawStateRepositoryLike(Protocol): + @property + def source_backend(self) -> _SourceTierBackendLike | None: ... + async def update_raw_state(self, raw_id: str, *, state: RawSessionStateUpdate) -> object: ... @@ -28,7 +33,11 @@ def repository(self) -> _RawStateRepositoryLike: ... class _BulkConnectionBackendLike(Protocol): - def bulk_connection(self) -> AbstractAsyncContextManager[object]: ... + def bulk_connection(self) -> AbstractAsyncContextManager[None]: ... + + +class _SourceTierBackendLike(_BulkConnectionBackendLike, Protocol): + def connection(self) -> AbstractAsyncContextManager[aiosqlite.Connection]: ... class _ConnectionBackendLike(Protocol): @@ -54,6 +63,9 @@ class _IngestBatchSummary: processed_ids: set[str] = field(default_factory=set) changed_session_ids: list[str] = field(default_factory=list) fts_repair_session_ids: list[str] = field(default_factory=list) + publication_payloads_by_raw_id: dict[str, list[PublicationPayload]] = field(default_factory=dict) + publication_payload_bytes: int = 0 + publication_deferred_raw_ids: set[str] = field(default_factory=set) counts: dict[str, int] = field( default_factory=lambda: { "sessions": 0, diff --git a/polylogue/pipeline/services/ingest_batch/_summary.py b/polylogue/pipeline/services/ingest_batch/_summary.py index 2e071015ce..a0cb9b4795 100644 --- a/polylogue/pipeline/services/ingest_batch/_summary.py +++ b/polylogue/pipeline/services/ingest_batch/_summary.py @@ -19,14 +19,20 @@ def apply_ingest_batch_summary(result: ParseResult, batch_summary: _IngestBatchS def progressed_raw_count(batch_summary: _IngestBatchSummary) -> int: - return sum(1 for outcome in batch_summary.outcomes.values() if outcome.had_sessions and outcome.error is None) + return sum( + 1 + for raw_id, outcome in batch_summary.outcomes.items() + if outcome.had_sessions and outcome.error is None and raw_id not in batch_summary.publication_deferred_raw_ids + ) def successful_raw_ids(batch_summary: _IngestBatchSummary) -> set[str]: return { raw_id for raw_id, outcome in batch_summary.outcomes.items() - if outcome.had_sessions and raw_id not in batch_summary.failed_raw_ids + if outcome.had_sessions + and raw_id not in batch_summary.failed_raw_ids + and raw_id not in batch_summary.publication_deferred_raw_ids } diff --git a/polylogue/sinex/__init__.py b/polylogue/sinex/__init__.py index 2f56e40154..ca3110498a 100644 --- a/polylogue/sinex/__init__.py +++ b/polylogue/sinex/__init__.py @@ -1,39 +1,23 @@ -"""Sinex-backed evidence mode: durable publication obligation and transport. +"""Durable Sinex publication outbox and daemon convergence integration. -Polylogue-side implementation of polylogue-303r.2 ("Publish Sinex materials -and anchored observations with durable retry"). This package owns: +In ``mirror`` and ``primary`` modes, production ingest encodes each accepted +normalized session revision and stages its exact manifest/segment bytes in the +same ``source.db`` transaction that marks the source raw accepted. The daemon +then drains that source-tier outbox through an injected +:class:`~polylogue.sinex.transport.SinexTransport`, persists every receipt, +and resumes from durable payload bytes after restart. -- the durable **publication obligation** ledger (``obligations.py``), a - ``source.db`` table recording that a normalized-session material revision - (see ``polylogue.material_protocol.v1``) must reach Sinex before backed-mode - local projections may treat that revision as authoritative; -- the transport contract (``transport.py``) a real Sinex producer must - satisfy, modeled on Sinex's documented ``DurableEmissionReceipt`` - (sinex-r6d.11) and ``RawEnvelopeSettlement`` (sinex-r6d.12) primitives, plus - a contract-faithful in-process reference transport for local operation and - tests; -- the orchestration service (``service.py``) that stages obligations and - drains them against a transport; -- a best-effort adapter (``material_adapter.py``) from live archive - ``Session`` reads to the ``SessionMaterial`` input the v1 encoder expects. +``primary`` mode places the publication stage before derived local convergence +and blocks only the affected objects until their newest accepted revision has +an allowed durable receipt. ``mirror`` retains exact lag and failure history +without blocking later projection stages. ``off`` mode constructs no +publication service, encodes no material payload, writes no obligation, and +performs no transport work. -Scope note (binding, see ``docs/sinex-interop.md``): as of this package's -introduction, Sinex's own consumer primitives for this exact contract -(sinex-4j2.1.1, layered on sinex-r6d.11 which is itself still open upstream) -are not yet landed. This package is therefore a real, fully-tested Polylogue- -side producer against a documented contract, exercised in tests against a -local reference transport. Neither that reference transport nor a live Sinex -JetStream endpoint is wired into any production code path yet: no ingest, -daemon, or CLI call site constructs a -:class:`~polylogue.sinex.service.PublicationService` from -``polylogue.toml``'s ``[sinex] mode`` / ``POLYLOGUE_SINEX_MODE`` today, so -setting ``mode = "mirror"`` or ``"primary"`` has zero effect on real archive -writes -- ``polylogue config --format json`` surfaces this explicitly via a -``sinex_mode_not_yet_wired`` diagnostic (``polylogue/config.py``). Wiring a -real call site, and then pointing it at a live Sinex JetStream endpoint, is -cross-repo follow-up work, not something this package can complete -unilaterally. Standalone (``mode = "off"``) is and remains the default and -permanently supported mode (operator directive, ``docs/sinex-interop.md``). +The package does not contain a live Sinex network implementation. +:class:`~polylogue.sinex.transport.LocalReferenceTransport` is an in-process +contract double used for deterministic idempotency and failure-path tests; a +real configured transport must be injected by deployment composition. """ from __future__ import annotations @@ -42,12 +26,31 @@ ObligationStatus, PublicationMode, PublicationObligation, + PublicationPayload, PublicationReceipt, + PublicationStatus, ReceiptState, ) -from polylogue.sinex.obligations import get_obligation, list_obligations, record_obligation +from polylogue.sinex.obligations import ( + PublicationPayloadConflictError, + PublicationPayloadInvalidError, + get_obligation, + list_obligations, + load_payload, + record_obligation, + stage_payload, +) from polylogue.sinex.service import DrainSummary, PublicationService -from polylogue.sinex.transport import LocalReferenceTransport, NullTransport, SinexTransport +from polylogue.sinex.transport import ( + LocalReferenceTransport, + NullTransport, + SinexTransport, + SinexTransportUnavailableError, + TransportPayloadConflictError, + clear_configured_transport_factory, + register_configured_transport_factory, + resolve_configured_transport, +) __all__ = [ "DrainSummary", @@ -56,11 +59,22 @@ "ObligationStatus", "PublicationMode", "PublicationObligation", + "PublicationPayload", + "PublicationPayloadConflictError", + "PublicationPayloadInvalidError", "PublicationReceipt", "PublicationService", + "PublicationStatus", "ReceiptState", "SinexTransport", + "SinexTransportUnavailableError", + "TransportPayloadConflictError", + "clear_configured_transport_factory", "get_obligation", "list_obligations", + "load_payload", "record_obligation", + "register_configured_transport_factory", + "resolve_configured_transport", + "stage_payload", ] diff --git a/polylogue/sinex/material_adapter.py b/polylogue/sinex/material_adapter.py index 9522edc6dd..ed7f4876be 100644 --- a/polylogue/sinex/material_adapter.py +++ b/polylogue/sinex/material_adapter.py @@ -1,111 +1,587 @@ -"""Adapt a live archive ``Session`` read into a ``SessionMaterial`` input. - -This is the first real Polylogue-side producer glue for polylogue-303r.2: it -turns an already-hydrated :class:`~polylogue.archive.models.Session` (for -example from ``SessionRepository.get_session_tree``) into the -:class:`~polylogue.material_protocol.v1.SessionMaterial` the v1 encoder -accepts, using the SAME id/vocabulary formulas the live archive uses -(``native_id_from_session_id``, the real ``Origin``/``Role``/``BlockType``/ -``MaterialOrigin``/``MessageType`` enums) rather than inventing a parallel -vocabulary. - -Known, explicitly-declared scope gap (v1 of this adapter): lineage -(``session_links``), usage (``session_model_usage``), and session events are -separate repository reads the caller has not necessarily loaded alongside the -session tree, so this adapter does not populate them and instead records a -:class:`~polylogue.material_protocol.v1.FidelityGapInput` naming the omission --- honest under-coverage using the protocol's own fidelity-gap vocabulary, -not silent data loss. Wiring those additional repository reads through is -follow-up scope (tracked on polylogue-303r.2's own notes), not required to -prove the durable-obligation/transport contract this package's tests target. +"""Adapt accepted Polylogue session material to material-protocol v1. + +The production adapter consumes the parser's full ``ParsedSession`` before the +batch releases it. Every material-protocol unit available at that boundary is +encoded: session, message, block, attachment, lineage, usage, and session +event. Normalized fields that v1 cannot represent are named in fidelity gaps +rather than silently discarded. """ from __future__ import annotations -from datetime import datetime +import dataclasses +import hashlib +import json +from collections import defaultdict +from collections.abc import Iterable, Mapping, Sequence +from datetime import UTC, datetime +from decimal import Decimal +from enum import Enum from polylogue.archive.models import Session -from polylogue.core.enums import BlockType +from polylogue.core.enums import ( + BlockType, + LinkType, + MaterialOrigin, + MessageType, + Origin, + Role, + SessionKind, +) from polylogue.core.json import JSONValue from polylogue.core.web_urls import native_id_from_session_id -from polylogue.material_protocol.v1 import BlockInput, FidelityGapInput, MessageInput, SessionMaterial +from polylogue.material_protocol.v1 import ( + AttachmentInput, + BlockInput, + FidelityGapInput, + LineageInput, + MaterialProtocolError, + MessageInput, + SessionEventInput, + SessionMaterial, + UsageInput, + decode_session_revision, + encode_session_revision, + verify_revision, +) +from polylogue.material_protocol.v1.canonical import canonical_bytes +from polylogue.sinex.models import PublicationPayload +from polylogue.sources.parsers.base import ( + ParsedAttachment, + ParsedContentBlock, + ParsedMessage, + ParsedSession, + ParsedSessionEvent, +) + +_PROTOCOL_VERSION = "polylogue.material-protocol/v1" + + +class PublicationEncodingError(RuntimeError): + """Accepted normalized material could not be reconciled to exact wire bytes.""" + + +class PublicationBackpressureError(PublicationEncodingError): + """Exact publication bytes exceeded the bounded ingest staging budget.""" + + +def _attr(value: object, *names: str, default: object = None) -> object: + if isinstance(value, Mapping): + for name in names: + if name in value: + return value[name] + return default + for name in names: + if hasattr(value, name): + return getattr(value, name) + return default + + +def _items(value: object) -> tuple[object, ...]: + """Return a bounded sequence view without treating scalars as records.""" + if isinstance(value, (str, bytes, bytearray, Mapping)) or not isinstance(value, Iterable): + return () + return tuple(value) -def _timestamp_ms(value: datetime | None) -> int | None: +def _int(value: object, default: int = 0) -> int: + return value if isinstance(value, int) and not isinstance(value, bool) else default + + +def _timestamp_ms(value: object) -> int | None: if value is None: return None - return int(value.timestamp() * 1000) + if isinstance(value, datetime): + normalized = value if value.tzinfo is not None else value.replace(tzinfo=UTC) + return int(normalized.timestamp() * 1000) + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return int(parsed.timestamp() * 1000) + return None + + +def _revision_created_at(value: object) -> str: + milliseconds = _timestamp_ms(value) + if milliseconds is None: + return "1970-01-01T00:00:00+00:00" + return datetime.fromtimestamp(milliseconds / 1000, tz=UTC).isoformat() def _json_safe(value: object) -> JSONValue: if value is None or isinstance(value, (bool, int, float, str)): return value - if isinstance(value, dict): - return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, Enum): + return str(value.value) + if isinstance(value, Decimal): + return str(value) + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, bytes): + return value.hex() + if isinstance(value, Mapping): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (set, frozenset)): + safe_items = [_json_safe(item) for item in value] + return sorted(safe_items, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":"))) if isinstance(value, (list, tuple)): return [_json_safe(item) for item in value] + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return _json_safe(dataclasses.asdict(value)) + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + return _json_safe(model_dump(mode="json")) return str(value) -def _dropped_block_gap( - session_id: str, message_index: int, block_position: int, block: dict[str, object] -) -> FidelityGapInput: - raw_type = block.get("type") - return FidelityGapInput( - scope="block", - record_id=f"{session_id}:message[{message_index}]:block[{block_position}]", - gap_kind="dropped_block", - detail=( - f"block type {raw_type!r} is missing or not recognized by BlockType.from_string; " - "this block was omitted from the exported material, not silently kept" - ), - ) +def _json_object(value: object) -> dict[str, JSONValue]: + safe = _json_safe(value) + if not isinstance(safe, dict): + return {"value": safe} + return safe + + +def _origin_for_session_id(session_id: str) -> Origin: + prefix, separator, _native_id = session_id.partition(":") + if not separator: + raise ValueError(f"session_id {session_id!r} is not a well-formed 'origin:native_id' session id") + try: + return Origin(prefix) + except ValueError as exc: + raise PublicationEncodingError(f"session_id {session_id!r} uses an unknown Origin token") from exc + +def _role(value: object) -> Role: + if isinstance(value, Role): + return value + return Role.normalize(str(value) if value is not None else "unknown") + + +def _message_type(value: object) -> MessageType: + return MessageType.normalize(value) + + +def _material_origin(value: object) -> MaterialOrigin: + return MaterialOrigin.normalize(value) -def _block_input(position: int, block: dict[str, object]) -> BlockInput | None: - raw_type = block.get("type") + +def _session_kind(value: object) -> SessionKind: + return SessionKind.normalize(value) + + +def _link_type(value: object) -> LinkType: + if isinstance(value, LinkType): + return value + candidate = str(value).strip().lower() if value is not None else LinkType.BRANCH.value + try: + return LinkType(candidate) + except ValueError: + return LinkType.BRANCH + + +def _block_input(position: int, block: object) -> BlockInput | None: + raw_type = _attr(block, "type", "block_type") if raw_type is None: return None try: - block_type = BlockType.from_string(str(raw_type)) + block_type = raw_type if isinstance(raw_type, BlockType) else BlockType.from_string(str(raw_type)) except ValueError: return None - tool_input = block.get("tool_input") - text = block.get("text") - tool_name = block.get("tool_name") - tool_id = block.get("tool_id") - is_error = block.get("tool_result_is_error") - exit_code = block.get("tool_result_exit_code") - semantic_type = block.get("semantic_type") - media_type = block.get("media_type") + tool_input = _attr(block, "tool_input", "input") + text = _attr(block, "text") + tool_name = _attr(block, "tool_name", "name") + tool_id = _attr(block, "tool_id") + is_error = _attr(block, "tool_result_is_error", "is_error") + exit_code = _attr(block, "tool_result_exit_code", "exit_code") + metadata = _attr(block, "metadata", default={}) + semantic_type = _attr(block, "semantic_type") + if semantic_type is None and isinstance(metadata, Mapping): + semantic_type = metadata.get("semantic_type") + media_type = _attr(block, "media_type", "mime_type") + language = _attr(block, "language") + if language is None and isinstance(metadata, Mapping): + language = metadata.get("language") return BlockInput( position=position, block_type=block_type, text=text if isinstance(text, str) else None, tool_name=tool_name if isinstance(tool_name, str) else None, tool_id=tool_id if isinstance(tool_id, str) else None, - tool_input=dict(tool_input) if isinstance(tool_input, dict) else None, + tool_input=_json_object(tool_input) if isinstance(tool_input, Mapping) else None, tool_result_is_error=is_error if isinstance(is_error, bool) else None, - tool_result_exit_code=exit_code if isinstance(exit_code, int) else None, + tool_result_exit_code=(exit_code if isinstance(exit_code, int) and not isinstance(exit_code, bool) else None), semantic_type=semantic_type if isinstance(semantic_type, str) else None, media_type=media_type if isinstance(media_type, str) else None, + language=language if isinstance(language, str) else None, + ) + + +def _dropped_block_gap( + session_id: str, + message_index: int, + block_position: int, + block: object, +) -> FidelityGapInput: + return FidelityGapInput( + scope="block", + record_id=f"{session_id}:message[{message_index}]:block[{block_position}]", + gap_kind="dropped_block", + detail=f"block type {_attr(block, 'type', 'block_type')!r} is missing or unsupported", + ) + + +def _block_fidelity_gap( + session_id: str, + message_index: int, + block_position: int, + block: object, +) -> FidelityGapInput | None: + unsupported: list[str] = [] + metadata = _attr(block, "metadata", default={}) + if isinstance(metadata, Mapping): + represented_metadata = {"language", "semantic_type"} + unsupported.extend(f"metadata.{key}" for key in sorted(set(map(str, metadata)) - represented_metadata)) + if _items(_attr(block, "web_constructs", default=())): + unsupported.append("web_constructs") + if not unsupported: + return None + return FidelityGapInput( + scope="block", + record_id=f"{session_id}:message[{message_index}]:block[{block_position}]", + gap_kind="unsupported_normalized_fields", + detail="material-protocol v1 has no field for: " + ", ".join(unsupported), + ) + + +def _parsed_block_input(position: int, block: ParsedContentBlock) -> BlockInput: + metadata = block.metadata or {} + semantic_type = metadata.get("semantic_type") + language = metadata.get("language") + return BlockInput( + position=position, + block_type=block.type, + text=block.text, + tool_name=block.tool_name, + tool_id=block.tool_id, + tool_input=_json_object(block.tool_input) if block.tool_input is not None else None, + tool_result_is_error=block.is_error, + tool_result_exit_code=block.exit_code, + semantic_type=semantic_type if isinstance(semantic_type, str) else None, + media_type=block.media_type, + language=language if isinstance(language, str) else None, + ) + + +def _parsed_block_fidelity_gap( + session_id: str, + message_index: int, + block_position: int, + block: ParsedContentBlock, +) -> FidelityGapInput | None: + metadata = block.metadata or {} + represented_metadata = {"language", "semantic_type"} + unsupported = [f"metadata.{key}" for key in sorted(set(metadata) - represented_metadata)] + if block.web_constructs: + unsupported.append("web_constructs") + if not unsupported: + return None + return FidelityGapInput( + scope="block", + record_id=f"{session_id}:message[{message_index}]:block[{block_position}]", + gap_kind="unsupported_normalized_fields", + detail="material-protocol v1 has no field for: " + ", ".join(unsupported), + ) + + +def _parsed_attachment_input(position: int, attachment: ParsedAttachment) -> AttachmentInput: + blob_sha = hashlib.sha256(attachment.inline_bytes).hexdigest() if attachment.inline_bytes is not None else None + byte_count = attachment.size_bytes + if byte_count is None and attachment.inline_bytes is not None: + byte_count = len(attachment.inline_bytes) + return AttachmentInput( + position=position, + attachment_id=attachment.provider_attachment_id, + display_name=attachment.name, + media_type=attachment.mime_type, + byte_count=max(0, byte_count or 0), + blob_sha256=blob_sha, + acquisition_status="acquired" if attachment.inline_bytes is not None else "unfetched", + upload_origin=attachment.upload_origin, + source_url=attachment.source_url, + caption=attachment.caption, + ) + + +def _parsed_attachment_fidelity_gap( + session_id: str, + attachment: ParsedAttachment, +) -> FidelityGapInput | None: + unsupported = [ + field + for field, value in ( + ("path", attachment.path), + ("provider_file_id", attachment.provider_file_id), + ("provider_drive_id", attachment.provider_drive_id), + ("attachment_kind", attachment.attachment_kind), + ) + if value not in (None, "") + ] + if not unsupported: + return None + return FidelityGapInput( + scope="attachment", + record_id=f"{session_id}:attachment:{attachment.provider_attachment_id}", + gap_kind="unsupported_normalized_fields", + detail="material-protocol v1 has no field for: " + ", ".join(unsupported), + ) + + +def _message_anchor(attachment: object) -> tuple[str, object] | None: + provider_id = _attr( + attachment, + "message_provider_id", + "source_message_provider_id", + "provider_message_id", + "message_native_id", + ) + if provider_id: + return ("native", str(provider_id)) + message_position = _attr(attachment, "message_position", "message_index") + if isinstance(message_position, int) and not isinstance(message_position, bool): + return ("position", message_position) + return None + + +def _number(value: object) -> float | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float, Decimal)): + return float(value) + return None + + +def _usage_inputs(messages: Sequence[ParsedMessage], session: ParsedSession) -> tuple[UsageInput, ...]: + totals: dict[str, dict[str, int]] = defaultdict( + lambda: {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0} + ) + for message in messages: + model_name = message.model_name + token_values = { + "input": message.input_tokens, + "output": message.output_tokens, + "cache_read": message.cache_read_tokens, + "cache_write": message.cache_write_tokens, + } + has_tokens = any(value != 0 for value in token_values.values()) + if model_name is None and not has_tokens: + continue + key = str(model_name or "unknown") + for name, value in token_values.items(): + totals[key][name] += max(0, value) + cost_usd = _number(session.reported_cost_usd) + if not totals and cost_usd is not None: + totals["unknown"] + usages: list[UsageInput] = [] + for index, (model_name, values) in enumerate(sorted(totals.items())): + usages.append( + UsageInput( + model_name=model_name, + input_tokens=values["input"], + output_tokens=values["output"], + cache_read_tokens=values["cache_read"], + cache_write_tokens=values["cache_write"], + cost_usd=cost_usd if index == 0 else None, + cost_provenance="reported" if index == 0 and cost_usd is not None else None, + ) + ) + return tuple(usages) + + +def _session_metadata(parsed_session: ParsedSession) -> dict[str, JSONValue]: + values: dict[str, object] = { + "active_leaf_message_provider_id": parsed_session.active_leaf_message_provider_id, + "branch_type": parsed_session.branch_type, + "git_commit_hash": parsed_session.git_commit_hash, + "instructions_text": parsed_session.instructions_text, + "models_used": parsed_session.models_used, + "reported_duration_ms": parsed_session.reported_duration_ms, + "source_name": parsed_session.source_name, + "title_source": parsed_session.title_source, + } + metadata: dict[str, JSONValue] = {} + for field, value in values.items(): + if value not in (None, (), [], {}, ""): + metadata[field] = _json_safe(value) + return metadata + + +def _parsed_event_input(position: int, event: ParsedSessionEvent) -> SessionEventInput: + payload = _json_object(event.payload) + summary_value = payload.get("summary") + summary = summary_value if isinstance(summary_value, str) else event.event_type + return SessionEventInput( + position=position, + event_type=event.event_type, + summary=summary, + payload=payload, + source_message_native_id=event.source_message_provider_id, + occurred_at_ms=_timestamp_ms(event.timestamp), + ) + + +def session_material_from_parsed_session(parsed_session: ParsedSession, *, session_id: str) -> SessionMaterial: + """Build complete v1 material from the real ingest ``ParsedSession``.""" + native_id = native_id_from_session_id(session_id) + if native_id is None: + raise ValueError(f"session_id {session_id!r} is not a well-formed 'origin:native_id' session id") + origin = _origin_for_session_id(session_id) + raw_messages = parsed_session.messages + attachments_by_message: dict[str, list[ParsedAttachment]] = defaultdict(list) + unanchored_attachments: list[ParsedAttachment] = [] + for attachment in parsed_session.attachments: + if attachment.message_provider_id is None: + unanchored_attachments.append(attachment) + else: + attachments_by_message[attachment.message_provider_id].append(attachment) + + fidelity_gaps: list[FidelityGapInput] = [] + messages: list[MessageInput] = [] + for index, message in enumerate(raw_messages): + position = message.position if message.position is not None else index + native_message_id = message.provider_message_id + blocks = [_parsed_block_input(block_index, block) for block_index, block in enumerate(message.blocks)] + for block_index, block in enumerate(message.blocks): + gap = _parsed_block_fidelity_gap(session_id, index, block_index, block) + if gap is not None: + fidelity_gaps.append(gap) + anchored = attachments_by_message.pop(native_message_id, []) + attachment_inputs: list[AttachmentInput] = [] + for attachment_position, attachment in enumerate(anchored): + attachment_inputs.append(_parsed_attachment_input(attachment_position, attachment)) + gap = _parsed_attachment_fidelity_gap(session_id, attachment) + if gap is not None: + fidelity_gaps.append(gap) + message_fields = { + "delivery_status": message.delivery_status, + "end_turn": message.end_turn, + "is_active_leaf": message.is_active_leaf, + "is_active_path": message.is_active_path, + "model_effort": message.model_effort, + "recipient": message.recipient, + "sender_name": message.sender_name, + "user_context_text": message.user_context_text, + } + unsupported_message_fields = [field for field, value in message_fields.items() if value not in (None, "")] + if message.branch_index != 0: + unsupported_message_fields.append("branch_index") + if message.paste_spans: + unsupported_message_fields.append("paste_spans") + if unsupported_message_fields: + fidelity_gaps.append( + FidelityGapInput( + scope="message", + record_id=f"{session_id}:message[{index}]", + gap_kind="unsupported_normalized_fields", + detail="material-protocol v1 has no field for: " + ", ".join(unsupported_message_fields), + ) + ) + messages.append( + MessageInput( + native_id=native_message_id, + position=position, + role=message.role, + text=message.text, + variant_index=message.variant_index or 0, + message_type=message.message_type, + material_origin=message.material_origin, + occurred_at_ms=( + message.occurred_at_ms if message.occurred_at_ms is not None else _timestamp_ms(message.timestamp) + ), + model_name=message.model_name, + parent_native_id=message.parent_message_provider_id, + input_tokens=message.input_tokens, + output_tokens=message.output_tokens, + cache_read_tokens=message.cache_read_tokens, + cache_write_tokens=message.cache_write_tokens, + duration_ms=message.duration_ms, + blocks=tuple(blocks), + attachments=tuple(attachment_inputs), + ) + ) + + for message_provider_id, orphaned in attachments_by_message.items(): + unanchored_attachments.extend(orphaned) + fidelity_gaps.append( + FidelityGapInput( + scope="attachment", + record_id=f"{session_id}:attachment-anchor:native:{message_provider_id}", + gap_kind="unresolved_anchor", + detail="attachment referenced a message anchor absent from the accepted session", + ) + ) + for index, attachment in enumerate(unanchored_attachments): + if attachment.message_provider_id is not None: + continue + fidelity_gaps.append( + FidelityGapInput( + scope="attachment", + record_id=attachment.provider_attachment_id or f"{session_id}:attachment[{index}]", + gap_kind="unresolved_anchor", + detail="material-protocol v1 requires a message anchor; source attachment had none", + ) + ) + + lineage: list[LineageInput] = [] + parent_native_id = parsed_session.parent_session_provider_id + if parent_native_id: + lineage.append( + LineageInput( + dst_origin=origin, + dst_native_id=str(parent_native_id), + link_type=_link_type(parsed_session.branch_type), + inheritance="prefix-sharing", + status="unresolved", + confidence=1.0, + observed_at_ms=_timestamp_ms(parsed_session.updated_at or parsed_session.created_at), + ) + ) + + events = [_parsed_event_input(index, event) for index, event in enumerate(parsed_session.session_events)] + + tags = tuple(parsed_session.ingest_flags) + return SessionMaterial( + origin=origin, + native_id=native_id, + title=parsed_session.title, + session_kind=parsed_session.session_kind, + created_at_ms=_timestamp_ms(parsed_session.created_at), + updated_at_ms=_timestamp_ms(parsed_session.updated_at), + git_branch=parsed_session.git_branch, + git_repository_url=parsed_session.git_repository_url, + provider_project_ref=parsed_session.provider_project_ref, + working_directories=tuple(parsed_session.working_directories), + metadata=_session_metadata(parsed_session), + tags=tags, + messages=tuple(messages), + lineage=tuple(lineage), + usage=_usage_inputs(parsed_session.messages, parsed_session), + session_events=tuple(events), + fidelity_gaps=tuple(fidelity_gaps), ) def session_material_from_session(session: Session) -> SessionMaterial: - """Build a ``SessionMaterial`` from a hydrated archive ``Session``. - - Message/block ``native_id`` is deliberately left ``None``: the encoder's - own documented fallback (``position || '.' || variant_index``) is a real - production identity path, not a placeholder -- many provider payloads - have no native per-message id at all. See the module docstring for the - lineage/usage/session-event scope gap this v1 adapter declares. - - Raises: - ValueError: if ``session.id`` is not a well-formed ``origin:native_id`` - string (every archive-read session id is, by construction of the - generated ``sessions.session_id`` column). - """ + """Build best-available material from a hydrated archive session tree.""" native_id = native_id_from_session_id(session.id) if native_id is None: raise ValueError(f"session.id {session.id!r} is not a well-formed 'origin:native_id' session id") @@ -114,11 +590,11 @@ def session_material_from_session(session: Session) -> SessionMaterial: for index, message in enumerate(session.messages): blocks: list[BlockInput] = [] for block_position, raw_block in enumerate(message.blocks): - block_input = _block_input(block_position, raw_block) - if block_input is None: + block = _block_input(block_position, raw_block) + if block is None: dropped_block_gaps.append(_dropped_block_gap(session.id, index, block_position, raw_block)) - continue - blocks.append(block_input) + else: + blocks.append(block) messages.append( MessageInput( native_id=None, @@ -137,18 +613,6 @@ def session_material_from_session(session: Session) -> SessionMaterial: blocks=tuple(blocks), ) ) - fidelity_gaps = ( - FidelityGapInput( - scope="session", - record_id=session.id, - gap_kind="omitted_relation", - detail=( - "polylogue.sinex.material_adapter v1 does not populate lineage, usage, " - "or session_events -- see module docstring" - ), - ), - *dropped_block_gaps, - ) return SessionMaterial( origin=session.origin, native_id=native_id, @@ -166,8 +630,102 @@ def session_material_from_session(session: Session) -> SessionMaterial: lineage=(), usage=(), session_events=(), - fidelity_gaps=fidelity_gaps, + fidelity_gaps=( + FidelityGapInput( + scope="session", + record_id=session.id, + gap_kind="omitted_relation", + detail="hydrated Session does not include attachment/lineage/usage/event repository relations", + ), + *dropped_block_gaps, + ), + ) + + +def _minimum_text_bytes(value: object, *, stop_after: int) -> int: + """Count definitely-emitted text without constructing serialized protocol bytes.""" + total = 0 + stack = [value] + while stack and total <= stop_after: + item = stack.pop() + if item is None or isinstance(item, (bool, int, float, bytes, Enum, Decimal)): + continue + if isinstance(item, str): + total += len(item) + continue + if dataclasses.is_dataclass(item) and not isinstance(item, type): + stack.extend(getattr(item, field.name) for field in dataclasses.fields(item)) + continue + if isinstance(item, Mapping): + stack.extend(str(key) for key in item) + stack.extend(item.values()) + continue + if isinstance(item, Iterable): + stack.extend(item) + return total + + +def encode_parsed_session_publication( + parsed_session: ParsedSession, + *, + session_id: str, + max_payload_bytes: int | None = None, +) -> PublicationPayload: + """Encode, verify, and return the exact bytes staged by production ingest.""" + try: + material = session_material_from_parsed_session(parsed_session, session_id=session_id) + if max_payload_bytes is not None and ( + max_payload_bytes < 0 or _minimum_text_bytes(material, stop_after=max_payload_bytes) > max_payload_bytes + ): + raise PublicationBackpressureError( + f"Sinex exact-payload staging budget exceeded before encoding: limit_bytes={max_payload_bytes}" + ) + revision_time = parsed_session.updated_at or parsed_session.created_at + encoded = encode_session_revision( + material, + revision_created_at=_revision_created_at(revision_time), + ) + manifest = encoded.manifest + raw_segments = encoded.segments + names = encoded.segment_filenames() + # Run the protocol's byte/digest/count/anchor/vocabulary/semantic closure + # verifier before these bytes become durable publication evidence. + verify_revision(manifest, raw_segments) + decoded = decode_session_revision(manifest, raw_segments) + decoded_session_id = decoded.session.get("session_id") + if decoded_session_id != session_id: + raise PublicationEncodingError( + f"encoded revision session_id mismatch: expected={session_id!r} actual={decoded_session_id!r}" + ) + # This exactly matches material_protocol.v1.io.write_revision. + manifest_bytes = canonical_bytes(manifest.to_dict()) + b"\n" + revision_id = manifest.revision_id + protocol_version = manifest.protocol_version + segments = tuple((names[index], bytes(raw_segments[index])) for index in sorted(raw_segments)) + except PublicationEncodingError: + raise + except (MaterialProtocolError, TypeError, ValueError, OverflowError, AssertionError) as exc: + raise PublicationEncodingError(f"material protocol reconciliation failed: {type(exc).__name__}: {exc}") from exc + payload = PublicationPayload( + object_id=session_id, + protocol_version=str(protocol_version), + revision_id=str(revision_id), + manifest_digest=hashlib.sha256(manifest_bytes).hexdigest(), + manifest_bytes=manifest_bytes, + segments=segments, ) + if max_payload_bytes is not None and payload.size_bytes > max_payload_bytes: + raise PublicationBackpressureError( + "Sinex exact-payload staging budget exceeded after encoding: " + f"payload_bytes={payload.size_bytes} limit_bytes={max_payload_bytes}" + ) + return payload -__all__ = ["session_material_from_session"] +__all__ = [ + "PublicationBackpressureError", + "PublicationEncodingError", + "encode_parsed_session_publication", + "session_material_from_parsed_session", + "session_material_from_session", +] diff --git a/polylogue/sinex/models.py b/polylogue/sinex/models.py index 459581695a..155926d7c4 100644 --- a/polylogue/sinex/models.py +++ b/polylogue/sinex/models.py @@ -1,21 +1,21 @@ -"""Typed vocabulary for the Sinex publication obligation ledger and transport. - -``ReceiptState`` mirrors the outcome vocabulary Sinex documents for -``DurableEmissionReceipt`` (sinex-r6d.11): progress may unlock only on -``PERSISTED_CONFIRMED`` or a documented terminal debt/lossless-spool outcome, -never on a bare in-memory accept. ``ObligationStatus`` is the Polylogue-side -publication-obligation lifecycle this package actually owns and persists in -``source.db``. +"""Typed vocabulary for durable Sinex publication and convergence. + +The source-tier ledger stores both the obligation metadata and the exact +manifest/segment bytes needed to redrive it after a process crash. Receipt +state, rather than successful invocation of a transport method, is the only +thing that may unlock primary-mode projection progress. """ from __future__ import annotations +import hashlib +import json from dataclasses import dataclass from enum import Enum class PublicationMode(str, Enum): - """Sinex-backed authority profile (design: polylogue-303r / 303r.2).""" + """Sinex-backed authority profile (polylogue-303r / polylogue-303r.2).""" OFF = "off" MIRROR = "mirror" @@ -44,24 +44,14 @@ def from_string(cls, value: str | ObligationStatus) -> ObligationStatus: return cls(str(value).strip().lower()) -#: Terminal states: a drain loop must not keep retrying these automatically. TERMINAL_OBLIGATION_STATUSES = frozenset({ObligationStatus.CONFIRMED, ObligationStatus.REJECTED}) - -#: Retryable states: durable_debt is terminal-for-this-attempt but explicitly -#: retryable (design: "configured failure is never a no-op"). RETRYABLE_OBLIGATION_STATUSES = frozenset( {ObligationStatus.PENDING, ObligationStatus.PUBLISHING, ObligationStatus.DURABLE_DEBT} ) class ReceiptState(str, Enum): - """Outcome vocabulary modeled on Sinex's DurableEmissionReceipt (r6d.11). - - Only :meth:`unlocks_progress` states may advance a local projection. - ``RAW_ACCEPTED`` intentionally does NOT unlock progress -- it models the - documented failure mode (mpsc/NATS-publish acceptance mistaken for a - durable commit) that r6d.11 exists to close off. - """ + """Durable-emission outcome vocabulary shared with the transport.""" RAW_ACCEPTED = "raw_accepted" PERSISTED_CONFIRMED = "persisted_confirmed" @@ -70,13 +60,7 @@ class ReceiptState(str, Enum): REJECTED = "rejected" def unlocks_progress(self) -> bool: - """Whether this receipt state may unlock a local projection advance. - - Mirrors sinex-r6d.11's stated rule: PersistedConfirmed or a - documented terminal DurableDebt/SpoolAcceptedLossless outcome only. - RawAccepted (bare mpsc/NATS-publish acceptance) and Rejected never - unlock progress. - """ + """Return whether this receipt may release primary-mode projection.""" return self in ( ReceiptState.PERSISTED_CONFIRMED, ReceiptState.DURABLE_DEBT, @@ -86,7 +70,7 @@ def unlocks_progress(self) -> bool: @dataclass(frozen=True, slots=True) class PublicationReceipt: - """One transport attempt's outcome, keyed by the obligation's request_id.""" + """One transport attempt's outcome, keyed by the obligation request id.""" request_id: str state: ReceiptState @@ -94,15 +78,35 @@ class PublicationReceipt: @dataclass(frozen=True, slots=True) -class PublicationObligation: - """One durable ``sinex_publication_obligations`` row. +class PublicationPayload: + """Exact, restart-safe material bytes staged with an obligation. - The 4-tuple ``(object_id, protocol_version, revision_id, - manifest_digest)`` is both the SQL primary key and the transport - idempotency key (design: polylogue-303r.2, "idempotent by protocol - version + stable object revision + manifest digest"). + ``segments`` is an ordered tuple rather than a mutable mapping so the + payload can cross the process-pool/async boundary without aliasing. The + segment name is the transport-visible protocol filename, not an invented + database identifier. """ + object_id: str + protocol_version: str + revision_id: str + manifest_digest: str + manifest_bytes: bytes + segments: tuple[tuple[str, bytes], ...] + + @property + def segment_bytes(self) -> dict[str, bytes]: + return dict(self.segments) + + @property + def size_bytes(self) -> int: + return len(self.manifest_bytes) + sum(len(payload) for _, payload in self.segments) + + +@dataclass(frozen=True, slots=True) +class PublicationObligation: + """One durable ``sinex_publication_obligations`` row.""" + object_id: str protocol_version: str revision_id: str @@ -116,22 +120,63 @@ class PublicationObligation: created_at_ms: int updated_at_ms: int retired_at_ms: int | None + next_attempt_at_ms: int | None = None @property def request_id(self) -> str: - """Deterministic transport idempotency key for this exact revision. + """Deterministic transport idempotency key for this exact revision.""" + fields = (self.object_id, self.protocol_version, self.revision_id, self.manifest_digest) + framed = json.dumps(fields, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return f"polylogue-publication-v1:{hashlib.sha256(framed).hexdigest()}" - Same revision -> same request_id -> a real transport can de-duplicate - retried attempts by identity rather than by side-channel bookkeeping. - """ - return "|".join((self.object_id, self.protocol_version, self.revision_id, self.manifest_digest)) + @property + def progress_unlocked(self) -> bool: + return self.last_receipt_state is not None and self.last_receipt_state.unlocks_progress() + + +@dataclass(frozen=True, slots=True) +class PublicationStatus: + """Secret-safe status snapshot for daemon/operator observability.""" + + mode: PublicationMode + total: int = 0 + pending: int = 0 + publishing: int = 0 + confirmed: int = 0 + durable_debt: int = 0 + rejected: int = 0 + retry_due: int = 0 + blocking: int = 0 + active_lag: int = 0 + oldest_active_age_ms: int | None = None + last_receipt_state: ReceiptState | None = None + last_error_code: str | None = None + + def as_dict(self) -> dict[str, str | int | None]: + return { + "mode": self.mode.value, + "total": self.total, + "pending": self.pending, + "publishing": self.publishing, + "confirmed": self.confirmed, + "durable_debt": self.durable_debt, + "rejected": self.rejected, + "retry_due": self.retry_due, + "blocking": self.blocking, + "active_lag": self.active_lag, + "oldest_active_age_ms": self.oldest_active_age_ms, + "last_receipt_state": self.last_receipt_state.value if self.last_receipt_state else None, + "last_error_code": self.last_error_code, + } __all__ = [ "ObligationStatus", "PublicationMode", "PublicationObligation", + "PublicationPayload", "PublicationReceipt", + "PublicationStatus", "ReceiptState", "RETRYABLE_OBLIGATION_STATUSES", "TERMINAL_OBLIGATION_STATUSES", diff --git a/polylogue/sinex/obligations.py b/polylogue/sinex/obligations.py index a6c00ac24e..9e39392ae8 100644 --- a/polylogue/sinex/obligations.py +++ b/polylogue/sinex/obligations.py @@ -1,21 +1,26 @@ -"""Durable CRUD over ``source.db.sinex_publication_obligations``. - -Every function here operates on an already-open ``sqlite3.Connection`` and -never calls ``commit()``/``rollback()`` itself -- the caller controls the -transaction boundary. This is deliberate: design polylogue-303r.2 requires -the obligation row to be created "in the same durable source-tier transaction -that records the acquired/normalized revision", so obligation creation must -be composable into a caller's existing transaction, not own its own. +"""Durable source-tier publication outbox primitives. + +All synchronous functions accept an already-open ``sqlite3.Connection`` and +never commit or roll back. ``stage_payload_async`` follows the same rule for +the async source backend used by ingest. This lets raw-session acceptance, +exact material bytes, and the idempotent obligation share one source.db +transaction without pretending a WAL transaction spans source.db/index.db. """ from __future__ import annotations +import hashlib import sqlite3 +from collections.abc import Sequence +from pathlib import PurePosixPath +from typing import Protocol, cast, runtime_checkable from polylogue.sinex.models import ( ObligationStatus, PublicationMode, PublicationObligation, + PublicationPayload, + PublicationReceipt, ReceiptState, ) @@ -33,9 +38,58 @@ "created_at_ms", "updated_at_ms", "retired_at_ms", + "next_attempt_at_ms", ) +class PublicationPayloadConflictError(RuntimeError): + """The same publication key was presented with different exact bytes.""" + + +class PublicationPayloadInvalidError(ValueError): + """A staged payload is malformed or does not match its declared digest.""" + + +@runtime_checkable +class _AsyncCursor(Protocol): + async def fetchone(self) -> object | None: ... + + async def fetchall(self) -> list[object]: ... + + +@runtime_checkable +class AsyncSqlConnection(Protocol): + async def execute(self, sql: str, parameters: Sequence[object] = ()) -> _AsyncCursor: ... + + +Key = tuple[str, str, str, str] + + +def _key(obligation: PublicationObligation | PublicationPayload) -> Key: + return ( + obligation.object_id, + obligation.protocol_version, + obligation.revision_id, + obligation.manifest_digest, + ) + + +def _validate_payload(payload: PublicationPayload) -> None: + if not payload.object_id or not payload.protocol_version or not payload.revision_id: + raise PublicationPayloadInvalidError("publication identity fields must be non-empty") + actual_manifest_digest = hashlib.sha256(payload.manifest_bytes).hexdigest() + if actual_manifest_digest != payload.manifest_digest: + raise PublicationPayloadInvalidError("manifest_digest does not match the exact staged manifest bytes") + names: set[str] = set() + for name, _segment in payload.segments: + path = PurePosixPath(name) + if not name or "\x00" in name or path.is_absolute() or ".." in path.parts: + raise PublicationPayloadInvalidError(f"unsafe protocol segment name: {name!r}") + if name in names: + raise PublicationPayloadInvalidError(f"duplicate protocol segment name: {name!r}") + names.add(name) + + def _row_to_obligation(row: sqlite3.Row) -> PublicationObligation: return PublicationObligation( object_id=str(row["object_id"]), @@ -53,6 +107,7 @@ def _row_to_obligation(row: sqlite3.Row) -> PublicationObligation: created_at_ms=int(row["created_at_ms"]), updated_at_ms=int(row["updated_at_ms"]), retired_at_ms=(int(row["retired_at_ms"]) if row["retired_at_ms"] is not None else None), + next_attempt_at_ms=(int(row["next_attempt_at_ms"]) if row["next_attempt_at_ms"] is not None else None), ) @@ -66,25 +121,31 @@ def record_obligation( mode: PublicationMode, now_ms: int, ) -> PublicationObligation: - """Idempotently create (or return the existing) obligation for a revision. - - ``mode`` must not be :attr:`PublicationMode.OFF` -- callers gate obligation - creation on mode before calling this (off mode performs zero durable - writes and zero transport work, per design). INSERT OR IGNORE makes a - retried call for the *same* revision a true no-op: the idempotency key is - the primary key, so a duplicate obligation is structurally impossible. - """ + """Idempotently create an obligation and monotonically elevate its mode.""" if mode is PublicationMode.OFF: raise ValueError("record_obligation must not be called in off mode") conn.row_factory = sqlite3.Row conn.execute( """ - INSERT OR IGNORE INTO sinex_publication_obligations ( + INSERT INTO sinex_publication_obligations ( object_id, protocol_version, revision_id, manifest_digest, mode, - status, attempt_count, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?) + status, attempt_count, created_at_ms, updated_at_ms, + next_attempt_at_ms + ) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?) + ON CONFLICT(object_id, protocol_version, revision_id, manifest_digest) + DO UPDATE SET + mode = CASE + WHEN sinex_publication_obligations.mode = 'mirror' + AND excluded.mode = 'primary' THEN 'primary' + ELSE sinex_publication_obligations.mode + END, + updated_at_ms = CASE + WHEN sinex_publication_obligations.mode = 'mirror' + AND excluded.mode = 'primary' THEN excluded.updated_at_ms + ELSE sinex_publication_obligations.updated_at_ms + END """, - (object_id, protocol_version, revision_id, manifest_digest, mode.value, now_ms, now_ms), + (object_id, protocol_version, revision_id, manifest_digest, mode.value, now_ms, now_ms, now_ms), ) existing = get_obligation( conn, @@ -97,6 +158,221 @@ def record_obligation( return existing +def _assert_existing_payload_matches( + *, + manifest_bytes: bytes, + manifest_sha256: str, + manifest_size_bytes: int, + segment_count: int, + total_size_bytes: int, + payload: PublicationPayload, +) -> None: + if ( + manifest_bytes != payload.manifest_bytes + or manifest_sha256 != payload.manifest_digest + or manifest_size_bytes != len(payload.manifest_bytes) + or segment_count != len(payload.segments) + or total_size_bytes != payload.size_bytes + ): + raise PublicationPayloadConflictError( + "publication key already exists with different exact payload bytes or metadata" + ) + + +def stage_payload( + conn: sqlite3.Connection, + *, + payload: PublicationPayload, + mode: PublicationMode, + now_ms: int, +) -> PublicationObligation: + """Stage exact bytes and obligation in the caller's source.db transaction.""" + _validate_payload(payload) + obligation = record_obligation( + conn, + object_id=payload.object_id, + protocol_version=payload.protocol_version, + revision_id=payload.revision_id, + manifest_digest=payload.manifest_digest, + mode=mode, + now_ms=now_ms, + ) + key = _key(payload) + conn.execute( + """ + INSERT OR IGNORE INTO sinex_publication_payloads ( + object_id, protocol_version, revision_id, manifest_digest, + manifest_bytes, manifest_sha256, manifest_size_bytes, + segment_count, total_size_bytes, staged_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + *key, + payload.manifest_bytes, + payload.manifest_digest, + len(payload.manifest_bytes), + len(payload.segments), + payload.size_bytes, + now_ms, + ), + ) + row = conn.execute( + """ + SELECT manifest_bytes, manifest_sha256, manifest_size_bytes, + segment_count, total_size_bytes + FROM sinex_publication_payloads + WHERE object_id = ? AND protocol_version = ? AND revision_id = ? AND manifest_digest = ? + """, + key, + ).fetchone() + assert row is not None + _assert_existing_payload_matches( + manifest_bytes=bytes(row[0]), + manifest_sha256=str(row[1]), + manifest_size_bytes=int(row[2]), + segment_count=int(row[3]), + total_size_bytes=int(row[4]), + payload=payload, + ) + for position, (name, segment_bytes) in enumerate(payload.segments): + digest = hashlib.sha256(segment_bytes).hexdigest() + conn.execute( + """ + INSERT OR IGNORE INTO sinex_publication_segments ( + object_id, protocol_version, revision_id, manifest_digest, + position, segment_name, segment_bytes, segment_sha256, size_bytes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (*key, position, name, segment_bytes, digest, len(segment_bytes)), + ) + existing = conn.execute( + """ + SELECT segment_name, segment_bytes, segment_sha256, size_bytes + FROM sinex_publication_segments + WHERE object_id = ? AND protocol_version = ? AND revision_id = ? + AND manifest_digest = ? AND position = ? + """, + (*key, position), + ).fetchone() + assert existing is not None + if ( + str(existing[0]), + bytes(existing[1]), + str(existing[2]), + int(existing[3]), + ) != (name, segment_bytes, digest, len(segment_bytes)): + raise PublicationPayloadConflictError( + f"publication key already exists with different segment bytes at position={position}" + ) + return obligation + + +async def stage_payload_async( + conn: AsyncSqlConnection, + *, + payload: PublicationPayload, + mode: PublicationMode, + now_ms: int, +) -> None: + """Async equivalent of :func:`stage_payload`, without transaction ownership.""" + if mode is PublicationMode.OFF: + raise ValueError("stage_payload_async must not be called in off mode") + _validate_payload(payload) + key = _key(payload) + await conn.execute( + """ + INSERT INTO sinex_publication_obligations ( + object_id, protocol_version, revision_id, manifest_digest, mode, + status, attempt_count, created_at_ms, updated_at_ms, + next_attempt_at_ms + ) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?) + ON CONFLICT(object_id, protocol_version, revision_id, manifest_digest) + DO UPDATE SET + mode = CASE + WHEN sinex_publication_obligations.mode = 'mirror' + AND excluded.mode = 'primary' THEN 'primary' + ELSE sinex_publication_obligations.mode + END, + updated_at_ms = CASE + WHEN sinex_publication_obligations.mode = 'mirror' + AND excluded.mode = 'primary' THEN excluded.updated_at_ms + ELSE sinex_publication_obligations.updated_at_ms + END + """, + (*key, mode.value, now_ms, now_ms, now_ms), + ) + await conn.execute( + """ + INSERT OR IGNORE INTO sinex_publication_payloads ( + object_id, protocol_version, revision_id, manifest_digest, + manifest_bytes, manifest_sha256, manifest_size_bytes, + segment_count, total_size_bytes, staged_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + *key, + payload.manifest_bytes, + payload.manifest_digest, + len(payload.manifest_bytes), + len(payload.segments), + payload.size_bytes, + now_ms, + ), + ) + cursor = await conn.execute( + """ + SELECT manifest_bytes, manifest_sha256, manifest_size_bytes, + segment_count, total_size_bytes + FROM sinex_publication_payloads + WHERE object_id = ? AND protocol_version = ? AND revision_id = ? AND manifest_digest = ? + """, + key, + ) + row = await cursor.fetchone() + assert row is not None + values: tuple[object, ...] = tuple(row) # type: ignore[arg-type] + _assert_existing_payload_matches( + manifest_bytes=bytes(cast(bytes, values[0])), + manifest_sha256=str(values[1]), + manifest_size_bytes=int(cast(int, values[2])), + segment_count=int(cast(int, values[3])), + total_size_bytes=int(cast(int, values[4])), + payload=payload, + ) + for position, (name, segment_bytes) in enumerate(payload.segments): + digest = hashlib.sha256(segment_bytes).hexdigest() + await conn.execute( + """ + INSERT OR IGNORE INTO sinex_publication_segments ( + object_id, protocol_version, revision_id, manifest_digest, + position, segment_name, segment_bytes, segment_sha256, size_bytes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (*key, position, name, segment_bytes, digest, len(segment_bytes)), + ) + cursor = await conn.execute( + """ + SELECT segment_name, segment_bytes, segment_sha256, size_bytes + FROM sinex_publication_segments + WHERE object_id = ? AND protocol_version = ? AND revision_id = ? + AND manifest_digest = ? AND position = ? + """, + (*key, position), + ) + existing = await cursor.fetchone() + assert existing is not None + existing_values: tuple[object, ...] = tuple(existing) # type: ignore[arg-type] + if ( + str(existing_values[0]), + bytes(cast(bytes, existing_values[1])), + str(existing_values[2]), + int(cast(int, existing_values[3])), + ) != (name, segment_bytes, digest, len(segment_bytes)): + raise PublicationPayloadConflictError( + f"publication key already exists with different segment bytes at position={position}" + ) + + def get_obligation( conn: sqlite3.Connection, *, @@ -121,8 +397,11 @@ def list_obligations( *, statuses: tuple[ObligationStatus, ...] | None = None, object_id: str | None = None, + object_ids: Sequence[str] | None = None, + due_at_ms: int | None = None, + limit: int | None = None, ) -> tuple[PublicationObligation, ...]: - """List obligations, optionally filtered by status set and/or object.""" + """List obligations with deterministic ordering and optional due filter.""" conn.row_factory = sqlite3.Row clauses: list[str] = [] params: list[object] = [] @@ -133,54 +412,110 @@ def list_obligations( if object_id is not None: clauses.append("object_id = ?") params.append(object_id) + if object_ids is not None: + unique_ids = tuple(dict.fromkeys(str(value) for value in object_ids if value)) + if not unique_ids: + return () + placeholders = ",".join("?" for _ in unique_ids) + clauses.append(f"object_id IN ({placeholders})") + params.extend(unique_ids) + if due_at_ms is not None: + clauses.append("COALESCE(next_attempt_at_ms, created_at_ms) <= ?") + params.append(due_at_ms) where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + limit_sql = "" + if limit is not None: + if limit < 1: + return () + limit_sql = " LIMIT ?" + params.append(limit) rows = conn.execute( f""" SELECT {", ".join(_COLUMNS)} FROM sinex_publication_obligations {where} - ORDER BY created_at_ms, object_id + ORDER BY COALESCE(next_attempt_at_ms, created_at_ms), created_at_ms, object_id + {limit_sql} """, params, ).fetchall() return tuple(_row_to_obligation(row) for row in rows) -def mark_attempt( +def load_payload(conn: sqlite3.Connection, obligation: PublicationObligation) -> PublicationPayload: + key = _key(obligation) + row = conn.execute( + """ + SELECT manifest_bytes, manifest_sha256, manifest_size_bytes, + segment_count, total_size_bytes + FROM sinex_publication_payloads + WHERE object_id = ? AND protocol_version = ? AND revision_id = ? AND manifest_digest = ? + """, + key, + ).fetchone() + if row is None: + raise PublicationPayloadInvalidError("obligation has no durable staged payload") + segment_rows = conn.execute( + """ + SELECT segment_name, segment_bytes, segment_sha256, size_bytes + FROM sinex_publication_segments + WHERE object_id = ? AND protocol_version = ? AND revision_id = ? AND manifest_digest = ? + ORDER BY position + """, + key, + ).fetchall() + manifest_bytes = bytes(row[0]) + payload = PublicationPayload( + object_id=obligation.object_id, + protocol_version=obligation.protocol_version, + revision_id=obligation.revision_id, + manifest_digest=obligation.manifest_digest, + manifest_bytes=manifest_bytes, + segments=tuple((str(item[0]), bytes(item[1])) for item in segment_rows), + ) + _validate_payload(payload) + if str(row[1]) != hashlib.sha256(manifest_bytes).hexdigest(): + raise PublicationPayloadInvalidError("staged manifest digest reconciliation failed") + if int(row[2]) != len(manifest_bytes): + raise PublicationPayloadInvalidError("staged manifest size reconciliation failed") + if int(row[3]) != len(payload.segments): + raise PublicationPayloadInvalidError("staged segment count does not reconcile with payload metadata") + if int(row[4]) != payload.size_bytes: + raise PublicationPayloadInvalidError("staged total size reconciliation failed") + for item, (_name, segment_bytes) in zip(segment_rows, payload.segments, strict=True): + if str(item[2]) != hashlib.sha256(segment_bytes).hexdigest() or int(item[3]) != len(segment_bytes): + raise PublicationPayloadInvalidError("staged segment digest/size reconciliation failed") + return payload + + +def mark_publishing( conn: sqlite3.Connection, obligation: PublicationObligation, *, - status: ObligationStatus, - receipt_state: ReceiptState | None, - error: str | None, now_ms: int, + lease_until_ms: int, ) -> PublicationObligation: - """Record one transport attempt outcome and advance obligation status. - - Always increments ``attempt_count`` -- this is the durable evidence that - a retry happened, independent of the disposable ``ops.db`` diagnostics a - caller may also choose to record. - """ - retired_at_ms = now_ms if status in (ObligationStatus.CONFIRMED, ObligationStatus.REJECTED) else None - conn.execute( + """Durably lease one retryable row before invoking the transport.""" + cursor = conn.execute( """ UPDATE sinex_publication_obligations - SET status = ?, attempt_count = attempt_count + 1, last_attempt_at_ms = ?, - last_receipt_state = ?, last_error = ?, updated_at_ms = ?, retired_at_ms = ? + SET status = 'publishing', updated_at_ms = ?, next_attempt_at_ms = ? WHERE object_id = ? AND protocol_version = ? AND revision_id = ? AND manifest_digest = ? + AND status IN ('pending', 'publishing', 'durable_debt') + AND COALESCE(next_attempt_at_ms, created_at_ms) <= ? """, - ( - status.value, - now_ms, - receipt_state.value if receipt_state is not None else None, - error, - now_ms, - retired_at_ms, - obligation.object_id, - obligation.protocol_version, - obligation.revision_id, - obligation.manifest_digest, - ), + (now_ms, lease_until_ms, *_key(obligation), now_ms), ) + if cursor.rowcount != 1: + current = get_obligation( + conn, + object_id=obligation.object_id, + protocol_version=obligation.protocol_version, + revision_id=obligation.revision_id, + manifest_digest=obligation.manifest_digest, + ) + if current is None: + raise RuntimeError("publication obligation disappeared while acquiring lease") + return current updated = get_obligation( conn, object_id=obligation.object_id, @@ -192,35 +527,38 @@ def mark_attempt( return updated -def mark_publishing( +def mark_attempt( conn: sqlite3.Connection, obligation: PublicationObligation, *, + status: ObligationStatus, + receipt: PublicationReceipt | None, + error_code: str | None, now_ms: int, + next_attempt_at_ms: int | None, ) -> PublicationObligation: - """Mark an obligation as actively in-flight to a transport attempt. - - Distinct from :func:`mark_attempt`: this is the pre-attempt transition, - written durably *before* the (possibly slow, possibly crashing) transport - call starts, so a crash mid-attempt leaves the row at ``publishing`` - instead of indistinguishable from a still-untried ``pending`` row. It - deliberately does not touch ``attempt_count``/``last_receipt_state``/ - ``last_error`` -- those describe an attempt's *outcome*, which this call - precedes and does not yet know. - """ + """Persist one attempt outcome and append its secret-safe receipt history.""" + receipt_state = receipt.state if receipt is not None else None + receipt_detail = receipt.detail if receipt is not None else "" + retired_at_ms = now_ms if status in (ObligationStatus.CONFIRMED, ObligationStatus.REJECTED) else None + next_attempt = None if retired_at_ms is not None else next_attempt_at_ms conn.execute( """ UPDATE sinex_publication_obligations - SET status = ?, updated_at_ms = ? + SET status = ?, attempt_count = attempt_count + 1, + last_attempt_at_ms = ?, last_receipt_state = ?, last_error = ?, + updated_at_ms = ?, retired_at_ms = ?, next_attempt_at_ms = ? WHERE object_id = ? AND protocol_version = ? AND revision_id = ? AND manifest_digest = ? """, ( - ObligationStatus.PUBLISHING.value, + status.value, now_ms, - obligation.object_id, - obligation.protocol_version, - obligation.revision_id, - obligation.manifest_digest, + receipt_state.value if receipt_state is not None else None, + error_code, + now_ms, + retired_at_ms, + next_attempt, + *_key(obligation), ), ) updated = get_obligation( @@ -231,7 +569,70 @@ def mark_publishing( manifest_digest=obligation.manifest_digest, ) assert updated is not None + conn.execute( + """ + INSERT INTO sinex_publication_receipts ( + object_id, protocol_version, revision_id, manifest_digest, + attempt_number, request_id, receipt_state, receipt_detail, + error_code, received_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + *_key(obligation), + updated.attempt_count, + obligation.request_id, + receipt_state.value if receipt_state is not None else None, + receipt_detail, + error_code, + now_ms, + ), + ) return updated -__all__ = ["get_obligation", "list_obligations", "mark_attempt", "mark_publishing", "record_obligation"] +def reset_retryable( + conn: sqlite3.Connection, + *, + now_ms: int, + object_ids: Sequence[str] | None = None, + include_rejected: bool = False, +) -> int: + """Operator-safe redrive primitive used by restart/ops reset paths.""" + statuses = ["pending", "publishing", "durable_debt"] + if include_rejected: + statuses.append("rejected") + status_placeholders = ",".join("?" for _ in statuses) + params: list[object] = [now_ms, now_ms, *statuses] + object_clause = "" + if object_ids is not None: + ids = tuple(dict.fromkeys(str(value) for value in object_ids if value)) + if not ids: + return 0 + object_clause = f" AND object_id IN ({','.join('?' for _ in ids)})" + params.extend(ids) + cursor = conn.execute( + f""" + UPDATE sinex_publication_obligations + SET status = 'pending', retired_at_ms = NULL, + next_attempt_at_ms = ?, updated_at_ms = ? + WHERE status IN ({status_placeholders}){object_clause} + """, + params, + ) + return int(cursor.rowcount) + + +__all__ = [ + "AsyncSqlConnection", + "PublicationPayloadConflictError", + "PublicationPayloadInvalidError", + "get_obligation", + "list_obligations", + "load_payload", + "mark_attempt", + "mark_publishing", + "record_obligation", + "reset_retryable", + "stage_payload", + "stage_payload_async", +] diff --git a/polylogue/sinex/service.py b/polylogue/sinex/service.py index 8db68fad69..bac1f0bdda 100644 --- a/polylogue/sinex/service.py +++ b/polylogue/sinex/service.py @@ -1,52 +1,134 @@ -"""Orchestrates the durable publication obligation against a transport. - -``PublicationService`` is the seam between (a) the durable ``source.db`` -obligation ledger, which must survive process crashes, and (b) an injected -:class:`~polylogue.sinex.transport.SinexTransport`, which may fail, be slow, -or (in ``off`` mode) not exist at all. It intentionally does not know how to -build ``SessionMaterial``/manifest/segment bytes -- callers supply those -(from :mod:`polylogue.material_protocol.v1`), keeping this module's only job -"is this revision durably staged, and has it been confirmed". -""" +"""Supervised, bounded draining of the durable Sinex publication outbox.""" from __future__ import annotations +import asyncio +import re import sqlite3 import time from collections.abc import Callable, Mapping, Sequence +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from pathlib import Path from polylogue.sinex import obligations as obligations_store -from polylogue.sinex.models import ObligationStatus, PublicationMode, PublicationObligation, ReceiptState -from polylogue.sinex.transport import SinexTransport +from polylogue.sinex.models import ( + ObligationStatus, + PublicationMode, + PublicationObligation, + PublicationPayload, + PublicationReceipt, + PublicationStatus, + ReceiptState, +) +from polylogue.sinex.transport import SinexTransport, SinexTransportUnavailableError + +_RETRYABLE_STATUSES = ( + ObligationStatus.PENDING, + ObligationStatus.PUBLISHING, + ObligationStatus.DURABLE_DEBT, +) _DEFAULT_CLOCK: Callable[[], int] = lambda: int(time.time() * 1000) # noqa: E731 +_SAFE_CODE_RE = re.compile(r"[^a-zA-Z0-9_.:-]+") +_SECRET_RE = re.compile( + r"(?i)\b(token|secret|password|authorization|api[-_]?key)\b" + r"\s*[:=]\s*[^\r\n,;]*" +) @dataclass(frozen=True, slots=True) class DrainSummary: - """Outcome of one drain pass over pending/retryable obligations.""" + """Outcome of one bounded drain pass.""" attempted: int = 0 confirmed: int = 0 durable_debt: int = 0 rejected: int = 0 + deferred: int = 0 + transport_failures: int = 0 + payload_failures: int = 0 remaining_lag: int = 0 +@dataclass(slots=True) +class _OutcomeCounts: + confirmed: int = 0 + durable_debt: int = 0 + rejected: int = 0 + deferred: int = 0 + transport_failures: int = 0 + payload_failures: int = 0 + + def record(self, obligation: PublicationObligation, *, payload_failed: bool = False) -> None: + if obligation.status is ObligationStatus.CONFIRMED: + self.confirmed += 1 + elif obligation.status is ObligationStatus.DURABLE_DEBT: + self.durable_debt += 1 + elif obligation.status is ObligationStatus.REJECTED: + self.rejected += 1 + elif payload_failed: + self.payload_failures += 1 + elif obligation.last_error: + self.transport_failures += 1 + else: + self.deferred += 1 + + @dataclass class PublicationService: - """Stage and drain Sinex publication obligations for one archive.""" + """Stage and drain publication obligations for one source.db. + + SQLite mutations occur only on the calling daemon thread. When a sync + convergence pass is already running inside an asyncio loop, only the + transport coroutine is run in a short-lived worker thread; that thread + never receives a database connection. + """ source_db_path: Path mode: PublicationMode transport: SinexTransport | None = None clock: Callable[[], int] = field(default=_DEFAULT_CLOCK) + max_batch: int = 16 + attempt_timeout_s: float = 30.0 + base_retry_ms: int = 1_000 + max_retry_ms: int = 5 * 60_000 + publishing_lease_ms: int = 60_000 + durable_debt_retry_ms: int = 15 * 60_000 def __post_init__(self) -> None: + self.mode = PublicationMode.from_string(self.mode) if self.mode is not PublicationMode.OFF and self.transport is None: - raise ValueError(f"mode={self.mode.value} requires a transport") + raise SinexTransportUnavailableError( + f"mode={self.mode.value} requires an injected configured Sinex transport" + ) + if self.max_batch < 1: + raise ValueError("max_batch must be positive") + if self.attempt_timeout_s <= 0: + raise ValueError("attempt_timeout_s must be positive") + + def _connect(self, *, readonly: bool = False) -> sqlite3.Connection: + if readonly: + conn = sqlite3.connect(f"file:{self.source_db_path}?mode=ro", uri=True, timeout=30.0) + else: + conn = sqlite3.connect(self.source_db_path, timeout=30.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 30000") + conn.execute("PRAGMA foreign_keys = ON") + return conn + + @staticmethod + def _safe_code(value: str) -> str: + return _SAFE_CODE_RE.sub("_", value)[:128] + + @staticmethod + def _safe_detail(value: str) -> str: + return _SECRET_RE.sub(r"\1=", value.replace("\x00", ""))[:256] + + def _retry_at(self, obligation: PublicationObligation, now_ms: int) -> int: + exponent = min(obligation.attempt_count, 12) + delay = int(self.base_retry_ms) * (2**exponent) + return int(now_ms + min(int(self.max_retry_ms), delay)) def stage( self, @@ -57,15 +139,7 @@ def stage( manifest_digest: str, conn: sqlite3.Connection | None = None, ) -> PublicationObligation | None: - """Create (or return the existing) durable obligation for a revision. - - Returns ``None`` in off mode without touching ``source.db`` at all -- - "off: today's Polylogue source/user tiers and blobs are canonical; no - Sinex dependency or hidden network work" (design). When ``conn`` is - supplied, the obligation is written into the CALLER's open - transaction (the "same durable source-tier transaction" requirement); - otherwise this method opens and commits its own transaction. - """ + """Compatibility metadata stage; production ingest uses stage_payload.""" if self.mode is PublicationMode.OFF: return None now_ms = self.clock() @@ -79,12 +153,11 @@ def stage( mode=self.mode, now_ms=now_ms, ) - owned_conn = sqlite3.connect(self.source_db_path, timeout=30.0) - owned_conn.execute("PRAGMA busy_timeout = 30000") + owned = self._connect() try: - owned_conn.execute("BEGIN IMMEDIATE") + owned.execute("BEGIN IMMEDIATE") obligation = obligations_store.record_obligation( - owned_conn, + owned, object_id=object_id, protocol_version=protocol_version, revision_id=revision_id, @@ -92,13 +165,37 @@ def stage( mode=self.mode, now_ms=now_ms, ) - owned_conn.commit() + owned.commit() return obligation except Exception: - owned_conn.rollback() + owned.rollback() raise finally: - owned_conn.close() + owned.close() + + def stage_payload( + self, + payload: PublicationPayload, + *, + conn: sqlite3.Connection | None = None, + ) -> PublicationObligation | None: + """Durably stage exact bytes; off mode performs no database work.""" + if self.mode is PublicationMode.OFF: + return None + now_ms = self.clock() + if conn is not None: + return obligations_store.stage_payload(conn, payload=payload, mode=self.mode, now_ms=now_ms) + owned = self._connect() + try: + owned.execute("BEGIN IMMEDIATE") + obligation = obligations_store.stage_payload(owned, payload=payload, mode=self.mode, now_ms=now_ms) + owned.commit() + return obligation + except Exception: + owned.rollback() + raise + finally: + owned.close() async def publish( self, @@ -112,133 +209,464 @@ async def publish( conn: sqlite3.Connection | None = None, on_confirmed: Callable[[PublicationObligation], None] | None = None, ) -> PublicationObligation | None: - """Stage the obligation, then attempt exactly one publish. - - In ``primary`` mode ``on_confirmed`` is the ONLY sanctioned way a - caller may advance a local projection for this revision -- it fires - if and only if the transport receipt's - :meth:`~polylogue.sinex.models.ReceiptState.unlocks_progress` is - true, never on a bare send. Returns ``None`` in off mode. - """ - obligation = self.stage( + """Stage exact bytes and make one bounded transport attempt.""" + if conn is not None: + raise ValueError( + "publish cannot invoke transport inside an uncommitted caller transaction; " + "use stage_payload(..., conn=conn), commit, then drain_once()" + ) + payload = PublicationPayload( object_id=object_id, protocol_version=protocol_version, revision_id=revision_id, manifest_digest=manifest_digest, - conn=conn, + manifest_bytes=manifest_bytes, + segments=tuple(sorted((str(name), bytes(value)) for name, value in segment_bytes.items())), ) + obligation = self.stage_payload(payload) if obligation is None: return None - return await self._attempt(obligation, manifest_bytes, segment_bytes, on_confirmed=on_confirmed) + return await self._attempt_async(obligation, payload, on_confirmed=on_confirmed) - async def _attempt( - self, - obligation: PublicationObligation, - manifest_bytes: bytes, - segment_bytes: Mapping[str, bytes], - *, - on_confirmed: Callable[[PublicationObligation], None] | None, - ) -> PublicationObligation: - assert self.transport is not None # off mode never reaches here - publishing_conn = sqlite3.connect(self.source_db_path, timeout=30.0) - publishing_conn.execute("PRAGMA busy_timeout = 30000") + def _lease(self, obligation: PublicationObligation) -> PublicationObligation: + now_ms = self.clock() + conn = self._connect() try: - publishing_conn.execute("BEGIN IMMEDIATE") - obligations_store.mark_publishing(publishing_conn, obligation, now_ms=self.clock()) - publishing_conn.commit() + conn.execute("BEGIN IMMEDIATE") + leased = obligations_store.mark_publishing( + conn, + obligation, + now_ms=now_ms, + lease_until_ms=now_ms + self.publishing_lease_ms, + ) + conn.commit() + return leased except Exception: - publishing_conn.rollback() + conn.rollback() raise finally: - publishing_conn.close() - receipt = await self.transport.publish_revision( - request_id=obligation.request_id, - manifest_bytes=manifest_bytes, - segment_bytes=segment_bytes, - ) - if receipt.state is ReceiptState.REJECTED: - new_status = ObligationStatus.REJECTED + conn.close() + + def _persist_outcome( + self, + obligation: PublicationObligation, + *, + receipt: PublicationReceipt | None, + error_code: str | None, + ) -> PublicationObligation: + now_ms = self.clock() + if receipt is None: + status = ObligationStatus.PENDING + next_attempt_at_ms = self._retry_at(obligation, now_ms) + elif receipt.state is ReceiptState.REJECTED: + status = ObligationStatus.REJECTED + next_attempt_at_ms = None + elif receipt.state is ReceiptState.PERSISTED_CONFIRMED: + status = ObligationStatus.CONFIRMED + next_attempt_at_ms = None elif receipt.state.unlocks_progress(): - new_status = ( - ObligationStatus.CONFIRMED - if receipt.state is ReceiptState.PERSISTED_CONFIRMED - else ObligationStatus.DURABLE_DEBT - ) + status = ObligationStatus.DURABLE_DEBT + next_attempt_at_ms = now_ms + self.durable_debt_retry_ms else: - # RAW_ACCEPTED or any other non-unlocking state: still pending, - # still retryable. Never a silent success. - new_status = ObligationStatus.PENDING - conn = sqlite3.connect(self.source_db_path, timeout=30.0) - conn.execute("PRAGMA busy_timeout = 30000") + status = ObligationStatus.PENDING + next_attempt_at_ms = self._retry_at(obligation, now_ms) + safe_receipt = None + if receipt is not None: + safe_receipt = PublicationReceipt( + request_id=receipt.request_id, + state=receipt.state, + detail=self._safe_detail(receipt.detail), + ) + conn = self._connect() try: conn.execute("BEGIN IMMEDIATE") updated = obligations_store.mark_attempt( conn, obligation, - status=new_status, - receipt_state=receipt.state, - error=receipt.detail if receipt.state is ReceiptState.REJECTED else None, - now_ms=self.clock(), + status=status, + receipt=safe_receipt, + error_code=self._safe_code(error_code) if error_code else None, + now_ms=now_ms, + next_attempt_at_ms=next_attempt_at_ms, ) conn.commit() + return updated except Exception: conn.rollback() raise finally: conn.close() - if receipt.state.unlocks_progress() and on_confirmed is not None: + + async def _attempt_async( + self, + obligation: PublicationObligation, + payload: PublicationPayload, + *, + on_confirmed: Callable[[PublicationObligation], None] | None = None, + ) -> PublicationObligation: + transport = self.transport + assert transport is not None + leased = self._lease(obligation) + if leased.status is not ObligationStatus.PUBLISHING: + return leased + receipt: PublicationReceipt | None = None + error_code: str | None = None + try: + receipt = await asyncio.wait_for( + transport.publish_revision( + request_id=leased.request_id, + manifest_bytes=payload.manifest_bytes, + segment_bytes=payload.segment_bytes, + ), + timeout=self.attempt_timeout_s, + ) + if receipt.request_id != leased.request_id: + raise ValueError("transport returned a receipt for a different request_id") + except TimeoutError: + error_code = "transport_timeout" + except Exception as exc: + error_code = f"transport_exception:{type(exc).__name__}" + updated = self._persist_outcome(leased, receipt=receipt, error_code=error_code) + if updated.progress_unlocked and on_confirmed is not None: on_confirmed(updated) return updated - def pending(self) -> tuple[PublicationObligation, ...]: - """List every non-terminal obligation (mirror-mode lag report).""" - conn = sqlite3.connect(f"file:{self.source_db_path}?mode=ro", uri=True) + def _run_transport_sync(self, obligation: PublicationObligation, payload: PublicationPayload) -> PublicationReceipt: + transport = self.transport + assert transport is not None + + async def invoke() -> PublicationReceipt: + return await asyncio.wait_for( + transport.publish_revision( + request_id=obligation.request_id, + manifest_bytes=payload.manifest_bytes, + segment_bytes=payload.segment_bytes, + ), + timeout=self.attempt_timeout_s, + ) + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(invoke()) + with ThreadPoolExecutor(max_workers=1, thread_name_prefix="sinex-transport") as executor: + return executor.submit(asyncio.run, invoke()).result() + + def _attempt_sync(self, obligation: PublicationObligation, payload: PublicationPayload) -> PublicationObligation: + leased = self._lease(obligation) + if leased.status is not ObligationStatus.PUBLISHING: + return leased + receipt: PublicationReceipt | None = None + error_code: str | None = None + try: + receipt = self._run_transport_sync(leased, payload) + if receipt.request_id != leased.request_id: + raise ValueError("transport returned a receipt for a different request_id") + except TimeoutError: + error_code = "transport_timeout" + except Exception as exc: + error_code = f"transport_exception:{type(exc).__name__}" + return self._persist_outcome(leased, receipt=receipt, error_code=error_code) + + def pending(self, *, object_ids: Sequence[str] | None = None) -> tuple[PublicationObligation, ...]: + if self.mode is PublicationMode.OFF: + return () + conn = self._connect(readonly=True) try: return obligations_store.list_obligations( conn, - statuses=(ObligationStatus.PENDING, ObligationStatus.PUBLISHING, ObligationStatus.DURABLE_DEBT), + statuses=_RETRYABLE_STATUSES, + object_ids=object_ids, ) finally: conn.close() - def lag(self) -> int: - """Exact count of obligations awaiting a confirming receipt.""" - return len(self.pending()) + def lag(self, *, object_ids: Sequence[str] | None = None) -> int: + """Exact unresolved count, including rejected terminal failures.""" + if self.mode is PublicationMode.OFF: + return 0 + conn = self._connect(readonly=True) + try: + clauses = ["status != 'confirmed'"] + params: list[object] = [] + if object_ids is not None: + ids = tuple(dict.fromkeys(str(value) for value in object_ids if value)) + if not ids: + return 0 + clauses.append(f"object_id IN ({','.join('?' for _ in ids)})") + params.extend(ids) + row = conn.execute( + f"SELECT COUNT(*) FROM sinex_publication_obligations WHERE {' AND '.join(clauses)}", + params, + ).fetchone() + return int(row[0]) if row is not None else 0 + finally: + conn.close() + + def has_due_work(self, object_ids: Sequence[str]) -> bool: + if self.mode is PublicationMode.OFF or not object_ids: + return False + conn = self._connect(readonly=True) + try: + return bool( + obligations_store.list_obligations( + conn, + statuses=_RETRYABLE_STATUSES, + object_ids=object_ids, + due_at_ms=self.clock(), + limit=1, + ) + ) + finally: + conn.close() + + def unresolved_object_ids(self, object_ids: Sequence[str]) -> set[str]: + """Return selected objects with any exact revision not fully confirmed.""" + if self.mode is PublicationMode.OFF or not object_ids: + return set() + ids = tuple(dict.fromkeys(str(value) for value in object_ids if value)) + if not ids: + return set() + conn = self._connect(readonly=True) + try: + rows = conn.execute( + f""" + SELECT DISTINCT object_id + FROM sinex_publication_obligations + WHERE object_id IN ({",".join("?" for _ in ids)}) + AND status != 'confirmed' + """, + ids, + ).fetchall() + return {str(row[0]) for row in rows} + finally: + conn.close() + + def blocking_object_ids(self, object_ids: Sequence[str]) -> set[str]: + """Return objects whose newest accepted revision lacks an allowed receipt. + + Historical revisions remain queryable as lag/debt, but cannot re-block a + newer confirmed revision. ``rowid`` is a deterministic tie-breaker for + multiple accepted revisions staged in the same millisecond. + """ + if self.mode is not PublicationMode.PRIMARY or not object_ids: + return set() + ids = tuple(dict.fromkeys(str(value) for value in object_ids if value)) + if not ids: + return set() + conn = self._connect(readonly=True) + try: + rows = conn.execute( + f""" + WITH ranked AS ( + SELECT object_id, last_receipt_state, + ROW_NUMBER() OVER ( + PARTITION BY object_id + ORDER BY created_at_ms DESC, rowid DESC + ) AS revision_rank + FROM sinex_publication_obligations + WHERE object_id IN ({",".join("?" for _ in ids)}) + ) + SELECT object_id + FROM ranked + WHERE revision_rank = 1 + AND (last_receipt_state IS NULL + OR last_receipt_state NOT IN ( + 'persisted_confirmed', 'durable_debt', 'spool_accepted_lossless' + )) + """, + ids, + ).fetchall() + return {str(row[0]) for row in rows} + finally: + conn.close() + + def projection_blocked(self, object_ids: Sequence[str]) -> bool: + """Whether a selected newest primary revision lacks an allowed receipt.""" + return bool(self.blocking_object_ids(object_ids)) + + def drain_once( + self, + *, + object_ids: Sequence[str] | None = None, + limit: int | None = None, + ) -> DrainSummary: + """Drain at most ``limit`` due rows and persist every outcome.""" + if self.mode is PublicationMode.OFF: + return DrainSummary() + bounded_limit = min(limit or self.max_batch, self.max_batch) + now_ms = self.clock() + conn = self._connect(readonly=True) + try: + due = obligations_store.list_obligations( + conn, + statuses=_RETRYABLE_STATUSES, + object_ids=object_ids, + due_at_ms=now_ms, + limit=bounded_limit, + ) + finally: + conn.close() + counts = _OutcomeCounts() + for obligation in due: + payload_failed = False + payload_conn = self._connect(readonly=True) + try: + payload = obligations_store.load_payload(payload_conn, obligation) + except Exception as exc: + payload_failed = True + leased = self._lease(obligation) + if leased.status is ObligationStatus.PUBLISHING: + updated = self._persist_outcome( + leased, + receipt=None, + error_code=f"payload_load:{type(exc).__name__}", + ) + else: + updated = leased + else: + updated = self._attempt_sync(obligation, payload) + finally: + payload_conn.close() + counts.record(updated, payload_failed=payload_failed) + return DrainSummary( + attempted=len(due), + confirmed=counts.confirmed, + durable_debt=counts.durable_debt, + rejected=counts.rejected, + deferred=counts.deferred, + transport_failures=counts.transport_failures, + payload_failures=counts.payload_failures, + remaining_lag=self.lag(object_ids=object_ids), + ) async def retry_pending( self, - staged: Sequence[tuple[PublicationObligation, bytes, Mapping[str, bytes]]], + staged: Sequence[tuple[PublicationObligation, bytes, Mapping[str, bytes]]] | None = None, *, on_confirmed: Callable[[PublicationObligation], None] | None = None, ) -> DrainSummary: - """Redrive previously-staged obligations the caller re-supplies bytes for. - - The obligation ledger deliberately does not store manifest/segment - bytes (that is the material store's job, not this ledger's); a - caller resolves each pending obligation back to its material bytes - and passes the pairs here. Off mode returns an all-zero summary - without any transport calls. - """ + """Compatibility async redrive; durable bytes are authoritative when omitted.""" if self.mode is PublicationMode.OFF: return DrainSummary() - confirmed = 0 - debt = 0 - rejected = 0 - for obligation, manifest_bytes, segment_bytes in staged: - updated = await self._attempt(obligation, manifest_bytes, segment_bytes, on_confirmed=on_confirmed) - if updated.status is ObligationStatus.CONFIRMED: - confirmed += 1 - elif updated.status is ObligationStatus.DURABLE_DEBT: - debt += 1 - elif updated.status is ObligationStatus.REJECTED: - rejected += 1 + if staged is None: + return await asyncio.to_thread(self.drain_once) + counts = _OutcomeCounts() + object_ids: list[str] = [] + for obligation, manifest_bytes, segments in staged[: self.max_batch]: + object_ids.append(obligation.object_id) + payload = PublicationPayload( + object_id=obligation.object_id, + protocol_version=obligation.protocol_version, + revision_id=obligation.revision_id, + manifest_digest=obligation.manifest_digest, + manifest_bytes=manifest_bytes, + segments=tuple(sorted((str(name), bytes(value)) for name, value in segments.items())), + ) + updated = await self._attempt_async(obligation, payload, on_confirmed=on_confirmed) + counts.record(updated) return DrainSummary( - attempted=len(staged), - confirmed=confirmed, - durable_debt=debt, - rejected=rejected, - remaining_lag=self.lag(), + attempted=min(len(staged), self.max_batch), + confirmed=counts.confirmed, + durable_debt=counts.durable_debt, + rejected=counts.rejected, + deferred=counts.deferred, + transport_failures=counts.transport_failures, + payload_failures=counts.payload_failures, + remaining_lag=self.lag(object_ids=object_ids), ) + def reset_retryable(self, *, include_rejected: bool = False) -> int: + if self.mode is PublicationMode.OFF: + return 0 + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + count = obligations_store.reset_retryable( + conn, + now_ms=self.clock(), + include_rejected=include_rejected, + ) + conn.commit() + return count + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def status(self) -> PublicationStatus: + """Return bounded, secret-safe status without payload or raw detail.""" + if self.mode is PublicationMode.OFF: + return PublicationStatus(mode=self.mode) + now_ms = self.clock() + conn = self._connect(readonly=True) + try: + rows = conn.execute("SELECT status, COUNT(*) FROM sinex_publication_obligations GROUP BY status").fetchall() + counts = {str(row[0]): int(row[1]) for row in rows} + due_row = conn.execute( + """ + SELECT COUNT(*) FROM sinex_publication_obligations + WHERE status IN ('pending', 'publishing', 'durable_debt') + AND COALESCE(next_attempt_at_ms, created_at_ms) <= ? + """, + (now_ms,), + ).fetchone() + blocking_row = conn.execute( + """ + WITH ranked AS ( + SELECT last_receipt_state, + ROW_NUMBER() OVER ( + PARTITION BY object_id + ORDER BY created_at_ms DESC, rowid DESC + ) AS revision_rank + FROM sinex_publication_obligations + ) + SELECT COUNT(*) FROM ranked + WHERE revision_rank = 1 + AND (last_receipt_state IS NULL + OR last_receipt_state NOT IN ( + 'persisted_confirmed', 'durable_debt', 'spool_accepted_lossless' + )) + """ + ).fetchone() + oldest_row = conn.execute( + """ + SELECT MIN(created_at_ms) FROM sinex_publication_obligations + WHERE status != 'confirmed' + """ + ).fetchone() + recent = conn.execute( + """ + SELECT receipt_state, error_code + FROM sinex_publication_receipts + ORDER BY received_at_ms DESC, attempt_number DESC LIMIT 1 + """ + ).fetchone() + oldest = int(oldest_row[0]) if oldest_row is not None and oldest_row[0] is not None else None + receipt_state = ReceiptState(str(recent[0])) if recent is not None and recent[0] is not None else None + error_code = str(recent[1]) if recent is not None and recent[1] is not None else None + total = sum(counts.values()) + active_lag = total - counts.get("confirmed", 0) + return PublicationStatus( + mode=self.mode, + total=total, + pending=counts.get("pending", 0), + publishing=counts.get("publishing", 0), + confirmed=counts.get("confirmed", 0), + durable_debt=counts.get("durable_debt", 0), + rejected=counts.get("rejected", 0), + retry_due=int(due_row[0]) if due_row is not None else 0, + blocking=( + int(blocking_row[0]) if self.mode is PublicationMode.PRIMARY and blocking_row is not None else 0 + ), + active_lag=active_lag, + oldest_active_age_ms=max(0, now_ms - oldest) if oldest is not None else None, + last_receipt_state=receipt_state, + last_error_code=error_code, + ) + finally: + conn.close() + __all__ = ["DrainSummary", "PublicationService"] diff --git a/polylogue/sinex/transport.py b/polylogue/sinex/transport.py index 773f97f126..555950db8c 100644 --- a/polylogue/sinex/transport.py +++ b/polylogue/sinex/transport.py @@ -19,8 +19,10 @@ from __future__ import annotations +import hashlib from collections.abc import Callable, Mapping from dataclasses import dataclass, field +from threading import RLock from typing import Protocol, runtime_checkable from polylogue.sinex.models import PublicationReceipt, ReceiptState @@ -46,6 +48,54 @@ async def publish_revision( ) -> PublicationReceipt: ... +class SinexTransportUnavailableError(RuntimeError): + """Configured mirror/primary mode has no injected transport.""" + + +TransportFactory = Callable[[], SinexTransport] +_TRANSPORT_FACTORY_LOCK = RLock() +_CONFIGURED_TRANSPORT_FACTORY: TransportFactory | None = None + + +def register_configured_transport_factory(factory: TransportFactory) -> None: + """Register the deployment-owned transport composition hook. + + Polylogue intentionally does not infer endpoint credentials from ad-hoc + environment variables. Deployment composition registers one factory + before the daemon builds its default convergence stages. + """ + if not callable(factory): + raise TypeError("Sinex transport factory must be callable") + global _CONFIGURED_TRANSPORT_FACTORY + with _TRANSPORT_FACTORY_LOCK: + _CONFIGURED_TRANSPORT_FACTORY = factory + + +def clear_configured_transport_factory() -> None: + """Clear process-local transport composition (primarily test isolation).""" + global _CONFIGURED_TRANSPORT_FACTORY + with _TRANSPORT_FACTORY_LOCK: + _CONFIGURED_TRANSPORT_FACTORY = None + + +def resolve_configured_transport() -> SinexTransport: + """Construct the registered transport or fail backed-mode startup loudly.""" + with _TRANSPORT_FACTORY_LOCK: + factory = _CONFIGURED_TRANSPORT_FACTORY + if factory is None: + raise SinexTransportUnavailableError( + "mirror/primary mode requires deployment composition to register a Sinex transport factory" + ) + transport = factory() + if not isinstance(transport, SinexTransport): + raise TypeError("configured Sinex transport does not satisfy SinexTransport") + return transport + + +class TransportPayloadConflictError(RuntimeError): + """A request id was reused with bytes different from its first attempt.""" + + class TransportUsedInOffModeError(RuntimeError): """Raised when a transport is invoked despite ``PublicationMode.OFF``. @@ -99,6 +149,7 @@ class LocalReferenceTransport: fault_fn: Callable[[str, int], ReceiptState | None] | None = None _receipts_by_request_id: dict[str, PublicationReceipt] = field(default_factory=dict) _attempt_counts: dict[str, int] = field(default_factory=dict) + _payload_digests: dict[str, str] = field(default_factory=dict) calls: list[_RecordedCall] = field(default_factory=list) async def publish_revision( @@ -108,6 +159,22 @@ async def publish_revision( manifest_bytes: bytes, segment_bytes: Mapping[str, bytes], ) -> PublicationReceipt: + digest = hashlib.sha256() + + def add_frame(payload: bytes) -> None: + digest.update(len(payload).to_bytes(8, "big")) + digest.update(payload) + + add_frame(manifest_bytes) + for name, payload in sorted(segment_bytes.items()): + add_frame(name.encode("utf-8")) + add_frame(payload) + payload_digest = digest.hexdigest() + existing_digest = self._payload_digests.get(request_id) + if existing_digest is not None and existing_digest != payload_digest: + raise TransportPayloadConflictError(f"request_id={request_id!r} was reused with different exact bytes") + self._payload_digests.setdefault(request_id, payload_digest) + # Idempotency: a request_id that already reached a durable outcome # returns that SAME receipt rather than doing (or recording) another # publish. This is the property real Sinex transport must have too. @@ -137,5 +204,11 @@ def call_count(self, request_id: str | None = None) -> int: "LocalReferenceTransport", "NullTransport", "SinexTransport", + "SinexTransportUnavailableError", + "TransportFactory", + "TransportPayloadConflictError", "TransportUsedInOffModeError", + "clear_configured_transport_factory", + "register_configured_transport_factory", + "resolve_configured_transport", ] diff --git a/polylogue/storage/repository/__init__.py b/polylogue/storage/repository/__init__.py index 9032a5ea0b..282c275672 100644 --- a/polylogue/storage/repository/__init__.py +++ b/polylogue/storage/repository/__init__.py @@ -105,6 +105,11 @@ def backend(self) -> SQLiteBackend: """Access the underlying async storage backend.""" return self._backend + @property + def source_backend(self) -> SQLiteBackend | None: + """Access the durable source-tier backend when this repository owns one.""" + return self._source_backend + async def close(self) -> None: """Close database connections and release resources.""" await self._backend.close() diff --git a/polylogue/storage/sqlite/archive_tiers/source.py b/polylogue/storage/sqlite/archive_tiers/source.py index 113fb52557..eb94034ed5 100644 --- a/polylogue/storage/sqlite/archive_tiers/source.py +++ b/polylogue/storage/sqlite/archive_tiers/source.py @@ -9,7 +9,7 @@ from polylogue.core.enums import ArtifactSupportStatus, Origin, Provider, ValidationMode, ValidationStatus from polylogue.storage.sqlite.archive_tiers.common import check, nullable_check -SOURCE_SCHEMA_VERSION = 11 +SOURCE_SCHEMA_VERSION = 12 SOURCE_DDL = f""" CREATE TABLE IF NOT EXISTS raw_sessions ( @@ -222,16 +222,77 @@ created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, retired_at_ms INTEGER, + next_attempt_at_ms INTEGER, PRIMARY KEY(object_id, protocol_version, revision_id, manifest_digest) ) STRICT; CREATE INDEX IF NOT EXISTS idx_sinex_publication_obligations_pending -ON sinex_publication_obligations(status, created_at_ms) +ON sinex_publication_obligations(status, next_attempt_at_ms, created_at_ms) WHERE status IN ('pending', 'publishing', 'durable_debt'); CREATE INDEX IF NOT EXISTS idx_sinex_publication_obligations_object ON sinex_publication_obligations(object_id, created_at_ms DESC); +CREATE TABLE IF NOT EXISTS sinex_publication_payloads ( + object_id TEXT NOT NULL, + protocol_version TEXT NOT NULL, + revision_id TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + manifest_bytes BLOB NOT NULL, + manifest_sha256 TEXT NOT NULL CHECK(length(manifest_sha256) = 64), + manifest_size_bytes INTEGER NOT NULL CHECK(manifest_size_bytes >= 0), + segment_count INTEGER NOT NULL CHECK(segment_count >= 0), + total_size_bytes INTEGER NOT NULL CHECK(total_size_bytes >= manifest_size_bytes), + staged_at_ms INTEGER NOT NULL CHECK(staged_at_ms >= 0), + PRIMARY KEY(object_id, protocol_version, revision_id, manifest_digest), + FOREIGN KEY(object_id, protocol_version, revision_id, manifest_digest) + REFERENCES sinex_publication_obligations( + object_id, protocol_version, revision_id, manifest_digest + ) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS sinex_publication_segments ( + object_id TEXT NOT NULL, + protocol_version TEXT NOT NULL, + revision_id TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + position INTEGER NOT NULL CHECK(position >= 0), + segment_name TEXT NOT NULL CHECK(segment_name != ''), + segment_bytes BLOB NOT NULL, + segment_sha256 TEXT NOT NULL CHECK(length(segment_sha256) = 64), + size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0), + PRIMARY KEY(object_id, protocol_version, revision_id, manifest_digest, position), + UNIQUE(object_id, protocol_version, revision_id, manifest_digest, segment_name), + FOREIGN KEY(object_id, protocol_version, revision_id, manifest_digest) + REFERENCES sinex_publication_payloads( + object_id, protocol_version, revision_id, manifest_digest + ) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS sinex_publication_receipts ( + object_id TEXT NOT NULL, + protocol_version TEXT NOT NULL, + revision_id TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + attempt_number INTEGER NOT NULL CHECK(attempt_number > 0), + request_id TEXT NOT NULL, + receipt_state TEXT CHECK(receipt_state IS NULL OR receipt_state IN ( + 'raw_accepted', 'persisted_confirmed', 'durable_debt', + 'spool_accepted_lossless', 'rejected' + )), + receipt_detail TEXT NOT NULL DEFAULT '', + error_code TEXT, + received_at_ms INTEGER NOT NULL CHECK(received_at_ms >= 0), + PRIMARY KEY(object_id, protocol_version, revision_id, manifest_digest, attempt_number), + FOREIGN KEY(object_id, protocol_version, revision_id, manifest_digest) + REFERENCES sinex_publication_obligations( + object_id, protocol_version, revision_id, manifest_digest + ) ON DELETE CASCADE +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_sinex_publication_receipts_recent +ON sinex_publication_receipts(received_at_ms DESC); + -- Durable removed-content ledger (polylogue-27m). A row here is the -- authoritative "this content is forgotten on purpose" marker for -- standalone/off-mode excision: the acquire-time write chokepoint diff --git a/polylogue/storage/sqlite/migrations/source/012_sinex_publication_payloads_receipts.sql b/polylogue/storage/sqlite/migrations/source/012_sinex_publication_payloads_receipts.sql new file mode 100644 index 0000000000..a670b370c7 --- /dev/null +++ b/polylogue/storage/sqlite/migrations/source/012_sinex_publication_payloads_receipts.sql @@ -0,0 +1,79 @@ +-- Restart-safe exact Sinex publication payloads, receipts, and retry schedule +-- (polylogue-303r.2.1). +-- +-- 011 is the durable excised-content migration present in the complete +-- repository but outside this job's selected source footprint. This change +-- therefore advances source.db from 11 to 12; it deliberately does not edit +-- migration 010 and does not create an index.db migration chain. +ALTER TABLE sinex_publication_obligations +ADD COLUMN next_attempt_at_ms INTEGER; + +UPDATE sinex_publication_obligations +SET next_attempt_at_ms = COALESCE(last_attempt_at_ms, created_at_ms) +WHERE status IN ('pending', 'publishing', 'durable_debt'); + +CREATE TABLE sinex_publication_payloads ( + object_id TEXT NOT NULL, + protocol_version TEXT NOT NULL, + revision_id TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + manifest_bytes BLOB NOT NULL, + manifest_sha256 TEXT NOT NULL CHECK(length(manifest_sha256) = 64), + manifest_size_bytes INTEGER NOT NULL CHECK(manifest_size_bytes >= 0), + segment_count INTEGER NOT NULL CHECK(segment_count >= 0), + total_size_bytes INTEGER NOT NULL CHECK(total_size_bytes >= manifest_size_bytes), + staged_at_ms INTEGER NOT NULL CHECK(staged_at_ms >= 0), + PRIMARY KEY(object_id, protocol_version, revision_id, manifest_digest), + FOREIGN KEY(object_id, protocol_version, revision_id, manifest_digest) + REFERENCES sinex_publication_obligations( + object_id, protocol_version, revision_id, manifest_digest + ) ON DELETE CASCADE +) STRICT; + +CREATE TABLE sinex_publication_segments ( + object_id TEXT NOT NULL, + protocol_version TEXT NOT NULL, + revision_id TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + position INTEGER NOT NULL CHECK(position >= 0), + segment_name TEXT NOT NULL CHECK(segment_name != ''), + segment_bytes BLOB NOT NULL, + segment_sha256 TEXT NOT NULL CHECK(length(segment_sha256) = 64), + size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0), + PRIMARY KEY(object_id, protocol_version, revision_id, manifest_digest, position), + UNIQUE(object_id, protocol_version, revision_id, manifest_digest, segment_name), + FOREIGN KEY(object_id, protocol_version, revision_id, manifest_digest) + REFERENCES sinex_publication_payloads( + object_id, protocol_version, revision_id, manifest_digest + ) ON DELETE CASCADE +) STRICT; + +CREATE TABLE sinex_publication_receipts ( + object_id TEXT NOT NULL, + protocol_version TEXT NOT NULL, + revision_id TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + attempt_number INTEGER NOT NULL CHECK(attempt_number > 0), + request_id TEXT NOT NULL, + receipt_state TEXT CHECK(receipt_state IS NULL OR receipt_state IN ( + 'raw_accepted', 'persisted_confirmed', 'durable_debt', + 'spool_accepted_lossless', 'rejected' + )), + receipt_detail TEXT NOT NULL DEFAULT '', + error_code TEXT, + received_at_ms INTEGER NOT NULL CHECK(received_at_ms >= 0), + PRIMARY KEY(object_id, protocol_version, revision_id, manifest_digest, attempt_number), + FOREIGN KEY(object_id, protocol_version, revision_id, manifest_digest) + REFERENCES sinex_publication_obligations( + object_id, protocol_version, revision_id, manifest_digest + ) ON DELETE CASCADE +) STRICT; + +DROP INDEX idx_sinex_publication_obligations_pending; + +CREATE INDEX idx_sinex_publication_obligations_pending +ON sinex_publication_obligations(status, next_attempt_at_ms, created_at_ms) +WHERE status IN ('pending', 'publishing', 'durable_debt'); + +CREATE INDEX idx_sinex_publication_receipts_recent +ON sinex_publication_receipts(received_at_ms DESC); diff --git a/tests/unit/core/test_config_inventory.py b/tests/unit/core/test_config_inventory.py index 50d9a3e0e1..ceb008a792 100644 --- a/tests/unit/core/test_config_inventory.py +++ b/tests/unit/core/test_config_inventory.py @@ -382,19 +382,16 @@ def test_effective_config_payload_reports_embedding_enabled_without_key( ) -def test_effective_config_payload_reports_sinex_mode_not_yet_wired( +def test_effective_config_payload_accepts_wired_sinex_mode_without_noop_warning( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, workspace_env: dict[str, Path], ) -> None: - """Setting sinex_mode=mirror must surface a loud diagnostic, not a silent no-op. - - Guards polylogue-303r.2's design principle ("configured failure is never - a no-op"): as of this test, no ingest/daemon/CLI code path constructs a - PublicationService from config.sinex_mode, so flipping the config knob - must remain observable through this diagnostic. Mutation check: removing - the ``_sinex_mode_diagnostics`` call from ``config_diagnostics`` (or - reverting ``sinex_mode`` to ``"off"``) makes this assertion fail. + """A recognized backed mode is no longer described as an unwired no-op. + + Production-route ingest and daemon tests exercise the actual service + construction. This guards the operator-facing config surface against + regressing to the stale `sinex_mode_not_yet_wired` warning. """ from polylogue.config import effective_config_payload, load_polylogue_config @@ -408,14 +405,7 @@ def test_effective_config_payload_reports_sinex_mode_not_yet_wired( diagnostics = payload["diagnostics"] assert isinstance(diagnostics, list) - assert any( - diag.get("code") == "sinex_mode_not_yet_wired" - and diag.get("severity") == "warning" - and diag.get("key") == "sinex_mode" - and diag.get("value") == "mirror" - for diag in diagnostics - if isinstance(diag, dict) - ) + assert not any(diag.get("code") == "sinex_mode_not_yet_wired" for diag in diagnostics if isinstance(diag, dict)) def test_effective_config_payload_reports_sinex_mode_unrecognized_value( diff --git a/tests/unit/pipeline/test_ingest_batch.py b/tests/unit/pipeline/test_ingest_batch.py index 129d748bc7..308e101b35 100644 --- a/tests/unit/pipeline/test_ingest_batch.py +++ b/tests/unit/pipeline/test_ingest_batch.py @@ -46,6 +46,12 @@ ) from polylogue.pipeline.services.parsing import ParsingService from polylogue.pipeline.services.parsing_models import ParseResult +from polylogue.sinex.models import PublicationMode, ReceiptState +from polylogue.sinex.transport import ( + LocalReferenceTransport, + clear_configured_transport_factory, + register_configured_transport_factory, +) from polylogue.sources.parsers.base import ( ParsedAttachment, ParsedContentBlock, @@ -130,6 +136,156 @@ def test_sync_index_connection_ensures_runtime_indexes(tmp_path: Path) -> None: assert row is not None +def test_primary_mode_keeps_unconfirmed_revision_out_of_index_and_fts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Primary authority must be established before any local read projection.""" + archive_root = tmp_path / "archive" + initialize_active_archive_root(archive_root) + raw_record = RawSessionRecord( + raw_id="raw-primary", + source_name="codex", + source_path="/sources/primary.jsonl", + blob_size=16, + acquired_at="2026-04-02T00:00:00Z", + ) + session = _session_data( + "codex-session:primary-unconfirmed", + content_hash="primary-unconfirmed", + raw_id=raw_record.raw_id, + message_tuples=[ + _message_tuple( + "msg-primary", + "codex-session:primary-unconfirmed", + role="assistant", + text="must remain hidden", + content_hash="msg-primary", + sort_key=1.0, + ) + ], + ) + + monkeypatch.setattr( + ingest_batch_core, + "ingest_record", + lambda *_args, **_kwargs: IngestRecordResult(raw_id=raw_record.raw_id, sessions=[session]), + ) + transport = LocalReferenceTransport(fault_fn=lambda _request_id, _attempt: ReceiptState.RAW_ACCEPTED) + register_configured_transport_factory(lambda: transport) + try: + summary = _process_ingest_batch_sync( + [raw_record], + db_path=archive_root / "index.db", + archive_root_str=str(archive_root), + blob_root_str=str(archive_root / "blob"), + validation_mode="advisory", + ingest_workers=1, + measure_ingest_result_size=False, + publication_mode=PublicationMode.PRIMARY, + ) + finally: + clear_configured_transport_factory() + + with sqlite3.connect(archive_root / "index.db") as index_conn: + assert index_conn.execute("SELECT COUNT(*) FROM sessions").fetchone() == (0,) + assert index_conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone() == (0,) + with sqlite3.connect(archive_root / "source.db") as source_conn: + assert source_conn.execute("SELECT COUNT(*) FROM sinex_publication_obligations").fetchone() == (1,) + assert summary.publication_deferred_raw_ids == {raw_record.raw_id} + assert summary.publication_payloads_by_raw_id == {} + assert summary.publication_payload_bytes == 0 + + +def test_primary_transport_resolution_precedes_index_connection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class MissingTransportError(RuntimeError): + pass + + opened = False + + def missing_transport() -> NoReturn: + raise MissingTransportError("primary transport is not configured") + + def unexpected_open(_path: Path) -> sqlite3.Connection: + nonlocal opened + opened = True + raise AssertionError("index connection must not open before transport resolution") + + monkeypatch.setattr(ingest_batch_core, "resolve_configured_transport", missing_transport) + monkeypatch.setattr(ingest_batch_core, "_open_sync_connection", unexpected_open) + + with pytest.raises(MissingTransportError, match="not configured"): + _process_ingest_batch_sync( + [], + db_path=tmp_path / "archive" / "index.db", + archive_root_str=str(tmp_path / "archive"), + blob_root_str=str(tmp_path / "archive" / "blob"), + validation_mode="advisory", + ingest_workers=1, + measure_ingest_result_size=False, + publication_mode=PublicationMode.PRIMARY, + ) + + assert not opened + + +def test_primary_mode_projects_revision_after_allowed_durable_receipt( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + archive_root = tmp_path / "archive" + initialize_active_archive_root(archive_root) + raw_record = RawSessionRecord( + raw_id="raw-primary-confirmed", + source_name="codex", + source_path="/sources/primary-confirmed.jsonl", + blob_size=16, + acquired_at="2026-04-02T00:00:00Z", + ) + session = _session_data( + "codex-session:primary-confirmed", + content_hash="primary-confirmed", + raw_id=raw_record.raw_id, + message_tuples=[ + _message_tuple( + "msg-primary-confirmed", + "codex-session:primary-confirmed", + role="assistant", + text="durably visible", + content_hash="msg-primary-confirmed", + sort_key=1.0, + ) + ], + ) + monkeypatch.setattr( + ingest_batch_core, + "ingest_record", + lambda *_args, **_kwargs: IngestRecordResult(raw_id=raw_record.raw_id, sessions=[session]), + ) + register_configured_transport_factory(LocalReferenceTransport) + try: + summary = _process_ingest_batch_sync( + [raw_record], + db_path=archive_root / "index.db", + archive_root_str=str(archive_root), + blob_root_str=str(archive_root / "blob"), + validation_mode="advisory", + ingest_workers=1, + measure_ingest_result_size=False, + publication_mode=PublicationMode.PRIMARY, + ) + finally: + clear_configured_transport_factory() + + with sqlite3.connect(archive_root / "index.db") as index_conn: + assert index_conn.execute("SELECT COUNT(*) FROM sessions").fetchone() == (1,) + assert index_conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone() == (1,) + assert summary.publication_deferred_raw_ids == set() + + class _FakeConnectionBackend: def __init__(self, connection: Callable[[], AbstractAsyncContextManager[aiosqlite.Connection]]) -> None: self._connection = connection @@ -139,10 +295,10 @@ def connection(self) -> AbstractAsyncContextManager[aiosqlite.Connection]: class _FakeBulkBackend: - def __init__(self, connection: Callable[[], AbstractAsyncContextManager[object]]) -> None: + def __init__(self, connection: Callable[[], AbstractAsyncContextManager[None]]) -> None: self._connection = connection - def bulk_connection(self) -> AbstractAsyncContextManager[object]: + def bulk_connection(self) -> AbstractAsyncContextManager[None]: return self._connection() @@ -150,6 +306,10 @@ class _FakeRawStateRepository: def __init__(self, update_raw_state: AsyncMock) -> None: self._update_raw_state = update_raw_state + @property + def source_backend(self) -> None: + return None + async def update_raw_state(self, raw_id: str, *, state: RawSessionStateUpdate) -> object: return await self._update_raw_state(raw_id, state=state) @@ -1527,6 +1687,7 @@ def fake_process_sync( archive_root_str: str, blob_root_str: str, validation_mode: str, + publication_mode: str, ingest_workers: int | None, measure_ingest_result_size: bool, force_write: bool, @@ -1541,6 +1702,7 @@ def fake_process_sync( "archive_root_str": archive_root_str, "blob_root_str": blob_root_str, "validation_mode": validation_mode, + "publication_mode": publication_mode, "ingest_workers": ingest_workers, "measure_ingest_result_size": measure_ingest_result_size, "force_write": force_write, @@ -1567,6 +1729,7 @@ def fake_process_sync( assert seen["archive_root_str"] == str(archive_root) assert seen["blob_root_str"] == str(expected_blob_root) assert seen["blob_root_str"] != str(ambient_blob_root) + assert seen["publication_mode"] == "off" def test_iter_ingest_results_sync_bounds_in_flight_process_results( diff --git a/tests/unit/pipeline/test_ingest_batch_resource_bounds.py b/tests/unit/pipeline/test_ingest_batch_resource_bounds.py index de27043065..cfd001b956 100644 --- a/tests/unit/pipeline/test_ingest_batch_resource_bounds.py +++ b/tests/unit/pipeline/test_ingest_batch_resource_bounds.py @@ -12,6 +12,7 @@ from polylogue.core.enums import Provider from polylogue.pipeline.services.ingest_batch import _IngestWorkerRequest, _iter_ingest_results_sync from polylogue.pipeline.services.ingest_worker import IngestRecordResult, SessionWritePayload +from polylogue.sinex.models import PublicationMode from polylogue.sources.parsers.base import ParsedMessage, ParsedSession from polylogue.storage.runtime import RawSessionRecord @@ -128,6 +129,7 @@ def fake_drain(*args: object, **kwargs: object) -> None: worker_request=_worker_request(), summary=summary, # type: ignore[arg-type] materialized_ids=set(), + publication_mode=PublicationMode.OFF, ) assert transaction_started is True @@ -163,6 +165,7 @@ def execute(self, sql: str) -> object: worker_request=_worker_request(), summary=summary, # type: ignore[arg-type] materialized_ids=set(), + publication_mode=PublicationMode.OFF, ) assert transaction_started is True diff --git a/tests/unit/sinex/_fixtures.py b/tests/unit/sinex/_fixtures.py new file mode 100644 index 0000000000..039501eb42 --- /dev/null +++ b/tests/unit/sinex/_fixtures.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass + +from polylogue.sinex.models import PublicationPayload + + +def publication_payload( + object_id: str = "claude-code-session:s1", + revision_id: str = "rev-1", + marker: str = "one", +) -> PublicationPayload: + manifest = ( + json.dumps( + {"marker": marker, "object_id": object_id, "revision_id": revision_id}, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + b"\n" + ) + return PublicationPayload( + object_id=object_id, + protocol_version="polylogue.material-protocol/v1", + revision_id=revision_id, + manifest_digest=hashlib.sha256(manifest).hexdigest(), + manifest_bytes=manifest, + segments=( + ("head.ndjson", b'{"kind":"head"}\n'), + ("seg-00000.ndjson", marker.encode("utf-8")), + ), + ) + + +@dataclass +class MutableClock: + now_ms: int = 1_000 + + def __call__(self) -> int: + return self.now_ms + + def advance(self, milliseconds: int) -> None: + self.now_ms += milliseconds diff --git a/tests/unit/sinex/test_convergence.py b/tests/unit/sinex/test_convergence.py new file mode 100644 index 0000000000..e7b408a3b8 --- /dev/null +++ b/tests/unit/sinex/test_convergence.py @@ -0,0 +1,149 @@ +"""Per-subject primary barriers and mirror continuation in the real converger.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path + +import pytest + +from polylogue.daemon.convergence import ConvergenceStage, DaemonConverger, StageState +from polylogue.daemon.convergence_stages import make_sinex_publication_stage +from polylogue.sinex.models import PublicationMode, ReceiptState +from polylogue.sinex.service import PublicationService +from polylogue.sinex.transport import LocalReferenceTransport +from tests.unit.sinex._fixtures import publication_payload + + +def _projection_stage(calls: list[Path]) -> ConvergenceStage: + def execute(path: Path) -> bool: + calls.append(path) + return True + + return ConvergenceStage( + name="projection", + description="test projection", + check=lambda _path: True, + execute=execute, + ) + + +def _projection_stage_with_sessions( + path_calls: list[Path], + session_calls: list[str], +) -> ConvergenceStage: + stage = _projection_stage(path_calls) + + def execute_sessions(session_ids: Sequence[str]) -> bool: + session_calls.extend(session_ids) + return True + + return ConvergenceStage( + name=stage.name, + description=stage.description, + check=stage.check, + execute=stage.execute, + check_sessions=lambda session_ids: set(session_ids), + execute_sessions=execute_sessions, + ) + + +def test_primary_barrier_blocks_only_affected_path_and_mirror_does_not() -> None: + blocked = Path("blocked.json") + free = Path("free.json") + projected: list[Path] = [] + primary = ConvergenceStage( + name="sinex_publication", + description="primary publication", + check=lambda _path: False, + execute=lambda _path: True, + blocks_following_stages=True, + barrier_check=lambda path: path == blocked, + ) + states, _ = DaemonConverger((primary, _projection_stage(projected))).converge_batch((blocked, free)) + assert states[blocked].stages["projection"] is StageState.PENDING + assert states[free].stages["projection"] is StageState.DONE + assert projected == [free] + + projected.clear() + mirror = ConvergenceStage( + name="sinex_publication", + description="mirror publication", + check=lambda _path: False, + execute=lambda _path: True, + blocks_following_stages=False, + barrier_check=lambda _path: True, + ) + states, _ = DaemonConverger((mirror, _projection_stage(projected))).converge_batch((blocked, free)) + assert states[blocked].stages["projection"] is StageState.DONE + assert states[free].stages["projection"] is StageState.DONE + assert set(projected) == {blocked, free} + + +def test_stage_status_masks_probe_failure() -> None: + good = ConvergenceStage("good", "", lambda _p: False, lambda _p: True, status=lambda: {"lag": 2}) + + def bad_status() -> Mapping[str, object]: + raise ZeroDivisionError + + bad = ConvergenceStage("bad", "", lambda _p: False, lambda _p: True, status=bad_status) + assert DaemonConverger((good, bad)).stage_status() == { + "good": {"lag": 2}, + "bad": {"state": "unavailable"}, + } + + +def test_real_sinex_stage_blocks_affected_file_and_session_scopes( + workspace_env: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, +) -> None: + blocked_path = Path("blocked.json") + free_path = Path("free.json") + blocked_id = "claude-code-session:blocked" + free_id = "claude-code-session:free" + service = PublicationService( + workspace_env["archive_root"] / "source.db", + PublicationMode.PRIMARY, + LocalReferenceTransport(fault_fn=lambda _request, _attempt: ReceiptState.RAW_ACCEPTED), + ) + service.stage_payload(publication_payload(object_id=blocked_id)) + monkeypatch.setattr( + "polylogue.daemon.convergence_stages._sinex_session_ids_for_paths", + lambda _db, paths: {path: [blocked_id] if path == blocked_path else [free_id] for path in paths}, + ) + projected: list[Path] = [] + projected_sessions: list[str] = [] + stage = make_sinex_publication_stage(workspace_env["archive_root"] / "index.db", service) + converger = DaemonConverger((stage, _projection_stage_with_sessions(projected, projected_sessions))) + + blocked_state = converger.converge_file(blocked_path) + free_state = converger.converge_file(free_path) + session_states, _timings = converger.converge_sessions((blocked_id, free_id)) + + assert blocked_state.stages["sinex_publication"] is StageState.PENDING + assert blocked_state.stages["projection"] is StageState.PENDING + assert free_state.stages["projection"] is StageState.DONE + assert session_states[blocked_id].stages["projection"] is StageState.PENDING + assert session_states[free_id].stages["projection"] is StageState.DONE + assert projected == [free_path] + assert projected_sessions == [free_id] + + +def test_real_sinex_stage_treats_barrier_probe_failure_as_failure( + workspace_env: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, +) -> None: + subject = "claude-code-session:barrier-error" + service = PublicationService( + workspace_env["archive_root"] / "source.db", + PublicationMode.PRIMARY, + LocalReferenceTransport(), + ) + monkeypatch.setattr(service, "blocking_object_ids", lambda _ids: (_ for _ in ()).throw(RuntimeError("boom"))) + stage = make_sinex_publication_stage(workspace_env["archive_root"] / "index.db", service) + projected: list[Path] = [] + states, _timings = DaemonConverger((stage, _projection_stage(projected))).converge_sessions((subject,)) + + assert states[subject].stages["sinex_publication"] is StageState.FAILED + assert states[subject].stages["projection"] is StageState.PENDING + assert states[subject].last_error == "stage sinex_publication barrier check failed" diff --git a/tests/unit/sinex/test_ingest_atomicity.py b/tests/unit/sinex/test_ingest_atomicity.py new file mode 100644 index 0000000000..2c6b604fbc --- /dev/null +++ b/tests/unit/sinex/test_ingest_atomicity.py @@ -0,0 +1,159 @@ +"""Production ingest acceptance/outbox transaction wiring.""" + +from __future__ import annotations + +import asyncio +import dataclasses +import sqlite3 +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import pytest + +from polylogue.pipeline.services.ingest_batch._core import _persist_batch_raw_state_updates +from polylogue.sinex.material_adapter import PublicationEncodingError +from polylogue.sinex.models import PublicationMode +from polylogue.sinex.obligations import PublicationPayloadInvalidError +from tests.unit.sinex._fixtures import publication_payload + + +class _AsyncCursor: + def __init__(self, cursor: sqlite3.Cursor) -> None: + self._cursor = cursor + + async def fetchone(self) -> object | None: + return cast(object | None, self._cursor.fetchone()) + + async def fetchall(self) -> list[object]: + return cast(list[object], self._cursor.fetchall()) + + +class _AsyncConnection: + def __init__(self, conn: sqlite3.Connection) -> None: + self._conn = conn + + async def execute(self, sql: str, parameters: tuple[object, ...] = ()) -> _AsyncCursor: + return _AsyncCursor(self._conn.execute(sql, parameters)) + + +class _SourceBackend: + def __init__(self, path: Path) -> None: + self.path = path + self.active: sqlite3.Connection | None = None + + @asynccontextmanager + async def bulk_connection(self) -> AsyncIterator[None]: + conn = sqlite3.connect(self.path) + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("BEGIN IMMEDIATE") + self.active = conn + try: + yield None + except BaseException: + conn.rollback() + raise + else: + conn.commit() + finally: + self.active = None + conn.close() + + @asynccontextmanager + async def connection(self) -> AsyncIterator[_AsyncConnection]: + assert self.active is not None + yield _AsyncConnection(self.active) + + +class _Repository: + def __init__(self, backend: _SourceBackend) -> None: + self._source_backend = backend + + @property + def source_backend(self) -> _SourceBackend: + return self._source_backend + + async def update_raw_state(self, raw_id: str, *, state: object) -> None: + assert self._source_backend.active is not None + self._source_backend.active.execute( + "INSERT OR REPLACE INTO test_raw_acceptance(raw_id, accepted) VALUES (?, 1)", + (raw_id,), + ) + + +def _counts(path: Path) -> tuple[int, int]: + conn = sqlite3.connect(path) + try: + accepted_row = conn.execute("SELECT COUNT(*) FROM test_raw_acceptance").fetchone() + obligation_row = conn.execute("SELECT COUNT(*) FROM sinex_publication_obligations").fetchone() + assert accepted_row is not None + assert obligation_row is not None + return (int(accepted_row[0]), int(obligation_row[0])) + finally: + conn.close() + + +def test_real_raw_state_helper_commits_or_rolls_back_acceptance_with_payload( + workspace_env: dict[str, Path], +) -> None: + source_db = workspace_env["archive_root"] / "source.db" + conn = sqlite3.connect(source_db) + conn.execute("CREATE TABLE IF NOT EXISTS test_raw_acceptance(raw_id TEXT PRIMARY KEY, accepted INTEGER NOT NULL)") + conn.commit() + conn.close() + backend = _SourceBackend(source_db) + service = SimpleNamespace(repository=_Repository(backend)) + + invalid = dataclasses.replace(publication_payload(), manifest_digest="0" * 64) + with pytest.raises(PublicationPayloadInvalidError): + asyncio.run( + _persist_batch_raw_state_updates( + service, + backend, + outcomes={}, + succeeded_raw_ids={"raw-1"}, + skipped_raw_ids=set(), + failed_raw_ids={}, + validation_mode="advisory", + publication_mode=PublicationMode.PRIMARY, + publication_payloads_by_raw_id={"raw-1": [invalid]}, + ) + ) + assert _counts(source_db) == (0, 0) + + valid = publication_payload() + asyncio.run( + _persist_batch_raw_state_updates( + service, + backend, + outcomes={}, + succeeded_raw_ids={"raw-1"}, + skipped_raw_ids=set(), + failed_raw_ids={}, + validation_mode="advisory", + publication_mode=PublicationMode.PRIMARY, + publication_payloads_by_raw_id={"raw-1": [valid]}, + ) + ) + assert _counts(source_db) == (1, 1) + + +def test_backed_mode_refuses_acceptance_without_source_tier_backend() -> None: + service = SimpleNamespace(repository=SimpleNamespace(source_backend=None)) + backend = SimpleNamespace() + with pytest.raises(PublicationEncodingError, match="durable source-tier"): + asyncio.run( + _persist_batch_raw_state_updates( + service, + backend, + outcomes={}, + succeeded_raw_ids={"raw-1"}, + skipped_raw_ids=set(), + failed_raw_ids={}, + validation_mode="advisory", + publication_mode=PublicationMode.MIRROR, + publication_payloads_by_raw_id={"raw-1": [publication_payload()]}, + ) + ) diff --git a/tests/unit/sinex/test_material_adapter.py b/tests/unit/sinex/test_material_adapter.py index a4c00e8c7e..c62df1aff1 100644 --- a/tests/unit/sinex/test_material_adapter.py +++ b/tests/unit/sinex/test_material_adapter.py @@ -1,94 +1,165 @@ -"""session_material_from_session against the REAL v1 encoder. - -Anti-vacuity: this test feeds the adapter's output into -``polylogue.material_protocol.v1.encode_session_revision`` (the real, -already-shipped production encoder from polylogue-303r.1) and decodes the -result back. Breaking the adapter's field mapping (wrong role/text/native_id/ -origin) makes the round-trip assertions fail, not just a mock's call count. -""" +"""ParsedSession-to-material-v1 coverage and exact-byte reconciliation.""" from __future__ import annotations -from datetime import UTC, datetime +import json +from datetime import datetime import pytest -from polylogue.archive.message.messages import MessageCollection -from polylogue.archive.message.models import Message -from polylogue.archive.message.roles import Role -from polylogue.archive.session.domain_models import Session -from polylogue.core.enums import Origin -from polylogue.core.types import SessionId -from polylogue.material_protocol.v1 import decode_session_revision, encode_session_revision -from polylogue.sinex.material_adapter import session_material_from_session - - -def _real_session() -> Session: - messages = [ - Message( - id="claude-code-session:s1:0", - role=Role.USER, - text="hello there", - timestamp=datetime(2026, 1, 1, tzinfo=UTC), - ), - Message( - id="claude-code-session:s1:1", - role=Role.ASSISTANT, - text="hi!", - timestamp=datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC), - blocks=[ - {"type": "text", "text": "hi!"}, - {"type": "tool_use", "tool_name": "Bash", "tool_id": "tool-1", "tool_input": {"command": "ls"}}, - {"type": "not-a-real-block-type"}, # must be dropped, not raise - ], - ), - ] - return Session( - id=SessionId("claude-code-session:s1"), - origin=Origin.CLAUDE_CODE_SESSION, - title="Adapter fixture session", - messages=MessageCollection(messages=messages), - created_at=datetime(2026, 1, 1, tzinfo=UTC), - updated_at=datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC), - metadata={"nested": {"a": 1}, "flag": True}, - tags_m2m=("dogfood",), - ) +from polylogue.core.enums import ( + BlockType, + BranchType, + MaterialOrigin, + MessageType, + Provider, + Role, + SessionKind, +) +from polylogue.sinex.material_adapter import ( + PublicationBackpressureError, + encode_parsed_session_publication, + session_material_from_parsed_session, +) +from polylogue.sources.parsers.base import ( + ParsedAttachment, + ParsedContentBlock, + ParsedMessage, + ParsedSession, + ParsedSessionEvent, +) -def test_adapter_output_round_trips_through_the_real_v1_encoder() -> None: - material = session_material_from_session(_real_session()) +def _parsed_session() -> ParsedSession: + first = ParsedMessage( + position=0, + provider_message_id="m1", + role=Role.USER, + text="hello", + message_type=MessageType.MESSAGE, + material_origin=MaterialOrigin.HUMAN_AUTHORED, + occurred_at_ms=1_000, + model_name="gpt-x", + input_tokens=3, + output_tokens=0, + cache_read_tokens=1, + cache_write_tokens=0, + duration_ms=5, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="hello", metadata={"provider_only": "gap"})], + delivery_status="sent", # explicit v1 fidelity gap + ) + second = ParsedMessage( + position=1, + provider_message_id="m2", + parent_message_provider_id="m1", + role=Role.ASSISTANT, + text="world", + message_type=MessageType.MESSAGE, + material_origin=MaterialOrigin.ASSISTANT_AUTHORED, + occurred_at_ms=2_000, + model_name="gpt-x", + input_tokens=0, + output_tokens=4, + cache_read_tokens=0, + cache_write_tokens=0, + blocks=[ + ParsedContentBlock( + type=BlockType.TOOL_USE, + tool_name="Shell", + tool_id="t1", + tool_input={"cmd": "pwd"}, + ), + ], + ) + attachment = ParsedAttachment( + provider_attachment_id="a1", + message_provider_id="m2", + name="out.txt", + mime_type="text/plain", + size_bytes=3, + path="provider-only.txt", + ) + event = ParsedSessionEvent( + event_type="checkpoint", + payload={"n": 1, "summary": "checkpoint saved"}, + source_message_provider_id="m2", + timestamp="1970-01-01T00:00:02.500Z", + ) + return ParsedSession( + source_name=Provider.CLAUDE_CODE, + provider_session_id="s1", + messages=[first, second], + attachments=[attachment], + title="Fixture", + session_kind=SessionKind.STANDARD, + created_at="1970-01-01T00:00:01Z", + updated_at="1970-01-01T00:00:03Z", + git_branch="main", + git_repository_url="https://example.invalid/repo", + provider_project_ref="p", + working_directories=["/repo"], + ingest_flags=["fixture"], + parent_session_provider_id="parent", + branch_type=BranchType.FORK, + reported_cost_usd=0.01, + session_events=[event], + ) - assert material.origin is Origin.CLAUDE_CODE_SESSION - assert material.native_id == "s1" + +def test_adapter_covers_every_available_material_unit_and_names_gaps() -> None: + material = session_material_from_parsed_session(_parsed_session(), session_id="claude-code-session:s1") assert len(material.messages) == 2 - assert material.messages[0].text == "hello there" - assert material.messages[1].blocks[0].block_type.value == "text" - assert material.messages[1].blocks[1].tool_name == "Bash" - # The invalid block type was dropped, not raised or mis-mapped -- but the - # drop itself must be a declared fidelity gap, not silent (polylogue-ihwv). - assert len(material.messages[1].blocks) == 2 - assert len(material.fidelity_gaps) == 2 - assert material.fidelity_gaps[0].gap_kind == "omitted_relation" - dropped_block_gap = material.fidelity_gaps[1] - assert dropped_block_gap.gap_kind == "dropped_block" - assert dropped_block_gap.scope == "block" - assert dropped_block_gap.record_id == "claude-code-session:s1:message[1]:block[2]" - - encoded = encode_session_revision(material, revision_created_at="2026-01-01T00:00:02+00:00") - assert encoded.manifest.session_id == "claude-code-session:s1" - assert encoded.manifest.origin == "claude-code-session" - assert encoded.manifest.native_id == "s1" - - decoded = decode_session_revision(encoded.manifest, encoded.segments) - assert decoded.session["session_id"] == "claude-code-session:s1" - assert [m.text for m in decoded.messages] == ["hello there", "hi!"] - - -def test_adapter_rejects_a_malformed_session_id() -> None: - session = Session( - id=SessionId("not-well-formed"), - origin=Origin.CLAUDE_CODE_SESSION, - messages=MessageCollection(messages=[]), - ) - with pytest.raises(ValueError, match="well-formed"): - session_material_from_session(session) + assert sum(len(message.blocks) for message in material.messages) == 2 + assert sum(len(message.attachments) for message in material.messages) == 1 + assert len(material.lineage) == 1 + assert len(material.usage) == 1 + assert len(material.session_events) == 1 + assert {gap.gap_kind for gap in material.fidelity_gaps} == {"unsupported_normalized_fields"} + + +def test_production_adapter_runs_real_encoder_verifier_decoder_and_preserves_wire_names() -> None: + payload = encode_parsed_session_publication(_parsed_session(), session_id="claude-code-session:s1") + manifest = json.loads(payload.manifest_bytes) + assert payload.protocol_version == "polylogue.material-protocol/v1" + assert payload.revision_id == manifest["revision_id"] + assert payload.object_id == manifest["session_id"] + assert payload.manifest_digest + assert [name for name, _data in payload.segments][0] == "head.ndjson" + assert manifest["expected_record_counts"]["message"] == 2 + assert manifest["expected_record_counts"]["attachment"] == 1 + assert manifest["expected_record_counts"]["lineage"] == 1 + assert manifest["expected_record_counts"]["usage"] == 1 + assert manifest["expected_record_counts"]["session_event"] == 1 + + +def test_naive_timestamps_are_utc_and_unordered_metadata_is_deterministic() -> None: + parsed = _parsed_session() + parsed.created_at = datetime(2026, 7, 16, 3, 0, 0).isoformat() + parsed.messages[0].occurred_at_ms = 0 + parsed.messages[0].blocks[0].metadata = {"unordered": {"z", "a"}} + material = session_material_from_parsed_session(parsed, session_id="claude-code-session:s1") + first = encode_parsed_session_publication(parsed, session_id="claude-code-session:s1") + second = encode_parsed_session_publication(parsed, session_id="claude-code-session:s1") + assert material.messages[0].occurred_at_ms == 0 + assert first.manifest_bytes == second.manifest_bytes + assert first.segments == second.segments + + +def test_payload_budget_rejects_before_protocol_encoder(monkeypatch: pytest.MonkeyPatch) -> None: + parsed = _parsed_session() + parsed.messages[0].text = "x" * 1_024 + called = False + + def unexpected_encoder(*args: object, **kwargs: object) -> object: + nonlocal called + called = True + raise AssertionError("protocol encoder must not allocate an over-budget payload") + + monkeypatch.setattr("polylogue.sinex.material_adapter.encode_session_revision", unexpected_encoder) + with pytest.raises(PublicationBackpressureError): + encode_parsed_session_publication( + parsed, + session_id="claude-code-session:s1", + max_payload_bytes=128, + ) + assert not called diff --git a/tests/unit/sinex/test_models.py b/tests/unit/sinex/test_models.py index f1071a5e71..dee329ae4c 100644 --- a/tests/unit/sinex/test_models.py +++ b/tests/unit/sinex/test_models.py @@ -1,4 +1,4 @@ -"""ReceiptState/PublicationObligation vocabulary invariants.""" +"""Receipt and idempotency vocabulary invariants.""" from __future__ import annotations @@ -8,32 +8,17 @@ ObligationStatus, PublicationMode, PublicationObligation, + PublicationStatus, ReceiptState, ) -def test_only_documented_states_unlock_progress() -> None: - """Mirrors sinex-r6d.11: PersistedConfirmed/DurableDebt/SpoolAcceptedLossless - - unlock progress; a bare RawAccepted or an explicit Rejected must not -- - that is the exact bug class (mpsc/NATS-publish acceptance mistaken for a - durable commit) r6d.11 exists to close off. - """ - unlocking = {ReceiptState.PERSISTED_CONFIRMED, ReceiptState.DURABLE_DEBT, ReceiptState.SPOOL_ACCEPTED_LOSSLESS} - for state in ReceiptState: - assert state.unlocks_progress() == (state in unlocking), state - - -def test_request_id_is_deterministic_and_distinguishes_every_key_component() -> None: - """The transport idempotency key must change if ANY of the 4 identity - components changes -- otherwise two distinct revisions could collide on - the same request_id and a real transport would treat them as duplicates. - """ - base = PublicationObligation( - object_id="claude-code-session:abc", +def _obligation() -> PublicationObligation: + return PublicationObligation( + object_id="claude-code-session:s1", protocol_version="polylogue.material-protocol/v1", revision_id="rev-1", - manifest_digest="digest-1", + manifest_digest="a" * 64, mode=PublicationMode.MIRROR, status=ObligationStatus.PENDING, attempt_count=0, @@ -43,17 +28,61 @@ def test_request_id_is_deterministic_and_distinguishes_every_key_component() -> created_at_ms=1, updated_at_ms=1, retired_at_ms=None, + next_attempt_at_ms=1, ) - same = dataclasses.replace(base) - assert base.request_id == same.request_id + +def test_only_documented_durable_receipts_unlock_primary_progress() -> None: + allowed = { + ReceiptState.PERSISTED_CONFIRMED, + ReceiptState.DURABLE_DEBT, + ReceiptState.SPOOL_ACCEPTED_LOSSLESS, + } + assert {state for state in ReceiptState if state.unlocks_progress()} == allowed + + +def test_request_id_is_stable_and_uses_every_identity_component() -> None: + base = _obligation() + assert dataclasses.replace(base).request_id == base.request_id variants = ( - dataclasses.replace(base, object_id="codex-session:xyz"), + dataclasses.replace(base, object_id="codex-session:s1"), dataclasses.replace(base, protocol_version="polylogue.material-protocol/v2"), dataclasses.replace(base, revision_id="rev-2"), - dataclasses.replace(base, manifest_digest="digest-2"), + dataclasses.replace(base, manifest_digest="b" * 64), + ) + assert len({base.request_id, *(item.request_id for item in variants)}) == 5 + + +def test_request_id_frames_identity_components_instead_of_delimiter_joining() -> None: + base = _obligation() + left = dataclasses.replace(base, object_id="a|b", protocol_version="c") + right = dataclasses.replace(base, object_id="a", protocol_version="b|c") + assert left.request_id != right.request_id + + +def test_status_serialization_contains_only_bounded_operator_fields() -> None: + status = PublicationStatus( + mode=PublicationMode.PRIMARY, + total=3, + pending=1, + confirmed=1, + durable_debt=1, + blocking=1, + last_receipt_state=ReceiptState.DURABLE_DEBT, + last_error_code="transport_timeout", ) - request_ids = {base.request_id} - for mutated in variants: - assert mutated.request_id not in request_ids, mutated - request_ids.add(mutated.request_id) + assert status.as_dict() == { + "mode": "primary", + "total": 3, + "pending": 1, + "publishing": 0, + "confirmed": 1, + "durable_debt": 1, + "rejected": 0, + "retry_due": 0, + "blocking": 1, + "active_lag": 0, + "oldest_active_age_ms": None, + "last_receipt_state": "durable_debt", + "last_error_code": "transport_timeout", + } diff --git a/tests/unit/sinex/test_obligations.py b/tests/unit/sinex/test_obligations.py index 3abf9168ea..464124beb5 100644 --- a/tests/unit/sinex/test_obligations.py +++ b/tests/unit/sinex/test_obligations.py @@ -1,9 +1,4 @@ -"""Durable CRUD contract for sinex_publication_obligations. - -Uses a real source.db bootstrapped by ArchiveStore (via ``workspace_env``), -not a synthetic in-memory schema -- these tests fail if the migration/DDL -drifts from what ``obligations.py`` actually reads/writes. -""" +"""Real-source.db exact outbox, transaction, and migration contracts.""" from __future__ import annotations @@ -12,231 +7,141 @@ import pytest -from polylogue.sinex.models import ObligationStatus, PublicationMode, ReceiptState +from polylogue.sinex.models import ( + ObligationStatus, + PublicationMode, + PublicationReceipt, + ReceiptState, +) from polylogue.sinex.obligations import ( - get_obligation, + PublicationPayloadConflictError, + PublicationPayloadInvalidError, list_obligations, + load_payload, mark_attempt, - mark_publishing, - record_obligation, + stage_payload, ) +from tests.unit.sinex._fixtures import publication_payload -def _conn(source_db_path: Path) -> sqlite3.Connection: - conn = sqlite3.connect(source_db_path) +def _conn(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") return conn -def test_record_obligation_is_idempotent_by_the_four_part_key(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - conn = _conn(source_db) +def test_acceptance_marker_and_exact_payload_share_commit_or_rollback( + workspace_env: dict[str, Path], +) -> None: + conn = _conn(workspace_env["archive_root"] / "source.db") + conn.execute("CREATE TABLE IF NOT EXISTS test_raw_acceptance(raw_id TEXT PRIMARY KEY)") + payload = publication_payload() try: - first = record_obligation( - conn, - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - mode=PublicationMode.MIRROR, - now_ms=1000, - ) - second = record_obligation( - conn, - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - mode=PublicationMode.MIRROR, - now_ms=9999, # a later retry must NOT overwrite created_at_ms - ) - conn.commit() - assert first == second - assert first.created_at_ms == 1000 - assert first.status is ObligationStatus.PENDING - assert first.attempt_count == 0 - rows = conn.execute("SELECT COUNT(*) FROM sinex_publication_obligations").fetchone()[0] - assert rows == 1 - - # A different revision_id is a genuinely new obligation, not merged - # with the first -- proves the idempotency key is the full 4-tuple, - # not just object_id. - record_obligation( - conn, - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-2", - manifest_digest="digest-2", - mode=PublicationMode.MIRROR, - now_ms=2000, - ) + conn.execute("BEGIN IMMEDIATE") + conn.execute("INSERT INTO test_raw_acceptance VALUES ('rollback')") + stage_payload(conn, payload=payload, mode=PublicationMode.MIRROR, now_ms=1_000) + conn.rollback() + assert conn.execute("SELECT COUNT(*) FROM test_raw_acceptance").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM sinex_publication_obligations").fetchone()[0] == 0 + + conn.execute("BEGIN IMMEDIATE") + conn.execute("INSERT INTO test_raw_acceptance VALUES ('commit')") + stage_payload(conn, payload=payload, mode=PublicationMode.MIRROR, now_ms=1_001) conn.commit() - rows = conn.execute("SELECT COUNT(*) FROM sinex_publication_obligations").fetchone()[0] - assert rows == 2 + assert conn.execute("SELECT COUNT(*) FROM test_raw_acceptance").fetchone()[0] == 1 + assert load_payload(conn, list_obligations(conn)[0]) == payload finally: conn.close() -def test_record_obligation_rejects_off_mode(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - conn = _conn(source_db) +def test_duplicate_is_idempotent_mode_only_elevates_and_changed_revision_is_history( + workspace_env: dict[str, Path], +) -> None: + conn = _conn(workspace_env["archive_root"] / "source.db") + first = publication_payload() + second = publication_payload(revision_id="rev-2", marker="two") try: - with pytest.raises(ValueError, match="off mode"): - record_obligation( - conn, - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - mode=PublicationMode.OFF, - now_ms=1000, - ) + conn.execute("BEGIN IMMEDIATE") + stage_payload(conn, payload=first, mode=PublicationMode.MIRROR, now_ms=1_000) + stage_payload(conn, payload=first, mode=PublicationMode.PRIMARY, now_ms=2_000) + stage_payload(conn, payload=first, mode=PublicationMode.MIRROR, now_ms=3_000) + stage_payload(conn, payload=second, mode=PublicationMode.PRIMARY, now_ms=4_000) + conn.commit() + rows = list_obligations(conn) + assert len(rows) == 2 + assert rows[0].mode is PublicationMode.PRIMARY + assert conn.execute("SELECT COUNT(*) FROM sinex_publication_payloads").fetchone()[0] == 2 + assert conn.execute("SELECT COUNT(*) FROM sinex_publication_segments").fetchone()[0] == 4 finally: conn.close() -def test_mark_attempt_increments_count_and_sets_retired_only_on_terminal_status( +def test_exact_byte_corruption_and_same_key_collision_are_detected( workspace_env: dict[str, Path], ) -> None: - source_db = workspace_env["archive_root"] / "source.db" - conn = _conn(source_db) + conn = _conn(workspace_env["archive_root"] / "source.db") + payload = publication_payload() try: - obligation = record_obligation( - conn, - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - mode=PublicationMode.PRIMARY, - now_ms=1000, - ) + conn.execute("BEGIN IMMEDIATE") + obligation = stage_payload(conn, payload=payload, mode=PublicationMode.MIRROR, now_ms=1_000) conn.commit() - - after_pending = mark_attempt( - conn, - obligation, - status=ObligationStatus.PENDING, - receipt_state=ReceiptState.RAW_ACCEPTED, - error=None, - now_ms=2000, + conn.execute( + "UPDATE sinex_publication_segments SET segment_bytes=X'00' WHERE object_id=? AND position=0", + (payload.object_id,), ) conn.commit() - assert after_pending.attempt_count == 1 - assert after_pending.retired_at_ms is None - assert after_pending.status is ObligationStatus.PENDING - - after_confirmed = mark_attempt( - conn, - after_pending, - status=ObligationStatus.CONFIRMED, - receipt_state=ReceiptState.PERSISTED_CONFIRMED, - error=None, - now_ms=3000, - ) - conn.commit() - assert after_confirmed.attempt_count == 2 - assert after_confirmed.retired_at_ms == 3000 - assert after_confirmed.status is ObligationStatus.CONFIRMED - assert after_confirmed.last_receipt_state is ReceiptState.PERSISTED_CONFIRMED + with pytest.raises(PublicationPayloadInvalidError): + load_payload(conn, obligation) + conn.execute("BEGIN IMMEDIATE") + with pytest.raises(PublicationPayloadConflictError): + stage_payload(conn, payload=payload, mode=PublicationMode.MIRROR, now_ms=2_000) + conn.rollback() finally: conn.close() -def test_mark_publishing_transitions_status_without_incrementing_attempt_count( +def test_attempt_receipt_history_and_retry_schedule_are_durable( workspace_env: dict[str, Path], ) -> None: - """The pre-attempt marker (polylogue-ihwv) is distinct from mark_attempt. - - It durably records "a transport call is in flight" so a crash between - this write and the attempt's resolution leaves the row at ``publishing`` - -- observably different from a still-untried ``pending`` row -- but it - must not touch attempt_count/receipt/error, which describe an outcome - this call precedes and does not yet know. - """ - source_db = workspace_env["archive_root"] / "source.db" - conn = _conn(source_db) + conn = _conn(workspace_env["archive_root"] / "source.db") + payload = publication_payload() try: - obligation = record_obligation( - conn, - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - mode=PublicationMode.PRIMARY, - now_ms=1000, - ) - conn.commit() - - publishing = mark_publishing(conn, obligation, now_ms=1500) - conn.commit() - assert publishing.status is ObligationStatus.PUBLISHING - assert publishing.attempt_count == 0 - assert publishing.retired_at_ms is None - assert publishing.updated_at_ms == 1500 - - after_confirmed = mark_attempt( + conn.execute("BEGIN IMMEDIATE") + obligation = stage_payload(conn, payload=payload, mode=PublicationMode.PRIMARY, now_ms=1_000) + updated = mark_attempt( conn, - publishing, - status=ObligationStatus.CONFIRMED, - receipt_state=ReceiptState.PERSISTED_CONFIRMED, - error=None, - now_ms=2000, + obligation, + status=ObligationStatus.DURABLE_DEBT, + receipt=PublicationReceipt(obligation.request_id, ReceiptState.DURABLE_DEBT, "spooled"), + error_code=None, + now_ms=2_000, + next_attempt_at_ms=5_000, ) conn.commit() - assert after_confirmed.attempt_count == 1 - assert after_confirmed.status is ObligationStatus.CONFIRMED + assert updated.attempt_count == 1 + assert updated.next_attempt_at_ms == 5_000 + receipt = conn.execute( + "SELECT request_id, receipt_state, receipt_detail FROM sinex_publication_receipts" + ).fetchone() + assert tuple(receipt) == (obligation.request_id, "durable_debt", "spooled") finally: conn.close() -def test_list_obligations_filters_by_status_and_object(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - conn = _conn(source_db) +def test_source_schema_v12_contains_outbox_payload_receipt_tables( + workspace_env: dict[str, Path], +) -> None: + conn = _conn(workspace_env["archive_root"] / "source.db") try: - pending = record_obligation( - conn, - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - mode=PublicationMode.MIRROR, - now_ms=1000, - ) - confirmed = record_obligation( - conn, - object_id="claude-code-session:s2", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - mode=PublicationMode.MIRROR, - now_ms=1000, - ) - mark_attempt( - conn, - confirmed, - status=ObligationStatus.CONFIRMED, - receipt_state=ReceiptState.PERSISTED_CONFIRMED, - error=None, - now_ms=2000, - ) - conn.commit() - - only_pending = list_obligations(conn, statuses=(ObligationStatus.PENDING,)) - assert [o.object_id for o in only_pending] == [pending.object_id] - - only_s2 = list_obligations(conn, object_id="claude-code-session:s2") - assert len(only_s2) == 1 - assert only_s2[0].status is ObligationStatus.CONFIRMED - - assert ( - get_obligation( - conn, - object_id="claude-code-session:does-not-exist", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - ) - is None - ) + names = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + assert { + "sinex_publication_obligations", + "sinex_publication_payloads", + "sinex_publication_segments", + "sinex_publication_receipts", + } <= names + columns = {row[1] for row in conn.execute("PRAGMA table_info(sinex_publication_obligations)")} + assert "next_attempt_at_ms" in columns finally: conn.close() diff --git a/tests/unit/sinex/test_service.py b/tests/unit/sinex/test_service.py index b92cbd7190..7887e33ba6 100644 --- a/tests/unit/sinex/test_service.py +++ b/tests/unit/sinex/test_service.py @@ -1,311 +1,179 @@ -"""PublicationService: mode gating, durability, and receipt-barrier semantics. - -These tests exercise the real ``sinex_publication_obligations`` table via a -real source.db (``workspace_env``) and the real ``LocalReferenceTransport`` -contract double -- only network I/O is out of scope here, per the package -docstring's documented cross-repo blocker. -""" +"""Production publication-service retry, restart, gating, and status behavior.""" from __future__ import annotations +import asyncio +import json import sqlite3 +from collections.abc import Mapping from pathlib import Path -import pytest - -from polylogue.sinex.models import ObligationStatus, PublicationMode, ReceiptState +from polylogue.sinex.models import PublicationMode, PublicationReceipt, ReceiptState from polylogue.sinex.service import PublicationService from polylogue.sinex.transport import LocalReferenceTransport +from tests.unit.sinex._fixtures import MutableClock, publication_payload -def _obligation_table_rows(source_db: Path) -> int: - conn = sqlite3.connect(source_db) - try: - return int(conn.execute("SELECT COUNT(*) FROM sinex_publication_obligations").fetchone()[0]) - finally: - conn.close() - - -def test_off_mode_creates_no_obligation_and_requires_no_transport(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - service = PublicationService(source_db_path=source_db, mode=PublicationMode.OFF, transport=None) - - obligation = service.stage( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - ) - - assert obligation is None - assert _obligation_table_rows(source_db) == 0 - +class LostReceiptTransport: + """Persist remotely, then lose the first response before local receipt commit.""" -def test_non_off_mode_requires_a_transport(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - with pytest.raises(ValueError, match="requires a transport"): - PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=None) + def __init__(self) -> None: + self.remote = LocalReferenceTransport() + self.lose_first = True + async def publish_revision(self, **kwargs: object) -> PublicationReceipt: + receipt = await self.remote.publish_revision(**kwargs) # type: ignore[arg-type] + if self.lose_first: + self.lose_first = False + raise ConnectionError("authorization=Bearer never-persist-me") + return receipt -async def test_mirror_mode_stages_obligation_in_caller_supplied_transaction(workspace_env: dict[str, Path]) -> None: - """The obligation must be visible in the SAME transaction the caller - controls (design: "the same durable source-tier transaction that records - the acquired/normalized revision"), and rolling that transaction back - must roll the obligation back too -- an obligation orphaned from its - revision commit is worse than no obligation. - """ - source_db = workspace_env["archive_root"] / "source.db" - transport = LocalReferenceTransport() - service = PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=transport) - - conn = sqlite3.connect(source_db, timeout=30.0) - try: - conn.execute("BEGIN IMMEDIATE") - obligation = service.stage( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - conn=conn, +class UnsafeDetailTransport: + async def publish_revision( + self, *, request_id: str, manifest_bytes: bytes, segment_bytes: Mapping[str, bytes] + ) -> PublicationReceipt: + return PublicationReceipt( + request_id=request_id, + state=ReceiptState.RAW_ACCEPTED, + detail="authorization=Bearer bearer-secret, password: my secret phrase; token=top-secret endpoint=local", ) - assert obligation is not None - # Visible inside the same uncommitted transaction. - assert conn.execute("SELECT COUNT(*) FROM sinex_publication_obligations").fetchone()[0] == 1 - conn.rollback() - finally: - conn.close() - - assert _obligation_table_rows(source_db) == 0 -async def test_primary_mode_advances_projection_only_on_confirmed_receipt(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - transport = LocalReferenceTransport() - service = PublicationService(source_db_path=source_db, mode=PublicationMode.PRIMARY, transport=transport) - confirmed_calls: list[str] = [] - - obligation = await service.publish( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - manifest_bytes=b"{}", - segment_bytes={"head": b"{}"}, - on_confirmed=lambda o: confirmed_calls.append(o.object_id), - ) - +def test_lost_receipt_restart_reuses_request_id_without_duplicate_remote_record( + workspace_env: dict[str, Path], +) -> None: + db = workspace_env["archive_root"] / "source.db" + clock = MutableClock(10_000) + transport = LostReceiptTransport() + first_process = PublicationService(db, PublicationMode.PRIMARY, transport, clock=clock, base_retry_ms=10) + obligation = first_process.stage_payload(publication_payload(object_id="claude-code-session:crash")) assert obligation is not None - assert obligation.status is ObligationStatus.CONFIRMED - assert confirmed_calls == ["claude-code-session:s1"] - assert service.lag() == 0 + first = first_process.drain_once() + assert first.transport_failures == 1 + assert first_process.projection_blocked([obligation.object_id]) + assert transport.remote.call_count(obligation.request_id) == 1 + clock.advance(100) + restarted = PublicationService(db, PublicationMode.PRIMARY, transport, clock=clock, base_retry_ms=10) + assert restarted.drain_once().confirmed == 1 + assert not restarted.projection_blocked([obligation.object_id]) + assert transport.remote.call_count(obligation.request_id) == 1 -async def test_primary_mode_does_not_advance_projection_on_raw_accepted(workspace_env: dict[str, Path]) -> None: - """A bare RAW_ACCEPTED (in-memory accept, not a durable Sinex receipt) - must leave the obligation pending and must NOT fire on_confirmed -- this - is the exact failure mode r6d.11 exists to prevent (mpsc/NATS-publish - acceptance mistaken for a durable commit). - """ - source_db = workspace_env["archive_root"] / "source.db" - transport = LocalReferenceTransport(fault_fn=lambda _rid, _attempt: ReceiptState.RAW_ACCEPTED) - service = PublicationService(source_db_path=source_db, mode=PublicationMode.PRIMARY, transport=transport) - confirmed_calls: list[str] = [] +def test_retry_debt_rejection_and_mode_specific_gating(workspace_env: dict[str, Path]) -> None: + db = workspace_env["archive_root"] / "source.db" + clock = MutableClock(20_000) - obligation = await service.publish( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - manifest_bytes=b"{}", - segment_bytes={}, - on_confirmed=lambda o: confirmed_calls.append(o.object_id), + raw_transport = LocalReferenceTransport( + fault_fn=lambda _request_id, attempt: ReceiptState.RAW_ACCEPTED if attempt == 1 else None ) - - assert obligation is not None - assert obligation.status is ObligationStatus.PENDING - assert confirmed_calls == [] - assert service.lag() == 1 - - -async def test_rejected_receipt_marks_obligation_rejected_and_never_confirms(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - transport = LocalReferenceTransport(fault_fn=lambda _rid, _attempt: ReceiptState.REJECTED) - service = PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=transport) - confirmed_calls: list[str] = [] - - obligation = await service.publish( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - manifest_bytes=b"{}", - segment_bytes={}, - on_confirmed=lambda o: confirmed_calls.append(o.object_id), + raw_service = PublicationService(db, PublicationMode.PRIMARY, raw_transport, clock=clock, base_retry_ms=10) + raw_service.stage_payload(publication_payload(object_id="claude-code-session:raw")) + assert raw_service.drain_once(object_ids=["claude-code-session:raw"]).deferred == 1 + assert raw_service.projection_blocked(["claude-code-session:raw"]) + clock.advance(100) + assert raw_service.drain_once(object_ids=["claude-code-session:raw"]).confirmed == 1 + + debt = PublicationService( + db, + PublicationMode.PRIMARY, + LocalReferenceTransport(fault_fn=lambda _r, _n: ReceiptState.DURABLE_DEBT), + clock=clock, ) - - assert obligation is not None - assert obligation.status is ObligationStatus.REJECTED - assert confirmed_calls == [] - # rejected is terminal: not in the retryable "lag" set. - assert service.lag() == 0 - - -async def test_durable_debt_unlocks_progress_but_is_distinct_from_confirmed(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - transport = LocalReferenceTransport(fault_fn=lambda _rid, _attempt: ReceiptState.DURABLE_DEBT) - service = PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=transport) - confirmed_calls: list[str] = [] - - obligation = await service.publish( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - manifest_bytes=b"{}", - segment_bytes={}, - on_confirmed=lambda o: confirmed_calls.append(o.object_id), + debt.stage_payload(publication_payload(object_id="claude-code-session:debt")) + assert debt.drain_once(object_ids=["claude-code-session:debt"]).durable_debt == 1 + assert not debt.projection_blocked(["claude-code-session:debt"]) + assert debt.lag(object_ids=["claude-code-session:debt"]) == 1 + + rejected = PublicationService( + db, + PublicationMode.PRIMARY, + LocalReferenceTransport(fault_fn=lambda _r, _n: ReceiptState.REJECTED), + clock=clock, ) - - assert obligation is not None - assert obligation.status is ObligationStatus.DURABLE_DEBT - # DurableDebt IS a documented unlocking outcome (r6d.11) -- on_confirmed - # fires -- but the persisted status stays distinguishable from a clean - # PersistedConfirmed so a mirror-mode operator can see the exact lag. - assert confirmed_calls == ["claude-code-session:s1"] + rejected.stage_payload(publication_payload(object_id="claude-code-session:rejected")) + rejected.drain_once(object_ids=["claude-code-session:rejected"]) + assert rejected.projection_blocked(["claude-code-session:rejected"]) + + mirror = PublicationService( + db, + PublicationMode.MIRROR, + LocalReferenceTransport(fault_fn=lambda _r, _n: ReceiptState.REJECTED), + clock=clock, + ) + mirror.stage_payload(publication_payload(object_id="claude-code-session:mirror")) + mirror.drain_once(object_ids=["claude-code-session:mirror"]) + assert not mirror.projection_blocked(["claude-code-session:mirror"]) + assert mirror.status().blocking == 0 + assert mirror.lag(object_ids=["claude-code-session:mirror"]) == 1 -async def test_retry_pending_eventually_confirms_and_reports_zero_remaining_lag( +def test_corrupt_payload_is_retry_debt_and_does_not_abort_bounded_batch( workspace_env: dict[str, Path], ) -> None: - source_db = workspace_env["archive_root"] / "source.db" - transport = LocalReferenceTransport( - fault_fn=lambda _rid, attempt_number: ReceiptState.RAW_ACCEPTED if attempt_number == 1 else None + db = workspace_env["archive_root"] / "source.db" + clock = MutableClock(30_000) + service = PublicationService(db, PublicationMode.MIRROR, LocalReferenceTransport(), clock=clock, max_batch=2) + service.stage_payload(publication_payload(object_id="claude-code-session:bad")) + service.stage_payload(publication_payload(object_id="claude-code-session:good")) + conn = sqlite3.connect(db) + conn.execute( + "UPDATE sinex_publication_segments SET segment_bytes=X'FF' WHERE object_id=?", + ("claude-code-session:bad",), ) - service = PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=transport) + conn.commit() + conn.close() - first_attempt = await service.publish( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - manifest_bytes=b"{}", - segment_bytes={}, - ) - assert first_attempt is not None - assert first_attempt.status is ObligationStatus.PENDING - assert service.lag() == 1 - - summary = await service.retry_pending([(first_attempt, b"{}", {})]) - - assert summary.attempted == 1 + summary = service.drain_once(object_ids=["claude-code-session:bad", "claude-code-session:good"], limit=999) + assert summary.attempted == 2 assert summary.confirmed == 1 - assert summary.remaining_lag == 0 - assert service.lag() == 0 - assert transport.call_count("claude-code-session:s1|polylogue.material-protocol/v1|rev-1|digest-1") == 2 - - -async def test_retry_pending_is_a_true_no_op_in_off_mode(workspace_env: dict[str, Path]) -> None: - source_db = workspace_env["archive_root"] / "source.db" - service = PublicationService(source_db_path=source_db, mode=PublicationMode.OFF, transport=None) - - summary = await service.retry_pending([]) + assert summary.payload_failures == 1 + assert summary.transport_failures == 0 - assert summary.attempted == 0 - assert summary.confirmed == 0 - -def test_obligation_survives_a_process_restart_between_commit_and_transport_attempt( - workspace_env: dict[str, Path], +def test_status_redacts_receipt_details_and_off_mode_is_zero_work( + workspace_env: dict[str, Path], tmp_path: Path ) -> None: - """Killpoint: a crash after the durable local commit but before the - - transport attempt runs must not lose the obligation. Simulated here by - staging with one ``PublicationService``/connection (committed and - closed, standing in for "process A commits, then dies"), then reading it - back with a brand-new ``PublicationService`` instance that never saw the - first one in memory -- the only thing connecting them is the durable - source.db row. - """ - source_db = workspace_env["archive_root"] / "source.db" - transport_a = LocalReferenceTransport() - service_a = PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=transport_a) - staged = service_a.stage( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", + db = workspace_env["archive_root"] / "source.db" + clock = MutableClock(40_000) + service = PublicationService(db, PublicationMode.MIRROR, UnsafeDetailTransport(), clock=clock) + service.stage_payload(publication_payload(object_id="claude-code-session:secret")) + service.drain_once(object_ids=["claude-code-session:secret"]) + conn = sqlite3.connect(db) + detail = conn.execute( + "SELECT receipt_detail FROM sinex_publication_receipts WHERE object_id=?", + ("claude-code-session:secret",), + ).fetchone()[0] + conn.close() + assert "top-secret" not in detail + assert "bearer-secret" not in detail + assert "my secret phrase" not in detail + assert "" in detail + assert "top-secret" not in json.dumps(service.status().as_dict()) + assert "my secret phrase" not in json.dumps(service.status().as_dict()) + + nonexistent = tmp_path / "off-does-not-exist.db" + off = PublicationService(nonexistent, PublicationMode.OFF) + assert off.stage_payload(publication_payload(object_id="claude-code-session:off")) is None + assert off.drain_once().attempted == 0 + assert off.status().total == 0 + assert not nonexistent.exists() + + +def test_compat_retry_reports_lag_only_for_staged_subjects(workspace_env: dict[str, Path]) -> None: + db = workspace_env["archive_root"] / "source.db" + service = PublicationService(db, PublicationMode.MIRROR, LocalReferenceTransport()) + selected_payload = publication_payload(object_id="claude-code-session:selected") + other_payload = publication_payload(object_id="claude-code-session:other", revision_id="other") + selected = service.stage_payload(selected_payload) + service.stage_payload(other_payload) + assert selected is not None + + summary = asyncio.run( + service.retry_pending([(selected, selected_payload.manifest_bytes, selected_payload.segment_bytes)]) ) - assert staged is not None - del service_a, transport_a # "process A" is gone; nothing but the DB row remains - - transport_b = LocalReferenceTransport() - service_b = PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=transport_b) - resumed = service_b.pending() - - assert len(resumed) == 1 - assert resumed[0].object_id == "claude-code-session:s1" - assert resumed[0].status is ObligationStatus.PENDING - assert resumed[0].attempt_count == 0 - assert transport_b.call_count() == 0 # resuming did not fabricate a phantom attempt - - -def test_obligation_ledger_does_not_depend_on_ops_db(workspace_env: dict[str, Path]) -> None: - """ops.db is disposable diagnostics only; deleting it must not touch the - - durable source.db obligation (design: "ops.db/convergence debt may - mirror attempts, latency, and diagnostics only. It is disposable and can - never be the sole outbox or recovery authority."). - """ - source_db = workspace_env["archive_root"] / "source.db" - ops_db = workspace_env["archive_root"] / "ops.db" - transport = LocalReferenceTransport() - service = PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=transport) - service.stage( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - ) - - ops_db.unlink(missing_ok=True) - for suffix in ("-wal", "-shm"): - Path(f"{ops_db}{suffix}").unlink(missing_ok=True) + assert summary.confirmed == 1 + assert summary.remaining_lag == 0 assert service.lag() == 1 - assert len(service.pending()) == 1 - - -async def test_stage_retry_after_confirmation_does_not_reopen_the_obligation( - workspace_env: dict[str, Path], -) -> None: - """Restaging the SAME revision after it already confirmed must return the - - settled row unchanged, not reset it to pending -- proves duplicate - delivery (e.g. a re-run ingest pass for a revision already published) - cannot resurrect a closed obligation. - """ - source_db = workspace_env["archive_root"] / "source.db" - transport = LocalReferenceTransport() - service = PublicationService(source_db_path=source_db, mode=PublicationMode.MIRROR, transport=transport) - - confirmed = await service.publish( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - manifest_bytes=b"{}", - segment_bytes={}, - ) - assert confirmed is not None - assert confirmed.status is ObligationStatus.CONFIRMED - - restaged = service.stage( - object_id="claude-code-session:s1", - protocol_version="polylogue.material-protocol/v1", - revision_id="rev-1", - manifest_digest="digest-1", - ) - assert restaged == confirmed - assert _obligation_table_rows(source_db) == 1 diff --git a/tests/unit/sinex/test_transport.py b/tests/unit/sinex/test_transport.py index 26ce91307f..56f54d360b 100644 --- a/tests/unit/sinex/test_transport.py +++ b/tests/unit/sinex/test_transport.py @@ -1,67 +1,73 @@ -"""LocalReferenceTransport contract fidelity + NullTransport hard-fail.""" +"""Transport identity, composition, and off-mode contracts.""" from __future__ import annotations +import asyncio + import pytest from polylogue.sinex.models import ReceiptState -from polylogue.sinex.transport import LocalReferenceTransport, NullTransport, TransportUsedInOffModeError - - -async def test_null_transport_never_silently_no_ops() -> None: - """Off mode's zero-transport-work guarantee: an accidental call is loud, - not a graceful skip -- silent no-ops are exactly what let a real bug - (transport wired despite off mode) go unnoticed. - """ - transport = NullTransport() +from polylogue.sinex.transport import ( + LocalReferenceTransport, + NullTransport, + SinexTransportUnavailableError, + TransportPayloadConflictError, + TransportUsedInOffModeError, + clear_configured_transport_factory, + register_configured_transport_factory, + resolve_configured_transport, +) + + +def test_null_transport_is_a_loud_bug_not_a_noop() -> None: with pytest.raises(TransportUsedInOffModeError): - await transport.publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={}) - - -async def test_local_reference_transport_default_confirms() -> None: - transport = LocalReferenceTransport() - receipt = await transport.publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={"a": b"x"}) - assert receipt.state is ReceiptState.PERSISTED_CONFIRMED - assert transport.call_count("req-1") == 1 - + asyncio.run(NullTransport().publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={})) -async def test_local_reference_transport_is_idempotent_by_request_id_once_confirmed() -> None: - """A retried publish for an ALREADY-confirmed request_id must not perform - (or record) a second underlying attempt -- this is the exact property a - real Sinex transport must have too (design: "same-revision retry is - idempotent"). - """ +def test_reference_transport_replays_confirmed_request_without_duplicate_call() -> None: transport = LocalReferenceTransport() - first = await transport.publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={}) - second = await transport.publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={}) + first = asyncio.run( + transport.publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={"head": b"x"}) + ) + second = asyncio.run( + transport.publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={"head": b"x"}) + ) assert first == second + assert first.state is ReceiptState.PERSISTED_CONFIRMED assert transport.call_count("req-1") == 1 - # A DIFFERENT request_id is a fully independent attempt. - await transport.publish_revision(request_id="req-2", manifest_bytes=b"{}", segment_bytes={}) - assert transport.call_count("req-1") == 1 - assert transport.call_count("req-2") == 1 - assert transport.call_count() == 2 - - -async def test_local_reference_transport_fault_injection_does_not_cache_non_unlocking_states() -> None: - """A non-unlocking outcome (RAW_ACCEPTED) must be retried for real on the - - next attempt, unlike a confirmed outcome which short-circuits. - """ - attempts: list[int] = [] - def fault_fn(request_id: str, attempt_number: int) -> ReceiptState | None: - attempts.append(attempt_number) - return ReceiptState.RAW_ACCEPTED if attempt_number == 1 else None +def test_request_id_reuse_with_different_exact_bytes_is_rejected() -> None: + transport = LocalReferenceTransport() + asyncio.run(transport.publish_revision(request_id="req-1", manifest_bytes=b"a", segment_bytes={})) + with pytest.raises(TransportPayloadConflictError): + asyncio.run(transport.publish_revision(request_id="req-1", manifest_bytes=b"b", segment_bytes={})) - transport = LocalReferenceTransport(fault_fn=fault_fn) - first = await transport.publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={}) - assert first.state is ReceiptState.RAW_ACCEPTED - assert not first.state.unlocks_progress() - second = await transport.publish_revision(request_id="req-1", manifest_bytes=b"{}", segment_bytes={}) - assert second.state is ReceiptState.PERSISTED_CONFIRMED - assert attempts == [1, 2] - assert transport.call_count("req-1") == 2 +def test_payload_digest_frames_manifest_segment_names_and_bytes() -> None: + transport = LocalReferenceTransport() + asyncio.run( + transport.publish_revision( + request_id="req-framing", + manifest_bytes=b"a", + segment_bytes={"b": b"c"}, + ) + ) + with pytest.raises(TransportPayloadConflictError): + asyncio.run( + transport.publish_revision( + request_id="req-framing", + manifest_bytes=b"ab", + segment_bytes={"": b"c"}, + ) + ) + + +def test_deployment_transport_factory_is_explicit_and_resettable() -> None: + clear_configured_transport_factory() + with pytest.raises(SinexTransportUnavailableError): + resolve_configured_transport() + transport = LocalReferenceTransport() + register_configured_transport_factory(lambda: transport) + assert resolve_configured_transport() is transport + clear_configured_transport_factory() diff --git a/tests/unit/storage/test_durable_migrations.py b/tests/unit/storage/test_durable_migrations.py index 70a93abf90..4527a35734 100644 --- a/tests/unit/storage/test_durable_migrations.py +++ b/tests/unit/storage/test_durable_migrations.py @@ -489,7 +489,7 @@ def test_source_tier_v1_migrates_to_current_without_native_uniqueness( result = migrate_archive_tier(conn, ArchiveTier.SOURCE, backup_manifest=manifest) assert result.from_version == 1 assert result.to_version == SOURCE_SCHEMA_VERSION - assert result.applied_versions == (2, 3, 4, 5, 6, 7, 8, 9, 10, 11) + assert result.applied_versions == (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) assert int(conn.execute("PRAGMA user_version").fetchone()[0]) == SOURCE_SCHEMA_VERSION columns = {str(row[1]) for row in conn.execute("PRAGMA table_info('raw_sessions')")} assert "predecessor_source_revision" in columns @@ -547,23 +547,39 @@ def test_source_tier_v1_migrates_to_current_without_native_uniqueness( conn.close() -def test_source_additive_ledger_migrations_do_not_require_a_backup(tmp_path: Path) -> None: - db_path = tmp_path / "source.db" +def test_source_publication_backfill_requires_verified_backup( + workspace_env: dict[str, Path], + tmp_path: Path, +) -> None: + db_path = workspace_env["archive_root"] / "source.db" with sqlite3.connect(db_path) as conn: - conn.executescript(SOURCE_DDL) conn.execute("DROP TABLE excised_content") + conn.execute("DROP TABLE sinex_publication_segments") + conn.execute("DROP TABLE sinex_publication_receipts") + conn.execute("DROP TABLE sinex_publication_payloads") conn.execute("DROP TABLE sinex_publication_obligations") conn.execute("PRAGMA user_version = 9") conn.commit() - result = migrate_archive_tier(conn, ArchiveTier.SOURCE, backup_manifest=None) + with pytest.raises(MigrationError, match="verified backup manifest"): + migrate_archive_tier(conn, ArchiveTier.SOURCE, backup_manifest=None) + + manifest = _verified_backup_manifest(tmp_path / "backup-source-publication") + with sqlite3.connect(db_path) as conn: + result = migrate_archive_tier(conn, ArchiveTier.SOURCE, backup_manifest=manifest) assert result.from_version == 9 - assert result.to_version == SOURCE_SCHEMA_VERSION == 11 - assert result.applied_versions == (10, 11) - assert result.backup_receipt is None + assert result.to_version == SOURCE_SCHEMA_VERSION == 12 + assert result.applied_versions == (10, 11, 12) + assert result.backup_receipt == manifest.with_name("verification-receipt.json") tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} - assert {"sinex_publication_obligations", "excised_content"} <= tables + assert { + "sinex_publication_obligations", + "sinex_publication_payloads", + "sinex_publication_segments", + "sinex_publication_receipts", + "excised_content", + } <= tables def test_additive_no_backup_marker_must_be_the_header_not_a_substring() -> None: @@ -699,8 +715,8 @@ def test_source_tier_v7_expands_origin_checks_with_verified_backup( with sqlite3.connect(db_path) as conn: result = migrate_archive_tier(conn, ArchiveTier.SOURCE, backup_manifest=manifest) assert result.from_version == 7 - assert result.to_version == SOURCE_SCHEMA_VERSION == 11 - assert result.applied_versions == (8, 9, 10, 11) + assert result.to_version == SOURCE_SCHEMA_VERSION == 12 + assert result.applied_versions == (8, 9, 10, 11, 12) assert conn.execute( """ SELECT predecessor_source_revision, predecessor_raw_id, baseline_raw_id, @@ -861,7 +877,7 @@ def test_source_tier_v2_migrates_to_v3_dropping_pending_blob_refs( assert result.from_version == 2 assert result.to_version == SOURCE_SCHEMA_VERSION - assert result.applied_versions == (3, 4, 5, 6, 7, 8, 9, 10, 11) + assert result.applied_versions == (3, 4, 5, 6, 7, 8, 9, 10, 11, 12) assert int(conn.execute("PRAGMA user_version").fetchone()[0]) == SOURCE_SCHEMA_VERSION assert not conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_blob_refs'" @@ -919,7 +935,7 @@ def test_source_tier_v3_adds_publication_reservations_with_verified_backup_recei conn = sqlite3.connect(db_path) try: result = migrate_archive_tier(conn, ArchiveTier.SOURCE, backup_manifest=manifest) - assert result.applied_versions == (4, 5, 6, 7, 8, 9, 10, 11) + assert result.applied_versions == (4, 5, 6, 7, 8, 9, 10, 11, 12) assert int(conn.execute("PRAGMA user_version").fetchone()[0]) == SOURCE_SCHEMA_VERSION conn.execute( """