This is an automated email from the ASF dual-hosted git repository.

netudima pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-ccm.git


The following commit(s) were added to refs/heads/trunk by this push:
     new e8508d1  Retry git clone and fetch from remote repositories, and abort 
stalled git transfers
e8508d1 is described below

commit e8508d167e8538fc7213b83cbd1fc8cbba5dc175
Author: Dmitry Konstantinov <[email protected]>
AuthorDate: Sun Nov 1 13:00:12 2026 +0000

    Retry git clone and fetch from remote repositories, and abort stalled git 
transfers
    
    Make up to 5 attempts for git clone --bare and git fetch when the 
repository is remote, waiting 15s after
    the first failure and doubling the wait after each further failure. Local 
paths and file:// urls get one attempt.
    Abort a transfer that stays below 1000 bytes/s for 60s instead of waiting 
for the server to close the connection
    (unless GIT_HTTP_LOW_SPEED_LIMIT or GIT_HTTP_LOW_SPEED_TIME is already set).
    After a remote command fails every attempt, later remote commands get one 
attempt until one succeeds.
    Include git's error lines in the "Could not do a git clone" and "Could not 
update git" messages.
    
    patch by Dmitry Konstantinov; reviewed by Brandon Williams for 
CASSANDRA-21834
---
 ccmlib/repository.py     |  69 ++++++++++++++++++++++-----
 tests/test_repository.py | 121 ++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 177 insertions(+), 13 deletions(-)

