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 74 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 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
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>
Export git_busy_count() as a zombie gauge and refuse new scope git ops (log-once) once in-flight ops reach SCOPES_GIT_MAX_ZOMBIES (default 4x MAX_WORKERS=40), bounding thread growth during a remote outage. (libgit2 transport-timeout half deferred.) Addresses review comments: - git_fetcher.py:229 (@zeevmoney)
Validate cf_thread._worker arity (==4) and self._initializer before mirroring CPython internals; fall back to the stdlib on mismatch instead of spawning a malformed thread. Cap python_requires at <3.13 until newer internals are verified. Addresses review comments: - git_fetcher.py:71 (@zeevmoney)
Bind and repr-log the scope-policy exception; route the permanent 'branch head not found' case to a non-retryable 409 instead of an endless 503 retry loop. Transient clone-vanished / OSError stays on 503 + Retry-After. Addresses review comments: - api.py:342 (@zeevmoney)
No behavior change; black-wraps multi-line conditionals/calls and isort-sorts new imports introduced across the C5.1/C5.2/C5.3 commits.
Acquire the lock before fork and reinit (not re-acquire) it in the child so a lock inherited mid-mutation cannot deadlock a worker. Addresses review comments: - git_fetcher.py:135 (@zeevmoney)
Mirror purge_local_memory's guard: a pygit2 handle whose git op is still lingering on a daemon thread must not be free()'d by the pre-fork cache reset, only dropped from the cache. Addresses review comments: - task.py:117 (@zeevmoney)
shutdown_git_executor no longer clears _git_busy so reset_caches sees which sources have a lingering git op; the forked child clears the stale markers. Addresses review comments: - task.py:117 (@zeevmoney)
preload_scopes waits up to SCOPES_GIT_PRELOAD_DRAIN_TIMEOUT for lingering clone/fetch ops to clear their markers before the pre-fork teardown; ops still busy at the bound are left running and protected by reset_caches's in-flight guard. Deferred follow-up (gated on boot-test Run B): subprocess isolation (option B). Addresses review comments: - task.py:117 (@zeevmoney)
Cosmetic only (docstring rewrap, import sort, blank-line/spacing style) — no behavior change.
…_REFRESH_INTERVAL Addresses review comments: - purge.py:195 (@zeevmoney) - service.py:213 (@zeevmoney)
…er at delete Drop this worker's in-memory fetcher caches for the deleted source (memory only; disk stays leader-gated) so a client-less non-leader does not pin the pygit2 handle for life. Fix the now-accurate DELETE route comment. Addresses review comments: - service.py:213 (@zeevmoney)
…aising sibling scan Addresses review comments: - api.py:157 (@zeevmoney)
…e-check candidates under lock One scopes.all() filters the bulk of clearly-live dirs (no per-orphan scan); only candidate-orphans get a fresh under-lock re-check before deletion, which serializes against a PUT's sync so a re-claimed clone is kept. is_dir() moved off the event loop via os.scandir; a raising re-check is logged, not swallowed. Also inverts test_delete_route_does_not_purge_fetcher_caches_without_pubsub (delete_scope_route_test.py, out of the brief's declared file list but a direct route-level duplicate of the C4.2 behavior change) so the full opal-server suite stays green. Addresses review comments: - purge.py:265 (@zeevmoney) - purge.py:270 (@zeevmoney)
Cosmetic-only: line-wraps a long monkeypatch.setattr call and a long ScopePurgeCommand call in purge_channel_test.py, and re-wraps one docstring in delete_scope_cache_purge_test.py. No behavior change.
…ning regressions Addresses review comments: - packages/opal-server/opal_server/tests/purge_channel_test.py:209 (@zeevmoney)
Addresses review comments: - packages/opal-server/opal_server/tests/scopes_task_wiring_test.py:27 (@zeevmoney) Only C7.5 (start() subscribes the leader purge handler to SCOPES_PURGE_CHANNEL) landed here. C7.4's two _periodic_polling cancellation tests were withheld, not committed red or weakened: they deterministically fail against current code (task.py:82's outer `except asyncio.CancelledError:` logs and does not re-raise, so a cancelled task completes normally instead of raising to its awaiter — confirmed with a standalone asyncio repro, independent of pytest/timing). A NOTE in the test file and the cluster-C7 report document the gap for follow-up.
Addresses review comments: - packages/opal-server/opal_server/tests/delete_scope_route_test.py:71 (@zeevmoney)
…ing doc; fix dangling ref Addresses review comments: - .claude/plans/docs/05-config-reference.md:1 (@zeevmoney) - .claude/plans/docs/05-config-reference.md:24 (@zeevmoney)
…riodic_polling tests The outer except-CancelledError in _periodic_polling and _periodic_orphan_sweep logged without re-raising, swallowing cancellation (harmless today via stop()'s gather(return_exceptions=True) but a latent footgun). Re-raise to honor the cancellation contract; land the previously-withheld C7.4 tests. Addresses review comments: - packages/opal-server/opal_server/tests/scopes_task_wiring_test.py:27 (@zeevmoney) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… config.py verbatim
The bed's I4 invariant caught it: purge_local_memory under lock_source mints a repo_locks entry it never pops, so churn accumulates stray repo_locks keys with no live scope; it also over-purges a shared source's memory locally. Draining memory on a client-less non-leader is deferred to the lock-lifecycle work (Plan B / C1); C4.1's always-on sweep timer already backstops the disk half. Reverts part of 82c7a4e. Re: service.py:213 (@zeevmoney)
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
|
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