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 12 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.
| loop = asyncio.get_event_loop() | ||
| fut = loop.run_in_executor(_get_git_executor(), partial(func, *args, **kwargs)) |
There was a problem hiding this comment.
Good catch — switched to asyncio.get_running_loop() in 5dd53a2.
| GitPolicyFetcher.repos_last_fetched[ | ||
| self._source_id | ||
| ] = datetime.datetime.now() | ||
| await run_sync( | ||
| await run_in_git_executor( | ||
| repo.remotes[self._remote].fetch, |
There was a problem hiding this comment.
Fixed in 5dd53a2 — repos_last_fetched is now updated only after the fetch completes successfully, so a timeout/error leaves it stale and won't suppress a later force_fetch via _was_fetched_after.
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.
| # retries and force_fetch is not wrongly suppressed. | ||
| logger.error( | ||
| "Timed out fetching {url}, skipping: {err}", | ||
| url=self._source.url, |
There was a problem hiding this comment.
[HIGH] New git-fetcher log lines leak un-redacted repo URLs (bypass redact_url)
Problem: On origin/master every repo URL in this file's logs is redacted via redact_url(self._source.url) (git_fetcher.py:110, 113, 140, 189, 199, 224, 235, 237) — a deliberate credential-redaction control, since a scope git URL can embed user:token@host. This PR adds three new log statements that log the raw URL: the single-flight skip warning (line 376), this fetch-timeout error (line 412), and the clone-error handler (line 465). redact_url is not imported here. This branch was cut before that redaction landed, so its diff looks clean in isolation — but the file currently conflicts with master, and once the merge is resolved these will be the only un-redacted URL logs in the module, on exactly the offline/error paths this PR adds → credential exposure in logs.
Suggestion: import redact_url and wrap every self._source.url used as a log arg:
from opal_common.http_utils import redact_url
...
logger.error(
"Timed out fetching {url}, skipping: {err}",
url=redact_url(self._source.url),
err=repr(exc),
)Apply the same at lines 376 and 465. When resolving the git_fetcher.py merge conflict, also preserve master's redact_url(...) on the pre-existing context lines (395, 423, 449, 469).
There was a problem hiding this comment.
Fixed — merged master (which brought in the credential redaction from #921) and resolved the git_fetcher.py conflict in f4c54f13.
redact_urlis now imported fromopal_common.http_utils, and all three new log lines wrap the URL: single-flight skip, fetch-timeout, and clone-error. Master'sredact_url(...)on the pre-existing context lines is preserved.- The fetch path keeps this PR's soft-timeout
run_in_git_executorflow (dropped master'srun_syncdouble-fetch and its separateexcept pygit2.GitErrorclause during the resolution). - The only bare
self._source.urluses left are the actual args toclone_repository/verify_found_repo_matches_remoteand the sha256 hash input — no log line emits a raw URL.CONFLICTINGis cleared; the branch now contains master.
| > **Boot / concurrency.** Scope syncs run concurrently, bounded by `OPAL_SCOPES_GIT_MAX_WORKERS`, so | ||
| > a slow/offline repo only holds its own slot for up to the timeout rather than serially delaying the | ||
| > whole pass. With more offline repos than workers, boot/poll can still take | ||
| > `ceil(offline / workers) × timeout`; the pool workers are daemon threads so a lingering op never |
There was a problem hiding this comment.
[MEDIUM] Boot/poll worst-case bound is optimistic — timed-out threads are not reclaimed
Problem: the timeout is soft. run_in_git_executor uses asyncio.wait and deliberately does not cancel the future, so a timed-out clone/fetch keeps its pool thread until the OS network timeout (the caveat just above says this, and test_busy_key_stays_in_flight_until_call_returns asserts it). So when the number of simultaneously-offline repos is >= SCOPES_GIT_MAX_WORKERS, the lingering threads keep the pool fully occupied and healthy repos submitted afterward are queued behind them until the OS network timeout — not merely ceil(offline / workers) × timeout. That formula assumes each wave's threads are reclaimed before the next wave starts, which is not the case with lingering (uncancellable) ops.
Suggestion: state that queued git ops wait for a freed pool thread, so with offline >= workers a healthy repo can be delayed up to the OS/TCP timeout of the lingering ops, not just ceil × timeout. The guarantee this PR actually delivers is event-loop isolation (bundle serving and the HTTP surface never block) plus a bounded per-slot stall — worth stating explicitly. (This also bears on the app-tests/git-leak offline test, which uses 40 offline repos against the default 10 workers.)
There was a problem hiding this comment.
Fixed in 904bf7d1. Rewrote the "Boot / concurrency" note:
- Dropped the optimistic
ceil(offline / workers) × timeoutbound and explained that queued git ops wait for a freed pool thread — so withoffline >= workersa healthy repo can be delayed up to the OS/TCP timeout of the lingering (uncancellable) ops, notceil × timeout. - Stated the guarantee this actually delivers: event-loop isolation (HTTP surface + bundle serving never block on a hung repo) plus a bounded per-slot stall.
- Called out the
app-tests/git-leak40-offline / 10-worker case explicitly, and fixed the twoconfig.pyline refs while there.
…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>
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