Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
245f80b
feat(opal-server): add scope git fetch timeout + dedicated pool config
dshoen619 Jun 23, 2026
6abbc2a
feat(opal-server): dedicated git thread pool with hard per-op timeout
dshoen619 Jun 23, 2026
ed86a9c
fix(opal-server): bound scope clone/fetch with timeout, skip on hang
dshoen619 Jun 23, 2026
c043e88
fix(opal-server): normalize asyncio.TimeoutError to builtin + format
dshoen619 Jun 24, 2026
5dd53a2
fix(opal-server): address Copilot review on git resilience
dshoen619 Jun 24, 2026
382cd83
fix(opal-server): clean fetch-timeout logging, trim test thread sleeps
dshoen619 Jun 24, 2026
f778edc
Merge branch 'master' into david/per-15157-pr3-git-resilience-never-s…
dshoen619 Jun 25, 2026
c353ceb
Merge branch 'master' into david/per-15157-pr3-git-resilience-never-s…
dshoen619 Jun 30, 2026
bad21c1
docs(opal-server): address Zeev review on git resilience
dshoen619 Jul 1, 2026
d31a0b6
fix(opal-server): harden git resilience — fork-safety, single-flight,…
dshoen619 Jul 1, 2026
f4c54f1
Merge branch 'master' into david/per-15157-pr3-git-resilience-never-s…
dshoen619 Jul 7, 2026
904bf7d
docs(opal-server): correct git-resilience worst-case boot bound (revi…
dshoen619 Jul 7, 2026
4e2cbfe
Merge branch 'master' into david/per-15157-pr3-git-resilience-never-s…
dshoen619 Jul 8, 2026
4c0dc7e
Merge remote-tracking branch 'origin/master' into david/per-15157-pr3…
Zivxx Jul 19, 2026
c2d8328
feat(opal-server): add SCOPES_PURGE_CHANNEL config key
Zivxx Jul 19, 2026
a583537
feat(opal-server): scope purge command + every-worker memory purge ha…
Zivxx Jul 19, 2026
311e292
feat(opal-server): subscribe every worker to the scope purge channel
Zivxx Jul 19, 2026
02d9465
feat(opal-server): leader-side disk purge handler on the scope purge …
Zivxx Jul 19, 2026
00715dd
test(opal-server): pin leader purge handler garbage-payload tolerance…
Zivxx Jul 19, 2026
02b4bf2
feat(opal-server): route scope delete purge through the fleet-wide ch…
Zivxx Jul 19, 2026
08081ef
feat(opal-server): broadcast purge for the old source when a scope is…
Zivxx Jul 19, 2026
d5868d6
fix(opal-server): unreadable prior record must not block scope PUT
Zivxx Jul 19, 2026
705cac1
fix(opal-server): check scope liveness before clone to close the resu…
Zivxx Jul 19, 2026
4beb296
feat(opal-server): leader orphan clone-dir sweep after sync passes
Zivxx Jul 19, 2026
a15b1ef
test(opal-server): make sweep store-error abort test discriminating; …
Zivxx Jul 19, 2026
43236aa
fix(opal-server): retryable 503 when a live scope's clone is transien…
Zivxx Jul 19, 2026
78975e4
test(git-leak): lift delete-vs-sync churn exclusion closed by PR3
Zivxx Jul 19, 2026
83dacd2
fix(opal-server): run leader disk purge off the publish path (bounded…
Zivxx Jul 19, 2026
3cf9d5e
fix(opal-server): two-phase purge — leader sibling-check gates all me…
Zivxx Jul 19, 2026
569595f
fix(opal-server): sweep orphans on refresh-triggered syncs; log backg…
Zivxx Jul 19, 2026
86f97c3
fix(opal-server): zombie-aware git executor — timed-out ops stop cons…
Zivxx Jul 19, 2026
84afe1d
docs(opal-server): correct executor capacity model in config descript…
Zivxx Jul 19, 2026
ae8cd92
test(git-leak): allow_worker_restart marker on force-recreating boot …
Zivxx Jul 19, 2026
f9b170a
fix(opal-server): drain lock/timestamp entries and confirm when disk …
Zivxx Jul 19, 2026
d304232
test(git-leak): bed timeout env, repoint gate flipped to green guard,…
Zivxx Jul 19, 2026
208a404
fix(opal-server): publish purge confirmation under the source lock (u…
Zivxx Jul 20, 2026
7a595d6
fix(opal-server): liveness probe rejects a repointed scope's stale so…
Zivxx Jul 20, 2026
a6cc9d2
docs: correct git executor capacity model in the config reference
Zivxx Jul 20, 2026
81f8507
style: apply docformatter/black to PR3 files
Zivxx Jul 20, 2026
d577103
fix(opal-server): derive purge clone path from source_id, never trust…
Zivxx Jul 21, 2026
85dc358
fix(opal-server): don't register daemon git threads in the atexit joi…
Zivxx Jul 21, 2026
0c52dd8
fix(opal-server): release the git live-op semaphore slot on cancellation
Zivxx Jul 21, 2026
2c02c53
fix(opal-server): publish repoint purge even on an ambiguous scope PU…
Zivxx Jul 21, 2026
c3eb19b
test+docs(opal-server): close review-flagged coverage and clarity gaps
Zivxx Jul 21, 2026
842a098
fix(opal-server): clear the fetcher cache in the master pre-fork so w…
Zivxx Jul 21, 2026
d610a60
style: apply isort/docformatter to review-fix files
Zivxx Jul 21, 2026
8400a75
test(opal-server): restore event loop after preload_scopes asyncio.ru…
Zivxx Jul 22, 2026
d2ad045
perf(opal-server): bound the local scope-sync pass separately from th…
Zivxx Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .claude/plans/docs/05-config-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 05 — OPAL Config Reference (private)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] A doc that declares itself private is committed to the OSS repo, and it is the only place the new keys are documented

Problem: this file's own opening lines call it a "private, internal supplement to the public operator docs". .claude/ has never been tracked in this repository — git ls-tree -r origin/master matches nothing under .claude, and the only commits touching .claude/plans are on this branch.

At the same time it is the only documentation the three new keys get. documentation/docs/getting-started/configuration.mdx is the public per-key reference (169 #### OPAL_ entries, including OPAL_SCOPES_REPO_CLONES_SHARDS at :476 and OPAL_POLICY_REPO_CLONE_TIMEOUT at :720) and gains none of OPAL_SCOPES_GIT_FETCH_TIMEOUT, OPAL_SCOPES_GIT_MAX_WORKERS, OPAL_SCOPES_PURGE_CHANNEL. Operators reading the published docs cannot discover any of the new knobs — including the one that changes worst-case boot behaviour.

Suggestion: add the three keys to configuration.mdx next to the other OPAL_SCOPES* entries, and either drop this file from the commit or keep .claude/ untracked (moving any engineering rationale worth publishing into the public docs or code comments).


Private, internal supplement to the public operator docs under `documentation/docs/`. Tracks
`OPAL_*` env vars added or clarified by the OPAL Server Git Fixes work, with the declaring
`file:line` so contributors can jump straight to the `Confi` declaration. Each key maps to an
`OPAL_<NAME>` env var (the `OPAL_` prefix is added once by the component's `Confi(prefix="OPAL_")`
instantiation — the bare name is what appears in the table).

## 4. opal-server keys

| Env var | Type | Default | Purpose | Declared at |
|---|---|---|---|---|
| `OPAL_SCOPES_GIT_FETCH_TIMEOUT` | float (seconds) | `120.0` | Hard timeout for a single scope git clone/fetch. On timeout the operation is logged and skipped (retried next cycle), so one unreachable repo can never block boot or other scopes *indefinitely*. `0` = no timeout. | `packages/opal-server/opal_server/config.py:196-202` |
| `OPAL_SCOPES_GIT_MAX_WORKERS` | int | `10` | Size of the dedicated `ThreadPoolExecutor` for scope git operations, which also bounds how many scopes are synced concurrently. Isolating git work keeps a hung fetch from starving bundle serving and other server work that uses the default executor. | `packages/opal-server/opal_server/config.py:203-209` |

> **Caveat (timeout is soft, not a hard kill).** `OPAL_SCOPES_GIT_FETCH_TIMEOUT` is enforced via
> `asyncio.wait`, which unblocks 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 pool
> (`OPAL_SCOPES_GIT_MAX_WORKERS`) bounds and isolates those lingering threads so they cannot affect
> bundle serving or other scopes, and a per-repo in-flight guard prevents a second git op from
> touching the same (non-thread-safe) pygit2 repo while the first is still lingering. Hard-kill via
> subprocess is out of scope. See spec §6.
>
> **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
Comment thread
dshoen619 marked this conversation as resolved.
Outdated
> blocks process shutdown.
16 changes: 16 additions & 0 deletions packages/opal-server/opal_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,22 @@ class OpalServerConfig(Confi):
0,
description="The timeout for cloning the policy repository (0 means wait forever)",
)
SCOPES_GIT_FETCH_TIMEOUT = confi.float(
"SCOPES_GIT_FETCH_TIMEOUT",
120.0,
description="Hard timeout in seconds for a single scope git clone/fetch. "
"On timeout the operation is logged and skipped (retried next cycle), so "
"one unreachable repo can never block boot or other scopes indefinitely "
"(0 = no timeout).",
)
SCOPES_GIT_MAX_WORKERS = confi.int(
"SCOPES_GIT_MAX_WORKERS",
10,
description="Size of the dedicated thread pool for scope git operations, "
"which also bounds how many scopes are synced concurrently. Isolating git "
"work keeps a hung fetch from starving bundle serving and other server "
"work that uses the default executor.",
)
LEADER_LOCK_FILE_PATH = confi.str(
"LEADER_LOCK_FILE_PATH",
"/tmp/opal_server_leader.lock",
Expand Down
255 changes: 245 additions & 10 deletions packages/opal-server/opal_server/git_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
import codecs
import datetime
import hashlib
import os
import shutil
import threading
import time
import weakref
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import thread as cf_thread
from functools import partial
from pathlib import Path
from typing import Optional, cast

import aiofiles.os
import pygit2
from ddtrace import tracer
from git import Repo
from opal_common.async_utils import run_sync
from opal_common.git_utils.bundle_maker import BundleMaker
from opal_common.logger import logger
from opal_common.schemas.policy import PolicyBundle
Expand All @@ -33,6 +38,197 @@
reference_is_valid_name,
)

_git_executor: Optional[ThreadPoolExecutor] = None

# Source ids whose scope git op (clone/fetch) is still running on a pool thread
# — including one that already exceeded its timeout but whose blocking pygit2
# call has not yet returned. Guarded by a lock because it is cleared from the
# pool thread (see ``run_in_git_executor``) and read/written from the event
# loop. Used to guarantee at most one live git op per repository, since pygit2
# ``Repository`` objects are not thread-safe.
_git_busy: set = set()
_git_busy_lock = threading.Lock()


class _DaemonThreadPoolExecutor(ThreadPoolExecutor):
"""A ``ThreadPoolExecutor`` whose worker threads are daemon threads.

A scope git op can stay blocked in a libgit2 network call well past our
soft timeout. With the stdlib's non-daemon workers, ``concurrent.futures``'
atexit handler would ``join()`` such a thread and hang interpreter shutdown
until the OS network timeout fires. Daemon workers let the process exit
promptly; abandoning an in-flight fetch at exit is safe (libgit2 stages
objects in a temp pack and swaps refs atomically under lockfiles, and a
half-written clone dir is detected as invalid and re-cloned on next boot).

Only thread creation is customised, mirroring CPython's
``_adjust_thread_count``. If a future CPython changes the internals we rely
on, we fall back to the stdlib (non-daemon) behaviour.
"""

def _adjust_thread_count(self) -> None: # pragma: no cover - thread mgmt
if not hasattr(cf_thread, "_worker") or not hasattr(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] The fallback guard checks that CPython internals exist, not that they still have the shape this code assumes

Problem: the docstring promises "If a future CPython changes the internals we rely on, we fall back to the stdlib (non-daemon) behaviour", but the guard only tests hasattr(cf_thread, "_worker") and hasattr(cf_thread, "_threads_queues"). On CPython 3.14 both still exist, while _worker's signature changed and ThreadPoolExecutor.__init__ no longer sets _initializer/_initargs — so the override raises AttributeError out of submit(), which lands in the except BaseException at 236-240 and makes every scope clone/fetch fail. 3.9-3.13 are unaffected (verified: 3.13 still has the 4-arg _worker), and setup.py classifiers stop at 3.12 — but python_requires is ">=3.9" with no upper bound, so a 3.14 install is permitted and would silently never sync.

Suggestion: validate what is actually used — e.g. inspect.signature(cf_thread._worker) parameter count plus hasattr(self, "_initializer") — and fall back to super()._adjust_thread_count() on any mismatch. Adding an upper bound to python_requires would also make the constraint explicit.

cf_thread, "_threads_queues"
):
return super()._adjust_thread_count()
# If idle threads are available, don't spin up new ones.
if self._idle_semaphore.acquire(timeout=0):
return

def weakref_cb(_, q=self._work_queue):
q.put(None)

num_threads = len(self._threads)
if num_threads < self._max_workers:
thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads)
t = threading.Thread(
name=thread_name,
target=cf_thread._worker,
args=(
weakref.ref(self, weakref_cb),
self._work_queue,
self._initializer,
self._initargs,
),
daemon=True,
)
t.start()
self._threads.add(t)
cf_thread._threads_queues[t] = self._work_queue


