Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/plans/degrade-loudly-allowlist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,22 @@ entries:
- Exception
occurrence: 0
reason: 'Returns {"available": False, "error": str(exc)} -- an already-typed signal.'
- path: polylogue/storage/archive_readiness.py
function: <module>._action_readiness_counts
exceptions:
- Error
occurrence: 0
reason: 'Sets actions_view_error = str(exc) in the returned counts dict -- an already-typed signal.
Extracted from polylogue/cli/commands/status.py (polylogue-ogn1 layering fix); pre-existing
behavior, unchanged by the move.'
- path: polylogue/storage/archive_readiness.py
function: <module>.archive_readiness_status
exceptions:
- Error
occurrence: 0
reason: 'Returns {"checked": False, "reason": str(exc), "surfaces": {}} -- an already-typed signal.
Extracted from polylogue/cli/commands/status.py (polylogue-ogn1 layering fix); pre-existing
behavior, unchanged by the move.'
- path: polylogue/storage/artifacts/inspection.py
function: <module>._hermes_state_db_schema_version
exceptions:
Expand Down
22 changes: 20 additions & 2 deletions polylogue/cli/commands/maintenance/_rebuild_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@
from polylogue.paths import archive_root
from polylogue.storage.archive_identity import ArchiveLocation

_BUILTIN_DAEMON_URL = "http://127.0.0.1:8766"


def _default_daemon_url() -> str:
"""Resolve the default daemon URL through the layered config resolver.

polylogue-ogn1: this option's default previously read
``POLYLOGUE_DAEMON_URL`` directly via ``os.environ.get``, bypassing the
5-layer config precedence chain (site TOML -> user TOML -> env -> CLI)
that every other daemon-URL-consuming surface in this repo goes through
(see ``polylogue.cli.commands.status._default_daemon_url``). A site/user
TOML ``daemon.url`` override was silently ignored here even though it was
honoured everywhere else.
"""
from polylogue.config import load_polylogue_config

return load_polylogue_config().daemon_url or _BUILTIN_DAEMON_URL


