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 13 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
PR3 — Git Resilience: never stuck on an offline repo
Closes PER-15157.
Problem
Scope git clone/fetch use pygit2 with no timeout, dispatched through
run_sync(...)onto the shared default executor with no time limit on the await:clone_repository(...)inGitPolicyFetcher._clonerepo.remotes[self._remote].fetch(...)infetch_and_notify_on_changesPOLICY_REPO_CLONE_TIMEOUTis wired only to the legacy non-scopes path, so it does not apply in scopes mode. The consequences:gunicorn_conf→preload_scopes()→asyncio.run(sync_scopes(...))) blocks indefinitely on a single unreachable repo.Fix
A hard per-operation timeout plus an isolated, bounded thread pool so a hung repo fails fast, is skipped, and never starves anything else.
ThreadPoolExecutor(opal-gitthreads, lazily built) for scope git work, isolated from the default executor.run_in_git_executor(func, *args, timeout=..., **kwargs)that runs the blocking call on that pool and wraps the await inasyncio.wait_for.timeout <= 0means no limit.SCOPES_GIT_FETCH_TIMEOUT._cloneerror handling broadened to(pygit2.GitError, asyncio.TimeoutError)— a hung clone is logged and the scope skipped instead of crashing the caller. Per-scope isolation inScopesService.sync_scope(try/except Exception) already handles a timed-out fetch, making boot best-effort once operations can no longer hang forever.run_syncimport fromgit_fetcher.py.New config keys (opal-server, server-only)
OPAL_SCOPES_GIT_FETCH_TIMEOUT120.00= no timeout.OPAL_SCOPES_GIT_MAX_WORKERS10asyncio.wait_forcancels the await — unblocking the event loop and the awaiting coroutine — but the underlying pygit2 call keeps running on its pool thread until the OS network timeout. The dedicated bounded pool (OPAL_SCOPES_GIT_MAX_WORKERS) isolates those lingering threads so they cannot affect bundle serving or other scopes. Hard-kill via subprocess is explicitly out of scope (spec §6).Tests
tests/git_executor_test.py— config defaults; helper returns value; helper times out;timeout=0means no limit.tests/fetch_timeout_test.py— a hanging op surfacesTimeoutErrorandwait_forunblocks promptly (< 2s with a 0.2s timeout).Local run (Python 3.12 venv, editable installs of all three packages):
Not runnable in this PR's CI yet
Task 4 of the plan includes the PR1 regression gate
app-tests/git-leak/test_resilience.py::test_offline_repo_does_not_block_healthy_scopesand a live/healthchecksmoke test. Both depend on PR1 (test environment) being merged — the stated prerequisite — and a running stack, so they should be exercised once PR1 lands. This PR ships after PR2 and is independent of it.Review checklist (opal-development)
OPAL_double-prefix); mandatorydescription=present (covered bytest_config.py); exactly one declaration per env name (no collision).opal_client/opal_commonsymbol touched; no PDP override of these names.concurrent.futures+asyncioonly.sync_scopesrelies on this PR's guarantee that each fetch terminates, so its boundedgathercan never wait on an infinite member.🤖 Generated with Claude Code