Skip to content

Commit 500932a

Browse files
committed
fix(conveyor): keep long index stages alive and dedupe aliases
1 parent be673f6 commit 500932a

6 files changed

Lines changed: 383 additions & 123 deletions

File tree

scripts/streaming_conveyor.py

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2499,6 +2499,18 @@ def code_key(repo: str) -> str:
24992499
return f"{repo}::code"
25002500

25012501

2502+
def claim_code_project_identity(
2503+
claims: dict[str, str],
2504+
repo: str,
2505+
project_identity: str,
2506+
) -> str | None:
2507+
"""Claim one canonical code project and return an existing alias, if any."""
2508+
previous = claims.get(project_identity)
2509+
if previous is None:
2510+
claims[project_identity] = repo
2511+
return previous
2512+
2513+
25022514
def no_git_key(repo: str) -> str:
25032515
"""Checkpoint key proving the .git-preserving stream saw no git metadata."""
25042516
return f"{repo}::no_git"
@@ -4122,13 +4134,14 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
41224134
help="Parse workers passed to index_project for the CODE half "
41234135
"(default 2; avoid multiplying --repo-workers by the old "
41244136
"index_project default of 8 clang workers).")
4125-
p.add_argument("--code-index-timeout-s", type=int, default=0,
4126-
help="Optional fail-loud timeout for each CODE index_project "
4127-
"stage. 0 disables the timeout (default).")
4128-
p.add_argument("--code-index-stall-timeout-s", type=int, default=0,
4129-
help="Optional fail-loud CODE index_project stall watchdog: "
4130-
"kill when the index log has no size/mtime progress for "
4131-
"this many seconds. 0 disables the watchdog (default).")
4137+
p.add_argument("--code-index-timeout-s", type=int,
4138+
default=sr.DEFAULT_CODE_INDEX_TIMEOUT_S,
4139+
help="Fail-loud timeout for each CODE index_project stage "
4140+
f"(default {sr.DEFAULT_CODE_INDEX_TIMEOUT_S}s; 0 disables).")
4141+
p.add_argument("--code-index-stall-timeout-s", type=int,
4142+
default=sr.DEFAULT_CODE_INDEX_STALL_TIMEOUT_S,
4143+
help="Fail-loud log-heartbeat CODE index_project watchdog "
4144+
f"(default {sr.DEFAULT_CODE_INDEX_STALL_TIMEOUT_S}s; 0 disables).")
41324145
p.add_argument("--background-code-recompress", action="store_true",
41334146
help="Defer code parquet zstd-max recompress to a background "
41344147
"pool so repo processing can continue after valid parquet "
@@ -4220,7 +4233,11 @@ def main(argv: list[str]) -> int:
42204233
raise SystemExit("--max-active-repos must be >= --repo-workers")
42214234
parse_workers = max(1, int(args.parse_workers or 1))
42224235
args.parse_workers = parse_workers
4223-
code_index_stall_timeout_s = int(args.code_index_stall_timeout_s or 0)
4236+
code_index_stall_timeout_s = int(
4237+
args.code_index_stall_timeout_s
4238+
if args.code_index_stall_timeout_s is not None
4239+
else sr.DEFAULT_CODE_INDEX_STALL_TIMEOUT_S
4240+
)
42244241
source_cache_dir = Path(args.source_cache_dir) if args.source_cache_dir else None
42254242
source_dir_roots = [Path(p) for p in args.source_dir_root]
42264243
if args.source_cache_only and source_cache_dir is None:
@@ -4377,6 +4394,14 @@ def _on_signal(signum, _frame):
43774394
# CONVEYOR_MANIFEST; ConcurrentManifest merges + flocks every write so they
43784395
# cannot clobber each other's resume/accounting keys (H4 fix).
43794396
manifest = ConcurrentManifest.load(CONVEYOR_MANIFEST)
4397+
submitted_code_project_identities: dict[str, str] = {}
4398+
if args.streams == "code" and repo_list is not None:
4399+
for key in manifest.done:
4400+
if not key.endswith("::code"):
4401+
continue
4402+
repo_name = key[:-len("::code")]
4403+
project_id = sr.resolve_project_identity(repo_name, repo_list)
4404+
submitted_code_project_identities.setdefault(project_id, repo_name)
43804405
try:
43814406
manifest.bind_code_revision(revision_guard.receipt)
43824407
child_guard_path = install_code_revision_child_guard(
@@ -4564,14 +4589,44 @@ def should_process(repo: str) -> bool:
45644589
range_pool = ThreadPoolExecutor(max_workers=workers)
45654590
repo_pool = ThreadPoolExecutor(max_workers=repo_workers) if repo_workers > 1 else None
45664591

4567-
def claim_repo_once(repo: str) -> None:
4592+
def claim_repo_once(repo: str) -> bool:
45684593
if repo in submitted_repo_names:
45694594
raise RepoFailure(
45704595
repo,
45714596
"duplicate_repo",
45724597
f"repo {repo!r} was yielded/submitted twice in one conveyor run",
45734598
)
45744599
submitted_repo_names.add(repo)
4600+
if args.streams != "code" or repo_list is None:
4601+
return True
4602+
try:
4603+
project_id = sr.resolve_project_identity(repo, repo_list)
4604+
except Exception as exc:
4605+
raise RepoFailure(repo, "project_identity", str(exc)) from exc
4606+
previous = claim_code_project_identity(
4607+
submitted_code_project_identities,
4608+
repo,
4609+
project_id,
4610+
)
4611+
if previous is None:
4612+
return True
4613+
info = {
4614+
"source": "code",
4615+
"skipped": True,
4616+
"skip_reason": "duplicate_project_identity",
4617+
"project_identity": project_id,
4618+
"canonical_repo": previous,
4619+
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
4620+
}
4621+
with manifest_lock:
4622+
manifest.mark_done(code_key(repo), info)
4623+
progress.emit(
4624+
"duplicate_project_identity_skipped",
4625+
repo=repo,
4626+
stream="code",
4627+
**info,
4628+
)
4629+
return False
45754630

45764631
def mark_no_git_repo(repo: str) -> None:
45774632
info = {
@@ -4611,7 +4666,8 @@ def mark_no_git_repo(repo: str) -> None:
46114666
if hasattr(gen, "close"):
46124667
gen.close()
46134668
break
4614-
claim_repo_once(repo)
4669+
if not claim_repo_once(repo):
4670+
continue
46154671
revision_guard.verify(f"submit repo worker for {repo}")
46164672
res = process_one_repo(
46174673
repo, repo_dir, lengths_code, lengths_commits, args.range_size,
@@ -4694,7 +4750,8 @@ def drain_one_or_more(block: bool = True) -> None:
46944750
if hasattr(gen, "close"):
46954751
gen.close()
46964752
break
4697-
claim_repo_once(repo)
4753+
if not claim_repo_once(repo):
4754+
continue
46984755
while len(inflight) >= max_active_repos:
46994756
drain_one_or_more(block=True)
47004757
# Worker threads mutate cumulative['valid'] under manifest_lock

scripts/streaming_reindex.py

Lines changed: 12 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ def _load_local_symbol_identity() -> ModuleType:
9191
# Tokenize at the model's full context so packing decides the final lengths.
9292
TOKENIZE_BUDGET = 65536
9393
DEFAULT_TARGET_LENGTHS = (1024, 2048, 4096)
94+
DEFAULT_CODE_INDEX_TIMEOUT_S = 4 * 60 * 60
95+
DEFAULT_CODE_INDEX_STALL_TIMEOUT_S = 10 * 60
9496

9597
# Directories never worth indexing (VCS / build artifacts). index_project has its
9698
# own excludes; we additionally avoid extracting .git to keep staging small for
@@ -382,65 +384,6 @@ def _subprocess_env() -> dict:
382384
return env
383385

384386

385-
def _parse_ps_time_seconds(value: str) -> float | None:
386-
"""Parse ps TIME values like MM:SS.cc, HH:MM:SS.cc, or DD-HH:MM:SS.cc."""
387-
raw = value.strip()
388-
if not raw:
389-
return None
390-
days = 0
391-
if "-" in raw:
392-
day_raw, raw = raw.split("-", 1)
393-
try:
394-
days = int(day_raw)
395-
except ValueError:
396-
return None
397-
parts = raw.split(":")
398-
try:
399-
if len(parts) == 2:
400-
hours = 0
401-
minutes = int(parts[0])
402-
seconds = float(parts[1])
403-
elif len(parts) == 3:
404-
hours = int(parts[0])
405-
minutes = int(parts[1])
406-
seconds = float(parts[2])
407-
else:
408-
return None
409-
except ValueError:
410-
return None
411-
return float(days * 86400 + hours * 3600 + minutes * 60) + seconds
412-
413-
414-
def _process_group_cpu_seconds(pgid: int) -> float | None:
415-
"""Return cumulative CPU seconds for a process group, when ps supports it."""
416-
try:
417-
output = subprocess.check_output(
418-
["ps", "-axo", "pgid=,time="],
419-
text=True,
420-
stderr=subprocess.DEVNULL,
421-
)
422-
except (OSError, subprocess.SubprocessError):
423-
return None
424-
total = 0.0
425-
seen = False
426-
for line in output.splitlines():
427-
fields = line.split()
428-
if len(fields) < 2:
429-
continue
430-
try:
431-
row_pgid = int(fields[0])
432-
except ValueError:
433-
continue
434-
if row_pgid != pgid:
435-
continue
436-
seconds = _parse_ps_time_seconds(fields[1])
437-
if seconds is None:
438-
continue
439-
total += seconds
440-
seen = True
441-
return total if seen else None
442-
443-
444387
def run_checked(
445388
repo: str,
446389
stage: str,
@@ -494,7 +437,6 @@ def terminate_process(reason: str) -> None:
494437
deadline = time.monotonic() + timeout if timeout and timeout > 0 else None
495438
last_activity = time.monotonic()
496439
last_signature: tuple[int, int] | None = None
497-
last_cpu_seconds: float | None = None
498440
while proc.poll() is None:
499441
now = time.monotonic()
500442
if deadline is not None and now > deadline:
@@ -510,18 +452,12 @@ def terminate_process(reason: str) -> None:
510452
if signature != last_signature:
511453
last_signature = signature
512454
last_activity = now
513-
cpu_seconds = _process_group_cpu_seconds(proc.pid)
514-
if cpu_seconds is not None and cpu_seconds != last_cpu_seconds:
515-
last_cpu_seconds = cpu_seconds
516-
last_activity = now
517455
if now - last_activity > stall_timeout:
518-
terminate_process(
519-
f"no log/CPU progress for {stall_timeout}s"
520-
)
456+
terminate_process(f"no log progress for {stall_timeout}s")
521457
raise RepoFailure(
522458
repo,
523459
stage,
524-
f"stalled after {stall_timeout}s without log or CPU progress",
460+
f"stalled after {stall_timeout}s without log progress",
525461
)
526462
time.sleep(1.0)
527463
stdout_data, _ = proc.communicate(timeout=1)
@@ -1498,13 +1434,14 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
14981434
p.add_argument("--parse-workers", type=int, default=2,
14991435
help="Parse workers passed to index_project for the code stage "
15001436
"(default 2; keep this low when multiple repos run).")
1501-
p.add_argument("--code-index-timeout-s", type=int, default=0,
1502-
help="Optional fail-loud timeout for each index_project code "
1503-
"stage. 0 disables the timeout (default).")
1504-
p.add_argument("--code-index-stall-timeout-s", type=int, default=0,
1505-
help="Optional fail-loud stall watchdog for index_project: "
1506-
"kill when the log file has no size/mtime progress for "
1507-
"this many seconds. 0 disables the watchdog (default).")
1437+
p.add_argument("--code-index-timeout-s", type=int,
1438+
default=DEFAULT_CODE_INDEX_TIMEOUT_S,
1439+
help="Fail-loud timeout for each index_project code stage "
1440+
f"(default {DEFAULT_CODE_INDEX_TIMEOUT_S}s; 0 disables).")
1441+
p.add_argument("--code-index-stall-timeout-s", type=int,
1442+
default=DEFAULT_CODE_INDEX_STALL_TIMEOUT_S,
1443+
help="Fail-loud log-heartbeat watchdog for index_project "
1444+
f"(default {DEFAULT_CODE_INDEX_STALL_TIMEOUT_S}s; 0 disables).")
15081445
p.add_argument("--source-cache-dir", default=None,
15091446
help="Optional repo-level source cache for code-only runs. "
15101447
"Complete cached repos are reused before opening the "
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from pathlib import Path
5+
6+
7+
def test_parse_executor_recycles_long_lived_libclang_workers() -> None:
8+
from tools.clang_indexer.index_project import make_parse_executor
9+
10+
with make_parse_executor(max_workers=1, max_tasks_per_child=1) as executor:
11+
worker_pids = [executor.submit(os.getpid).result(timeout=10) for _ in range(3)]
12+
13+
assert len(set(worker_pids)) == 3
14+
15+
16+
def test_c_file_with_cpp_construct_keeps_cpp_language_mode(tmp_path: Path) -> None:
17+
from tools.clang_indexer.index_project import _adapt_args_for_file
18+
19+
source = tmp_path / "compiler-regression.c"
20+
source.write_text(
21+
"char *foo(void) { return(::new char[2] ('a', 'b')); }\n",
22+
encoding="utf-8",
23+
)
24+
25+
adapted = _adapt_args_for_file(["-std=c++17"], str(source))
26+
27+
assert adapted[:2] == ["-x", "c++"]
28+
assert "-std=c++17" in adapted
29+
30+
plain_c = tmp_path / "plain.c"
31+
plain_c.write_text("int add(int a, int b) { return a + b; }\n", encoding="utf-8")
32+
plain_args = _adapt_args_for_file(["-std=c++17"], str(plain_c))
33+
34+
assert plain_args[:2] == ["-x", "c"]
35+
assert "-std=c11" in plain_args
36+
37+
38+
def test_training_document_generation_emits_periodic_heartbeat(capsys) -> None:
39+
from tools.clang_indexer.index_project import (
40+
DOCUMENT_HEARTBEAT_ITEMS,
41+
FunctionDef,
42+
ProjectIndex,
43+
build_training_documents,
44+
)
45+
46+
index = ProjectIndex()
47+
total = DOCUMENT_HEARTBEAT_ITEMS + 1
48+
for item in range(total):
49+
index.add_function(
50+
FunctionDef(
51+
name=f"function_{item}",
52+
qualified_name=f"function_{item}",
53+
file=f"file_{item}.cpp",
54+
line=1,
55+
text=(
56+
f"int function_{item}(int value) {{ int first = value + {item}; "
57+
"int second = first * 2; int third = second - value; "
58+
"return third + first + second; }"
59+
),
60+
callees=[],
61+
)
62+
)
63+
64+
docs = build_training_documents(index, enriched=False)
65+
66+
captured = capsys.readouterr()
67+
assert len(docs) == total
68+
assert (
69+
f"Training document generation heartbeat: {DOCUMENT_HEARTBEAT_ITEMS}/{total}"
70+
in captured.err
71+
)

tests/test_streaming_conveyor_progress.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ def _repo_identity_map(tmp_path: Path) -> Path:
2525
return path
2626

2727

28+
def test_code_project_identity_claims_deduplicate_aliases() -> None:
29+
import streaming_conveyor
30+
31+
claims: dict[str, str] = {}
32+
33+
assert streaming_conveyor.claim_code_project_identity(
34+
claims,
35+
"open-watcom",
36+
"open-watcom/open-watcom-v2",
37+
) is None
38+
assert streaming_conveyor.claim_code_project_identity(
39+
claims,
40+
"open-watcom-v2",
41+
"open-watcom/open-watcom-v2",
42+
) == "open-watcom"
43+
44+
2845
def test_progress_writer_appends_jsonl(tmp_path):
2946
import streaming_conveyor
3047

0 commit comments

Comments
 (0)