Skip to content

Commit 2ff6e34

Browse files
committed
uploader: default to eight workers and harden I/O
1 parent 123113e commit 2ff6e34

12 files changed

Lines changed: 170 additions & 35 deletions

docs/Configuration_Reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ Rule: `dd_payload_uploader(...)`
303303
| `keep_payloads` | bool | `False` | Keep payload files after successful upload |
304304
| `filter_prefix` | bool | `False` | Only upload files matching `span_events_*.json` or `coverage_*.json` |
305305
| `gzip_payloads` | bool | `False` | Gzip test payloads before upload |
306-
| `workers` | int | `4` | Maximum independent payload-file workers in Python mode; `DD_TEST_OPTIMIZATION_WORKERS` and `--workers` override it at runtime |
306+
| `workers` | int | `8` | Maximum independent payload-file workers in Python mode; `DD_TEST_OPTIMIZATION_WORKERS` and `--workers` override it at runtime |
307307
| `use_python_uploader` | bool | `False` | Opt in to the parallel Python uploader; requires host Python 3.10+ at uploader runtime |
308308
| `data` | label_list | `[]` | Data files to include (for example, `context.json` for enrichment) |
309309
| `expected_targets` | string_list | `[]` | Optional exact local labels expected in the matching BEP. Fresh and cached results jointly satisfy coverage; only fresh outputs are inspected or uploaded |