def _get_git_executor() -> ThreadPoolExecutor:
"""Lazily build the dedicated pool for scope git operations.

Isolated from the default executor so a hung clone/fetch can never starve
bundle serving or other server work. Workers are daemon threads so a
lingering (timed-out) git op cannot block interpreter shutdown.

``SCOPES_GIT_MAX_WORKERS`` is read once on first use; the executor is then
cached for the process lifetime. It is reset after ``fork`` (see
``_reset_git_executor_after_fork``) because a ``ThreadPoolExecutor``
inherited across a fork has no live worker threads in the child.
"""
global _git_executor
if _git_executor is None:
_git_executor = _DaemonThreadPoolExecutor(
max_workers=opal_server_config.SCOPES_GIT_MAX_WORKERS,
thread_name_prefix="opal-git",
)
return _git_executor


def shutdown_git_executor() -> None:
"""Drop the dedicated git pool and clear in-flight state.

Called at the end of the pre-fork ``preload_scopes`` so the gunicorn master
does not carry idle git-pool threads (or stale in-flight markers) into the
forked workers. Workers lazily rebuild their own pool on first use.
"""
global _git_executor
executor, _git_executor = _git_executor, None
if executor is not None:
executor.shutdown(wait=False, cancel_futures=True)
with _git_busy_lock:
_git_busy.clear()


