fix(opal-server): git resilience — never stuck on an offline repo (PR3) - #924
fix(opal-server): git resilience — never stuck on an offline repo (PR3)#924dshoen619 wants to merge 88 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire scope clone/fetch through run_in_git_executor with SCOPES_GIT_FETCH_TIMEOUT, and broaden the _clone except to catch asyncio.TimeoutError so a hung clone is logged and the scope skipped instead of crashing the caller. Drop the now-unused run_sync import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for opal-docs canceled.
|
There was a problem hiding this comment.
Pull request overview
This PR improves opal-server resilience when syncing scope policy repos by ensuring git clone/fetch operations can’t block indefinitely or starve the server’s shared executor.
Changes:
- Add a dedicated, bounded
ThreadPoolExecutorandrun_in_git_executor(...)helper to run blocking pygit2 operations with anasyncio.wait_fortimeout. - Apply the helper + new timeout config to scope repo clone and fetch paths.
- Add server config keys for timeout and executor sizing, plus focused unit tests for timeout behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/opal-server/opal_server/git_fetcher.py | Introduces dedicated git executor + timeout helper; routes scope clone/fetch through it. |
| packages/opal-server/opal_server/config.py | Adds SCOPES_GIT_FETCH_TIMEOUT and SCOPES_GIT_MAX_WORKERS configuration. |
| packages/opal-server/opal_server/tests/git_executor_test.py | Tests config defaults and run_in_git_executor basic behavior. |
| packages/opal-server/opal_server/tests/fetch_timeout_test.py | Tests that a hanging git op times out quickly (doesn’t block). |
| .claude/plans/docs/05-config-reference.md | Internal config reference entry for the new env vars and caveat. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
On Python < 3.11 asyncio.TimeoutError is a distinct class from the builtin TimeoutError, so run_in_git_executor's wait_for timeout was not caught by `pytest.raises(TimeoutError)` — failing build (3.9)/(3.10). Normalize to the builtin TimeoutError so the documented contract holds on every supported Python, and update the _clone catch site to match. Also apply black/isort/docformatter formatting to satisfy pre-commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- run_in_git_executor: use asyncio.get_running_loop() instead of the deprecated get_event_loop() inside an async function - fetch_and_notify_on_changes: set repos_last_fetched only after a successful fetch so a timeout/error does not wrongly suppress a later force_fetch via _was_fetched_after - fetch_timeout_test: measure elapsed time with time.monotonic() Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fetch path let TimeoutError propagate to sync_scope's catch-all, which logged a full traceback at ERROR level for the expected unreachable-repo case — inconsistent with the clone path's quiet logger.error. Catch TimeoutError at the fetch site and log without a traceback, then skip (repos_last_fetched stays stale so the next cycle retries). Also shorten the hanging-thread sleeps in the timeout tests so the lingering pool thread doesn't delay process teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tuck-on-an-offline-repo
…tuck-on-an-offline-repo
Review notes — overlap + gate location (planned with the
|
- git_fetcher: document that the dedicated scope-git ThreadPoolExecutor reads SCOPES_GIT_MAX_WORKERS once on first use, caches for the process lifetime (not runtime-reconfigurable), and is never explicitly shut down — matches the PR3 design. - 05-config-reference: fix stale config.py line refs after the master merge shifted the keys — SCOPES_GIT_FETCH_TIMEOUT 150-156 -> 196-202, SCOPES_GIT_MAX_WORKERS 157-163 -> 203-209. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @zeevmoney — addressed in bad21c1. Minor items
Things to resolve
Also confirmed the two improvements you flagged are in place: |
… concurrent sync
Addresses review findings on PR3 (never stuck on an offline repo):
- CRITICAL: reset the dedicated git ThreadPoolExecutor after fork
(os.register_at_fork) and shut it down at the end of preload_scopes. A
pool built in the pre-fork gunicorn master was inherited with dead worker
threads by every worker, so the leader's scope sync stalled forever
(silent policy staleness). Verified with a fork repro on 3.12.
- HIGH: never use the non-thread-safe pygit2 Repository from two threads.
A timed-out clone/fetch keeps running on its pool thread while the
per-source_id lock is released; a per-source_id in-flight guard now skips
a cycle while a prior op is still lingering. run_in_git_executor switches
asyncio.wait_for -> asyncio.wait so a timeout never cancels the future
(the thread runs to completion and clears the in-flight marker).
- HIGH: sync scopes concurrently, bounded by SCOPES_GIT_MAX_WORKERS, so one
unreachable repo no longer serially blocks boot and other scopes.
- MEDIUM: daemon-thread pool so a lingering git op can't block interpreter
shutdown.
- MEDIUM: stamp repos_last_fetched with the fetch start time on success
(was completion time, which could wrongly suppress a force_fetch whose
req_time falls within an in-flight fetch).
- rmtree(ignore_errors) for the abandoned-clone race; harden the
env-sensitive config-defaults test; correct config/doc wording
("logged and skipped" instead of "marked failed").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zeevmoney
left a comment
There was a problem hiding this comment.
Review (PER-15157 / PR3) — git resilience: never stuck on an offline repo
What this PR does. Makes scope git clone/fetch resilient to unreachable repos. It moves scope git work off the shared default executor onto a dedicated daemon-thread ThreadPoolExecutor (SCOPES_GIT_MAX_WORKERS, default 10), wraps each clone/fetch in a soft per-op timeout (SCOPES_GIT_FETCH_TIMEOUT, default 120s) via run_in_git_executor using asyncio.wait (so a timeout unblocks the event loop without cancelling the still-running pygit2 call), adds a per-repo in-flight guard so a lingering timed-out op is not touched concurrently (pygit2 Repository is not thread-safe), records repos_last_fetched only on fetch success, makes scope sync concurrent (bounded by the pool via a semaphore), and adds fork-safety (os.register_at_fork reset + shutdown_git_executor() after pre-fork preload). Plus two unit-test files and a private config-reference doc.
Verdict: REQUEST_CHANGES. Per the severity rule, there is one Postable HIGH finding (credential-redaction bypass in the new log lines) → REQUEST_CHANGES. Independently, the PR is not mergeable: GitHub reports CONFLICTING and git_fetcher.py has a content conflict with master (see Blockers).
The core design is sound and a real improvement over master: isolating git work onto a dedicated pool means a hung clone/fetch can no longer starve bundle serving or the event loop (on master these share the default executor via run_sync, so one offline repo hangs the whole server). The soft-timeout + single-flight + fork-safety mechanics are correct and well-tested (test_busy_key_stays_in_flight_until_call_returns, test_hanging_git_op_raises_timeout, the config-default tests). The two prior open Copilot threads are already addressed by the current code (asyncio.get_running_loop() replaces get_event_loop; repos_last_fetched is now written only after a successful fetch) — not re-raised.
Findings
Postable:
| # | Severity | File:Line | Category | Description |
|---|---|---|---|---|
| 1 | HIGH | packages/opal-server/opal_server/git_fetcher.py:412 (also 376, 465) | Security | New skip/timeout/clone-error log lines log the raw self._source.url; master redacts every repo URL in this file via redact_url(). Once merged these are the only un-redacted URL logs → credential exposure. redact_url isn't imported. |
| 2 | MEDIUM | .claude/plans/docs/05-config-reference.md:27 | Doc accuracy | The ceil(offline / workers) × timeout boot/poll bound is optimistic: timed-out ops keep their pool thread until the OS network timeout, so with offline >= workers healthy repos queue behind lingering threads longer than the stated bound. |
Informational (not posted inline):
| # | Severity | File:Line | Category | Description |
|---|---|---|---|---|
| 3 | LOW | packages/opal-server/opal_server/git_fetcher.py:52-91 | Maintainability | _DaemonThreadPoolExecutor._adjust_thread_count reimplements CPython concurrent.futures.thread internals. It falls back to super() if _worker/_threads_queues disappear, but a signature change to _worker (name kept, args changed) would pass the hasattr check yet break. Acceptable given the fallback + # pragma: no cover, but a fragility to track across Python upgrades (repo targets 3.9–3.12). |
| 4 | LOW | .claude/plans/docs/05-config-reference.md (new tracked file) | Cross-PR coordination | PR #922 adds .claude/ to .gitignore while this PR tracks a file under .claude/plans/docs/. Not a conflict on master today (.claude/ isn't ignored), but once both land the tracked doc sits under a gitignored path — coordinate. |
Design note (not a blocker). The "never stuck" guarantee delivered is: the event loop / HTTP surface / bundle serving never block, and each sync slot stalls at most SCOPES_GIT_FETCH_TIMEOUT. It is not that a fixed pool immediately reclaims capacity on timeout — a timed-out op lingers on its thread until the OS network timeout (by design; pygit2 can't be cancelled). For the realistic case (a few offline repos among many healthy) this is fine — the offline ops each hold one lingering thread and the rest of the pool serves healthy repos. The pathological case (offline repos >= pool size) can saturate the git pool; the daemon threads still let the process exit promptly and other server work is unaffected. Finding #2 asks the doc to reflect this precisely.
Blast radius: Production opal-server git-fetcher + scopes sync path — affects every scoped deployment's boot and poll behavior. No client-facing symbol from references/pdp-impact.md §3 is renamed/removed (new module-level helpers + two config keys only; GitPolicyFetcher public shape unchanged), so no PDP import-surface break. Two new OPAL_* keys are additive with sane defaults, no env-name collision, no OPAL_ double-prefix. Pub/sub topology unchanged — this only changes how the leader fetches git; the scope publish path is untouched, so PDP policy-update propagation is unaffected except that offline repos now fail fast (skip + retry) instead of hanging.
Isolation / scope: Well-isolated. All changes serve the stated purpose (git resilience); no unrelated refactors, no half-done work.
Blockers:
- CONFLICTING / not mergeable.
git_fetcher.pyhas a content conflict with master (master addedredact_url()to the log lines this PR also edits). Rebase/merge master and resolve — and when doing so, applyredact_url()to the new log lines too (finding #1). Reviewed here against the merge-base three-dot diff (d30e462...d31a0b63), which is unaffected by the conflict.
…tuck-on-an-offline-repo Resolve git_fetcher.py conflict: keep the PR's soft-timeout fetch path (run_in_git_executor + stamping repos_last_fetched with the start time only on success) and the combined pygit2.GitError/TimeoutError clone handler; drop master's run_sync double-fetch and its separate `except pygit2.GitError` clause. Apply master's redact_url() to the three new offline/error log lines the PR added — single-flight skip, fetch-timeout, and clone-error — so scope git URLs (which can embed user:token@host) are never logged raw once the redaction control from master is in effect (review finding #1, HIGH). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2) The `ceil(offline / workers) × timeout` bound was optimistic: the soft timeout unblocks the awaiting coroutine but the timed-out op keeps its pool thread until the OS network timeout, so with offline >= workers a healthy repo queues behind lingering threads up to the OS/TCP timeout, not `ceil × timeout`. Restate the guarantee this actually delivers (event-loop isolation + a bounded per-slot stall), reference the app-tests/git-leak 40-offline/10-worker case, and fix the two config.py line refs (196-203, 204-211). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tuck-on-an-offline-repo
…-git-resilience-never-stuck-on-an-offline-repo # Conflicts: # packages/opal-server/opal_server/git_fetcher.py # packages/opal-server/opal_server/scopes/service.py
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-2 review addressed — 16 of 21, bed-validatedThanks for the thorough pass @zeevmoney. Pushed the fixes ( Fixed + resolved (16): fork/teardown safety (fork-lock triple, Validation: opal-server unit suite green, and the git-leak docker bed is 21/21 with zero invariant violations. The bed earned its keep — it caught that an attempted local-memory-purge fix for service.py:213 minted stray Left open, deferred to a follow-up (lock-lifecycle / "Plan B"): the two HIGH use-after-free items (worker-side Happy to do a quick sync on the purge-delivery split for the UAF fix before I implement it. |
…hz, transient-branch 409/503, observability Post-review hardening from a 3-round adversarial+constructive review of the boot-resilience branch. Security - Reject SCOPES_PURGE_CHANNEL publish/subscribe from external RPC peers. The channel is server-internal (a purge evicts GitPolicyFetcher caches, and via the leader deletes clone dirs, fleet-wide), but _verify_permitted_topics default-allows tokens with no permitted_topics claim, so any connected client/PDP could forge a fleet-wide purge. Legitimate server + broadcaster publishes are channel=None and bypass the gate; clients only ever publish STATISTICS_ADD_CLIENT_CHANNEL, so no client is affected and no redeploy is needed. (+tests pinning that client traffic is untouched) Correctness - _get_current_branch_head now distinguishes a PERMANENT missing branch (KeyError -> BranchHeadNotFoundError -> non-retryable 409) from a TRANSIENT gutted object store (pygit2.GitError -> propagates -> retryable 503). It was collapsing both to a 409, telling clients "not retryable" for a scope the sync path is actively self-healing. (+tests exercising the real raise path) Observability (Datadog) - Log the preload drain timeout instead of discarding drain_git_ops()'s result — that is the one condition that carries lingering git ops across the fork, and it was silent. - Continuous git_ops_in_flight gauge, not just the one-shot log at the cap. - Orphan-sweep heartbeat summary so a healthy no-op sweep is visible. - metrics.event on the 503/409 policy-unavailable responses. Hygiene - Clean-log GitConcurrencyLimitExceeded (no per-scope traceback during a zombie-cap outage). - \Z (not $) in the source_id regex — block the trailing-newline bypass. - Skip the always-on orphan-sweep timer when periodic polling already sweeps (avoid duplicate scans + purge broadcasts). - Fix misleading ScopePurgeCommand.reason/clone_path comments; drop an unused import. 199 opal-server unit tests + 21 git-leak bed tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXknyVRt9oHeJ4BHAauu5c
PR #924 — post-review hardening pass (for @zeevmoney)Ran a 3-round adversarial + constructive review over the whole branch (correctness, Fixed in
|
…p isolation in purge-channel tests CI fixups on ff5904b: - black/isort/docformatter formatting (the local commit bypassed the hooks). - purge_channel_test F1 tests now await the restriction under @pytest.mark.asyncio instead of asyncio.run(), which on Python 3.9 leaves the current event loop set to None and broke later SYNC tests that call asyncio.get_event_loop() (reconnecting_broadcaster_test) — the same py3.9 isolation trap as 8400a75. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXknyVRt9oHeJ4BHAauu5c
zeevmoney
left a comment
There was a problem hiding this comment.
Changes requested — 2 HIGH, 4 MEDIUM.
Blocking:
- HIGH
packages/opal-server/opal_server/pubsub.py:388— theALL_TOPICSexemption reopens the purge channel on the subscribe path; verified against the installedfastapi_websocket_pubsubthat an external peer subscribed asALL_TOPICS(bare or in a list) receives everyScopePurgeCommand - HIGH
packages/opal-server/opal_server/scopes/purge.py:305— the sweep's snapshot filter is O(dirs × scopes) with two sha256 per pair and noawait; measured 1.25s of blocked event loop at 2000 scopes, growing quadratically
Non-blocking:
- MEDIUM
packages/opal-server/opal_server/tests/purge_channel_test.py:112— the only test guarding theconfirmedgate passes with the gate deleted (verified by mutation; whole suite stays green) - MEDIUM
packages/opal-common/setup.py:76—opal-clientwas not capped alongsideopal-common/opal-server, sopip install opal-clienton 3.13 fails on a transitive dependency - MEDIUM
packages/opal-server/opal_server/scopes/task.py:43— the leader purge subscription has nostop()teardown and_pending_purgesis not cancelled or awaited, so a shutdown can abandon anrmtreeand skip the confirmation publish - MEDIUM
packages/opal-server/opal_server/tests/purge_channel_test.py:657— the third test dropped in thedelete_scope_cache_purge_test.pyrewrite (recreate-after-delete) still has no equivalent
Details are in the inline comments on each line.
PR description accuracy (not inline findings — the description does not match the diff):
- The "New config keys" table lists 3 keys;
config.pyadds 6.SCOPES_GIT_PRELOAD_DRAIN_TIMEOUT(10.0),SCOPES_GIT_MAX_ZOMBIES(40) andSCOPES_ORPHAN_SWEEP_INTERVAL(300) are missing. All six are documented correctly inconfiguration.mdx— verified programmatically that every documented default matchesconfig.py. - "The 503 above is the one intentional contract change" —
api.pyalso adds a 409 CONFLICT forBranchHeadNotFoundError, a case that previously fell through to_generate_default_scope_bundleand returned 200. That is a second contract change and it is not in the §5 table. Worth noting for direct consumers thatopal-clientcannot act on the "non-retryable" distinction:throw_if_bad_status_codeturns any non-200 into aValueError, so 409 and 503 are both retried identically by its tenacity policy. - "
packages/opal-clientandpackages/opal-commonare untouched" —packages/opal-common/setup.pyis touched (python_requiresnarrowed to<3.13), which is a packaging change for downstream consumers. - The "
⚠️ Caveat" says worst-case thread count isSCOPES_GIT_MAX_WORKERSplus lingering timed-out ops, with no bound.SCOPES_GIT_MAX_ZOMBIESnow bounds exactly that, so the caveat understates the code. - "opal-server unit suite: 151 passed" — the suite is 199 on this head (green, re-run locally).
- The linked design doc path
docs/superpowers/specs/2026-07-19-pr3-deferred-items-design.mddoes not exist in the repo.
Verified clean: all 199 unit tests pass on this head; pre-commit (black / isort / docformatter / codespell) passes on the diff; the six new config defaults match their configuration.mdx entries exactly; no new log line carries an unredacted URL or credential (only server-local filesystem paths); _reject_external_purge_channel correctly does not fire for server-internal publishes (PubSubEndpoint.publish passes channel=None, and restrictions run only if channel:); the endpoint's publisher id and subscriber id are distinct, so the "confirmation runs the local subscriber inline" rationale holds; the executor object-lifetime chain keeps a timed-out op's executor alive to completion without leaking, and the live-ops semaphore is released exactly once on both the success and timeout paths.
zeevmoney
left a comment
There was a problem hiding this comment.
Second pass — changes still requested. 2 further HIGH, 5 further MEDIUM.
Blocking:
- HIGH
packages/opal-server/opal_server/scopes/purge.py:96— the every-worker handler callsforget_repo→Repository.free()with no lock, so on every process except the publisher it is the exact use-after-free thatpurge.py:257-264locks the confirmation publish to prevent;git_op_in_flightdoes not cover_notify_on_changesor therun_sync(_get_valid_repo)executor thread - HIGH
packages/opal-server/opal_server/git_fetcher.py:276— "until the OS network timeout" is enforced by nothing (noGIT_OPT_SET_SERVER_TIMEOUT, no socket read timeout, libgit2 1.7 defaults to none), so a black-holed remote pins its_git_busymarker for process life; atSCOPES_GIT_MAX_ZOMBIESsuch entries every git op is refused fleet-wide, logged only as warning-level backpressure
Non-blocking:
- MEDIUM
packages/opal-server/opal_server/scopes/purge.py:340— the sweeprmtrees any subdirectory ofgit_sources/without the_SOURCE_ID_REcheck every other deletion path uses - MEDIUM
packages/opal-server/opal_server/scopes/task.py:57— the orphan-sweep timer is skipped when polling is on, contradictingconfig.py:513-516,task.py:95and the.mdx - MEDIUM
packages/opal-server/opal_server/scopes/service.py:292—max(..., 32)is a floor, soSCOPES_GIT_MAX_WORKERScannot lower phase-2 concurrency;config.py:230-231claims it can, and phase 2 shares the executor that serves policy bundles - MEDIUM
packages/opal-server/opal_server/scopes/task.py:64— the boot sync is outside the try that protects the sweep; a store error kills the task with no log and, at the defaultPOLICY_REFRESH_INTERVAL=0, no retry - MEDIUM
packages/opal-server/opal_server/git_fetcher.py:502— neither the in-flight sync guard nor "a timed-out fetch must not stamprepos_last_fetched" has a test; both mutations leave the suite green
Details are in the inline comments on each line.
Correction to the previous pass. The suggested fix on scopes/task.py:43 was wrong and that comment has been updated in place. PubSubEndpoint.subscribe registers all server-side subscriptions under one shared _subscriber_id, and EventNotifier.unsubscribe deletes by subscriber id per topic, so the unsubscribe([SCOPES_PURGE_CHANNEL]) I proposed would also have removed the every-worker handle_purge_message registered at server.py:395. The corrected comment proposes a dedicated subscriber id (or an _is_leader flag) instead. The finding itself stands; only the remedy changed. Also narrowed there: there is no in-process restart path, so the handler cannot double-subscribe — the real exposure is the window between the flock being released and the old worker dying.
Pre-existing, not introduced here, but worth a decision. api.py:308-315 still returns _generate_default_scope_bundle (HTTP 200, carrying the default scope's policy modules) when a scope record is missing, while the new comment at api.py:352-356 states the principle that "serving the default scope's bundle here would hand a live tenant another tenant's policy". _allowed_scoped_authenticator (api.py:89-98) authorizes the caller for the requested scope_id only, never for default. If tenant B's record is briefly absent — a Redis failover, a partial restore — B's PDP receives 200 plus the default scope's policy and loads it into OPA. The PR applies its own stated rule to the broken-clone case and not to the missing-record case, and scope_policy_fallback_test.py:117-132 pins the latter. Out of scope for this PR, but the two halves should not stay inconsistent indefinitely.
Also flagged, not filed inline (lower value, or better handled as a batch): metrics.event on the 503/409 paths is a DogStatsD event rather than a counter, emitted per failed request with a caller-controlled scope_id tag — during a clone outage retrying PDPs bury the signal; the confirmation publish at purge.py:266 and :349 does an unbounded backbone round trip inside lock_source, so a hung broadcaster pins that source's lock (there is mitigating precedent at git_fetcher.py:778); sweep_orphans and purge_source_if_unshared measure at cyclomatic complexity 17 and 11, and fetch_and_notify_on_changes grew from 82 to 133 lines with early returns carrying load-bearing invariants six levels deep; asyncio.gather in sync_scopes materializes one task per scope with no bound on scope count, including in the gunicorn master during preload; handle_purge_message reads the module-global BASE_DIR while LeaderScopePurger uses its injected base_dir; shutdown_git_executor() shuts down no executor and _release_once's guard is currently unreachable; git_executor_test.py mutates the process-global _git_busy without an autouse fixture and asserts on wall-clock elapsed time, which is flaky by construction under -p xdist or a randomizer.
Verified clean on this pass: the two-phase design holds — publish() runs local subscribers inline, and the publisher's and subscriber's ids are distinct so the leader really does receive its own confirmation; the replay case is sound with no epoch token needed (a scope re-created before the sibling check is seen under lock_source, so no confirmation is published at all — I ran this: the recreated scope's clone dir and cached handle both survived, zero confirmations published, while the truly-deleted control purged and confirmed); disk purges lost to a leaderless window are genuinely backstopped by the sweep; _confined_clone_path rejects traversal and the wire-supplied clone_path never reaches rmtree or free(); the publish-direction channel guard works for single, mixed and list topic forms; mixed-fleet is safe in both directions; route authz is unchanged and correct; Python 3.9 and pydantic v1 compatibility hold across all new constructs; the live-ops semaphore is released exactly once on every exit path and no executor leaks.
… UAF/authz, sweep perf/validation, lifecycle, concurrency knob, docs Security / correctness - pubsub: also reject ALL_TOPICS from external peers on the purge-channel gate (the gate guards subscribe too, and notify() fans every topic into the ALL_TOPICS bucket, so an ALL_TOPICS subscriber would still receive purge traffic; no opal-client subscribes to ALL_TOPICS, so it's safe). - purge: the every-worker handler now purges under lock_source (closing the use-after-free the leader's confirmation-under-lock prevents, previously unguarded on every non-publishing process) and pops the repo_locks entry it mints (invariant I4). - purge sweep: validate each dir name via _confined_clone_path before rmtree (the source-id SECURITY invariant every other deletion path enforces); the in-flight-defer branch drains the repo_locks entry it mints (I4). - git_fetcher: _get_current_branch_head distinguishes a permanent missing branch (KeyError -> 409) from a transient gutted object store (pygit2.GitError -> retryable 503). Performance - purge sweep: precompute the live source_id set once (O(scopes)) and filter dirs by O(1) membership with a periodic yield, replacing an O(dirs x scopes) double-sha256 scan that blocked the leader's event loop. Concurrency knob (finding 11) - service.py: bound phase-2 by max(1, SCOPES_GIT_MAX_WORKERS) instead of max(MAX_WORKERS, 32). 32 was a floor the knob could not lower, and it over-subscribed the default executor that also serves policy bundles. Docs in config.py + configuration.mdx corrected to match. Lifecycle - task: orphan sweep is always-on (independent of POLICY_REFRESH_INTERVAL, as documented); _periodic_polling no longer also sweeps (no duplicate scans / broadcasts); the boot sync_scopes is wrapped so a store hiccup can't kill the watcher task silently; stop() unsubscribes the purge handler and drains in-flight purges so shutdown can't abandon an rmtree. Docs (finding 8) - The per-fetch timeout is documented honestly as SOFT: it unblocks the event loop, not the thread; the pinned libgit2 enforces no read timeout, so a black-holed remote can pin the thread for the process's life, and SCOPES_GIT_MAX_ZOMBIES is the real bound (git_fetcher.py, config.py, configuration.mdx). Packaging - opal-client capped python_requires <3.13 to match opal-common/opal-server (it hard-depends on the capped opal-common, so 3.13 installs failed). Tests - cover the sync-path git_op_in_flight guard, the confirmed gate (was masked by an invalid source_id), recreate-after-delete under the fleet-purge design, the repo_locks-leak regressions, and the phase-2 knob bound; update the sweep/polling/perpass tests for the behavior changes. Bed: add a repo_locks-drain settle to hard_reset so the offline-repo test's invariant check can't race the post-restart boot sweep (flaky I4). 203 opal-server unit tests green; git-leak bed clean of invariant violations (the repoint/postgres timing tests flake intermittently on a loaded local box, unrelated to these changes — the broadcaster path is untouched). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXknyVRt9oHeJ4BHAauu5c
Round-2 review addressed — all 13 threads fixed in
|
zeevmoney
left a comment
There was a problem hiding this comment.
Changes requested — 5 HIGH, 5 MEDIUM, 2 LOW on the fix commit.
Ten of the thirteen round-2 findings are genuinely fixed and I verified each by mutation rather than by reading the reply. The ALL_TOPICS purge-channel guard now holds against every spelling I could construct (bare sentinel, in a list, mixed list, tuple, set; nested and None shapes fail closed) with no loss of legitimate channel=None fan-out. The phase-2 concurrency knob is a true ceiling and its replacement test discriminates. The confirmed-gate test now fails when the gate is deleted. The in-flight sync guard has real coverage. _periodic_polling genuinely no longer sweeps.
What follows is what the fix commit broke or left.
Blocking:
- HIGH
packages/opal-server/opal_server/scopes/task.py:65—unsubscribe([SCOPES_PURGE_CHANNEL])also removes the every-workerhandle_purge_message; all server-side subscriptions share one_subscriber_id, so the worker goes deaf to fleet purges (verified: 2 handlers → 0) - HIGH
packages/opal-server/opal_server/scopes/task.py:70— the purge drain is awaited beforesuper().stop()cancels the tasks holding the locks it waits on; shutdown can block forSCOPES_GIT_FETCH_TIMEOUTor indefinitely, inside the leadership lock (PoC hangs) - HIGH
packages/opal-server/opal_server/scopes/purge.py:337— a successful empty scope scan yields an empty live set, so every clone dir is reclaimed and a confirmed purge is broadcast for each; a wrong Redis DB index or an empty replica destroys the local clone tree fleet-wide - HIGH
packages/opal-server/opal_server/git_fetcher.py:560— theexcept TimeoutErrorearly return is still untested; the reply addressed_get_current_branch_headinstead, and the mutation ships green - HIGH
packages/opal-server/opal_server/scopes/purge.py:286— the leader avoids self-deadlock only because this pop precedes the confirmation publish; moving it after deadlocks the leader permanently and the full suite still reports 203 passed
Non-blocking:
- MEDIUM
packages/opal-server/opal_server/scopes/purge.py:104— the use-after-free lock, the sweep's path validation, andLeaderScopePurger.stop()each ship green when mutated away - MEDIUM
packages/opal-server/opal_server/scopes/purge.py:381— the orphan-heavy path is still O(orphans × scopes), one full store scan per candidate (2.9 s at N=2000) - MEDIUM
packages/opal-server/opal_server/scopes/task.py:80— bothtask.pyfixes ship green when reverted - MEDIUM
packages/opal-server/opal_server/scopes/service.py:283— the comment still argues for the behaviour the same commit reversed - MEDIUM
packages/opal-server/opal_server/git_fetcher.py:291— a negativeSCOPES_GIT_MAX_ZOMBIESrefuses every git op fleet-wide - LOW
packages/opal-server/opal_server/scopes/task.py:116— docstring contradicts the change made 60 lines above - LOW
documentation/docs/getting-started/configuration.mdx:486— re-breaksadade574's verbatim match forSCOPES_GIT_FETCH_TIMEOUT
Details are in the inline comments on each line.
Pre-existing, not inline-postable — but this PR makes the case for fixing it. api.py:309-315 still answers a missing scope record with HTTP 200 carrying the default scope's policy modules. Those lines are byte-identical to master (confirmed against the diff), so they are out of this PR's scope. But this PR removed the identical fallback from the clone-unavailable branch and wrote the reason at api.py:351-356 — "Serving the default scope's bundle here would hand a live tenant another tenant's policy" — so the PR itself establishes the record-missing branch as wrong and leaves it. _allowed_scoped_authenticator (api.py:89-98) authorizes the caller for the id they asked for, never for default, which api.py:390 then loads; and with OPAL_AUTH_PUBLIC_KEY unset the authenticator short-circuits entirely. A tenant whose record is briefly absent — a Redis failover, or the wiped-store case the finding above describes — has its PDP load and enforce another scope's Rego and see a 200 while doing it. scope_policy_fallback_test.py:117 currently pins this as "unchanged contract". Worth a follow-up issue rather than scope creep here.
Two smaller pre-existing items on the same function: api.py:396 runs make_bundle synchronously on the event loop while the live path correctly uses run_sync; and a ScopeNotFoundError escaping the fallback surfaces as an unhandled 500 rather than a 404, since there is no exception handler registered for it.
One operational note for the description: phase-2 sync concurrency drops from a hard 32 to SCOPES_GIT_MAX_WORKERS (default 10) — a 3.2× narrowing of the local change-check pass. Making the knob a real ceiling is right; the default change riding along with it is not called out anywhere.
…phan-sweep safety/perf, zombie clamp, doc drift Five HIGH / five MEDIUM / two LOW findings, all reproduced before fixing. Lifecycle (HIGH #1, #2): - The watcher's leader purge subscription now uses its OWN subscriber id. PubSubEndpoint files every server-side subscription under one shared id and EventNotifier.unsubscribe deletes that id's whole callback list for a topic, so unsubscribing by topic in stop() also dropped the every-worker handle_purge_message registered once at boot in server.py — leaving the process deaf to fleet purges for life, with nothing to re-add it. - stop() now cancels its tasks BEFORE draining in-flight purges, and bounds the drain (_PURGE_DRAIN_TIMEOUT=5s). The old order awaited lock_source held by a sync across a whole clone/fetch — unbounded when SCOPES_GIT_FETCH_TIMEOUT is 0 — whose release required the very cancellation the drain was blocking, while still holding the leadership lock. LeaderScopePurger gains signal_stop() and its "awaiting here cannot hang" docstring claim is gone (it was false). - stop() is idempotent, and the comment claiming start()/stop() run exactly once is corrected: __aexit__ and stop_server_background_tasks both call it. Orphan sweep (HIGH #3, MEDIUM #7): - A SUCCESSFUL empty store read is no longer taken as "everything is an orphan". ScopeRepository.all() is a Redis SCAN loop that returns zero keys and no error against a wrong or empty keyspace, so a REDIS_URL on the wrong DB index, a failover to an empty replica or a stray FLUSHDB would rmtree every tenant's clone and broadcast confirmed purges fleet-wide. Refused (error-logged) unless the new OPAL_SCOPES_ORPHAN_SWEEP_RECLAIM_ON_EMPTY_STORE opts in; the git-leak bed sets it, since its FLUSHALL gate wants exactly that reclaim. - The under-lock re-check now takes ONE fresh scopes.all() for the whole candidate batch instead of one per candidate (a full SCAN + a parse per record + two sha256 per live scope, each time): 2903ms -> ~10ms at 2000 all-orphan scopes, O(scopes + dirs) instead of O(orphans x scopes) Redis round trips. Validation and docs (MEDIUM #10, #9, LOW #11, #12): - SCOPES_GIT_MAX_ZOMBIES is clamped with max(0, ...). Unclamped, a negative value is truthy and `count >= cap` holds with nothing in flight, so the first git op was refused and no scope ever synced — while the one error line said "remotes appear stuck". - Deleted the service.py prose still arguing that phase 2 must NOT inherit phase 1's cap, which the same commit reversed. NOTE: phase-2 concurrency did drop from a hard 32 to SCOPES_GIT_MAX_WORKERS (default 10) — the knob is now a true ceiling, but that default change was not advertised. - Rewrote _periodic_orphan_sweep's docstring, which still described the sweep as living in _periodic_polling. - Restored OPAL_SCOPES_GIT_FETCH_TIMEOUT's configuration.mdx text verbatim from config.py (drifted again after adade57 fixed it once). - Documented the pop-before-publish invariant at both sites: publish() runs local subscribers inline, so handle_purge_message re-enters lock_source; the pop is what makes it mint a fresh lock instead of deadlocking. Addresses review comments: - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every guard the round-2 commit added shipped green when mutated away — verified on this head, full suite each time (204 passed with each fix reverted). This commit closes that: 21 tests, each one demonstrated to FAIL under exactly the mutation it pins and pass on clean code. | mutation reverted | pinning test | |---|---| | fetch-timeout `return` -> fallthrough | test_timed_out_fetch_does_not_stamp_last_fetched_or_notify | | worker handler drops lock_source | test_handle_purge_message_waits_for_the_source_lock | | repo_locks.pop moved after the confirm publish | test_inline_confirmation_delivery_does_not_deadlock | | LeaderScopePurger.stop skips the drain | test_stop_awaits_a_slow_pending_purge | | stop() unsubscribes by topic (shared id) | test_stop_does_not_unsubscribe_the_every_worker_purge_handler | | drain awaited before super().stop() | test_stop_cancels_lock_holders_before_draining_purges | | boot sync loses its try/except | test_sync_all_then_sweep_still_sweeps_when_sync_raises | | sweep timer re-gated on POLICY_REFRESH_INTERVAL | test_orphan_sweep_timer_starts_even_when_polling_is_enabled | | sweep skips dir-name validation | test_sweep_leaves_non_source_id_dirs_untouched | | empty-store guard removed | test_empty_store_with_clone_dirs_refuses_to_reclaim | | fresh re-read restored per candidate | test_sweep_issues_one_fresh_read_for_the_whole_candidate_batch | | negative zombie cap unclamped | test_negative_max_zombies_is_treated_as_no_cap | | configuration.mdx description reworded | config_docs_drift_test (new, parametrized over all 7 scopes keys) | Two of these needed a real EventNotifier-backed PubSubEndpoint rather than the recording fake every other purge test uses — inline local delivery is what makes the re-entrancy and the shared-subscriber-id bugs observable at all. Existing sweep tests were adapted to the new empty-store policy (they used an empty FakeScopeRepository with clone dirs present, which is now refused) and to the batched re-read: test_redis_wiped_boot_reclaims_everything became the refuse-by-default pair, and the raising-re-check test now asserts the pass aborts rather than keeping one dir. 225 passed (204 before). opal-common 93 passed. The one opal-client failure (data_updater_test.py::test_data_updater_with_report_callback) reproduces identically with these files reverted to HEAD — pre-existing, not from this series. Addresses review comments: - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…that needs it; bound the sweep's re-read staleness Setting OPAL_SCOPES_ORPHAN_SWEEP_RECLAIM_ON_EMPTY_STORE bed-wide was wrong twice over: every other test then exercised the behaviour the previous commit made opt-in rather than the shipped default, and it turned test_server_recovers_after_postgres_bounce red — the sweep reclaimed the clone of the scope PUT during the broadcaster outage, so it never became servable (503). That is the exact "deletes a live clone" mode the guard exists to prevent, caught by the bed. Verified: passes on fb1b823, failed 2/2 with the key on bed-wide, passes again with it off by default. - docker-compose.yml now interpolates ${OPAL_TEST_RECLAIM_ON_EMPTY_STORE:-false}, and test_redis_wiped_boot_reclaims_clones flips it on for its own container (force-recreate to pick up the env, then seed the clone, since a recreate empties the clone tree) and restores the default on teardown — the same idiom as OPAL_TEST_WORKERS in the multiworker fixture. - test_orphan_clone_dir_is_reclaimed now runs with a live scope present instead of an empty store. Its old shape (zero scopes + one stale dir) is precisely what the new guard refuses, and the shape worth gating is the production one: real scopes alongside a stale dir, where reclaiming the stale dir must not touch the live clone — which it now also asserts. - The sweep's fresh live-set read is re-taken every _FRESH_READ_EVERY=200 candidates instead of once per pass. A single read per pass leaves the whole pass's duration as a window in which a PUT that re-claims a source is unseen and its just-cloned dir reclaimed — the failure above. The cadence bounds that window while keeping the pass O(scopes x dirs/200 + dirs) rather than the O(orphans x scopes) the per-candidate read cost; it also serves as the loop's event-loop yield. Extracted as _fresh_live_source_ids (returns None to abort, keeping the conservative bias on a raising scan or an underivable scope). - test_sweep_refreshes_the_live_set_on_a_cadence pins it (fails when the read is pinned to `i == 0`), alongside the existing one-read-per-batch assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le fresh read The "Hybrid" paragraph still said the re-check uses ONE fresh scopes.all() for the whole candidate batch, while the paragraph below it (and the code) describe a read per batch of _FRESH_READ_EVERY candidates — the same two-sides-of-a-decision prose problem flagged at service.py:283 in the round-3 review, introduced by the follow-up commit's own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-3 review addressed — 12/12 threads (5 HIGH · 5 MEDIUM · 2 LOW)
Verification
Bed detail: Two behaviour changes worth stating outright
Bed changes, and one gate reshapedThe empty-store opt-in is scoped to the single test that wants it (
Corrections to my own commit messages
Latent bed bug found while verifying (not from this PR)The one bed failure is
When Fix would be one line in the seeder — make the seeded content per-repo distinct (e.g. embed the repo name in |
zeevmoney
left a comment
There was a problem hiding this comment.
Changes requested — 2 HIGH, 5 MEDIUM, 1 LOW.
All 12 round-3 findings are fixed, and this time the fixes are properly pinned: every mutation I ran against them now fails a specific named test. The two stop() HIGHs are correct, including the super().stop()-first ordering and the bounded drain — my round-3 hang PoC now returns instead of wedging, and the leader unsubscribe no longer takes the every-worker handler with it (2 handlers → 1, was 2 → 0). The except TimeoutError return, the use-after-free lock, the sweep's path validation, LeaderScopePurger.stop(), the boot-sync wrapper, the sweep-timer gate and the zombie clamp are each now mutation-detected. The new docs-drift guard genuinely catches a reworded description, and all 8 scopes keys round-trip verbatim.
Two of the fixes, however, introduced new problems, and that is now the third consecutive round where fixing a finding created a fresh one on the same surface. Both are in sweep_orphans, which has grown to 152 lines at cyclomatic complexity ~18 against the project's ≤100 / ≤8 — the next fix lands in that function too. Extracting the candidate-build pre-pass and the per-candidate reclaim tail, and sharing that tail with the leader path, would remove the duplication that let the pop-ordering invariant get pinned at one of two sites.
Blocking:
- HIGH
packages/opal-server/opal_server/scopes/purge.py:506— batching the fresh read moved it out from underlock_source, so a source re-claimed mid-pass has its freshly-cloned dir deleted and aconfirmed=truepurge broadcast for it. Reproduced three times independently; the same probe passes onfb1b823d, so it is a regression from this round. At the shippedPOLICY_REFRESH_INTERVAL=0there is no periodic re-sync, so the tenant 503s until a PUT, webhook or restart. The comment at:510-514still asserts the old guarantee, andtest_sweep_issues_one_fresh_read_for_the_whole_candidate_batchnow pins the stale behaviour. - HIGH
packages/opal-server/opal_server/scopes/purge.py:392— the new gate fires only on a zero-scope read, so a wrong-but-non-empty keyspace still reclaims everything: 5/5 production clone dirs deleted with the flag at its safe default. The key's description names "aREDIS_URLpointed at the wrong DB index" as covered; it is covered only when that DB is empty. The round-3 suggestion's second half — a per-pass ceiling — is what catches this, and is the part that did not ship.
Non-blocking:
- MEDIUM
packages/opal-server/opal_server/scopes/purge.py:543— the sweep's pop-before-publish ordering and its per-candidatelock_sourceboth ship green when removed; the leader twin of the first is pinned, the sweep is not - MEDIUM
packages/opal-server/opal_server/tests/config_docs_drift_test.py:60— two vacuous-pass modes: 7 skipped if the docs file moves, and 7 passed with the verbatim text pasted under another key's heading - MEDIUM
packages/opal-server/opal_server/scopes/task.py:114— this comment and two others promise a boot-sweep backstop that the default-off gate makes unreachable once the last scope is deleted (verified: dir survives three sweeps) - MEDIUM
app-tests/git-leak/test_boot_states.py:175— the destructive opt-in is exported before thetry, so one setup failure leaves it enabled for the rest of the bed run - MEDIUM
packages/opal-server/opal_server/scopes/purge.py:404— the refusal is log-only with no metric, and a mid-pass abort discards both the reclaim count and the heartbeat - LOW
packages/opal-server/opal_server/scopes/purge.py:538— theOSErrorbranchcontinues past therepo_locks.pop, leaking a stray lock per permanently-unreclaimable dir
Details are in the inline comments on each line.
Also noted, not filed inline. _FRESH_READ_EVERY = 200 serves two opposing purposes (freshness bound vs yield cadence) and is not a config key — folded into the first HIGH, since fixing that one should split them. git_fetcher.py:191 still reads SCOPES_GIT_MAX_ZOMBIES raw while :294 reads it clamped; inert today only because the clamp makes the negative case unreachable, so it is dead-but-wrong code and the clamp comment ("clamped like every sibling knob") overstates it — worth hoisting into one helper. _scope_sharing_source's docstring still claims the orphan sweep reuses it, which stopped being true this round, and its excluded_scope_id parameter is now never passed non-None outside tests. _may_reclaim receives raw dir_names before source-id validation, so a single unrelated entry under git_sources/ (a backup/, a lost+found) makes a legitimately-empty store log a false ERROR every 300 s with a wrong count — the same one-line change as the second HIGH fixes both. test_recreate_after_delete_serializes_and_sees_clean_caches is still tautological (built with pubsub_endpoint=None, so publish never runs; it passes with lock_source removed from purge_source_if_unshared entirely) — the behaviour it names is covered by three other tests, so this is a misleading artefact rather than a coverage hole, but it will mislead the next reader. One latent trap: _may_reclaim gates on the raw record count while the delete decision uses the git-filtered set, so if Scope.policy ever gains a second union member, a deployment using only the new type reads non-empty and reclaims its whole tree — gating on the derived set closes it now, cheaply.
Still open, pre-existing, unchanged this round. api.py:309-315 continues to answer a missing scope record with HTTP 200 carrying the default scope's policy modules, while _allowed_scoped_authenticator only ever authorizes the caller for the id they requested. api.py is not in this round's diff, so it stays out of scope here — but the PR removed the identical fallback from the clone-unavailable branch and documented why at api.py:351-356, so the argument for fixing the twin is already written into the file. Worth a follow-up issue.
…ess, add a reclaim ceiling, make refusals observable Round-3's batched fresh read was my change and it was a real regression: the read moved OUT of the per-candidate lock_source, so the set backing each delete decision aged by every lock acquisition and rmtree before it. A PUT re-claiming a source in that window was invisible and a LIVE tenant's clone dir was deleted, plus a confirmed orphan purge broadcast fleet-wide. My own docstring called that "a spurious reclaim plus a re-clone on the scope's next sync" — wrong at the shipped defaults: POLICY_REFRESH_INTERVAL=0 means _periodic_polling never runs, nothing re-clones, and the scope serves 503 until a webhook or manual refresh-all. Reviewer's reproduction matched, and the probe passes on fb1b823. - The authoritative read is back inside `async with lock_source(name)`, per candidate (`_classify_candidate`), returning claimed / orphan / abort. There is no targeted lookup to make it cheaper: ScopeRepository is keyed by scope_id and `all()` is a SCAN, so this is the reviewer's stated fallback — batched set as pre-filter only. The round-3 perf win is kept where it actually mattered: an all-live pass still costs ONE scan (pinned by test), because only dirs that already look orphaned reach the per-candidate read. - `_FRESH_READ_EVERY` is gone. It was asked to be both a freshness bound and the event-loop yield cadence, which pull opposite ways; the yield cadence is now `_YIELD_EVERY` and means only what its name says. - NEW: `_reclaim_is_plausible` + OPAL_SCOPES_ORPHAN_SWEEP_MAX_RECLAIM_FRACTION (default 0.5). The zero-scope guard only catches a store that returns NOTHING; a store pointed at a wrong-but-populated keyspace answers successfully with someone else's scopes, none of whose source_ids match, so every local clone looks orphaned and the whole tree goes. The ceiling needs no way to tell a wrong store from a right one — it only notices that one pass is about to delete an implausible share. A single candidate is always allowed (the ordinary case), and an operator can raise or disable the fraction for a deliberate SCOPES_REPO_CLONES_SHARDS reshard. An opted-in empty-store reclaim skips the ceiling: that flag is already a declaration of intent for a mass reclaim. - Observability: metrics.event("ScopeOrphanSweepRefused") on every refusal (empty_store / implausible_share / scan_failed), and the heartbeat now fires on EVERY exit path with an explicit outcome and the partial reclaim count — an aborted pass no longer reads like a healthy "swept, found nothing", and a pass that deleted N dirs before aborting no longer reports nothing about them. - A failed rmtree no longer `continue`s past `repo_locks.pop`: the pop is in a `finally` (still before the confirmation publish, per the documented ordering), so a permanently-unreclaimable dir stops leaking one stray lock per pass (invariant I4). A symlink where a clone dir should be is now logged at error as the anomaly it is, not as a recurring reclaim failure. - Three comments promised that the shutdown drain's abandoned dirs are reclaimed by the next boot's sweep. With the empty-store refusal at its default that is false when the abandoned dir was the last one; all three now name the exception. Addresses review comments: - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uous passes Nine tests added or replaced, and the two that pinned the stale batched design are gone (they would have failed the fix — the reviewer called that out). Mutation-verified, full suite each time; each mutation fails exactly the test that pins it: | mutation | caught by | |---|---| | hoist the fresh read out of the per-candidate lock (the round-3 shape) | test_candidate_that_goes_live_mid_pass_is_kept | | drop the plausibility ceiling | test_wrong_but_populated_keyspace_is_refused_by_the_ceiling | | let the ceiling veto an opted-in empty-store reclaim | test_opted_in_empty_store_reclaim_is_not_vetoed_by_the_ceiling | | failed rmtree continues past the repo_locks pop | test_failed_rmtree_leaves_no_stray_repo_lock | | sweep's pop moved below the confirmation publish | test_sweep_inline_confirmation_does_not_deadlock | | neutralise the per-candidate lock_source | test_sweep_waits_for_the_source_lock_before_deleting | | heartbeat only on the "complete" path | test_heartbeat_reports_outcome_and_partial_count_on_abort | The two sweep invariants that had NO coverage at all — the pop-before-publish ordering and the per-candidate lock — are covered now. Both needed a real EventNotifier-backed PubSubEndpoint: every sweep test used FakePubSubEndpoint, which appends to a list and never delivers inline, so no sweep test could observe the re-entrancy those guards exist for. The deadlock mutation now fails fast via asyncio.wait_for instead of wedging CI. test_all_live_pass_costs_one_scan keeps the round-3 perf property pinned (one scan for an all-live tree) so restoring per-candidate freshness cannot silently reintroduce O(dirs x scopes) for the common case. Drift guard: both demonstrated vacuous-pass modes are closed, and verified by breaking the DOCS against the unmutated guard rather than by mutating the test (mutating a test's own strictness is undetectable by construction): - docs file moved/renamed -> now FAILS inside a checkout instead of skipping all 7 cases (it only skips where documentation/ is genuinely absent, i.e. an installed package); - a key's heading renamed -> FAILS. This needed anchoring the heading match with its trailing newline: `#### OPAL_FOO` is a prefix of `#### OPAL_FOO_BAR`, so my first attempt at this fix still passed on a renamed heading; - a key's body paraphrased while the verbatim text sits under another key's heading -> FAILS, because assertions are now sliced to the key's own section. Plus each key's documented `Default:` must match config.py. 236 passed (226 before). opal-common 93 passed. Addresses review comments: - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… whole lifetime OPAL_TEST_RECLAIM_ON_EMPTY_STORE was exported before the `try:` whose `finally:` restores it, leaving the recreate, wait_healthy, put_scope and a 300s wait_until outside the protected region. Any of those raising left the variable "true" for the rest of the pytest process — and compose() inherits os.environ, so every later test that recreates opal_server would boot with the destructive reclaim on, which is exactly what this test's own docstring rules out and what the README's gate matrix depends on. The bed already caught this class of mistake once. The `reclaim_on_empty_store` fixture now owns the variable from the moment it is set: its teardown runs whether setup succeeded or not, and it pop()s rather than writing "false" so the compose default stays authoritative. Bed verified with the round-4 source changes (--boot-scopes=20): 7/7 — test_orphan_clone_dir_is_reclaimed, test_redis_wiped_boot_reclaims_clones (via this fixture), test_shard_reconfig_still_serves_but_orphans_old_clones (its single-candidate reclaim is unaffected by the new plausibility ceiling), test_boot_with_unreachable_remotes_still_serves_healthy, warm boot, corrupt clone, and test_churn_releases_caches. Addresses review comments: - #924 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-4 addressed — 8/8 threads (2 HIGH · 5 MEDIUM · 1 LOW)
The HIGH on the batched read was a real regression, and it was mineRound 3's batching moved the authoritative read out of Worth being explicit about the trajectory on that one block, since it has now moved three times: round 2 "O(M²), too slow" → round 3 "batch it" → round 4 "batching deletes live clones". The endpoint satisfies both constraints at once rather than trading them: the precomputed set stays as a pre-filter, so an all-live pass is still ONE scan (now pinned by Verification
Two of the eight were pre-existing, not from round 3: the New key
Things I found by applying your own review lenses to my diff before pushingSince three lenses — mutation-test every guard, check every comment against the code, check observability — produced five of this round's eight findings, I ran them myself first. They caught, in my own new code: a heartbeat claim of "every exit path" that the no-clone-dir return didn't honour (now does, pinned); a missing metric on the One judgement call I made differentlyOn |
…y locks on the sweep's keep/abort paths, unbounded read under the lock, unpinned guards Ran the 12 lenses reconstructed from all 56 review findings on this PR against the round-4 code before waiting for a round 5. Five defects, four of them in the code I wrote yesterday. 1. **Stray repo_locks entries (invariant I4).** The pop only covered the deletion attempt, so the two paths that DECLINE to delete — keep-on-error and the mid-pass abort — each left behind the entry lock_source minted for a candidate, i.e. a lock with no live scope. Exactly the class already filed twice (the in-flight branch, then the OSError branch). PoC before/after, both paths: `I4-violating paths: 2/2` -> `0/2`. The pop is now a `finally` around the whole locked block (still before the confirmation publish, per the documented ordering), which also makes the in-flight branch's explicit pop redundant. 2. **Unbounded store read held under a source lock.** `_classify_candidate`'s read has to be inside `lock_source` to be authoritative, but `RedisDB` is built with no socket_timeout/socket_connect_timeout — so an unreachable store would pin that source's lock for the life of the process and block every sync for it. Now bounded by `_STORE_READ_TIMEOUT` (10s); expiry keeps the candidate, so no deletion decision is ever made on a timed-out read. Deliberately a constant and not a knob: there is nothing to tune when the failure mode is already safe. 3. **A comment asserting something false** — the candidate loop claimed "every check below is O(1) or a bounded await" while that same read was unbounded. Rewritten. This is the lens that has produced 9 findings on this PR. 4. **An out-of-range reclaim fraction silently disabled a safety ceiling.** 1.5, 2 or -1 all disable it; disabling is the right reading of the intent, but doing it silently turns a typo into a disabled guard — the shape of the unclamped negative SCOPES_GIT_MAX_ZOMBIES finding. Warns once now (one-shot latch, mirroring `_zombie_cap_logged`), never per-pass. 5. **An unpinned guard.** Mutation-testing all ten load-bearing guards in the PR found nine pinned and one not: deleting the preload drain-timeout warning left the suite green, and that warning is the only signal that git threads survived into the forked workers. Two tests now (fires on timeout, silent on a clean drain, so it cannot pass by always-warning). Also hardened `test_failed_rmtree_leaves_no_stray_repo_lock`, which could have passed vacuously if a future guard stopped the candidate from ever reaching the deletion — it now proves the reclaim was attempted. Same critique the reviewer made of the drift guard, applied to my own test. The refusal log for the mass-reclaim ceiling now names bulk-delete-with-lost- broadcasts alongside a reshard, so on-call is not sent to REDIS_URL when the store is fine. Mutation-verified: F3 (PoC + test), F4, F4b (the latch), F1, and F2 (drop the wait_for -> the hung-store test fails) all fail their pinning test and pass clean. 241 passed (236 before). opal-common 93. The three docs attacks on the drift guard still fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ss; refresh the matrix for post-PR3 reality
`test_scope_repoint_releases_old_repo_cache` — PR3's update-path gate — has been
failing for a reason that has nothing to do with the server: it never reached the
cache-drain assertion it exists for, dying instead at its precondition ("scope
never switched to serving the re-pointed content", test_leak.py:236).
Cause: the seeder wrote byte-identical content into every repo and committed with
a fixed author and message, so a repo's commit sha was determined solely by the
wall-clock second it was pushed in. `docker compose down` drops the gitea volume,
so every teardown forces a full re-seed, and 20 repos pushed in a loop routinely
share a second. Two repos with the same sha serve byte-identical bundles, and the
test's "did the content change?" check can then never become true — it burns its
300s poll and fails. Measured: a 3-way sha collision in one sample; after the fix,
20/20 repos have distinct shas. That also explains why it passed in isolation
(reusing a volume whose two repos happened to straddle a second) and failed in
full runs.
`_data_json_for(name)` embeds the repo name in data.json, so the tree — and the
sha — is unique per repo. The repoint gate is the only consumer of bundle bytes
in the bed, and the other app-test suite uses its own repo, so nothing else is
affected. PR3's repoint purge itself was always working: during a wedged run the
server logged the purge firing and drained the old source's entries, leaving one
clone dir (the new source's).
Full bed after the fix: 20/21, with the repoint gate green.
Matrix refreshed to describe the post-PR3 state instead of the pre-PR2 one: ten
rows that still said "FAILS"/"unowned" now say what they guard and since when,
keeping the "fails without X" wording as the reason each gate exists. Six
docstrings that still said "RED until PR3" updated likewise. `test_boot_loads_all_scopes`
deliberately still points forward — it is PR4's gate via BOOT_TARGET_SECONDS.
The one row that is honestly red is documented as such: assertion (d) of
test_server_recovers_after_postgres_bounce. See that row for the mechanism —
it is a gap in the merged broadcaster work, not in PR3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…baseline The previous wording asserted this gate was "not owned by PR3" on the strength of one passing run at fb1b823 — which is a commit ON this branch (the round-2 state), not a pre-PR3 baseline. That comparison could not support the claim. Measured properly against origin/master, which IS this branch's merge-base (master has not moved since the fork) and already carries the bed, the debug-stats endpoint and the #933 broadcaster work: origin/master (pre-PR3): 1 pass / 4 runs PR3 head: 2 passes / 6 runs Same assertion (d), same line (test_resilience.py:228), same message on both. The only difference is the status code in the failure text — 500 on master, 503 on this branch — because PR3 turns the clone-vanish case into a retryable 503, which is an improvement, not a regression. So the conclusion stands but is now evidence-backed rather than assumed: the gate is intermittently red on master and PR3 neither introduced nor worsened it. The test file itself is untouched by this branch, as is every BROADCAST_* setting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ct, not a broadcaster gap Correcting my own row from the previous commit, which called (d) "a genuine gap in the already-merged broadcaster work" needing "a follow-up on the broadcaster". That overstates it, and the PR description already says so in its "Known limitation" section — which Zeev's round-2 comment even pointed at. A worker receives backbone messages only if its broadcaster reader is running, and that starts via STATISTICS_ENABLED (default off) or a connected websocket client (PubSubEndpoint.main_loop enters the broadcaster context). subscribe() alone does not start it. This bed runs with no opal-client service and statistics off, so the non-leader worker is deaf to the backbone and a publish it buffers during the outage never replays. The leader DOES have a reader, via the watcher's listening context — which is exactly why assertions (a)-(c) pass and only (d) fails. In a deployment with clients connected (PDPs hold long-lived websockets across the workers) or statistics on, the reader runs and the replay works. The measured baseline from the previous commit stands and is kept: origin/master 1 pass / 4 runs vs the PR3 head 2 passes / 6 runs, same assertion, same line. The only PR3-attributable difference remains the status code in the failure text (500 -> a retryable 503). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zeevmoney
left a comment
There was a problem hiding this comment.
Changes requested — 4 HIGH, 9 MEDIUM.
All eight round-4 findings are addressed in the code, and the fixes are pinned better than in any previous round: the per-candidate freshness fix works (a source re-claimed mid-sweep now keeps its clone dir), the ceiling refuses the exact wrong-keyspace scenario, both previously-unpinned sweep guards now fail named tests when reverted, the drift guard's heading-binding and .mdx-rename holes are genuinely closed, and the bed fixture now owns the destructive opt-in for its whole lifetime. e281db4e's self-audit was a good instinct and two of its three claims verify cleanly.
The pattern that has held for three rounds still holds here: the fixes introduced four new HIGH-severity issues on the same surface. Three of them are in _sweep_pass, which is now 153 lines at cyclomatic complexity ~22 against the project's ≤100/≤8 — it was 152/~18 last round, so the split moved code without reducing it. This function has produced a fresh HIGH in each of rounds 3, 4 and 5. Extracting the per-candidate body (lock → classify → in-flight → delete → publish) into its own coroutine, and sharing the reclaim tail with the leader path, is the change most likely to end that cycle — every one of these findings has been an interaction between guards that live too far apart to be read together.
Blocking:
- HIGH
packages/opal-server/opal_server/scopes/purge.py:454— the ceiling's denominator counts dirs that can never be candidates, so six non-source-id directories restore the round-4 wipe in full: 5 junk dirs refuses, 6 deletes all 5 production clones and broadcasts 5 purges - HIGH
packages/opal-server/opal_server/scopes/purge.py:419— a share-based ceiling only saturates on total store loss; a right-but-incomplete read (replica lag,allkeys-lruevicting scope keys, partial restore) deletes live tenants' clones inside the allowance — 40 of 100 measured, and the under-lock re-check reads the same degraded store - HIGH
packages/opal-server/opal_server/scopes/purge.py:390— a store read exceeding_STORE_READ_TIMEOUT, or one record whosesource_id()raises, turns every candidate into "claimed" permanently; both emit warnings with no metric and then log the pass ascomplete - HIGH
packages/opal-server/opal_server/tests/orphan_sweep_test.py:444— the dedicated pin for the round-4 freshness fix never enters the code path (it patchessource_idafter seeding the fillers, so the pass aborts on the ceiling); the mutation its own docstring names leaves it green
Non-blocking:
- MEDIUM
packages/opal-server/opal_server/scopes/purge.py:456— the refusal tells the operator to raise the fraction, which can never permit a whole-tree reclaim; and0/1disable the ceiling silently while typos warn - MEDIUM
packages/opal-server/opal_server/tests/config_docs_drift_test.py:86— three remaining holes: a docs-tree move still skips,Default:is asserted against the runtime value so anyOPAL_SCOPES_*in the environment reds it, and_TRACKED_KEYSis hand-maintained - MEDIUM
packages/opal-server/opal_server/tests/preload_reset_test.py:150—assert "3" in warned[0]is satisfied by the timestamp and line number - MEDIUM
packages/opal-server/opal_server/scopes/purge.py:329— cancelling mid-rmtreedeletes the dir but never publishes the confirmation, and the sweep is not covered by the shutdown drain - MEDIUM
packages/opal-server/opal_server/scopes/purge.py:582— the new Datadog event ships a raw exception repr from the scope store, on a channel log redaction does not cover - MEDIUM
app-tests/git-leak/README.md:63— the section's premise is false (PR1 is onmaster, so the bed does run there), and the postgres-bounce reclassification claims more than its evidence supports - MEDIUM
packages/opal-server/opal_server/scopes/purge.py:469— the refusal metric has no test on any of its three paths - MEDIUM
packages/opal-server/opal_server/scopes/purge.py:286— the self-audit bounded the sweep's read and left the identical unbounded read underlock_sourcein the leader delete path - MEDIUM
packages/opal-server/opal_server/scopes/purge.py:385— restoring per-candidate freshness restored the O(orphans × scopes) cost the previous round removed; the two fixes are oscillating
Details are in the inline comments on each line.
On the oscillation specifically. Round 4 batched the store read for speed and that caused live clone deletion; round 5 un-batched it for correctness and that restored the quadratic cost. Both properties are required, and neither round could have both because the read answers the wrong question — it fetches every scope in order to decide one source. A source_id -> scope_id index (written on scope put/delete) makes the per-candidate check O(1) and ends the trade. That is the one structural change I would prioritise over any individual finding above.
Not a finding, for the record. An automated pass flagged the python_requires=">=3.9,<3.13" cap on opal-client as an out-of-scope change. That cap is the fix requested in the round-2 review — leaving opal-client uncapped while its hard dependency opal-common was capped made pip install opal-client fail on 3.13 against a transitive package. It is correct as landed.
Still open, pre-existing, unchanged. api.py:309-315 continues to answer a missing scope record with HTTP 200 carrying the default scope's policy modules, while _allowed_scoped_authenticator only authorizes the caller for the id they requested. Out of scope for this PR — the file is untouched again this round — but the argument for fixing it is already written into the file at api.py:351-356.
zeevmoney
left a comment
There was a problem hiding this comment.
Follow-up detail on one already-filed thread — no new findings, and no change to the previous verdict.
The _STORE_READ_TIMEOUT thread understated how reachable that condition is: ScopeRepository.all() issues an awaited GET per key, so a single read is O(scopes) sequential round trips rather than one command. That moves "the sweep silently becomes a permanent no-op" from large stores to ordinary scale, and it means tuning the timeout is the wrong lever in both directions. Details in the reply on that thread.
…ation + a per-pass cap The round-4 ceiling was wrong-shaped, and four of this round's findings are it. Rather than patch the share arithmetic again, this replaces it — and DELETES the key it introduced, so net config surface goes down. - **The denominator counted dirs that could never be candidates.** `len(dir_names)` was the raw listing while the loop dropped every name `_confined_clone_path` rejects, so each junk entry raised the ceiling for free: measured, 6 stray dirs (a `clone.bak`, a `lost+found` on a PVC, a symlink — scandir follows them) let a wrong-keyspace read delete all 5 production clones. The same raw count also made a legitimately empty store report "1 clone dirs exist" and refuse forever. Both guards now judge validated names only. - **A share can only saturate on TOTAL store loss.** A store that is correct but incomplete — a replica seconds behind a failover, an LRU eviction of `permit.io/Scope:*` (SET with no TTL, so evictable), a partial restore — produces a sub-threshold orphan set that sails through, and the under-lock re-check reads the same degraded store so it confirms the wrong answer. Measured at the old default: 40 of 100 tenants missing from the read cost 40 live clones. Replaced by corroboration: a dir must look orphaned for _REQUIRED_ORPHAN_STREAK consecutive passes, so a transient gap costs one pass of delay instead of a tenant outage. - **O(orphans x scopes) is gone for good.** Per-candidate freshness is kept — it is what stops a stale set deleting a live tenant's clone — but only dirs that are corroborated AND within SCOPES_ORPHAN_SWEEP_MAX_RECLAIM_PER_PASS pay for a read, so a pass is O(cap) reads, not O(orphans). The two properties that have been traded against each other for three rounds now both hold. - **The disable values no longer hide.** 0/1 silently disabled the old fraction while typos warned — backwards for a safety control. Disabling the cap now logs once, and "MAX_RECLAIM_FRACTION=0 means no ceiling" is gone with the key. Also in the sweep, from the same round: - Two keep-on-error paths returned "claimed" forever and the pass then reported `complete` — a monitor watching the heartbeat saw success while the backstop was off. They now return "undecided", carry a metric (`store_read_timeout`, `recheck_failed`, `unresolvable_scope`), and make the outcome `degraded (N)`. A record whose source_id will not derive is handled per scope, so it no longer aborts the whole pass. - `_STORE_READ_TIMEOUT` is a config key (`SCOPES_ORPHAN_SWEEP_STORE_READ_TIMEOUT`): 10s is not a universal constant for a read that is a SCAN plus a GET per key. - The leader delete path held `lock_source` across the same unbounded `find_scope_sharing_source` read the self-audit bounded in the sweep. Same wrapper, same keep-the-clone fail-safe. - Reclaim + confirm is now one task registered in the set the watcher's bounded drain awaits, and shielded. run_sync dispatches rmtree to the default executor, so a cancellation deleted the dir and skipped the confirmation — leaving every other worker holding a handle for a directory that no longer exists. - The scan-failure metric no longer ships a raw exception repr to Datadog (a pydantic error over a tenant record, on a channel the log redactor does not cover); the type goes in a tag, counts move to the message. Addresses review comments: - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e the drift guard's last holes The freshness regression test could not fail. It patched `source_id` AFTER seeding the filler clone dirs, so their on-disk names never matched the patched live set; all 8 dirs became candidates, the pass refused before entering the loop, and the victim survived for a reason unrelated to freshness. Confirmed by running it against its own named mutation — the suite went red, but on two OTHER tests while this one passed. That is a flaw in how I verified, not just in the test: my mutation matrix checked "does the suite go red", so another test failing counted as caught. It now names the test that must fail. Re-verified per test: the hoisted-read mutation fails THIS test in isolation, and it asserts the deletion loop was actually entered so it cannot silently go vacuous again. It is also order-independent — which dir goes live is chosen from whichever the pass deletes first, since os.scandir order is not guaranteed. New behaviour pinned (each mutation-verified against the specific test): corroboration keeps a live clone through a transient store gap; the cap bounds one pass and the backlog drains over later ones; unrecognised dirs change no guard's arithmetic; disabling the cap is announced exactly once; an undecidable pass reports `degraded`, not `complete`; the refusal metric fires with its reason tag on each path; a cancelled sweep still publishes its confirmation. `preload_reset_test`'s `assert "3" in warned[0]` could not fail — `warned[0]` is the formatted record, and the timestamp and line number supply a "3". It now asserts the rendered payload (`"in flight (3)"`), verified by removing the count from the message. Drift guard, all three remaining holes, each verified by attacking the docs with the guard unmutated: - relocating the whole `documentation/` tree skipped every key (the checkout test asked the very tree whose move it was meant to notice); it now detects a checkout by `.git`/`packages/`, and a moved reference FAILS — 10 failed. - `Default:` compared the RUNTIME value, so exporting `OPAL_SCOPES_*` (this repo's own bed does) reddened a doc-vs-source test with a doc-vs-environment mismatch; it now compares the literal declared in config.py — env override no longer fails. - `_TRACKED_KEYS` was hand-maintained with the invariant only in a comment; keys are now derived from config.py, so a new undocumented key fails — 1 failed. The bed README's premise that the suite "cannot run against master" was false — the stats endpoint and the whole bed are on master, which is how the baseline in that very row was measured. Corrected, and the postgres-bounce row now separates what the measurement supports (PR3 did not introduce it) from the mechanism (reader-gated buffer replay), which is a production property, not a bed artifact. 245 server + 93 common pass. Addresses review comments: - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) - #924 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orated The sweep redesign changed a real contract: a dir is reclaimed on the SECOND consecutive pass that finds it orphaned, not the first. Both gates that assert a reclaim triggered one refresh-all and waited 60s against the 300s timer, so they went red — the bed doing its job. They now trigger twice, and each asserts the dir is still present after the first pass. That assertion is the point: without it both would pass just as well with corroboration removed, which is the vacuous-pin shape this review round was full of. test_redis_wiped_boot_reclaims_clones needed no change — the opted-in empty-store path is a declared mass reclaim and deliberately bypasses corroboration. Behaviour worth stating plainly, and now in the README rows: a leaked dir now lingers up to two sweep intervals instead of one (~10 min at the shipped 300s). That is the price of not deleting live tenants' clones when the store answers wrongly — and the DELETE path still removes dirs inline, so this only affects what the backstop catches. For a reshard, which orphans many dirs at once, SCOPES_ORPHAN_SWEEP_MAX_RECLAIM_PER_PASS decides how many passes the drain takes. Bed: test_boot_states.py 6/6, test_leak.py 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-5 addressed — 13/13 threads (4 HIGH · 9 MEDIUM)
The four HIGHs were three findings about one thing
Rather than patch the arithmetic a third time, the ceiling is deleted — and
The finding that mattered most was about my verification, not the code
The real defect was in how I verify: my mutation matrix asserted "does the suite go red", so when the hoisted-read mutation failed two other tests it counted as caught while the test that exists to pin it passed. It now names the test that must fail. Every fix this round is verified that way — the mutation fails its specific test in isolation. Two more of my assertions turned out to be dead on inspection: Verification
One behaviour change, stated plainlyA leaked dir is now reclaimed on the second consecutive pass, so it lingers up to two sweep intervals (~10 min at the shipped 300s) instead of one. That is the price of not deleting a live tenant's clone when the store answers wrongly; the DELETE path still removes dirs inline, so this only affects what the backstop catches. Both bed gates that encoded the one-pass contract were updated — and now also assert the dir survives the first pass, so they would catch corroboration being removed. One point I pushed back onWhen a scope record's Not done deliberatelyThe |
PR3 — Git resilience + the deferred items from the leak series
Closes PER-15157.
Ships PR3's original charter (no git operation can hang the server) plus the six items deferred out of PR2 (#923), and — because the resilience fix required it — the parallel scope loading originally scoped as PR4 (see §6; PR4 is closed as redundant). Design:
docs/superpowers/specs/2026-07-19-pr3-deferred-items-design.md.1. Git resilience — hung remotes can't starve healthy scopes
Scope clone/fetch used pygit2 with no timeout, on the shared default executor.
POLICY_REPO_CLONE_TIMEOUTis wired only to the legacy non-scopes path, so scopes mode had no bound at all: boot (preload_scopes()) blocked indefinitely on one unreachable repo, and in steady state a hung op held the per-source lock and a shared-pool thread.SCOPES_GIT_FETCH_TIMEOUT) on clone and fetch.SCOPES_GIT_MAX_WORKERSbounds only live ops via a semaphore. A timed-out op releases its capacity slot immediately — its pygit2 call lingers on a private thread until the OS gives up, but no longer consumes capacity. (A fixed pool starves once lingering threads exceed its size: 40 blackholed repos vs. 10 slots meant healthy scopes never got a thread. That's thetest_offline_repo_does_not_block_healthy_scopesgate.)git_op_in_flight(source_id)guards every path that would free a pygit2 handle or delete a clone dir while a thread may still be using it.2. Fleet-wide cache purge — two-phase (deferred items 1 + 3)
PR2's purge was process-local: only the worker serving the DELETE dropped its caches, so the leader — which populates most of
repos/repo_locks/repos_last_fetched— leaked until restart. A non-leader DELETE alsormtree'd the shared clone tree, breaking the "only the leader mutates the clone tree" invariant.New every-worker channel
SCOPES_PURGE_CHANNEL(__opal_scope_purge__, following the__opal_stats_*convention), carrying a two-phase purge:confirmed=False).lock_source, then disk work — in a background task, never on the publish path (publish()awaits subscriber callbacks inline, so an inline handler would put a lock wait on DELETE/PUT latency).confirmed=True), and that is what every worker's memory handler acts on.Phase 2 exists because purging on the raw request over-purges: a source shared by a sibling scope had its cache dropped while the sibling still used it. Only the leader can sibling-check, so only the leader may authorize a memory purge.
PUT /scopeswith a changedsource_idpublishes a purge for the old source. Same channel, same handlers.confirmedis additive with aFalsedefault.3. Scope-liveness check before clone (item 2)
A sync that loaded a scope before its DELETE and reached it mid-sync re-cloned the dead scope and re-populated the caches.
GitPolicyFetchernow takes a liveness probe, checked underlock_sourceimmediately before the clone;ScopesServicesupplies one that re-reads from Redis and also confirms the scope still points at this source (so a repointed scope's stale sync doesn't resurrect the old clone either). Fails open on store errors.4. Orphan clone-dir sweep (item 6)
Leader-only reconciliation after boot, periodic, and refresh-triggered syncs: clone dirs under
git_sources/referencing no live scope are reclaimed. One set-difference covers crash orphans, redis-wiped boots, and old-shard dirs after aSCOPES_REPO_CLONES_SHARDSchange. A store-scan error aborts the sweep (a transient Redis error must never read as "no scopes exist"); each orphan is re-checked under its own lock and skipped while a git op is in flight.5.⚠️ Behavior change — retryable 503 for a live scope's broken clone (item 5)
GET /scopes/{scope_id}/policy:503+Retry-After: 5make_bundleraises rawOSError503+Retry-After: 5A live tenant was briefly served another tenant's policy. The condition is transient by construction (recovery or the next sync re-creates the clone), so a retryable error is the honest answer.
opal-clienttolerates this: itsPolicyFetcherretries with tenacity backoff and a failed cycle is skipped, not fatal — it does not readRetry-After(uses its own backoff). Direct consumers of this endpoint should expect 503 and retry.6. Parallel scope loading — the ~20-minute boot fix (folds in PR4)
sync_scopes(boot preload and every periodic/refresh sweep) was serial: oneawait sync_scope(...)per scope. With a fleet of repos that meant a ~20-minute boot, and — once §1 added a per-fetch timeout — a serial loop would still stall healthy scopes behind each unreachable repo's timeout. So the resilience fix in §1 requires concurrency to actually deliver "one offline repo can't block the others"; the two ship together and PR4 (which planned this same work as a standalone change) is closed as redundant.The sweep runs in two phases, each
asyncio.gatherunder its own semaphore:SCOPES_GIT_MAX_WORKERS. Combined with the zombie-aware executor (§1), a hung remote consumes only its own slot until its timeout, then releases it._should_fetchreturnsFalse, so there's no network fetch — just a disk open + change-check + notify. It therefore gets a separate, wider bound (max(SCOPES_GIT_MAX_WORKERS, 32)) instead of inheriting phase 1's network cap;32matches asyncio's default thread-pool ceiling, which is what actually limits those disk opens. Any rare re-fetch phase 2 does trigger (a branch missing after phase 1) is still throttled by the inner live-ops semaphore, so the network is never over-driven.Per-scope failures stay isolated (
ScopeNotFoundErrorfrom a mid-sweep delete is skipped; other exceptions are logged), andsync_scopere-reads each scope by id right before use, so a delete that lands after the snapshot never re-clones a dead scope.No new config key — the split is internal.
SCOPES_GIT_MAX_WORKERSremains the single operator lever for git concurrency; a dedicated sync-concurrency knob was considered (PR4'sSCOPES_SYNC_CONCURRENCY) and deliberately dropped to avoid a redundant, easily-misconfigured second bound.The timeout unblocks the event loop and the awaiting coroutine, but the underlying pygit2 call keeps running on its private daemon thread until the OS network timeout. Daemon threads never block shutdown, and
git_op_in_flightkeeps anything from freeing that repo meanwhile — but worst-case thread count during an outage isSCOPES_GIT_MAX_WORKERSplus the number of lingering timed-out ops. Hard-kill via subprocess is explicitly out of scope.New config keys (opal-server, server-only, additive)
OPAL_SCOPES_GIT_FETCH_TIMEOUT120.00= no limit.OPAL_SCOPES_GIT_MAX_WORKERS10OPAL_SCOPES_PURGE_CHANNEL__opal_scope_purge__Invariants (enforced and tested)
repo_locksentries are popped only while holding that source's lock.forget_repo/rmtreenever run while a git op is in flight for that source.The purge confirmation is published while holding
lock_source— it frees this process's cached handle via the inline local subscriber, so releasing first opened a use-after-free against a re-created scope's_notify_on_changes(which holds the handle across an await, then callsset_target()on it).Verification
sync_scopes_perpass_test.py(2 tests: phase 2 runs wider than the git cap; phase 1 still respects it) — both green, and the related sync/delete/preload suites pass unchanged.app-tests/git-leakdocker bed: all 10 acceptance gates green — 19/19 in the main phase plustest_offline_repo_does_not_block_healthy_scopes. The bed (not the unit suite) is the real gate: it caught three product bugs unit tests could not — the purge blocking DELETE/PUT on the publish path, the shared-source over-purge, and the thread-pool starvation.OPAL_SCOPES_GIT_FETCH_TIMEOUT=10for realistic serve windows; the delete-vs-inflight-sync churn exclusion lifted (closed by the liveness probe); the repoint gate rewritten as a green guard (it was racing a purge that now completes in ~4ms);allow_worker_restarton the force-recreating boot test;chownafter the restoringcompose cp.Known limitation (pre-existing, not introduced here)
test_server_recovers_after_postgres_bouncecan fail in a full-file bed run. A worker receives backbone messages only if its broadcaster reader is running, which happens viaSTATISTICS_ENABLED(defaultFalse) or a connected websocket client —subscribe()alone does not start it. The bed has no opal-client service and statistics off, so a non-leader worker there is deaf to the backbone and a publish it buffers during an outage never replays. With clients connected (or statistics on) the reader runs and the replay works — verified in the logs of a passing run. Unrelated to this PR's changes; tracked separately.Consumer surface
packages/opal-clientandpackages/opal-commonare untouched. NoOPAL_*key renamed or removed; all three new keys are additive with behavior-preserving defaults. The 503 above is the one intentional contract change.🤖 Generated with Claude Code