Files
imagefind/scripts/stress-webdav.py
T

377 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Orthogonal ImageFind WebDAV upload stress test with isolated cleanup.
The default profile is CI-friendly. Pass ``--full`` to execute the requested
10 MiB / 1 GiB / 5 GiB singles, ten-file batch and fifty 10 MiB-file batch.
The API token is used for both WebDAV Basic authentication and cleanup.
"""
from __future__ import annotations
import argparse
import base64
import concurrent.futures
import hashlib
import os
import sys
import threading
import time
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote
import httpx
MIB = 1024**2
GIB = 1024**3
CHUNK = MIB
@dataclass(frozen=True)
class Case:
virtual_path: str
size: int
resume: bool = False
@dataclass
class TimedBody:
name: str
size: int
prefix: bytes = b""
start: int = 0
end: int | None = None
finished_at: float | None = None
def __iter__(self) -> Iterator[bytes]:
yield from blocks(
self.name,
self.size,
prefix=self.prefix,
start=self.start,
end=self.end,
)
# httpx resumes the iterator after it has handed the final request
# bytes to the transport. The remaining interval is server response
# latency, including any accidental post-PUT whole-file work.
self.finished_at = time.monotonic()
def blocks(
name: str,
size: int,
*,
prefix: bytes = b"",
start: int = 0,
end: int | None = None,
) -> Iterator[bytes]:
end = size if end is None else end
seed = hashlib.sha256(name.encode()).digest()
block = (seed * (CHUNK // len(seed) + 1))[:CHUNK]
position = start
while position < end:
if position < len(prefix):
length = min(len(prefix) - position, end - position)
yield prefix[position : position + length]
position += length
continue
offset = position % CHUNK
length = min(CHUNK - offset, end - position)
yield block[offset : offset + length]
position += length
def encoded_path(path: str) -> str:
return "/".join(quote(part, safe="") for part in path.split("/"))
class StressRun:
def __init__(
self,
base_url: str,
token: str,
full: bool,
large: bool,
workers: int,
timeout: float,
probe_timeout: float,
media_prefix: bytes = b"",
):
self.base_url = base_url.rstrip("/")
self.token = token
self.full = full
self.large = large
self.workers = workers
self.timeout = timeout
self.probe_timeout = probe_timeout
self.media_prefix = media_prefix
self.run_id = f"ifstress-{uuid.uuid4().hex[:10]}"
basic = base64.b64encode(f"imagefind:{token}".encode()).decode()
self.dav_headers = {"Authorization": f"Basic {basic}"}
self.api_headers = {"Authorization": f"Bearer {token}"}
self.upload_ids: set[str] = set()
self.request_timeout = httpx.Timeout(connect=15, write=60, read=120, pool=30)
self.probe_latencies: list[float] = []
self.probe_errors: list[str] = []
def probe(self, stopping: threading.Event) -> None:
timeout = httpx.Timeout(connect=3, write=3, read=self.probe_timeout, pool=3)
while not stopping.is_set():
started = time.monotonic()
try:
response = httpx.get(f"{self.base_url}/api/v1/status", timeout=timeout)
response.raise_for_status()
self.probe_latencies.append(time.monotonic() - started)
except Exception as exc:
if len(self.probe_errors) < 10:
self.probe_errors.append(f"{type(exc).__name__}: {exc}")
stopping.wait(0.25)
def cases(self) -> list[Case]:
if self.large:
return [
Case(f"{self.run_id}/large-{index}.mp4", 256 * MIB)
for index in range(4)
]
single_sizes = [10 * MIB, GIB, 5 * GIB] if self.full else [10 * MIB]
cases = [
Case(f"{self.run_id}/single-{size}.mp4", size, resume=index == 0)
for index, size in enumerate(single_sizes)
]
cases.append(Case(f"root-{self.run_id}.mp4", 10 * MIB if self.full else MIB))
for index in range(10):
depth = (1, 5, 10)[index % 3]
groups = "/".join(f"level-{level}" for level in range(1, depth + 1))
size = 10 * MIB if self.full else MIB
cases.append(Case(f"{self.run_id}/{groups}/{self.run_id}-ten-{index:02d}.mp4", size))
for index in range(50):
size = 10 * MIB if self.full else 256 * 1024
cases.append(Case(f"{self.run_id}/fifty/{self.run_id}-fifty-{index:02d}.mp4", size))
return cases
def put(self, case: Case) -> tuple[str, int, str | None, float]:
url = f"{self.base_url}/webdav/{encoded_path(case.virtual_path)}"
headers = dict(self.dav_headers)
upload_id = None
response_body: TimedBody
with httpx.Client(timeout=self.request_timeout) as client:
if case.resume:
split = case.size // 2
first_body = TimedBody(
case.virtual_path, case.size, self.media_prefix, end=split
)
first = client.put(
url,
headers={
**headers,
"Content-Length": str(split),
"Content-Range": f"bytes 0-{split - 1}/{case.size}",
},
content=first_body,
)
first.raise_for_status()
if first.headers.get("upload-offset") != str(split):
raise RuntimeError(f"{case.virtual_path}: first offset was not persisted")
head = client.head(url, headers=headers)
head.raise_for_status()
if head.headers.get("upload-offset") != str(split):
raise RuntimeError(f"{case.virtual_path}: HEAD did not report the partial offset")
response_body = TimedBody(
case.virtual_path, case.size, self.media_prefix, start=split
)
response = client.put(
url,
headers={
**headers,
"Content-Length": str(case.size - split),
"Content-Range": f"bytes {split}-{case.size - 1}/{case.size}",
},
content=response_body,
)
else:
response_body = TimedBody(case.virtual_path, case.size, self.media_prefix)
response = client.put(
url,
headers={**headers, "Content-Length": str(case.size)},
content=response_body,
)
response.raise_for_status()
upload_id = response.headers.get("x-imagefind-upload-id")
response_latency = time.monotonic() - (response_body.finished_at or time.monotonic())
return case.virtual_path, case.size, upload_id, response_latency
def wait(self, expected: int) -> None:
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
response = httpx.get(
f"{self.base_url}/api/v1/uploads?limit=500",
headers=self.api_headers,
timeout=30,
)
response.raise_for_status()
rows = [row for row in response.json() if row.get("id") in self.upload_ids]
failures = [row for row in rows if row.get("status") == "failed"]
if failures:
raise RuntimeError(f"upload failures: {failures}")
if len(rows) == expected and all(row.get("status") == "completed" for row in rows):
return
time.sleep(2)
raise TimeoutError("uploads did not complete before the stress timeout")
def upload_rows(self) -> list[dict]:
response = httpx.get(
f"{self.base_url}/api/v1/uploads?limit=500",
headers=self.api_headers,
timeout=self.request_timeout,
)
response.raise_for_status()
return response.json()
def verify_dedup(self, case: Case) -> None:
url = f"{self.base_url}/webdav/{encoded_path(case.virtual_path)}"
before = {row["id"] for row in self.upload_rows()}
body = TimedBody(case.virtual_path, case.size, self.media_prefix)
response = httpx.put(
url,
headers={
**self.dav_headers,
"Content-Length": str(case.size),
},
content=body,
timeout=self.request_timeout,
)
response.raise_for_status()
if response.headers.get("x-imagefind-deduplicated") != "true":
raise RuntimeError("full retry after a simulated lost response was not deduplicated")
after = {row["id"] for row in self.upload_rows()}
if after != before:
raise RuntimeError("full retry created another upload task")
latency = time.monotonic() - (body.finished_at or time.monotonic())
print(
f"deduplicated lost-response retry response_latency={latency:.3f}s",
flush=True,
)
def cleanup(self) -> None:
try:
videos = httpx.get(
f"{self.base_url}/api/v1/videos?limit=500", headers=self.api_headers, timeout=30
).json()
for video in videos:
if self.run_id in str(video.get("display_name")) or self.run_id in str(video.get("source_key")):
response = httpx.delete(
f"{self.base_url}/api/v1/videos/{video['id']}?delete_source=true",
headers=self.api_headers,
timeout=60,
)
if response.status_code != 404:
response.raise_for_status()
collections = httpx.get(
f"{self.base_url}/api/v1/collections", headers=self.api_headers, timeout=30
).json()
for collection in collections:
if collection.get("name") == self.run_id:
httpx.delete(
f"{self.base_url}/api/v1/collections/{collection['id']}",
headers=self.api_headers,
timeout=30,
).raise_for_status()
trash = httpx.get(
f"{self.base_url}/api/v1/trash", headers=self.api_headers, timeout=30
).json()
for item in trash:
if self.run_id in str(item.get("display_name")) or self.run_id in str(item.get("original_key")):
response = httpx.delete(
f"{self.base_url}/api/v1/trash/{item['id']}",
headers=self.api_headers,
timeout=60,
)
if response.status_code != 404:
response.raise_for_status()
except Exception as exc:
print(f"cleanup warning: {exc}", file=sys.stderr)
def execute(self, keep: bool) -> None:
cases = self.cases()
started = time.monotonic()
probe_stopping = threading.Event()
probe_thread = threading.Thread(
target=self.probe,
args=(probe_stopping,),
name="imagefind-stress-probe",
daemon=True,
)
probe_thread.start()
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
futures = [executor.submit(self.put, case) for case in cases]
for future in concurrent.futures.as_completed(futures):
path, size, upload_id, response_latency = future.result()
if upload_id:
self.upload_ids.add(upload_id)
print(
f"received {path} ({size} bytes) "
f"response_latency={response_latency:.3f}s",
flush=True,
)
self.wait(len(self.upload_ids))
self.verify_dedup(cases[0])
if self.probe_errors:
raise RuntimeError(
"ImageFind API became unreachable during WebDAV transfer: "
+ "; ".join(self.probe_errors)
)
maximum_probe = max(self.probe_latencies, default=0)
print(
f"PASS run={self.run_id} files={len(cases)} bytes={sum(case.size for case in cases)} "
f"seconds={time.monotonic() - started:.1f} max_probe_latency={maximum_probe:.3f}s",
flush=True,
)
finally:
probe_stopping.set()
probe_thread.join(timeout=self.probe_timeout + 5)
if not keep:
self.cleanup()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", required=True, help="ImageFind native URL, e.g. http://nas:8765")
parser.add_argument("--token", default=os.getenv("IMAGEFIND_WEBDAV_TOKEN"))
profile = parser.add_mutually_exclusive_group()
profile.add_argument("--full", action="store_true", help="include 1 GiB, 5 GiB and full-size batches")
profile.add_argument("--large", action="store_true", help="run four concurrent 256 MiB uploads")
parser.add_argument("--workers", type=int, default=8)
parser.add_argument("--timeout", type=float, default=7200)
parser.add_argument(
"--probe-timeout",
type=float,
default=10,
help="fail when the API is unreachable for this many seconds during transfers",
)
parser.add_argument("--keep", action="store_true", help="keep isolated stress data for inspection")
parser.add_argument(
"--media-prefix",
help="optional valid media file prepended before deterministic padding",
)
args = parser.parse_args()
if not args.token:
parser.error("--token or IMAGEFIND_WEBDAV_TOKEN is required")
media_prefix = Path(args.media_prefix).read_bytes() if args.media_prefix else b""
StressRun(
args.base_url,
args.token,
args.full,
args.large,
min(10, max(1, args.workers)),
args.timeout,
max(1, args.probe_timeout),
media_prefix,
).execute(args.keep)
if __name__ == "__main__":
main()