def _reset_git_executor_after_fork() -> None:
"""Reset the git pool + in-flight state in a freshly forked child.

A ``ThreadPoolExecutor`` created in the parent is inherited *broken* by the
child: its worker threads do not survive ``fork``, yet its bookkeeping makes
``_adjust_thread_count`` skip spawning live workers, so every submitted task
would sit queued forever. Dropping the reference forces the child to build
its own working pool. The in-flight markers are stale in the child too (no
thread will ever clear them), so clear them.
"""
global _git_executor
_git_executor = None
with _git_busy_lock:
_git_busy.clear()


if hasattr(os, "register_at_fork"):
os.register_at_fork(after_in_child=_reset_git_executor_after_fork)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] register_at_fork installs only after_in_child, so a fork while _git_busy_lock is held deadlocks the child

Problem: _reset_git_executor_after_fork acquires _git_busy_lock (line 130). threading.Lock state is inherited across fork(): if another thread held it at fork time, the child's copy is permanently locked and the handler blocks forever in the post-fork path — the worker never starts. That lock is taken by non-forking threads: _mark_git_op_done runs on the git daemon thread (from _runner's finally), and preload_scopes can leave such threads alive in the gunicorn arbiter at exactly the moment it forks workers.

The stdlib module this file already imports does it the complete way:

