tapbench 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tapbench/__init__.py +5 -0
- tapbench/__main__.py +6 -0
- tapbench/cli.py +564 -0
- tapbench/client.py +748 -0
- tapbench/examples/expensive_small_result.sql +7 -0
- tapbench/examples/fast_lookup.sql +6 -0
- tapbench/examples/large_data.sql +4 -0
- tapbench/examples/selective_join.sql +12 -0
- tapbench/examples/selective_join_and_sort.sql +13 -0
- tapbench/models.py +140 -0
- tapbench/scheduler.py +205 -0
- tapbench/stats.py +223 -0
- tapbench-0.1.0.dist-info/METADATA +313 -0
- tapbench-0.1.0.dist-info/RECORD +17 -0
- tapbench-0.1.0.dist-info/WHEEL +4 -0
- tapbench-0.1.0.dist-info/entry_points.txt +2 -0
- tapbench-0.1.0.dist-info/licenses/LICENSE +28 -0
tapbench/__init__.py
ADDED
tapbench/__main__.py
ADDED
tapbench/cli.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Command-line interface and output rendering for tapbench."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import asdict
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, cast
|
|
21
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
22
|
+
|
|
23
|
+
from tapbench.models import JobRecord, RunConfig, RunResult, SchedulerResult
|
|
24
|
+
|
|
25
|
+
JSON_SCHEMA = "tapbench.run/v1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigError(ValueError):
|
|
29
|
+
"""Raised when command configuration is invalid."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _positive_finite(value: str) -> float:
|
|
33
|
+
try:
|
|
34
|
+
parsed = float(value)
|
|
35
|
+
except ValueError as exc:
|
|
36
|
+
raise argparse.ArgumentTypeError("must be a number") from exc
|
|
37
|
+
if not math.isfinite(parsed) or parsed <= 0:
|
|
38
|
+
raise argparse.ArgumentTypeError("must be finite and greater than zero")
|
|
39
|
+
return parsed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _positive_int(value: str) -> int:
|
|
43
|
+
try:
|
|
44
|
+
parsed = int(value)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise argparse.ArgumentTypeError("must be an integer") from exc
|
|
47
|
+
if parsed < 1:
|
|
48
|
+
raise argparse.ArgumentTypeError("must be at least one")
|
|
49
|
+
return parsed
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
"""Build the command parser."""
|
|
54
|
+
|
|
55
|
+
parser = argparse.ArgumentParser(prog="tapbench", description=__doc__)
|
|
56
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
57
|
+
run = subparsers.add_parser("run", help="run a fixed-rate TAP async benchmark")
|
|
58
|
+
run.add_argument("--url", required=True, help="TAP base URL")
|
|
59
|
+
run.add_argument(
|
|
60
|
+
"--query-file", required=True, type=Path, help="file containing one ADQL query"
|
|
61
|
+
)
|
|
62
|
+
run.add_argument("--rate", required=True, type=_positive_finite, help="submissions per second")
|
|
63
|
+
run.add_argument("--duration", required=True, type=_positive_finite, help="scheduling seconds")
|
|
64
|
+
run.add_argument("--max-inflight", type=_positive_int, default=200)
|
|
65
|
+
run.add_argument("--poll-interval", type=_positive_finite, default=0.5)
|
|
66
|
+
run.add_argument("--request-timeout", type=_positive_finite, default=30.0)
|
|
67
|
+
run.add_argument("--job-timeout", type=_positive_finite, default=300.0)
|
|
68
|
+
run.add_argument(
|
|
69
|
+
"--download-results",
|
|
70
|
+
action=argparse.BooleanOptionalAction,
|
|
71
|
+
default=True,
|
|
72
|
+
help="download completed results (default: enabled)",
|
|
73
|
+
)
|
|
74
|
+
run.add_argument("--keep-jobs", action="store_true", help="do not delete remote UWS jobs")
|
|
75
|
+
run.add_argument("--json-output", type=Path, help="write detailed JSON results")
|
|
76
|
+
run.add_argument("--seed", type=int, default=0, help="polling jitter seed")
|
|
77
|
+
run.add_argument(
|
|
78
|
+
"--progress",
|
|
79
|
+
action=argparse.BooleanOptionalAction,
|
|
80
|
+
default=True,
|
|
81
|
+
help="show live progress (default: enabled)",
|
|
82
|
+
)
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _normalize_url(raw_url: str) -> str:
|
|
87
|
+
parsed = urlsplit(raw_url)
|
|
88
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
89
|
+
raise ConfigError("--url must be an absolute HTTP or HTTPS URL")
|
|
90
|
+
if parsed.username is not None or parsed.password is not None:
|
|
91
|
+
raise ConfigError("--url must not contain credentials")
|
|
92
|
+
if parsed.query or parsed.fragment:
|
|
93
|
+
raise ConfigError("--url must not contain a query string or fragment")
|
|
94
|
+
path = parsed.path.rstrip("/")
|
|
95
|
+
return urlunsplit((parsed.scheme.lower(), parsed.netloc, path, "", ""))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def config_from_namespace(args: argparse.Namespace) -> tuple[RunConfig, str]:
|
|
99
|
+
"""Validate parsed arguments and read the query file without network access."""
|
|
100
|
+
|
|
101
|
+
query_file = args.query_file.expanduser()
|
|
102
|
+
if not query_file.is_file():
|
|
103
|
+
raise ConfigError(f"query file is not a readable regular file: {query_file}")
|
|
104
|
+
try:
|
|
105
|
+
query = query_file.read_text(encoding="utf-8")
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise ConfigError(f"cannot read query file: {exc.strerror or type(exc).__name__}") from None
|
|
108
|
+
if not query.strip():
|
|
109
|
+
raise ConfigError("query file is empty")
|
|
110
|
+
|
|
111
|
+
json_output = args.json_output.expanduser() if args.json_output else None
|
|
112
|
+
if json_output is not None:
|
|
113
|
+
if json_output.resolve() == query_file.resolve():
|
|
114
|
+
raise ConfigError("--json-output must not overwrite --query-file")
|
|
115
|
+
if not json_output.parent.is_dir():
|
|
116
|
+
raise ConfigError("--json-output parent directory does not exist")
|
|
117
|
+
|
|
118
|
+
config = RunConfig(
|
|
119
|
+
url=_normalize_url(args.url),
|
|
120
|
+
query_file=query_file,
|
|
121
|
+
rate=args.rate,
|
|
122
|
+
duration=args.duration,
|
|
123
|
+
max_inflight=args.max_inflight,
|
|
124
|
+
poll_interval=args.poll_interval,
|
|
125
|
+
request_timeout=args.request_timeout,
|
|
126
|
+
job_timeout=args.job_timeout,
|
|
127
|
+
download_results=args.download_results,
|
|
128
|
+
keep_jobs=args.keep_jobs,
|
|
129
|
+
json_output=json_output,
|
|
130
|
+
seed=args.seed,
|
|
131
|
+
progress=args.progress,
|
|
132
|
+
)
|
|
133
|
+
return config, query
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_credentials() -> tuple[str, str]:
|
|
137
|
+
"""Read required Basic-auth credentials from the inherited environment."""
|
|
138
|
+
|
|
139
|
+
username = os.environ.get("TAPBENCH_USER")
|
|
140
|
+
password = os.environ.get("TAPBENCH_PASSWORD")
|
|
141
|
+
if not username or not password:
|
|
142
|
+
raise ConfigError("TAPBENCH_USER and TAPBENCH_PASSWORD must both be set")
|
|
143
|
+
return username, password
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def human_bytes(value: int) -> str:
|
|
147
|
+
"""Render a byte count using binary units."""
|
|
148
|
+
|
|
149
|
+
amount = float(value)
|
|
150
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
151
|
+
if abs(amount) < 1024.0 or unit == "TiB":
|
|
152
|
+
return f"{amount:.0f} {unit}" if unit == "B" else f"{amount:.2f} {unit}"
|
|
153
|
+
amount /= 1024.0
|
|
154
|
+
raise AssertionError("unreachable")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def progress_line(
|
|
158
|
+
records: Sequence[JobRecord],
|
|
159
|
+
*,
|
|
160
|
+
active: int,
|
|
161
|
+
elapsed_s: float,
|
|
162
|
+
duration_s: float,
|
|
163
|
+
download_results: bool,
|
|
164
|
+
scheduling_complete: bool = False,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Render one stable live-progress snapshot."""
|
|
167
|
+
|
|
168
|
+
scheduling = not scheduling_complete and elapsed_s < duration_s
|
|
169
|
+
label = (
|
|
170
|
+
f"schedule {min(elapsed_s, duration_s):5.1f}/{duration_s:.1f}s"
|
|
171
|
+
if scheduling
|
|
172
|
+
else f"drain {elapsed_s:5.1f}s"
|
|
173
|
+
)
|
|
174
|
+
completed = sum(record.final_uws_phase == "COMPLETED" for record in records)
|
|
175
|
+
errors = sum(record.final_uws_phase == "ERROR" for record in records)
|
|
176
|
+
aborted = sum(record.final_uws_phase == "ABORTED" for record in records)
|
|
177
|
+
values = [
|
|
178
|
+
f"[{label}]",
|
|
179
|
+
f"offered={len(records)}",
|
|
180
|
+
f"started={sum(record.actual_submission_start_offset_s is not None for record in records)}",
|
|
181
|
+
f"dropped={sum(record.dropped_locally for record in records)}",
|
|
182
|
+
f"active={active}",
|
|
183
|
+
f"created={sum(record.job_created for record in records)}",
|
|
184
|
+
f"completed={completed}",
|
|
185
|
+
f"error={errors}",
|
|
186
|
+
f"aborted={aborted}",
|
|
187
|
+
f"success={sum(record.success for record in records)}",
|
|
188
|
+
f"failed={sum(record.failure_category is not None for record in records)}",
|
|
189
|
+
f"polls={sum(record.polling_request_count for record in records)}",
|
|
190
|
+
]
|
|
191
|
+
submission_rate_limits = sum(record.submission_rate_limit_count for record in records)
|
|
192
|
+
if submission_rate_limits:
|
|
193
|
+
values.append(f"submission_rate_limited={submission_rate_limits}")
|
|
194
|
+
start_rate_limits = sum(record.start_rate_limit_count for record in records)
|
|
195
|
+
if start_rate_limits:
|
|
196
|
+
values.append(f"start_rate_limited={start_rate_limits}")
|
|
197
|
+
rate_limits = sum(record.polling_rate_limit_count for record in records)
|
|
198
|
+
if rate_limits:
|
|
199
|
+
values.append(f"rate_limited={rate_limits}")
|
|
200
|
+
cleanup_rate_limits = sum(record.cleanup_rate_limit_count for record in records)
|
|
201
|
+
if cleanup_rate_limits:
|
|
202
|
+
values.append(f"cleanup_rate_limited={cleanup_rate_limits}")
|
|
203
|
+
if download_results:
|
|
204
|
+
values.append(
|
|
205
|
+
f"downloaded={human_bytes(sum(record.result_bytes or 0 for record in records))}"
|
|
206
|
+
)
|
|
207
|
+
return " ".join(values)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def redact_output(value: Any, secrets: Sequence[str]) -> Any:
|
|
211
|
+
"""Recursively redact secret values from output-bound data."""
|
|
212
|
+
|
|
213
|
+
if isinstance(value, str):
|
|
214
|
+
redacted = value
|
|
215
|
+
for secret in secrets:
|
|
216
|
+
if secret:
|
|
217
|
+
redacted = redacted.replace(secret, "<redacted>")
|
|
218
|
+
return redacted
|
|
219
|
+
if isinstance(value, dict):
|
|
220
|
+
return {str(key): redact_output(item, secrets) for key, item in value.items()}
|
|
221
|
+
if isinstance(value, (list, tuple)):
|
|
222
|
+
return [redact_output(item, secrets) for item in value]
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _json_safe(value: Any) -> Any:
|
|
227
|
+
if isinstance(value, Enum):
|
|
228
|
+
return value.value
|
|
229
|
+
if isinstance(value, Path):
|
|
230
|
+
return str(value)
|
|
231
|
+
if isinstance(value, dict):
|
|
232
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
233
|
+
if isinstance(value, (list, tuple)):
|
|
234
|
+
return [_json_safe(item) for item in value]
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
239
|
+
"""Atomically replace *path* with a complete JSON document."""
|
|
240
|
+
|
|
241
|
+
temporary: Path | None = None
|
|
242
|
+
try:
|
|
243
|
+
with tempfile.NamedTemporaryFile(
|
|
244
|
+
mode="w",
|
|
245
|
+
encoding="utf-8",
|
|
246
|
+
dir=path.parent,
|
|
247
|
+
prefix=f".{path.name}.",
|
|
248
|
+
suffix=".tmp",
|
|
249
|
+
delete=False,
|
|
250
|
+
) as stream:
|
|
251
|
+
temporary = Path(stream.name)
|
|
252
|
+
json.dump(_json_safe(payload), stream, indent=2, sort_keys=True, allow_nan=False)
|
|
253
|
+
stream.write("\n")
|
|
254
|
+
stream.flush()
|
|
255
|
+
os.fsync(stream.fileno())
|
|
256
|
+
os.replace(temporary, path)
|
|
257
|
+
temporary = None
|
|
258
|
+
finally:
|
|
259
|
+
if temporary is not None:
|
|
260
|
+
temporary.unlink(missing_ok=True)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def result_payload(
|
|
264
|
+
result: RunResult, summary: dict[str, Any], *, secrets: Sequence[str] = ()
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
"""Build the versioned machine-readable result document."""
|
|
267
|
+
|
|
268
|
+
config = asdict(result.config)
|
|
269
|
+
config["authentication"] = "basic"
|
|
270
|
+
payload = {
|
|
271
|
+
"schema": JSON_SCHEMA,
|
|
272
|
+
"configuration": config,
|
|
273
|
+
"query_file": str(result.config.query_file),
|
|
274
|
+
"query_sha256": result.query_sha256,
|
|
275
|
+
"started_at": result.started_at,
|
|
276
|
+
"finished_at": result.finished_at,
|
|
277
|
+
"actual_scheduling_duration_s": result.scheduling_duration_s,
|
|
278
|
+
"elapsed_s": result.elapsed_s,
|
|
279
|
+
"interrupted": result.interrupted,
|
|
280
|
+
"summary": summary,
|
|
281
|
+
"records": [asdict(record) for record in result.records],
|
|
282
|
+
}
|
|
283
|
+
return cast(dict[str, Any], redact_output(payload, secrets))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _format_metric(value: float | int | None, *, decimals: int = 3) -> str:
|
|
287
|
+
return "unavailable" if value is None else f"{value:.{decimals}f}"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def render_summary(result: RunResult, summary: dict[str, Any]) -> str:
|
|
291
|
+
"""Render the final human-readable summary."""
|
|
292
|
+
|
|
293
|
+
counts = summary["counts"]
|
|
294
|
+
throughput = summary["throughput"]
|
|
295
|
+
mode = "download results" if result.config.download_results else "no download"
|
|
296
|
+
lines = [
|
|
297
|
+
"tapbench partial summary" if result.interrupted else "tapbench summary",
|
|
298
|
+
f" mode: {mode}",
|
|
299
|
+
" requested rate: "
|
|
300
|
+
f"{result.config.rate:.3f} jobs/s for {result.config.duration:.3f} s",
|
|
301
|
+
f" opportunities: {counts['opportunities']}",
|
|
302
|
+
f" started / dropped: {counts['started']} / {counts['dropped']}",
|
|
303
|
+
f" jobs created: {counts['jobs_created']}",
|
|
304
|
+
" completed / error / aborted: "
|
|
305
|
+
f"{counts['completed']} / {counts['error']} / {counts['aborted']}",
|
|
306
|
+
" UWS timing samples: "
|
|
307
|
+
f"{counts['server_timing_samples']} / {counts['completed']} completed",
|
|
308
|
+
f" successful / failed: {counts['successful']} / {counts['failed']}",
|
|
309
|
+
f" peak in-flight: {result.peak_inflight}",
|
|
310
|
+
f" HTTP requests / polls: {counts['http_requests']} / {counts['polls']}",
|
|
311
|
+
" average polls / created job: "
|
|
312
|
+
f"{_format_metric(summary['average_polls_per_created_job'])}",
|
|
313
|
+
]
|
|
314
|
+
if counts["submission_rate_limits"]:
|
|
315
|
+
lines.append(
|
|
316
|
+
" submission rate limits / wait:"
|
|
317
|
+
f" {counts['submission_rate_limits']} / "
|
|
318
|
+
f"{_format_metric(counts['submission_rate_limit_wait_s'])} s"
|
|
319
|
+
)
|
|
320
|
+
if counts["start_rate_limits"]:
|
|
321
|
+
lines.append(
|
|
322
|
+
" start rate limits / wait: "
|
|
323
|
+
f"{counts['start_rate_limits']} / "
|
|
324
|
+
f"{_format_metric(counts['start_rate_limit_wait_s'])} s"
|
|
325
|
+
)
|
|
326
|
+
if counts["polling_rate_limits"]:
|
|
327
|
+
lines.append(
|
|
328
|
+
" polling rate limits / wait: "
|
|
329
|
+
f"{counts['polling_rate_limits']} / "
|
|
330
|
+
f"{_format_metric(counts['polling_rate_limit_wait_s'])} s"
|
|
331
|
+
)
|
|
332
|
+
if counts["cleanup_rate_limits"]:
|
|
333
|
+
lines.append(
|
|
334
|
+
" cleanup rate limits / wait: "
|
|
335
|
+
f"{counts['cleanup_rate_limits']} / "
|
|
336
|
+
f"{_format_metric(counts['cleanup_rate_limit_wait_s'])} s"
|
|
337
|
+
)
|
|
338
|
+
if result.config.download_results:
|
|
339
|
+
lines.append(f" downloaded: {human_bytes(counts['downloaded_bytes'])}")
|
|
340
|
+
lines.extend(
|
|
341
|
+
[
|
|
342
|
+
" achieved submission rate: "
|
|
343
|
+
f"{_format_metric(throughput['achieved_submission_start_rate_jobs_s'])} jobs/s",
|
|
344
|
+
" job creation throughput: "
|
|
345
|
+
f"{_format_metric(throughput['job_creation_throughput_jobs_s'])} jobs/s",
|
|
346
|
+
" server execution throughput: "
|
|
347
|
+
f"{_format_metric(throughput['server_execution_throughput_jobs_s'])} jobs/s",
|
|
348
|
+
]
|
|
349
|
+
)
|
|
350
|
+
if result.config.download_results:
|
|
351
|
+
lines.append(
|
|
352
|
+
" result completion throughput: "
|
|
353
|
+
f"{_format_metric(throughput['result_completion_throughput_jobs_s'])} jobs/s"
|
|
354
|
+
)
|
|
355
|
+
lines.append(
|
|
356
|
+
" download throughput: "
|
|
357
|
+
f"{_format_metric(throughput['result_download_throughput_bytes_s'])} B/s"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
percentiles = summary["latency"]
|
|
361
|
+
lines.extend(["", "latency (seconds) p50 p90 p95 p99 max"])
|
|
362
|
+
labels = {
|
|
363
|
+
"scheduling_delay_s": "scheduling delay",
|
|
364
|
+
"job_creation_s": "job creation",
|
|
365
|
+
"query_queue_s": "query queue (UWS)",
|
|
366
|
+
"query_execution_s": "query execution (UWS)",
|
|
367
|
+
"submission_to_terminal_s": "submission to terminal",
|
|
368
|
+
"result_response_headers_s": "result response headers",
|
|
369
|
+
"result_first_byte_s": "result first byte",
|
|
370
|
+
"result_download_s": "result download",
|
|
371
|
+
"submission_to_download_s": "submission to download",
|
|
372
|
+
}
|
|
373
|
+
for key, label in labels.items():
|
|
374
|
+
if not result.config.download_results and key.startswith(
|
|
375
|
+
("result_", "submission_to_download")
|
|
376
|
+
):
|
|
377
|
+
continue
|
|
378
|
+
row = percentiles[key]
|
|
379
|
+
rendered = " ".join(
|
|
380
|
+
f"{_format_metric(row[name]):>8}" for name in ("p50", "p90", "p95", "p99", "max")
|
|
381
|
+
)
|
|
382
|
+
lines.append(f" {label:<28}{rendered}")
|
|
383
|
+
|
|
384
|
+
failures = summary["failure_breakdown"]
|
|
385
|
+
if failures or counts["cleanup_failures"]:
|
|
386
|
+
lines.extend(["", "failures"])
|
|
387
|
+
for category, count in sorted(failures.items()):
|
|
388
|
+
if count:
|
|
389
|
+
lines.append(f" {category + ':':<30}{count}")
|
|
390
|
+
if counts["cleanup_failures"]:
|
|
391
|
+
lines.append(f" {'cleanup failures:':<30}{counts['cleanup_failures']} (separate)")
|
|
392
|
+
return "\n".join(lines)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def _progress_reporter(
|
|
396
|
+
state: Any, config: RunConfig, started: float, done_event: asyncio.Event
|
|
397
|
+
) -> None:
|
|
398
|
+
"""Render scheduler state without participating in job execution."""
|
|
399
|
+
|
|
400
|
+
interactive = sys.stderr.isatty()
|
|
401
|
+
interval = 1.0 if interactive else 5.0
|
|
402
|
+
last_length = 0
|
|
403
|
+
loop = asyncio.get_running_loop()
|
|
404
|
+
next_emit = loop.time()
|
|
405
|
+
saw_scheduling_complete = state.scheduling_complete
|
|
406
|
+
|
|
407
|
+
def emit() -> None:
|
|
408
|
+
nonlocal last_length
|
|
409
|
+
line = progress_line(
|
|
410
|
+
state.records,
|
|
411
|
+
active=state.active_count,
|
|
412
|
+
elapsed_s=loop.time() - started,
|
|
413
|
+
duration_s=config.duration,
|
|
414
|
+
download_results=config.download_results,
|
|
415
|
+
scheduling_complete=state.scheduling_complete,
|
|
416
|
+
)
|
|
417
|
+
if interactive:
|
|
418
|
+
padded = line.ljust(last_length)
|
|
419
|
+
print(f"\r{padded}", file=sys.stderr, end="", flush=True)
|
|
420
|
+
last_length = len(line)
|
|
421
|
+
else:
|
|
422
|
+
print(f"tapbench progress: {line}", file=sys.stderr, flush=True)
|
|
423
|
+
|
|
424
|
+
emit()
|
|
425
|
+
next_emit = loop.time() + interval
|
|
426
|
+
while not done_event.is_set():
|
|
427
|
+
try:
|
|
428
|
+
await asyncio.wait_for(done_event.wait(), timeout=0.1)
|
|
429
|
+
except TimeoutError:
|
|
430
|
+
now = loop.time()
|
|
431
|
+
transitioned = state.scheduling_complete and not saw_scheduling_complete
|
|
432
|
+
if transitioned or now >= next_emit:
|
|
433
|
+
emit()
|
|
434
|
+
next_emit = now + interval
|
|
435
|
+
saw_scheduling_complete = state.scheduling_complete
|
|
436
|
+
if state.scheduling_complete and not saw_scheduling_complete:
|
|
437
|
+
emit()
|
|
438
|
+
if interactive and last_length:
|
|
439
|
+
print(file=sys.stderr)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def execute_run(config: RunConfig, query: str, username: str, password: str) -> RunResult:
|
|
443
|
+
"""Execute one benchmark run and return all measurements."""
|
|
444
|
+
|
|
445
|
+
from tapbench.client import TapClient, make_client_session
|
|
446
|
+
from tapbench.scheduler import SchedulerState, run_fixed_rate
|
|
447
|
+
|
|
448
|
+
loop = asyncio.get_running_loop()
|
|
449
|
+
started_mono = loop.time()
|
|
450
|
+
started_at = datetime.now(UTC).isoformat()
|
|
451
|
+
stop_event = asyncio.Event()
|
|
452
|
+
state = SchedulerState()
|
|
453
|
+
jitter_rng = random.Random(config.seed)
|
|
454
|
+
reporter_done = asyncio.Event()
|
|
455
|
+
|
|
456
|
+
installed_signals: list[signal.Signals] = []
|
|
457
|
+
interrupt_count = 0
|
|
458
|
+
|
|
459
|
+
def interrupt(signum: signal.Signals) -> None:
|
|
460
|
+
nonlocal interrupt_count
|
|
461
|
+
interrupt_count += 1
|
|
462
|
+
if interrupt_count == 1:
|
|
463
|
+
stop_event.set()
|
|
464
|
+
return
|
|
465
|
+
loop.remove_signal_handler(signum)
|
|
466
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
467
|
+
os.kill(os.getpid(), signum)
|
|
468
|
+
|
|
469
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
470
|
+
try:
|
|
471
|
+
loop.add_signal_handler(signum, interrupt, signum)
|
|
472
|
+
installed_signals.append(signum)
|
|
473
|
+
except (NotImplementedError, RuntimeError):
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
scheduler_result = None
|
|
477
|
+
progress_task: asyncio.Task[None] | None = None
|
|
478
|
+
try:
|
|
479
|
+
async with make_client_session(config) as session:
|
|
480
|
+
client = TapClient(config, session, started_mono, username, password)
|
|
481
|
+
|
|
482
|
+
async def run_one(record: JobRecord) -> None:
|
|
483
|
+
await client.run_job(query, record, jitter_rng)
|
|
484
|
+
|
|
485
|
+
if config.progress:
|
|
486
|
+
progress_task = asyncio.create_task(
|
|
487
|
+
_progress_reporter(state, config, started_mono, reporter_done)
|
|
488
|
+
)
|
|
489
|
+
scheduler_result = await run_fixed_rate(
|
|
490
|
+
config,
|
|
491
|
+
run_one,
|
|
492
|
+
stop_event=stop_event,
|
|
493
|
+
state=state,
|
|
494
|
+
clock=loop.time,
|
|
495
|
+
sleep=asyncio.sleep,
|
|
496
|
+
)
|
|
497
|
+
except asyncio.CancelledError:
|
|
498
|
+
stop_event.set()
|
|
499
|
+
if scheduler_result is None:
|
|
500
|
+
scheduler_result = SchedulerResult(
|
|
501
|
+
records=state.records,
|
|
502
|
+
peak_inflight=state.peak_inflight,
|
|
503
|
+
scheduling_duration_s=loop.time() - started_mono,
|
|
504
|
+
interrupted=True,
|
|
505
|
+
)
|
|
506
|
+
finally:
|
|
507
|
+
reporter_done.set()
|
|
508
|
+
if progress_task is not None:
|
|
509
|
+
await progress_task
|
|
510
|
+
for signum in installed_signals:
|
|
511
|
+
loop.remove_signal_handler(signum)
|
|
512
|
+
|
|
513
|
+
assert scheduler_result is not None
|
|
514
|
+
finished_mono = loop.time()
|
|
515
|
+
return RunResult(
|
|
516
|
+
config=config,
|
|
517
|
+
query_sha256=hashlib.sha256(query.encode()).hexdigest(),
|
|
518
|
+
started_at=started_at,
|
|
519
|
+
finished_at=datetime.now(UTC).isoformat(),
|
|
520
|
+
scheduling_duration_s=scheduler_result.scheduling_duration_s,
|
|
521
|
+
elapsed_s=finished_mono - started_mono,
|
|
522
|
+
peak_inflight=scheduler_result.peak_inflight,
|
|
523
|
+
records=scheduler_result.records,
|
|
524
|
+
interrupted=scheduler_result.interrupted,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def run_command(config: RunConfig, query: str) -> int:
|
|
529
|
+
"""Run the async implementation and write terminal/JSON output."""
|
|
530
|
+
|
|
531
|
+
from tapbench.stats import aggregate_run
|
|
532
|
+
|
|
533
|
+
username, password = load_credentials()
|
|
534
|
+
result = asyncio.run(execute_run(config, query, username, password))
|
|
535
|
+
summary = aggregate_run(result)
|
|
536
|
+
print(render_summary(result, summary))
|
|
537
|
+
if config.json_output is not None:
|
|
538
|
+
atomic_write_json(
|
|
539
|
+
config.json_output,
|
|
540
|
+
result_payload(result, summary, secrets=(username, password)),
|
|
541
|
+
)
|
|
542
|
+
return 3 if result.interrupted else 0
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
546
|
+
"""CLI entry point."""
|
|
547
|
+
|
|
548
|
+
parser = build_parser()
|
|
549
|
+
args = parser.parse_args(argv)
|
|
550
|
+
try:
|
|
551
|
+
config, query = config_from_namespace(args)
|
|
552
|
+
status = run_command(config, query)
|
|
553
|
+
except ConfigError as exc:
|
|
554
|
+
parser.error(str(exc))
|
|
555
|
+
except OSError as exc:
|
|
556
|
+
print(f"tapbench: fatal output error: {type(exc).__name__}", file=sys.stderr)
|
|
557
|
+
status = 4
|
|
558
|
+
except KeyboardInterrupt:
|
|
559
|
+
print("tapbench: interrupted", file=sys.stderr)
|
|
560
|
+
status = 3
|
|
561
|
+
except Exception as exc:
|
|
562
|
+
print(f"tapbench: fatal run error: {type(exc).__name__}", file=sys.stderr)
|
|
563
|
+
status = 4
|
|
564
|
+
raise SystemExit(status)
|