Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
74 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
3d18c07
fix(opal-server): freeze-exempt SCOPES_PURGE_CHANNEL by configured name
Zivxx Jul 27, 2026
cf77f2b
style(opal-server): wrap SCOPES_GIT_MAX_WORKERS description
Zivxx Jul 27, 2026
2d71090
feat(opal-server): cap in-flight git ops via SCOPES_GIT_MAX_ZOMBIES
Zivxx Jul 27, 2026
a039768
fix(opal-server): shape-check CPython worker before daemon-thread spawn
Zivxx Jul 27, 2026
d260de8
fix(opal-server): non-retryable 409 for permanently-broken scope branch
Zivxx Jul 27, 2026
41f34a4
style(opal-server): apply black/isort to cluster C5 files
Zivxx Jul 27, 2026
d80ba66
fix(opal-server): make _git_busy_lock fork-safe with full at-fork triple
Zivxx Jul 27, 2026
4ae382e
fix(opal-server): skip freeing in-flight repo handles in reset_caches
Zivxx Jul 27, 2026
4151a5d
fix(opal-server): keep in-flight markers across pre-fork teardown
Zivxx Jul 27, 2026
238cc63
feat(opal-server): bounded drain of in-flight git ops before fork
Zivxx Jul 27, 2026
6765ef8
style(opal-server): apply black/isort/docformatter to cluster C3 files
Zivxx Jul 27, 2026
0b413c0
feat(opal-server): always-on orphan-sweep timer independent of POLICY…
Zivxx Jul 27, 2026
82c7a4e
feat(opal-server): best-effort local memory purge on the serving work…
Zivxx Jul 27, 2026
7beaebf
fix(opal-server): reason-aware fail-open — repoint keeps clone on a r…
Zivxx Jul 27, 2026
6439f07
perf(opal-server): hybrid orphan sweep — snapshot-filter live dirs, r…
Zivxx Jul 27, 2026
6af6b6c
style(opal-server): apply black/docformatter to cluster C4 test files
Zivxx Jul 27, 2026
f81b6ec
test(opal-server): port purge TOCTOU + shard-distinct + defensive-war…
Zivxx Jul 27, 2026
f1b854f
test(opal-server): cover start() leader wiring; defer _periodic_polling
Zivxx Jul 27, 2026
49250f2
test(opal-server): cover DELETE route purge publish-through
Zivxx Jul 27, 2026
a145563
docs(opal-server): document scope-git config keys; drop private plann…
Zivxx Jul 27, 2026
738dd8f
fix(opal-server): re-raise CancelledError in periodic loops; land _pe…
Zivxx Jul 27, 2026
adade57
docs(opal-server): match OPAL_SCOPES_GIT_FETCH_TIMEOUT description to…
Zivxx Jul 27, 2026
3702177
revert(opal-server): drop C4.2 local memory purge on the serving worker
Zivxx Jul 27, 2026
ff5904b
fix(opal-server): address PR3/PR4 review findings — purge-channel aut…
Zivxx Jul 28, 2026
462e18a
style(opal-server): apply pre-commit formatting + fix py3.9 event-loo…
Zivxx Jul 28, 2026
fb1b823
fix(opal-server): address Zeev's round-2 review (13 findings) — purge…
Zivxx Jul 28, 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)
Comment thread
Zivxx marked this conversation as resolved.
Outdated

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(
Comment thread
Zivxx marked this conversation as resolved.
Outdated
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)
Comment thread
Zivxx marked this conversation as resolved.
Outdated


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):
Comment thread
Zivxx marked this conversation as resolved.
# 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}",

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.

[HIGH] This early return is still untested — the reply on the previous thread answered a different finding

Problem: the round-2 thread asked for coverage of two safety behaviours in this method: (a) the in-flight sync guard, and (b) this except TimeoutError return, which must not fall through to stamping repos_last_fetched. The reply described tests for _get_current_branch_head (fresh-handle-on-read, and the KeyError vs GitError split) — those belong to a different thread and do not touch either behaviour.

Mutation-verified on this head, in an isolated checkout at fb1b823d:

  • (a) genuinely fixed. Deleting the in-flight guard block → 1 failed, 202 passed on the new test_fetch_and_notify_skips_when_git_op_in_flight. The inverse test makes it discriminating, so an always-firing guard fails too. Good.
  • (b) still uncovered. Changing this return to pass203 passed. Green.

Failure scenario with that mutation in place: a timed-out fetch falls through to GitPolicyFetcher.repos_last_fetched[self._source_id] = fetch_started and then _notify_on_changes(repo). _was_fetched_after(req_time) now reports the repo as fresh, so _should_fetch logs "Repo was fetched after refresh request, override force_fetch with False" and suppresses the webhook-forced refresh — the repo is never re-fetched — while _notify_on_changes publishes a policy update computed off the stale pre-fetch HEAD. Both of the invariants the comment on this line spells out are violated, and nothing in the suite notices.

Suggestion: one test closes it — stub run_in_git_executor to raise TimeoutError, then assert the source id is absent from repos_last_fetched and that callbacks.on_update was never called:

async def test_timed_out_fetch_does_not_stamp_last_fetched(monkeypatch, ...):
    async def _boom(*a, **kw):
        raise TimeoutError("git operation exceeded 0.1s")
    monkeypatch.setattr(git_fetcher_module, "run_in_git_executor", _boom)
    await fetcher.fetch_and_notify_on_changes(force_fetch=True)
    assert fetcher._source_id not in GitPolicyFetcher.repos_last_fetched
    assert callbacks.on_update_calls == []

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