docs/Uploader_Reference.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ dd_test_optimization_targets(
172172
sync_repo_name = "test_optimization_data",
173173
uploader_kwargs = {
174174
"use_python_uploader": True,
175-
"workers": 4,
175+
"workers": 8,
176176
},
177177
)
178178
```
@@ -186,7 +186,7 @@ worker can process test, coverage, or telemetry payloads; chunks and other
186186
derived requests belonging to one source remain sequential. Test and telemetry
187187
JSON reject non-standard numbers (`NaN` and positive/negative `Infinity`) before
188188
HTTP; coverage bodies remain opaque JSON/msgpack multipart parts. The
189-
rule-level `workers` value defaults to `4`; `DD_TEST_OPTIMIZATION_WORKERS`
189+
rule-level `workers` value defaults to `8`; `DD_TEST_OPTIMIZATION_WORKERS`
190190
overrides it at runtime and `--workers=<positive-integer>` has highest
191191
precedence. Leave
192192
`use_python_uploader = False` (the default) to retain the legacy Bash or
@@ -301,7 +301,7 @@ bazel run --config=test-optimization //:dd_upload_payloads
301301
| `DD_TEST_OPTIMIZATION_FILTER_PREFIX` | `0` | `0` uploads all payload files; set to `1` to only upload `span_events_*.json` or `coverage_*.json` |
302302
| `DD_TEST_OPTIMIZATION_DEBUG` | `0` | Set to `1` to enable verbose upload logging (HTTP codes, response bodies, startTime stats, and key runfile/CODEOWNERS resolution hits) |
303303
| `DD_TEST_OPTIMIZATION_GZIP` | `0` | Set to `1` to gzip test payloads before upload (adds `Content-Encoding: gzip`) |
304-
| `DD_TEST_OPTIMIZATION_WORKERS` | rule `workers` (`4`) | Override the maximum independent payload-file workers in Python mode; `--workers` has higher precedence |
304+
| `DD_TEST_OPTIMIZATION_WORKERS` | rule `workers` (`8`) | Override the maximum independent payload-file workers in Python mode; `--workers` has higher precedence |
305305
| `DD_TEST_OPTIMIZATION_MAX_WAIT_SEC` | `300` | Override max wait time for slow filesystems (NFS, network drives); set to `0` to skip waiting when no payloads are present |
306306
| `DD_TEST_OPTIMIZATION_QUIESCENT_SEC` | `10` | Override quiescence wait time |
307307
| `DD_TEST_OPTIMIZATION_MAX_DEPTH` | `0` (unlimited) | Limit `find` depth for large `bazel-testlogs` trees |

docs/python_parallel_uploader_migration_plan.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -577,9 +577,17 @@ Measure with identical payload fixtures and backend behavior:
577577
- temporary bytes written;
578578
- p50/p95 per-file latency.
579579

580-
Compare `workers=1`, the legacy uploader, and at least `workers=2/4/8`. The
581-
worker default remains conservative (`4`) until supported CI hosts show a
582-
repeatable benefit without backend throttling or excessive memory/temp usage.
580+
Compare `workers=1`, the legacy uploader, and at least `workers=2/4/8`.
581+
582+
A local ARM64/Python 3.12 loopback benchmark at commit `123113e` measured the
583+
real worker pipeline with identical fixtures and rotating execution order. For
584+
48 small mixed test/coverage/telemetry files, median speedups over one worker
585+
were `1.99x`, `3.83x`, and `6.14x` with 2, 4, and 8 workers. For eight test
586+
payloads above the 4.5 MiB split threshold, the corresponding speedups were
587+
`1.40x`, `1.50x`, and `1.51x`. Based on the network-bound large-batch result,
588+
the default is `8`; consumers with split-heavy workloads or backend throttling
589+
can override it to `4`. Continue validating memory, temporary storage, and
590+
real-backend retry behavior on supported CI hosts.
583591

584592
## Rollout and Rollback
585593

tools/core/test_optimization_uploader.bzl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -977,7 +977,7 @@ _dd_payload_uploader_rule = rule(
977977
"keep_payloads": attr.bool(default = False, doc = "Keep payload files after successful upload (env: DD_TEST_OPTIMIZATION_KEEP_PAYLOADS)"),
978978
"filter_prefix": attr.bool(default = False, doc = "Boolean gate: only upload files matching span_events_*.json or coverage_*.json; telemetry uploads are always eligible (env: DD_TEST_OPTIMIZATION_FILTER_PREFIX)"),
979979
"gzip_payloads": attr.bool(default = False, doc = "Gzip test payloads before upload (env: DD_TEST_OPTIMIZATION_GZIP)"),
980-
"workers": attr.int(default = 4, doc = "Maximum number of independent payload-file workers (env: DD_TEST_OPTIMIZATION_WORKERS, CLI: --workers)"),
980+
"workers": attr.int(default = 8, doc = "Maximum number of independent payload-file workers (env: DD_TEST_OPTIMIZATION_WORKERS, CLI: --workers)"),
981981
"use_python_uploader": attr.bool(default = False, doc = "Temporary rollout switch selecting the Python uploader implementation."),
982982
# Optional files to place in runfiles (e.g., a generated context.json)
983983
"data": attr.label_list(allow_files = True, doc = "Data files to include in runfiles (e.g., context.json for enrichment)"),

tools/core/uploader_py/discovery.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ def discover_file_tasks(
138138
for payload_type, relative_directory in _PAYLOAD_SUBDIRECTORIES:
139139
payload_directory = output.path / relative_directory
140140
if not payload_directory.is_dir():
141+
if payload_directory.is_symlink():
142+
warnings.append("payload_symlink_skipped")
143+
continue
144+
if not _safe_payload_directory(payload_directory, output.path):
145+
warnings.append("payload_symlink_skipped")
141146
continue
142147
for source_path in sorted(payload_directory.iterdir(), key=lambda item: item.name):
143148
if source_path.is_symlink():
@@ -267,3 +272,17 @@ def _find_test_outputs(root: Path, *, max_depth: int) -> tuple[Path, ...]:
267272
if max_depth > 0 and depth >= max_depth:
268273
subdirectories.clear()
269274
return tuple(sorted(found, key=lambda path: path.as_posix()))
275+
276+
277+
def _safe_payload_directory(path: Path, output: Path) -> bool:
278+
"""Accept only real payload directories contained by their test output."""
279+
current = output
280+
for part in path.relative_to(output).parts:
281+
current /= part
282+
if current.is_symlink():
283+
return False
284+
try:
285+
path.resolve().relative_to(output.resolve())
286+
except (OSError, ValueError):
287+
return False
288+
return True

tools/core/uploader_py/freshness.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -500,11 +500,7 @@ def _build_plan(
500500

501501
def _parse_execution_log(path: Path) -> set[tuple[str, str]]:
502502
eligible: set[tuple[str, str]] = set()
503-
try:
504-
lines = path.read_text(encoding="utf-8-sig").splitlines()
505-
except OSError as exc:
506-
raise FreshnessError(f"failed to read execution log JSON {path}: {exc}") from exc
507-
for line_number, raw_line in enumerate(lines, start=1):
503+
for line_number, raw_line in enumerate(_stream_execution_log(path), start=1):
508504
if not raw_line.strip():
509505
continue
510506
try:
@@ -540,6 +536,15 @@ def _parse_execution_log(path: Path) -> set[tuple[str, str]]:
540536
return eligible
541537

542538

539+
def _stream_execution_log(path: Path) -> Iterable[str]:
540+
"""Yield one action record at a time so large logs stay memory-bounded."""
541+
try:
542+
with path.open("r", encoding="utf-8-sig") as lines:
543+
yield from lines
544+
except (OSError, UnicodeError) as exc:
545+
raise FreshnessError(f"failed to read execution log JSON {path}: {exc}") from exc
546+
547+
543548
def _execution_output_key(raw: str) -> str:
544549
normalized = raw.replace("\\", "/")
545550
if "/testlogs/" in normalized:

tools/core/uploader_py/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818

1919
MAX_TEST_PAYLOAD_BYTES = 4_718_592
20-
DEFAULT_WORKERS = 4
20+
DEFAULT_WORKERS = 8
2121

2222

2323
class PayloadType(str, Enum):

tools/core/uploader_py/transport.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -609,9 +609,14 @@ def _post(
609609
headers=dict(prepared_request.headers),
610610
method="POST",
611611
)
612-
with closing(
613-
self._opener.open(http_request, timeout=self.request_timeout)
614-
) as response:
612+
try:
613+
response = self._opener.open(
614+
http_request,
615+
timeout=self.request_timeout,
616+
)
617+
except HTTPError as exc:
618+
response = exc
619+
with closing(response):
615620
status_code = int(response.status)
616621
response_excerpt, excerpt_truncated = _bounded_response(
617622
response,
@@ -633,17 +638,13 @@ def _post(
633638
)
634639
retryable = _retryable_status(status_code)
635640
retry_after = response.headers.get("Retry-After")
636-
except HTTPError as exc:
637-
with closing(exc):
638-
status_code = int(exc.code)
639-
response_excerpt, excerpt_truncated = _bounded_response(
640-
exc,
641-
self.response_limit,
642-
)
643-
retry_after = exc.headers.get("Retry-After")
644-
transport_error = None
645-
retryable = _retryable_status(status_code)
646-
except (URLError, TimeoutError, ConnectionError, http.client.HTTPException) as exc:
641+
except (
642+
URLError,
643+
TimeoutError,
644+
ConnectionError,
645+
http.client.HTTPException,
646+
ssl.SSLError,
647+
) as exc:
647648
status_code = None
648649
response_excerpt = b""
649650
excerpt_truncated = False

tools/tests/python/test_uploader_discovery_tools.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,43 @@ def test_payload_symlink_is_never_scheduled(self) -> None:
117117
self.assertEqual((), discovery.tasks)
118118
self.assertIn("payload_symlink_skipped", discovery.warning_codes)
119119

120+
def test_payload_directory_symlink_is_never_scheduled(self) -> None:
121+
with tempfile.TemporaryDirectory() as raw_root:
122+
root = Path(raw_root)
123+
output = root / "pkg" / "target" / "test.outputs"
124+
outside = root / "outside" / "tests"
125+
outside.mkdir(parents=True)
126+
(outside / "events.json").write_text("{}", encoding="utf-8")
127+
link = output / "payloads" / "tests"
128+
link.parent.mkdir(parents=True)
129+
try:
130+
link.symlink_to(outside, target_is_directory=True)
131+
except OSError as exc:
132+
self.skipTest(f"directory symlink creation unavailable: {exc}")
133+
134+
discovery = discover_file_tasks((ScanRoot(root),))
135+
136+
self.assertEqual((), discovery.tasks)
137+
self.assertIn("payload_symlink_skipped", discovery.warning_codes)
138+
139+
def test_intermediate_payload_symlink_cannot_escape_test_outputs(self) -> None:
140+
with tempfile.TemporaryDirectory() as raw_root:
141+
root = Path(raw_root)
142+
output = root / "pkg" / "target" / "test.outputs"
143+
outside = root / "outside" / "payloads"
144+
(outside / "tests").mkdir(parents=True)
145+
(outside / "tests" / "events.json").write_text("{}", encoding="utf-8")
146+
output.mkdir(parents=True)
147+
try:
148+
(output / "payloads").symlink_to(outside, target_is_directory=True)
149+
except OSError as exc:
150+
self.skipTest(f"directory symlink creation unavailable: {exc}")
151+
152+
discovery = discover_file_tasks((ScanRoot(root),))
153+
154+
self.assertEqual((), discovery.tasks)
155+
self.assertIn("payload_symlink_skipped", discovery.warning_codes)
156+
120157
def test_selected_output_never_falls_back_to_stale_local_when_staging_failed(self) -> None:
121158
with tempfile.TemporaryDirectory() as raw_root:
122159
root = Path(raw_root)

tools/tests/python/test_uploader_freshness_tools.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pathlib import Path
1717
import tempfile
1818
import unittest
19+
from unittest import mock
1920
import zipfile
2021

2122
from uploader_test_support import (
@@ -135,11 +136,16 @@ def test_execution_log_selects_only_fresh_output_and_stamps_label(self) -> None:
135136
)
136137
resolver = RunfilesResolver.from_environment(cwd=root, environ={})
137138

138-
prepared = prepare_freshness(
139-
config,
140-
resolver=resolver,
141-
local_testlogs_root=testlogs,
142-
)
139+
with mock.patch.object(
140+
Path,
141+
"read_text",
142+
side_effect=AssertionError("execution logs must be streamed"),
143+
):
144+
prepared = prepare_freshness(
145+
config,
146+
resolver=resolver,
147+
local_testlogs_root=testlogs,
148+
)
143149
discovery = discover_file_tasks(prepared.scan_roots)
144150
filtered = filter_discovery_for_freshness(
145151
discovery,

0 commit comments

Comments
 (0)