os.register_at_fork(before=_global_shutdown_lock.acquire,
                    after_in_child=_global_shutdown_lock._at_fork_reinit,
                    after_in_parent=_global_shutdown_lock.release)

Suggestion: register the same triple — before=_git_busy_lock.acquire, after_in_parent=_git_busy_lock.release, and in the child reinit the lock before touching _git_busy.



def _mark_git_op_started(key: str) -> None:
with _git_busy_lock:
_git_busy.add(key)


def _mark_git_op_done(key: str) -> None:
with _git_busy_lock:
_git_busy.discard(key)


def git_op_in_flight(key: str) -> bool:
"""True while a git op for ``key`` is still running on a pool thread.

Stays True during the "lingering" window after a timeout, until the
blocking pygit2 call actually returns.
"""
with _git_busy_lock:
return key in _git_busy


def _consume_future_result(fut) -> None:
# A future left running after its awaiter timed out is never awaited again;
# retrieve its outcome so asyncio doesn't log "exception never retrieved".
if not fut.cancelled():
try:
fut.exception()
except Exception:
pass


async def run_in_git_executor(func, *args, timeout: float, busy_key=None, **kwargs):
"""Run a blocking git call on the dedicated pool with a hard timeout.

Raises the builtin ``TimeoutError`` when the call exceeds ``timeout``
seconds (``timeout <= 0`` means no limit). NOTE: the timeout unblocks 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 pool
keeps that lingering thread isolated.

When ``busy_key`` is given it is marked in-flight for the *entire real
duration* of the call — including any lingering time after a timeout — and
cleared only when the blocking call actually returns (on the pool thread).
Callers use ``git_op_in_flight`` to avoid starting a second git op against
the same repository while a timed-out one is still running.
"""
loop = asyncio.get_running_loop()

