Merge unseen-extract-deadline: per-repo extraction time bound

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-08 13:22:23 +02:00
co-authored by Claude Opus 4.8
4 changed files with 142 additions and 8 deletions
+10 -4
View File
@@ -123,10 +123,11 @@ def write_draft(root: Path, assigned: list[dict], *, seed: int) -> tuple[Path, P
class Clones: class Clones:
"""One disposable blobless clone at a time; no name in cache paths or subprocess output.""" """One disposable blobless clone at a time; no name in cache paths or subprocess output."""
def __init__(self, root: Path, cache: Path, *, runner=None, limit_bytes=500 * 1024 * 1024): def __init__(self, root: Path, cache: Path, *, runner=None, limit_bytes=500 * 1024 * 1024, deadline_seconds: float | None = None):
self.cache = external(root, cache) self.cache = external(root, cache)
self.runner = runner or subprocess.run self.runner = runner or subprocess.run
self.limit_bytes = limit_bytes self.limit_bytes = limit_bytes
self.deadline_seconds = deadline_seconds
self.counts = Counter() self.counts = Counter()
@contextmanager @contextmanager
@@ -135,7 +136,7 @@ class Clones:
started = time.monotonic() started = time.monotonic()
with tempfile.TemporaryDirectory(prefix="clone-", dir=self.cache) as directory: with tempfile.TemporaryDirectory(prefix="clone-", dir=self.cache) as directory:
path = Path(directory) path = Path(directory)
git = Git(path, runner=self.runner, limit_bytes=self.limit_bytes) git = Git(path, runner=self.runner, limit_bytes=self.limit_bytes, deadline_seconds=self.deadline_seconds)
try: try:
# Full commit graph is needed for temporal thirds and OSV brackets; blobs # Full commit graph is needed for temporal thirds and OSV brackets; blobs
# are fetched lazily. This is a partial clone, not a truncated history. # are fetched lazily. This is a partial clone, not a truncated history.
@@ -145,7 +146,7 @@ class Clones:
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
check=False, check=False,
timeout=180, timeout=git.command_timeout(),
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
) )
if done.returncode: if done.returncode:
@@ -154,6 +155,8 @@ class Clones:
if git.disk_bytes() > self.limit_bytes: if git.disk_bytes() > self.limit_bytes:
raise InstrumentFailure("clone_size_cap") raise InstrumentFailure("clone_size_cap")
yield git yield git
except subprocess.TimeoutExpired:
raise extract.ExtractionTimeout("extraction_timeout") from None
finally: finally:
size = git.disk_bytes() size = git.disk_bytes()
self.counts["clone_bytes"] += size self.counts["clone_bytes"] += size
@@ -331,6 +334,8 @@ def run(
) -> dict: ) -> dict:
started = time.monotonic() started = time.monotonic()
report = {"status": "sourcing", "seed": seed, "candidates": {}, "rejections": {}, "ordinary_exclusions": {}} report = {"status": "sourcing", "seed": seed, "candidates": {}, "rejections": {}, "ordinary_exclusions": {}}
# Operational budget only: changing it must not invalidate journal resume inputs.
report["extract_deadline_seconds"] = getattr(clones, "deadline_seconds", None)
counts, rejected, ordinary_counts = Counter(), Counter(), Counter() counts, rejected, ordinary_counts = Counter(), Counter(), Counter()
checkpoint = None checkpoint = None
try: try:
@@ -440,6 +445,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--root", type=Path, default=Path.cwd()) parser.add_argument("--root", type=Path, default=Path.cwd())
parser.add_argument("--cache", type=Path) parser.add_argument("--cache", type=Path)
parser.add_argument("--max-hours", type=float, help="Stop between units after this many hours per invocation") parser.add_argument("--max-hours", type=float, help="Stop between units after this many hours per invocation")
parser.add_argument("--extract-deadline", type=float, default=240.0, help="Extraction wall-clock budget per repository in seconds")
parser.add_argument("--seed", type=int, default=20260601) parser.add_argument("--seed", type=int, default=20260601)
parser.add_argument("--local-store") parser.add_argument("--local-store")
parser.add_argument("--s3-store", default="s3://") parser.add_argument("--s3-store", default="s3://")
@@ -451,7 +457,7 @@ def main(argv: list[str] | None = None) -> int:
# Refuse an existing draft before doing any network work. # Refuse an existing draft before doing any network work.
if any((args.root / p).exists() for p in ("benchmarks/unseen/draft-p1.toml", "benchmarks/unseen/private/draft-p1.toml")): if any((args.root / p).exists() for p in ("benchmarks/unseen/draft-p1.toml", "benchmarks/unseen/private/draft-p1.toml")):
raise FileExistsError("draft_exists") raise FileExistsError("draft_exists")
clones = Clones(args.root, args.cache or results_root() / "unseen-draft") clones = Clones(args.root, args.cache or results_root() / "unseen-draft", deadline_seconds=args.extract_deadline)
stores = { stores = {
"local": memory.open_store(args.local_store or "file://" + str(results_root() / "memory"), read_only=True), "local": memory.open_store(args.local_store or "file://" + str(results_root() / "memory"), read_only=True),
"s3": memory.open_store(args.s3_store, read_only=True), "s3": memory.open_store(args.s3_store, read_only=True),
+21 -2
View File
@@ -11,6 +11,7 @@ import ast
import json import json
import re import re
import subprocess import subprocess
import time
from pathlib import Path from pathlib import Path
from .source import Candidate, Rejected from .source import Candidate, Rejected
@@ -28,8 +29,15 @@ class InstrumentFailure(ValueError):
"""A failed read or command must never masquerade as an empty history.""" """A failed read or command must never masquerade as an empty history."""
class ExtractionTimeout(Rejected):
"""A slow repository is skipped without aborting the draft."""
class Git: class Git:
def __init__(self, path: Path, *, runner=None, limit_bytes: int | None = None): def __init__(self, path: Path, *, runner=None, limit_bytes: int | None = None, deadline_seconds: float | None = None, _now=None):
self._now = _now or time.monotonic
self.started = self._now()
self.deadline_seconds = deadline_seconds
self.path = path self.path = path
self.runner = runner or subprocess.run self.runner = runner or subprocess.run
self.bytes_read = 0 self.bytes_read = 0
@@ -37,16 +45,27 @@ class Git:
self.last_blob_bytes = 0 self.last_blob_bytes = 0
self.limit_bytes = limit_bytes self.limit_bytes = limit_bytes
def command_timeout(self) -> float:
if self.deadline_seconds is None:
return 180
remaining = self.deadline_seconds - (self._now() - self.started)
if remaining <= 0:
raise ExtractionTimeout("extraction_timeout")
return min(180, remaining)
def run(self, *args: str, allowed=(0,)) -> str: def run(self, *args: str, allowed=(0,)) -> str:
timeout = self.command_timeout()
try: try:
done = self.runner( done = self.runner(
["git", "-c", "core.quotePath=false", "-C", str(self.path), *args], ["git", "-c", "core.quotePath=false", "-C", str(self.path), *args],
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=180, timeout=timeout,
check=False, check=False,
) )
except subprocess.TimeoutExpired:
raise ExtractionTimeout("extraction_timeout") from None
except Exception: except Exception:
raise InstrumentFailure("git_launch_failed") from None raise InstrumentFailure("git_launch_failed") from None
if done.returncode not in allowed: if done.returncode not in allowed:
+73 -2
View File
@@ -224,7 +224,10 @@ def test_zero_source_is_instrument_failure_not_rejection(tmp_path, monkeypatch):
assert report["status"] == "instrument_failure" and not report["rejections"] assert report["status"] == "instrument_failure" and not report["rejections"]
def test_cli_uses_environment_and_injected_stores(tmp_path, monkeypatch, capsys): @pytest.mark.parametrize("deadline_args,deadline", [([], 240.0), (["--extract-deadline", "12.5"], 12.5)])
def test_cli_uses_environment_and_injected_stores(tmp_path, monkeypatch, capsys, deadline_args, deadline):
import json
from openultrasast import config from openultrasast import config
from openultrasast.plane import memory from openultrasast.plane import memory
@@ -238,13 +241,81 @@ def test_cli_uses_environment_and_injected_stores(tmp_path, monkeypatch, capsys)
args = ["--root", str(tmp_path), "--cache", str(tmp_path.parent / "clone-cache")] args = ["--root", str(tmp_path), "--cache", str(tmp_path.parent / "clone-cache")]
for label in e.SOURCES: for label in e.SOURCES:
args += ["--known-empty", label] args += ["--known-empty", label]
assert d.main(args) == 1 assert d.main(args + deadline_args) == 1
output = capsys.readouterr().out output = capsys.readouterr().out
assert json.loads(output)["extract_deadline_seconds"] == deadline
assert "instrument_failure" in output and "fake-secret" not in output and "fixture/" not in output assert "instrument_failure" in output and "fake-secret" not in output and "fixture/" not in output
assert seen[-1] == "fake-secret" assert seen[-1] == "fake-secret"
assert all(kwargs == {"read_only": True} for _, kwargs in seen[:-1]) assert all(kwargs == {"read_only": True} for _, kwargs in seen[:-1])
@pytest.mark.parametrize("timeout_stage", ["clone", "fetch"])
def test_timeout_skips_repository_and_draft_continues(tmp_path, monkeypatch, timeout_stage):
import subprocess
clone_count = 0
def runner(cmd, **kwargs):
nonlocal clone_count
if "clone" in cmd:
clone_count += 1
if clone_count == 1 and timeout_stage in cmd:
raise subprocess.TimeoutExpired(cmd, kwargs["timeout"])
return subprocess.CompletedProcess(cmd, 0, b"", b"")
monkeypatch.setattr(d, "repository_changes", lambda *a, **kw: (entries()[0]["changes"], {}, {"bytes": 1, "files": 1}))
clones = d.Clones(tmp_path / "repo", tmp_path / "cache", runner=runner, deadline_seconds=240.0)
used = e.UsedSet()
report = d.run(tmp_path / "repo", github=FakeAPI(2), osv=FakeOSV(), clones=clones, used=used, seed=1, known_empty=set(used.sources))
assert report["status"] == "insufficient_repositories"
assert report["rejections"] == {"extraction_timeout": 1}
assert report["repositories"] == 1 and clone_count == 2
assert not list(clones.cache.glob("clone-*"))
def test_clone_time_counts_and_each_repository_gets_fresh_deadline(tmp_path, monkeypatch):
import subprocess
now, timeouts = [0.0], []
monkeypatch.setattr(d.time, "monotonic", lambda: now[0])
def runner(cmd, **kwargs):
timeouts.append(kwargs["timeout"])
now[0] += 7.0 if "clone" in cmd else 1.0
return subprocess.CompletedProcess(cmd, 0, b"", b"")
clones = d.Clones(tmp_path / "repo", tmp_path / "cache", runner=runner, deadline_seconds=10.0)
for _ in range(2):
with clones.open("https://github.com/fixture/project") as git:
git.run("log")
now[0] += 3.0
with pytest.raises(d.extract.ExtractionTimeout, match="^extraction_timeout$"):
git.run("log")
assert timeouts == [10.0, 3.0, 10.0, 3.0]
def test_deadline_is_reported_without_changing_resume_inputs(tmp_path):
clones = d.Clones(tmp_path / "repo", tmp_path / "cache", deadline_seconds=240.0)
used = e.UsedSet()
before = d.inputs(used, 1, set(used.sources), clones)
kwargs = dict(
root=tmp_path / "repo",
github=FakeAPI(),
osv=FakeOSV(),
clones=clones,
used=used,
seed=1,
known_empty=set(used.sources),
max_hours=0,
)
assert d.run(**kwargs)["status"] == "stopped"
clones.deadline_seconds = 12.5
assert d.inputs(used, 1, set(used.sources), clones) == before
report = d.run(**kwargs)
assert report["status"] == "stopped"
assert report["extract_deadline_seconds"] == 12.5
def test_repository_changes_excludes_all_advisory_sites(monkeypatch): def test_repository_changes_excludes_all_advisory_sites(monkeypatch):
calls = [] calls = []
+38
View File
@@ -118,6 +118,44 @@ def test_git_failures_are_sanitized_and_not_empty(tmp_path):
e.Git(tmp_path, runner=failed).run("log") e.Git(tmp_path, runner=failed).run("log")
@pytest.mark.parametrize("deadline,timeout", [(None, 180), (240.0, 180), (12.5, 12.5)])
def test_git_timeout_is_rejection(tmp_path, deadline, timeout):
def runner(cmd, **kwargs):
assert kwargs["timeout"] == timeout
raise subprocess.TimeoutExpired(cmd, kwargs["timeout"])
git = e.Git(tmp_path, runner=runner, deadline_seconds=deadline, _now=lambda: 0.0)
with pytest.raises(e.ExtractionTimeout, match="^extraction_timeout$") as caught:
git.run("log")
assert isinstance(caught.value, s.Rejected)
assert not isinstance(caught.value, e.InstrumentFailure)
def test_git_cumulative_deadline_prevents_launch(tmp_path):
now, timeouts = [100.0], []
def runner(cmd, **kwargs):
timeouts.append(kwargs["timeout"])
now[0] += 4.0
return subprocess.CompletedProcess(cmd, 0, b"read", b"")
git = e.Git(tmp_path, runner=runner, deadline_seconds=10.0, _now=lambda: now[0])
assert git.run("log") == "read"
assert git.run("log") == "read"
now[0] += 3.0 # Time between commands counts as well.
with pytest.raises(e.ExtractionTimeout, match="^extraction_timeout$"):
git.run("log")
assert timeouts == [10.0, 6.0]
def test_git_launch_failure_stays_fatal(tmp_path):
def runner(cmd, **kwargs):
raise FileNotFoundError("private executable path")
with pytest.raises(e.InstrumentFailure, match="^git_launch_failed$"):
e.Git(tmp_path, runner=runner, deadline_seconds=240.0).run("log")
def test_walkback_is_bounded_and_file_cap_falls_back(history): def test_walkback_is_bounded_and_file_cap_falls_back(history):
git, _, candidate, _, _, parent, _ = history git, _, candidate, _, _, parent, _ = history