feat(formatter): resumable chunk merge with bounded fan-in

Collapse chunk_*.jsonl in rounds with a bounded merge fan-in so the number of
simultaneously open chunk iterators stays flat regardless of how many chunks a
trace produces, and write a resume manifest so --resume-tmp-dir can continue
from an existing formatter temp directory without rescanning the raw trace.
This commit is contained in:
2026-09-11 06:30:48 +00:00
parent 5a6b1acb49
commit 5ee98f9e4d
3 changed files with 295 additions and 36 deletions

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
import trace_formatter.formatting as formatting
from trace_analyzer.cli import main as analyzer_main from trace_analyzer.cli import main as analyzer_main
from trace_analyzer.parser import load_records from trace_analyzer.parser import load_records
from trace_formatter.cli import main as formatter_main from trace_formatter.cli import main as formatter_main
@@ -684,6 +685,99 @@ class AliTracePipelineTest(unittest.TestCase):
self.assertEqual(stats["row_count"], 1) self.assertEqual(stats["row_count"], 1)
formatted_rows = [json.loads(line) for line in output_path.read_text(encoding="utf-8").splitlines()] formatted_rows = [json.loads(line) for line in output_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual([row["meta"]["request_id"] for row in formatted_rows], ["req-zst"]) self.assertEqual([row["meta"]["request_id"] for row in formatted_rows], ["req-zst"])
def test_format_and_sort_trace_limits_simultaneous_chunk_iterators_during_merge(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
input_dir = root / "raw"
input_dir.mkdir()
output_path = root / "formatted.jsonl"
with (input_dir / "0417-1500-1530.jsonl").open("w", encoding="utf-8") as handle:
for index in range(5):
handle.write(
json.dumps(
make_raw_row(
f"req-{index}",
utc_ms(f"2026-04-17 15:00:0{index + 1}.000"),
)
)
+ "\n"
)
original_iter_chunk_rows = formatting._iter_chunk_rows
active_iterators = 0
max_active_iterators = 0
def counting_iter_chunk_rows(path):
nonlocal active_iterators, max_active_iterators
active_iterators += 1
max_active_iterators = max(max_active_iterators, active_iterators)
try:
yield from original_iter_chunk_rows(path)
finally:
active_iterators -= 1
with mock.patch("trace_formatter.formatting._resolve_merge_fan_in", return_value=2):
with mock.patch("trace_formatter.formatting._iter_chunk_rows", side_effect=counting_iter_chunk_rows):
stats = format_and_sort_trace(input_dir=input_dir, output_path=output_path, chunk_bytes=256)
self.assertEqual(stats["chunk_count"], 5)
self.assertEqual(max_active_iterators, 2)
self.assertEqual(
[record.meta.request_id for record in load_records(output_path)],
["req-0", "req-1", "req-2", "req-3", "req-4"],
)
def test_format_and_sort_trace_can_resume_from_existing_chunk_dir_without_rescanning(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
input_dir = root / "raw"
input_dir.mkdir()
output_path = root / "formatted.jsonl"
resume_dir = root / "resume-tmp"
resume_dir.mkdir()
raw_rows = [
make_raw_row("req-1", utc_ms("2026-04-17 15:00:01.000")),
make_raw_row("req-2", utc_ms("2026-04-17 15:00:02.000"), user_id="user-2"),
]
with (input_dir / "0417-1500-1530.jsonl").open("w", encoding="utf-8") as handle:
for row in raw_rows:
handle.write(json.dumps(row) + "\n")
chunk_rows = []
for seq, raw in enumerate(reversed(raw_rows)):
request_params, response_params, request_payload, attributes, messages = formatting._extract_request_components(raw)
sort_time_ms, user_id, request_id, sequence_hashes, roles, row_json = formatting._build_unified_row_payload_from_components(
raw,
request_params=request_params,
response_params=response_params,
request_payload=request_payload,
request_attributes=attributes,
messages=messages,
source_file="0417-1500-1530.jsonl",
source_line=seq + 1,
)
chunk_rows.append((sort_time_ms, seq, user_id, request_id, sequence_hashes, roles, row_json))
formatting._write_chunk([chunk_rows[0]], resume_dir, 0)
formatting._write_chunk([chunk_rows[1]], resume_dir, 1)
with mock.patch("trace_formatter.formatting.open_trace_text", side_effect=AssertionError("unexpected rescan")):
stats = format_and_sort_trace(
input_dir=input_dir,
output_path=output_path,
resume_tmp_dir=resume_dir,
)
self.assertEqual(stats["row_count"], 2)
self.assertEqual(stats["chunk_count"], 2)
self.assertIsNone(stats["filtered_row_count"])
self.assertEqual(
[record.meta.request_id for record in load_records(output_path)],
["req-1", "req-2"],
)
self.assertFalse(resume_dir.exists())
def test_export_release_ready_trace_defaults_temp_dir_to_output_parent(self): def test_export_release_ready_trace_defaults_temp_dir_to_output_parent(self):
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:

View File

@@ -64,6 +64,11 @@ def build_parser() -> argparse.ArgumentParser:
help="Base directory used when --output is omitted. Defaults to a sibling trace-*-formatted directory.", help="Base directory used when --output is omitted. Defaults to a sibling trace-*-formatted directory.",
) )
format_parser.add_argument("--tmp-dir", default=None) format_parser.add_argument("--tmp-dir", default=None)
format_parser.add_argument(
"--resume-tmp-dir",
default=None,
help="Resume from an existing formatter temp directory containing chunk_*.jsonl and skip raw trace scanning.",
)
format_parser.add_argument("--chunk-bytes", type=int, default=128 * 1024 * 1024) format_parser.add_argument("--chunk-bytes", type=int, default=128 * 1024 * 1024)
format_parser.add_argument( format_parser.add_argument(
"--log-file", "--log-file",
@@ -146,6 +151,7 @@ def main(argv: list[str] | None = None) -> int:
input_dir=args.input, input_dir=args.input,
output_path=raw_output_path, output_path=raw_output_path,
tmp_dir=args.tmp_dir, tmp_dir=args.tmp_dir,
resume_tmp_dir=args.resume_tmp_dir,
chunk_bytes=args.chunk_bytes, chunk_bytes=args.chunk_bytes,
start_time=args.start_time, start_time=args.start_time,
end_time=args.end_time, end_time=args.end_time,

View File

@@ -12,7 +12,7 @@ from concurrent.futures import ProcessPoolExecutor, as_completed
from contextlib import contextmanager, nullcontext from contextlib import contextmanager, nullcontext
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterable, Iterator
from trace_analyzer.helpers import normalize_unicode_text, parse_jsonish, safe_int from trace_analyzer.helpers import normalize_unicode_text, parse_jsonish, safe_int
from tokenizers import Tokenizer from tokenizers import Tokenizer
@@ -308,6 +308,10 @@ def _build_unified_row_payload_from_components(
def _write_chunk(rows: list[tuple[int, int, str, str, str, str, str]], tmp_dir: Path, chunk_index: int) -> Path: def _write_chunk(rows: list[tuple[int, int, str, str, str, str, str]], tmp_dir: Path, chunk_index: int) -> Path:
rows.sort(key=lambda item: (item[0], item[1])) rows.sort(key=lambda item: (item[0], item[1]))
path = tmp_dir / f"chunk_{chunk_index:05d}.jsonl" path = tmp_dir / f"chunk_{chunk_index:05d}.jsonl"
return _write_chunk_rows(rows, path)
def _write_chunk_rows(rows: Iterable[tuple[int, int, str, str, str, str, str]], path: Path) -> Path:
with path.open("w", encoding="utf-8") as handle: with path.open("w", encoding="utf-8") as handle:
for sort_time_ms, seq, user_id, request_id, sequence_hashes, roles, row_json in rows: for sort_time_ms, seq, user_id, request_id, sequence_hashes, roles, row_json in rows:
handle.write(f"{sort_time_ms}\t{seq}\t{user_id}\t{request_id}\t{sequence_hashes}\t{roles}\t{row_json}\n") handle.write(f"{sort_time_ms}\t{seq}\t{user_id}\t{request_id}\t{sequence_hashes}\t{roles}\t{row_json}\n")
@@ -321,6 +325,136 @@ def _iter_chunk_rows(path: Path) -> Iterator[tuple[int, int, str, str, str, str,
yield int(sort_text), int(seq_text), user_id, request_id, sequence_hashes, roles, row_json yield int(sort_text), int(seq_text), user_id, request_id, sequence_hashes, roles, row_json
def _merge_chunk_group(chunk_paths: list[Path], output_path: Path) -> Path:
iterators = [_iter_chunk_rows(path) for path in chunk_paths]
return _write_chunk_rows(
heapq.merge(*iterators, key=lambda item: (item[0], item[1])),
output_path,
)
def _resume_manifest_path(chunk_root: Path) -> Path:
return chunk_root / "resume-manifest.json"
def _write_resume_manifest(chunk_root: Path, payload: dict) -> None:
_resume_manifest_path(chunk_root).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _load_resume_manifest(chunk_root: Path) -> dict | None:
path = _resume_manifest_path(chunk_root)
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def _resolve_merge_fan_in() -> int:
fallback_fan_in = 128
max_fan_in = 512
reserved_fds = 32
try:
import resource
soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
except (ImportError, OSError, ValueError):
return fallback_fan_in
if soft_limit < 0:
return max_fan_in
return max(2, min(max_fan_in, soft_limit - reserved_fds))
def _collapse_chunk_paths(chunk_paths: list[Path], tmp_dir: Path, *, merge_fan_in: int | None = None) -> list[Path]:
current_paths = list(chunk_paths)
fan_in = max(2, merge_fan_in if merge_fan_in is not None else _resolve_merge_fan_in())
round_index = 0
while len(current_paths) > fan_in:
next_round: list[Path] = []
for group_index, start in enumerate(range(0, len(current_paths), fan_in)):
group = current_paths[start : start + fan_in]
if len(group) == 1:
next_round.append(group[0])
continue
merged_path = tmp_dir / f"merge_{round_index:02d}_{group_index:05d}.jsonl"
_merge_chunk_group(group, merged_path)
for path in group:
path.unlink(missing_ok=True)
next_round.append(merged_path)
current_paths = next_round
round_index += 1
return current_paths
def _discover_resume_chunk_paths(chunk_root: Path) -> list[Path]:
chunk_paths = sorted(chunk_root.glob("chunk_*.jsonl"))
if not chunk_paths:
raise FileNotFoundError(f"No resumable chunk_*.jsonl files found under {chunk_root}")
return chunk_paths
def _merge_chunks_into_output(
*,
chunk_paths: list[Path],
chunk_root: Path,
destination: Path,
merge_fan_in: int | None,
progress_stream,
show_progress: bool,
row_count_hint: int | None = None,
progress_chunk_count: int | None = None,
) -> dict:
raw_chunk_count = len(chunk_paths)
visible_chunk_count = progress_chunk_count if progress_chunk_count is not None else raw_chunk_count
temp_raw_destination = chunk_root / "formatted-raw.tmp.jsonl"
reduced_chunk_paths = _collapse_chunk_paths(chunk_paths, chunk_root, merge_fan_in=merge_fan_in)
iterators = [_iter_chunk_rows(path) for path in reduced_chunk_paths]
sessionizer = LogicalSessionizer()
row_count = 0
min_sort_time_ms: int | None = None
max_sort_time_ms: int | None = None
user_scoped_rows = 0
merge_progress = tqdm(
total=row_count_hint,
desc="Merge formatted trace",
unit="row",
dynamic_ncols=True,
file=progress_stream,
disable=not show_progress,
)
try:
with temp_raw_destination.open("w", encoding="utf-8") as output_handle:
for sort_time_ms, _, user_id, request_id, sequence_hashes, roles, row_json in heapq.merge(
*iterators, key=lambda item: (item[0], item[1])
):
assignment = sessionizer.assign_precomputed(
user_id=user_id,
request_id=request_id,
sequence_hashes=decode_prefix_hashes(sequence_hashes),
roles=decode_roles(roles),
)
output_handle.write(_apply_session_assignment_to_row_json(row_json, assignment))
output_handle.write("\n")
row_count += 1
min_sort_time_ms = sort_time_ms if min_sort_time_ms is None else min(min_sort_time_ms, sort_time_ms)
max_sort_time_ms = sort_time_ms if max_sort_time_ms is None else max(max_sort_time_ms, sort_time_ms)
if user_id:
user_scoped_rows += 1
if show_progress:
merge_progress.update(1)
merge_progress.set_postfix(rows=row_count if row_count_hint is None else row_count_hint, chunks=visible_chunk_count)
finally:
if show_progress:
merge_progress.close()
shutil.move(str(temp_raw_destination), str(destination))
return {
"row_count": row_count,
"chunk_count": raw_chunk_count,
"min_sort_time_ms": min_sort_time_ms or 0,
"max_sort_time_ms": max_sort_time_ms or 0,
"rows_with_user_id": user_scoped_rows,
}
def _replace_json_field_once(row_json: str, *, key: str, value_text: str) -> str: def _replace_json_field_once(row_json: str, *, key: str, value_text: str) -> str:
target = f'"{key}":""' target = f'"{key}":""'
if target in row_json: if target in row_json:
@@ -654,7 +788,9 @@ def format_and_sort_trace(
input_dir: str | Path, input_dir: str | Path,
output_path: str | Path, output_path: str | Path,
tmp_dir: str | Path | None = None, tmp_dir: str | Path | None = None,
resume_tmp_dir: str | Path | None = None,
chunk_bytes: int = 128 * 1024 * 1024, chunk_bytes: int = 128 * 1024 * 1024,
merge_fan_in: int | None = None,
start_time: str | None = None, start_time: str | None = None,
end_time: str | None = None, end_time: str | None = None,
truncate_to_window: bool = True, truncate_to_window: bool = True,
@@ -670,6 +806,38 @@ def format_and_sort_trace(
total_input_bytes = sum(path.stat().st_size for path in source_files if path.suffix != ".zst") total_input_bytes = sum(path.stat().st_size for path in source_files if path.suffix != ".zst")
has_zst = any(path.suffix == ".zst" for path in source_files) has_zst = any(path.suffix == ".zst" for path in source_files)
if resume_tmp_dir is not None:
chunk_root = Path(resume_tmp_dir)
manifest = _load_resume_manifest(chunk_root) or {}
with _open_progress_stream(log_file) as progress_stream:
merge_stats = _merge_chunks_into_output(
chunk_paths=_discover_resume_chunk_paths(chunk_root),
chunk_root=chunk_root,
destination=destination,
merge_fan_in=merge_fan_in,
progress_stream=progress_stream,
show_progress=show_progress,
row_count_hint=manifest.get("row_count"),
progress_chunk_count=manifest.get("chunk_count"),
)
shutil.rmtree(chunk_root, ignore_errors=True)
return {
"output_path": str(destination),
"row_count": merge_stats["row_count"],
"source_file_count": len(source_files),
"chunk_count": manifest.get("chunk_count", merge_stats["chunk_count"]),
"min_sort_time_ms": merge_stats["min_sort_time_ms"],
"max_sort_time_ms": merge_stats["max_sort_time_ms"],
"rows_with_user_id": merge_stats["rows_with_user_id"],
"truncated_row_count": manifest.get("truncated_row_count"),
"filtered_row_count": manifest.get("filtered_row_count"),
"filtered_empty_messages_row_count": manifest.get("filtered_empty_messages_row_count"),
"filtered_empty_response_params_row_count": manifest.get("filtered_empty_response_params_row_count"),
"window_start_ms": time_window.start_ms if time_window is not None else None,
"window_end_ms": time_window.end_ms if time_window is not None else None,
"resumed_from_tmp_dir": str(chunk_root),
}
with _open_progress_stream(log_file) as progress_stream, tempfile.TemporaryDirectory(dir=temp_root_dir) as temp_root: with _open_progress_stream(log_file) as progress_stream, tempfile.TemporaryDirectory(dir=temp_root_dir) as temp_root:
temp_raw_destination = Path(temp_root) / "formatted-raw.tmp.jsonl" temp_raw_destination = Path(temp_root) / "formatted-raw.tmp.jsonl"
chunk_root = Path(temp_root) chunk_root = Path(temp_root)
@@ -783,46 +951,37 @@ def format_and_sort_trace(
if chunk_rows: if chunk_rows:
chunk_paths.append(_write_chunk(chunk_rows, chunk_root, len(chunk_paths))) chunk_paths.append(_write_chunk(chunk_rows, chunk_root, len(chunk_paths)))
iterators = [_iter_chunk_rows(path) for path in chunk_paths] raw_chunk_count = len(chunk_paths)
sessionizer = LogicalSessionizer() _write_resume_manifest(
merge_progress = tqdm( chunk_root,
total=total_rows, {
desc="Merge formatted trace", "row_count": total_rows,
unit="row", "chunk_count": raw_chunk_count,
dynamic_ncols=True, "truncated_row_count": truncated_rows,
file=progress_stream, "filtered_row_count": filtered_rows,
disable=not show_progress, "filtered_empty_messages_row_count": filtered_empty_messages_rows,
"filtered_empty_response_params_row_count": filtered_empty_response_rows,
},
)
merge_stats = _merge_chunks_into_output(
chunk_paths=chunk_paths,
chunk_root=chunk_root,
destination=destination,
merge_fan_in=merge_fan_in,
progress_stream=progress_stream,
show_progress=show_progress,
row_count_hint=total_rows,
progress_chunk_count=raw_chunk_count,
) )
try:
with temp_raw_destination.open("w", encoding="utf-8") as output_handle:
for _, _, user_id, request_id, sequence_hashes, roles, row_json in heapq.merge(
*iterators, key=lambda item: (item[0], item[1])
):
assignment = sessionizer.assign_precomputed(
user_id=user_id,
request_id=request_id,
sequence_hashes=decode_prefix_hashes(sequence_hashes),
roles=decode_roles(roles),
)
output_handle.write(_apply_session_assignment_to_row_json(row_json, assignment))
output_handle.write("\n")
if show_progress:
merge_progress.update(1)
merge_progress.set_postfix(rows=total_rows, chunks=len(chunk_paths))
finally:
if show_progress:
merge_progress.close()
shutil.move(str(temp_raw_destination), str(destination))
return { return {
"output_path": str(destination), "output_path": str(destination),
"row_count": total_rows, "row_count": merge_stats["row_count"],
"source_file_count": len(source_files), "source_file_count": len(source_files),
"chunk_count": len(chunk_paths), "chunk_count": raw_chunk_count,
"min_sort_time_ms": min_sort_time_ms or 0, "min_sort_time_ms": merge_stats["min_sort_time_ms"],
"max_sort_time_ms": max_sort_time_ms or 0, "max_sort_time_ms": merge_stats["max_sort_time_ms"],
"rows_with_user_id": user_scoped_rows, "rows_with_user_id": merge_stats["rows_with_user_id"],
"truncated_row_count": truncated_rows, "truncated_row_count": truncated_rows,
"filtered_row_count": filtered_rows, "filtered_row_count": filtered_rows,
"filtered_empty_messages_row_count": filtered_empty_messages_rows, "filtered_empty_messages_row_count": filtered_empty_messages_rows,