def _run_daemon_rebuild(
daemon_url: str,
Expand Down Expand Up @@ -342,8 +360,8 @@ def _rebuild_index_selection_plan(
)
@click.option(
"--daemon-url",
default=lambda: __import__("os").environ.get("POLYLOGUE_DAEMON_URL", "http://127.0.0.1:8766"),
show_default="POLYLOGUE_DAEMON_URL or http://127.0.0.1:8766",
default=_default_daemon_url,
show_default="resolved via load_polylogue_config().daemon_url (site/user TOML -> POLYLOGUE_DAEMON_URL -> built-in default)",
help="Daemon HTTP base URL used with --daemon.",
)
def rebuild_index_command(
Expand Down
334 changes: 1 addition & 333 deletions polylogue/cli/commands/status.py

Large diffs are not rendered by default.

44 changes: 34 additions & 10 deletions polylogue/daemon/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,16 @@ def __bool__(self) -> bool:
return self.allowed


# polylogue-ogn1: the write bridge's default run_sync/hold timeout (30s,
# DaemonWriteThreadBridge.__init__) is sized for ordinary request-scoped
# writes. A bounded rebuild-index pass is allowed to run far longer -- the
# CLI's own --daemon HTTP client already tolerates up to 600s
# (_rebuild_index.py's _run_daemon_rebuild, urlopen(..., timeout=600)) -- so
# the HTTP route asks the bridge to wait that same 600s instead of the 30s
# default, which would otherwise kill a still-running rebuild pass early.
_REBUILD_INDEX_WRITE_TIMEOUT_S = 600.0


class DaemonAPIHandler(BaseHTTPRequestHandler):
"""HTTP handler for the daemon API server.

Expand Down Expand Up @@ -5228,7 +5238,14 @@ def _handle_maintenance_run(self) -> None:

@daemon_safe_handler
def _handle_rebuild_index(self) -> None:
"""POST /api/maintenance/rebuild-index — one coordinator-owned replay pass."""
"""POST /api/maintenance/rebuild-index — one coordinator-owned replay pass.

polylogue-ogn1: waits up to ``_REBUILD_INDEX_WRITE_TIMEOUT_S`` through
the write bridge, matching the CLI's own ``--daemon`` HTTP client
timeout (``_run_daemon_rebuild``'s ``urlopen(..., timeout=600)``)
rather than the bridge's much shorter default request timeout (30s),
which would otherwise kill a still-running rebuild pass early.
"""
content_length = int(self.headers.get("Content-Length", 0))
body_raw = self.rfile.read(content_length) if content_length > 0 else b"{}"
try:
Expand Down Expand Up @@ -5303,15 +5320,22 @@ def _handle_rebuild_index(self) -> None:

bridge = getattr(self.server, "write_bridge", None)
if bridge is None:
# Direct handler unit tests predate the server-owned bridge; real
# daemon servers always install it and therefore use run_sync.
receipt = rebuild_index_from_source_sync(request)
else:
receipt = cast(DaemonWriteThreadBridge, bridge).run_sync(
"http.maintenance.rebuild-index",
rebuild_index_from_source_sync,
request,
)
# polylogue-ogn1: a real DaemonAPIHTTPServer always installs
# write_bridge in __init__ (either the caller's coordinator or an
# owned standalone one) -- this branch is never reachable there.
# Fail closed instead of running the rebuild directly outside the
# sole-writer coordinator: a route that can execute an authority-
# promoting archive write without ever holding the writer gate is
# a bypass of this daemon's single-writer invariant, not a safe
# fallback, even if nothing exercises it in production today.
self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, "write_coordinator_unavailable")
return
receipt = cast(DaemonWriteThreadBridge, bridge).run_sync_with_timeout(
"http.maintenance.rebuild-index",
_REBUILD_INDEX_WRITE_TIMEOUT_S,
rebuild_index_from_source_sync,
request,
)
self._send_json(HTTPStatus.OK, receipt.to_dict())

@daemon_safe_handler
Expand Down
27 changes: 26 additions & 1 deletion polylogue/daemon/write_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,11 +492,36 @@ def run_sync(self, actor: str, function: Callable[P, T], /, *args: P.args, **kwa
Unlike :meth:`hold`, this is for a complete bounded request operation:
the coordinator owns the worker thread until the function has really
returned, so a timed-out HTTP caller never admits a second writer.

Waits at most this bridge's constructor ``timeout`` (default 30s) for
completion. Use :meth:`run_sync_with_timeout` for an operation whose
own contract needs a longer bound (see polylogue-ogn1).
"""
return self.run_sync_with_timeout(actor, self._timeout, function, *args, **kwargs)

def run_sync_with_timeout(
self,
actor: str,
timeout: float,
function: Callable[P, T],
/,
*args: P.args,
**kwargs: P.kwargs,
) -> T:
"""Like :meth:`run_sync`, waiting up to ``timeout`` seconds instead of the bridge default.

polylogue-ogn1: the bridge's constructor ``timeout`` (30s) is sized for
ordinary request-scoped writes (reset, ingest, maintenance run). A
bounded index rebuild pass can legitimately run far longer -- the
CLI/HTTP contract already allows up to 600s (``_run_daemon_rebuild``'s
``urlopen(..., timeout=600)``) -- so that call site needs its own,
longer wait here rather than being silently killed by the bridge's
default gate at 30s while the rebuild is still replaying.
"""
future = asyncio.run_coroutine_threadsafe(
self._coordinator.run_sync(actor, function, *args, **kwargs), self._loop
)
return future.result(timeout=self._timeout)
return future.result(timeout=timeout)


def daemon_write_telemetry_payload() -> dict[str, object]:
Expand Down
18 changes: 14 additions & 4 deletions polylogue/maintenance/rebuild_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,20 @@ def count_source_raw_sessions(root: Path) -> int:


def missing_index_raw_ids(root: Path) -> list[str]:
"""Return source raw_ids that have not yet reached ``index.sessions``.

polylogue-ogn1: a missing/lost ``index.db`` (fresh archive, or one just
reset via ``ops reset --index``) means every source row is missing from
the index by definition -- return the full source set instead of an
empty list, so ``--only-missing`` actually rebuilds something on a
fresh/lost index rather than silently doing nothing.
"""
source_db = root / "source.db"
index_db = ArchiveLocation.resolve(root).active_index_path
if not source_db.exists() or not index_db.exists():
if not source_db.exists():
return []
index_db = ArchiveLocation.resolve(root).active_index_path
if not index_db.exists():
return all_index_rebuild_raw_ids(root)
with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn:
conn.execute("ATTACH DATABASE ? AS idx", (str(index_db),))
rows = conn.execute(
Expand Down Expand Up @@ -304,9 +314,9 @@ def select_rebuild_raw_ids(request: RebuildIndexRequest) -> tuple[int, list[str]

async def rebuild_index_from_source(request: RebuildIndexRequest) -> RebuildIndexReceipt:
"""Replay one source snapshot into an owned generation and optionally promote it."""
from polylogue.cli.commands.status import _archive_readiness_status
from polylogue.maintenance.archive_verification import verify_archive
from polylogue.maintenance.replay import rebuild_index_from_source as replay_source
from polylogue.storage.archive_readiness import archive_readiness_status
from polylogue.storage.index_generation import IndexGenerationStore, RebuildLease, source_revision_snapshot
from polylogue.storage.repair import repair_session_insights

Expand Down Expand Up @@ -522,7 +532,7 @@ async def rebuild_index_from_source(request: RebuildIndexRequest) -> RebuildInde
f"bulk-build FTS/trigram parity failed for generation {generation.generation_id}: {failing}"
)
terminal_started_at = time.perf_counter()
readiness = _archive_readiness_status(generation_root)
readiness = archive_readiness_status(generation_root)
logger.info(
"rebuild_terminal_stage_complete",
generation_id=generation.generation_id,
Expand Down
Loading