test(sync): 添加 S-09 持续生产基准测试

This commit is contained in:
2026-09-09 15:49:21 +08:00
parent 9e772fb5da
commit e51c36c1c6
3 changed files with 887 additions and 1 deletions
@@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py `
报告目录包含 `summary.json``case-manifest.json``junit.xml``cases/<ID>.json` 和脱敏的 `logs/<ID>.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包、S-01/S-02/S-03/S-08 Sync 客户端以及 S-04/S-05/S-06/S-07 Sync 服务 driver 已登记;其余 17 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包、S-01/S-02/S-03/S-08 Sync 客户端以及 S-04/S-05/S-06/S-07/S-09 Sync 服务 driver 已登记;其余 16 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
@@ -0,0 +1,868 @@
"""S-09 sustained load, bounded upload RSS, and initial-sync acceptance."""
from __future__ import annotations
import argparse
import asyncio
from concurrent.futures import ThreadPoolExecutor
import ctypes
from ctypes import wintypes
import hashlib
import json
import math
import os
from pathlib import Path
import platform
import subprocess
import sys
from threading import Event, Thread
import time
from sync_production_stack import ROOT, SERVICE, SyncProductionStack, call, sha256
LOAD_SECONDS = 30 * 60
LOAD_RATE = 20
LOAD_CLIENTS = 10
INITIAL_NOTES = 10_000
NOTE_BYTES = 4 * 1024
CHUNK = 1024 * 1024
UPLOAD_BYTES = 100 * CHUNK
RSS_LIMIT = 2 * 1024**3
NETWORK_RTT_SECONDS = 0.020
def percentile(values: list[float], proportion: float) -> float:
ordered = sorted(values)
return ordered[max(0, math.ceil(len(ordered) * proportion) - 1)]
def stable_id(kind: str, index: int, length: int = 32) -> str:
return hashlib.sha256(f"OpenNexus:S-09:{kind}:{index}".encode()).hexdigest()[:length]
def note_data(index: int) -> bytes:
line = f"# OpenNexus S-09 note {index:05d}\nseed=20260908\n".encode()
return (line * (NOTE_BYTES // len(line) + 1))[:NOTE_BYTES]
def transfer_block(index: int, offset: int, length: int = CHUNK) -> bytes:
seed = hashlib.sha256(f"OpenNexus:S-09:upload:{index}:{offset}".encode()).digest()
return (seed * (length // len(seed) + 1))[:length]
def transfer_hash(index: int) -> str:
digest = hashlib.sha256()
for offset in range(0, UPLOAD_BYTES, CHUNK):
digest.update(transfer_block(index, offset))
return digest.hexdigest()
class ProcessEntry(ctypes.Structure):
_fields_ = [
("dwSize", wintypes.DWORD),
("cntUsage", wintypes.DWORD),
("th32ProcessID", wintypes.DWORD),
("th32DefaultHeapID", ctypes.POINTER(wintypes.ULONG)),
("th32ModuleID", wintypes.DWORD),
("cntThreads", wintypes.DWORD),
("th32ParentProcessID", wintypes.DWORD),
("pcPriClassBase", wintypes.LONG),
("dwFlags", wintypes.DWORD),
("szExeFile", wintypes.WCHAR * 260),
]
class MemoryCounters(ctypes.Structure):
_fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD)] + [
(name, ctypes.c_size_t)
for name in (
"PeakWorkingSetSize",
"WorkingSetSize",
"QuotaPeakPagedPoolUsage",
"QuotaPagedPoolUsage",
"QuotaPeakNonPagedPoolUsage",
"QuotaNonPagedPoolUsage",
"PagefileUsage",
"PeakPagefileUsage",
"PrivateUsage",
)
]
class ProcessTreeMemory:
def __init__(self, root_pid: int):
if os.name != "nt":
raise RuntimeError("S09_WINDOWS_MEMORY_COLLECTOR_REQUIRED")
self.root_pid = root_pid
self.kernel = ctypes.WinDLL("kernel32", use_last_error=True)
self.psapi = ctypes.WinDLL("psapi", use_last_error=True)
self.kernel.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
self.kernel.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
self.kernel.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
self.kernel.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
self.kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
self.kernel.OpenProcess.restype = wintypes.HANDLE
self.kernel.CloseHandle.argtypes = [wintypes.HANDLE]
self.psapi.GetProcessMemoryInfo.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(MemoryCounters),
wintypes.DWORD,
]
def process_ids(self) -> set[int]:
snapshot = self.kernel.CreateToolhelp32Snapshot(0x00000002, 0)
if snapshot == wintypes.HANDLE(-1).value:
raise OSError("PROCESS_SNAPSHOT_FAILED")
parents = {}
entry = ProcessEntry()
entry.dwSize = ctypes.sizeof(entry)
try:
current = self.kernel.Process32FirstW(snapshot, ctypes.byref(entry))
while current:
parents[int(entry.th32ProcessID)] = int(entry.th32ParentProcessID)
current = self.kernel.Process32NextW(snapshot, ctypes.byref(entry))
finally:
self.kernel.CloseHandle(snapshot)
selected = {self.root_pid}
changed = True
while changed:
before = len(selected)
selected.update(pid for pid, parent in parents.items() if parent in selected)
changed = len(selected) != before
return selected
def working_set(self) -> tuple[int, int]:
total = observed = 0
for pid in self.process_ids():
handle = self.kernel.OpenProcess(0x1000 | 0x0010, False, pid)
if not handle:
continue
try:
counters = MemoryCounters()
counters.cb = ctypes.sizeof(counters)
if self.psapi.GetProcessMemoryInfo(
handle, ctypes.byref(counters), counters.cb
):
total += int(counters.WorkingSetSize)
observed += 1
finally:
self.kernel.CloseHandle(handle)
return total, observed
def start_memory_sampler(root_pid: int):
stopping = Event()
state = {"peak": 0, "samples": 0, "errors": 0, "processes": 0}
collector = ProcessTreeMemory(root_pid)
def sample():
while not stopping.is_set():
try:
value, processes = collector.working_set()
state["peak"] = max(state["peak"], value)
state["processes"] = max(state["processes"], processes)
state["samples"] += 1
except OSError:
state["errors"] += 1
time.sleep(0.01)
thread = Thread(target=sample, daemon=True)
thread.start()
return stopping, thread, state
def upload_small(base: str, auth: dict, data: bytes) -> str:
content_hash = hashlib.sha256(data).hexdigest()
created = call(
"POST", base + "/uploads", headers=auth, body={"content_hash": content_hash, "size": len(data)}
)
assert created[0] == 200
if not created[1]["complete"]:
path = base + "/uploads/" + created[1]["upload_id"]
assert call("PUT", path + "?offset=0", headers=auth, body=data)[0] == 200
assert call("POST", path + "/complete", headers=auth)[0] == 200
return content_hash
def create_client_vault(stack: SyncProductionStack, username: str, password: str, label: str):
session = stack.login(username, password, label)
auth = {"Authorization": "Bearer " + session["access_token"]}
_, base, vault_id = stack.create_vault_for_auth(auth, label)
return {"username": username, "password": password, "auth": auth, "base": base, "vault": vault_id}
def run_memory_uploads(stack: SyncProductionStack, clients: list[dict]) -> dict:
from threading import Barrier
from urllib.request import Request, urlopen
selected = clients[:4]
hashes = [transfer_hash(index) for index in range(4)]
gate = Barrier(5)
def transfer(index: int):
client = selected[index]
created = call(
"POST",
client["base"] + "/uploads",
headers=client["auth"],
body={"content_hash": hashes[index], "size": UPLOAD_BYTES},
)
assert created[0] == 200 and created[1]["complete"] is False
upload = client["base"] + "/uploads/" + created[1]["upload_id"]
gate.wait(timeout=60)
for offset in range(0, UPLOAD_BYTES, CHUNK):
started = time.monotonic()
time.sleep(NETWORK_RTT_SECONDS / 2)
response = call(
"PUT",
upload + f"?offset={offset}",
headers=client["auth"],
body=transfer_block(index, offset),
)
time.sleep(NETWORK_RTT_SECONDS / 2)
assert response[0] == 200 and response[1]["offset"] == offset + CHUNK
# Four equal streams share an aggregate 100 Mbps client-side ceiling.
delay = (CHUNK * 8 * 4 / 100_000_000) - (time.monotonic() - started)
if delay > 0:
time.sleep(delay)
assert call("POST", upload + "/complete", headers=client["auth"])[0] == 200
body = {
"operation_id": stable_id("memory-operation", index),
"file_id": stable_id("memory-file", index),
"base_revision": 0,
"path": f"memory/{index}.bin",
"operation": "put",
"content_hash": hashes[index],
"size": UPLOAD_BYTES,
}
assert call("POST", client["base"] + "/revisions", headers=client["auth"], body=body)[0] == 200
return index
stopping, monitor, memory = start_memory_sampler(stack.sync.pid)
try:
with ThreadPoolExecutor(max_workers=4) as pool:
futures = [pool.submit(transfer, index) for index in range(4)]
gate.wait(timeout=60)
completed = [future.result(timeout=300) for future in futures]
finally:
stopping.set()
monitor.join(timeout=10)
verified = 0
for index, client in enumerate(selected):
request = Request(
client["base"] + "/objects/" + hashes[index],
headers=client["auth"],
)
digest = hashlib.sha256()
size = 0
with urlopen(request, timeout=120) as response:
for chunk in iter(lambda: response.read(CHUNK), b""):
digest.update(chunk)
size += len(chunk)
verified += int(size == UPLOAD_BYTES and digest.hexdigest() == hashes[index])
return {"completed": len(completed), "verified": verified, **memory}
async def initial_sync_worker() -> int:
import httpx
origin = os.environ["S09_ORIGIN"]
base = os.environ["S09_INITIAL_BASE"]
token = os.environ["S09_ACCESS_TOKEN"]
note_count = int(os.environ["S09_NOTE_COUNT"])
headers = {"Authorization": "Bearer " + token}
timeout = httpx.Timeout(120, connect=10)
limits = httpx.Limits(max_connections=64, max_keepalive_connections=32)
semaphore = asyncio.Semaphore(32)
started = time.monotonic()
async with httpx.AsyncClient(
base_url=origin,
headers=headers,
timeout=timeout,
limits=limits,
follow_redirects=False,
trust_env=False,
) as client:
async def request(method: str, path: str, **kwargs):
await asyncio.sleep(NETWORK_RTT_SECONDS / 2)
response = await client.request(method, path, **kwargs)
await asyncio.sleep(NETWORK_RTT_SECONDS / 2)
if response.status_code != 200:
raise RuntimeError("S09_INITIAL_HTTP_ERROR")
return response
async def put_note(index: int):
async with semaphore:
data = note_data(index)
content_hash = hashlib.sha256(data).hexdigest()
created = (
await request(
"POST",
base + "/uploads",
json={"content_hash": content_hash, "size": len(data)},
)
).json()
if not created["complete"]:
upload = base + "/uploads/" + created["upload_id"]
await request("PUT", upload, params={"offset": 0}, content=data)
await request("POST", upload + "/complete")
revision = {
"operation_id": stable_id("initial-operation", index),
"file_id": stable_id("initial-file", index),
"base_revision": 0,
"path": f"notes/{index:05d}.md",
"operation": "put",
"content_hash": content_hash,
"size": len(data),
}
await request("POST", base + "/revisions", json=revision)
return content_hash
async def put_attachment():
index = 99
content_hash = transfer_hash(index)
created = (
await request(
"POST",
base + "/uploads",
json={"content_hash": content_hash, "size": UPLOAD_BYTES},
)
).json()
upload = base + "/uploads/" + created["upload_id"]
for offset in range(0, UPLOAD_BYTES, CHUNK):
chunk_started = time.monotonic()
await request(
"PUT", upload, params={"offset": offset}, content=transfer_block(index, offset)
)
delay = (CHUNK * 8 / 100_000_000) - (time.monotonic() - chunk_started)
if delay > 0:
await asyncio.sleep(delay)
await request("POST", upload + "/complete")
await request(
"POST",
base + "/revisions",
json={
"operation_id": stable_id("initial-attachment-operation", 0),
"file_id": stable_id("initial-attachment-file", 0),
"base_revision": 0,
"path": "attachments/initial.bin",
"operation": "put",
"content_hash": content_hash,
"size": UPLOAD_BYTES,
},
)
return content_hash
hashes, attachment_hash = await asyncio.gather(
asyncio.gather(*(put_note(index) for index in range(note_count))),
put_attachment(),
)
items = []
cursor = 0
boundary = None
while boundary is None or cursor < boundary:
params = {"cursor": cursor, "limit": 500}
if boundary is not None:
params["boundary"] = boundary
page = (await request("GET", base + "/changes", params=params)).json()
boundary = page["boundary"]
items.extend(page["items"])
if not page["items"]:
break
cursor = page["items"][-1]["sequence"]
if len(items) != note_count + 1:
raise RuntimeError("S09_INITIAL_REVISION_COUNT")
async def verify_note(index: int):
async with semaphore:
response = await request("GET", base + "/objects/" + hashes[index])
return hashlib.sha256(response.content).hexdigest() == hashes[index]
verified = sum(
await asyncio.gather(*(verify_note(index) for index in range(note_count)))
)
digest = hashlib.sha256()
size = 0
async with client.stream("GET", base + "/objects/" + attachment_hash) as response:
if response.status_code != 200:
raise RuntimeError("S09_INITIAL_ATTACHMENT_HTTP")
async for chunk in response.aiter_bytes(CHUNK):
digest.update(chunk)
size += len(chunk)
if size != UPLOAD_BYTES or digest.hexdigest() != attachment_hash:
raise RuntimeError("S09_INITIAL_ATTACHMENT_DIGEST")
print(
json.dumps(
{
"notes": note_count,
"attachment_bytes": UPLOAD_BYTES,
"verified_files": verified + 1,
"elapsed_ms": int((time.monotonic() - started) * 1000),
"first_note_hash": hashes[0],
"boundary": note_count + 1,
}
)
)
return 0
def validate_load_worker() -> int:
sys.path.insert(0, str(SERVICE))
from sqlalchemy import text
from sync_server.database import Database
db = Database(os.environ["SYNC_DATABASE_URL"])
clients = json.loads(os.environ["S09_LOAD_CATALOG"])
validated = 0
with db.engine.connect() as conn:
for index, client in enumerate(clients):
rows = list(conn.execute(
text(
"SELECT sequence,file_id,path,hash,size,operation_id FROM revisions "
"WHERE vault_id=:vault ORDER BY sequence"
),
{"vault": client["vault"]},
).mappings())
if [row["sequence"] for row in rows] != list(range(1, len(rows) + 1)):
raise RuntimeError("S09_LOAD_SEQUENCE_GAP")
observed_rounds = set()
for row in rows:
try:
round_index = int(row["path"].removeprefix("load/").removesuffix(".md"))
except ValueError as error:
raise RuntimeError("S09_LOAD_PATH_INVALID") from error
if dict(row) != {
"sequence": row["sequence"],
"file_id": stable_id(f"load-file-{index}", round_index),
"path": f"load/{round_index:06d}.md",
"hash": client["hash"],
"size": NOTE_BYTES,
"operation_id": stable_id(f"load-operation-{index}", round_index),
}:
raise RuntimeError("S09_LOAD_DATA_MISMATCH")
if round_index in observed_rounds:
raise RuntimeError("S09_LOAD_DUPLICATE")
observed_rounds.add(round_index)
validated += 1
if observed_rounds != set(range(len(rows))):
raise RuntimeError("S09_LOAD_ROUND_GAP")
print(json.dumps({"validated": validated}))
return 0
def worker_entry() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--worker", choices=["initial", "validate-load"], required=True)
args = parser.parse_args()
if args.worker == "initial":
return asyncio.run(initial_sync_worker())
return validate_load_worker()
def run_worker(stack: SyncProductionStack, worker: str, **environment: str) -> dict:
child_env = dict(stack.service_env)
child_env.update(environment)
completed = subprocess.run(
[str(stack.server_python), str(Path(__file__).resolve()), "--worker", worker],
cwd=ROOT,
env=child_env,
capture_output=True,
text=True,
check=True,
timeout=1200,
)
return json.loads(completed.stdout.splitlines()[-1])
def run_initial_sync(stack: SyncProductionStack, client: dict, note_count: int) -> dict:
return run_worker(
stack,
"initial",
S09_ORIGIN=stack.origin,
S09_INITIAL_BASE=client["base"].removeprefix(stack.origin),
S09_ACCESS_TOKEN=client["auth"]["Authorization"].removeprefix("Bearer "),
S09_NOTE_COUNT=str(note_count),
)
def run_convergence(client: dict, content_hash: str, cursor: int) -> dict:
latencies = []
for index in range(100):
started = time.monotonic()
body = {
"operation_id": stable_id("convergence-operation", index),
"file_id": stable_id("convergence-file", index),
"base_revision": 0,
"path": f"incremental/{index:03d}.md",
"operation": "put",
"content_hash": content_hash,
"size": NOTE_BYTES,
}
committed = call("POST", client["base"] + "/revisions", headers=client["auth"], body=body)
assert committed[0] == 200
sequence = committed[1]["sequence"]
deadline = started + 10
while True:
changes = call(
"GET", client["base"] + f"/changes?cursor={cursor}", headers=client["auth"]
)
assert changes[0] == 200
if any(item["sequence"] == sequence for item in changes[1]["items"]):
cursor = sequence
break
if time.monotonic() >= deadline:
raise RuntimeError("S09_CONVERGENCE_TIMEOUT")
time.sleep(0.05)
latencies.append((time.monotonic() - started) * 1000)
return {"samples": len(latencies), "p95_ms": percentile(latencies, 0.95)}
def run_sustained_load(stack: SyncProductionStack, clients: list[dict], duration: int) -> dict:
total = duration * LOAD_RATE
started = time.monotonic()
starts = []
def commit(global_index: int):
client_index = global_index % LOAD_CLIENTS
round_index = global_index // LOAD_CLIENTS
client = clients[client_index]
begin = time.monotonic()
time.sleep(NETWORK_RTT_SECONDS / 2)
response = call(
"POST",
client["base"] + "/revisions",
headers=client["auth"],
body={
"operation_id": stable_id(f"load-operation-{client_index}", round_index),
"file_id": stable_id(f"load-file-{client_index}", round_index),
"base_revision": 0,
"path": f"load/{round_index:06d}.md",
"operation": "put",
"content_hash": client["hash"],
"size": NOTE_BYTES,
},
)
time.sleep(NETWORK_RTT_SECONDS / 2)
return response[0], (time.monotonic() - begin) * 1000
def rotate_sessions():
for index, client in enumerate(clients):
session = stack.login(
client["username"], client["password"], f"S-09 load rotate {index}"
)
client["auth"] = {"Authorization": "Bearer " + session["access_token"]}
refreshes = []
futures = []
with ThreadPoolExecutor(max_workers=48) as pool, ThreadPoolExecutor(max_workers=1) as manager:
next_refresh = 600
for global_index in range(total):
target = started + global_index / LOAD_RATE
while True:
now = time.monotonic()
if now >= target:
break
time.sleep(min(0.01, target - now))
starts.append(time.monotonic())
futures.append(pool.submit(commit, global_index))
elapsed = starts[-1] - started
if elapsed >= next_refresh and len(refreshes) < 2:
if refreshes:
refreshes[-1].result(timeout=60)
refreshes.append(manager.submit(rotate_sessions))
next_refresh += 600
remaining = started + duration - time.monotonic()
if remaining > 0:
time.sleep(remaining)
results = [future.result(timeout=180) for future in futures]
for refresh in refreshes:
refresh.result(timeout=60)
elapsed = time.monotonic() - started
codes = [item[0] for item in results]
latencies = [item[1] for item in results]
return {
"duration_seconds": elapsed,
"requests": len(results),
"successes": codes.count(200),
"unexpected_5xx": sum(500 <= code < 600 for code in codes),
"other_errors": sum(code != 200 and not 500 <= code < 600 for code in codes),
"p95_ms": percentile(latencies, 0.95),
"rate_per_second": len(starts) / ((starts[-1] - starts[0]) + 1 / LOAD_RATE),
"max_schedule_lag_ms": max(
(actual - (started + index / LOAD_RATE)) * 1000
for index, actual in enumerate(starts)
),
"refreshes": len(refreshes),
}
def result(case_id: str, status: str, reason: str, facts: dict) -> dict:
passed = status == "PASSED"
assertions = [
(
"the oracle uses real PostgreSQL, MinIO, two workers, and ten clients",
facts.get("dependencies") is True
and facts.get("worker_count") == 2
and facts.get("client_count") == LOAD_CLIENTS,
),
(
"ten clients sustain 20 4 KiB commits per second for 30 minutes",
facts.get("load_duration_seconds", 0) >= LOAD_SECONDS
and facts.get("load_requests") == LOAD_SECONDS * LOAD_RATE
and facts.get("load_successes") == LOAD_SECONDS * LOAD_RATE
and facts.get("session_rotations") == 2
and 19.9 <= facts.get("load_rate_per_second", 0) <= 20.1,
),
(
"API p95 is at most 500 ms, unexpected 5xx is under 0.1%, and all rows validate",
facts.get("api_p95_ms", 501) <= 500
and facts.get("unexpected_5xx", 1) / max(1, facts.get("load_requests", 0)) < 0.001
and facts.get("other_errors") == 0
and facts.get("validated_commits") == LOAD_SECONDS * LOAD_RATE,
),
(
"four concurrent 100 MiB uploads verify with total service RSS at most 2 GiB",
facts.get("upload_completed") == 4
and facts.get("upload_verified") == 4
and 0 < facts.get("service_peak_rss_bytes", 0) <= RSS_LIMIT
and facts.get("rss_samples", 0) > 0,
),
(
"10000 distinct 4 KiB notes and a 100 MiB attachment initially sync within ten minutes",
facts.get("initial_notes") == INITIAL_NOTES
and facts.get("initial_verified_files") == INITIAL_NOTES + 1
and facts.get("initial_sync_ms", 600_001) <= 600_000,
),
(
"100 fault-free incremental changes converge with p95 at most ten seconds",
facts.get("convergence_samples") == 100
and facts.get("convergence_p95_ms", 10_001) <= 10_000,
),
]
evidence = (
"real PostgreSQL 17, fixed MinIO, two production Uvicorn workers, "
"100 Mbps/20 ms client profile, and black-box HTTP"
)
return {
"schema": 1,
"case_id": case_id,
"status": status,
"reason": reason,
"assertions": [
{
"name": name,
"status": "PASSED" if passed and actual else "FAILED",
"evidence": evidence,
}
for name, actual in assertions
],
"metrics": {
"load_requests": facts.get("load_requests", 0),
"api_p95_ms": facts.get("api_p95_ms", 0),
"unexpected_5xx_rate": facts.get("unexpected_5xx", 0)
/ max(1, facts.get("load_requests", 0)),
"validated_commits": facts.get("validated_commits", 0),
"service_peak_rss_bytes": facts.get("service_peak_rss_bytes", 0),
"upload_verified": facts.get("upload_verified", 0),
"initial_sync_ms": facts.get("initial_sync_ms", 0),
"initial_verified_files": facts.get("initial_verified_files", 0),
"convergence_p95_ms": facts.get("convergence_p95_ms", 0),
"worker_count": facts.get("worker_count", 0),
"client_count": facts.get("client_count", 0),
},
"files": [
{"path": relative, "sha256": sha256(ROOT / relative)}
for relative in (
"server sync/sync_server/app.py",
"server sync/sync_server/database.py",
"server sync/sync_server/storage.py",
"scripts/acceptance_cases/sync_production_stack.py",
"scripts/acceptance_cases/s09_sync_performance.py",
)
],
"revisions": [
{
"scope": "runtime",
"postgres": facts.get("postgres_version"),
"minio": facts.get("minio_version"),
"os": platform.platform(),
"logical_processors": os.cpu_count(),
},
{
"scope": "network profile",
"aggregate_bits_per_second": 100_000_000,
"round_trip_ms": 20,
"method": "client-side pacing over TCP loopback",
},
{
"scope": "load",
"duration_seconds": facts.get("load_duration_seconds", 0),
"rate_per_second": facts.get("load_rate_per_second", 0),
"max_schedule_lag_ms": facts.get("max_schedule_lag_ms", 0),
"session_rotations": facts.get("session_rotations", 0),
},
{
"scope": "memory",
"samples": facts.get("rss_samples", 0),
"read_errors": facts.get("rss_errors", 0),
"max_processes": facts.get("rss_processes", 0),
},
{
"scope": "last checkpoint",
"stage": facts.get("active_stage"),
"iteration": facts.get("active_iteration", -1),
},
],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--duration-seconds", type=int, default=LOAD_SECONDS)
parser.add_argument("--note-count", type=int, default=INITIAL_NOTES)
args = parser.parse_args()
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
facts: dict = {}
reason = ""
status = "FAILED"
stack = None
try:
config = json.loads(Path(args.config).read_text(encoding="utf-8"))
data_root = Path(os.environ["OPENNEXUS_ACCEPTANCE_DATA_ROOT"]).resolve()
data_root.mkdir(parents=True, exist_ok=True)
stack = SyncProductionStack(config, data_root, "s09").start()
facts.update(
{
"dependencies": True,
"postgres_version": stack.postgres_version,
"minio_version": stack.minio_version,
"worker_count": len(stack.worker_ids()),
}
)
assert facts["worker_count"] == 2
second_username, second_password = stack.add_user("s09-second")
facts["active_stage"] = "memory-uploads"
memory_clients = [
create_client_vault(
stack,
stack.username if index < 2 else second_username,
stack.password if index < 2 else second_password,
f"S-09 memory {index}",
)
for index in range(4)
]
memory = run_memory_uploads(stack, memory_clients)
facts.update(
{
"upload_completed": memory["completed"],
"upload_verified": memory["verified"],
"service_peak_rss_bytes": memory["peak"],
"rss_samples": memory["samples"],
"rss_errors": memory["errors"],
"rss_processes": memory["processes"],
}
)
assert memory["completed"] == 4 and memory["verified"] == 4
assert 0 < memory["peak"] <= RSS_LIMIT and memory["samples"] > 0
facts["active_stage"] = "initial-sync"
initial_client = create_client_vault(
stack, stack.username, stack.password, "S-09 initial sync"
)
initial = run_initial_sync(stack, initial_client, args.note_count)
facts.update(
{
"initial_notes": initial["notes"],
"initial_verified_files": initial["verified_files"],
"initial_sync_ms": initial["elapsed_ms"],
}
)
assert initial["verified_files"] == args.note_count + 1
assert initial["elapsed_ms"] <= 600_000
facts["active_stage"] = "incremental-convergence"
convergence = run_convergence(
initial_client, initial["first_note_hash"], initial["boundary"]
)
facts["convergence_samples"] = convergence["samples"]
facts["convergence_p95_ms"] = convergence["p95_ms"]
assert convergence["samples"] == 100 and convergence["p95_ms"] <= 10_000
facts["active_stage"] = "sustained-load"
load_clients = []
for index in range(LOAD_CLIENTS):
username, password = (
(stack.username, stack.password)
if index < LOAD_CLIENTS // 2
else (second_username, second_password)
)
client = create_client_vault(stack, username, password, f"S-09 load {index}")
client["hash"] = upload_small(
client["base"], client["auth"], note_data(20_000 + index)
)
load_clients.append(client)
facts["client_count"] = len(load_clients)
load = run_sustained_load(stack, load_clients, args.duration_seconds)
facts.update(
{
"load_duration_seconds": load["duration_seconds"],
"load_requests": load["requests"],
"load_successes": load["successes"],
"unexpected_5xx": load["unexpected_5xx"],
"other_errors": load["other_errors"],
"api_p95_ms": load["p95_ms"],
"load_rate_per_second": load["rate_per_second"],
"max_schedule_lag_ms": load["max_schedule_lag_ms"],
"session_rotations": load["refreshes"],
}
)
expected_requests = args.duration_seconds * LOAD_RATE
assert load["requests"] == expected_requests and load["successes"] == expected_requests
assert load["p95_ms"] <= 500 and load["other_errors"] == 0
assert load["unexpected_5xx"] / max(1, expected_requests) < 0.001
facts["active_stage"] = "load-validation"
validated = run_worker(
stack,
"validate-load",
S09_LOAD_CATALOG=json.dumps(
[{"vault": client["vault"], "hash": client["hash"]} for client in load_clients],
separators=(",", ":"),
),
)
facts["validated_commits"] = validated["validated"]
facts.update({"active_stage": "complete", "active_iteration": expected_requests})
assert validated["validated"] == expected_requests
stack.assert_running()
if args.duration_seconds == LOAD_SECONDS and args.note_count == INITIAL_NOTES:
status = "PASSED"
else:
reason = "DEVELOPMENT_PROFILE_COMPLETE"
except BaseException as error:
reason = "S09_ORACLE_FAILED:" + type(error).__name__
finally:
if stack is not None:
stack.stop()
payload = result(
case_id,
status if case_id == "S-09" else "FAILED",
reason or ("" if case_id == "S-09" else "CASE_ID_MISMATCH"),
facts,
)
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 0 if payload["status"] == "PASSED" else 1
if __name__ == "__main__":
if "--worker" in sys.argv:
raise SystemExit(worker_entry())
raise SystemExit(main())
+18
View File
@@ -125,6 +125,24 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
),
"required_artifacts": ("postgres_initdb", "minio_server"),
},
"S-09": {
"driver": "scripts/acceptance_cases/s09_sync_performance.py",
"timeout_seconds": 3000,
"required_metrics": (
"load_requests",
"api_p95_ms",
"unexpected_5xx_rate",
"validated_commits",
"service_peak_rss_bytes",
"upload_verified",
"initial_sync_ms",
"initial_verified_files",
"convergence_p95_ms",
"worker_count",
"client_count",
),
"required_artifacts": ("postgres_initdb", "minio_server"),
},
}
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]{2,127}")
RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{2,63}")