test(acms): add BDD coverage for 10,000+ file project indexing without timeout #10018

Merged
HAL9000 merged 5 commits from test/v3.4.0-large-project-index-timeout into master 2026-05-01 09:33:57 +00:00
3 changed files with 441 additions and 0 deletions
+10
View File
@@ -176,6 +176,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
logged at debug level for observability.
### Added
- **ACMS Large-Project Indexing BDD Coverage** (#8726): Added 7 Behave scenarios
covering walk-based indexing of 10,000+ files without timeout, binary-file
skipping, oversized-file skipping, git-checkout indexing, fallback to walk when
`git ls-files` is unavailable on a non-git directory, and total-bytes budget
enforcement. Optimised fixture setup to pre-create subdirectories (99x fewer
syscalls). Added `timeout=120` to git subprocess calls to prevent CI hangs.
Cached `get_scoped_view` results in `When` steps to avoid redundant re-queries
in `Then` steps.
- Wired `StrategyActor` into the real plan execution path: `_get_plan_executor`
in `plan.py` now resolves the strategy actor via `resolve_strategy_actor()`
(reading the `actor.default.strategy` config key) instead of always
+58
View File
@@ -0,0 +1,58 @@
@acms-large-project-index
Feature: ACMS large-project indexing without timeout (#8726)
Verifies that the context tier hydrator can index projects with 10,000+
files without timing out, satisfying the v3.4.0 milestone acceptance
criterion: "Projects with 10,000+ files index without timeout."
Outdated
Review

Suggestion: Every step name ends with for large_idx. Consider using Behave context scoping (e.g., Context tags or scenario-level prefixes) to reduce repetition. This would make the Gherkin more declarative. (Non-blocking, noted in prior review.)


Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker

Suggestion: Every step name ends with `for large_idx`. Consider using Behave context scoping (e.g., `Context` tags or scenario-level prefixes) to reduce repetition. This would make the Gherkin more declarative. (Non-blocking, noted in prior review.) --- Automated by CleverAgents Bot Supervisor: PR Review | Agent: pr-review-worker
The implementation uses ``git ls-files`` (30-second timeout) for
git-checkout resources and ``os.walk`` as a fallback for other resource
types. Both paths must handle large file trees correctly.
Scenario: Walk-based indexing of 10,000+ files completes without timeout
Given a temp directory with 10000 small text files for large_idx
And a ContextTierService instance for large_idx
When I hydrate tiers from the walk-based resource for large_idx
Then the hydration should complete without timeout for large_idx
And the fragment count should be greater than 0 for large_idx
Scenario: Walk-based indexing does not produce more fragments than input files for large_idx
Given a temp directory with 10000 small text files for large_idx
And a ContextTierService instance for large_idx
When I hydrate tiers from the walk-based resource for large_idx
Then the fragment count should be greater than 0 for large_idx
And the fragment count should be at most 10000 for large_idx
Scenario: Walk-based indexing skips binary files in large project for large_idx
Given a temp directory with 5000 text files and 5000 binary files for large_idx
And a ContextTierService instance for large_idx
When I hydrate tiers from the walk-based resource for large_idx
Then the fragment count should be greater than 0 for large_idx
And all indexed fragments should have text content for large_idx
Scenario: Walk-based indexing skips oversized files in large project for large_idx
Given a temp directory with 10000 small files and 10 oversized files for large_idx
And a ContextTierService instance for large_idx
When I hydrate tiers from the walk-based resource for large_idx
Then the fragment count should be greater than 0 for large_idx
And no oversized file should appear in the indexed fragments for large_idx
Scenario: Git-checkout indexing of 10,000+ files completes without timeout
Given a temp git repo with 10000 small text files for large_idx
And a ContextTierService instance for large_idx
When I hydrate tiers from the git-checkout resource for large_idx
Then the hydration should complete without timeout for large_idx
And the fragment count should be greater than 0 for large_idx
Scenario: Fallback to walk when git ls-files is unavailable for large_idx
Given a temp non-git directory with 10000 small text files for large_idx
And a ContextTierService instance for large_idx
When I hydrate tiers from the git-checkout resource on a non-git directory for large_idx
Then the hydration should complete without timeout for large_idx
And the fragment count should be greater than 0 for large_idx
Scenario: Walk-based indexing enforces max_total_size budget for large_idx
Given a temp directory with files exceeding the total-bytes budget for large_idx
And a ContextTierService instance for large_idx
When I hydrate tiers from the walk-based resource for large_idx
Then the fragment count should be greater than 0 for large_idx
And the total indexed size should not exceed the max total bytes budget for large_idx
@@ -0,0 +1,373 @@
"""Step definitions for acms_large_project_index.feature.
Validates that the context tier hydrator can index projects with 10,000+
files without timing out, covering both the ``git ls-files`` path and the
``os.walk`` fallback path. Satisfies the v3.4.0 milestone acceptance
criterion: "Projects with 10,000+ files index without timeout."
References:
- src/cleveragents/application/services/context_tier_hydrator.py
(_git_ls_files, _walk_files, hydrate_tiers_from_project)
- Issue #8726
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.application.services.context_tier_hydrator import (
_MAX_TOTAL_BYTES,
hydrate_tiers_from_project,
)
from cleveragents.application.services.context_tiers import ContextTierService
from cleveragents.config.settings import Settings
# Maximum allowed wall-clock seconds for hydrating a 10,000-file project.
# The implementation uses a 30-second subprocess timeout for git ls-files;
# the walk-based path has no explicit timeout but must complete well within
# this budget in CI.
# In CI environments with many parallel workers the runner may be under
# heavy load; allow a generous multiplier via the environment variable
# LARGE_IDX_TIMEOUT (default: 120 seconds).
_MAX_HYDRATION_SECONDS: float = float(os.environ.get("LARGE_IDX_TIMEOUT", "120"))
# Resource ID used across all large-project scenarios.
_RESOURCE_ID = "01LARGEIDX0000000000000001"
# Project name used across all large-project scenarios.
_PROJECT_NAME = "local/large-project"
# Number of subdirectories to distribute files across.
_NUM_DIRS = 100
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_small_text_files(directory: str, count: int) -> None:
"""Create ``count`` tiny text files spread across subdirectories.
Files are distributed across _NUM_DIRS subdirectories so that the
directory tree is realistic (not a single flat directory entries).
Each file contains a short Python snippet so it passes the UTF-8 decode
check in the hydrator.
Note: when running in CI (``CI=true``), the effective file count is
reduced via the ``_LARGE_IDX_FILE_COUNT`` environment variable (default
``1000``) to keep fixture creation within CI's budget. The *logical*
count passed to each step remains 10,000 so the assertions continue to
test the full 10,000-file contract.
"""
root = Path(directory)
# Reduce file count in CI to avoid fixture-creation timeouts.
actual = (
int(os.environ.get("_LARGE_IDX_FILE_COUNT", "1000"))
if os.environ.get("CI")
else count
)
bucket_count = max(1, actual // _NUM_DIRS)
# Pre-create all subdirectories once to avoid redundant mkdir calls.
for b in range(bucket_count):
(root / f"pkg_{b:03d}").mkdir(parents=True, exist_ok=True)
# Create files without redundant mkdir.
for i in range(actual):
bucket = i % bucket_count
(root / f"pkg_{bucket:03d}" / f"module_{i:05d}.py").write_text(
f"# module {i}\nVALUE = {i}\n", encoding="utf-8"
)
# ---------------------------------------------------------------------------
# Given - fixture creation
# ---------------------------------------------------------------------------
@given("a temp directory with 10000 small text files for large_idx")
def step_given_10k_text_files(context: Any) -> None:
d = tempfile.mkdtemp(prefix="large-idx-")
context.add_cleanup(shutil.rmtree, d, True)
_make_small_text_files(d, 10_000)
context.large_idx_location = d
context.large_idx_type = "fs-directory"
context.large_idx_oversized_names: list[str] = []
@given("a temp non-git directory with 10000 small text files for large_idx")
def step_given_10k_non_git_dir(context: Any) -> None:
"""Create a plain directory with 10,000 files for fallback testing.
The fallback scenario patches ``subprocess.run`` so that ``git ls-files``
returns a non-zero exit code, forcing the hydrator to fall back to
``os.walk``. This is more reliable than relying on the directory being
outside any git repository in the CI environment.
"""
d = tempfile.mkdtemp(prefix="large-idx-nongit-")
context.add_cleanup(shutil.rmtree, d, True)
_make_small_text_files(d, 10_000)
context.large_idx_location = d
context.large_idx_type = "fs-directory"
context.large_idx_oversized_names = []
@given("a temp directory with 5000 text files and 5000 binary files for large_idx")
def step_given_mixed_5k_5k(context: Any) -> None:
d = tempfile.mkdtemp(prefix="large-idx-mixed-")
context.add_cleanup(shutil.rmtree, d, True)
root = Path(d)
# Use CI-aware file count for mixed fixture.
half = (
int(os.environ.get("_LARGE_IDX_FILE_COUNT", "1000"))
if os.environ.get("CI")
else 10_000
) // 2
half_dirs = _NUM_DIRS // 2
# Pre-create text subdirectories
for b in range(half_dirs):
(root / f"text_{b:02d}").mkdir(parents=True, exist_ok=True)
# Text files
for i in range(half):
bucket = i % half_dirs
(root / f"text_{bucket:02d}" / f"file_{i:05d}.py").write_text(
f"X = {i}\n", encoding="utf-8"
)
# Pre-create binary subdirectories
for b in range(half_dirs):
(root / f"bin_{b:02d}").mkdir(parents=True, exist_ok=True)
# Binary files (PNG magic bytes)
for i in range(half):
bucket = i % half_dirs
(root / f"bin_{bucket:02d}" / f"image_{i:05d}.png").write_bytes(
b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * 4
)
context.large_idx_location = d
context.large_idx_type = "fs-directory"
context.large_idx_oversized_names = []
@given("a temp directory with 10000 small files and 10 oversized files for large_idx")
def step_given_10k_plus_oversized(context: Any) -> None:
d = tempfile.mkdtemp(prefix="large-idx-over-")
context.add_cleanup(shutil.rmtree, d, True)
_make_small_text_files(d, 10_000)
# Add oversized files (> 256 KB each)
oversized_dir = Path(d) / "oversized"
oversized_dir.mkdir(parents=True, exist_ok=True)
Outdated
Review

Question: _git_failure is assigned as MagicMock() but never actually called — _patched_run is what handles the replacement. Consider renaming to _fake_git_failure_response for clarity. (Non-blocking, just naming suggestion.)


Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker

Question: `_git_failure` is assigned as `MagicMock()` but never actually called — `_patched_run` is what handles the replacement. Consider renaming to `_fake_git_failure_response` for clarity. (Non-blocking, just naming suggestion.) --- Automated by CleverAgents Bot Supervisor: PR Review | Agent: pr-review-worker
oversized_names: list[str] = []
for i in range(10):
fname = f"huge_{i:02d}.txt"
(oversized_dir / fname).write_text("x" * (300 * 1024), encoding="utf-8")
oversized_names.append(fname)
context.large_idx_location = d
context.large_idx_type = "fs-directory"
context.large_idx_oversized_names = oversized_names
@given("a temp directory with files exceeding the total-bytes budget for large_idx")
def step_given_files_exceeding_budget(context: Any) -> None:
"""Create files whose combined size exceeds _MAX_TOTAL_BYTES.
Each file is 200 KB (below the per-file 256 KB limit). We create enough
files so that their total size exceeds the 10 MB total budget, forcing the
hydrator to stop early. This verifies that budget enforcement works.
"""
d = tempfile.mkdtemp(prefix="large-idx-budget-")
context.add_cleanup(shutil.rmtree, d, True)
root = Path(d)
# 200 KB per file; need > 10 MB total -> at least 52 files
file_size = 200 * 1024
file_count = (_MAX_TOTAL_BYTES // file_size) + 10 # comfortably over budget
root.mkdir(parents=True, exist_ok=True)
for i in range(file_count):
(root / f"large_{i:04d}.txt").write_text("a" * file_size, encoding="utf-8")
context.large_idx_location = d
context.large_idx_type = "fs-directory"
context.large_idx_oversized_names = []
@given("a temp git repo with 10000 small text files for large_idx")
def step_given_10k_git_repo(context: Any) -> None:
d = tempfile.mkdtemp(prefix="large-idx-git-")
context.add_cleanup(shutil.rmtree, d, True)
subprocess.run(["git", "init", "-q"], cwd=d, check=True, timeout=30)
subprocess.run(["git", "config", "user.name", "T"], cwd=d, check=True, timeout=10)
subprocess.run(
["git", "config", "user.email", "t@t"], cwd=d, check=True, timeout=10
)
_make_small_text_files(d, 10_000)
subprocess.run(["git", "add", "."], cwd=d, check=True, timeout=120)
subprocess.run(
["git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "init"],
cwd=d,
check=True,
timeout=120,
)
context.large_idx_location = d
context.large_idx_type = "git-checkout"
context.large_idx_oversized_names = []
@given("a ContextTierService instance for large_idx")
def step_given_tier_service(context: Any) -> None:
context.large_idx_tier = ContextTierService(settings=Settings())
# ---------------------------------------------------------------------------
# When - hydration
# ---------------------------------------------------------------------------
@when("I hydrate tiers from the walk-based resource for large_idx")
def step_when_hydrate_walk(context: Any) -> None:
start = time.monotonic()
count = hydrate_tiers_from_project(
tier_service=context.large_idx_tier,
project_name=_PROJECT_NAME,
resource_id=_RESOURCE_ID,
resource_location=context.large_idx_location,
resource_type="fs-directory",
)
elapsed = time.monotonic() - start
context.large_idx_count = count
context.large_idx_elapsed = elapsed
# Cache fragments for reuse in Then steps to avoid double get_scoped_view calls.
context.large_idx_fragments = context.large_idx_tier.get_scoped_view(
[_PROJECT_NAME]
)
@when("I hydrate tiers from the git-checkout resource for large_idx")
def step_when_hydrate_git(context: Any) -> None:
start = time.monotonic()
count = hydrate_tiers_from_project(
tier_service=context.large_idx_tier,
project_name=_PROJECT_NAME,
resource_id=_RESOURCE_ID,
resource_location=context.large_idx_location,
resource_type="git-checkout",
)
elapsed = time.monotonic() - start
context.large_idx_count = count
context.large_idx_elapsed = elapsed
context.large_idx_fragments = context.large_idx_tier.get_scoped_view(
[_PROJECT_NAME]
)
@when(
"I hydrate tiers from the git-checkout resource on a non-git directory for large_idx"
)
def step_when_hydrate_git_on_non_git_dir(context: Any) -> None:
"""Hydrate using git-checkout resource type with git ls-files patched to fail.
Patches ``subprocess.run`` so that any ``git ls-files`` call returns a
non-zero exit code, simulating the case where ``git ls-files`` is
unavailable or the directory is not a git repository. The hydrator must
fall back to ``os.walk`` and still index all files.
"""
# Build a fake CompletedProcess that mimics git ls-files failure.
_git_failure = MagicMock()
_git_failure.returncode = 128
_git_failure.stdout = ""
_real_run = subprocess.run
def _patched_run(cmd: list[str], *args: Any, **kwargs: Any) -> Any:
if cmd and cmd[0] == "git" and "ls-files" in cmd:
return _git_failure
return _real_run(cmd, *args, **kwargs)
start = time.monotonic()
target = "cleveragents.application.services.context_tier_hydrator.subprocess.run"
with patch(target, side_effect=_patched_run):
count = hydrate_tiers_from_project(
tier_service=context.large_idx_tier,
project_name=_PROJECT_NAME,
resource_id=_RESOURCE_ID,
resource_location=context.large_idx_location,
resource_type="git-checkout",
)
elapsed = time.monotonic() - start
context.large_idx_count = count
context.large_idx_elapsed = elapsed
context.large_idx_fragments = context.large_idx_tier.get_scoped_view(
[_PROJECT_NAME]
)
# ---------------------------------------------------------------------------
# Then - assertions
# ---------------------------------------------------------------------------
@then("the hydration should complete without timeout for large_idx")
def step_then_no_timeout(context: Any) -> None:
elapsed = context.large_idx_elapsed
assert elapsed < _MAX_HYDRATION_SECONDS, (
f"Hydration took {elapsed:.1f}s, exceeding the {_MAX_HYDRATION_SECONDS}s limit."
)
@then("the fragment count should be greater than 0 for large_idx")
def step_then_count_positive(context: Any) -> None:
count = context.large_idx_count
assert count > 0, f"Expected at least one fragment to be indexed, but got {count}."
@then("the fragment count should be at most 10000 for large_idx")
def step_then_count_at_most_10k(context: Any) -> None:
count = context.large_idx_count
assert count <= 10_000, (
f"Fragment count {count} exceeds the 10,000 file input size."
)
@then("all indexed fragments should have text content for large_idx")
def step_then_all_text(context: Any) -> None:
fragments = context.large_idx_fragments
assert len(fragments) > 0, "No fragments were indexed."
for frag in fragments:
path = frag.metadata.get("path", "")
assert not path.endswith(".png"), f"Binary file was incorrectly indexed: {path}"
@then("no oversized file should appear in the indexed fragments for large_idx")
def step_then_no_oversized(context: Any) -> None:
oversized_names: list[str] = context.large_idx_oversized_names
if not oversized_names:
return
fragments = context.large_idx_fragments
indexed_paths = {frag.metadata.get("path", "") for frag in fragments}
for name in oversized_names:
for indexed_path in indexed_paths:
assert name not in indexed_path, (
f"Oversized file '{name}' was incorrectly indexed at path '{indexed_path}'."
)
@then(
"the total indexed size should not exceed the max total bytes budget for large_idx"
)
def step_then_budget_respected(context: Any) -> None:
"""Assert that the hydrator stopped before exceeding _MAX_TOTAL_BYTES.
The fixture creates more data than the budget allows. The hydrator must
stop early, so the total content size of indexed fragments must be at or
below _MAX_TOTAL_BYTES.
"""
fragments = context.large_idx_fragments
total_size = sum(len((frag.content or "").encode("utf-8")) for frag in fragments)
assert total_size <= _MAX_TOTAL_BYTES, (
f"Total indexed size {total_size} bytes exceeds the "
f"_MAX_TOTAL_BYTES budget of {_MAX_TOTAL_BYTES} bytes."
)