diff --git a/ccmlib/repository.py b/ccmlib/repository.py
index 55af470..78ad8cd 100644
--- a/ccmlib/repository.py
+++ b/ccmlib/repository.py
@@ -52,12 +52,20 @@ MIRRORS = ["https://dlcdn.apache.org/cassandra";, 
"https://downloads.apache.org/c
 DOWNLOAD_TIMEOUT = 45  # seconds a download may wait for data before the 
attempt is abandoned
 DOWNLOAD_ATTEMPTS = 3  # attempts per url
 DOWNLOAD_RETRY_DELAY = 5  # seconds, multiplied by the attempt number
+GIT_ATTEMPTS = 5  # attempts for the git commands that reach a remote 
repository
+GIT_RETRY_DELAY_SEC = 15  # doubled after each attempt, so the attempts span 
about 4 minutes
+GIT_STALL_BYTES_PER_SEC = 1000  # a git transfer slower than this counts as 
stalled
+GIT_STALL_TIMEOUT_SEC = 60  # how long a git transfer may stay stalled before 
the attempt is abandoned
 GIT_REPO = "https://github.com/apache/cassandra.git";
 GITHUB_REPO = "https://github.com/apache/cassandra";
 GITHUB_TAGS = "https://api.github.com/repos/apache/cassandra/git/refs/tags";
 CCM_CONFIG = ConfigParser.RawConfigParser()
 CCM_CONFIG.read(os.path.join(os.path.expanduser("~"), ".ccm", "config"))
 
+# set when a remote git command failed every attempt, cleared when one 
succeeds; meanwhile each command
+# is tried once, so a remote that stays unreachable does not add minutes to 
every clone_development
+_git_remote_down = False
+
 
 def setup(version, verbose=False):
     (cdir, version, fallback) = __setup(version, verbose)
@@ -173,20 +181,15 @@ def clone_development(git_repo, version, verbose=False, 
alias=False):
         # remote fetches we need to perform:
         if not os.path.exists(local_git_cache):
             common.info("Cloning Cassandra...")
-            process = subprocess.Popen(
-                ['git', 'clone', '--bare',
-                 git_repo, local_git_cache],
-                cwd=__get_dir(), stdout=subprocess.PIPE, 
stderr=subprocess.PIPE)
-            out, _, _ = log_info(process, logger)
-            assert out == 0, "Could not do a git clone"
+            out, error = __git_remote(['clone', '--bare', git_repo, 
local_git_cache],
+                                      git_repo, __get_dir(), logger)
+            assert out == 0, "Could not do a git clone: {}".format(error)
         else:
             common.info("Fetching Cassandra updates...")
-            process = subprocess.Popen(
-                ['git', 'fetch', '-fup', 'origin',
-                 '+refs/heads/*:refs/heads/*', '+refs/tags/*:refs/tags/*'],
-                 cwd=local_git_cache, stdout=subprocess.PIPE, 
stderr=subprocess.PIPE)
-            out, _, _ = log_info(process, logger)
-            assert out == 0, "Could not update git"
+            out, error = __git_remote(['fetch', '-fup', 'origin',
+                                       '+refs/heads/*:refs/heads/*', 
'+refs/tags/*:refs/tags/*'],
+                                      git_repo, local_git_cache, logger)
+            assert out == 0, "Could not update git: {}".format(error)
 
         # Checkout the version we want from the local cache:
         if not os.path.exists(target_dir):
@@ -282,6 +285,48 @@ def clone_development(git_repo, version, verbose=False, 
alias=False):
             raise e
 
 
+def __git_remote(args, git_repo, cwd, logger):
+    """
+    Run a git command that reaches git_repo, retrying it when git_repo is 
remote.
+    Returns git's exit code and the errors it printed on the last attempt.
+    """
+    global _git_remote_down
+    remote = __is_remote(git_repo)
+    attempts = GIT_ATTEMPTS if remote and not _git_remote_down else 1
+    # without a low speed limit git waits on a stalled http transfer until the 
server drops the connection
+    env = {'GIT_HTTP_LOW_SPEED_LIMIT': str(GIT_STALL_BYTES_PER_SEC),
+           'GIT_HTTP_LOW_SPEED_TIME': str(GIT_STALL_TIMEOUT_SEC)}
+    env.update(os.environ)
+    for attempt in range(1, attempts + 1):
+        process = subprocess.Popen(['git'] + args, cwd=cwd, env=env, 
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        out, _, stderr = log_info(process, logger)
+        if out == 0:
+            if remote:
+                _git_remote_down = False
+            return out, None
+        error = __git_errors(stderr)
+        if attempt < attempts:
+            common.warning("git {} {} failed (attempt {} of {}): {}".format(
+                args[0], git_repo, attempt, attempts, error))
+            time.sleep(GIT_RETRY_DELAY_SEC * 2 ** (attempt - 1))
+    if attempts > 1:
+        _git_remote_down = True
+    return out, error
+
+
+def __is_remote(git_repo):
+    # local paths and file:// urls are not retried; scp-like addresses are, 
e.g. [email protected]:apache/cassandra.git
+    if '://' in git_repo:
+        return not git_repo.startswith('file://')
+    return re.match(r'[\w.-]+@[\w.-]+:', git_repo) is not None
+
+
+def __git_errors(stderr):
+    # git also prints ref updates to stderr, so keep its error lines
+    lines = [line.strip() for line in stderr.decode().splitlines() if 
line.strip()]
+    return ' | '.join([line for line in lines if line.startswith(('error:', 
'fatal:'))] or lines[-3:])
+
+
 def download_version(version, url=None, verbose=False, binary=False):
     """Download, extract, and build Cassandra tarball.
 
diff --git a/tests/test_repository.py b/tests/test_repository.py
index 4b1031c..701708e 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -16,13 +16,15 @@
 
 
 import configparser
+import functools
 import io
 import os
 import ssl
+import subprocess
 import tarfile
 import threading
 import time
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler, 
ThreadingHTTPServer
 
 import pytest
 from six.moves import urllib
@@ -106,10 +108,67 @@ def untrusted_mirror():
     yield from _serve(_MirrorServer(context))
 
 
+class _GitRemoteHandler(SimpleHTTPRequestHandler):
+    """
+    Serves git repositories over git's dumb http protocol. Every git command 
starts by requesting info/refs,
+    and that request misbehaves as the next entry of server.misbehave says.
+    """
+
+    def do_GET(self):
+        if '/info/refs' in self.path:
+            self.server.refs_requests += 1
+            behaviour = self.server.misbehave.pop(0) if self.server.misbehave 
else None
+            if behaviour == 'error':
+                self.send_error(500)
+                return
+            if behaviour == 'stall':
+                self.send_response(200)
+                self.send_header('Content-Length', '1000000')
+                self.end_headers()
+                time.sleep(STALL_SECONDS)
+                return
+        SimpleHTTPRequestHandler.do_GET(self)
+
+    def log_message(self, *args):
+        pass
+
+
+class _GitRemoteServer(ThreadingHTTPServer):
+    block_on_close = False  # do not wait for stalled handlers
+
+    def __init__(self, directory):
+        ThreadingHTTPServer.__init__(self, ('127.0.0.1', 0), 
functools.partial(_GitRemoteHandler, directory=directory))
+        self.refs_requests = 0
+        self.misbehave = []
+        self.url = 
'http://127.0.0.1:{}/cassandra.git'.format(self.server_address[1])
+
+
+def _git(*args):
+    subprocess.check_call(('git',) + args, stdout=subprocess.DEVNULL)
+
+
[email protected]
+def git_remote(tmp_path, monkeypatch):
+    """A repository with a cassandra-4.0 branch served over http, with ccm's 
repository directory in tmp_path"""
+    source, served = str(tmp_path / 'source'), str(tmp_path / 'served' / 
'cassandra.git')
+    _git('init', '-q', source)
+    _git('-C', source, 'symbolic-ref', 'HEAD', 'refs/heads/cassandra-4.0')
+    _git('-C', source, '-c', 'user.name=ccm', '-c', 
'[email protected]', '-c', 'commit.gpgsign=false',
+         'commit', '-q', '--allow-empty', '-m', 'initial')
+    _git('clone', '-q', '--bare', source, served)
+    _git('-C', served, 'update-server-info')
+    monkeypatch.setattr(repository, 'get_default_path', lambda: str(tmp_path))
+    monkeypatch.setattr(repository, 'compile_version', lambda *args: None)
+    yield from _serve(_GitRemoteServer(str(tmp_path / 'served')))
+
+
 @pytest.fixture(autouse=True)
 def fast_retries(monkeypatch):
     monkeypatch.setattr(repository, 'DOWNLOAD_TIMEOUT', 0.5)
     monkeypatch.setattr(repository, 'DOWNLOAD_RETRY_DELAY', 0)
+    monkeypatch.setattr(repository, 'GIT_RETRY_DELAY_SEC', 0)
+    monkeypatch.setattr(repository, 'GIT_STALL_TIMEOUT_SEC', 2)
+    monkeypatch.setattr(repository, '_git_remote_down', False)
 
 
 def _read(path):
@@ -195,3 +254,63 @@ def test_download_version_tries_mirrors_in_order(mirror, 
tmp_path, monkeypatch):
     installed = tmp_path / 'repository' / '9.9.9'
     assert _read(str(installed / 'bin' / 'cassandra')) == PAYLOAD
     assert _read(str(installed / '0.version.txt')) == b'9.9.9'
+
+
+GITHUB_VERSION = 'github:apache/cassandra-4.0'
+
+
+def test_git_clone_from_remote_is_retried(git_remote):
+    git_remote.misbehave = ['error']
+    repository.clone_development(git_remote.url, GITHUB_VERSION)
+
+    assert git_remote.refs_requests == 2
+    assert 
os.path.isdir(os.path.join(repository.directory_name(GITHUB_VERSION), '.git'))
+
+
+def test_stalled_git_fetch_is_abandoned_and_retried(git_remote):
+    repository.clone_development(git_remote.url, GITHUB_VERSION)
+    git_remote.misbehave = ['stall']
+    start = time.time()
+    repository.clone_development(git_remote.url, GITHUB_VERSION)
+
+    assert git_remote.refs_requests == 3
+    assert time.time() - start < STALL_SECONDS
+
+
+def test_git_error_is_reported_once_attempts_run_out(git_remote):
+    git_remote.misbehave = ['error'] * repository.GIT_ATTEMPTS
+    with pytest.raises(AssertionError) as e:
+        repository.clone_development(git_remote.url, GITHUB_VERSION)
+
+    assert str(e.value).startswith("Could not do a git clone: fatal: unable to 
access '{}/'".format(git_remote.url))
+    assert str(e.value).endswith('500')
+    assert git_remote.refs_requests == repository.GIT_ATTEMPTS
+
+
+def test_unreachable_remote_is_tried_once_until_it_works_again(git_remote):
+    git_remote.misbehave = ['error'] * (repository.GIT_ATTEMPTS + 1)
+    with pytest.raises(AssertionError):
+        repository.clone_development(git_remote.url, GITHUB_VERSION)
+    with pytest.raises(AssertionError):
+        repository.clone_development(git_remote.url, GITHUB_VERSION)
+    assert git_remote.refs_requests == repository.GIT_ATTEMPTS + 1
+
+    repository.clone_development(git_remote.url, GITHUB_VERSION)
+    assert git_remote.refs_requests == repository.GIT_ATTEMPTS + 2
+
+    # the remote works again, so its next failure is retried, this time by the 
fetch into the cache
+    git_remote.misbehave = ['error']
+    repository.clone_development(git_remote.url, GITHUB_VERSION)
+    assert git_remote.refs_requests == repository.GIT_ATTEMPTS + 4
+
+
+def test_local_repository_is_not_retried(tmp_path, monkeypatch):
+    monkeypatch.setattr(repository, 'get_default_path', lambda: str(tmp_path))
+    monkeypatch.setattr(repository, 'GIT_RETRY_DELAY_SEC', 60)
+    missing = str(tmp_path / 'missing')
+    start = time.time()
+    with pytest.raises(AssertionError) as e:
+        repository.clone_development(missing, 'local:{}:trunk'.format(missing))
+
+    assert 'does not exist' in str(e.value)
+    assert time.time() - start < STALL_SECONDS


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to