def _runner():
try:
return func(*args, **kwargs)
finally:
if busy_key is not None:
_mark_git_op_done(busy_key)

if busy_key is not None:
_mark_git_op_started(busy_key)
try:
fut = loop.run_in_executor(_get_git_executor(), _runner)
except BaseException:
if busy_key is not None:
_mark_git_op_done(busy_key)
raise

if not (timeout and timeout > 0):
return await fut

# Use asyncio.wait (not wait_for) so a timeout does NOT cancel the future:
# the pool thread runs to completion and clears busy_key, and a still-queued
# task isn't silently dropped. The done-callback retrieves the eventual
# result to avoid an "exception never retrieved" warning.
fut.add_done_callback(_consume_future_result)
done, _pending = await asyncio.wait({fut}, timeout=timeout)
if not done:
raise TimeoutError(f"git operation exceeded {timeout}s")
return fut.result()


class PolicyFetcherCallbacks:
async def on_update(self, old_head: Optional[str], head: str):
Expand Down Expand Up @@ -169,6 +365,17 @@ async def fetch_and_notify_on_changes(
"""
repo_lock = await self._get_repo_lock()
async with repo_lock:
if git_op_in_flight(self._source_id):
# A previous git op for this repo exceeded its timeout and is
# still running on a pool thread. pygit2 Repository objects are
# not thread-safe, so skip this cycle rather than touch the same
# repo concurrently; the next cycle retries once it finishes.
logger.warning(
"Skipping sync for {url}: a previous git operation is still "
"running after its timeout.",
url=self._source.url,
)
return
with tracer.trace(
"git_policy_fetcher.fetch_and_notify_on_changes",
resource=self._scope_id,
Expand All @@ -187,13 +394,32 @@ async def fetch_and_notify_on_changes(
logger.debug(
f"Fetching remote (force_fetch={force_fetch}): {self._remote} ({self._source.url})"
)
started = datetime.datetime.now()
try:
await run_in_git_executor(
repo.remotes[self._remote].fetch,
callbacks=self._auth_callbacks,
timeout=opal_server_config.SCOPES_GIT_FETCH_TIMEOUT,
busy_key=self._source_id,
)
except TimeoutError as exc:
# Expected when a repo is unreachable: log cleanly
# (no traceback) and skip, matching the clone path.
# repos_last_fetched stays stale so the next cycle
# retries and force_fetch is not wrongly suppressed.
logger.error(
"Timed out fetching {url}, skipping: {err}",
url=self._source.url,
Comment thread
dshoen619 marked this conversation as resolved.
Outdated
err=repr(exc),
)
return
# Record the fetch *start* time, but only now that it
# has succeeded: a completion timestamp could wrongly
# suppress a force_fetch whose req_time falls within an
# in-flight fetch (see _was_fetched_after).
GitPolicyFetcher.repos_last_fetched[
self._source_id
] = datetime.datetime.now()
await run_sync(
repo.remotes[self._remote].fetch,
callbacks=self._auth_callbacks,
)
] = started
logger.debug(f"Fetch completed: {self._source.url}")

# New commits might be present because of a previous fetch made by another scope
Expand All @@ -204,7 +430,10 @@ async def fetch_and_notify_on_changes(
logger.warning(
"Deleting invalid repo: {path}", path=self._repo_path
)
shutil.rmtree(self._repo_path)
# ignore_errors: a partial dir left by an abandoned
# (timed-out) clone may still be written to; a failed
# delete self-heals via the clone below (or next cycle).
shutil.rmtree(self._repo_path, ignore_errors=True)
else:
logger.info("Repo not found at {path}", path=self._repo_path)

Expand All @@ -222,14 +451,20 @@ async def _clone(self):
path=self._repo_path,
)
try:
repo: Repository = await run_sync(
repo: Repository = await run_in_git_executor(
clone_repository,
self._source.url,
str(self._repo_path),
callbacks=self._auth_callbacks,
timeout=opal_server_config.SCOPES_GIT_FETCH_TIMEOUT,
busy_key=self._source_id,
)
except (pygit2.GitError, TimeoutError) as exc:
logger.error(
"Could not clone repo at {url}: {err}",
url=self._source.url,
err=repr(exc),
)
except pygit2.GitError:
logger.exception(f"Could not clone repo at {self._source.url}")
else:
logger.info(f"Clone completed: {self._source.url}")
await self._notify_on_changes(repo)
Expand Down
Loading
Loading