163 lines
6.4 KiB
Python
163 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Read-only live acceptance for upload pagination and core API latency."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import statistics
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
|
|
def call(client: httpx.Client, path: str) -> tuple[Any, float]:
|
|
started = time.perf_counter()
|
|
response = client.get(path)
|
|
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
if response.is_error:
|
|
raise RuntimeError(f"GET {path} -> {response.status_code}: {response.text[:500]}")
|
|
return response.json(), elapsed_ms
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--base-url", required=True)
|
|
auth = parser.add_mutually_exclusive_group(required=True)
|
|
auth.add_argument("--token-file", type=Path)
|
|
auth.add_argument("--password-stdin", action="store_true")
|
|
parser.add_argument("--report", type=Path)
|
|
args = parser.parse_args()
|
|
|
|
headers: dict[str, str] = {}
|
|
if args.token_file:
|
|
headers["Authorization"] = f"Bearer {args.token_file.read_text(encoding='utf-8').strip()}"
|
|
client = httpx.Client(
|
|
base_url=args.base_url.rstrip("/"),
|
|
headers=headers,
|
|
timeout=httpx.Timeout(30, connect=10),
|
|
)
|
|
if args.password_stdin:
|
|
password = sys.stdin.readline().rstrip("\r\n")
|
|
login = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"password": password, "remember_device": False},
|
|
)
|
|
password = ""
|
|
if login.is_error:
|
|
raise RuntimeError(f"login -> {login.status_code}: {login.text[:500]}")
|
|
|
|
status, status_ms = call(client, "/api/v1/status")
|
|
if status.get("version") != "0.5.45" or not status.get("configured"):
|
|
raise AssertionError(f"unexpected status: {status}")
|
|
|
|
legacy, legacy_ms = call(client, "/api/v1/uploads?limit=4")
|
|
if not isinstance(legacy, list) or len(legacy) > 4:
|
|
raise AssertionError("legacy upload response is no longer a bounded array")
|
|
|
|
first, first_ms = call(client, "/api/v1/uploads?page=1&page_size=10")
|
|
required = {
|
|
"items",
|
|
"status_items",
|
|
"active_count",
|
|
"failed_count",
|
|
"page",
|
|
"page_size",
|
|
"total",
|
|
"pages",
|
|
}
|
|
if not isinstance(first, dict) or not required.issubset(first):
|
|
actual = sorted(first) if isinstance(first, dict) else type(first)
|
|
raise AssertionError(f"upload page contract mismatch: {actual}")
|
|
if first["page"] != 1 or first["page_size"] != 10 or len(first["items"]) > 10:
|
|
raise AssertionError("upload first page bounds are invalid")
|
|
if first["pages"] < 1 or first["total"] < len(first["items"]):
|
|
raise AssertionError("upload pagination totals are invalid")
|
|
order = [(str(item.get("created_at") or ""), str(item["id"])) for item in first["items"]]
|
|
if order != sorted(order, reverse=True):
|
|
raise AssertionError("upload page ordering is unstable")
|
|
allowed_statuses = {"receiving", "queued", "transferring", "indexing", "failed"}
|
|
if any(item.get("status") not in allowed_statuses for item in first["status_items"]):
|
|
raise AssertionError("upload status summary contains terminal history")
|
|
active_visible = sum(item.get("status") != "failed" for item in first["status_items"])
|
|
failed_visible = sum(item.get("status") == "failed" for item in first["status_items"])
|
|
if first["active_count"] < active_visible or first["failed_count"] < failed_visible:
|
|
raise AssertionError("upload status counters are smaller than their visible summaries")
|
|
|
|
second_ids: list[str] = []
|
|
second_ms: float | None = None
|
|
if first["pages"] > 1:
|
|
second, second_ms = call(client, "/api/v1/uploads?page=2&page_size=10")
|
|
second_ids = [str(item["id"]) for item in second["items"]]
|
|
if set(second_ids) & {str(item["id"]) for item in first["items"]}:
|
|
raise AssertionError("upload pages overlap")
|
|
|
|
latency_paths = {
|
|
"uploads": "/api/v1/uploads?page=1&page_size=10",
|
|
"videos": "/api/v1/videos?limit=20",
|
|
"profile": "/api/v1/profile",
|
|
"resources": "/api/v1/system/resources",
|
|
}
|
|
latency: dict[str, dict[str, float]] = {}
|
|
resource_state: dict[str, Any] = {}
|
|
for name, path in latency_paths.items():
|
|
samples: list[float] = []
|
|
value: Any = None
|
|
for _ in range(3):
|
|
value, elapsed_ms = call(client, path)
|
|
samples.append(elapsed_ms)
|
|
latency[name] = {
|
|
"median_ms": round(statistics.median(samples), 2),
|
|
"max_ms": round(max(samples), 2),
|
|
}
|
|
if name == "resources" and isinstance(value, dict):
|
|
database = value.get("database") or {}
|
|
resource_state = {
|
|
"cpu_percent": value.get("cpu_percent"),
|
|
"memory_available_bytes": value.get("memory_available_bytes"),
|
|
"running_jobs": value.get("running_jobs"),
|
|
"paused_jobs": value.get("paused_jobs"),
|
|
"database": {
|
|
"engine": database.get("engine"),
|
|
"pool_in_use": database.get("pool_in_use"),
|
|
"pool_waiters": database.get("pool_waiters"),
|
|
"slow_transaction_count": database.get("slow_transaction_count"),
|
|
},
|
|
}
|
|
|
|
report = {
|
|
"status": status,
|
|
"contract": {
|
|
"legacy_count": len(legacy),
|
|
"page": first["page"],
|
|
"page_size": first["page_size"],
|
|
"page_items": len(first["items"]),
|
|
"total": first["total"],
|
|
"pages": first["pages"],
|
|
"second_page_items": len(second_ids),
|
|
"status_items": len(first["status_items"]),
|
|
"active_count": first["active_count"],
|
|
"failed_count": first["failed_count"],
|
|
},
|
|
"initial_latency_ms": {
|
|
"status": round(status_ms, 2),
|
|
"legacy_uploads": round(legacy_ms, 2),
|
|
"first_page": round(first_ms, 2),
|
|
"second_page": round(second_ms, 2) if second_ms is not None else None,
|
|
},
|
|
"latency": latency,
|
|
"resources": resource_state,
|
|
}
|
|
encoded = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
|
if args.report:
|
|
args.report.parent.mkdir(parents=True, exist_ok=True)
|
|
args.report.write_text(encoded, encoding="utf-8")
|
|
print(encoded, end="")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|