Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,56 @@
|
||||
# 性能测试
|
||||
|
||||
主入口是 `test/rust/performance.rs`,比较真实 Dever 生成程序和 Rust 实现。Go 与 Web 框架对照使用本目录的 Python 驱动。先测改动涉及的功能,完整矩阵用于阶段检查。
|
||||
|
||||
## 运行
|
||||
|
||||
在仓库的 `rust/` 目录执行,使用锁定工具链:
|
||||
|
||||
```sh
|
||||
cargo +1.95.0 bench --locked --offline --bench performance -- --storage
|
||||
cargo +1.95.0 bench --locked --offline --bench performance -- --map-values
|
||||
cargo +1.95.0 bench --locked --offline --bench performance -- --json
|
||||
```
|
||||
|
||||
| 参数 | 工作负载 |
|
||||
| --- | --- |
|
||||
| 不传参数 | 基础内核 |
|
||||
| `--storage`、`--map-values` | Map/List 的构造、查找、借用和筛选 |
|
||||
| `--collections`、`--handlers`、`--text`、`--bytes` | 集合处理、函数传递、文本和字节 |
|
||||
| `--numeric`、`--decimal-ops`、`--decimal-sum` | 数值与十进制计算 |
|
||||
| `--application`、`--query` | CMD、JSON 输入输出和 query 解码 |
|
||||
| `--json`、`--json-document` | JSON 与动态文档 |
|
||||
| `--resources`、`--tasks` | 文件、流、任务调度和取消 |
|
||||
| `--database`、`--database-postgres` | 数据库操作,PostgreSQL 需独立测试配置 |
|
||||
| `--websocket-protocol` | 独立协议对端的消息传输 |
|
||||
| `--websocket` | 共享运行库的 WebSocket 生成适配 |
|
||||
|
||||
其他诊断开关见主入口及 `performance/extended/`、`performance/transport/`。比较程序保存在 `rust/target/programs/release/`,使用 `--build-only` 可只构建,再单独运行生成的程序。
|
||||
|
||||
Python 驱动从仓库根使用,先查看参数:
|
||||
|
||||
```sh
|
||||
python3 test/performance/kernel_go.py --help
|
||||
python3 test/performance/http_go.py --help
|
||||
python3 test/performance/cms.py --help
|
||||
```
|
||||
|
||||
这些测试可能编译程序、启动回环服务或访问测试数据库;地址和工具路径使用现有测试配置,不能指向业务数据。
|
||||
|
||||
## 公平比较
|
||||
|
||||
- 使用相同语义、输入、精度、依赖、编译选项和资源限制。Dever 的错误处理、复制和任务清理,参照也须完成。
|
||||
- 绑定可用 CPU,避免同时构建或跑其他基准。记录频率、节流和负载,保留正向与反向执行顺序。
|
||||
- 提前固定轮次和预热次数,保留全部样本、绝对耗时、失败次数及源码/二进制摘要,不测到通过才停。
|
||||
- 独立 Rust 参照用于比较实现;同表、同函数或共享运行库仅用于诊断生成层与测量波动。
|
||||
- 报告修改前后及相对参照的变化。一次快结果不能代表稳定提速,一个样例不能代表整门语言。
|
||||
|
||||
## 网络与内存
|
||||
|
||||
网络结果须同时检查响应内容、错误、丢弃请求和实际测量时长,不能只看 QPS。客户端与服务端分别限制 CPU;并发数相同不等于负载相同。
|
||||
|
||||
`--memory` 构建分配诊断产物,探针位于 `test/rust/performance/memory.c`。分配次数、存活字节、RSS/PSS 和 cgroup 内存分别报告;插桩结果不作为普通性能耗时。
|
||||
|
||||
## 保存结果
|
||||
|
||||
原始数据放在忽略的 `target/performance/` 或测试输出目录,与源码提交和编译器摘要对应。README 只维护测试方法,不追加逐轮成绩和修改日志。
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Measure real typed/raw JSON API requests with isolated native applications."""
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from build import fingerprint
|
||||
from process import process_sample
|
||||
from settings import write_private_json
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures/api-json"
|
||||
|
||||
|
||||
def configuration(port):
|
||||
return {
|
||||
"http": {"host": "127.0.0.1", "port": port},
|
||||
"runtime": {"mode": "api", "shutdown_ms": 2000},
|
||||
"log": {"level": "error"},
|
||||
"database": {"default": {"type": "sqlite", "path": "data/control.db",
|
||||
"max_connections": 2}},
|
||||
"auth": {"providers": {"session": {
|
||||
"verify": "benchmark.auth.verify", "jwtSecret": secrets.token_hex(32),
|
||||
"cookie": "benchmark_session"}}},
|
||||
"sites": {"test": {"path": "", "auth": "session", "hosts": ["localhost"],
|
||||
"origin": "https://localhost"}},
|
||||
}
|
||||
|
||||
|
||||
def build(arguments):
|
||||
output = arguments.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
project = output / "project"
|
||||
shutil.copytree(FIXTURE / "benchmark", project / "module/benchmark")
|
||||
(project / "config").mkdir()
|
||||
write_private_json(project / "config/setting.json", configuration(8080))
|
||||
artifacts = {}
|
||||
for label, compiler in (("before", arguments.baseline_compiler),
|
||||
("after", arguments.compiler)):
|
||||
compiler = compiler.resolve()
|
||||
executable = output / label
|
||||
started = time.monotonic()
|
||||
with (output / f"{label}.build.log").open("x") as log:
|
||||
subprocess.run([compiler, "build", project, "--output", executable],
|
||||
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=900)
|
||||
artifacts[label] = {"path": str(executable), **fingerprint(executable),
|
||||
"compiler": {"path": str(compiler), **fingerprint(compiler)},
|
||||
"build_seconds": time.monotonic() - started,
|
||||
"runtime_manifests": {
|
||||
str(path.relative_to(compiler.parent)): fingerprint(path)
|
||||
for path in sorted((compiler.parent / "runtime").glob("*/manifest.json"))}}
|
||||
if arguments.with_peers:
|
||||
go_binary = output / "go"
|
||||
subprocess.run(["go", "build", "-trimpath", "-ldflags=-s -w", "-o", go_binary,
|
||||
FIXTURE.resolve() / "peer.go"], check=True, timeout=180)
|
||||
artifacts["go"] = {"path": str(go_binary), **fingerprint(go_binary),
|
||||
"compiler": subprocess.check_output(["go", "version"], text=True).strip(),
|
||||
"source": fingerprint(FIXTURE / "peer.go"),
|
||||
"scope": "native codec/transport baseline; not full Dever API semantics"}
|
||||
built = subprocess.run(["cargo", "build", "--offline", "--locked", "--release",
|
||||
"-p", "dever-tests", "--example", "api-json-peer",
|
||||
"--message-format=json"], capture_output=True, text=True,
|
||||
timeout=600)
|
||||
(output / "rust.build.log").write_text(built.stderr)
|
||||
built.check_returncode()
|
||||
executables = [message["executable"] for line in built.stdout.splitlines()
|
||||
if (message := json.loads(line)).get("reason") == "compiler-artifact"
|
||||
and message.get("executable") and message["target"]["name"] == "api-json-peer"]
|
||||
if len(executables) != 1:
|
||||
raise RuntimeError("expected one native Rust API peer")
|
||||
rust_binary = output / "rust"
|
||||
shutil.copy2(executables[0], rust_binary)
|
||||
artifacts["rust"] = {"path": str(rust_binary), **fingerprint(rust_binary),
|
||||
"compiler": subprocess.check_output(["rustc", "--version"], text=True).strip(),
|
||||
"source": fingerprint(FIXTURE / "peer.rs"),
|
||||
"profile": "release: opt-level=3, fat LTO, codegen-units=1",
|
||||
"scope": "native codec/transport baseline; not full Dever API semantics"}
|
||||
if arguments.with_runtime_peer:
|
||||
runtime = arguments.compiler.resolve().parent / "runtime/linux-x86_64"
|
||||
if not (runtime / "libc.a").is_file() or not (runtime / "crt1.o").is_file():
|
||||
raise ValueError("Rust runtime peer requires the compiler's Linux x86_64 runtime inputs")
|
||||
command = ["cargo", "rustc", "--offline", "--locked", "--profile", "runtime-pack",
|
||||
"-p", "dever-runtime", "--features", "api,crypto,external,sqlite",
|
||||
"--example", "api-json-runtime-peer", "--message-format=json", "--",
|
||||
"-C", "target-feature=+crt-static", "-C", "relocation-model=static",
|
||||
"-C", f"link-arg=-B{runtime}/", "-C", f"link-arg=-L{runtime}/",
|
||||
"-C", f"link-arg=-Wl,-Map,{output / 'rust-runtime.link.map'}"]
|
||||
built = subprocess.run(command, capture_output=True, text=True, timeout=600)
|
||||
(output / "rust-runtime.build.log").write_text(built.stderr)
|
||||
built.check_returncode()
|
||||
executables = [message["executable"] for line in built.stdout.splitlines()
|
||||
if (message := json.loads(line)).get("reason") == "compiler-artifact"
|
||||
and message.get("executable") and message["target"]["name"] == "api-json-runtime-peer"]
|
||||
if len(executables) != 1:
|
||||
raise RuntimeError("expected one Rust runtime API peer")
|
||||
runtime_binary = output / "rust-runtime"
|
||||
shutil.copy2(executables[0], runtime_binary)
|
||||
artifacts["rust_runtime"] = {
|
||||
"path": str(runtime_binary), **fingerprint(runtime_binary),
|
||||
"compiler": subprocess.check_output(["rustc", "--version"], text=True).strip(),
|
||||
"source": fingerprint(FIXTURE / "runtime-peer.rs"), "command": command,
|
||||
"runtime_inputs": {name: fingerprint(runtime / name) for name in
|
||||
["crt1.o", "crti.o", "crtbeginT.o", "crtend.o", "crtn.o", "libc.a", "libm.a", "libgcc.a", "libgcc_eh.a"]},
|
||||
"profile": "runtime-pack: opt-level=3, fat LTO, static executable, codegen-units=1",
|
||||
"scope": "shared Dever runtime contract with native Rust values and codecs; not an independent runtime",
|
||||
}
|
||||
manifest = {
|
||||
"artifacts": artifacts,
|
||||
"sources": {str(path.relative_to(FIXTURE)): fingerprint(path)
|
||||
for path in sorted(FIXTURE.rglob("*.dever"))},
|
||||
}
|
||||
write_private_json(output / "manifest.json", manifest)
|
||||
print(output / "manifest.json", flush=True)
|
||||
|
||||
|
||||
def request(client, route, body, expected):
|
||||
started = time.perf_counter_ns()
|
||||
client.request("POST", f"/benchmark/echo/{route}", body,
|
||||
{"Host": "localhost", "Content-Type": "application/json"})
|
||||
response = client.getresponse()
|
||||
encoded = response.read()
|
||||
elapsed = time.perf_counter_ns() - started
|
||||
if response.status != 200 or json.loads(encoded) != {
|
||||
"code": 0, "message": "ok", "data": expected}:
|
||||
raise AssertionError(f"API response mismatch: HTTP {response.status}")
|
||||
return elapsed
|
||||
|
||||
|
||||
def percentile(values, fraction):
|
||||
return sorted(values)[min(len(values) - 1, int(len(values) * fraction))] / 1000
|
||||
|
||||
|
||||
def measure(artifact, route, payload, arguments):
|
||||
expected = payload["payload" if route == "echo" else "source"]
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
with tempfile.TemporaryDirectory(prefix="api-json-", dir=arguments.output) as temporary:
|
||||
directory = Path(temporary)
|
||||
executable = directory / "app"
|
||||
shutil.copy2(artifact["path"], executable)
|
||||
(directory / "config").mkdir()
|
||||
write_private_json(directory / "config/setting.json", configuration(port))
|
||||
with (directory / "stdout").open("w+") as stdout, \
|
||||
(directory / "stderr").open("w+") as stderr:
|
||||
process = subprocess.Popen(["taskset", "-c", str(arguments.server_cpu), executable],
|
||||
cwd=directory, stdout=stdout, stderr=stderr,
|
||||
start_new_session=True)
|
||||
client = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
try:
|
||||
deadline = time.monotonic() + 20
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"API exited before readiness: {process.returncode}")
|
||||
try:
|
||||
request(client, route, body, expected)
|
||||
break
|
||||
except (ConnectionError, http.client.HTTPException):
|
||||
client.close()
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("API readiness timeout")
|
||||
time.sleep(0.02)
|
||||
for _ in range(50):
|
||||
request(client, route, body, expected)
|
||||
before = process_sample(process.pid)
|
||||
started = time.monotonic()
|
||||
latencies = [request(client, route, body, expected)
|
||||
for _ in range(arguments.requests)]
|
||||
elapsed = time.monotonic() - started
|
||||
after = process_sample(process.pid)
|
||||
result = {
|
||||
"requests": len(latencies), "request_bytes": len(body),
|
||||
"elapsed_seconds": elapsed,
|
||||
"p50_us": percentile(latencies, 0.5),
|
||||
"p95_us": percentile(latencies, 0.95),
|
||||
"p99_us": percentile(latencies, 0.99),
|
||||
"server_cpu_us_per_request":
|
||||
(after["cpu_seconds"] - before["cpu_seconds"]) * 1e6 / len(latencies),
|
||||
"rss_bytes": after["rss_bytes"],
|
||||
"rss_growth_bytes": after["rss_bytes"] - before["rss_bytes"],
|
||||
}
|
||||
except BaseException as error:
|
||||
stderr.flush()
|
||||
stderr.seek(0)
|
||||
error.add_note(stderr.read()[-4000:])
|
||||
raise
|
||||
finally:
|
||||
client.close()
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=5)
|
||||
raise TimeoutError("API did not drain after SIGTERM")
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"API shutdown failed: {process.returncode}")
|
||||
return result
|
||||
|
||||
|
||||
def run(arguments):
|
||||
allowed = os.sched_getaffinity(0)
|
||||
if (arguments.server_cpu == arguments.client_cpu or
|
||||
not {arguments.server_cpu, arguments.client_cpu} <= allowed):
|
||||
raise ValueError("server and client require distinct allowed CPUs")
|
||||
if arguments.requests < 1 or arguments.repeats < 1:
|
||||
raise ValueError("requests and repeats must be positive")
|
||||
manifest = json.loads(arguments.manifest.read_text())
|
||||
for artifact in manifest["artifacts"].values():
|
||||
if fingerprint(Path(artifact["path"])) != {
|
||||
key: artifact[key] for key in ("bytes", "sha256")}:
|
||||
raise ValueError("API benchmark binary changed after build")
|
||||
arguments.output.mkdir(parents=True, exist_ok=False)
|
||||
os.sched_setaffinity(0, {arguments.client_cpu})
|
||||
labels = list(manifest["artifacts"])
|
||||
report = {"manifest": manifest, "server_cpu": arguments.server_cpu,
|
||||
"client_cpu": arguments.client_cpu, "samples": [], "summary": [],
|
||||
"environment": {"kernel": platform.release(), "machine": platform.machine(),
|
||||
"allowed_cpus": sorted(allowed), "python": platform.python_version()},
|
||||
"runner": fingerprint(Path(__file__)),
|
||||
"requests_per_process": arguments.requests, "repeats": arguments.repeats}
|
||||
try:
|
||||
for count in (1, 16, 128):
|
||||
row = {"id": 9007199254740993, "text": "雪😀\n\"" + "x" * 80,
|
||||
"weight": 1.25, "note": None}
|
||||
value = {"rows": [row] * count, "raw": {"large": 123456789012345678901234567890}}
|
||||
for route, argument in (("echo", "payload"), ("raw", "source")):
|
||||
for repeat in range(arguments.repeats):
|
||||
offset = repeat % len(labels)
|
||||
order = labels[offset:] + labels[:offset]
|
||||
for label in order:
|
||||
sample = measure(manifest["artifacts"][label], route,
|
||||
{argument: value}, arguments)
|
||||
report["samples"].append({"label": label, "route": route,
|
||||
"rows": count, "repeat": repeat, **sample})
|
||||
for label in labels:
|
||||
samples = [row for row in report["samples"]
|
||||
if row["label"] == label and row["route"] == route
|
||||
and row["rows"] == count]
|
||||
summary = {"label": label, "route": route, "rows": count}
|
||||
for metric in ("p50_us", "p95_us", "p99_us",
|
||||
"server_cpu_us_per_request", "rss_bytes"):
|
||||
summary[metric] = statistics.median(sample[metric] for sample in samples)
|
||||
report["summary"].append(summary)
|
||||
print(json.dumps(summary), flush=True)
|
||||
for artifact in manifest["artifacts"].values():
|
||||
if fingerprint(Path(artifact["path"])) != {
|
||||
key: artifact[key] for key in ("bytes", "sha256")}:
|
||||
raise ValueError("API benchmark binary changed during measurement")
|
||||
report["passed"] = True
|
||||
finally:
|
||||
write_private_json(arguments.output / "report.json", report)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
prepare = commands.add_parser("build")
|
||||
prepare.add_argument("--compiler", type=Path, required=True)
|
||||
prepare.add_argument("--baseline-compiler", type=Path, required=True)
|
||||
prepare.add_argument("--output", type=Path, required=True)
|
||||
prepare.add_argument("--with-peers", action="store_true")
|
||||
prepare.add_argument("--with-runtime-peer", action="store_true")
|
||||
prepare.set_defaults(action=build)
|
||||
measure = commands.add_parser("run")
|
||||
measure.add_argument("--manifest", type=Path, required=True)
|
||||
measure.add_argument("--output", type=Path, required=True)
|
||||
measure.add_argument("--server-cpu", type=int, required=True)
|
||||
measure.add_argument("--client-cpu", type=int, required=True)
|
||||
measure.add_argument("--requests", type=int, default=1000)
|
||||
measure.add_argument("--repeats", type=int, default=7)
|
||||
measure.set_defaults(action=run)
|
||||
arguments = parser.parse_args()
|
||||
arguments.output = arguments.output.resolve()
|
||||
arguments.action(arguments)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Verify the API JSON fixture's observable contract before runtime comparison."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from api_json import configuration, request, write_private_json
|
||||
from build import fingerprint
|
||||
|
||||
|
||||
@contextmanager
|
||||
def server(binary, output, settings):
|
||||
with tempfile.TemporaryDirectory(prefix="api-contract-", dir=output) as temporary:
|
||||
directory = Path(temporary)
|
||||
executable = directory / "app"
|
||||
shutil.copy2(binary, executable)
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
settings = deepcopy(settings)
|
||||
settings["http"]["port"] = port
|
||||
(directory / "config").mkdir()
|
||||
write_private_json(directory / "config/setting.json", settings)
|
||||
with (directory / "stdout").open("w+") as stdout, (directory / "stderr").open("w+") as stderr:
|
||||
process = subprocess.Popen([executable], cwd=directory, stdout=stdout,
|
||||
stderr=stderr, start_new_session=True)
|
||||
client = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
||||
try:
|
||||
deadline = time.monotonic() + 30
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"API contract server exited: {process.returncode}")
|
||||
try:
|
||||
request(client, "raw", b'{"source":0}', 0)
|
||||
break
|
||||
except (ConnectionError, http.client.HTTPException):
|
||||
client.close()
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("API contract server readiness timeout")
|
||||
time.sleep(0.05)
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
if process.poll() is None:
|
||||
process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
raise
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"API contract server shutdown failed: {process.returncode}")
|
||||
|
||||
|
||||
def credential(settings):
|
||||
def encode(value):
|
||||
return base64.urlsafe_b64encode(json.dumps(value, separators=(",", ":")).encode()).rstrip(b"=")
|
||||
|
||||
now = int(time.time())
|
||||
payload = encode({"alg": "HS256", "typ": "JWT"}) + b"." + encode({
|
||||
"iss": "session", "sub": "fixture", "sid": "fixture-session", "aud": "test",
|
||||
"tenant": None, "iat": now, "exp": now + 600,
|
||||
})
|
||||
key = settings["auth"]["providers"]["session"]["jwtSecret"].encode()
|
||||
signature = base64.urlsafe_b64encode(hmac.digest(key, payload, "sha256")).rstrip(b"=")
|
||||
return (payload + b"." + signature).decode()
|
||||
|
||||
|
||||
def cases(settings):
|
||||
result = []
|
||||
row = '{"id":9007199254740993,"text":"雪😀\\n\\\"","weight":1.25,"note":null}'
|
||||
typed = '{"payload":{"rows":[' + row + '],"raw":{"large":123456789012345678901234567890}}}'
|
||||
|
||||
def add(name, body, status=200, route="raw", headers=None, method="POST", query=""):
|
||||
result.append({"name": name, "body": body.encode() if isinstance(body, str) else body,
|
||||
"status": status, "path": "/benchmark/echo/" + route + query,
|
||||
"headers": headers or [("Host", "localhost"), ("Content-Type", "application/json")],
|
||||
"method": method})
|
||||
|
||||
add("typed-unicode-exact-number", typed, route="echo")
|
||||
add("typed-optional-absent", typed.replace(',"note":null', ""), route="echo")
|
||||
add("typed-optional-text", typed.replace('"note":null', '"note":"雪"'), route="echo")
|
||||
for spelling in ["-0", "-9223372036854775808", "9223372036854775807"]:
|
||||
add("typed-int-" + spelling, typed.replace("9007199254740993", spelling), route="echo")
|
||||
for spelling in ["1.0", "1e0", "9223372036854775808", "-9223372036854775809", "true"]:
|
||||
add("typed-invalid-int-" + spelling, typed.replace("9007199254740993", spelling), 400, "echo")
|
||||
add("typed-nonfinite", typed.replace("1.25", "1e999"), 400, "echo")
|
||||
add("typed-unknown-field", typed.replace('"weight":', '"extra":0,"weight":'), 400, "echo")
|
||||
add("typed-missing-field", typed.replace('"weight":1.25,', ""), 400, "echo")
|
||||
add("typed-duplicate-escaped-field", typed.replace('"weight":', '"we\\u0069ght":0,"weight":'), 400, "echo")
|
||||
add("typed-invalid-optional", typed.replace('"note":null', '"note":false'), 400, "echo")
|
||||
for name, value in [("number", "1e999"), ("null", "null"), ("array", "[1,true,null]"),
|
||||
("unicode", '"\\ud83d\\ude00"'), ("object", '{"large":123456789012345678901234567890}')]:
|
||||
add("raw-" + name, '{"source":' + value + '}')
|
||||
for name, body in [("duplicate", '{"source":{"key":1,"k\\u0065y":2}}'),
|
||||
("surrogate", '{"source":"\\ud800"}'),
|
||||
("trailing-comma", '{"source":[0,]}'),
|
||||
("root-duplicate", '{"source":0,"source":1}'),
|
||||
("root-unknown", '{"source":0,"extra":1}'),
|
||||
("root-missing", '{}'), ("trailing-root", '{"source":0}[]'),
|
||||
("invalid-utf8", b'{"source":"\xff"}')]:
|
||||
add(name, body, 400)
|
||||
for depth, status in [(63, 200), (64, 400)]:
|
||||
add(f"depth-{depth}", '{"source":' + '[' * depth + '0' + ']' * depth + '}', status)
|
||||
for count, status in [(65534, 200), (65535, 400)]:
|
||||
add(f"elements-{count}", '{"source":[' + ','.join(['0'] * count) + ']}', status)
|
||||
add("wrong-method", '{}', 405, method="GET")
|
||||
add("unknown-route", '{}', 404, route="missing")
|
||||
add("query-on-post", '{"source":0}', 400, query="?value=1")
|
||||
add("wrong-content-type", '{}', 400, headers=[("Host", "localhost"), ("Content-Type", "text/plain")])
|
||||
add("duplicate-content-type", '{}', 400, headers=[("Host", "localhost"), ("Content-Type", "application/json"), ("Content-Type", "application/json")])
|
||||
add("wrong-host", '{}', 401, headers=[("Host", "other.invalid"), ("Content-Type", "application/json")])
|
||||
add("invalid-bearer", '{}', 401, headers=[("Host", "localhost"), ("Content-Type", "application/json"), ("Authorization", "Bearer invalid")])
|
||||
add("valid-bearer", typed, route="echo", headers=[("Host", "localhost"), ("Content-Type", "application/json"), ("Authorization", "Bearer " + credential(settings))])
|
||||
add("public-expired-cookie", '{"source":0}', headers=[("Host", "localhost"), ("Content-Type", "application/json"), ("Cookie", "benchmark_session=invalid")])
|
||||
return result
|
||||
|
||||
|
||||
def verify(dever, rust, output):
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
artifacts = {label: {"path": str(binary.resolve()), **fingerprint(binary)}
|
||||
for label, binary in [("dever", dever), ("rust_runtime", rust)]}
|
||||
settings = configuration(8080)
|
||||
report = {"artifacts": artifacts, "cases": [], "passed": False}
|
||||
try:
|
||||
with ExitStack() as stack:
|
||||
clients = {label: stack.enter_context(server(Path(artifact["path"]), output, settings))
|
||||
for label, artifact in artifacts.items()}
|
||||
for case in cases(settings):
|
||||
observed = {}
|
||||
for label, client in clients.items():
|
||||
client.putrequest(case["method"], case["path"], skip_host=True, skip_accept_encoding=True)
|
||||
for name, value in case["headers"]:
|
||||
client.putheader(name, value)
|
||||
client.putheader("Content-Length", str(len(case["body"])))
|
||||
client.endheaders(case["body"])
|
||||
response = client.getresponse()
|
||||
body = response.read()
|
||||
observed[label] = (response.status, response.getheader("Content-Type"), response.getheader("Allow"), body)
|
||||
client.close()
|
||||
passed = all(value[0] == case["status"] for value in observed.values()) and len(set(observed.values())) == 1
|
||||
report["cases"].append({"name": case["name"], "expected_status": case["status"], "passed": passed,
|
||||
"observed": {label: {"status": value[0], "body_sha256": hashlib.sha256(value[3]).hexdigest(), "bytes": len(value[3])}
|
||||
for label, value in observed.items()}})
|
||||
if not passed:
|
||||
raise AssertionError(f"API contract differs for {case['name']}: " + repr({label: (value[0], value[3][:240]) for label, value in observed.items()}))
|
||||
for artifact in artifacts.values():
|
||||
assert fingerprint(Path(artifact["path"])) == {key: artifact[key] for key in ["bytes", "sha256"]}
|
||||
report["passed"] = True
|
||||
finally:
|
||||
write_private_json(output / "report.json", report)
|
||||
print(json.dumps({"passed": True, "cases": len(report["cases"])}), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dever", type=Path, required=True)
|
||||
parser.add_argument("--rust", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
arguments = parser.parse_args()
|
||||
verify(arguments.dever, arguments.rust, arguments.output)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""构建可搬运的基准产物;构建耗时与运行测量分开。"""
|
||||
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from settings import PEER_CONFIGURATION, stage_peer, write_private_json
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
TLS = ROOT / "test/dever-tests/fixtures/tls"
|
||||
SETTING_PATH = ROOT / "config/setting.json"
|
||||
POSTGRES_TEST_CONNECTION = "postgres_test"
|
||||
APPLICATION_CONFIGURATION = "llvm-cmd-v1"
|
||||
ENTRIES = {
|
||||
"runtime": ("sync_idle", "async_idle", "tasks", "tasks_on_worker", "channels", "cancel"),
|
||||
"http": ("http", "https", "http2", "https2"),
|
||||
"live": ("tcp", "ws", "sse"),
|
||||
}
|
||||
ORM_ENTRIES = ("orm_idle", "orm_crud", "orm_list", "orm_cursor", "orm_stream",
|
||||
"orm_pool", "orm_http")
|
||||
RUNTIME_PROFILES = {
|
||||
"base": ("runtime-api", "runtime-external"),
|
||||
"sqlite": ("runtime-api", "runtime-external", "runtime-sqlite"),
|
||||
"postgres": ("runtime-api", "runtime-external", "runtime-postgres"),
|
||||
"both": ("runtime-api", "runtime-external", "runtime-sqlite", "runtime-postgres"),
|
||||
}
|
||||
BENCHMARK_COMMAND = "benchmark.command.run"
|
||||
CONFIG_FUNCTIONS = {
|
||||
"runtime": ("lifetime", "idleSampleMs", "pending", "channelCapacity", "input", "pendingInput"),
|
||||
"http": ("lifetime", "limits", "http2Limits", "largeBody", "certificate", "key"),
|
||||
"live": (),
|
||||
"orm": ("lifetime", "ormInput", "ormRows", "ormConcurrency", "ormCancelWork"),
|
||||
}
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
with path.open("rb") as binary:
|
||||
digest = hashlib.file_digest(binary, "sha256").hexdigest()
|
||||
return {"sha256": digest, "bytes": path.stat().st_size}
|
||||
|
||||
|
||||
def postgres_test_connection(document):
|
||||
try:
|
||||
connection = document["database"][POSTGRES_TEST_CONNECTION]
|
||||
except (KeyError, TypeError) as error:
|
||||
raise ValueError(
|
||||
f"config/setting.json must define database.{POSTGRES_TEST_CONNECTION}"
|
||||
) from error
|
||||
if not isinstance(connection, dict):
|
||||
raise ValueError(f"database.{POSTGRES_TEST_CONNECTION} must be an object")
|
||||
if connection.get("type") != "postgres":
|
||||
raise ValueError(f"database.{POSTGRES_TEST_CONNECTION} must use type 'postgres'")
|
||||
max_connections = connection.get("max_connections")
|
||||
if type(max_connections) is not int or not 1 <= max_connections <= 256:
|
||||
raise ValueError(
|
||||
f"database.{POSTGRES_TEST_CONNECTION}.max_connections must be an explicit 1..256 integer"
|
||||
)
|
||||
return connection
|
||||
|
||||
|
||||
def postgres_case_url(document, case_name):
|
||||
template = postgres_test_connection(document).get("url")
|
||||
if not isinstance(template, str):
|
||||
raise ValueError(f"database.{POSTGRES_TEST_CONNECTION}.url must be Text")
|
||||
parsed = urlsplit(template)
|
||||
database = parsed.path.removeprefix("/")
|
||||
if (template.count("{case}") != 1 or "{case}" not in database
|
||||
or not database or "/" in database):
|
||||
raise ValueError(
|
||||
f"database.{POSTGRES_TEST_CONNECTION}.url must contain one {{case}} placeholder "
|
||||
"in its database name"
|
||||
)
|
||||
if parsed.scheme not in ("postgres", "postgresql"):
|
||||
raise ValueError(
|
||||
f"database.{POSTGRES_TEST_CONNECTION}.url must use postgres:// or postgresql://"
|
||||
)
|
||||
return template.replace("{case}", case_name)
|
||||
|
||||
|
||||
def postgres_database_case(output, case_name):
|
||||
run = hashlib.sha256(str(output.resolve()).encode()).hexdigest()[:12]
|
||||
return f"{run}_{case_name.replace('-', '_')}"
|
||||
|
||||
|
||||
def load_postgres_setting(path=SETTING_PATH):
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
document = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"cannot load '{path}': {error}") from error
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError(f"'{path}' must contain a JSON object")
|
||||
connections = document.get("database")
|
||||
if connections is None:
|
||||
return None
|
||||
if not isinstance(connections, dict):
|
||||
raise ValueError(f"'{path}' database must be an object")
|
||||
if POSTGRES_TEST_CONNECTION not in connections:
|
||||
return None
|
||||
postgres_case_url(document, "validation")
|
||||
return document
|
||||
|
||||
|
||||
def configuration(args, postgres_setting=None):
|
||||
settings = {
|
||||
"workers": args.workers,
|
||||
"connections": args.connections,
|
||||
"pending": args.pending,
|
||||
"iterations": args.iterations,
|
||||
"channel_capacity": 64,
|
||||
"timeout_ms": 2000,
|
||||
"lifetime_ms": 3_600_000,
|
||||
"idle_sample_ms": 3000,
|
||||
"task_capacity": max(args.pending + 16, 2 * args.connections + 16),
|
||||
"max_blocking_threads": 2,
|
||||
"http2_streams": args.http2_streams,
|
||||
"http2_stream_window_bytes": args.http2_stream_window_bytes,
|
||||
"http2_connection_window_bytes": args.http2_connection_window_bytes,
|
||||
"orm_iterations": args.orm_iterations,
|
||||
"orm_rows": args.orm_rows,
|
||||
"orm_concurrency": args.orm_concurrency,
|
||||
"orm_cancel_work": args.orm_cancel_work,
|
||||
"orm_sqlite_connections": args.orm_sqlite_connections,
|
||||
"orm_postgres_connections": (
|
||||
postgres_test_connection(postgres_setting)["max_connections"]
|
||||
if postgres_setting is not None else None
|
||||
),
|
||||
}
|
||||
if settings["task_capacity"] > 65536:
|
||||
raise ValueError("task capacity exceeds the language limit")
|
||||
return settings
|
||||
|
||||
|
||||
def config_source(settings, fixture):
|
||||
names = CONFIG_FUNCTIONS[fixture]
|
||||
functions = {
|
||||
"lifetime": ("Int", str(settings["lifetime_ms"])),
|
||||
"idleSampleMs": ("Int", str(settings["idle_sample_ms"])),
|
||||
"pending": ("Int", str(settings["pending"])),
|
||||
"channelCapacity": ("Int", str(settings["channel_capacity"])),
|
||||
"input": ("Bytes", f'dever.bytes.fromText({json.dumps("x" * settings["iterations"])})'),
|
||||
"pendingInput": ("Bytes", f'dever.bytes.fromText({json.dumps("x" * settings["pending"])})'),
|
||||
"ormInput": ("Bytes", f'dever.bytes.fromText({json.dumps("x" * settings["orm_iterations"])})'),
|
||||
"ormRows": ("Bytes", f'dever.bytes.fromText({json.dumps("r" * settings["orm_rows"])})'),
|
||||
"ormConcurrency": ("Int", str(settings["orm_concurrency"])),
|
||||
"ormCancelWork": ("Int", str(settings["orm_cancel_work"])),
|
||||
"limits": ("dever.http.Limits", "dever.http.Limits {\nheader_bytes = 8192\n"
|
||||
f'body_bytes = 65536\ntimeout_ms = {settings["timeout_ms"]}\n'
|
||||
f'connections = {settings["connections"]}\nhttp2 = null\n}}'),
|
||||
"http2Limits": ("dever.http.Limits", "dever.http.Limits {\nheader_bytes = 8192\n"
|
||||
f'body_bytes = 65536\ntimeout_ms = {settings["timeout_ms"]}\n'
|
||||
f'connections = {settings["connections"]}\nhttp2 = dever.http.Http2Limits {{\n'
|
||||
f'streams = {settings["http2_streams"]}\n'
|
||||
f'stream_window_bytes = {settings["http2_stream_window_bytes"]}\n'
|
||||
f'connection_window_bytes = {settings["http2_connection_window_bytes"]}\n}}\n}}'),
|
||||
}
|
||||
if "certificate" in names:
|
||||
functions["certificate"] = ("Bytes", f'dever.bytes.fromText({json.dumps((TLS / "server.pem").read_text())})')
|
||||
if "key" in names:
|
||||
functions["key"] = ("Bytes", f'dever.bytes.fromText({json.dumps((TLS / "server-key.pem").read_text())})')
|
||||
source = ""
|
||||
for name in names:
|
||||
if name == "largeBody":
|
||||
continue
|
||||
kind, expression = functions[name]
|
||||
source += f"\n{name}() (result: {kind}) {{\nresult = {expression}\n}}\n"
|
||||
if "largeBody" in names:
|
||||
source += '\ndouble(value: Int, text: Text) (result: Text) { result = text + text }\n'
|
||||
source += '\nlargeBody() (result: Bytes) {\nresult = dever.bytes.fromText(reduce(double, '
|
||||
source += '[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], "x"))\n}\n'
|
||||
return source
|
||||
|
||||
|
||||
def stage_application_command(module, capability):
|
||||
command = module / "benchmark/command"
|
||||
command.mkdir(parents=True, exist_ok=True)
|
||||
(command / "app.dever").write_text(
|
||||
f"run() (result: Bool) {{\n result = {capability}()\n}}\n"
|
||||
)
|
||||
(command / "api.dever").write_text("cmd run = app.run\n")
|
||||
|
||||
|
||||
def stage_benchmark_support(module, settings, fixture):
|
||||
if fixture in ("runtime", "orm"):
|
||||
bench = module / "benchmark/bench/app.dever"
|
||||
bench.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(FIXTURES / "bench.dever", bench)
|
||||
if CONFIG_FUNCTIONS[fixture]:
|
||||
config = module / "benchmark/config/app.dever"
|
||||
config.parent.mkdir(parents=True, exist_ok=True)
|
||||
config.write_text(config_source(settings, fixture))
|
||||
|
||||
|
||||
def database_setting(driver, settings, postgres_setting=None, case_name=None):
|
||||
if driver == "sqlite":
|
||||
database = {
|
||||
"type": "sqlite",
|
||||
"path": "data/db/orm.db",
|
||||
"max_connections": settings["orm_sqlite_connections"],
|
||||
"max_page_size": max(32, min(settings["orm_rows"], 10_000)),
|
||||
}
|
||||
elif driver == "postgres":
|
||||
if postgres_setting is None or case_name is None:
|
||||
raise ValueError("PostgreSQL requires config/setting.json")
|
||||
database = deepcopy(postgres_test_connection(postgres_setting))
|
||||
database["url"] = postgres_case_url(postgres_setting, case_name)
|
||||
else:
|
||||
raise ValueError(f"unknown database driver: {driver}")
|
||||
document = ({name: deepcopy(postgres_setting[name])
|
||||
for name in ("http", "log") if name in postgres_setting}
|
||||
if driver == "postgres" else {})
|
||||
document.setdefault("http", {})
|
||||
document.setdefault("log", {})
|
||||
document["database"] = {"default": database}
|
||||
document["auth"] = {"providers": {"benchmark": {
|
||||
"verify": "benchmark.item.verify",
|
||||
"jwtSecret": secrets.token_urlsafe(48),
|
||||
"ttlSeconds": 3600,
|
||||
}}}
|
||||
document["sites"] = {"benchmark": {"path": "", "auth": "benchmark"}}
|
||||
return document
|
||||
|
||||
|
||||
def profile_database_setting(profile):
|
||||
if profile == "base":
|
||||
return {}
|
||||
sqlite = {
|
||||
"type": "sqlite",
|
||||
"path": "data/db/profile.db",
|
||||
"max_connections": 1,
|
||||
"max_page_size": 100,
|
||||
}
|
||||
if profile == "sqlite":
|
||||
return {"database": {"default": sqlite}}
|
||||
if profile not in ("postgres", "both"):
|
||||
raise ValueError(f"unknown runtime profile: {profile}")
|
||||
postgres = {
|
||||
"type": "postgres",
|
||||
"url": "postgres://build-only.invalid/dever_profile",
|
||||
"tls": "disabled",
|
||||
"max_connections": 1,
|
||||
}
|
||||
if profile == "postgres":
|
||||
return {"database": {"default": postgres}}
|
||||
return {"database": {"default": sqlite, "postgres_profile": postgres}}
|
||||
|
||||
|
||||
def dependency_packages(text):
|
||||
return sorted({line.removesuffix(" (*)") for line in text.splitlines() if line})
|
||||
|
||||
|
||||
def runtime_dependencies(cargo, features):
|
||||
command = [cargo, "tree", "--offline", "--locked", "-p", "dever-backend-bridge",
|
||||
"--no-default-features", "--prefix", "none"]
|
||||
if features:
|
||||
command.extend(("--features", ",".join(features)))
|
||||
return dependency_packages(subprocess.check_output(command, cwd=ROOT, text=True))
|
||||
|
||||
|
||||
def prepare_native_project(destination, fixture, settings):
|
||||
module = destination / "module"
|
||||
domain = module / "benchmark" / fixture
|
||||
domain.mkdir(parents=True)
|
||||
shutil.copyfile(FIXTURES / fixture / "app.dever", domain / "app.dever")
|
||||
for role in ("port", "adapter", "api"):
|
||||
shutil.copyfile(FIXTURES / fixture / f"{role}.dever",
|
||||
domain / f"{role}.dever")
|
||||
stage_benchmark_support(module, settings, fixture)
|
||||
config = destination / "config"
|
||||
config.mkdir()
|
||||
write_private_json(config / "setting.json",
|
||||
native_runtime_setting(settings) if fixture == "live" else {})
|
||||
|
||||
|
||||
def prepare_project(destination, fixture, settings, driver, postgres_setting=None):
|
||||
module = destination / "module"
|
||||
domain = module / "benchmark/item"
|
||||
domain.mkdir(parents=True)
|
||||
shutil.copyfile(FIXTURES / fixture / "app.dever", domain / "app.dever")
|
||||
shutil.copyfile(FIXTURES / fixture / "app/model/item.dever", domain / "model.dever")
|
||||
if fixture == "orm":
|
||||
shutil.copyfile(FIXTURES / fixture / "app/api.dever", domain / "api.dever")
|
||||
stage_benchmark_support(module, settings, fixture)
|
||||
stage_application_command(module, "benchmark.item.ormIdle")
|
||||
config = destination / "config"
|
||||
config.mkdir()
|
||||
write_private_json(
|
||||
config / "setting.json",
|
||||
database_setting(driver, settings, postgres_setting, "build_only"),
|
||||
)
|
||||
(destination / "data").mkdir()
|
||||
|
||||
|
||||
def prepare_profile_project(destination, setting):
|
||||
shutil.copytree(FIXTURES / "profile", destination / "module")
|
||||
config = destination / "config"
|
||||
config.mkdir()
|
||||
write_private_json(config / "setting.json", setting)
|
||||
(destination / "data").mkdir()
|
||||
|
||||
|
||||
def prepare_cms_project(destination, source):
|
||||
shutil.copytree(ROOT / "examples/cms" / source, destination)
|
||||
|
||||
|
||||
def native_runtime_setting(settings):
|
||||
return {"adapter": {"benchmark.live": {"setting": {
|
||||
"limits": {
|
||||
"header_bytes": 8192,
|
||||
"body_bytes": 65536,
|
||||
"timeout_ms": settings["timeout_ms"],
|
||||
"connections": settings["connections"],
|
||||
"http2": None,
|
||||
},
|
||||
"lifetime_ms": settings["lifetime_ms"],
|
||||
}}}}
|
||||
|
||||
|
||||
def record_binary(manifest, name, binary, started, **metadata):
|
||||
manifest["binaries"][name] = {
|
||||
**fingerprint(binary),
|
||||
"build_seconds": time.monotonic() - started,
|
||||
**metadata,
|
||||
}
|
||||
|
||||
|
||||
def build_profile_report(args, manifest, log):
|
||||
report = {"source": "profile/benchmark/command/app.dever", "profiles": {}}
|
||||
for profile, features in RUNTIME_PROFILES.items():
|
||||
item = {"runtime_features": list(features),
|
||||
"dependencies": runtime_dependencies(args.cargo, features)}
|
||||
print(f"build profile-{profile}", flush=True)
|
||||
binary_name = f"profile-{profile}"
|
||||
binary = args.output.resolve() / binary_name
|
||||
started = time.monotonic()
|
||||
project = args.output.resolve() / "source" / f"profile-{profile}"
|
||||
setting = profile_database_setting(profile)
|
||||
prepare_profile_project(project, setting)
|
||||
subprocess.run(
|
||||
[args.compiler.resolve(), "build", project, "--output", binary],
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
check=True,
|
||||
)
|
||||
record_binary(manifest, binary_name, binary, started,
|
||||
profile=profile, fixture="profile")
|
||||
item.update({"status": "built", "binary": fingerprint(binary)})
|
||||
report["profiles"][profile] = item
|
||||
path = args.output.resolve() / "profile-report.json"
|
||||
path.write_text(json.dumps(report, indent=2) + "\n")
|
||||
manifest["files"]["profile-report.json"] = fingerprint(path)
|
||||
|
||||
|
||||
def build(args):
|
||||
has_protocols = args.selection in ("all", "protocols")
|
||||
if args.with_go and not has_protocols:
|
||||
raise ValueError("--with-go requires protocol fixtures")
|
||||
postgres_setting = load_postgres_setting() if args.selection == "all" else None
|
||||
settings = configuration(args, postgres_setting)
|
||||
destination = args.output.resolve()
|
||||
destination.mkdir(parents=True, exist_ok=False)
|
||||
compiler = args.compiler.resolve()
|
||||
cargo = shutil.which(str(args.cargo))
|
||||
if cargo is None:
|
||||
raise ValueError(f"cannot find Cargo command: {args.cargo}")
|
||||
args.cargo = Path(cargo)
|
||||
peers = ({name: getattr(args, name).resolve()
|
||||
for name in ("network_bench", "live_bench", "async_bench")}
|
||||
if has_protocols else {})
|
||||
required_tools = (compiler, *peers.values())
|
||||
for executable in required_tools:
|
||||
if not executable.is_file():
|
||||
raise ValueError(f"build the executable first: {executable}")
|
||||
# 在昂贵构建之前拒绝旧环境变量协议产物;这里只读配置,不启动网络。
|
||||
for name, peer in peers.items():
|
||||
if name == "async_bench":
|
||||
continue # The finite Rust reference takes explicit positional arguments.
|
||||
with tempfile.TemporaryDirectory(prefix="dever-peer-config-") as temporary:
|
||||
executable = stage_peer(peer, Path(temporary), {"workers": 2})
|
||||
result = subprocess.run([executable, "check-config"], capture_output=True,
|
||||
text=True, check=True, timeout=5)
|
||||
if json.loads(result.stdout) != {"workers": 2}:
|
||||
raise ValueError(f"benchmark peer does not implement {PEER_CONFIGURATION}: {peer}")
|
||||
manifest = {
|
||||
"peer_configuration": PEER_CONFIGURATION,
|
||||
"application_configuration": APPLICATION_CONFIGURATION,
|
||||
"selection": args.selection,
|
||||
"settings": settings,
|
||||
"cargo": subprocess.check_output([cargo, "-V"], text=True),
|
||||
"compiler": fingerprint(compiler),
|
||||
"optimization": "LLVM O2 application objects, LLD static link, selected prebuilt runtime archive",
|
||||
"runtime_scheduling": "Dever uses production runtime defaults; workers/task_capacity configure Rust references; workers also sets Go GOMAXPROCS when selected",
|
||||
"binaries": {},
|
||||
"files": {},
|
||||
}
|
||||
with (destination / "build.log").open("w") as log:
|
||||
for fixture, entries in (ENTRIES.items() if has_protocols else ()):
|
||||
source = destination / "source" / fixture
|
||||
prepare_native_project(source, fixture, settings)
|
||||
print(f"build {fixture}", flush=True)
|
||||
started = time.monotonic()
|
||||
executable = source / "app"
|
||||
subprocess.run([compiler, "build", source, "--output", executable],
|
||||
stdout=log, stderr=log, check=True)
|
||||
for entry in entries:
|
||||
binary = destination / entry
|
||||
shutil.copy2(executable, binary)
|
||||
record_binary(manifest, entry, binary, started, profile="base", fixture=fixture,
|
||||
command=f"benchmark.{fixture}.{entry}", shared_application=fixture)
|
||||
drivers = ()
|
||||
if args.selection == "all":
|
||||
drivers = ("sqlite", "postgres") if postgres_setting is not None else ("sqlite",)
|
||||
for driver in drivers:
|
||||
project = destination / "source" / f"orm-{driver}"
|
||||
try:
|
||||
prepare_project(project, "orm", settings, driver, postgres_setting)
|
||||
subprocess.run([compiler, "fmt", project], stdout=log, stderr=log, check=True)
|
||||
for entry in ORM_ENTRIES:
|
||||
name = f"{driver}-{entry.removeprefix('orm_')}"
|
||||
print(f"build {name}", flush=True)
|
||||
binary = destination / name
|
||||
started = time.monotonic()
|
||||
method = "ormSeed" if entry == "orm_http" else "orm" + entry.removeprefix("orm_").capitalize()
|
||||
command_entry = f"benchmark.item.{method}"
|
||||
stage_application_command(project / "module", command_entry)
|
||||
subprocess.run([compiler, "build", project, "--output", binary],
|
||||
stdout=log, stderr=log, check=True)
|
||||
record_binary(manifest, name, binary, started, profile=driver, fixture="orm",
|
||||
entry=entry)
|
||||
finally:
|
||||
if driver == "postgres":
|
||||
(project / "config/setting.json").unlink(missing_ok=True)
|
||||
cms_sources = {"all": ("dever",), "cms-profiles": ("dever",),
|
||||
"profiles": (), "protocols": ()}[args.selection]
|
||||
for source in cms_sources:
|
||||
cms_project = destination / "source" / f"cms-{source}"
|
||||
prepare_cms_project(cms_project, source)
|
||||
config_name = f"source/cms-{source}/config/setting.json"
|
||||
manifest["files"][config_name] = fingerprint(cms_project / "config/setting.json")
|
||||
binary_name = "cms"
|
||||
print(f"build cms-{source}", flush=True)
|
||||
cms_binary = destination / binary_name
|
||||
started = time.monotonic()
|
||||
subprocess.run([compiler, "build", cms_project, "--output", cms_binary],
|
||||
stdout=log, stderr=log, check=True)
|
||||
record_binary(manifest, binary_name, cms_binary, started,
|
||||
profile="sqlite", fixture="cms", source=source)
|
||||
if args.selection != "protocols":
|
||||
build_profile_report(args, manifest, log)
|
||||
for name, peer in peers.items():
|
||||
shutil.copy2(peer, destination / name)
|
||||
manifest["binaries"][name] = fingerprint(destination / name)
|
||||
if has_protocols:
|
||||
shutil.copytree(TLS, destination / "tls")
|
||||
for path in sorted((destination / "tls").rglob("*")):
|
||||
if path.is_file():
|
||||
name = path.relative_to(destination).as_posix()
|
||||
manifest["files"][name] = fingerprint(path)
|
||||
if args.with_go:
|
||||
binary = destination / "go_http"
|
||||
source = FIXTURES / "http/peer.go"
|
||||
with (destination / "go.build.log").open("w") as log:
|
||||
subprocess.run(["go", "build", "-trimpath", "-ldflags=-s -w", "-o", binary, source],
|
||||
stdout=log, stderr=log, check=True, timeout=120)
|
||||
manifest["binaries"]["go_http"] = fingerprint(binary)
|
||||
manifest["go"] = {"compiler": subprocess.check_output(["go", "version"], text=True).strip(),
|
||||
"source": fingerprint(source), "profile": "go build -trimpath -ldflags=-s -w"}
|
||||
(destination / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(f"artifacts: {destination}", flush=True)
|
||||
@@ -0,0 +1,675 @@
|
||||
"""验收 CMS 的真实身份、租户、发布链及专属内存预算。"""
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from copy import deepcopy
|
||||
import http.client
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
from threading import Event
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from build import ROOT, fingerprint
|
||||
from process import Budget, Process, observe, summarize, resource_trend
|
||||
from settings import write_private_json
|
||||
|
||||
FORMATS = ("dever",)
|
||||
ARTICLE_PATH = "/news/article/admin/manage"
|
||||
ARTICLE_LIST_PATH = f"{ARTICLE_PATH}/list?size=100"
|
||||
REVISION_PATH = "/content/revision/admin/manage/create"
|
||||
SESSION_PATH = "/user/account/{site}/session/login"
|
||||
PUBLICATIONS_PATH = "/news/article/front/browse/list?size=100"
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def execute(command, directory, log_name, *, envelope=False, timeout=180):
|
||||
child = subprocess.Popen([str(value) for value in command], cwd=directory,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
||||
failure = None
|
||||
try:
|
||||
stdout, stderr = child.communicate(timeout=timeout)
|
||||
except BaseException as error:
|
||||
failure = error
|
||||
try:
|
||||
signal_group(child.pid, signal.SIGTERM)
|
||||
stdout, stderr = child.communicate(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
signal_group(child.pid, signal.SIGKILL)
|
||||
stdout, stderr = child.communicate(timeout=3)
|
||||
(directory / f"{log_name}.stdout.log").write_bytes(stdout)
|
||||
(directory / f"{log_name}.stderr.log").write_bytes(stderr)
|
||||
if failure is not None:
|
||||
raise failure
|
||||
require(child.returncode == 0, f"{log_name} exited {child.returncode}: {directory}")
|
||||
if envelope:
|
||||
return decode_response(stdout, 200)
|
||||
|
||||
|
||||
def signal_group(pid, signal_number):
|
||||
try:
|
||||
os.killpg(pid, signal_number)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def decode_response(body, status):
|
||||
document = json.loads(body)
|
||||
require(isinstance(document, dict) and set(document) == {"code", "message", "data"},
|
||||
f"unexpected response envelope (HTTP {status})")
|
||||
require(status == 200 and document["code"] == 0,
|
||||
f"request failed: HTTP {status}, code={document['code']}, {document['message']}")
|
||||
return document["data"]
|
||||
|
||||
|
||||
def copy_cms_project(source, destination):
|
||||
destination.mkdir()
|
||||
for name in ("config", "module", "test"):
|
||||
shutil.copytree(source / name, destination / name)
|
||||
|
||||
|
||||
def build_cases(compiler, output):
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
cases = {}
|
||||
for source_format in FORMATS:
|
||||
source = ROOT / "examples/cms" / source_format
|
||||
project = output / source_format
|
||||
copy_cms_project(source, project)
|
||||
started = time.monotonic()
|
||||
execute([compiler, "check", project], project, "check")
|
||||
execute([compiler, "build", project, "--output", project / "cms-app"], project, "build", timeout=900)
|
||||
executable = project / "cms-app"
|
||||
cases[source_format] = {
|
||||
"executable": str(executable), "config": str(project / "config/setting.json"),
|
||||
"fingerprint": fingerprint(executable),
|
||||
"build_seconds": time.monotonic() - started,
|
||||
}
|
||||
manifest = {"format": "dever-cms-publish-v1", "cases": cases}
|
||||
write_private_json(output / "manifest.json", manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def available_port():
|
||||
with socket.socket() as reservation:
|
||||
reservation.bind(("127.0.0.1", 0))
|
||||
return reservation.getsockname()[1]
|
||||
|
||||
|
||||
def isolated_settings(settings, port):
|
||||
settings = deepcopy(settings)
|
||||
settings["runtime"]["mode"] = "api"
|
||||
settings["http"] = {"host": "127.0.0.1", "port": port}
|
||||
settings["auth"]["providers"]["session"]["jwtSecret"] = secrets.token_hex(32)
|
||||
for site in settings["sites"].values():
|
||||
site["origin"] = f"http://127.0.0.1:{port}"
|
||||
site["hosts"] = ["127.0.0.1"]
|
||||
database = settings["database"]["default"]
|
||||
if database["type"] == "sqlite":
|
||||
database["path"] = "data/db/platform.db"
|
||||
database["tenant_directory"] = "data/tenants"
|
||||
elif database["type"] == "postgres":
|
||||
require(bool(database.get("url")) and bool(database.get("tenant_database_prefix")),
|
||||
"PostgreSQL CMS acceptance requires an explicit isolated control database and tenant prefix")
|
||||
else:
|
||||
raise RuntimeError("CMS acceptance supports only SQLite and PostgreSQL")
|
||||
settings["log"]["level"] = "error"
|
||||
return settings
|
||||
|
||||
|
||||
class CmsClient:
|
||||
def __init__(self, port):
|
||||
self.connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
self.origin = f"http://127.0.0.1:{port}"
|
||||
self.cookies = {}
|
||||
self.timings = {}
|
||||
self.record_timings = True
|
||||
|
||||
def request(self, method, path, payload=None, *, site=None, expected=200):
|
||||
headers = {"Content-Type": "application/json", "Origin": self.origin}
|
||||
if site in self.cookies:
|
||||
headers["Cookie"] = self.cookies[site]
|
||||
body = None
|
||||
if payload is not None:
|
||||
if method in ("GET", "DELETE"):
|
||||
query = {name: value if isinstance(value, str) else json.dumps(value)
|
||||
for name, value in payload.items()}
|
||||
path += ("&" if "?" in path else "?") + urlencode(query)
|
||||
else:
|
||||
body = json.dumps(payload)
|
||||
started = time.monotonic()
|
||||
self.connection.request(method, path, body, headers)
|
||||
response = self.connection.getresponse()
|
||||
body = response.read()
|
||||
route = path.split("?")[0]
|
||||
label = f"{method} {route}" + (f" expected {expected}" if expected != 200 else "")
|
||||
if self.record_timings:
|
||||
self.timings.setdefault(label, []).append(
|
||||
(time.monotonic() - started) * 1000)
|
||||
require(response.status == expected,
|
||||
f"{method} {path}: HTTP {response.status}, expected {expected}: {body!r}")
|
||||
if expected != 200:
|
||||
document = json.loads(body)
|
||||
require(document.get("code") != 0, "rejected request returned success")
|
||||
return None
|
||||
data = decode_response(body, response.status)
|
||||
if path.endswith("/login"):
|
||||
cookie = SimpleCookie()
|
||||
for name, value in response.getheaders():
|
||||
if name.lower() == "set-cookie":
|
||||
cookie.load(value)
|
||||
require(bool(cookie), "login did not return a session cookie")
|
||||
self.cookies[site] = "; ".join(f"{name}={value.value}" for name, value in cookie.items())
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
self.connection.close()
|
||||
|
||||
|
||||
def wait_ready(process, client):
|
||||
deadline = time.monotonic() + 20
|
||||
while time.monotonic() < deadline:
|
||||
process.sample()
|
||||
require(process.child.poll() is None, f"CMS exited before readiness: {process.directory}")
|
||||
try:
|
||||
client.request("GET", ARTICLE_LIST_PATH, expected=401)
|
||||
return time.monotonic() - process.started
|
||||
except (ConnectionError, http.client.HTTPException, OSError):
|
||||
client.close()
|
||||
time.sleep(0.02)
|
||||
raise TimeoutError(f"CMS readiness timeout: {process.directory}")
|
||||
|
||||
|
||||
def login_client(client, identity, password, tenant_key):
|
||||
for site in ("admin", "front"):
|
||||
data = client.request("POST", SESSION_PATH.format(site=site), {
|
||||
"credential_id": identity["credential_id"], "password": password, "tenant": tenant_key,
|
||||
}, site=site)
|
||||
require(data["account"]["id"] == identity["user_id"]
|
||||
and data["tenant_id"] == identity["tenant_id"] and data["site"] == site,
|
||||
"login changed the trusted identity or tenant/site")
|
||||
|
||||
|
||||
def publish_workload(client, process, identity, password, articles, *,
|
||||
tenant_key="bench", title_prefix="Article"):
|
||||
login_client(client, identity, password, tenant_key)
|
||||
titles = []
|
||||
ids = []
|
||||
for index in range(articles):
|
||||
slug, title = f"bench-{index}", f"{title_prefix} {index}"
|
||||
created = client.request("POST", f"{ARTICLE_PATH}/create", {
|
||||
"slug": f" {slug.upper()} ", "title": f" {title} ", "body": "Draft body",
|
||||
}, site="admin")
|
||||
require(created["slug"] == slug and created["title"] == title,
|
||||
"article creation did not normalize input")
|
||||
require("author_id" not in created and "status" not in created,
|
||||
"article creation leaked private owner or publication state")
|
||||
changed = client.request("POST", REVISION_PATH, {
|
||||
"selected_article_id": created["id"], "expected_version": created["version"],
|
||||
"title": f" {title} published ", "body": "Published body",
|
||||
}, site="admin")
|
||||
expected_title = f"{title} published"
|
||||
require(changed["title"] == expected_title
|
||||
and changed["article_id"] == created["id"]
|
||||
and changed["version"] == created["version"] + 1,
|
||||
"revision did not preserve normalized content and advance the article version")
|
||||
published = client.request("POST", f"{ARTICLE_PATH}/publish", {
|
||||
"article_id": created["id"],
|
||||
}, site="admin")
|
||||
require(published["slug"] == slug and published["title"] == expected_title
|
||||
and published["body"] == "Published body",
|
||||
"publication did not reflect the stored article")
|
||||
titles.append(expected_title)
|
||||
ids.append(created["id"])
|
||||
process.sample()
|
||||
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
require(listing["total"] == articles
|
||||
and sorted(value["title"] for value in listing["items"]) == sorted(titles),
|
||||
"front query did not return every published article exactly once")
|
||||
client.request("POST", f"{ARTICLE_PATH}/publish", {"article_id": ids[0]},
|
||||
site="admin", expected=409)
|
||||
return {"articles": articles, "titles": sorted(titles), "duplicate_publish_status": 409}
|
||||
|
||||
|
||||
def provision_identity(executable, directory, tenant_key):
|
||||
password = secrets.token_urlsafe(24)
|
||||
identity = execute([executable, "user.account.bootstrap", json.dumps({
|
||||
"tenant_key": tenant_key, "tenant_name": f"Benchmark {tenant_key}",
|
||||
"email": f"{tenant_key}@example.invalid", "display_name": "Benchmark",
|
||||
"password": password,
|
||||
})], directory, f"bootstrap-{tenant_key}", envelope=True)
|
||||
execute([executable, "--tenant", identity["tenant_id"], "dever.tenant.migrate", "null"],
|
||||
directory, f"migrate-{tenant_key}")
|
||||
for site in ("admin", "front"):
|
||||
execute([executable, "--tenant", identity["tenant_id"], "dever.auth.owner",
|
||||
json.dumps({"site": site, "user_id": identity["user_id"]})],
|
||||
directory, f"owner-{tenant_key}-{site}")
|
||||
return identity, password
|
||||
|
||||
|
||||
def latency_summary(timings):
|
||||
result = {}
|
||||
for action, samples in timings.items():
|
||||
values = sorted(samples)
|
||||
result[action] = {"samples": len(values), "p50_ms": values[len(values) // 2],
|
||||
"max_ms": values[-1]}
|
||||
return result
|
||||
|
||||
|
||||
def catalog_key(catalog, component, domain, site, action, method):
|
||||
matches = [entry["key"] for entry in catalog
|
||||
if (entry["component"], entry["domain"], entry["site"],
|
||||
entry["action"], entry["method"]) == (component, domain, site, action, method)]
|
||||
require(len(matches) == 1 and bool(matches[0]),
|
||||
f"permission catalog must uniquely identify {component}.{domain}.{site}.{action}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def run_postgres_rbac(executable, directory, port, owner, identity):
|
||||
catalog = owner.request("GET", "/user/authorization/admin/manage/permissions", site="admin")
|
||||
front_catalog = owner.request("GET", "/user/authorization/front/manage/permissions", site="front")
|
||||
permissions = {
|
||||
"admin_read": catalog_key(catalog, "news", "article", "admin", "list", "GET"),
|
||||
"admin_permissions": catalog_key(catalog, "user", "authorization", "admin", "permissions", "GET"),
|
||||
"front_list": catalog_key(front_catalog, "news", "article", "front", "list", "GET"),
|
||||
}
|
||||
require(len(set(permissions.values())) == 3, "RBAC acceptance reused a permission key")
|
||||
password = secrets.token_urlsafe(24)
|
||||
member = execute([executable, "user.account.fixture_member", json.dumps({
|
||||
"selected_tenant_id": identity["tenant_id"], "email": f"member-{secrets.token_hex(8)}@example.invalid",
|
||||
"display_name": "Acceptance member", "password": password,
|
||||
})], directory, "rbac-member", envelope=True)
|
||||
require(isinstance(member["user_id"], int) and not isinstance(member["user_id"], bool)
|
||||
and member["user_id"] > 0 and member["user_id"] != identity["user_id"]
|
||||
and member["tenant_id"] == identity["tenant_id"],
|
||||
"RBAC acceptance did not create a distinct member in the owner's tenant")
|
||||
probes = (("admin", ARTICLE_LIST_PATH),
|
||||
("admin", "/user/authorization/admin/manage/permissions"),
|
||||
("front", PUBLICATIONS_PATH))
|
||||
client = CmsClient(port)
|
||||
try:
|
||||
login_client(client, member, password, "tenant_a")
|
||||
for site, path in probes:
|
||||
client.request("GET", path, site=site, expected=403)
|
||||
roles = (("admin", "fixture-reader", "admin_read"),
|
||||
("admin", "fixture-catalog", "admin_permissions"),
|
||||
("front", "fixture-reader", "front_list"))
|
||||
for site, role_id, permission in roles:
|
||||
base = f"/user/authorization/{site}/manage"
|
||||
owner.request("POST", f"{base}/save", {
|
||||
"id": role_id, "name": role_id, "all_permissions": False,
|
||||
"permission_keys": [permissions[permission]],
|
||||
}, site=site)
|
||||
owner.request("POST", f"{base}/grant", {
|
||||
"user_id": member["user_id"], "role_id": role_id,
|
||||
}, site=site)
|
||||
for site, path in probes:
|
||||
client.request("GET", path, site=site)
|
||||
owner.request("DELETE", "/user/authorization/admin/manage/revoke", {
|
||||
"user_id": member["user_id"], "role_id": "fixture-reader",
|
||||
}, site="admin")
|
||||
client.request("GET", ARTICLE_LIST_PATH, site="admin", expected=403)
|
||||
client.request("GET", probes[1][1], site="admin")
|
||||
client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
for site, role_id, path in (("admin", "fixture-catalog", probes[1][1]),
|
||||
("front", "fixture-reader", PUBLICATIONS_PATH)):
|
||||
owner.request("DELETE", f"/user/authorization/{site}/manage/revoke", {
|
||||
"user_id": member["user_id"], "role_id": role_id,
|
||||
}, site=site)
|
||||
client.request("GET", path, site=site, expected=403)
|
||||
finally:
|
||||
client.close()
|
||||
return {"member_user_id": member["user_id"], "permissions": permissions,
|
||||
"before_grant_denied": True, "both_admin_roles_allowed": True,
|
||||
"revoked_role_denied": True, "second_role_still_allowed": True,
|
||||
"front_same_named_role_still_allowed": True}
|
||||
|
||||
|
||||
class ReadLatency:
|
||||
"""固定内存的对数直方图;分位数报告桶上界,最大相邻桶比例约1.011。"""
|
||||
|
||||
def __init__(self):
|
||||
self.buckets = [0] * 2048
|
||||
self.count = 0
|
||||
|
||||
def add(self, milliseconds):
|
||||
index = math.ceil(math.log2(1 + milliseconds) * 64)
|
||||
require(0 <= index < len(self.buckets), "CMS latency exceeds histogram bounds")
|
||||
self.buckets[index] += 1
|
||||
self.count += 1
|
||||
|
||||
def merge(self, other):
|
||||
self.buckets = [left + right for left, right in zip(self.buckets, other.buckets)]
|
||||
self.count += other.count
|
||||
|
||||
def percentile(self, fraction):
|
||||
threshold = math.ceil(self.count * fraction)
|
||||
count = 0
|
||||
for index, samples in enumerate(self.buckets):
|
||||
count += samples
|
||||
if count >= threshold:
|
||||
return 2 ** (index / 64) - 1
|
||||
raise ValueError("empty latency histogram")
|
||||
|
||||
|
||||
def concurrent_reads(port, cookies, expected, process, *, concurrency, seconds, interval):
|
||||
"""Each closed-loop client keeps its own connection and verifies every reply."""
|
||||
stop = Event()
|
||||
started = time.monotonic()
|
||||
deadline = started + seconds
|
||||
first = len(process.samples)
|
||||
|
||||
def read_publications():
|
||||
client = CmsClient(port)
|
||||
client.cookies = cookies.copy()
|
||||
client.record_timings = False
|
||||
latency = ReadLatency()
|
||||
try:
|
||||
while not stop.is_set() and time.monotonic() < deadline:
|
||||
request_started = time.monotonic()
|
||||
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
require(listing == expected, "concurrent CMS read changed publication results")
|
||||
latency.add((time.monotonic() - request_started) * 1000)
|
||||
return latency
|
||||
except BaseException:
|
||||
stop.set()
|
||||
raise
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as clients:
|
||||
futures = [clients.submit(read_publications) for _ in range(concurrency)]
|
||||
try:
|
||||
while any(not future.done() for future in futures):
|
||||
process.check()
|
||||
observe([process], interval, interval)
|
||||
elapsed = time.monotonic() - started
|
||||
latency = ReadLatency()
|
||||
for future in futures:
|
||||
latency.merge(future.result())
|
||||
finally:
|
||||
stop.set()
|
||||
require(latency.count > 0, "concurrent CMS read completed without a successful request")
|
||||
require(elapsed >= seconds, "concurrent CMS read ended before the measurement window")
|
||||
process.sample()
|
||||
return {"mode": "closed_loop_authenticated_read", "concurrency": concurrency,
|
||||
"requested_seconds": seconds, "elapsed_seconds": elapsed,
|
||||
"succeeded": latency.count, "errors": 0, "result_checked_on_every_request": True,
|
||||
"actual_success_qps": latency.count / elapsed,
|
||||
"latency_measurement": "request and reply validation; logarithmic bucket upper bound, 64 buckets per octave",
|
||||
"request_latency_ms": {name: latency.percentile(fraction)
|
||||
for name, fraction in (("p50", .5), ("p95", .95), ("p99", .99))},
|
||||
"resources": summarize(process.samples[first:]),
|
||||
"trend": resource_trend(process.samples[first:]) if len(process.samples[first:]) >= 4 else None}
|
||||
|
||||
|
||||
def sqlite_pool_descriptor_limits(settings, directory):
|
||||
database = settings["database"]["default"]
|
||||
require(database["type"] == "sqlite", "CMS read benchmark requires isolated SQLite")
|
||||
databases = [directory / database["path"]]
|
||||
databases.extend((directory / database["tenant_directory"]).glob("*.db"))
|
||||
# 每个连接可持有主文件与WAL;同进程各连接共用一个SHM句柄。
|
||||
return {str(path.resolve()) + suffix: limit for path in databases
|
||||
for suffix, limit in (("", database["max_connections"]),
|
||||
("-wal", database["max_connections"]), ("-shm", 1))}
|
||||
|
||||
|
||||
def validate_read_descriptor_recovery(load):
|
||||
before = Counter(load["descriptors_before"].values())
|
||||
after = Counter(load["descriptors_after"].values())
|
||||
for path, limit in load["pooled_descriptor_limits"].items():
|
||||
require(after[path] <= limit, f"CMS SQLite descriptor count exceeds pool limit: {path}")
|
||||
before.pop(path, None)
|
||||
after.pop(path, None)
|
||||
require(before == after,
|
||||
f"CMS read descriptors did not recover: added={dict(after - before)}, missing={dict(before - after)}")
|
||||
|
||||
|
||||
def run_case(case, directory, *, articles, cgroup_parent, memory_mib,
|
||||
idle_seconds=0.2, interval=0.05, server_cpu=None, cpu_quota=None,
|
||||
concurrency=0, read_seconds=5):
|
||||
executable = Path(case["executable"])
|
||||
require(fingerprint(executable) == case["fingerprint"], "CMS artifact fingerprint changed")
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
target = directory / "cms-app"
|
||||
shutil.copy2(executable, target)
|
||||
(directory / "config").mkdir()
|
||||
(directory / "data").mkdir()
|
||||
port = available_port()
|
||||
settings = isolated_settings(json.loads(Path(case["config"]).read_text()), port)
|
||||
write_private_json(directory / "config/setting.json", settings)
|
||||
identity, password = provision_identity(target, directory, "bench")
|
||||
budget = Budget(cgroup_parent, memory_mib, cpu_quota)
|
||||
client = CmsClient(port)
|
||||
failure = None
|
||||
result = {}
|
||||
try:
|
||||
with Process([target], directory / "server", budget=budget,
|
||||
cpus=(server_cpu,) if server_cpu is not None else None) as process:
|
||||
result["raw"] = process.samples
|
||||
ready_seconds = wait_ready(process, client)
|
||||
started = time.monotonic()
|
||||
contract = publish_workload(client, process, identity, password, articles)
|
||||
elapsed = time.monotonic() - started
|
||||
result.update(contract=contract, ready_seconds=ready_seconds,
|
||||
workload_seconds=elapsed, published_articles_per_second=articles / elapsed,
|
||||
latency=latency_summary(client.timings))
|
||||
if concurrency:
|
||||
expected = client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
# 控制连接不参与长读负载,先关闭,避免其空闲超时改变基线。
|
||||
client.close()
|
||||
observe([process], 0.3, interval)
|
||||
descriptors_before = process.descriptors()
|
||||
pooled_limits = sqlite_pool_descriptor_limits(settings, directory)
|
||||
read_load = {"descriptors_before": descriptors_before,
|
||||
"pooled_descriptor_limits": pooled_limits}
|
||||
result["concurrent_reads"] = read_load
|
||||
read_load.update(concurrent_reads(port, client.cookies, expected, process,
|
||||
concurrency=concurrency, seconds=read_seconds, interval=interval))
|
||||
observe([process], 0.3, interval)
|
||||
descriptors_after = process.descriptors()
|
||||
read_load.update(descriptors_after=descriptors_after,
|
||||
fd_delta_after_disconnect=len(descriptors_after) - len(descriptors_before))
|
||||
validate_read_descriptor_recovery(read_load)
|
||||
observe([process], idle_seconds, interval)
|
||||
process.sample()
|
||||
process.check()
|
||||
require(process.child.poll() is None, "CMS exited during the publish workload")
|
||||
resources = summarize(process.samples)
|
||||
result.update(resources=resources, budget_before_stop=budget.sample())
|
||||
except BaseException as error:
|
||||
failure = error
|
||||
finally:
|
||||
client.close()
|
||||
try:
|
||||
budget.close()
|
||||
except BaseException as error:
|
||||
if failure is None:
|
||||
failure = error
|
||||
else:
|
||||
failure.add_note(f"cannot close owned CMS budget: {error}")
|
||||
result["cgroup"] = budget.final
|
||||
if failure is None and budget.final is not None and (
|
||||
budget.final["events"]["oom"] or budget.final["events"]["oom_kill"]):
|
||||
failure = RuntimeError("CMS exceeded its memory budget")
|
||||
result["status"] = "failed" if failure else "passed"
|
||||
if failure is not None:
|
||||
result["error"] = str(failure)
|
||||
write_private_json(directory / "report.json", result)
|
||||
if failure is not None:
|
||||
raise failure
|
||||
return result
|
||||
|
||||
|
||||
def run_postgres_case(project, output, compiler, *, articles, rbac_fixture=False):
|
||||
# 构建、临时密钥和端口只写测试副本,调用者的项目配置始终保持不变。
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-postgres-") as temporary:
|
||||
staged = Path(temporary) / "project"
|
||||
copy_cms_project(project, staged)
|
||||
return run_staged_postgres_case(staged, output, compiler, articles=articles,
|
||||
rbac_fixture=rbac_fixture)
|
||||
|
||||
|
||||
def run_staged_postgres_case(project, output, compiler, *, articles, rbac_fixture):
|
||||
require(1 <= articles <= 100, "PostgreSQL CMS acceptance requires 1..100 articles")
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
report = {"status": "running", "source": "dever", "tenants": {},
|
||||
"scope": "PostgreSQL HTTP owner flow and physical database tenant isolation; not non-owner RBAC revocation"}
|
||||
if rbac_fixture:
|
||||
report["scope"] = "PostgreSQL HTTP owner flow, physical database tenant isolation and non-owner multi-role/site grant-revoke"
|
||||
clients = {}
|
||||
try:
|
||||
setting_path = project / "config/setting.json"
|
||||
initial = json.loads(setting_path.read_text())
|
||||
require(initial["database"]["default"]["type"] == "postgres",
|
||||
"postgres-case requires a PostgreSQL project configuration")
|
||||
port = available_port()
|
||||
write_private_json(setting_path, isolated_settings(initial, port), replace=True)
|
||||
execute([compiler, "check", project], output, "check")
|
||||
executable = project / "cms-app"
|
||||
execute([compiler, "build", project, "--output", executable], output, "build", timeout=900)
|
||||
report["binary"] = fingerprint(executable)
|
||||
identities = {key: provision_identity(executable, output, key)
|
||||
for key in ("tenant_a", "tenant_b")}
|
||||
require(identities["tenant_a"][0]["tenant_id"] != identities["tenant_b"][0]["tenant_id"],
|
||||
"PostgreSQL acceptance reused a tenant identity")
|
||||
clients = {key: CmsClient(port) for key in identities}
|
||||
with Process([executable], output / "server") as process:
|
||||
report["ready_seconds"] = wait_ready(process, clients["tenant_a"])
|
||||
for key, (identity, password) in identities.items():
|
||||
contract = publish_workload(clients[key], process, identity, password, articles,
|
||||
tenant_key=key, title_prefix=key)
|
||||
permissions = clients[key].request(
|
||||
"GET", "/user/authorization/admin/manage/permissions", site="admin")
|
||||
require(bool(permissions), "CMS permission catalog is empty")
|
||||
report["tenants"][key] = {"tenant_id": identity["tenant_id"],
|
||||
"user_id": identity["user_id"], "contract": contract}
|
||||
for key, client in clients.items():
|
||||
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
contract = report["tenants"][key]["contract"]
|
||||
require(listing["total"] == articles
|
||||
and sorted(value["title"] for value in listing["items"]) == contract["titles"],
|
||||
"PostgreSQL tenant query leaked another tenant's articles")
|
||||
require(sorted(value["slug"] for value in listing["items"])
|
||||
== sorted(f"bench-{index}" for index in range(articles)),
|
||||
"PostgreSQL tenants did not preserve the same slug namespace")
|
||||
report["tenants"][key]["slugs"] = sorted(value["slug"] for value in listing["items"])
|
||||
identity, password = identities["tenant_a"]
|
||||
clients["tenant_a"].request("POST", SESSION_PATH.format(site="admin"), {
|
||||
"credential_id": identity["credential_id"], "password": password, "tenant": "tenant_b",
|
||||
}, site="admin", expected=401)
|
||||
front_cookie = clients["tenant_a"].cookies["front"]
|
||||
clients["tenant_a"].cookies["front"] = clients["tenant_a"].cookies["admin"]
|
||||
clients["tenant_a"].request("GET", PUBLICATIONS_PATH, site="front", expected=401)
|
||||
clients["tenant_a"].cookies["front"] = front_cookie
|
||||
if rbac_fixture:
|
||||
report["rbac"] = run_postgres_rbac(executable, output, port,
|
||||
clients["tenant_a"], identity)
|
||||
process.sample()
|
||||
process.check()
|
||||
require(process.child.poll() is None, "PostgreSQL CMS exited during tenant acceptance")
|
||||
report["resources"] = summarize(process.samples)
|
||||
report["cross_site_cookie_rejected"] = True
|
||||
report["cross_tenant_login_rejected"] = True
|
||||
report["status"] = "passed"
|
||||
except BaseException as error:
|
||||
report["status"] = "failed"
|
||||
report["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
for client in clients.values():
|
||||
client.close()
|
||||
write_private_json(output / "report.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def run_cases(manifest, output, *, articles, cgroup_parent, memory_mib,
|
||||
concurrency=0, read_seconds=5):
|
||||
require(manifest["format"] == "dever-cms-publish-v1", "unexpected CMS manifest format")
|
||||
require(1 <= articles <= 100, "articles must be 1..100")
|
||||
require(0 <= concurrency <= 64, "read concurrency must be 0..64")
|
||||
require(math.isfinite(read_seconds) and 0.1 <= read_seconds <= 3600,
|
||||
"read seconds must be finite and within 0.1..3600")
|
||||
require((memory_mib is None) == (cgroup_parent is None),
|
||||
"memory budget and cgroup parent must be provided together")
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
report = {"format": manifest["format"], "memory_mib": memory_mib,
|
||||
"scope": "owned loopback HTTP/1, sequential publish, isolated SQLite; not a saturation result",
|
||||
"cases": {}}
|
||||
if concurrency:
|
||||
report["scope"] = "owned loopback HTTP/1, sequential publish and closed-loop authenticated reads, isolated SQLite"
|
||||
try:
|
||||
for source_format in FORMATS:
|
||||
report["cases"][source_format] = run_case(manifest["cases"][source_format],
|
||||
output / source_format, articles=articles,
|
||||
cgroup_parent=cgroup_parent, memory_mib=memory_mib,
|
||||
concurrency=concurrency, read_seconds=read_seconds)
|
||||
report["status"] = "passed"
|
||||
except BaseException as error:
|
||||
report["status"] = "failed"
|
||||
report["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
for source_format in FORMATS:
|
||||
case_report = output / source_format / "report.json"
|
||||
if source_format not in report["cases"] and case_report.is_file():
|
||||
report["cases"][source_format] = json.loads(case_report.read_text())
|
||||
write_private_json(output / "report.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
actions = parser.add_subparsers(dest="action", required=True)
|
||||
build = actions.add_parser("build")
|
||||
build.add_argument("--compiler", type=Path, default=ROOT / "target/debug/dever")
|
||||
build.add_argument("--output", type=Path, required=True)
|
||||
run = actions.add_parser("run")
|
||||
run.add_argument("--manifest", type=Path, required=True)
|
||||
run.add_argument("--output", type=Path, required=True)
|
||||
run.add_argument("--articles", type=int, default=16)
|
||||
run.add_argument("--cgroup-parent", type=Path)
|
||||
run.add_argument("--memory-mib", type=int, choices=(64, 128))
|
||||
run.add_argument("--concurrency", type=int, default=0,
|
||||
help="0 disables load; otherwise 1..64 independent authenticated read clients")
|
||||
run.add_argument("--read-seconds", type=float, default=5)
|
||||
postgres = actions.add_parser("postgres-case")
|
||||
postgres.add_argument("--project", type=Path, required=True)
|
||||
postgres.add_argument("--output", type=Path, required=True)
|
||||
postgres.add_argument("--compiler", type=Path, default=ROOT / "target/debug/dever")
|
||||
postgres.add_argument("--articles", type=int, default=2)
|
||||
postgres.add_argument("--rbac-fixture", action="store_true",
|
||||
help="run test-only member CMD provided by the Rust PostgreSQL fixture")
|
||||
args = parser.parse_args()
|
||||
if args.action == "build":
|
||||
result = build_cases(args.compiler.resolve(strict=True), args.output.resolve())
|
||||
elif args.action == "postgres-case":
|
||||
report = run_postgres_case(args.project.resolve(strict=True), args.output.resolve(),
|
||||
args.compiler.resolve(strict=True), articles=args.articles,
|
||||
rbac_fixture=args.rbac_fixture)
|
||||
result = {"status": report["status"], "report": str(args.output.resolve() / "report.json")}
|
||||
else:
|
||||
result = run_cases(json.loads(args.manifest.read_text()), args.output.resolve(),
|
||||
articles=args.articles, cgroup_parent=args.cgroup_parent,
|
||||
memory_mib=args.memory_mib, concurrency=args.concurrency,
|
||||
read_seconds=args.read_seconds)
|
||||
result = {"status": result["status"], "memory_mib": result["memory_mib"],
|
||||
"report": str(args.output / "report.json")}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""同 Pod 两个隔离容器:server 执行固定矩阵,client 只负责施压。
|
||||
|
||||
构建产物与本文件、process.py、settings.py 放入 /work;两个角色通过 emptyDir
|
||||
交接有限测试命令。测量采用闭环长连接,延迟含收发和正文验证,不含建连时间。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from process import Process, summarize
|
||||
|
||||
ROOT = Path("/work")
|
||||
|
||||
|
||||
def write(path, value):
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def counters():
|
||||
return {name: (Path("/sys/fs/cgroup") / name).read_text()
|
||||
for name in ("cpu.max", "cpu.stat", "memory.max", "memory.events")}
|
||||
|
||||
|
||||
def client():
|
||||
request = ROOT / "request.json"
|
||||
while not (ROOT / "finished").exists():
|
||||
if not request.exists():
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
item = json.loads(request.read_text())
|
||||
request.unlink()
|
||||
directory = ROOT / item["name"]
|
||||
before = counters()
|
||||
with Process([ROOT / "bin/rust-http", "load", item["port"], item["path"],
|
||||
item["concurrency"], item["seconds"]], directory, cpus={2, 3, 4, 5}) as process:
|
||||
deadline = time.monotonic() + item["seconds"] + 30
|
||||
while process.child.poll() is None and time.monotonic() < deadline:
|
||||
process.sample()
|
||||
time.sleep(0.2)
|
||||
if process.child.poll() is None:
|
||||
raise TimeoutError(f"load client timed out: {item}")
|
||||
result = {"exit_code": process.child.returncode, "stdout": process.text(),
|
||||
"stderr": (directory / "stderr.log").read_text(),
|
||||
"resources": summarize(process.samples), "samples": process.samples,
|
||||
"cgroup_before": before, "cgroup_after": counters()}
|
||||
write(directory / "result.json", result)
|
||||
|
||||
|
||||
def load(process, name, port, path, concurrency, seconds):
|
||||
result_path = ROOT / name / "result.json"
|
||||
write(ROOT / "request.json", {"name": name, "port": port, "path": path,
|
||||
"concurrency": concurrency, "seconds": seconds})
|
||||
deadline = time.monotonic() + seconds + 40
|
||||
while not result_path.exists() and time.monotonic() < deadline:
|
||||
process.sample()
|
||||
time.sleep(0.2)
|
||||
result = json.loads(result_path.read_text())
|
||||
if result["exit_code"] == 0:
|
||||
result["measurement"] = json.loads(result["stdout"])
|
||||
return result
|
||||
|
||||
|
||||
def probe(port):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
||||
try:
|
||||
connection.request("GET", "/json")
|
||||
response = connection.getresponse()
|
||||
return response.status == 200 and response.read() == b'{"ok":true}'
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def command(language, capacity=2048):
|
||||
if language == "dever":
|
||||
return [ROOT / "bin" / ("dever-http" if capacity == 2048 else "dever-overload")]
|
||||
if language == "rust":
|
||||
return [ROOT / "bin/rust-http", "serve", capacity]
|
||||
binary = {"go": "go-http", "axum": "axum-http", "fiber": "fiber-http"}[language]
|
||||
return [ROOT / "bin" / binary, capacity]
|
||||
|
||||
|
||||
def measure(language, path, concurrency, seconds, name):
|
||||
with Process(command(language), ROOT / name / "server", cpus={0, 1}) as process:
|
||||
port = process.ready()
|
||||
if not probe(port):
|
||||
raise RuntimeError(f"smoke failed: {name}")
|
||||
warmup = load(process, name + "/warmup", port, path, concurrency, 2)
|
||||
process.samples.clear()
|
||||
before = counters()
|
||||
result = load(process, name + "/load", port, path, concurrency, seconds)
|
||||
after = counters()
|
||||
recovered = probe(port)
|
||||
record = {"name": name, "language": language, "path": path,
|
||||
"concurrency": concurrency, "seconds": seconds,
|
||||
"warmup": warmup, "client": result,
|
||||
"server": summarize(process.samples), "samples": process.samples,
|
||||
"server_alive": process.child.poll() is None, "recovery_probe": recovered,
|
||||
"stderr": (process.directory / "stderr.log").read_text(),
|
||||
"cgroup_before": before, "cgroup_after": after}
|
||||
write(ROOT / name / "result.json", record)
|
||||
with (ROOT / "results.jsonl").open("a") as output:
|
||||
output.write(json.dumps(record) + "\n")
|
||||
print(json.dumps({"name": name, "measurement": result.get("measurement"),
|
||||
"rss_mib": record["server"]["rss_high_water_bytes"] / 1048576,
|
||||
"server_cores": record["server"]["cpu_cores_sampled"],
|
||||
"client_cores": result["resources"]["cpu_cores_sampled"],
|
||||
"recovery": recovered}), flush=True)
|
||||
for phase in (warmup, result):
|
||||
measurement = phase.get("measurement", {})
|
||||
if phase["exit_code"] != 0 or measurement.get("errors") != 0 or not measurement.get("succeeded"):
|
||||
raise RuntimeError(f"load failed; retained result: {name}")
|
||||
if not record["server_alive"] or not recovered:
|
||||
raise RuntimeError(f"server did not recover: {name}")
|
||||
|
||||
|
||||
def overload(language):
|
||||
name = "overload/" + language
|
||||
with Process(command(language, 8192), ROOT / name / "server", cpus={0, 1}) as process:
|
||||
port = process.ready()
|
||||
connections = []
|
||||
successful = 0
|
||||
failed = 0
|
||||
started = time.monotonic()
|
||||
try:
|
||||
for _ in range(4500):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
|
||||
connections.append(connection)
|
||||
connection.request("GET", "/json")
|
||||
response = connection.getresponse()
|
||||
if response.status != 200 or response.read() != b'{"ok":true}':
|
||||
failed += 1
|
||||
break
|
||||
successful += 1
|
||||
except (OSError, http.client.HTTPException):
|
||||
failed += 1
|
||||
held_seconds = time.monotonic() - started
|
||||
process.sample()
|
||||
for connection in connections:
|
||||
connection.close()
|
||||
time.sleep(6)
|
||||
recovered = probe(port)
|
||||
record = {"language": language, "connections_target": 4500,
|
||||
"successful_connections": successful, "failed": failed,
|
||||
"open_seconds": held_seconds, "server_alive": process.child.poll() is None,
|
||||
"recovered_after_release": recovered, "stdout": process.text(),
|
||||
"stderr": (process.directory / "stderr.log").read_text(), "samples": process.samples}
|
||||
write(ROOT / name / "result.json", record)
|
||||
print(json.dumps(record), flush=True)
|
||||
|
||||
|
||||
def server(mode, suite):
|
||||
languages = ["dever", "axum", "fiber"] if suite == "frameworks" else ["dever", "rust", "go"]
|
||||
if mode == "smoke":
|
||||
for language in languages:
|
||||
for path in ("/json", "/echo"):
|
||||
measure(language, path, 4, 1, f"smoke/{language}/{path[1:]}")
|
||||
return
|
||||
manifest = {"suite": suite, "languages": languages,
|
||||
"protocol": "HTTP/1.1", "client": "closed loop; one outstanding request per connection",
|
||||
"server_cpus": [0, 1], "client_cpus": [2, 3, 4, 5],
|
||||
"replicates": 3, "seconds": 12, "warmup_seconds": 2,
|
||||
"server_capacity": 2048, "concurrency": [64, 256, 1024],
|
||||
"paths": {"/json": "GET; 11-byte JSON", "/echo": "POST; 65536-byte body returned unchanged"},
|
||||
"hashes": {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in (ROOT / "bin").iterdir()}}
|
||||
write(ROOT / "manifest.json", manifest)
|
||||
for replicate in range(3):
|
||||
for path in ("/json", "/echo"):
|
||||
for concurrency in (64, 256, 1024):
|
||||
for language in languages[replicate:] + languages[:replicate]:
|
||||
name = f"matrix/{replicate}/{path[1:]}/{concurrency}/{language}"
|
||||
measure(language, path, concurrency, 12, name)
|
||||
for language in languages:
|
||||
measure(language, "/json", 1024, 60, "sustained/" + language)
|
||||
if suite == "core":
|
||||
overload(language)
|
||||
(ROOT / "finished").touch()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("role", choices=("server", "client"))
|
||||
parser.add_argument("--mode", choices=("smoke", "matrix"), default="matrix")
|
||||
parser.add_argument("--suite", choices=("core", "frameworks"), default="core")
|
||||
arguments = parser.parse_args()
|
||||
if arguments.role == "client":
|
||||
client()
|
||||
else:
|
||||
try:
|
||||
server(arguments.mode, arguments.suite)
|
||||
finally:
|
||||
(ROOT / "finished").touch()
|
||||
@@ -0,0 +1,13 @@
|
||||
type Identity {
|
||||
id: Text
|
||||
user_id: model.id?
|
||||
tenant_id: model.id?
|
||||
}
|
||||
|
||||
verify(claims: dever.auth.Claims) (identity: Identity) {
|
||||
identity = Identity {
|
||||
id = claims.subject
|
||||
user_id = null
|
||||
tenant_id = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
global type Auth {
|
||||
label: Text(1, 64)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
public post echo = app.echo
|
||||
|
||||
public post raw = app.raw
|
||||
@@ -0,0 +1,19 @@
|
||||
type Row {
|
||||
id: Int
|
||||
text: Text
|
||||
weight: Float
|
||||
note: Text?
|
||||
}
|
||||
|
||||
type Payload {
|
||||
rows: List<Row>
|
||||
raw: Json
|
||||
}
|
||||
|
||||
echo(payload: Payload) (value: Payload) {
|
||||
value = payload
|
||||
}
|
||||
|
||||
raw(source: Json) (value: Json) {
|
||||
value = source
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use dever_backend_bridge::{Target, emit_object};
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::config::Settings;
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
let (project, output, ir) = match arguments.as_slice() {
|
||||
[project, output] => (project, output, None),
|
||||
[project, output, ir] => (project, output, Some(ir)),
|
||||
_ => {
|
||||
return Err("usage: api-json-object <project> <new-object-file> [new-ir-file]".into());
|
||||
}
|
||||
};
|
||||
let project = Path::new(project);
|
||||
let sources =
|
||||
SourceMap::load(&project.join("module")).map_err(|errors| format!("{errors:?}"))?;
|
||||
let settings = Settings::load_project(project).map_err(|error| error.to_string())?;
|
||||
let program = dever_core::check_with_settings(&sources, &settings).map_err(|errors| {
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
})?;
|
||||
let errors = program.application_errors()?;
|
||||
if !errors.is_empty() {
|
||||
return Err(errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>());
|
||||
}
|
||||
let module = dever_core::llvm::emit_executable(&program, &sources)?;
|
||||
if let Some(ir) = ir {
|
||||
let mut ir = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(ir)
|
||||
.map_err(|error| error.to_string())?;
|
||||
ir.write_all(module.as_bytes())
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let object = emit_object(&module, Target::LinuxX86_64)?;
|
||||
let mut output = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(output)
|
||||
.map_err(|error| error.to_string())?;
|
||||
output.write_all(&object).map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
ID int64 `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Weight float64 `json:"weight"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Rows []row `json:"rows"`
|
||||
Raw json.RawMessage `json:"raw"`
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func handle(writer http.ResponseWriter, request *http.Request) {
|
||||
typed := request.URL.Path == "/benchmark/echo/echo"
|
||||
if request.Method != http.MethodPost || (!typed && request.URL.Path != "/benchmark/echo/raw") {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(writer, request.Body, 16*1024*1024)
|
||||
defer body.Close()
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var result any
|
||||
var err error
|
||||
if typed {
|
||||
var input struct {
|
||||
Payload payload `json:"payload"`
|
||||
}
|
||||
err = decoder.Decode(&input)
|
||||
result = input.Payload
|
||||
} else {
|
||||
var input struct {
|
||||
Source json.RawMessage `json:"source"`
|
||||
}
|
||||
err = decoder.Decode(&input)
|
||||
result = input.Source
|
||||
}
|
||||
var extra any
|
||||
if err != nil || decoder.Decode(&extra) != io.EOF {
|
||||
http.Error(writer, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
encoded, err := json.Marshal(envelope{0, "ok", result})
|
||||
if err != nil {
|
||||
http.Error(writer, "invalid response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Write(encoded)
|
||||
}
|
||||
|
||||
func run() error {
|
||||
runtime.GOMAXPROCS(1)
|
||||
content, err := os.ReadFile("config/setting.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var settings struct {
|
||||
HTTP struct {
|
||||
Port uint16 `json:"port"`
|
||||
} `json:"http"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", settings.HTTP.Port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server := &http.Server{Handler: http.HandlerFunc(handle), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 10 * time.Second}
|
||||
stopping := make(chan os.Signal, 1)
|
||||
signal.Notify(stopping, syscall.SIGTERM)
|
||||
defer signal.Stop(stopping)
|
||||
closed := make(chan error, 1)
|
||||
go func() {
|
||||
<-stopping
|
||||
context, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
closed <- server.Shutdown(context)
|
||||
}()
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return <-closed
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::{
|
||||
Request, Response, StatusCode, body::Incoming, server::conn::http1, service::service_fn,
|
||||
};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Row {
|
||||
id: i64,
|
||||
text: String,
|
||||
weight: f64,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Payload {
|
||||
rows: Vec<Row>,
|
||||
raw: Box<RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TypedRequest {
|
||||
payload: Payload,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawRequest {
|
||||
source: Box<RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Envelope<Value> {
|
||||
code: u8,
|
||||
message: &'static str,
|
||||
data: Value,
|
||||
}
|
||||
|
||||
async fn handle(request: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
let typed = request.uri().path() == "/benchmark/echo/echo";
|
||||
if request.method() != hyper::Method::POST
|
||||
|| !typed && request.uri().path() != "/benchmark/echo/raw"
|
||||
{
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Full::new(Bytes::new()))
|
||||
.unwrap());
|
||||
}
|
||||
let body = Limited::new(request.into_body(), 16 * 1024 * 1024)
|
||||
.collect()
|
||||
.await;
|
||||
let encoded = body.map_err(|error| error.to_string()).and_then(|body| {
|
||||
let bytes = body.to_bytes();
|
||||
if typed {
|
||||
serde_json::from_slice::<TypedRequest>(&bytes).and_then(|value| {
|
||||
serde_json::to_vec(&Envelope {
|
||||
code: 0,
|
||||
message: "ok",
|
||||
data: value.payload,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
serde_json::from_slice::<RawRequest>(&bytes).and_then(|value| {
|
||||
serde_json::to_vec(&Envelope {
|
||||
code: 0,
|
||||
message: "ok",
|
||||
data: value.source,
|
||||
})
|
||||
})
|
||||
}
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
let (status, body) = match encoded {
|
||||
Ok(body) => (StatusCode::OK, body),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, Vec::new()),
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Full::new(Bytes::from(body)))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()?.block_on(async {
|
||||
let settings: serde_json::Value = serde_json::from_slice(&std::fs::read("config/setting.json")?)?;
|
||||
let port = u16::try_from(settings["http"]["port"].as_u64().ok_or("missing port")?)?;
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).await?;
|
||||
let mut terminate = signal(SignalKind::terminate())?;
|
||||
let (shutdown, stopping) = watch::channel(false);
|
||||
let mut connections = JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = terminate.recv() => break,
|
||||
Some(result) = connections.join_next(), if !connections.is_empty() => { result?; },
|
||||
accepted = listener.accept() => {
|
||||
let (stream, _) = accepted?;
|
||||
stream.set_nodelay(true)?;
|
||||
let mut stopping = stopping.clone();
|
||||
connections.spawn(async move {
|
||||
let connection = http1::Builder::new().serve_connection(TokioIo::new(stream), service_fn(handle));
|
||||
tokio::pin!(connection);
|
||||
tokio::select! {
|
||||
_ = &mut connection => {},
|
||||
_ = stopping.changed() => {
|
||||
connection.as_mut().graceful_shutdown();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), connection).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
shutdown.send(true)?;
|
||||
while let Some(result) = connections.join_next().await { result?; }
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use dever_runtime::api;
|
||||
use dever_runtime::application::Session;
|
||||
use dever_runtime::auth;
|
||||
use dever_runtime::config::RuntimeProfile;
|
||||
use dever_runtime::http::{Request, Response};
|
||||
use dever_runtime::wire::decode::{self, Fields, Reader};
|
||||
use dever_runtime::wire::{Encoded, Encoder};
|
||||
use dever_runtime::{lifecycle, task};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Row {
|
||||
id: i64,
|
||||
text: String,
|
||||
weight: f64,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
impl Fields for Row {
|
||||
fn count(&self) -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
fn index(&self, name: &str) -> Result<usize, String> {
|
||||
match name {
|
||||
"id" => Ok(0),
|
||||
"text" => Ok(1),
|
||||
"weight" => Ok(2),
|
||||
"note" => Ok(3),
|
||||
_ => Err("unknown wire record field".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(&self, index: usize) -> bool {
|
||||
index != 3
|
||||
}
|
||||
|
||||
fn read(&mut self, index: usize, reader: &mut Reader<'_, '_>) -> Result<(), String> {
|
||||
match index {
|
||||
0 => self.id = reader.int()?,
|
||||
1 => self.text = reader.text()?.into_owned(),
|
||||
2 => self.weight = reader.float()?,
|
||||
3 => {
|
||||
reader.nullable(&mut |reader| {
|
||||
self.note = Some(reader.text()?.into_owned());
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
_ => return Err("invalid wire field index".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Payload {
|
||||
rows: Vec<Row>,
|
||||
raw: Option<Encoded>,
|
||||
}
|
||||
|
||||
impl Fields for Payload {
|
||||
fn count(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn index(&self, name: &str) -> Result<usize, String> {
|
||||
match name {
|
||||
"rows" => Ok(0),
|
||||
"raw" => Ok(1),
|
||||
_ => Err("unknown wire record field".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(&self, _: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn read(&mut self, index: usize, reader: &mut Reader<'_, '_>) -> Result<(), String> {
|
||||
match index {
|
||||
0 => reader.list(&mut |reader| {
|
||||
let mut row = Row::default();
|
||||
reader.record(&mut row)?;
|
||||
self.rows.push(row);
|
||||
Ok(())
|
||||
}),
|
||||
1 => {
|
||||
self.raw = Some(reader.json()?);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("invalid wire field index".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Payload {
|
||||
fn encode(self) -> Result<Encoded, String> {
|
||||
let mut encoder = Encoder::default();
|
||||
encoder.compiled_record(2, |encoder| {
|
||||
encoder.compiled_key("\"rows\":")?;
|
||||
encoder.begin_array()?;
|
||||
for row in self.rows {
|
||||
encoder.compiled_record(4, |encoder| {
|
||||
encoder.compiled_key("\"id\":")?;
|
||||
encoder.int(row.id)?;
|
||||
encoder.compiled_key(",\"text\":")?;
|
||||
encoder.text(&row.text)?;
|
||||
encoder.compiled_key(",\"weight\":")?;
|
||||
encoder.float(row.weight)?;
|
||||
encoder.compiled_key(",\"note\":")?;
|
||||
match row.note {
|
||||
Some(note) => encoder.text(¬e),
|
||||
None => encoder.null(),
|
||||
}
|
||||
})?;
|
||||
}
|
||||
encoder.end()?;
|
||||
encoder.compiled_key(",\"raw\":")?;
|
||||
encoder.encoded(&self.raw.ok_or("missing required wire field")?)
|
||||
})?;
|
||||
encoder.finish()
|
||||
}
|
||||
}
|
||||
|
||||
enum Value {
|
||||
Typed(Payload),
|
||||
Raw(Encoded),
|
||||
}
|
||||
|
||||
struct Arguments {
|
||||
typed: bool,
|
||||
value: Option<Value>,
|
||||
}
|
||||
|
||||
impl Fields for Arguments {
|
||||
fn count(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn index(&self, name: &str) -> Result<usize, String> {
|
||||
if name == if self.typed { "payload" } else { "source" } {
|
||||
Ok(0)
|
||||
} else {
|
||||
Err("unknown wire record field".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn required(&self, _: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn read(&mut self, _: usize, reader: &mut Reader<'_, '_>) -> Result<(), String> {
|
||||
self.value = Some(if self.typed {
|
||||
let mut payload = Payload::default();
|
||||
reader.record(&mut payload)?;
|
||||
Value::Typed(payload)
|
||||
} else {
|
||||
Value::Raw(reader.json()?)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(mut request: Request, typed: bool) -> Response {
|
||||
if let Err(error) = auth::require_components(&[], &[]).await {
|
||||
return api::auth_error(error);
|
||||
}
|
||||
if let Err(error) = api::upload::read_body(&mut request).await {
|
||||
return api::invalid_input(error);
|
||||
}
|
||||
let body = match api::Inputs::json_body(&request) {
|
||||
Ok(body) => body,
|
||||
Err(error) => return api::invalid_input(error),
|
||||
};
|
||||
let mut arguments = Arguments { typed, value: None };
|
||||
if let Err(error) = decode::read(body, &mut |reader| reader.record(&mut arguments)) {
|
||||
return api::invalid_input(api::InputError(error));
|
||||
}
|
||||
let encoded = match arguments.value {
|
||||
Some(Value::Typed(payload)) => payload.encode(),
|
||||
Some(Value::Raw(value)) => Ok(value),
|
||||
None => Err("missing required wire field".into()),
|
||||
};
|
||||
match encoded {
|
||||
Ok(value) => {
|
||||
if api::commit_response_metadata().is_err() {
|
||||
return api::internal_error();
|
||||
}
|
||||
api::success(value)
|
||||
}
|
||||
Err(_) => api::internal_error(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn route(request: Request) -> Result<Response, String> {
|
||||
let typed = match request.target.split('?').next().unwrap_or("") {
|
||||
"/benchmark/echo/echo" => true,
|
||||
"/benchmark/echo/raw" => false,
|
||||
_ => return Ok(api::not_found()),
|
||||
};
|
||||
if request.method != "POST" {
|
||||
return Ok(api::method_not_allowed(&["POST"]));
|
||||
}
|
||||
let prepared = match auth::prepare(&[], &request, true) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return Ok(api::auth_error(error)),
|
||||
};
|
||||
let identity = prepared.claims().map(|claims| auth::Identity {
|
||||
id: claims.subject.clone(),
|
||||
user_id: None,
|
||||
tenant_id: None,
|
||||
});
|
||||
Ok(
|
||||
match auth::scope(prepared, identity, call(request, typed)).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => api::auth_error(error),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let session = Session::load(
|
||||
RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
&[(None, "benchmark", false)],
|
||||
&[],
|
||||
)?;
|
||||
session.initialize_authorization(false)?;
|
||||
let cleanup = session.clone();
|
||||
task::run_entry_with_typed_cleanup(
|
||||
task::RuntimeConfig::default(),
|
||||
session.clone().scope(async move {
|
||||
session
|
||||
.databases()
|
||||
.prepare()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
lifecycle::configure(true, false)?;
|
||||
lifecycle::run(api::serve(route)).await
|
||||
}),
|
||||
|result| async move {
|
||||
let closed = cleanup.close().await;
|
||||
result.and(closed)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "runtime.h"
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
extern long long dever_test_allocations(void);
|
||||
|
||||
static void text_clone(const void *source, void *target) {
|
||||
*(void **)target = dever_rt_v1_text_retain(*(void *const *)source);
|
||||
}
|
||||
|
||||
static void text_drop(void *value) {
|
||||
dever_rt_v1_text_release(*(void **)value);
|
||||
*(void **)value = NULL;
|
||||
}
|
||||
|
||||
static uint8_t text_equal(const void *left, const void *right) {
|
||||
return dever_rt_v1_text_equal(*(void *const *)left, *(void *const *)right);
|
||||
}
|
||||
|
||||
static const DeverRtType text_type = {
|
||||
sizeof(void *), _Alignof(void *), text_clone, text_drop, text_equal, NULL
|
||||
};
|
||||
|
||||
static uint32_t input_field(void *inputs, void *out, DeverRtBuffer *error) {
|
||||
const DeverRtWireName name = {(const uint8_t *)"source", 6};
|
||||
uint8_t present = 0;
|
||||
return dever_rt_v1_cmd_input_field(inputs, &name, 1, out, &present, error);
|
||||
}
|
||||
|
||||
static uint32_t decode(const void *reader, void *out, DeverRtBuffer *error) {
|
||||
return dever_rt_v1_wire_read_raw(reader, out, error);
|
||||
}
|
||||
|
||||
static uint32_t encode(const void *value, void *encoder, DeverRtBuffer *error) {
|
||||
return dever_rt_v1_wire_encoder_json(encoder, *(void *const *)value, error);
|
||||
}
|
||||
|
||||
static uint32_t operation(unsigned phase, void *body, void *source, void **out, DeverRtBuffer *error) {
|
||||
if (phase == 0) return dever_rt_v1_cmd_decode(body, &text_type, input_field, out, error);
|
||||
if (phase == 1) return dever_rt_v1_wire_decode(source, &text_type, decode, out, error);
|
||||
if (phase == 2) return dever_rt_v1_wire_encode(&source, &text_type, encode, out, error);
|
||||
return dever_rt_v1_json_from_text(source, out, error);
|
||||
}
|
||||
|
||||
int dever_test_run(void) {
|
||||
const char *setting = getenv("DEVER_WIRE_BENCH_ROWS");
|
||||
size_t rows = setting == NULL ? 128 : (size_t)strtoul(setting, NULL, 10);
|
||||
int wide = getenv("DEVER_WIRE_BENCH_WIDE") != NULL;
|
||||
if (rows != 1 && rows != 16 && rows != 128 && !(wide && rows == 1024)) return 1;
|
||||
const char *row = "{\"id\":9007199254740993,\"text\":\"雪😀\\n\\\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"weight\":1.25,\"note\":null}";
|
||||
const char *suffix = "],\"raw\":{\"large\":123456789012345678901234567890}}";
|
||||
size_t capacity = rows * (strlen(row) + 1) + strlen(suffix) + 64;
|
||||
char *raw = malloc(capacity);
|
||||
if (raw == NULL) return 2;
|
||||
strcpy(raw, wide ? "{" : "{\"rows\":[");
|
||||
for (size_t index = 0; index < rows; ++index) {
|
||||
if (index != 0) strcat(raw, ",");
|
||||
if (wide) {
|
||||
char field[64];
|
||||
snprintf(field, sizeof(field), "\"field_%zu\":%zu", index, index);
|
||||
strcat(raw, field);
|
||||
} else {
|
||||
strcat(raw, row);
|
||||
}
|
||||
}
|
||||
strcat(raw, wide ? "}" : suffix);
|
||||
size_t length = strlen(raw);
|
||||
DeverRtBuffer error = {0};
|
||||
void *source = NULL, *body = NULL;
|
||||
if (dever_rt_v1_text_new((const uint8_t *)raw, length, &source, &error)) return 3;
|
||||
if (getenv("DEVER_WIRE_BENCH_VALIDATED") != NULL) {
|
||||
void *validated = NULL;
|
||||
if (dever_rt_v1_wire_decode(source, &text_type, decode, &validated, &error)) return 11;
|
||||
dever_rt_v1_text_release(source);
|
||||
source = validated;
|
||||
}
|
||||
char *request = malloc(length + 12);
|
||||
if (request == NULL) return 4;
|
||||
snprintf(request, length + 12, "{\"source\":%s}", raw);
|
||||
if (dever_rt_v1_text_new((const uint8_t *)request, strlen(request), &body, &error)) return 5;
|
||||
free(request);
|
||||
for (unsigned phase = 0; phase < 4; ++phase) {
|
||||
void *output = NULL;
|
||||
if (operation(phase, body, source, &output, &error)) return 6;
|
||||
DeverRtBuffer bytes = {0};
|
||||
if (dever_rt_v1_text_export(output, &bytes) || bytes.len != length || memcmp(bytes.ptr, raw, length) != 0) return 7;
|
||||
dever_rt_v1_buffer_free(bytes.ptr, bytes.len);
|
||||
dever_rt_v1_text_release(output);
|
||||
struct timespec started, finished;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &started)) return 8;
|
||||
long long allocations = dever_test_allocations();
|
||||
const size_t iterations = 500;
|
||||
for (size_t iteration = 0; iteration < iterations; ++iteration) {
|
||||
if (operation(phase, body, source, &output, &error)) return 9;
|
||||
dever_rt_v1_text_release(output);
|
||||
}
|
||||
allocations = dever_test_allocations() - allocations;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &finished)) return 10;
|
||||
int64_t elapsed = (int64_t)(finished.tv_sec - started.tv_sec) * INT64_C(1000000000)
|
||||
+ finished.tv_nsec - started.tv_nsec;
|
||||
printf("{\"phase\":%u,\"rows\":%zu,\"iterations\":%zu,\"elapsed_ns\":%lld,\"allocations\":%lld}\n",
|
||||
phase, rows, iterations, (long long)elapsed, allocations);
|
||||
}
|
||||
dever_rt_v1_text_release(source);
|
||||
dever_rt_v1_text_release(body);
|
||||
free(raw);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "runtime.h"
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
extern long long dever_test_allocations(void);
|
||||
|
||||
#ifndef WIRE_TEXT_KEY
|
||||
#define WIRE_TEXT_KEY "text"
|
||||
#endif
|
||||
#ifndef WIRE_NUMBER_KEY
|
||||
#define WIRE_NUMBER_KEY "number"
|
||||
#endif
|
||||
#ifndef WIRE_RECORD
|
||||
#define WIRE_RECORD 0
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
void *text;
|
||||
int64_t rows;
|
||||
} Payload;
|
||||
|
||||
#if WIRE_RECORD != 2
|
||||
static const DeverRtWireName fields[] = {
|
||||
{(const uint8_t *)WIRE_TEXT_KEY, sizeof(WIRE_TEXT_KEY) - 1},
|
||||
{(const uint8_t *)WIRE_NUMBER_KEY, sizeof(WIRE_NUMBER_KEY) - 1}
|
||||
};
|
||||
#endif
|
||||
|
||||
static void payload_clone(const void *source, void *destination) {
|
||||
const Payload *input = source;
|
||||
Payload *output = destination;
|
||||
*output = *input;
|
||||
output->text = dever_rt_v1_text_retain(input->text);
|
||||
}
|
||||
|
||||
static void payload_drop(void *value) {
|
||||
Payload *payload = value;
|
||||
dever_rt_v1_text_release(payload->text);
|
||||
payload->text = NULL;
|
||||
}
|
||||
|
||||
static uint8_t payload_equal(const void *left, const void *right) {
|
||||
const Payload *first = left;
|
||||
const Payload *second = right;
|
||||
return first->rows == second->rows && dever_rt_v1_text_equal(first->text, second->text);
|
||||
}
|
||||
|
||||
static const DeverRtType payload_type = {
|
||||
sizeof(Payload), _Alignof(Payload), payload_clone, payload_drop, payload_equal, NULL
|
||||
};
|
||||
|
||||
#if WIRE_RECORD == 2
|
||||
static const DeverRtWireName prefixes[] = {
|
||||
{(const uint8_t *)"\"" WIRE_TEXT_KEY "\":", sizeof("\"" WIRE_TEXT_KEY "\":") - 1},
|
||||
{(const uint8_t *)",\"" WIRE_NUMBER_KEY "\":", sizeof(",\"" WIRE_NUMBER_KEY "\":") - 1}
|
||||
};
|
||||
static uint32_t encode_fields(const void *value, void *writer, DeverRtBuffer *error) {
|
||||
const Payload *payload = value;
|
||||
uint32_t status = dever_rt_v1_wire_encoder_compiled_key(writer, &prefixes[0], error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_text(writer, payload->text, error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_compiled_key(writer, &prefixes[1], error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_int(writer, INT64_C(9007199254740993), error);
|
||||
return status;
|
||||
}
|
||||
#elif WIRE_RECORD
|
||||
static uint32_t encode_field(uint64_t index, const void *value, void *writer, DeverRtBuffer *error) {
|
||||
const Payload *payload = value;
|
||||
if (index == 0) return dever_rt_v1_wire_encoder_text(writer, payload->text, error);
|
||||
if (index == 1) return dever_rt_v1_wire_encoder_int(writer, INT64_C(9007199254740993), error);
|
||||
return 2;
|
||||
}
|
||||
#endif
|
||||
|
||||
static uint32_t encode(const void *value, void *writer, DeverRtBuffer *error) {
|
||||
const Payload *payload = value;
|
||||
uint32_t status = dever_rt_v1_wire_encoder_begin_array(writer, error);
|
||||
for (int64_t index = 0; index < payload->rows && status == 0; ++index) {
|
||||
#if WIRE_RECORD == 2
|
||||
status = dever_rt_v1_wire_encoder_compiled_record(writer, value, &payload_type, 2, encode_fields, error);
|
||||
#elif WIRE_RECORD
|
||||
status = dever_rt_v1_wire_encoder_record(writer, value, &payload_type, fields, 2, encode_field, error);
|
||||
#else
|
||||
status = dever_rt_v1_wire_encoder_begin_object(writer, error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_key(writer, &fields[0], error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_text(writer, payload->text, error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_key(writer, &fields[1], error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_int(writer, INT64_C(9007199254740993), error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_end(writer, error);
|
||||
#endif
|
||||
}
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_end(writer, error);
|
||||
return status;
|
||||
}
|
||||
|
||||
int dever_test_run(void) {
|
||||
const char *configured = getenv("DEVER_WIRE_BENCH_ROWS");
|
||||
int64_t rows = configured == NULL ? 128 : strtoll(configured, NULL, 10);
|
||||
if (rows != 4 && rows != 128) return 1;
|
||||
const char *text = "Unicode 雪😀 and newline\n";
|
||||
const char *row = "{\"" WIRE_TEXT_KEY "\":\"Unicode 雪😀 and newline\\n\",\"" WIRE_NUMBER_KEY "\":9007199254740993}";
|
||||
Payload payload = {NULL, rows};
|
||||
DeverRtBuffer error = {0};
|
||||
if (dever_rt_v1_text_new((const uint8_t *)text, strlen(text), &payload.text, &error)) return 2;
|
||||
void *output = NULL;
|
||||
if (dever_rt_v1_wire_encode(&payload, &payload_type, encode, &output, &error)) return 3;
|
||||
DeverRtBuffer bytes = {0};
|
||||
if (dever_rt_v1_text_export(output, &bytes)) return 4;
|
||||
size_t row_length = strlen(row);
|
||||
if (bytes.len != 1 + (row_length + 1) * (size_t)rows || bytes.ptr[0] != '[' || bytes.ptr[bytes.len - 1] != ']') return 5;
|
||||
for (int64_t index = 0; index < rows; ++index) {
|
||||
if (memcmp(bytes.ptr + 1 + (size_t)index * (row_length + 1), row, row_length) != 0) return 6;
|
||||
}
|
||||
dever_rt_v1_buffer_free(bytes.ptr, bytes.len);
|
||||
dever_rt_v1_text_release(output);
|
||||
struct timespec started, finished;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &started)) return 7;
|
||||
long long allocations = dever_test_allocations();
|
||||
const int64_t iterations = 500;
|
||||
for (int64_t iteration = 0; iteration < iterations; ++iteration) {
|
||||
if (dever_rt_v1_wire_encode(&payload, &payload_type, encode, &output, &error)) return 8;
|
||||
dever_rt_v1_text_release(output);
|
||||
}
|
||||
allocations = dever_test_allocations() - allocations;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &finished)) return 9;
|
||||
int64_t elapsed = (int64_t)(finished.tv_sec - started.tv_sec) * INT64_C(1000000000)
|
||||
+ finished.tv_nsec - started.tv_nsec;
|
||||
payload_drop(&payload);
|
||||
printf("{\"rows\":%lld,\"iterations\":%lld,\"elapsed_ns\":%lld,\"allocations\":%lld}\n",
|
||||
(long long)rows, (long long)iterations, (long long)elapsed, allocations);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
ready(port: Int) () {
|
||||
dever.io.println("READY|" + int.toText(port))
|
||||
}
|
||||
|
||||
pause(millis: Int) () {
|
||||
dever.task.sleep(millis)
|
||||
}
|
||||
|
||||
report(
|
||||
begin: Int,
|
||||
finish: Int,
|
||||
name: Text,
|
||||
count: Int,
|
||||
checksum: Int
|
||||
) () recover("基准失败输出由运行器检查") {
|
||||
dever.io.println(
|
||||
"SAMPLE|" + name + "|" + int.toText(count) + "|" + int.toText(finish - begin) + "|" + int.toText(
|
||||
checksum
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
long cmd run = app.run
|
||||
|
||||
long cmd continuous = app.continuous
|
||||
@@ -0,0 +1,39 @@
|
||||
type InputResult {
|
||||
Parsed(value: Decimal)
|
||||
error Invalid
|
||||
}
|
||||
|
||||
type State {
|
||||
total: Decimal
|
||||
factor: Decimal
|
||||
}
|
||||
|
||||
type Sample {
|
||||
elapsed_ns: Int
|
||||
checksum: Text
|
||||
}
|
||||
|
||||
parsed(value: Decimal) (result: Decimal) { result = value }
|
||||
parsed(value: null) (result: Decimal) { fail(InputResult.Invalid) }
|
||||
|
||||
step(value: Int, state: State) (next: State) {
|
||||
next = State {
|
||||
total = state.total + decimal.fromInt(value) * state.factor
|
||||
factor = state.factor
|
||||
}
|
||||
}
|
||||
|
||||
run(ticks: Text, initial: Text, factor: Text) (sample: Sample) {
|
||||
bytes = dever.bytes.fromText(ticks)
|
||||
state = State { total = parsed(decimal.parse(initial))
|
||||
factor = parsed(decimal.parse(factor)) }
|
||||
begin = dever.time.monotonicNanos()
|
||||
final = reduce(step, bytes, state)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = decimal.toText(final.total) }
|
||||
}
|
||||
|
||||
continuous(ticks: Text, initial: Text, factor: Text) (samples: List<Sample>) {
|
||||
samples = [run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor)]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use dec::{Context, Decimal128, Rounding};
|
||||
use dever_runtime::number::DecimalValue;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
fn finite(context: &Context<Decimal128>, value: Decimal128) -> Decimal128 {
|
||||
let status = context.status();
|
||||
assert!(!status.division_by_zero() && !status.division_undefined());
|
||||
assert!(!status.overflow() && !status.underflow());
|
||||
assert!(!status.invalid_operation() && !status.invalid_context() && value.is_finite());
|
||||
value
|
||||
}
|
||||
|
||||
fn measure(mode: &str, ticks: &str, initial: &str, factor: &str) -> serde_json::Value {
|
||||
let bytes = black_box(ticks.as_bytes());
|
||||
let (checksum, elapsed) = if mode == "runtime" {
|
||||
let mut total = DecimalValue::parse(initial).expect("initial");
|
||||
let factor = DecimalValue::parse(factor).expect("factor");
|
||||
let begin = Instant::now();
|
||||
for value in bytes {
|
||||
let product = DecimalValue::from_int(i64::from(*value))
|
||||
.checked_mul(factor)
|
||||
.expect("multiply");
|
||||
total = total.checked_add(product).expect("add");
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
(total.to_string(), elapsed)
|
||||
} else {
|
||||
assert_eq!(mode, "decimal128");
|
||||
let mut context = Context::<Decimal128>::default();
|
||||
let mut total = context.parse(initial).expect("initial");
|
||||
assert!(!context.status().inexact() && total.is_finite());
|
||||
context.clear_status();
|
||||
let factor = context.parse(factor).expect("factor");
|
||||
assert!(!context.status().inexact() && factor.is_finite());
|
||||
context.set_rounding(Rounding::HalfEven);
|
||||
let begin = Instant::now();
|
||||
for value in bytes {
|
||||
let item = Decimal128::from(i64::from(*value));
|
||||
context.clear_status();
|
||||
let product = context.mul(item, factor);
|
||||
let product = finite(&context, product);
|
||||
context.clear_status();
|
||||
let sum = context.add(total, product);
|
||||
total = finite(&context, sum);
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
(context.reduce(total).to_string(), elapsed)
|
||||
};
|
||||
serde_json::json!({"checksum": checksum, "elapsed_ns": elapsed.as_nanos()})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let arguments = std::env::args().collect::<Vec<_>>();
|
||||
let input: serde_json::Value = serde_json::from_str(&arguments[3]).expect("input object");
|
||||
let ticks = input["ticks"].as_str().expect("ticks");
|
||||
let initial = input["initial"].as_str().expect("initial");
|
||||
let factor = input["factor"].as_str().expect("factor");
|
||||
let output = match arguments[2].as_str() {
|
||||
"run" => measure(&arguments[1], ticks, initial, factor),
|
||||
"continuous" => serde_json::Value::Array(
|
||||
(0..10)
|
||||
.map(|_| measure(&arguments[1], ticks, initial, factor))
|
||||
.collect(),
|
||||
),
|
||||
_ => panic!("unknown measurement"),
|
||||
};
|
||||
println!("{output}");
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
type Content {
|
||||
body: Bytes
|
||||
kind: Text
|
||||
}
|
||||
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (
|
||||
value: dever.system.Listener
|
||||
) {
|
||||
value = listener
|
||||
}
|
||||
|
||||
listening(result: dever.net.ListenResult.Failed(message)) (value: dever.system.Listener) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
bound(result: dever.net.PortResult.Bound(port)) (value: Int) {
|
||||
value = port
|
||||
}
|
||||
|
||||
bound(result: dever.net.PortResult.Failed(message)) (value: Int) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
paused(result: dever.time.SleepResult.Done) () {}
|
||||
|
||||
paused(result: dever.time.SleepResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
closed(result: dever.io.CloseResult.Closed) () {}
|
||||
|
||||
closed(result: dever.io.CloseResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
serverTls(result: dever.tls.ServerResult.Ready(tls)) (value: dever.system.ServerTls) {
|
||||
value = tls
|
||||
}
|
||||
|
||||
serverTls(result: dever.tls.ServerResult.Failed(message)) (
|
||||
value: dever.system.ServerTls
|
||||
) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
responseValue(content: Content) (result: dever.http.Response) {
|
||||
result = dever.http.Response {
|
||||
status = 200
|
||||
headers = [dever.http.header("Content-Type", content.kind)]
|
||||
body = content.body
|
||||
}
|
||||
}
|
||||
|
||||
route(request: dever.http.Request, large: Bytes) (response: dever.http.Response) {
|
||||
response = responseValue(select(request.target, large))
|
||||
}
|
||||
|
||||
select(path: "/plain", large: Bytes) (result: Content) {
|
||||
result = Content {
|
||||
body = dever.bytes.fromText("hello")
|
||||
kind = "text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
select(path: "/json", large: Bytes) (result: Content) {
|
||||
result = Content {
|
||||
body = dever.bytes.fromText("{\"ok\":true}")
|
||||
kind = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
select(path: "/bytes", large: Bytes) (result: Content) {
|
||||
result = Content {
|
||||
body = large
|
||||
kind = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
select(path: other, large: Bytes) (result: Content) {
|
||||
result = Content {
|
||||
body = dever.bytes.fromText("unknown benchmark path")
|
||||
kind = "text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
serve(
|
||||
listener: dever.system.Listener,
|
||||
tls: null,
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes
|
||||
) () {
|
||||
dever.system.httpServe(route, listener, limits, large)
|
||||
}
|
||||
|
||||
serve(
|
||||
listener: dever.system.Listener,
|
||||
tls: dever.system.ServerTls,
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes
|
||||
) () {
|
||||
dever.system.httpServeTls(route, listener, limits, tls, large)
|
||||
}
|
||||
|
||||
worker(
|
||||
tls: dever.system.ServerTls?,
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes,
|
||||
lifetime: Int
|
||||
) () {
|
||||
listener = listening(result(dever.net.listen("127.0.0.1", 0)))
|
||||
port = bound(result(dever.net.port(listener)))
|
||||
server = run(serve(listener, tls, limits, large))
|
||||
blocking(dever.io.println("READY|" + int.toText(port)))
|
||||
paused(result(dever.task.sleep(lifetime)))
|
||||
closed(result(dever.net.closeListener(listener)))
|
||||
stop(server)
|
||||
}
|
||||
|
||||
port.plain(limits: dever.http.Limits, large: Bytes, lifetime: Int) () {
|
||||
worker(null, limits, large, lifetime)
|
||||
}
|
||||
|
||||
port.secure(
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes,
|
||||
lifetime: Int,
|
||||
certificate: Bytes,
|
||||
key: Bytes
|
||||
) () {
|
||||
worker(
|
||||
serverTls(result(dever.tls.server(certificate, key))),
|
||||
limits,
|
||||
large,
|
||||
lifetime
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
cmd http = app.http
|
||||
|
||||
cmd https = app.https
|
||||
|
||||
cmd http2 = app.http2
|
||||
|
||||
cmd https2 = app.https2
|
||||
@@ -0,0 +1,35 @@
|
||||
type WorkerFailure {
|
||||
error Failed(message: Text)
|
||||
}
|
||||
|
||||
http() (result: Bool) {
|
||||
port.plain(config.limits(), config.largeBody(), config.lifetime())
|
||||
result = true
|
||||
}
|
||||
|
||||
https() (result: Bool) {
|
||||
port.secure(
|
||||
config.limits(),
|
||||
config.largeBody(),
|
||||
config.lifetime(),
|
||||
config.certificate(),
|
||||
config.key()
|
||||
)
|
||||
result = true
|
||||
}
|
||||
|
||||
http2() (result: Bool) {
|
||||
port.plain(config.http2Limits(), config.largeBody(), config.lifetime())
|
||||
result = true
|
||||
}
|
||||
|
||||
https2() (result: Bool) {
|
||||
port.secure(
|
||||
config.http2Limits(),
|
||||
config.largeBody(),
|
||||
config.lifetime(),
|
||||
config.certificate(),
|
||||
config.key()
|
||||
)
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 独立 Axum 路由;用官方 Hyper 入口统一连接、报文和超时上限。
|
||||
use axum::{
|
||||
Router,
|
||||
body::Bytes,
|
||||
extract::DefaultBodyLimit,
|
||||
http::header,
|
||||
routing::{get, post},
|
||||
};
|
||||
use hyper_util::{
|
||||
rt::{TokioIo, TokioTimer},
|
||||
service::TowerToHyperService,
|
||||
};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::{net::TcpListener, sync::Semaphore};
|
||||
|
||||
async fn serve(capacity: usize) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/json",
|
||||
get(async |_: Bytes| {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
"{\"ok\":true}",
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/echo",
|
||||
post(async |body: Bytes| ([(header::CONTENT_TYPE, "application/octet-stream")], body)),
|
||||
)
|
||||
.layer(DefaultBodyLimit::max(65536));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let slots = Arc::new(Semaphore::new(capacity));
|
||||
println!("READY|{}", listener.local_addr()?.port());
|
||||
loop {
|
||||
let permit = slots.clone().acquire_owned().await?;
|
||||
let (stream, _) = listener.accept().await?;
|
||||
stream.set_nodelay(true)?;
|
||||
let service = TowerToHyperService::new(router.clone());
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
let mut builder = hyper::server::conn::http1::Builder::new();
|
||||
builder
|
||||
.timer(TokioTimer::new())
|
||||
.header_read_timeout(Duration::from_secs(5))
|
||||
.max_buf_size(8192);
|
||||
if let Err(error) = builder
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
{
|
||||
if !error.is_incomplete_message() && !error.is_closed() {
|
||||
eprintln!("connection: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let capacity = std::env::args().nth(1).unwrap().parse()?;
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(serve(capacity))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
route(request: dever.http.Request) (response: dever.http.Response) {
|
||||
response = selected(request.target, request.body)
|
||||
}
|
||||
|
||||
selected(path: "/json", body: Bytes) (response: dever.http.Response) {
|
||||
response = dever.http.Response { status = 200, headers = [dever.http.header("content-type", "application/json")], body = dever.bytes.fromText("{\"ok\":true}") }
|
||||
}
|
||||
selected(path: "/echo", body: Bytes) (response: dever.http.Response) {
|
||||
response = dever.http.Response { status = 200, headers = [dever.http.header("content-type", "application/octet-stream")], body = body }
|
||||
}
|
||||
selected(path: other, body: Bytes) (response: dever.http.Response) {
|
||||
response = dever.http.Response { status = 404, headers = [], body = dever.bytes.fromText("") }
|
||||
}
|
||||
|
||||
serve(connections: Int) () {
|
||||
listener = dever.net.listen("127.0.0.1", 0)
|
||||
port = dever.net.port(listener)
|
||||
limits = dever.http.defaultLimits()
|
||||
limits.connections = connections
|
||||
limits.body_bytes = 65536
|
||||
blocking(dever.io.println("READY|" + int.toText(port)))
|
||||
dever.http.serve(route, listener, limits)
|
||||
}
|
||||
|
||||
main() () {
|
||||
serve(2048)
|
||||
}
|
||||
|
||||
overload() () {
|
||||
serve(8192)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type listener struct {
|
||||
net.Listener
|
||||
slots chan struct{}
|
||||
}
|
||||
|
||||
type connection struct {
|
||||
net.Conn
|
||||
slots chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (value *connection) Close() error {
|
||||
err := value.Conn.Close()
|
||||
value.once.Do(func() { <-value.slots })
|
||||
return err
|
||||
}
|
||||
|
||||
func (value *listener) Accept() (net.Conn, error) {
|
||||
value.slots <- struct{}{}
|
||||
stream, err := value.Listener.Accept()
|
||||
if err != nil {
|
||||
<-value.slots
|
||||
return nil, err
|
||||
}
|
||||
return &connection{Conn: stream, slots: value.slots}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
capacity, err := strconv.Atoi(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
runtime.GOMAXPROCS(2)
|
||||
bound, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
server := http.Server{
|
||||
ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second,
|
||||
IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192,
|
||||
Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
defer request.Body.Close()
|
||||
body, err := io.ReadAll(http.MaxBytesReader(writer, request.Body, 65536))
|
||||
if err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch request.URL.RequestURI() {
|
||||
case "/json":
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Write([]byte(`{"ok":true}`))
|
||||
case "/echo":
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Write(body)
|
||||
default:
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}),
|
||||
}
|
||||
fmt.Printf("READY|%d\n", bound.Addr().(*net.TCPAddr).Port)
|
||||
if err := server.Serve(&listener{Listener: bound, slots: make(chan struct{}, capacity)}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 独立 Hyper 服务及三种语言共用的 HTTP/1.1 客户端,不链接 Dever 运行库。
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::{Request, Response, body::Incoming, service::service_fn};
|
||||
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||
use std::{convert::Infallible, sync::Arc, time::Duration};
|
||||
use tokio::{net::TcpListener, sync::{Barrier, Semaphore}, time::Instant};
|
||||
|
||||
async fn route(request: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
let path = request.uri().path().to_owned();
|
||||
let body = match Limited::new(request.into_body(), 65536).collect().await {
|
||||
Ok(body) => body.to_bytes(),
|
||||
Err(_) => return Ok(Response::builder().status(400).body(Full::new(Bytes::new())).unwrap()),
|
||||
};
|
||||
let (status, kind, body) = match path.as_str() {
|
||||
"/json" => (200, "application/json", Bytes::from_static(b"{\"ok\":true}")),
|
||||
"/echo" => (200, "application/octet-stream", body),
|
||||
_ => (404, "text/plain", Bytes::new()),
|
||||
};
|
||||
Ok(Response::builder().status(status).header("content-type", kind).body(Full::new(body)).unwrap())
|
||||
}
|
||||
|
||||
async fn serve(capacity: usize) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let slots = Arc::new(Semaphore::new(capacity));
|
||||
println!("READY|{}", listener.local_addr()?.port());
|
||||
loop {
|
||||
let permit = slots.clone().acquire_owned().await?;
|
||||
let (stream, _) = listener.accept().await?;
|
||||
stream.set_nodelay(true)?;
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
let mut builder = hyper::server::conn::http1::Builder::new();
|
||||
builder.timer(TokioTimer::new()).header_read_timeout(Duration::from_secs(5)).max_buf_size(8192);
|
||||
if let Err(error) = builder.serve_connection(TokioIo::new(stream), service_fn(route)).await {
|
||||
if !error.is_incomplete_message() && !error.is_closed() {
|
||||
eprintln!("connection: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(port: u16, path: String, concurrency: usize, seconds: f64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let payload = if path == "/echo" { Bytes::from(vec![b'x'; 65536]) } else { Bytes::new() };
|
||||
let expected = if path == "/echo" { payload.clone() } else { Bytes::from_static(b"{\"ok\":true}") };
|
||||
let kind = if path == "/echo" { "application/octet-stream" } else { "application/json" };
|
||||
let barrier = Arc::new(Barrier::new(concurrency + 1));
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..concurrency {
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
|
||||
stream.set_nodelay(true)?;
|
||||
let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)).await?;
|
||||
let driver = tokio::spawn(connection);
|
||||
let barrier = barrier.clone();
|
||||
let payload = payload.clone();
|
||||
let expected = expected.clone();
|
||||
let path = path.clone();
|
||||
workers.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(seconds);
|
||||
let mut samples = Vec::new();
|
||||
let mut errors = 0_u64;
|
||||
while Instant::now() < deadline {
|
||||
let request = Request::builder().method(if path == "/echo" { "POST" } else { "GET" })
|
||||
.uri(&path).header("host", "localhost").body(Full::new(payload.clone())).unwrap();
|
||||
let started = Instant::now();
|
||||
let exchange = async {
|
||||
let response = sender.send_request(request).await.map_err(|error| error.to_string())?;
|
||||
if response.status() != 200 || response.headers().get("content-type").and_then(|value| value.to_str().ok()) != Some(kind) {
|
||||
return Err("status or content type differs".to_owned());
|
||||
}
|
||||
let body = response.into_body().collect().await.map_err(|error| error.to_string())?.to_bytes();
|
||||
if body != expected { return Err("response body differs".to_owned()); }
|
||||
Ok(())
|
||||
};
|
||||
match tokio::time::timeout(Duration::from_secs(5), exchange).await {
|
||||
Ok(Ok(())) => samples.push(started.elapsed().as_micros() as u64),
|
||||
result => {
|
||||
eprintln!("request: {result:?}");
|
||||
errors += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(sender);
|
||||
driver.abort();
|
||||
let _ = driver.await;
|
||||
(samples, errors)
|
||||
}));
|
||||
}
|
||||
let started = Instant::now();
|
||||
barrier.wait().await;
|
||||
let mut samples = Vec::new();
|
||||
let mut errors = 0;
|
||||
for worker in workers {
|
||||
let (mut values, failed) = worker.await?;
|
||||
samples.append(&mut values);
|
||||
errors += failed;
|
||||
}
|
||||
let elapsed = started.elapsed().as_secs_f64();
|
||||
samples.sort_unstable();
|
||||
let percentile = |percent: usize| -> Option<f64> {
|
||||
if samples.is_empty() { None } else { Some(samples[(samples.len() * percent).div_ceil(100) - 1] as f64 / 1000.0) }
|
||||
};
|
||||
println!("{}", serde_json::json!({"concurrency":concurrency,"seconds":seconds,"elapsed_seconds":elapsed,
|
||||
"succeeded":samples.len(),"errors":errors,"qps":samples.len() as f64 / elapsed,
|
||||
"p50_ms":percentile(50),"p95_ms":percentile(95),"p99_ms":percentile(99)}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let arguments = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
let server = arguments[0] == "serve";
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(if server { 2 } else { 4 }).enable_all().build()?;
|
||||
if server {
|
||||
runtime.block_on(serve(arguments[1].parse()?))
|
||||
} else {
|
||||
runtime.block_on(load(arguments[1].parse()?, arguments[2].clone(), arguments[3].parse()?, arguments[4].parse()?))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
capacity, err := strconv.Atoi(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
runtime.GOMAXPROCS(2)
|
||||
app := fiber.New(fiber.Config{
|
||||
Concurrency: capacity,
|
||||
BodyLimit: 65536,
|
||||
ReadBufferSize: 8192,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
IdleTimeout: 5 * time.Second,
|
||||
CaseSensitive: true,
|
||||
StrictRouting: true,
|
||||
})
|
||||
app.Get("/json", func(context fiber.Ctx) error {
|
||||
context.Set("Content-Type", "application/json")
|
||||
return context.SendString(`{"ok":true}`)
|
||||
})
|
||||
app.Post("/echo", func(context fiber.Ctx) error {
|
||||
context.Set("Content-Type", "application/octet-stream")
|
||||
return context.Send(context.BodyRaw())
|
||||
})
|
||||
bound, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("READY|%d\n", bound.Addr().(*net.TCPAddr).Port)
|
||||
if err := app.Listener(bound, fiber.ListenConfig{DisableStartupMessage: true}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type settings struct {
|
||||
Workers int `json:"workers"`
|
||||
Connections int `json:"connections"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
LifetimeMS int `json:"lifetime_ms"`
|
||||
Certificate string `json:"cert_path"`
|
||||
Key string `json:"key_path"`
|
||||
}
|
||||
|
||||
type limitedListener struct {
|
||||
net.Listener
|
||||
slots chan struct{}
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type limitedConnection struct {
|
||||
net.Conn
|
||||
slots chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (connection *limitedConnection) Close() error {
|
||||
err := connection.Conn.Close()
|
||||
connection.once.Do(func() { <-connection.slots })
|
||||
return err
|
||||
}
|
||||
|
||||
func (listener *limitedListener) Accept() (net.Conn, error) {
|
||||
connection, err := listener.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case listener.slots <- struct{}{}:
|
||||
return &limitedConnection{Conn: connection, slots: listener.slots}, nil
|
||||
case <-listener.closed:
|
||||
connection.Close()
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (listener *limitedListener) Close() error {
|
||||
listener.once.Do(func() { close(listener.closed) })
|
||||
return listener.Listener.Close()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
if len(os.Args) != 2 || (os.Args[1] != "http" && os.Args[1] != "https") {
|
||||
return errors.New("usage: go_http http|https")
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(filepath.Dir(executable), "config", "setting.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var document struct {
|
||||
Benchmark settings `json:"benchmark"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
config := document.Benchmark
|
||||
if config.Workers < 1 || config.Connections < 1 || config.TimeoutMS < 1 || config.LifetimeMS < 1 {
|
||||
return errors.New("benchmark requires positive workers, connections, timeout_ms and lifetime_ms")
|
||||
}
|
||||
runtime.GOMAXPROCS(config.Workers)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bounded := &limitedListener{Listener: listener, slots: make(chan struct{}, config.Connections), closed: make(chan struct{})}
|
||||
defer bounded.Close()
|
||||
large := bytes.Repeat([]byte{'x'}, 65536)
|
||||
deadline := time.Duration(config.TimeoutMS) * time.Millisecond
|
||||
server := &http.Server{
|
||||
ReadTimeout: deadline, ReadHeaderTimeout: deadline, WriteTimeout: deadline,
|
||||
IdleTimeout: deadline, MaxHeaderBytes: 8192,
|
||||
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){},
|
||||
Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body := http.MaxBytesReader(writer, request.Body, 65536)
|
||||
defer body.Close()
|
||||
if _, err := io.Copy(io.Discard, body); err != nil {
|
||||
http.Error(writer, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch request.URL.RequestURI() {
|
||||
case "/plain":
|
||||
writer.Header().Set("Content-Type", "text/plain")
|
||||
io.WriteString(writer, "hello")
|
||||
case "/json":
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(writer, `{"ok":true}`)
|
||||
case "/bytes":
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Write(large)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}),
|
||||
}
|
||||
timer := time.AfterFunc(time.Duration(config.LifetimeMS)*time.Millisecond, func() { server.Close() })
|
||||
defer timer.Stop()
|
||||
fmt.Printf("READY|%d\n", listener.Addr().(*net.TCPAddr).Port)
|
||||
if os.Args[1] == "https" {
|
||||
err = server.ServeTLS(bounded, config.Certificate, config.Key)
|
||||
} else {
|
||||
err = server.Serve(bounded)
|
||||
}
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
plain(limits: dever.http.Limits, large: Bytes, lifetime: Int) () fails app.WorkerFailure
|
||||
|
||||
secure(
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes,
|
||||
lifetime: Int,
|
||||
certificate: Bytes,
|
||||
key: Bytes
|
||||
) () fails app.WorkerFailure
|
||||
@@ -0,0 +1 @@
|
||||
long cmd run = app.run
|
||||
@@ -0,0 +1,56 @@
|
||||
type State {
|
||||
source: Json
|
||||
update: Bool
|
||||
checksum: Int
|
||||
}
|
||||
|
||||
run(source: Json, update: Bool, ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
initial = State {
|
||||
source = source
|
||||
update = update
|
||||
checksum = 0
|
||||
}
|
||||
output = transform(source, update)
|
||||
begin = dever.time.monotonicNanos()
|
||||
final = reduce(step, input, initial)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = final.checksum
|
||||
output = output
|
||||
}
|
||||
}
|
||||
|
||||
type Sample {
|
||||
elapsed_ns: Int
|
||||
checksum: Int
|
||||
output: Text
|
||||
}
|
||||
|
||||
step(unused: Int, state: State) (next: State) {
|
||||
output = transform(state.source, state.update)
|
||||
next = State {
|
||||
source = state.source
|
||||
update = state.update
|
||||
checksum = state.checksum + length(dever.bytes.fromText(output))
|
||||
}
|
||||
}
|
||||
|
||||
transform(source: Json, update: false) (output: Text) {
|
||||
output = selected(dever.json.propertyValue(source, "data"))
|
||||
}
|
||||
|
||||
transform(source: Json, update: true) (output: Text) {
|
||||
output = dever.json.toText(
|
||||
dever.json.setProperty(source, "active", dever.json.boolean(true))
|
||||
)
|
||||
}
|
||||
|
||||
selected(value: Json) (output: Text) {
|
||||
output = dever.json.toText(value)
|
||||
}
|
||||
|
||||
selected(value: null) (output: Text) {
|
||||
output = "null"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type input struct {
|
||||
Source json.RawMessage `json:"source"`
|
||||
Update bool `json:"update"`
|
||||
Ticks string `json:"ticks"`
|
||||
}
|
||||
|
||||
type sample struct {
|
||||
Elapsed int64 `json:"elapsed_ns"`
|
||||
Checksum int `json:"checksum"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
func transform(source []byte, update bool) []byte {
|
||||
decoder := json.NewDecoder(bytes.NewReader(source))
|
||||
decoder.UseNumber()
|
||||
var object map[string]any
|
||||
if err := decoder.Decode(&object); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var selected any = object["data"]
|
||||
if update {
|
||||
object["active"] = true
|
||||
selected = object
|
||||
}
|
||||
output, err := json.Marshal(selected)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func main() {
|
||||
var arguments input
|
||||
if len(os.Args) != 2 {
|
||||
panic("expected one JSON input")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(os.Args[1]), &arguments); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !json.Valid(arguments.Source) {
|
||||
panic("invalid source")
|
||||
}
|
||||
output := transform(arguments.Source, arguments.Update)
|
||||
checksum := 0
|
||||
begin := time.Now()
|
||||
for range []byte(arguments.Ticks) {
|
||||
checksum += len(transform(arguments.Source, arguments.Update))
|
||||
}
|
||||
result := sample{time.Since(begin).Nanoseconds(), checksum, string(output)}
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(string(encoded))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, value::RawValue};
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Input {
|
||||
source: Box<RawValue>,
|
||||
update: bool,
|
||||
ticks: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Sample {
|
||||
elapsed_ns: u128,
|
||||
checksum: usize,
|
||||
output: String,
|
||||
}
|
||||
|
||||
fn transform(source: &str, update: bool) -> String {
|
||||
let mut value: Value = serde_json::from_str(source).expect("valid JSON input");
|
||||
let object = value.as_object_mut().expect("object input");
|
||||
if update {
|
||||
object.insert("active".into(), Value::Bool(true));
|
||||
serde_json::to_string(&value).expect("serializable object")
|
||||
} else {
|
||||
serde_json::to_string(object.get("data").expect("data field")).expect("serializable value")
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let argument = std::env::args().nth(1).expect("one JSON argument");
|
||||
let input: Input = serde_json::from_str(&argument).expect("valid input fields");
|
||||
let output = transform(input.source.get(), input.update);
|
||||
let mut checksum = 0;
|
||||
let started = Instant::now();
|
||||
for _ in input.ticks.bytes() {
|
||||
checksum += transform(black_box(input.source.get()), input.update).len();
|
||||
}
|
||||
let sample = Sample {
|
||||
elapsed_ns: started.elapsed().as_nanos(),
|
||||
checksum,
|
||||
output,
|
||||
};
|
||||
println!("{}", serde_json::to_string(&sample).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
long cmd record_update = app.recordUpdate
|
||||
|
||||
long cmd continuous_record_update = app.continuousRecordUpdate
|
||||
|
||||
long cmd integer = app.integer
|
||||
|
||||
long cmd record_inspect = app.recordInspect
|
||||
|
||||
long cmd continuous_record_inspect = app.continuousRecordInspect
|
||||
|
||||
long cmd map_lookup = app.mapLookup
|
||||
|
||||
long cmd continuous_map_lookup = app.continuousMapLookup
|
||||
|
||||
long cmd map_text_lookup = app.mapTextLookup
|
||||
|
||||
long cmd continuous_map_text_lookup = app.continuousMapTextLookup
|
||||
|
||||
long cmd text_equal = app.textEqual
|
||||
|
||||
long cmd continuous_text_equal = app.continuousTextEqual
|
||||
|
||||
long cmd text_contains = app.textContains
|
||||
|
||||
long cmd continuous_text_contains = app.continuousTextContains
|
||||
|
||||
long cmd float = app.float
|
||||
|
||||
long cmd bytes_concat = app.bytesConcat
|
||||
|
||||
long cmd text_concat = app.textConcat
|
||||
|
||||
long cmd list = app.list
|
||||
|
||||
long cmd map = app.map
|
||||
|
||||
long cmd list_read = app.listRead
|
||||
|
||||
long cmd map_read = app.mapRead
|
||||
|
||||
long cmd list_record = app.listRecord
|
||||
|
||||
long cmd list_record_read = app.listRecordRead
|
||||
|
||||
long cmd continuous_integer = app.continuousInteger
|
||||
|
||||
long cmd continuous_float = app.continuousFloat
|
||||
|
||||
long cmd continuous_list = app.continuousList
|
||||
|
||||
long cmd continuous_map = app.continuousMap
|
||||
|
||||
long cmd continuous_list_read = app.continuousListRead
|
||||
|
||||
long cmd continuous_map_read = app.continuousMapRead
|
||||
|
||||
long cmd continuous_list_record = app.continuousListRecord
|
||||
|
||||
long cmd continuous_list_record_read = app.continuousListRecordRead
|
||||
|
||||
long cmd continuous_text_concat = app.continuousTextConcat
|
||||
|
||||
long cmd continuous_bytes_concat = app.continuousBytesConcat
|
||||
|
||||
long cmd call_read = app.callRead
|
||||
|
||||
long cmd continuous_call_read = app.continuousCallRead
|
||||
@@ -0,0 +1,393 @@
|
||||
type Lookup {
|
||||
values: Map<Int, Int>
|
||||
index: Int
|
||||
total: Int
|
||||
}
|
||||
|
||||
selected(value: Int) (answer: Int) { answer = value }
|
||||
selected(value: null) (answer: Int) { answer = 0 }
|
||||
|
||||
lookup(value: Int, state: Lookup) (next: Lookup) {
|
||||
found = selected(get(state.values, state.index))
|
||||
next = Lookup { values = state.values
|
||||
index = state.index + 1
|
||||
total = state.total + found }
|
||||
}
|
||||
|
||||
mapLookup(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
values = reduce(putValue, input, {})
|
||||
initial = Lookup { values = values
|
||||
index = 0
|
||||
total = 0 }
|
||||
begin = dever.time.monotonicNanos()
|
||||
final = reduce(lookup, input, initial)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = final.total }
|
||||
}
|
||||
|
||||
continuousMapLookup(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(mapLookup, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
type TextLookup {
|
||||
values: Map<Text, Int>
|
||||
total: Int
|
||||
}
|
||||
|
||||
type TextPair {
|
||||
left: Text
|
||||
right: Text
|
||||
}
|
||||
|
||||
textEntry(entry: MapEntry<Int, Int>, state: Map<Text, Int>) (next: Map<Text, Int>) {
|
||||
next = put(state, "lookup-key-" + int.toText(entry.key), entry.value)
|
||||
}
|
||||
|
||||
textKey(entry: MapEntry<Text, Int>) (key: Text) { key = entry.key }
|
||||
|
||||
textLookup(key: Text, state: TextLookup) (next: TextLookup) {
|
||||
found = selected(get(state.values, key))
|
||||
next = TextLookup { values = state.values
|
||||
total = state.total + found }
|
||||
}
|
||||
|
||||
mapTextLookup(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
values = reduce(textEntry, entries(reduce(putValue, input, {})), {})
|
||||
keys = each(textKey, entries(values))
|
||||
initial = TextLookup { values = values
|
||||
total = 0 }
|
||||
begin = dever.time.monotonicNanos()
|
||||
final = reduce(textLookup, keys, initial)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = final.total + length(keys) - length(input) }
|
||||
}
|
||||
|
||||
continuousMapTextLookup(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(mapTextLookup, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
textPair(value: Int) (pair: TextPair) {
|
||||
left = "compare-value-" + int.toText(value)
|
||||
pair = TextPair { left = left
|
||||
right = text.lower(text.upper(left)) }
|
||||
}
|
||||
|
||||
matchedValue(matched: true) (value: Int) { value = 120 }
|
||||
matchedValue(matched: false) (value: Int) { value = 0 }
|
||||
|
||||
equalTextPair(row: TextPair, total: Int) (next: Int) {
|
||||
next = total + matchedValue(row.left == row.right)
|
||||
}
|
||||
|
||||
containsTextPair(row: TextPair, total: Int) (next: Int) {
|
||||
next = total + matchedValue(text.contains(row.left, row.right))
|
||||
}
|
||||
|
||||
textEqual(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
rows = each(textPair, input)
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = reduce(equalTextPair, rows, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total + length(rows) - length(input) }
|
||||
}
|
||||
|
||||
continuousTextEqual(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(textEqual, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
textContains(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
rows = each(textPair, input)
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = reduce(containsTextPair, rows, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total + length(rows) - length(input) }
|
||||
}
|
||||
|
||||
continuousTextContains(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(textContains, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
type Sample {
|
||||
elapsed_ns: Int
|
||||
checksum: Int
|
||||
}
|
||||
|
||||
type FloatSample {
|
||||
elapsed_ns: Int
|
||||
checksum: Float
|
||||
}
|
||||
|
||||
addFloat(value: Int, state: Float) (next: Float) {
|
||||
next = state + float.fromInt(value)
|
||||
}
|
||||
|
||||
float(ticks: Text) (sample: FloatSample) {
|
||||
bytes = dever.bytes.fromText(ticks)
|
||||
zero = float.fromInt(0)
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = reduce(addFloat, bytes, zero)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = FloatSample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
joinBytes(value: Bytes, state: Bytes) (next: Bytes) {
|
||||
next = dever.bytes.concat(state, value)
|
||||
}
|
||||
|
||||
bytesConcat(ticks: Text) (sample: Sample) {
|
||||
chunks = each(dever.bytes.fromText, text.split(ticks, ""))
|
||||
empty = dever.bytes.fromText("")
|
||||
begin = dever.time.monotonicNanos()
|
||||
output = reduce(joinBytes, chunks, empty)
|
||||
end = dever.time.monotonicNanos()
|
||||
total = reduce(add, output, 0)
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
joinText(value: Text, state: Text) (next: Text) {
|
||||
next = state + value
|
||||
}
|
||||
|
||||
textConcat(ticks: Text) (sample: Sample) {
|
||||
chunks = text.split(ticks, ",")
|
||||
begin = dever.time.monotonicNanos()
|
||||
output = reduce(joinText, chunks, "")
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = reduce(add, dever.bytes.fromText(output), 0)
|
||||
}
|
||||
}
|
||||
|
||||
add(value: Int, state: Int) (next: Int) {
|
||||
next = state + value
|
||||
}
|
||||
|
||||
appendValue(value: Int, state: List<Int>) (next: List<Int>) {
|
||||
next = append(state, value)
|
||||
}
|
||||
|
||||
putValue(value: Int, state: Map<Int, Int>) (next: Map<Int, Int>) {
|
||||
key = length(state)
|
||||
next = put(state, key, value)
|
||||
}
|
||||
|
||||
mapped(entry: MapEntry<Int, Int>) (value: Int) {
|
||||
value = entry.value
|
||||
}
|
||||
|
||||
integer(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
value = reduce(add, input, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = value
|
||||
}
|
||||
}
|
||||
|
||||
list(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
value = reduce(appendValue, input, [])
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = sum(value)
|
||||
}
|
||||
}
|
||||
|
||||
map(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
value = reduce(putValue, input, {})
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = sum(each(mapped, entries(value)))
|
||||
}
|
||||
}
|
||||
|
||||
listRead(ticks: Text) (sample: Sample) {
|
||||
values = reduce(appendValue, dever.bytes.fromText(ticks), [])
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = sum(values)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
mapRead(ticks: Text) (sample: Sample) {
|
||||
values = reduce(putValue, dever.bytes.fromText(ticks), {})
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = sum(mapped, entries(values))
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
type Record {
|
||||
first: Int
|
||||
second: Int
|
||||
third: Int
|
||||
}
|
||||
|
||||
appendRecord(value: Int, state: List<Record>) (next: List<Record>) {
|
||||
next = append(state, Record { first = value
|
||||
second = 0
|
||||
third = 0 })
|
||||
}
|
||||
|
||||
recordValue(row: Record) (value: Int) {
|
||||
value = row.first + row.second + row.third
|
||||
}
|
||||
|
||||
listRecord(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
values = reduce(appendRecord, input, [])
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = sum(recordValue, values) }
|
||||
}
|
||||
|
||||
listRecordRead(ticks: Text) (sample: Sample) {
|
||||
values = reduce(appendRecord, dever.bytes.fromText(ticks), [])
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = sum(recordValue, values)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
continuousInteger(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(integer, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousFloat(ticks: Text) (samples: List<FloatSample>) {
|
||||
samples = each(float, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousList(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(list, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousMap(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(map, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousListRead(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(listRead, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousMapRead(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(mapRead, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousListRecord(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(listRecord, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousListRecordRead(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(listRecordRead, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousTextConcat(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(textConcat, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousBytesConcat(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(bytesConcat, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
type OwnedRecord {
|
||||
name: Text
|
||||
tags: List<Text>
|
||||
count: Int
|
||||
}
|
||||
|
||||
ownedRecord(value: Int) (row: OwnedRecord) {
|
||||
name = "row-value-" + int.toText(value)
|
||||
row = OwnedRecord { name = name
|
||||
tags = [name]
|
||||
count = value - length(name) - 1 }
|
||||
}
|
||||
|
||||
inspectOwned(row: OwnedRecord) (value: Int) {
|
||||
value = row.count + length(row.name) + length(row.tags)
|
||||
}
|
||||
|
||||
forwardOwned(row: OwnedRecord) (value: Int) {
|
||||
value = inspectOwned(row)
|
||||
}
|
||||
|
||||
callRead(ticks: Text) (sample: Sample) {
|
||||
rows = each(ownedRecord, dever.bytes.fromText(ticks))
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = sum(forwardOwned, rows)
|
||||
end = dever.time.monotonicNanos()
|
||||
preserved = length(rows)
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total + preserved - length(rows) }
|
||||
}
|
||||
|
||||
continuousCallRead(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(callRead, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
type Accumulator {
|
||||
values: List<Int>
|
||||
count: Int
|
||||
}
|
||||
|
||||
appendField(value: Int, state: Accumulator) (next: Accumulator) {
|
||||
values = append(state.values, value)
|
||||
next = Accumulator { values = values
|
||||
count = state.count + 1 }
|
||||
}
|
||||
|
||||
recordUpdate(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
state = reduce(appendField, input, Accumulator { values = []
|
||||
count = 0 })
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = sum(state.values) + state.count - length(state.values) }
|
||||
}
|
||||
|
||||
continuousRecordUpdate(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(recordUpdate, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
appendInspected(value: Int, state: Accumulator) (next: Accumulator) {
|
||||
count = length(state.values) + 1
|
||||
values = append(state.values, value)
|
||||
next = Accumulator { values = values
|
||||
count = count }
|
||||
}
|
||||
|
||||
recordInspect(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
state = reduce(appendInspected, input, Accumulator { values = []
|
||||
count = 0 })
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = sum(state.values) + state.count - length(state.values) }
|
||||
}
|
||||
|
||||
continuousRecordInspect(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(recordInspect, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use std::hint::black_box;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Fault {
|
||||
message: String,
|
||||
locations: Vec<&'static str>,
|
||||
causes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Fault {
|
||||
fn overflow(location: &'static str) -> Self {
|
||||
Self {
|
||||
message: "Int overflow".into(),
|
||||
locations: vec![location],
|
||||
causes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn at(mut self, location: &'static str) -> Self {
|
||||
self.locations.push(location);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct Record {
|
||||
first: i64,
|
||||
second: i64,
|
||||
third: i64,
|
||||
}
|
||||
|
||||
struct OwnedRecord {
|
||||
name: String,
|
||||
tags: Vec<String>,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn inspect_owned(row: &OwnedRecord) -> Result<i64, Fault> {
|
||||
let partial = row
|
||||
.count
|
||||
.checked_add(row.name.chars().count() as i64)
|
||||
.ok_or_else(|| Fault::overflow("inspect_owned"))?;
|
||||
partial
|
||||
.checked_add(row.tags.len() as i64)
|
||||
.ok_or_else(|| Fault::overflow("inspect_owned"))
|
||||
}
|
||||
|
||||
fn forward_owned(row: &OwnedRecord) -> Result<i64, Fault> {
|
||||
inspect_owned(row).map_err(|error| error.at("forward_owned"))
|
||||
}
|
||||
|
||||
fn owned_sum(rows: &[OwnedRecord]) -> Result<i64, Fault> {
|
||||
let mut total = 0_i64;
|
||||
for row in rows {
|
||||
let projected = forward_owned(row).map_err(|error| error.at("call_read"))?;
|
||||
total = total
|
||||
.checked_add(projected)
|
||||
.ok_or_else(|| Fault::overflow("call_read"))?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn add(total: i64, value: i64) -> Result<i64, Fault> {
|
||||
total
|
||||
.checked_add(value)
|
||||
.ok_or_else(|| Fault::overflow("add"))
|
||||
}
|
||||
|
||||
fn record_value(row: &Record) -> Result<i64, Fault> {
|
||||
let partial = row
|
||||
.first
|
||||
.checked_add(row.second)
|
||||
.ok_or_else(|| Fault::overflow("record_value"))?;
|
||||
partial
|
||||
.checked_add(row.third)
|
||||
.ok_or_else(|| Fault::overflow("record_value"))
|
||||
}
|
||||
|
||||
fn record_sum(values: &[Record]) -> Result<i64, Fault> {
|
||||
let mut total = 0i64;
|
||||
for row in values {
|
||||
let projected = record_value(row).map_err(|error| error.at("record_sum"))?;
|
||||
total = total
|
||||
.checked_add(projected)
|
||||
.ok_or_else(|| Fault::overflow("record_sum"))?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn measure(operation: &str, ticks: &str) -> Result<(i64, Duration), Fault> {
|
||||
let source = black_box(ticks.as_bytes());
|
||||
match operation {
|
||||
"call_read" => {
|
||||
let rows = source
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let name = format!("row-value-{value}");
|
||||
OwnedRecord {
|
||||
count: i64::from(*value) - name.chars().count() as i64 - 1,
|
||||
tags: vec![name.clone()],
|
||||
name,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let borrowed = black_box(&rows);
|
||||
let begin = Instant::now();
|
||||
let total = owned_sum(borrowed)?;
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box(&rows);
|
||||
Ok((total, elapsed))
|
||||
}
|
||||
"integer" => {
|
||||
let begin = Instant::now();
|
||||
let mut total = 0;
|
||||
for value in source {
|
||||
total = add(total, i64::from(*value)).map_err(|error| error.at("integer"))?;
|
||||
}
|
||||
Ok((black_box(total), begin.elapsed()))
|
||||
}
|
||||
"list_record_read" => {
|
||||
let mut values = Vec::new();
|
||||
for value in source {
|
||||
values.push(Record {
|
||||
first: i64::from(*value),
|
||||
second: 0,
|
||||
third: 0,
|
||||
});
|
||||
}
|
||||
let borrowed = black_box(&values);
|
||||
let begin = Instant::now();
|
||||
let total = record_sum(borrowed)?;
|
||||
black_box(total);
|
||||
drop(values);
|
||||
Ok((total, begin.elapsed()))
|
||||
}
|
||||
_ => panic!("unknown checked kernel"),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_errors() {
|
||||
let fault = owned_sum(&[OwnedRecord {
|
||||
name: "overflow".into(),
|
||||
tags: vec!["preserved".into()],
|
||||
count: i64::MAX,
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert_eq!(fault.message, "Int overflow");
|
||||
assert_eq!(
|
||||
fault.locations,
|
||||
["inspect_owned", "forward_owned", "call_read"]
|
||||
);
|
||||
assert!(fault.causes.is_empty());
|
||||
assert_eq!(add(i64::MAX, 0).unwrap(), i64::MAX);
|
||||
assert_eq!(add(i64::MIN, 0).unwrap(), i64::MIN);
|
||||
for (left, right) in [(i64::MAX, 1), (i64::MIN, -1)] {
|
||||
let fault = add(left, right).unwrap_err().at("integer");
|
||||
assert_eq!(fault.message, "Int overflow");
|
||||
assert_eq!(fault.locations, ["add", "integer"]);
|
||||
assert!(fault.causes.is_empty());
|
||||
}
|
||||
let fault = record_sum(&[Record {
|
||||
first: i64::MAX,
|
||||
second: 1,
|
||||
third: 0,
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert_eq!(fault.locations, ["record_value", "record_sum"]);
|
||||
let fault = record_sum(&[
|
||||
Record {
|
||||
first: i64::MAX,
|
||||
second: 0,
|
||||
third: 0,
|
||||
},
|
||||
Record {
|
||||
first: 1,
|
||||
second: 0,
|
||||
third: 0,
|
||||
},
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(fault.locations, ["record_sum"]);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let arguments = std::env::args().collect::<Vec<_>>();
|
||||
if arguments[1] == "--verify-errors" {
|
||||
verify_errors();
|
||||
println!("checked overflow and error frames verified");
|
||||
return;
|
||||
}
|
||||
let input: serde_json::Value = serde_json::from_str(&arguments[2]).expect("input object");
|
||||
let ticks = input["ticks"].as_str().expect("ticks string");
|
||||
let (operation, count) = arguments[1]
|
||||
.strip_prefix("continuous_")
|
||||
.map_or((arguments[1].as_str(), 1), |operation| (operation, 10));
|
||||
let mut samples = Vec::new();
|
||||
for _ in 0..count {
|
||||
match measure(operation, ticks) {
|
||||
Ok((checksum, elapsed)) => samples
|
||||
.push(serde_json::json!({"checksum": checksum, "elapsed_ns": elapsed.as_nanos()})),
|
||||
Err(error) => {
|
||||
eprintln!("{error:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = if count == 1 {
|
||||
samples.pop().unwrap()
|
||||
} else {
|
||||
serde_json::Value::Array(samples)
|
||||
};
|
||||
println!("{output}");
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type input struct {
|
||||
Ticks string `json:"ticks"`
|
||||
}
|
||||
type sample struct {
|
||||
Elapsed int64 `json:"elapsed_ns"`
|
||||
Checksum int64 `json:"checksum"`
|
||||
}
|
||||
type record struct {
|
||||
First int64
|
||||
Second int64
|
||||
Third int64
|
||||
}
|
||||
|
||||
type ownedRecord struct {
|
||||
Name string
|
||||
Tags []string
|
||||
Count int64
|
||||
}
|
||||
|
||||
func inspectOwned(row ownedRecord) int64 {
|
||||
return checkedAdd(checkedAdd(row.Count, int64(utf8.RuneCountInString(row.Name))), int64(len(row.Tags)))
|
||||
}
|
||||
|
||||
func forwardOwned(row ownedRecord) int64 {
|
||||
return inspectOwned(row)
|
||||
}
|
||||
|
||||
func checkedAdd(total, value int64) int64 {
|
||||
if value > 0 && total > math.MaxInt64-value {
|
||||
panic("overflow")
|
||||
}
|
||||
return total + value
|
||||
}
|
||||
|
||||
func measure(operation string, ticks string) sample {
|
||||
source := []byte(ticks)
|
||||
total := int64(0)
|
||||
elapsed := int64(0)
|
||||
switch operation {
|
||||
case "record_update":
|
||||
var values []int64
|
||||
count := int64(0)
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
values = append(values, int64(value))
|
||||
count = checkedAdd(count, 1)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, value)
|
||||
}
|
||||
total = checkedAdd(total, count) - int64(len(values))
|
||||
case "record_inspect":
|
||||
var values []int64
|
||||
count := int64(0)
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
count = checkedAdd(int64(len(values)), 1)
|
||||
values = append(values, int64(value))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, value)
|
||||
}
|
||||
total = checkedAdd(total, count) - int64(len(values))
|
||||
case "map_lookup":
|
||||
values := make(map[int64]int64, len(source))
|
||||
for index, value := range source {
|
||||
values[int64(index)] = int64(value)
|
||||
}
|
||||
index := int64(0)
|
||||
begin := time.Now()
|
||||
for range source {
|
||||
total = checkedAdd(total, values[index])
|
||||
index = checkedAdd(index, 1)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "text_equal", "text_contains":
|
||||
rows := make([][2]string, len(source))
|
||||
for index, value := range source {
|
||||
left := "compare-value-" + strconv.Itoa(int(value))
|
||||
rows[index] = [2]string{left, strings.ToLower(strings.ToUpper(left))}
|
||||
}
|
||||
begin := time.Now()
|
||||
if operation == "text_equal" {
|
||||
for _, row := range rows {
|
||||
matched := int64(0)
|
||||
if row[0] == row[1] {
|
||||
matched = 120
|
||||
}
|
||||
total = checkedAdd(total, matched)
|
||||
}
|
||||
} else {
|
||||
for _, row := range rows {
|
||||
matched := int64(0)
|
||||
if strings.Contains(row[0], row[1]) {
|
||||
matched = 120
|
||||
}
|
||||
total = checkedAdd(total, matched)
|
||||
}
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
runtime.KeepAlive(rows)
|
||||
total = checkedAdd(total, int64(len(rows))) - int64(len(source))
|
||||
case "map_text_lookup":
|
||||
keys := make([]string, len(source))
|
||||
values := make(map[string]int64, len(source))
|
||||
for index, value := range source {
|
||||
key := "lookup-key-" + strconv.Itoa(index)
|
||||
keys[index] = key
|
||||
values[key] = int64(value)
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, key := range keys {
|
||||
total = checkedAdd(total, values[key])
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
runtime.KeepAlive(values)
|
||||
runtime.KeepAlive(keys)
|
||||
total = checkedAdd(total, int64(len(keys))) - int64(len(source))
|
||||
case "call_read":
|
||||
rows := make([]ownedRecord, 0, len(source))
|
||||
for _, value := range source {
|
||||
name := "row-value-" + strconv.Itoa(int(value))
|
||||
rows = append(rows, ownedRecord{name, []string{name}, int64(value) - int64(utf8.RuneCountInString(name)) - 1})
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, row := range rows {
|
||||
total = checkedAdd(total, forwardOwned(row))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "float":
|
||||
floating := 0.0
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
floating += float64(value)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
total = int64(floating)
|
||||
case "bytes_concat":
|
||||
chunks := make([][]byte, len(source))
|
||||
for index := range source {
|
||||
chunks[index] = source[index : index+1]
|
||||
}
|
||||
var output []byte
|
||||
begin := time.Now()
|
||||
for _, chunk := range chunks {
|
||||
output = append(output, chunk...)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range output {
|
||||
total = checkedAdd(total, int64(value))
|
||||
}
|
||||
case "text_concat":
|
||||
chunks := strings.Split(ticks, ",")
|
||||
begin := time.Now()
|
||||
var output strings.Builder
|
||||
for _, chunk := range chunks {
|
||||
output.WriteString(chunk)
|
||||
}
|
||||
joined := output.String()
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for index := 0; index < len(joined); index++ {
|
||||
total = checkedAdd(total, int64(joined[index]))
|
||||
}
|
||||
case "integer":
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
total = checkedAdd(total, int64(value))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "list":
|
||||
var values []int64
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
values = append(values, int64(value))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, value)
|
||||
}
|
||||
case "map":
|
||||
values := make(map[int64]int64)
|
||||
var order []int64
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
key := int64(len(values))
|
||||
values[key] = int64(value)
|
||||
order = append(order, key)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, key := range order {
|
||||
total = checkedAdd(total, values[key])
|
||||
}
|
||||
case "list_read":
|
||||
var values []int64
|
||||
for _, value := range source {
|
||||
values = append(values, int64(value))
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, value)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "map_read":
|
||||
values := make(map[int64]int64)
|
||||
var order []int64
|
||||
for _, value := range source {
|
||||
key := int64(len(values))
|
||||
values[key] = int64(value)
|
||||
order = append(order, key)
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, key := range order {
|
||||
total = checkedAdd(total, values[key])
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "list_record":
|
||||
var values []record
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
values = append(values, record{int64(value), 0, 0})
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, checkedAdd(checkedAdd(value.First, value.Second), value.Third))
|
||||
}
|
||||
case "list_record_read":
|
||||
var values []record
|
||||
for _, value := range source {
|
||||
values = append(values, record{int64(value), 0, 0})
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, checkedAdd(checkedAdd(value.First, value.Second), value.Third))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
default:
|
||||
panic("unknown kernel")
|
||||
}
|
||||
return sample{elapsed, total}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var arguments input
|
||||
if err := json.Unmarshal([]byte(os.Args[2]), &arguments); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var result any
|
||||
if operation, continuous := strings.CutPrefix(os.Args[1], "continuous_"); continuous {
|
||||
samples := make([]sample, 0, 10)
|
||||
for range 10 {
|
||||
samples = append(samples, measure(operation, arguments.Ticks))
|
||||
}
|
||||
result = samples
|
||||
} else {
|
||||
result = measure(os.Args[1], arguments.Ticks)
|
||||
}
|
||||
output, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use indexmap::IndexMap;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
struct Record {
|
||||
first: i64,
|
||||
second: i64,
|
||||
third: i64,
|
||||
}
|
||||
|
||||
struct OwnedRecord {
|
||||
name: String,
|
||||
tags: Vec<String>,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn inspect_owned(row: &OwnedRecord) -> i64 {
|
||||
add(
|
||||
add(row.count, row.name.chars().count() as i64),
|
||||
row.tags.len() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
fn forward_owned(row: &OwnedRecord) -> i64 {
|
||||
inspect_owned(row)
|
||||
}
|
||||
|
||||
fn add(total: i64, value: i64) -> i64 {
|
||||
total.checked_add(value).expect("integer overflow")
|
||||
}
|
||||
|
||||
fn record_value(row: &Record) -> i64 {
|
||||
add(add(row.first, row.second), row.third)
|
||||
}
|
||||
|
||||
fn measure(operation: &str, ticks: &str) -> serde_json::Value {
|
||||
let source = black_box(ticks.as_bytes());
|
||||
let (checksum, elapsed) = match operation {
|
||||
"record_update" => {
|
||||
let begin = Instant::now();
|
||||
let mut values = Vec::new();
|
||||
let mut count = 0;
|
||||
for value in source {
|
||||
values.push(i64::from(*value));
|
||||
count = add(count, 1);
|
||||
}
|
||||
black_box((&values, count));
|
||||
let elapsed = begin.elapsed();
|
||||
let total = values.iter().fold(0, |total, value| add(total, *value));
|
||||
let checksum = add(total, count)
|
||||
.checked_sub(values.len() as i64)
|
||||
.expect("integer overflow");
|
||||
(checksum, elapsed)
|
||||
}
|
||||
"record_inspect" => {
|
||||
let begin = Instant::now();
|
||||
let mut values = Vec::new();
|
||||
let mut count = 0;
|
||||
for value in source {
|
||||
count = add(values.len() as i64, 1);
|
||||
values.push(i64::from(*value));
|
||||
}
|
||||
black_box((&values, count));
|
||||
let elapsed = begin.elapsed();
|
||||
let total = values.iter().fold(0, |total, value| add(total, *value));
|
||||
let checksum = add(total, count)
|
||||
.checked_sub(values.len() as i64)
|
||||
.expect("integer overflow");
|
||||
(checksum, elapsed)
|
||||
}
|
||||
"map_lookup" => {
|
||||
let values = source
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| (index as i64, i64::from(*value)))
|
||||
.collect::<IndexMap<_, _>>();
|
||||
let borrowed = black_box(&values);
|
||||
let mut index = 0;
|
||||
let mut total = 0;
|
||||
let begin = Instant::now();
|
||||
for _ in source {
|
||||
total = add(total, borrowed.get(&index).copied().unwrap_or(0));
|
||||
index = add(index, 1);
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box(&values);
|
||||
(total, elapsed)
|
||||
}
|
||||
"text_equal" | "text_contains" => {
|
||||
let rows = source
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let left = format!("compare-value-{value}");
|
||||
let right = left.to_uppercase().to_lowercase();
|
||||
(left, right)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let borrowed = black_box(&rows);
|
||||
let mut total = 0;
|
||||
let begin = Instant::now();
|
||||
if operation == "text_equal" {
|
||||
for (left, right) in borrowed {
|
||||
total = add(total, if left == right { 120 } else { 0 });
|
||||
}
|
||||
} else {
|
||||
for (left, right) in borrowed {
|
||||
total = add(
|
||||
total,
|
||||
if left.contains(right.as_str()) {
|
||||
120
|
||||
} else {
|
||||
0
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box(&rows);
|
||||
let checksum = add(total, rows.len() as i64)
|
||||
.checked_sub(source.len() as i64)
|
||||
.expect("integer overflow");
|
||||
(checksum, elapsed)
|
||||
}
|
||||
"map_text_lookup" => {
|
||||
// 与 Dever 使用相同的拥有键和插入顺序;HashMap<&str, _> 是不同的数据结构。
|
||||
let values = source
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| (format!("lookup-key-{index}"), i64::from(*value)))
|
||||
.collect::<IndexMap<_, _>>();
|
||||
let keys = values.keys().cloned().collect::<Vec<_>>();
|
||||
let borrowed = black_box(&values);
|
||||
let mut total = 0;
|
||||
let begin = Instant::now();
|
||||
for key in &keys {
|
||||
total = add(total, borrowed.get(key.as_str()).copied().unwrap_or(0));
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box((&keys, &values));
|
||||
let checksum = add(total, keys.len() as i64)
|
||||
.checked_sub(source.len() as i64)
|
||||
.expect("integer overflow");
|
||||
(checksum, elapsed)
|
||||
}
|
||||
"call_read" => {
|
||||
let rows = source
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let name = format!("row-value-{value}");
|
||||
OwnedRecord {
|
||||
count: i64::from(*value) - name.chars().count() as i64 - 1,
|
||||
tags: vec![name.clone()],
|
||||
name,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let borrowed = black_box(&rows);
|
||||
let begin = Instant::now();
|
||||
let total = borrowed
|
||||
.iter()
|
||||
.fold(0, |total, row| add(total, forward_owned(row)));
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box(&rows);
|
||||
(total, elapsed)
|
||||
}
|
||||
"float" => {
|
||||
let begin = Instant::now();
|
||||
let total = source
|
||||
.iter()
|
||||
.fold(0.0, |total, value| total + f64::from(*value));
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
(total as i64, elapsed)
|
||||
}
|
||||
"bytes_concat" => {
|
||||
let chunks = source.chunks(1).map(<[u8]>::to_vec).collect::<Vec<_>>();
|
||||
let begin = Instant::now();
|
||||
let mut output = Vec::new();
|
||||
for chunk in &chunks {
|
||||
output.extend_from_slice(chunk);
|
||||
}
|
||||
black_box(&output);
|
||||
let elapsed = begin.elapsed();
|
||||
drop(chunks);
|
||||
let total = output
|
||||
.iter()
|
||||
.fold(0, |total, byte| add(total, i64::from(*byte)));
|
||||
(total, elapsed)
|
||||
}
|
||||
"text_concat" => {
|
||||
let chunks = std::str::from_utf8(source)
|
||||
.unwrap()
|
||||
.split(',')
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let begin = Instant::now();
|
||||
let mut output = String::new();
|
||||
for chunk in &chunks {
|
||||
output.push_str(chunk);
|
||||
}
|
||||
black_box(&output);
|
||||
let elapsed = begin.elapsed();
|
||||
drop(chunks);
|
||||
let total = output
|
||||
.bytes()
|
||||
.fold(0, |total, byte| add(total, i64::from(byte)));
|
||||
(total, elapsed)
|
||||
}
|
||||
"integer" => {
|
||||
let begin = Instant::now();
|
||||
let total = source
|
||||
.iter()
|
||||
.fold(0, |total, value| add(total, i64::from(*value)));
|
||||
(black_box(total), begin.elapsed())
|
||||
}
|
||||
"list" | "list_read" => {
|
||||
let mut values = Vec::new();
|
||||
let begin = Instant::now();
|
||||
values.extend(source.iter().map(|value| i64::from(*value)));
|
||||
let construction = begin.elapsed();
|
||||
let borrowed = black_box(&values);
|
||||
let begin = Instant::now();
|
||||
let total = borrowed.iter().fold(0, |total, value| add(total, *value));
|
||||
black_box(total);
|
||||
let traversal = begin.elapsed();
|
||||
drop(values);
|
||||
(
|
||||
total,
|
||||
if operation == "list" {
|
||||
construction
|
||||
} else {
|
||||
traversal
|
||||
},
|
||||
)
|
||||
}
|
||||
"list_record" | "list_record_read" => {
|
||||
let mut values = Vec::new();
|
||||
let begin = Instant::now();
|
||||
for value in source {
|
||||
values.push(Record {
|
||||
first: i64::from(*value),
|
||||
second: 0,
|
||||
third: 0,
|
||||
});
|
||||
}
|
||||
let construction = begin.elapsed();
|
||||
let borrowed = black_box(&values);
|
||||
let begin = Instant::now();
|
||||
let total = borrowed
|
||||
.iter()
|
||||
.fold(0, |total, row| add(total, record_value(row)));
|
||||
black_box(total);
|
||||
let traversal = begin.elapsed();
|
||||
drop(values);
|
||||
(
|
||||
total,
|
||||
if operation == "list_record" {
|
||||
construction
|
||||
} else {
|
||||
traversal
|
||||
},
|
||||
)
|
||||
}
|
||||
"map" | "map_read" => {
|
||||
let mut values = IndexMap::new();
|
||||
let begin = Instant::now();
|
||||
for value in source {
|
||||
let key = i64::try_from(values.len()).expect("map size");
|
||||
values.insert(key, i64::from(*value));
|
||||
}
|
||||
let construction = begin.elapsed();
|
||||
let borrowed = black_box(&values);
|
||||
let begin = Instant::now();
|
||||
let total = borrowed.values().fold(0, |total, value| add(total, *value));
|
||||
black_box(total);
|
||||
drop(values);
|
||||
let traversal = begin.elapsed();
|
||||
(
|
||||
total,
|
||||
if operation == "map" {
|
||||
construction
|
||||
} else {
|
||||
traversal
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => panic!("unknown kernel"),
|
||||
};
|
||||
serde_json::json!({"checksum": checksum, "elapsed_ns": elapsed.as_nanos()})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let arguments = std::env::args().collect::<Vec<_>>();
|
||||
let input: serde_json::Value = serde_json::from_str(&arguments[2]).expect("input object");
|
||||
let ticks = input["ticks"].as_str().expect("ticks string");
|
||||
let output = if let Some(operation) = arguments[1].strip_prefix("continuous_") {
|
||||
serde_json::Value::Array((0..10).map(|_| measure(operation, ticks)).collect())
|
||||
} else {
|
||||
measure(&arguments[1], ticks)
|
||||
};
|
||||
println!("{output}");
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
setting {
|
||||
limits: dever.http.Limits
|
||||
lifetime_ms: Int
|
||||
}
|
||||
|
||||
type Service {
|
||||
Tcp
|
||||
Live
|
||||
}
|
||||
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (
|
||||
value: dever.system.Listener
|
||||
) {
|
||||
value = listener
|
||||
}
|
||||
|
||||
listening(result: dever.net.ListenResult.Failed(message)) (value: dever.system.Listener) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
bound(result: dever.net.PortResult.Bound(port)) (value: Int) {
|
||||
value = port
|
||||
}
|
||||
|
||||
bound(result: dever.net.PortResult.Failed(message)) (value: Int) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
written(result: dever.io.WriteResult.Done) () {}
|
||||
|
||||
written(result: dever.io.WriteResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
closed(result: dever.io.CloseResult.Closed) () {}
|
||||
|
||||
closed(result: dever.io.CloseResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
paused(result: dever.time.SleepResult.Done) () {}
|
||||
|
||||
paused(result: dever.time.SleepResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
tcpChunk(event: dever.io.ReadEvent.Chunk(bytes), socket: dever.system.Socket) () recover("TCP 连接错误由运行器检查") {
|
||||
written(result(dever.net.write(socket, bytes)))
|
||||
}
|
||||
|
||||
tcpChunk(event: dever.io.ReadEvent.Failed(message), socket: dever.system.Socket) () recover("TCP 连接错误由运行器检查") {
|
||||
blocking(dever.io.println("ERROR|" + message))
|
||||
}
|
||||
|
||||
tcpStream(
|
||||
result: dever.net.ReadStreamResult.Streaming(stream),
|
||||
socket: dever.system.Socket
|
||||
) () {
|
||||
each(tcpChunk, stream, socket)
|
||||
}
|
||||
|
||||
tcpStream(
|
||||
result: dever.net.ReadStreamResult.Failed(message),
|
||||
socket: dever.system.Socket
|
||||
) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
tcpConnection(
|
||||
event: dever.net.ConnectResult.Connected(socket),
|
||||
limits: dever.http.Limits
|
||||
) () recover("TCP 连接错误由运行器检查") {
|
||||
written(result(dever.net.timeout(socket, limits.timeout_ms)))
|
||||
tcpStream(result(dever.net.chunks(socket, 4096)), socket)
|
||||
closed(result(dever.net.close(socket)))
|
||||
}
|
||||
|
||||
tcpConnection(
|
||||
event: dever.net.ConnectResult.Failed(message),
|
||||
limits: dever.http.Limits
|
||||
) () recover("TCP 连接错误由运行器检查") {
|
||||
blocking(dever.io.println("ERROR|" + message))
|
||||
}
|
||||
|
||||
tcpServer(listener: dever.system.Listener, limits: dever.http.Limits) () {
|
||||
stream = dever.net.connections(listener)
|
||||
parallelEach(tcpConnection, stream, limits.connections, limits)
|
||||
}
|
||||
|
||||
websocketMessage(
|
||||
event: dever.websocket.MessageEvent.Read(message),
|
||||
socket: dever.system.WebSocket
|
||||
) () recover("WebSocket 连接错误由运行器检查") {
|
||||
websocketEcho(message, socket)
|
||||
}
|
||||
|
||||
websocketMessage(
|
||||
event: dever.websocket.MessageEvent.Failed(message),
|
||||
socket: dever.system.WebSocket
|
||||
) () recover("WebSocket 连接错误由运行器检查") {
|
||||
blocking(dever.io.println("ERROR|" + message))
|
||||
}
|
||||
|
||||
websocketEcho(
|
||||
message: dever.websocket.Message.Text(value),
|
||||
socket: dever.system.WebSocket
|
||||
) () recover("WebSocket 连接错误由运行器检查") {
|
||||
written(result(dever.websocket.send(socket, dever.websocket.Message.Text(value))))
|
||||
}
|
||||
|
||||
websocketEcho(
|
||||
message: dever.websocket.Message.Binary(value),
|
||||
socket: dever.system.WebSocket
|
||||
) () recover("WebSocket 连接错误由运行器检查") {
|
||||
written(result(dever.websocket.send(socket, dever.websocket.Message.Binary(value))))
|
||||
}
|
||||
|
||||
websocketEcho(message: other, socket: dever.system.WebSocket) () recover("WebSocket 连接错误由运行器检查") {}
|
||||
|
||||
websocketLimits() (result: dever.websocket.Limits) {
|
||||
result = dever.websocket.Limits {
|
||||
message_bytes = 4096
|
||||
idle_ms = setting.lifetime_ms
|
||||
write_ms = setting.limits.timeout_ms
|
||||
}
|
||||
}
|
||||
|
||||
websocketConnection(result: dever.websocket.ConnectResult.Connected(socket)) () {
|
||||
each(websocketMessage, dever.websocket.messages(socket), socket)
|
||||
}
|
||||
|
||||
websocketConnection(result: dever.websocket.ConnectResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
route(request: dever.http.Request, reply: dever.system.HttpReply) () {
|
||||
livePath(request.target, reply)
|
||||
}
|
||||
|
||||
livePath(target: "/ws", reply: dever.system.HttpReply) () recover("实时连接错误由运行器检查") {
|
||||
websocketConnection(result(dever.websocket.accept(reply, websocketLimits())))
|
||||
}
|
||||
|
||||
livePath(target: "/events", reply: dever.system.HttpReply) () recover("实时连接错误由运行器检查") {
|
||||
written(result(dever.sse.start(reply, [])))
|
||||
written(
|
||||
result(
|
||||
dever.sse.send(
|
||||
reply,
|
||||
dever.sse.Event {
|
||||
event = "live"
|
||||
data = "ready"
|
||||
id = "1"
|
||||
retry_ms = null
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
paused(result(dever.task.sleep(setting.lifetime_ms)))
|
||||
}
|
||||
|
||||
livePath(target: other, reply: dever.system.HttpReply) () recover("实时连接错误由运行器检查") {
|
||||
written(
|
||||
result(
|
||||
dever.http.respond(
|
||||
reply,
|
||||
dever.http.Response {
|
||||
status = 404
|
||||
headers = []
|
||||
body = dever.bytes.fromText("unknown live benchmark path")
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
liveLimits() (result: dever.http.LiveLimits) {
|
||||
result = dever.http.LiveLimits {
|
||||
chunk_bytes = 4096
|
||||
idle_ms = setting.lifetime_ms
|
||||
heartbeat_ms = 100
|
||||
}
|
||||
}
|
||||
|
||||
liveServer(listener: dever.system.Listener) () {
|
||||
dever.system.httpServeLive(route, listener, setting.limits, liveLimits())
|
||||
}
|
||||
|
||||
serve(service: Service.Tcp, listener: dever.system.Listener) () {
|
||||
tcpServer(listener, setting.limits)
|
||||
}
|
||||
|
||||
serve(service: Service.Live, listener: dever.system.Listener) () {
|
||||
liveServer(listener)
|
||||
}
|
||||
|
||||
worker(service: Service) () {
|
||||
listener = listening(result(dever.net.listen("127.0.0.1", 0)))
|
||||
port = bound(result(dever.net.port(listener)))
|
||||
server = run(serve(service, listener))
|
||||
blocking(dever.io.println("READY|" + int.toText(port)))
|
||||
paused(result(dever.task.sleep(setting.lifetime_ms)))
|
||||
stop(server)
|
||||
closed(result(dever.net.closeListener(listener)))
|
||||
}
|
||||
|
||||
port.tcp() () {
|
||||
worker(Service.Tcp)
|
||||
}
|
||||
|
||||
port.live() () {
|
||||
worker(Service.Live)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
cmd tcp = app.tcp
|
||||
|
||||
cmd ws = app.ws
|
||||
|
||||
cmd sse = app.sse
|
||||
@@ -0,0 +1,18 @@
|
||||
type WorkerFailure {
|
||||
error Failed(message: Text)
|
||||
}
|
||||
|
||||
tcp() (result: Bool) {
|
||||
port.tcp()
|
||||
result = true
|
||||
}
|
||||
|
||||
ws() (result: Bool) {
|
||||
port.live()
|
||||
result = true
|
||||
}
|
||||
|
||||
sse() (result: Bool) {
|
||||
port.live()
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
tcp() () fails app.WorkerFailure
|
||||
|
||||
live() () fails app.WorkerFailure
|
||||
@@ -0,0 +1,144 @@
|
||||
type Identity {
|
||||
id: Text
|
||||
user_id: model.id?
|
||||
tenant_id: model.id?
|
||||
}
|
||||
|
||||
verify(claims: dever.auth.Claims) (identity: Identity) {
|
||||
identity = Identity {
|
||||
id = claims.subject
|
||||
user_id = null
|
||||
tenant_id = null
|
||||
}
|
||||
}
|
||||
|
||||
ready() () {
|
||||
blocking(bench.ready(0))
|
||||
}
|
||||
|
||||
populateOne(value: Int) () {
|
||||
created = model.create({
|
||||
value = value
|
||||
label = "fixture"
|
||||
})
|
||||
}
|
||||
|
||||
populate() () {
|
||||
each(populateOne, config.ormRows())
|
||||
}
|
||||
|
||||
crudOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
created = model.create({
|
||||
value = value
|
||||
label = "created"
|
||||
})
|
||||
changed = model.update(created.id, { label = "updated" })
|
||||
removed = model.delete(created.id)
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "crud", 1, changed + removed))
|
||||
}
|
||||
|
||||
listOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
page = model.list({
|
||||
order = id.asc
|
||||
page = 1
|
||||
size = 32
|
||||
})
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "list", 1, page.total + length(page.items)))
|
||||
}
|
||||
|
||||
cursorOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
page = model.cursor({
|
||||
order = id.asc
|
||||
size = 32
|
||||
})
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "cursor", 1, length(page.items)))
|
||||
}
|
||||
|
||||
countItem(value: model.Item, total: Int) (next: Int) {
|
||||
next = total + 1
|
||||
}
|
||||
|
||||
streamOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
rows = model.stream({
|
||||
order = id.asc
|
||||
size = 16
|
||||
})
|
||||
total = reduce(countItem, rows, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "stream", 1, total))
|
||||
}
|
||||
|
||||
poolOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = model.count()
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "pool", 1, total))
|
||||
}
|
||||
|
||||
transaction fillCancelledTransaction() () {
|
||||
created = model.create({
|
||||
value = 1
|
||||
label = "cancelled"
|
||||
})
|
||||
delayed = model.delay(config.ormCancelWork())
|
||||
}
|
||||
|
||||
ormIdle() (result: Bool) {
|
||||
total = model.count()
|
||||
blocking(bench.ready(total))
|
||||
bench.pause(config.lifetime())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormCrud() (result: Bool) {
|
||||
ready()
|
||||
each(crudOnce, config.ormInput())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormList() (result: Bool) {
|
||||
populate()
|
||||
ready()
|
||||
each(listOnce, config.ormInput())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormCursor() (result: Bool) {
|
||||
populate()
|
||||
ready()
|
||||
each(cursorOnce, config.ormInput())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormStream() (result: Bool) {
|
||||
ready()
|
||||
each(streamOnce, config.ormInput())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormPool() (result: Bool) {
|
||||
ready()
|
||||
parallelEach(poolOnce, config.ormInput(), config.ormConcurrency())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormSeed() (result: Bool) {
|
||||
populate()
|
||||
result = true
|
||||
}
|
||||
|
||||
count() (total: Int) {
|
||||
total = model.count()
|
||||
}
|
||||
|
||||
cancel() (result: Bool) {
|
||||
fillCancelledTransaction()
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public get count = app.count
|
||||
public post cancel = app.cancel
|
||||
cmd idle = app.ormIdle
|
||||
cmd crud = app.ormCrud
|
||||
cmd list = app.ormList
|
||||
cmd cursor = app.ormCursor
|
||||
cmd stream = app.ormStream
|
||||
cmd pool = app.ormPool
|
||||
cmd seed = app.ormSeed
|
||||
@@ -0,0 +1,13 @@
|
||||
type DelayResult {
|
||||
total: Int
|
||||
}
|
||||
|
||||
global type Item {
|
||||
value: Int index
|
||||
label: Text(1, 32)
|
||||
}
|
||||
|
||||
sql delay(work: Int) (result: DelayResult) {
|
||||
sqlite = "WITH RECURSIVE counter(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM counter WHERE value < ?1) SELECT SUM(value) AS total FROM counter"
|
||||
postgres = "SELECT SUM(value)::bigint AS total FROM generate_series(1::bigint, $1::bigint) AS value"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
cmd run = app.run
|
||||
@@ -0,0 +1,4 @@
|
||||
run() (result: Bool) {
|
||||
dever.io.println("profile")
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
port.blockingPause(millis: Int) () {
|
||||
dever.time.sleep(millis)
|
||||
}
|
||||
|
||||
port.reportError(message: Text) () {
|
||||
dever.io.println("ERROR|" + message)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
cmd sync_idle = app.syncIdle
|
||||
|
||||
cmd async_idle = app.asyncIdle
|
||||
|
||||
cmd tasks = app.tasks
|
||||
|
||||
cmd tasks_on_worker = app.tasksOnWorker
|
||||
|
||||
cmd channels = app.channels
|
||||
|
||||
cmd cancel = app.cancel
|
||||
@@ -0,0 +1,132 @@
|
||||
type Parking {
|
||||
ready: Channel<Int>
|
||||
gate: Channel<Int>
|
||||
}
|
||||
|
||||
syncIdle() (result: Bool) {
|
||||
bench.ready(0)
|
||||
port.blockingPause(config.lifetime())
|
||||
result = true
|
||||
}
|
||||
|
||||
idle() () {
|
||||
blocking(bench.ready(0))
|
||||
bench.pause(config.lifetime())
|
||||
}
|
||||
|
||||
asyncIdle() (result: Bool) {
|
||||
idle()
|
||||
result = true
|
||||
}
|
||||
|
||||
identity(value: Int) (result: Int) {
|
||||
result = value
|
||||
}
|
||||
|
||||
taskSum(value: Int, state: Int) (result: Int) {
|
||||
job = run(identity(value))
|
||||
result = state + wait(job)
|
||||
}
|
||||
|
||||
sum(value: Int, state: Int) (result: Int) {
|
||||
result = state + value
|
||||
}
|
||||
|
||||
taskSample(index: Int, input: Bytes) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
checksum = reduce(taskSum, input, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "tasks", length(input), checksum))
|
||||
}
|
||||
|
||||
sendByte(value: Int, output: Channel<Int>) () {
|
||||
send(output, value)
|
||||
}
|
||||
|
||||
produce(input: Bytes, output: Channel<Int>) () {
|
||||
each(sendByte, input, output)
|
||||
close(output)
|
||||
}
|
||||
|
||||
channelSample(index: Int, input: Bytes) () {
|
||||
output = channel(Int, config.channelCapacity())
|
||||
begin = dever.time.monotonicNanos()
|
||||
producer = run(produce(input, output))
|
||||
checksum = reduce(sum, stream(output), 0)
|
||||
wait(producer)
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "channels", length(input), checksum))
|
||||
}
|
||||
|
||||
waitOne(value: Int, parking: Parking) () {
|
||||
send(parking.ready, value)
|
||||
signaled(receive(parking.gate))
|
||||
}
|
||||
|
||||
signaled(value: Int) () {}
|
||||
|
||||
signaled(value: null) () {
|
||||
blocking(port.reportError("channel closed before expected signal"))
|
||||
}
|
||||
|
||||
hold(parking: Parking) () {
|
||||
parallelEach(waitOne, config.pendingInput(), config.pending(), parking)
|
||||
}
|
||||
|
||||
started(value: Int, ready: Channel<Int>) () {
|
||||
signaled(receive(ready))
|
||||
}
|
||||
|
||||
cancelWorker() () {
|
||||
parking = Parking {
|
||||
ready = channel(Int, config.pending())
|
||||
gate = channel(Int, 1)
|
||||
}
|
||||
job = run(hold(parking))
|
||||
each(started, config.pendingInput(), parking.ready)
|
||||
blocking(bench.ready(config.pending()))
|
||||
bench.pause(config.idleSampleMs())
|
||||
begin = dever.time.monotonicNanos()
|
||||
stop(job)
|
||||
end = dever.time.monotonicNanos()
|
||||
close(parking.ready)
|
||||
close(parking.gate)
|
||||
blocking(bench.report(begin, end, "cancel", config.pending(), config.pending()))
|
||||
}
|
||||
|
||||
taskWorker() () {
|
||||
input = config.input()
|
||||
blocking(bench.ready(0))
|
||||
each(taskSample, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], input)
|
||||
}
|
||||
|
||||
channelWorker() () {
|
||||
input = config.input()
|
||||
blocking(bench.ready(0))
|
||||
each(channelSample, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], input)
|
||||
}
|
||||
|
||||
tasks() (result: Bool) {
|
||||
taskWorker()
|
||||
result = true
|
||||
}
|
||||
|
||||
nestedTaskWorker() () {
|
||||
job = run(taskWorker())
|
||||
wait(job)
|
||||
}
|
||||
|
||||
tasksOnWorker() (result: Bool) {
|
||||
nestedTaskWorker()
|
||||
result = true
|
||||
}
|
||||
|
||||
channels() (result: Bool) {
|
||||
channelWorker()
|
||||
result = true
|
||||
}
|
||||
|
||||
cancel() (result: Bool) {
|
||||
cancelWorker()
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
blockingPause(millis: Int) () fails dever.time.SleepResult
|
||||
|
||||
reportError(message: Text) () fails dever.io.WriteResult
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Compare native Dever JSON operations with Go and Rust; build separately."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures/json"
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
with path.open("rb") as source:
|
||||
digest = hashlib.file_digest(source, "sha256").hexdigest()
|
||||
return {"path": str(path), "bytes": path.stat().st_size, "sha256": digest}
|
||||
|
||||
|
||||
def build(arguments):
|
||||
output = arguments.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
module = output / "module/benchmark/json"
|
||||
module.mkdir(parents=True)
|
||||
for name in ("app.dever", "api.dever"):
|
||||
shutil.copy2(FIXTURE / name, module / name)
|
||||
(output / "config").mkdir()
|
||||
(output / "config/setting.json").write_text("{}\n")
|
||||
artifacts = {}
|
||||
for label, compiler in (("dever", arguments.compiler),
|
||||
("dever_before", arguments.baseline_compiler)):
|
||||
if compiler is None:
|
||||
continue
|
||||
binary = output / label
|
||||
with (output / (label + ".build.log")).open("w") as log:
|
||||
subprocess.run([compiler, "build", str(output), "--output", str(binary)],
|
||||
stdout=log, stderr=subprocess.STDOUT, check=True)
|
||||
artifacts[label] = fingerprint(binary)
|
||||
artifacts[label]["compiler"] = fingerprint(Path(compiler).resolve())
|
||||
binary = output / "go"
|
||||
subprocess.run(["go", "build", "-trimpath", "-ldflags=-s -w", "-o", str(binary),
|
||||
str(FIXTURE.resolve() / "peer.go")], check=True)
|
||||
artifacts["go"] = fingerprint(binary)
|
||||
artifacts["go"]["compiler"] = subprocess.check_output(["go", "version"], text=True).strip()
|
||||
command = ["cargo", "build", "--offline", "--locked", "--release", "-p", "dever-tests",
|
||||
"--example", "json-peer", "--message-format=json"]
|
||||
process = subprocess.run(command, cwd=FIXTURE.resolve().parents[3],
|
||||
capture_output=True, text=True, check=True, timeout=300)
|
||||
(output / "rust.build.log").write_text(process.stderr)
|
||||
executables = [message["executable"] for line in process.stdout.splitlines()
|
||||
if (message := json.loads(line)).get("reason") == "compiler-artifact"
|
||||
and message.get("executable") and message["target"]["name"] == "json-peer"]
|
||||
if len(executables) != 1:
|
||||
raise RuntimeError("expected exactly one native Rust JSON peer")
|
||||
binary = output / "rust"
|
||||
shutil.copy2(executables[0], binary)
|
||||
artifacts["rust"] = fingerprint(binary)
|
||||
artifacts["rust"]["compiler"] = subprocess.check_output(["rustc", "--version"], text=True).strip()
|
||||
artifacts["rust"]["source"] = fingerprint(FIXTURE.resolve() / "peer.rs")
|
||||
artifacts["rust"]["profile"] = "release: opt-level=3, fat LTO, codegen-units=1"
|
||||
(output / "manifest.json").write_text(json.dumps(artifacts, indent=2) + "\n")
|
||||
print(output / "manifest.json")
|
||||
|
||||
|
||||
def measure(command):
|
||||
with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
|
||||
process = subprocess.Popen(command, stdout=stdout, stderr=stderr, close_fds=False)
|
||||
deadline = time.monotonic() + 120
|
||||
high_water = 0
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
process_root = Path("/proc") / str(process.pid)
|
||||
if (process_root / "exe").resolve() == Path(command[0]).resolve():
|
||||
for line in (process_root / "status").read_text().splitlines():
|
||||
if line.startswith("VmHWM:"):
|
||||
high_water = max(high_water, int(line.split()[1]))
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
pass
|
||||
waited, status, _ = os.wait4(process.pid, os.WNOHANG)
|
||||
if waited:
|
||||
process.returncode = os.waitstatus_to_exitcode(status)
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("benchmark exceeded 120 seconds")
|
||||
time.sleep(0.005)
|
||||
finally:
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
stdout.seek(0)
|
||||
stderr.seek(0)
|
||||
errors = stderr.read().decode()
|
||||
if process.returncode or errors:
|
||||
raise RuntimeError(errors or "benchmark failed")
|
||||
result = json.loads(stdout.read())
|
||||
if "code" in result:
|
||||
if result["code"] != 0:
|
||||
raise RuntimeError("Dever benchmark returned failure")
|
||||
result = result["data"]
|
||||
if high_water == 0:
|
||||
raise RuntimeError("process ended before native RSS could be sampled")
|
||||
result["sampled_hwm_kib"] = high_water
|
||||
return result
|
||||
|
||||
|
||||
def run(arguments):
|
||||
if arguments.output.exists():
|
||||
raise FileExistsError(arguments.output)
|
||||
if arguments.cpu is not None:
|
||||
if arguments.cpu not in os.sched_getaffinity(0):
|
||||
raise ValueError("requested CPU is outside the allowed affinity")
|
||||
os.sched_setaffinity(0, {arguments.cpu})
|
||||
manifest = json.loads(arguments.manifest.read_text())
|
||||
for artifact in manifest.values():
|
||||
actual = fingerprint(Path(artifact["path"]))
|
||||
if any(actual[field] != artifact[field] for field in ("bytes", "sha256")):
|
||||
raise ValueError("benchmark artifact changed after build")
|
||||
report = {"artifacts": manifest, "cpu_affinity": sorted(os.sched_getaffinity(0)),
|
||||
"order": "rotate implementations each repeat within each workload",
|
||||
"samples": [], "summary": []}
|
||||
row = {"zeta": "field", "id": 9007199254740993, "alpha": "x" * 80,
|
||||
"enabled": True, "values": [1, None, "escaped\nvalue"]}
|
||||
for rows in (4, 40, 160):
|
||||
source = {"zeta": {"caption": "form"}, "data": {"rows": [row] * rows},
|
||||
"active": False, "alpha": "end"}
|
||||
payload_bytes = len(json.dumps(source, separators=(",", ":")).encode())
|
||||
for update in (False, True):
|
||||
expected = dict(source, active=True) if update else source["data"]
|
||||
groups = {label: [] for label in manifest}
|
||||
labels = list(manifest)
|
||||
for repeat in range(arguments.repeats):
|
||||
offset = repeat % len(labels)
|
||||
for label in labels[offset:] + labels[:offset]:
|
||||
artifact = manifest[label]
|
||||
iterations = (arguments.baseline_iterations if label == "dever_before"
|
||||
else arguments.iterations)
|
||||
payload = json.dumps({"source": source, "update": update,
|
||||
"ticks": "x" * iterations}, separators=(",", ":"))
|
||||
command = [artifact["path"]]
|
||||
if label.startswith("dever"):
|
||||
command.append("benchmark.json.run")
|
||||
sample = measure(command + [payload])
|
||||
if (json.loads(sample["output"]) != expected or
|
||||
sample["checksum"] != len(sample["output"].encode()) * iterations or
|
||||
sample["elapsed_ns"] <= 0):
|
||||
raise AssertionError("JSON output or checksum mismatch")
|
||||
sample.pop("output")
|
||||
sample.update(label=label, update=update, bytes=payload_bytes,
|
||||
iterations=iterations, repeat=repeat,
|
||||
us_per_operation=sample["elapsed_ns"] / iterations / 1000)
|
||||
groups[label].append(sample)
|
||||
report["samples"].append(sample)
|
||||
for label, samples in groups.items():
|
||||
summary = {"label": label, "operation": "set" if update else "get",
|
||||
"bytes": payload_bytes,
|
||||
"median_us": statistics.median(item["us_per_operation"] for item in samples),
|
||||
"sampled_hwm_kib": max(item["sampled_hwm_kib"] for item in samples)}
|
||||
report["summary"].append(summary)
|
||||
print(json.dumps(summary), flush=True)
|
||||
with arguments.output.open("x") as output:
|
||||
json.dump(report, output, indent=2)
|
||||
output.write("\n")
|
||||
|
||||
|
||||
def positive(value):
|
||||
parsed = int(value)
|
||||
if not 1 <= parsed <= 10000:
|
||||
raise argparse.ArgumentTypeError("expected 1..10000")
|
||||
return parsed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
building = commands.add_parser("build")
|
||||
building.add_argument("--compiler", required=True)
|
||||
building.add_argument("--baseline-compiler")
|
||||
building.add_argument("--output", type=Path, required=True)
|
||||
running = commands.add_parser("run")
|
||||
running.add_argument("--manifest", type=Path, required=True)
|
||||
running.add_argument("--output", type=Path, required=True)
|
||||
running.add_argument("--iterations", type=positive, default=2000)
|
||||
running.add_argument("--baseline-iterations", type=positive, default=1)
|
||||
running.add_argument("--repeats", type=positive, default=5)
|
||||
running.add_argument("--cpu", type=int)
|
||||
arguments = parser.parse_args()
|
||||
(build if arguments.command == "build" else run)(arguments)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Compare current Dever collection kernels with independent Go and Rust programs."""
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures/kernel"
|
||||
CORE = Path(__file__).resolve().parents[2]
|
||||
CASES = {"integer": (16384, 65536), "float": (16384, 65536),
|
||||
"list": (512, 1024, 2048, 4096),
|
||||
"map": (512, 1024, 2048, 4096),
|
||||
"list_read": (512, 1024, 2048, 4096),
|
||||
"list_record": (512, 1024, 2048, 4096),
|
||||
"list_record_read": (512, 1024, 2048, 4096),
|
||||
"record_update": (512, 1024, 2048, 4096),
|
||||
"record_inspect": (512, 1024, 2048, 4096),
|
||||
"call_read": (512, 1024, 2048, 4096),
|
||||
"map_read": (512, 1024, 2048, 4096),
|
||||
"map_lookup": (512, 1024, 2048, 4096),
|
||||
"map_text_lookup": (512, 1024, 2048, 4096),
|
||||
"text_equal": (512, 1024, 2048, 4096),
|
||||
"text_contains": (512, 1024, 2048, 4096),
|
||||
"text_concat": (512, 2048, 8192, 32768),
|
||||
"bytes_concat": (512, 2048, 8192, 32768)}
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
with path.open("rb") as source:
|
||||
digest = hashlib.file_digest(source, "sha256").hexdigest()
|
||||
return {"path": str(path), "bytes": path.stat().st_size, "sha256": digest}
|
||||
|
||||
|
||||
def source_identity():
|
||||
files = [CORE / "rust" / name for name in
|
||||
("Cargo.toml", "Cargo.lock", "rust-toolchain.toml")]
|
||||
for directory in ("rust/src", "lib", "sdk"):
|
||||
files.extend(path for path in (CORE / directory).rglob("*")
|
||||
if path.is_file() and path.suffix in
|
||||
{".rs", ".toml", ".md", ".dever", ".py", ".c", ".h", ".js", ".mjs", ".json", ".go"})
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(files):
|
||||
digest.update(str(path.relative_to(CORE)).encode() + b"\0")
|
||||
digest.update(fingerprint(path)["sha256"].encode() + b"\n")
|
||||
return {"sha256": digest.hexdigest(), "files": len(files)}
|
||||
|
||||
|
||||
def build(arguments):
|
||||
tree = source_identity()
|
||||
output = arguments.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
module = output / "module/benchmark/kernel"
|
||||
module.mkdir(parents=True)
|
||||
for name in ("app.dever", "api.dever"):
|
||||
shutil.copy2(FIXTURE / name, module / name)
|
||||
(output / "config").mkdir()
|
||||
(output / "config/setting.json").write_text("{}\n")
|
||||
artifacts = {}
|
||||
for label, compiler in (("dever", arguments.compiler),
|
||||
("dever_before", arguments.baseline_compiler)):
|
||||
if compiler is None:
|
||||
continue
|
||||
binary = output / label
|
||||
with (output / (label + ".build.log")).open("w") as log:
|
||||
subprocess.run([compiler, "build", str(output), "--output", str(binary)],
|
||||
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=300)
|
||||
artifacts[label] = fingerprint(binary)
|
||||
artifacts[label]["compiler"] = fingerprint(Path(compiler).resolve())
|
||||
artifacts[label]["source_tree"] = tree
|
||||
generated = output / (label + ".rs")
|
||||
with generated.open("w") as source:
|
||||
subprocess.run([compiler, "emit", str(output)], stdout=source,
|
||||
check=True, timeout=120)
|
||||
artifacts[label]["generated"] = fingerprint(generated)
|
||||
binary = output / "go"
|
||||
subprocess.run(["go", "build", "-trimpath", "-ldflags=-s -w", "-o", str(binary),
|
||||
str(FIXTURE.resolve() / "peer.go")], check=True, timeout=120)
|
||||
artifacts["go"] = fingerprint(binary)
|
||||
artifacts["go"]["compiler"] = subprocess.check_output(["go", "version"], text=True).strip()
|
||||
peer = output / "rust-peer"
|
||||
peer.mkdir()
|
||||
shutil.copy2(FIXTURE / "peer.rs", peer / "main.rs")
|
||||
core = FIXTURE.resolve().parents[3]
|
||||
shutil.copy2(core / "rust/rust-toolchain.toml", peer / "rust-toolchain.toml")
|
||||
(peer / "Cargo.toml").write_text(
|
||||
'[package]\nname = "kernel-peer"\nversion = "0.0.1"\nedition = "2024"\n'
|
||||
'[workspace]\n[[bin]]\nname = "kernel-peer"\npath = "main.rs"\n'
|
||||
'[dependencies]\nserde_json = "=1.0.145"\nindexmap = "=2.14.0"\n'
|
||||
'[profile.release]\nopt-level = 3\nlto = "fat"\ncodegen-units = 1\n'
|
||||
'overflow-checks = true\nstrip = "symbols"\n')
|
||||
subprocess.run(["cargo", "generate-lockfile", "--offline"], cwd=peer,
|
||||
check=True, timeout=120)
|
||||
command = ["cargo", "build", "--offline", "--locked", "--release",
|
||||
"--bin", "kernel-peer", "--message-format=json"]
|
||||
process = subprocess.run(command, cwd=peer,
|
||||
capture_output=True, text=True, check=True, timeout=300)
|
||||
(output / "rust.build.log").write_text(process.stderr)
|
||||
executables = [message["executable"] for line in process.stdout.splitlines()
|
||||
if (message := json.loads(line)).get("reason") == "compiler-artifact"
|
||||
and message.get("executable") and message["target"]["name"] == "kernel-peer"]
|
||||
if len(executables) != 1:
|
||||
raise RuntimeError("expected exactly one native Rust peer")
|
||||
binary = output / "rust"
|
||||
shutil.copy2(executables[0], binary)
|
||||
artifacts["rust"] = fingerprint(binary)
|
||||
artifacts["rust"]["compiler"] = subprocess.check_output(
|
||||
["rustc", "--version"], cwd=peer, text=True).strip()
|
||||
artifacts["rust"]["profile"] = "release: opt-level=3, fat LTO, codegen-units=1"
|
||||
artifacts["rust"]["source"] = fingerprint(peer / "main.rs")
|
||||
artifacts["rust"]["lock"] = fingerprint(peer / "Cargo.lock")
|
||||
artifacts["rust"]["map"] = "ordered IndexMap with owned keys, matching Dever"
|
||||
if source_identity() != tree:
|
||||
raise RuntimeError("sources changed during build; rebuild the comparison")
|
||||
(output / "manifest.json").write_text(json.dumps(artifacts, indent=2) + "\n")
|
||||
print(output / "manifest.json")
|
||||
|
||||
|
||||
def run(arguments):
|
||||
if arguments.output.exists():
|
||||
raise FileExistsError(arguments.output)
|
||||
if arguments.cpu is not None:
|
||||
if arguments.cpu not in os.sched_getaffinity(0):
|
||||
raise ValueError("requested CPU is outside the allowed affinity")
|
||||
os.sched_setaffinity(0, {arguments.cpu})
|
||||
manifest = json.loads(arguments.manifest.read_text())
|
||||
if arguments.candidate not in manifest:
|
||||
raise ValueError("candidate is missing from the manifest")
|
||||
if arguments.parity_with is not None and arguments.parity_with not in manifest:
|
||||
raise ValueError("parity comparator is missing from the manifest")
|
||||
for artifact in manifest.values():
|
||||
actual = fingerprint(Path(artifact["path"]))
|
||||
if any(actual[field] != artifact[field] for field in ("bytes", "sha256")):
|
||||
raise ValueError("benchmark artifact changed after build")
|
||||
identities = [artifact["source_tree"] for artifact in manifest.values()
|
||||
if "source_tree" in artifact]
|
||||
if identities and any(identity != source_identity() for identity in identities):
|
||||
raise ValueError("current sources differ from the build; rebuild before measuring")
|
||||
report = {"artifacts": manifest, "cpu_affinity": sorted(os.sched_getaffinity(0)),
|
||||
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"manifest": fingerprint(arguments.manifest), "repeats": arguments.repeats,
|
||||
"samples_per_process": 10 if arguments.continuous else 1,
|
||||
"measurement": "continuous" if arguments.continuous else "cold",
|
||||
"samples": [], "growth": {}}
|
||||
operations = getattr(arguments, "operations", None) or list(CASES)
|
||||
if len(set(operations)) != len(operations) or any(operation not in CASES for operation in operations):
|
||||
raise ValueError("kernel selection contains duplicate or unknown operations")
|
||||
if arguments.check_growth and not any(operation in operations for operation in ("list", "map", "list_record")):
|
||||
raise ValueError("growth checking requires a collection construction operation")
|
||||
report["operations"] = operations
|
||||
with tempfile.TemporaryDirectory(prefix="kernel-run-", dir=arguments.output.resolve().parent) as temporary:
|
||||
execution = stage_execution(manifest, Path(temporary))
|
||||
report["execution"] = execution
|
||||
measure(arguments, report)
|
||||
|
||||
|
||||
def stage_execution(manifest, directory):
|
||||
for label, artifact in manifest.items():
|
||||
if label.startswith("dever"):
|
||||
configuration = Path(artifact["path"]).parent / "config/setting.json"
|
||||
if json.loads(configuration.read_text()) != {}:
|
||||
raise ValueError("kernel benchmark requires an empty application configuration")
|
||||
configuration = directory / "config/setting.json"
|
||||
configuration.parent.mkdir()
|
||||
configuration.write_text("{}\n")
|
||||
artifacts = {}
|
||||
for index, (label, artifact) in enumerate(manifest.items()):
|
||||
binary = directory / f"program-{index:04d}"
|
||||
method = "hardlink"
|
||||
try:
|
||||
os.link(artifact["path"], binary)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EXDEV:
|
||||
raise
|
||||
shutil.copy2(artifact["path"], binary)
|
||||
method = "copy"
|
||||
actual = fingerprint(binary)
|
||||
if any(actual[field] != artifact[field] for field in ("bytes", "sha256")):
|
||||
raise ValueError("benchmark artifact changed during staging")
|
||||
artifacts[label] = {**actual, "method": method}
|
||||
return {"artifacts": artifacts, "configuration": fingerprint(configuration)}
|
||||
|
||||
|
||||
def measure(arguments, report):
|
||||
manifest = report["artifacts"]
|
||||
operations = report["operations"]
|
||||
executables = report["execution"]["artifacts"]
|
||||
for operation in operations:
|
||||
sizes = CASES[operation]
|
||||
for count in sizes:
|
||||
ticks = ",".join(["x"] * count) if operation == "text_concat" else "x" * count
|
||||
continuous = arguments.continuous
|
||||
payload = json.dumps({"ticks": ticks})
|
||||
timings = {label: [] for label in manifest}
|
||||
process_timings = {label: [] for label in manifest}
|
||||
process_elapsed = {label: [] for label in manifest}
|
||||
for repeat in range(arguments.repeats):
|
||||
labels = list(manifest)
|
||||
offset = repeat % len(labels)
|
||||
labels = labels[offset:] + labels[:offset]
|
||||
for label in labels:
|
||||
action = (("benchmark.kernel." if label.startswith("dever") else "")
|
||||
+ ("continuous_" if continuous else "") + operation)
|
||||
started = time.perf_counter_ns()
|
||||
process = subprocess.run([executables[label]["path"], action, payload],
|
||||
capture_output=True, text=True, timeout=120, check=True)
|
||||
process_elapsed[label].append((time.perf_counter_ns() - started) / 1_000_000)
|
||||
if process.stderr:
|
||||
raise RuntimeError(process.stderr)
|
||||
value = json.loads(process.stdout)
|
||||
if label.startswith("dever"):
|
||||
if value["code"] != 0:
|
||||
raise RuntimeError("Dever benchmark returned failure")
|
||||
value = value["data"]
|
||||
values = value if continuous else [value]
|
||||
if not isinstance(values, list) or len(values) != (10 if continuous else 1):
|
||||
raise AssertionError("kernel sample count mismatch")
|
||||
if any(sample["checksum"] != count * 120 or sample["elapsed_ns"] <= 0
|
||||
for sample in values):
|
||||
raise AssertionError("kernel output or checksum mismatch")
|
||||
measured = [sample["elapsed_ns"] / 1_000_000 for sample in values]
|
||||
process_timings[label].append(measured)
|
||||
timings[label].extend(measured[1:] if continuous else measured)
|
||||
item = {"operation": operation, "count": count, "samples_ms": timings,
|
||||
"process_samples_ms": process_timings,
|
||||
"process_elapsed_ms": process_elapsed,
|
||||
"first_sample_median_ms": {
|
||||
label: statistics.median(values[0] for values in processes)
|
||||
for label, processes in process_timings.items()},
|
||||
"median_ms": {label: statistics.median(values) for label, values in timings.items()}}
|
||||
report["samples"].append(item)
|
||||
print(json.dumps(item), flush=True)
|
||||
for operation in ("list", "map", "list_record"):
|
||||
if operation not in operations:
|
||||
continue
|
||||
samples = [item for item in report["samples"] if item["operation"] == operation]
|
||||
report["growth"][operation] = {
|
||||
label: samples[-1]["median_ms"][label] / samples[0]["median_ms"][label]
|
||||
for label in manifest}
|
||||
if arguments.parity_with is not None:
|
||||
cases = [{
|
||||
"operation": item["operation"],
|
||||
"count": item["count"],
|
||||
"ratio": item["median_ms"][arguments.candidate] / item["median_ms"][arguments.parity_with],
|
||||
"passed": item["median_ms"][arguments.candidate] <= item["median_ms"][arguments.parity_with],
|
||||
} for item in report["samples"]]
|
||||
report["parity"] = {
|
||||
"candidate": arguments.candidate,
|
||||
"comparator": arguments.parity_with,
|
||||
"passed": all(item["passed"] for item in cases),
|
||||
"cases": cases,
|
||||
}
|
||||
with arguments.output.open("x") as output:
|
||||
json.dump(report, output, indent=2)
|
||||
output.write("\n")
|
||||
if arguments.check_growth and any(values[arguments.candidate] > 24 for values in report["growth"].values()):
|
||||
raise AssertionError("8x input exceeded 24x collection construction time; inspect raw samples")
|
||||
if arguments.parity_with is not None and not report["parity"]["passed"]:
|
||||
failures = [item for item in report["parity"]["cases"] if not item["passed"]]
|
||||
raise AssertionError(
|
||||
f"{len(failures)} of {len(cases)} cases did not reach {arguments.parity_with} parity; "
|
||||
f"full samples and ratios saved to {arguments.output}"
|
||||
)
|
||||
|
||||
|
||||
def repeats(value):
|
||||
parsed = int(value)
|
||||
if not 3 <= parsed <= 25:
|
||||
raise argparse.ArgumentTypeError("expected 3..25 repetitions")
|
||||
return parsed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
building = commands.add_parser("build")
|
||||
building.add_argument("--compiler", required=True)
|
||||
building.add_argument("--baseline-compiler")
|
||||
building.add_argument("--output", type=Path, required=True)
|
||||
running = commands.add_parser("run")
|
||||
running.add_argument("--manifest", type=Path, required=True)
|
||||
running.add_argument("--output", type=Path, required=True)
|
||||
running.add_argument("--repeats", type=repeats, default=5)
|
||||
running.add_argument("--continuous", action="store_true",
|
||||
help="retain ten calls per process; compare calls 2..10 separately from the first")
|
||||
running.add_argument("--cpu", type=int)
|
||||
running.add_argument("--check-growth", action="store_true")
|
||||
running.add_argument("--operations", nargs="+", choices=tuple(CASES),
|
||||
help="select diagnostic kernels; omitted runs the complete matrix")
|
||||
running.add_argument("--parity-with", choices=("go", "rust"))
|
||||
running.add_argument("--candidate", default="dever")
|
||||
arguments = parser.parse_args()
|
||||
(build if arguments.command == "build" else run)(arguments)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""长连接对端的阶段校验与采样;进程预算由统一运行器管理。"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from process import Process, summarize
|
||||
|
||||
PHASES = ("connecting", "connected", "closing", "disconnected")
|
||||
SETTLE_SECONDS = 0.3
|
||||
PROCESS_MARGIN_SECONDS = 10
|
||||
|
||||
|
||||
def phase_sequence(cycles):
|
||||
return [(cycle, phase) for cycle in range(cycles) for phase in PHASES]
|
||||
|
||||
|
||||
def lifecycle_seconds(cycles, hold_seconds, timeout_ms):
|
||||
timeout_seconds = timeout_ms / 1000
|
||||
# Each cycle has independent connect, hold-overrun and close deadlines.
|
||||
return cycles * (hold_seconds + 3 * timeout_seconds + SETTLE_SECONDS) + PROCESS_MARGIN_SECONDS
|
||||
|
||||
|
||||
def stable_phase(before, after):
|
||||
return before[-1] if before and before == after else None
|
||||
|
||||
|
||||
def parse_progress(text, cycles):
|
||||
phases = []
|
||||
result = None
|
||||
# stdout is sampled while its writer is alive; only consume complete lines.
|
||||
for line in text.splitlines(keepends=True):
|
||||
if not line.endswith("\n"):
|
||||
continue
|
||||
if result is not None:
|
||||
raise ValueError("live benchmark output continued after its final result")
|
||||
if line.startswith("PHASE|"):
|
||||
_, cycle, phase = line.strip().split("|")
|
||||
phases.append((int(cycle), phase))
|
||||
elif line.startswith("{"):
|
||||
result = json.loads(line)
|
||||
else:
|
||||
raise ValueError(f"unexpected live benchmark output: {line.strip()}")
|
||||
if phases != phase_sequence(cycles)[:len(phases)]:
|
||||
raise ValueError("live benchmark phases are missing, duplicated or out of order")
|
||||
return phases, result
|
||||
|
||||
|
||||
def validate_result(result, protocol, connections, cycles):
|
||||
counts = ("attempted_connections", "connected", "disconnected", "messages", "errors")
|
||||
if result is None or any(type(result[key]) is not int or result[key] < 0 for key in counts):
|
||||
raise ValueError("missing or invalid live counters")
|
||||
expected = connections * cycles
|
||||
if (result["protocol"] != protocol or result["connections"] != connections or result["cycles"] != cycles
|
||||
or any(result[key] != expected for key in counts[:3])
|
||||
or result["messages"] < expected or result["errors"] != 0 or result["elapsed_ms"] <= 0):
|
||||
raise ValueError("live benchmark did not validate and close all requested connections")
|
||||
|
||||
|
||||
def load_live(args, process, port, protocol, connections, timeout_ms):
|
||||
scheme, path = {"tcp": ("tcp", "/"), "ws": ("ws", "/ws"), "sse": ("http", "/events")}[protocol]
|
||||
hold_ms = round(args.duration * 1000)
|
||||
command = [args.artifacts / "live_bench", protocol, f"{scheme}://127.0.0.1:{port}{path}",
|
||||
connections, args.cycles, hold_ms, timeout_ms]
|
||||
phases = {phase: [] for phase in phase_sequence(args.cycles)}
|
||||
with Process(command, process.directory / "load", cpus=args.client_cpus,
|
||||
peer_settings={"workers": args.client_workers}) as client:
|
||||
deadline = time.monotonic() + lifecycle_seconds(args.cycles, args.duration, timeout_ms)
|
||||
while client.child.poll() is None:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("long connection client exceeded its bounded lifecycle")
|
||||
progress_before, _ = parse_progress(client.text(), args.cycles)
|
||||
first = len(process.samples)
|
||||
process.sample()
|
||||
process.check()
|
||||
if process.child.poll() is not None:
|
||||
raise RuntimeError("server exited during long connection load")
|
||||
progress_after, _ = parse_progress(client.text(), args.cycles)
|
||||
if phase := stable_phase(progress_before, progress_after):
|
||||
phases[phase].extend(process.samples[first:])
|
||||
client.sample()
|
||||
time.sleep(args.interval)
|
||||
client.check()
|
||||
progress, result = parse_progress(client.text(), args.cycles)
|
||||
if progress != phase_sequence(args.cycles):
|
||||
raise ValueError("long connection client ended before all phases")
|
||||
validate_result(result, protocol, connections, args.cycles)
|
||||
# Connection setup/close can finish between polls. Only the two explicit
|
||||
# hold windows require observations; retain setup samples when present.
|
||||
observations = []
|
||||
for (cycle, phase), samples in phases.items():
|
||||
if phase in ("connected", "disconnected") and len(samples) < 2:
|
||||
raise ValueError(f"sampling interval missed {cycle}/{phase} window")
|
||||
observations.append({"cycle": cycle, "phase": phase,
|
||||
"resources": summarize(samples) if samples else None,
|
||||
"last": samples[-1] if samples else None, "raw": samples})
|
||||
return {"result": result, "phases": observations,
|
||||
"client_resources": summarize(client.samples), "client_raw": client.samples}
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Linux 进程与可选专属 cgroup 的生命周期和采样。"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from settings import stage_peer
|
||||
|
||||
|
||||
def key_values(path):
|
||||
return {fields[0].rstrip(":"): fields[1] for line in path.read_text().splitlines()
|
||||
if len(fields := line.split()) >= 2}
|
||||
|
||||
|
||||
def process_sample(pid):
|
||||
root = Path("/proc") / str(pid)
|
||||
status = key_values(root / "status")
|
||||
if "VmRSS" not in status:
|
||||
raise ProcessLookupError(f"process {pid} exited during sampling")
|
||||
memory = key_values(root / "smaps_rollup")
|
||||
# comm 字段可含空格和括号;从最后一个右括号之后解析固定 stat 字段。
|
||||
stat = (root / "stat").read_text().rsplit(")", 1)[1].split()
|
||||
switches = {"voluntary_ctxt_switches": 0, "nonvoluntary_ctxt_switches": 0}
|
||||
for thread in (root / "task").iterdir():
|
||||
try:
|
||||
thread_status = key_values(thread / "status")
|
||||
except FileNotFoundError:
|
||||
continue # A completed blocking thread can disappear during a sample.
|
||||
for name in switches:
|
||||
switches[name] += int(thread_status[name])
|
||||
return {
|
||||
"time": time.monotonic(),
|
||||
"rss_bytes": int(status["VmRSS"]) * 1024,
|
||||
"rss_high_water_bytes": int(status["VmHWM"]) * 1024,
|
||||
"pss_bytes": int(memory["Pss"]) * 1024,
|
||||
"threads": int(status["Threads"]),
|
||||
"fds": len(list((root / "fd").iterdir())),
|
||||
"cpu_seconds": (int(stat[11]) + int(stat[12])) / os.sysconf("SC_CLK_TCK"),
|
||||
**switches,
|
||||
}
|
||||
|
||||
|
||||
class Budget:
|
||||
def __init__(self, parent, memory_mib, cpu_quota):
|
||||
self.path = None
|
||||
self.final = None
|
||||
if parent is None:
|
||||
if memory_mib is not None or cpu_quota is not None:
|
||||
raise ValueError("memory/cpu quotas require --cgroup-parent")
|
||||
return
|
||||
self.path = parent.resolve() / ("dever-perf-" + uuid.uuid4().hex)
|
||||
self.path.mkdir()
|
||||
try:
|
||||
if memory_mib is not None:
|
||||
(self.path / "memory.max").write_text(str(memory_mib * 1024 * 1024))
|
||||
(self.path / "memory.swap.max").write_text("0")
|
||||
if cpu_quota is not None:
|
||||
(self.path / "cpu.max").write_text(f"{round(cpu_quota * 100000)} 100000")
|
||||
except BaseException:
|
||||
self.path.rmdir()
|
||||
raise
|
||||
|
||||
def sample(self):
|
||||
if self.path is None:
|
||||
return None
|
||||
return {
|
||||
"current_bytes": int((self.path / "memory.current").read_text()),
|
||||
"peak_bytes": int((self.path / "memory.peak").read_text()),
|
||||
"events": {k: int(v) for k, v in key_values(self.path / "memory.events").items()},
|
||||
"cpu": {k: int(v) for k, v in key_values(self.path / "cpu.stat").items()},
|
||||
"max": (self.path / "memory.max").read_text().strip(),
|
||||
"cpu_max": (self.path / "cpu.max").read_text().strip(),
|
||||
}
|
||||
|
||||
def close(self):
|
||||
if self.path is not None:
|
||||
try:
|
||||
self.final = self.sample()
|
||||
except OSError as error:
|
||||
try:
|
||||
self.path.rmdir()
|
||||
except OSError as cleanup_error:
|
||||
error.add_note(f"cannot remove owned cgroup {self.path}: {cleanup_error}")
|
||||
raise
|
||||
else:
|
||||
self.path.rmdir()
|
||||
|
||||
|
||||
class Process:
|
||||
def __init__(self, command, directory, *, peer_settings=None, cpus=None, budget=None):
|
||||
self.command = [str(arg) for arg in command]
|
||||
self.directory = directory
|
||||
self.budget = budget
|
||||
self.samples = []
|
||||
self.directory.mkdir(parents=True, exist_ok=False)
|
||||
if peer_settings is not None:
|
||||
self.command[0] = str(stage_peer(command[0], directory, peer_settings))
|
||||
self.stdout = (directory / "stdout.log").open("w")
|
||||
self.stderr = (directory / "stderr.log").open("w")
|
||||
self.started = time.monotonic()
|
||||
|
||||
def prepare_child():
|
||||
# 运行器单线程;只对即将 exec 的自有子进程设置 CPU/cgroup。
|
||||
if cpus is not None:
|
||||
os.sched_setaffinity(0, cpus)
|
||||
if budget is not None and budget.path is not None:
|
||||
(budget.path / "cgroup.procs").write_text(str(os.getpid()))
|
||||
|
||||
try:
|
||||
self.child = subprocess.Popen(self.command, stdout=self.stdout, stderr=self.stderr,
|
||||
preexec_fn=prepare_child, start_new_session=True)
|
||||
except BaseException:
|
||||
self.stdout.close()
|
||||
self.stderr.close()
|
||||
raise
|
||||
|
||||
def text(self):
|
||||
return (self.directory / "stdout.log").read_text()
|
||||
|
||||
def descriptors(self):
|
||||
descriptors = {}
|
||||
for path in (Path("/proc") / str(self.child.pid) / "fd").iterdir():
|
||||
try:
|
||||
descriptors[path.name] = os.readlink(path)
|
||||
except FileNotFoundError:
|
||||
continue # A descriptor may close while /proc is being read.
|
||||
return descriptors
|
||||
|
||||
def sample(self):
|
||||
if self.child.poll() is not None:
|
||||
return
|
||||
try:
|
||||
sample = process_sample(self.child.pid)
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
# Linux can release /proc memory fields before waitpid reports exit.
|
||||
# This observation is unavailable; check() still owns exit status.
|
||||
return
|
||||
except PermissionError as failure:
|
||||
try:
|
||||
self.child.wait(timeout=0.1)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise failure
|
||||
return
|
||||
sample["elapsed_seconds"] = sample.pop("time") - self.started
|
||||
if self.budget is not None:
|
||||
sample["cgroup"] = self.budget.sample()
|
||||
self.samples.append(sample)
|
||||
|
||||
def ready(self, timeout=10):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
self.sample()
|
||||
for line in self.text().splitlines():
|
||||
if line.startswith("ERROR|"):
|
||||
raise RuntimeError(line)
|
||||
if line.startswith("READY|"):
|
||||
self.ready_seconds = time.monotonic() - self.started
|
||||
return int(line.split("|")[1])
|
||||
if self.child.poll() is not None:
|
||||
raise RuntimeError(f"process exited before readiness: {self.directory}")
|
||||
time.sleep(0.005)
|
||||
raise TimeoutError(f"readiness timeout: {self.directory}")
|
||||
|
||||
def check(self):
|
||||
code = self.child.poll()
|
||||
if code is not None and code != 0:
|
||||
raise RuntimeError(f"process exited {code}: {self.directory}")
|
||||
if "ERROR|" in self.text() or (self.directory / "stderr.log").stat().st_size:
|
||||
raise RuntimeError(f"benchmark reported an error: {self.directory}")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
if self.child.poll() is None:
|
||||
try:
|
||||
os.killpg(self.child.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
# 子进程可能恰好在 poll 与 signal 之间自然退出。
|
||||
pass
|
||||
try:
|
||||
self.child.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(self.child.pid, signal.SIGKILL)
|
||||
self.child.wait(timeout=3)
|
||||
finally:
|
||||
self.stdout.close()
|
||||
self.stderr.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
|
||||
|
||||
def observe(processes, seconds, interval):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
for process in processes:
|
||||
process.sample()
|
||||
process.check()
|
||||
time.sleep(min(interval, max(0, deadline - time.monotonic())))
|
||||
|
||||
|
||||
def summarize(samples):
|
||||
if not samples:
|
||||
raise ValueError("process ended before any resource sample")
|
||||
result = {"samples": len(samples)}
|
||||
for metric in ("rss_bytes", "pss_bytes", "threads", "fds"):
|
||||
values = sorted(sample[metric] for sample in samples)
|
||||
result[metric + "_median"] = values[len(values) // 2]
|
||||
result[metric + "_max"] = max(values)
|
||||
result["rss_high_water_bytes"] = max(sample["rss_high_water_bytes"] for sample in samples)
|
||||
elapsed = samples[-1]["elapsed_seconds"] - samples[0]["elapsed_seconds"]
|
||||
cpu = samples[-1]["cpu_seconds"] - samples[0]["cpu_seconds"]
|
||||
result["cpu_seconds_sampled"] = cpu
|
||||
result["cpu_cores_sampled"] = cpu / elapsed if elapsed > 0 else None
|
||||
for name in ("voluntary_ctxt_switches", "nonvoluntary_ctxt_switches"):
|
||||
result[name + "_sampled"] = samples[-1][name] - samples[0][name]
|
||||
return result
|
||||
|
||||
|
||||
def resource_trend(samples):
|
||||
"""报告四段中位数与后半程增长,避免将启动分配误记为长期泄漏。"""
|
||||
if len(samples) < 4:
|
||||
raise ValueError("resource trend requires at least four observations")
|
||||
windows = [samples[len(samples) * index // 4:len(samples) * (index + 1) // 4]
|
||||
for index in range(4)]
|
||||
medians = [statistics.median(sample["rss_bytes"] for sample in window) for window in windows]
|
||||
elapsed = samples[-1]["elapsed_seconds"] - samples[0]["elapsed_seconds"]
|
||||
return {"observed_seconds": elapsed, "rss_quarter_medians_bytes": medians,
|
||||
"rss_late_growth_bytes": medians[-1] - medians[2],
|
||||
"rss_late_growth_bytes_per_second": (medians[-1] - medians[2]) / (elapsed / 4) if elapsed > 0 else None,
|
||||
"fds_first": samples[0]["fds"], "fds_last": samples[-1]["fds"]}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,59 @@
|
||||
"""基准配置与隔离 peer 布置;配置只来自 config/setting.json。"""
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PEER_CONFIGURATION = "setting-json-v1"
|
||||
|
||||
|
||||
def write_private_json(path, value, *, replace=False):
|
||||
path = Path(path)
|
||||
temporary = None
|
||||
if replace:
|
||||
descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-")
|
||||
else:
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w") as output:
|
||||
os.fchmod(output.fileno(), 0o600)
|
||||
json.dump(value, output, indent=2)
|
||||
output.write("\n")
|
||||
if temporary is not None:
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def stage_peer(executable, directory, settings):
|
||||
"""独立配置共用不可变程序;测量期间不得重写输入二进制。"""
|
||||
executable = Path(executable).resolve(strict=True)
|
||||
destination = directory / executable.name
|
||||
(directory / "config").mkdir()
|
||||
write_private_json(directory / "config/setting.json", {"benchmark": settings})
|
||||
try:
|
||||
os.link(executable, destination)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EXDEV:
|
||||
raise
|
||||
# 配置目录可以位于另一文件系统;这种情况下硬链接不可用。
|
||||
with destination.open("xb") as output, executable.open("rb") as source:
|
||||
shutil.copyfileobj(source, output)
|
||||
shutil.copymode(executable, destination)
|
||||
return destination.resolve()
|
||||
|
||||
|
||||
def configured_peer(name, setting_path=ROOT / "config/setting.json"):
|
||||
document = json.loads(setting_path.read_text())
|
||||
section = document.get("performance") if isinstance(document, dict) else None
|
||||
value = section.get(name) if isinstance(section, dict) else None
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"config/setting.json must define performance.{name} as a binary path")
|
||||
# 仓库设置中的相对路径以项目根目录为基准。
|
||||
path = Path(value)
|
||||
return (setting_path.parent.parent / path).resolve(strict=True)
|
||||
@@ -0,0 +1,272 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from cms import (CmsClient, ReadLatency, concurrent_reads, decode_response, execute, isolated_settings, latency_summary,
|
||||
provision_identity, publish_workload, run_postgres_case, run_postgres_rbac)
|
||||
from settings import write_private_json
|
||||
|
||||
|
||||
class CmsMeasurementTests(unittest.TestCase):
|
||||
def test_identity_provisioning_uses_current_tenant_commands(self):
|
||||
identity = {"tenant_id": 19, "user_id": 31, "credential_id": 7}
|
||||
executable = Path("owned-app")
|
||||
with patch("cms.execute", side_effect=[identity, None, None, None]) as execute_command:
|
||||
actual, password = provision_identity(executable, Path("owned-output"), "example")
|
||||
self.assertEqual(actual, identity)
|
||||
self.assertTrue(password)
|
||||
calls = execute_command.call_args_list
|
||||
self.assertEqual(calls[0].args[0][1], "user.account.bootstrap")
|
||||
self.assertEqual(calls[1].args[0],
|
||||
[executable, "--tenant", 19, "dever.tenant.migrate", "null"])
|
||||
for call, site in zip(calls[2:], ("admin", "front")):
|
||||
self.assertEqual(call.args[0][:4],
|
||||
[executable, "--tenant", 19, "dever.auth.owner"])
|
||||
self.assertEqual(json.loads(call.args[0][4]), {"site": site, "user_id": 31})
|
||||
|
||||
def test_long_read_histogram_is_bounded_and_reports_upper_bounds(self):
|
||||
latency = ReadLatency()
|
||||
for _ in range(10000):
|
||||
latency.add(3)
|
||||
other = ReadLatency()
|
||||
other.add(100)
|
||||
latency.merge(other)
|
||||
self.assertEqual(len(latency.buckets), 2048)
|
||||
self.assertEqual(latency.count, 10001)
|
||||
self.assertGreaterEqual(latency.percentile(.99), 3)
|
||||
self.assertLess(latency.percentile(.99), 3.05)
|
||||
client = CmsClient(12345)
|
||||
client.record_timings = False
|
||||
client.connection = SimpleNamespace(request=Mock(), getresponse=Mock(return_value=SimpleNamespace(
|
||||
status=200, read=Mock(return_value=b'{"code":0,"message":"ok","data":1}'))), close=Mock())
|
||||
try:
|
||||
self.assertEqual(client.request("GET", "/test"), 1)
|
||||
self.assertEqual(client.timings, {})
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_concurrent_read_failure_closes_clients_and_cannot_be_counted_as_success(self):
|
||||
expected = {"total": 1, "items": [{"title": "published"}]}
|
||||
for reply in ({"total": 0, "items": []}, RuntimeError("HTTP 403")):
|
||||
with self.subTest(reply=reply):
|
||||
opened = []
|
||||
|
||||
def client(_port):
|
||||
request = Mock(side_effect=reply) if isinstance(reply, Exception) else Mock(return_value=reply)
|
||||
instance = SimpleNamespace(cookies={}, timings={}, request=request, close=Mock())
|
||||
opened.append(instance)
|
||||
return instance
|
||||
|
||||
process = SimpleNamespace(samples=[], check=Mock())
|
||||
with patch("cms.CmsClient", side_effect=client), patch("cms.observe"):
|
||||
with self.assertRaises(RuntimeError):
|
||||
concurrent_reads(12345, {"front": "test-only-cookie"}, expected, process,
|
||||
concurrency=2, seconds=.1, interval=.01)
|
||||
self.assertEqual(len(opened), 2)
|
||||
for instance in opened:
|
||||
instance.close.assert_called_once()
|
||||
|
||||
def test_delete_inputs_use_query_instead_of_json_body(self):
|
||||
client = CmsClient(12345)
|
||||
response = SimpleNamespace(status=200, read=Mock(return_value=b'{"code":0,"message":"ok","data":true}'))
|
||||
connection = SimpleNamespace(request=Mock(), getresponse=Mock(return_value=response), close=Mock())
|
||||
client.connection = connection
|
||||
try:
|
||||
self.assertIs(client.request("DELETE", "/revoke", {"user_id": 3, "role_id": "fixture reader"}), True)
|
||||
sent = connection.request.call_args.args
|
||||
self.assertEqual(sent[:3], ("DELETE", "/revoke?user_id=3&role_id=fixture+reader", None))
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_postgres_rbac_checks_catalog_roles_and_same_session_revocation(self):
|
||||
catalog = [
|
||||
{"component": "news", "domain": "article", "site": "admin", "action": "list",
|
||||
"method": "GET", "key": "news.article.admin.list"},
|
||||
{"component": "user", "domain": "authorization", "site": "admin", "action": "permissions",
|
||||
"method": "GET", "key": "user.authorization.admin.permissions"},
|
||||
{"component": "news", "domain": "article", "site": "front", "action": "list",
|
||||
"method": "GET", "key": "news.article.front.list"},
|
||||
]
|
||||
identity = {"user_id": 1, "tenant_id": 2}
|
||||
member = {"user_id": 3, "tenant_id": 2, "credential_id": 4}
|
||||
login = [{"account": {"id": 3}, "tenant_id": 2, "site": site}
|
||||
for site in ("admin", "front")]
|
||||
for failure in (False, True):
|
||||
with self.subTest(failure=failure):
|
||||
owner = SimpleNamespace(request=Mock(side_effect=[
|
||||
[entry for entry in catalog if entry["site"] == "admin"],
|
||||
[entry for entry in catalog if entry["site"] == "front"],
|
||||
] + [True] * 9))
|
||||
responses = login + ([RuntimeError("missing denial")] if failure else [None] * 11)
|
||||
client = SimpleNamespace(request=Mock(side_effect=responses), close=Mock())
|
||||
with patch("cms.execute", return_value=member), patch("cms.CmsClient", return_value=client):
|
||||
if failure:
|
||||
with self.assertRaisesRegex(RuntimeError, "missing denial"):
|
||||
run_postgres_rbac(Path("owned-app"), Path("owned-output"), 12345, owner, identity)
|
||||
else:
|
||||
result = run_postgres_rbac(Path("owned-app"), Path("owned-output"), 12345, owner, identity)
|
||||
self.assertEqual(result["member_user_id"], 3)
|
||||
self.assertEqual(set(result["permissions"]), {"admin_read", "admin_permissions", "front_list"})
|
||||
self.assertNotIn("password", result)
|
||||
calls = client.request.call_args_list[2:]
|
||||
self.assertEqual([call.kwargs.get("expected", 200) for call in calls],
|
||||
[403, 403, 403, 200, 200, 200, 403, 200, 200, 403, 403])
|
||||
saves = [call for call in owner.request.call_args_list if call.args[1].endswith("/save")]
|
||||
self.assertEqual(owner.request.call_args_list[1].kwargs["site"], "front")
|
||||
self.assertEqual(saves[0].args[2]["id"], saves[2].args[2]["id"])
|
||||
self.assertNotEqual(saves[0].kwargs["site"], saves[2].kwargs["site"])
|
||||
client.close.assert_called_once()
|
||||
|
||||
def test_postgres_case_keeps_source_configuration_on_build_failure(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-source-") as temporary:
|
||||
directory = Path(temporary)
|
||||
source = directory / "project"
|
||||
source.mkdir()
|
||||
for name in ("config", "module", "test"):
|
||||
(source / name).mkdir()
|
||||
original = {
|
||||
"runtime": {"mode": "all"},
|
||||
"database": {"default": {
|
||||
"type": "postgres", "url": "postgres://fixture@127.0.0.1:12345/control",
|
||||
"tenant_database_prefix": "dever_test_owned",
|
||||
}},
|
||||
"auth": {"providers": {"session": {"jwtSecret": "original"}}},
|
||||
"sites": {}, "log": {"level": "info"},
|
||||
}
|
||||
setting = source / "config/setting.json"
|
||||
write_private_json(setting, original)
|
||||
before = setting.read_bytes()
|
||||
staged_paths = []
|
||||
|
||||
def fail_check(command, *_args, **_kwargs):
|
||||
staged = command[2]
|
||||
staged_paths.append(staged)
|
||||
self.assertNotEqual(staged, source)
|
||||
changed = json.loads((staged / "config/setting.json").read_text())
|
||||
self.assertEqual(changed["runtime"]["mode"], "api")
|
||||
self.assertNotEqual(changed["auth"]["providers"]["session"]["jwtSecret"], "original")
|
||||
raise RuntimeError("owned check failed")
|
||||
|
||||
output = directory / "result"
|
||||
with patch("cms.execute", side_effect=fail_check):
|
||||
with self.assertRaisesRegex(RuntimeError, "owned check failed"):
|
||||
run_postgres_case(source, output, directory / "compiler", articles=2)
|
||||
self.assertEqual(setting.read_bytes(), before)
|
||||
self.assertFalse((source / "cms-app").exists())
|
||||
self.assertTrue(staged_paths)
|
||||
self.assertFalse(staged_paths[0].exists())
|
||||
self.assertEqual(json.loads((output / "report.json").read_text())["status"], "failed")
|
||||
|
||||
def test_private_config_replacement_is_atomic_and_keeps_exclusive_creation(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-setting-") as temporary:
|
||||
directory = Path(temporary)
|
||||
setting = directory / "setting.json"
|
||||
write_private_json(setting, {"original": True})
|
||||
with self.assertRaises(FileExistsError):
|
||||
write_private_json(setting, {"overwrite": True})
|
||||
with self.assertRaises(TypeError):
|
||||
write_private_json(setting, {"invalid": object()}, replace=True)
|
||||
self.assertEqual(json.loads(setting.read_text()), {"original": True})
|
||||
self.assertEqual(list(directory.iterdir()), [setting])
|
||||
write_private_json(setting, {"postgres": True}, replace=True)
|
||||
self.assertEqual(json.loads(setting.read_text()), {"postgres": True})
|
||||
self.assertEqual(setting.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(list(directory.iterdir()), [setting])
|
||||
|
||||
def test_envelope_failure_cannot_count_as_publication(self):
|
||||
self.assertEqual(decode_response(b'{"code":0,"message":"ok","data":{"id":1}}', 200),
|
||||
{"id": 1})
|
||||
for document, status in (({"code": 0, "message": "ok", "data": {}}, 500),
|
||||
({"code": 409, "message": "conflict", "data": None}, 200),
|
||||
({"id": 1}, 200)):
|
||||
with self.assertRaises(RuntimeError):
|
||||
decode_response(json.dumps(document).encode(), status)
|
||||
|
||||
def test_isolation_preserves_site_contract_without_mutating_input(self):
|
||||
source = {
|
||||
"runtime": {"mode": "all"}, "http": {"port": 8080},
|
||||
"database": {"default": {"type": "sqlite", "path": "old.db"}},
|
||||
"auth": {"providers": {"session": {"jwtSecret": "example", "verify": "user.account.verify"}}},
|
||||
"sites": {"admin": {"path": "admin", "auth": "session", "origin": "https://example.invalid"}},
|
||||
"log": {"level": "info"},
|
||||
}
|
||||
staged = isolated_settings(source, 12345)
|
||||
self.assertEqual(source["runtime"]["mode"], "all")
|
||||
self.assertEqual(source["database"]["default"]["path"], "old.db")
|
||||
self.assertEqual(staged["runtime"]["mode"], "api")
|
||||
self.assertEqual(staged["sites"]["admin"]["path"], "admin")
|
||||
self.assertEqual(staged["sites"]["admin"]["origin"], "http://127.0.0.1:12345")
|
||||
self.assertEqual(staged["sites"]["admin"]["hosts"], ["127.0.0.1"])
|
||||
self.assertNotEqual(staged["auth"]["providers"]["session"]["jwtSecret"], "example")
|
||||
self.assertEqual(staged["auth"]["providers"]["session"]["verify"], "user.account.verify")
|
||||
self.assertEqual(source["log"]["level"], "info")
|
||||
self.assertEqual(staged["log"]["level"], "error")
|
||||
source["database"]["default"]["type"] = "postgres"
|
||||
with self.assertRaises(RuntimeError):
|
||||
isolated_settings(source, 12345)
|
||||
|
||||
def test_postgres_isolation_preserves_explicit_control_database_and_prefix(self):
|
||||
source = {
|
||||
"runtime": {"mode": "all"},
|
||||
"database": {"default": {
|
||||
"type": "postgres", "url": "postgres://fixture@127.0.0.1:12345/control",
|
||||
"tenant_database_prefix": "dever_test_owned", "tls": "disabled",
|
||||
}},
|
||||
"auth": {"providers": {"session": {"jwtSecret": "example"}}},
|
||||
"sites": {"admin": {"origin": "https://example.invalid"}},
|
||||
"log": {"level": "info"},
|
||||
}
|
||||
staged = isolated_settings(source, 23456)
|
||||
self.assertEqual(staged["database"], source["database"])
|
||||
self.assertEqual(staged["http"], {"host": "127.0.0.1", "port": 23456})
|
||||
self.assertNotIn("path", staged["database"]["default"])
|
||||
source["database"]["default"].pop("tenant_database_prefix")
|
||||
with self.assertRaises(RuntimeError):
|
||||
isolated_settings(source, 23456)
|
||||
|
||||
def test_publish_workload_uses_tenant_and_title_parameters(self):
|
||||
identity = {"credential_id": 11, "user_id": 3, "tenant_id": 2}
|
||||
publication = {"slug": "bench-0", "title": "Tenant A 0 published",
|
||||
"body": "Published body"}
|
||||
client = SimpleNamespace(request=Mock(side_effect=[
|
||||
{"account": {"id": 3}, "tenant_id": 2, "site": "admin"},
|
||||
{"account": {"id": 3}, "tenant_id": 2, "site": "front"},
|
||||
{"id": 1, "slug": "bench-0", "title": "Tenant A 0", "version": 1},
|
||||
{"id": 7, "article_id": 1, "title": "Tenant A 0 published", "version": 2},
|
||||
publication, {"total": 1, "items": [publication]}, None,
|
||||
]))
|
||||
process = SimpleNamespace(sample=Mock())
|
||||
contract = publish_workload(client, process, identity, "fixture-password", 1,
|
||||
tenant_key="tenant_a", title_prefix="Tenant A")
|
||||
self.assertEqual(contract, {"articles": 1, "titles": ["Tenant A 0 published"],
|
||||
"duplicate_publish_status": 409})
|
||||
for call in client.request.call_args_list[:2]:
|
||||
self.assertEqual(call.args[2]["tenant"], "tenant_a")
|
||||
revision = client.request.call_args_list[3]
|
||||
self.assertEqual(revision.args[:2], ("POST", "/content/revision/admin/manage/create"))
|
||||
self.assertEqual(revision.args[2]["expected_version"], 1)
|
||||
self.assertEqual(revision.args[2]["selected_article_id"], 1)
|
||||
self.assertEqual(process.sample.call_count, 1)
|
||||
|
||||
def test_small_workload_reports_samples_without_implying_p99(self):
|
||||
self.assertEqual(latency_summary({"publish": [4, 1, 3]}),
|
||||
{"publish": {"samples": 3, "p50_ms": 3, "max_ms": 4}})
|
||||
|
||||
def test_command_timeout_retains_output_and_reaps_its_owned_process(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-timeout-") as temporary:
|
||||
directory = Path(temporary)
|
||||
command = [sys.executable, "-u", "-c",
|
||||
"import time,sys; print('starting'); print('diagnostic',file=sys.stderr); time.sleep(10)"]
|
||||
with self.assertRaises(subprocess.TimeoutExpired):
|
||||
execute(command, directory, "timeout", timeout=0.2)
|
||||
self.assertEqual((directory / "timeout.stdout.log").read_text(), "starting\n")
|
||||
self.assertEqual((directory / "timeout.stderr.log").read_text(), "diagnostic\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""用独立 Python 对端检查 Rust 长连接客户端的正文与生命周期判定。"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socketserver
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from live import parse_progress, phase_sequence, validate_result
|
||||
from settings import configured_peer, stage_peer
|
||||
|
||||
|
||||
@contextmanager
|
||||
def serving(server):
|
||||
with server:
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server.server_address[1]
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=2)
|
||||
if thread.is_alive():
|
||||
raise RuntimeError("owned test listener did not stop")
|
||||
|
||||
|
||||
class Echo(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
self.request.settimeout(2)
|
||||
while payload := self.request.recv(4096):
|
||||
response = b"!" * len(payload) if self.server.wrong_body else payload
|
||||
self.request.sendall(response)
|
||||
if self.server.wrong_body:
|
||||
return
|
||||
|
||||
|
||||
class InvalidEvent(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
body = b"event: live\nid: 1\ndata: wrong\n\n"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
# The client aborts sibling sessions after the first deliberate mismatch.
|
||||
return
|
||||
|
||||
|
||||
class SilentEvent(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"event: live\nid: 1\ndata: ready\n\n")
|
||||
self.wfile.flush()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
class LiveClientTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory(prefix="dever-live-client-")
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.executable = stage_peer(configured_peer("live_peer"), Path(directory.name), {"workers": 1})
|
||||
|
||||
def execute(self, protocol, port, path):
|
||||
scheme = "http" if protocol == "sse" else protocol
|
||||
return subprocess.run([self.executable, protocol, f"{scheme}://127.0.0.1:{port}{path}", "2", "2", "300", "1000"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
|
||||
def test_tcp_reconnect_and_exact_echo(self):
|
||||
listener = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Echo)
|
||||
listener.wrong_body = False
|
||||
with serving(listener) as port:
|
||||
result = self.execute("tcp", port, "/")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
phases, counts = parse_progress(result.stdout, 2)
|
||||
self.assertEqual(phases, phase_sequence(2))
|
||||
validate_result(counts, "tcp", 2, 2)
|
||||
|
||||
def test_tcp_wrong_body_cannot_be_counted_as_held(self):
|
||||
listener = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Echo)
|
||||
listener.wrong_body = True
|
||||
with serving(listener) as port:
|
||||
result = self.execute("tcp", port, "/")
|
||||
self.assert_failure(result)
|
||||
|
||||
def test_sse_wrong_event_cannot_pass_connection_readiness(self):
|
||||
with serving(ThreadingHTTPServer(("127.0.0.1", 0), InvalidEvent)) as port:
|
||||
result = self.execute("sse", port, "/events")
|
||||
self.assert_failure(result)
|
||||
|
||||
def test_sse_silent_hold_cannot_pass_heartbeat_validation(self):
|
||||
with serving(ThreadingHTTPServer(("127.0.0.1", 0), SilentEvent)) as port:
|
||||
result = self.execute("sse", port, "/events")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
counts = json.loads(result.stdout.splitlines()[-1])
|
||||
self.assertGreater(counts["errors"], 0)
|
||||
self.assertIn("PHASE|0|connected\n", result.stdout)
|
||||
self.assertNotIn("PHASE|0|closing\n", result.stdout)
|
||||
|
||||
def assert_failure(self, result):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
counts = json.loads(result.stdout.splitlines()[-1])
|
||||
self.assertGreater(counts["errors"], 0)
|
||||
self.assertNotIn("PHASE|0|connected\n", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""用独立的 Python HTTP 对端验证负载计数;只占用自有随机回环端口。"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from process import Process
|
||||
from run import validate_load
|
||||
from settings import configured_peer, stage_peer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TLS = ROOT / "test/dever-tests/fixtures/tls"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def peer(body):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
worker = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01})
|
||||
worker.start()
|
||||
try:
|
||||
yield server.server_port
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join(timeout=2)
|
||||
if worker.is_alive():
|
||||
raise RuntimeError("owned HTTP peer did not stop")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def benchmark_service(executable, mode):
|
||||
settings = {"workers": 1, "connections": 4, "task_capacity": 128,
|
||||
"timeout_ms": 1000, "lifetime_ms": 5000, "http2_streams": 16,
|
||||
"http2_stream_window_bytes": 65535, "http2_connection_window_bytes": 262144,
|
||||
"cert_path": str(TLS / "server.pem"), "key_path": str(TLS / "server-key.pem")}
|
||||
with tempfile.TemporaryDirectory(prefix="dever-h2-bench-") as directory:
|
||||
with Process([executable, mode], Path(directory) / "server", peer_settings=settings) as process:
|
||||
yield process.ready()
|
||||
process.check()
|
||||
|
||||
|
||||
class NetworkMeasurementTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory(prefix="dever-network-client-")
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.executable = stage_peer(configured_peer("network_peer"), Path(directory.name), {"workers": 1})
|
||||
|
||||
def load(self, port):
|
||||
executable = self.executable
|
||||
output = subprocess.run([str(executable), "load", "h1", f"http://localhost:{port}",
|
||||
"/plain", "20", "0.2", "1", "1", "500", "true", "-"],
|
||||
capture_output=True, text=True, timeout=5, check=True)
|
||||
self.assertEqual(output.stderr, "")
|
||||
result = json.loads(output.stdout)
|
||||
validate_load(result)
|
||||
self.assertEqual(result["scheduled"], 4)
|
||||
self.assertGreaterEqual(result["elapsed_seconds"], 0.2)
|
||||
return result
|
||||
|
||||
def test_success_requires_exact_body_and_full_window(self):
|
||||
with peer(b"hello") as port:
|
||||
result = self.load(port)
|
||||
self.assertEqual(result["succeeded"], 4)
|
||||
self.assertEqual(result["errors"], 0)
|
||||
self.assertEqual(result["dropped"], 0)
|
||||
self.assertEqual(result["latency_samples"], 4)
|
||||
self.assertEqual(result["http_version"], "h1")
|
||||
self.assertEqual(result["physical_connections"], 1)
|
||||
self.assertEqual(result["streams_per_connection"], 1)
|
||||
self.assertEqual(result["request_slots"], 1)
|
||||
|
||||
def test_wrong_body_is_an_error_with_no_success_latency(self):
|
||||
with peer(b"wrong") as port:
|
||||
result = self.load(port)
|
||||
self.assertEqual(result["succeeded"], 0)
|
||||
self.assertEqual(result["errors"], 4)
|
||||
self.assertEqual(result["latency_samples"], 0)
|
||||
self.assertIsNone(result["latency_ms"])
|
||||
|
||||
def test_http2_uses_one_physical_connection_for_sixteen_request_slots(self):
|
||||
executable = self.executable
|
||||
for mode, transport in (("runtime-http2", "http"), ("hyper-http2", "http"),
|
||||
("runtime-https2", "https"), ("hyper-https2", "https")):
|
||||
with self.subTest(mode=mode), benchmark_service(executable, mode) as port:
|
||||
output = subprocess.run(
|
||||
[str(executable), "load", "h2", f"{transport}://localhost:{port}",
|
||||
"/bytes", "200", "0.1", "1", "16", "1000", "true", str(TLS / "root.pem")],
|
||||
capture_output=True, text=True, timeout=5, check=True)
|
||||
self.assertEqual(output.stderr, "")
|
||||
result = json.loads(output.stdout)
|
||||
validate_load(result)
|
||||
self.assertEqual(result["errors"], 0)
|
||||
self.assertGreater(result["succeeded"], 0)
|
||||
self.assertEqual(result["physical_connections"], 1)
|
||||
self.assertEqual(result["streams_per_connection"], 16)
|
||||
self.assertEqual(result["request_slots"], 16)
|
||||
self.assertEqual(result["successful_handshakes"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""只执行 peer 的配置检查入口,不创建网络或连接数据库。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from settings import configured_peer, stage_peer
|
||||
|
||||
|
||||
class PeerSettingsTests(unittest.TestCase):
|
||||
def check(self, peer, document):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-peer-config-test-") as temporary:
|
||||
root = Path(temporary)
|
||||
executable = stage_peer(configured_peer(peer), root, {})
|
||||
setting = root / "config/setting.json"
|
||||
if document is None:
|
||||
setting.unlink()
|
||||
else:
|
||||
setting.write_text(json.dumps(document))
|
||||
# 不在 executable 目录启动,验证读取位置不受工作目录影响。
|
||||
return subprocess.run([executable, "check-config"], cwd=root.parent,
|
||||
capture_output=True, text=True, timeout=5)
|
||||
|
||||
def test_both_peers_use_the_external_integer_setting(self):
|
||||
for peer in ("network_peer", "live_peer"):
|
||||
for workers in (1, 4, 64):
|
||||
with self.subTest(peer=peer, workers=workers):
|
||||
output = self.check(peer, {"benchmark": {"workers": workers}})
|
||||
self.assertEqual(output.returncode, 0, output.stderr)
|
||||
self.assertEqual(json.loads(output.stdout), {"workers": workers})
|
||||
|
||||
def test_both_peers_reject_missing_malformed_and_out_of_range_configuration(self):
|
||||
invalid = [None, {}, [], {"benchmark": []}, {"benchmark": {"unknown": 1}}]
|
||||
invalid.extend({"benchmark": {"workers": value}} for value in (0, 65, -1, 1.5, True, "4", None))
|
||||
for peer in ("network_peer", "live_peer"):
|
||||
for document in invalid:
|
||||
with self.subTest(peer=peer, document=document):
|
||||
output = self.check(peer, document)
|
||||
self.assertNotEqual(output.returncode, 0)
|
||||
self.assertEqual(output.stdout, "")
|
||||
self.assertTrue(output.stderr.strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user