wrolpi/scripts/api_perf_compare.py

110 lines
4 KiB
Python
Raw Permalink Normal View History

Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page (#678) * Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page Three response-time fixes found by benchmarking the read-only API endpoints hot and cold on real libraries: * `switch_worker` runs on the Sanic worker's event loop every 0.1s and called the blocking `multiprocessing.Event.wait(timeout=1)`. With no switch pending (the normal state) this froze the loop for a second at a time; about a third of all requests, including trivial ones, took ~1,000 ms. It now polls `is_set()` and returns immediately. * The search handlers (videos, files, archives, docs, zims) and `/api/statistics` ran synchronous SQLite queries on the event loop, so one slow search stalled every other request on that worker. They now run via `asyncio.to_thread`. * The unfiltered Videos page (`POST /api/videos/search` with only an order) used `mimetype LIKE 'video/%' OR ...` plus `COUNT(*) OVER()`, which visits every video's index entry before LIMIT applies: seconds cold on a large library on a spinning disk, and ~10 s hot for a deep page. It now seeks each exact mimetype on the (mimetype, effective_datetime) index for `limit + offset` rows and merges them, with the total from a separate count cached for 60 s. Results are identical to the generic query. Also adds `scripts/api_perf.py` (times every read-only endpoint against a host, optionally restarting the API over SSH for cold numbers) and `scripts/api_perf_compare.py` (merges per-host results). Host profiles live in a gitignored JSON file; an example is included. Measured on an 8-core docker host with a 10 GB library: median hot request 1,007 ms -> 22 ms; Videos page 1 hot 287 -> 108 ms, page 50 1,345 -> 120 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve the video description from its own endpoint instead of Video.__json__ `Video.__json__` computed `description` as `c_text or get_video_description()`, where the fallback opens and parses the video's info json from disk. The list endpoints serialize every result with `__json__`, so any page containing videos without an indexed description (common: many videos have none) read their info json files on every request, cold, for a field the list never displays. The description is not part of search either: search runs against the FTS copy of `c_text`, so the fallback never fed the index. Drop `description` from `__json__` and serve it from `GET /api/videos/<id>/description`, like comments and captions. The endpoint returns the indexed `c_text`, falling back to the info json only there, for a row indexed before descriptions were stored. The player page fetches it through `useVideoExtras` alongside the comments. Tests assert the list and detail responses never touch the info json, and cover the endpoint's indexed, fallback, and not-found cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: thread-local zim sessions, fast-path equivalence test, comments * Zim search, single-zim search, and search estimates run libzim off the event loop, each opening its own session inside the worker thread rather than passing the request's SQLAlchemy Session across threads. * The Videos fast-path test now runs the same requests through the generic query and asserts identical rows and paging for both date orders. The one intentional difference is documented: past the end, the fast path still reports the true total. * switch_worker returns before its try block so the empty path does not clear the event; its comment states the invariant only. The copied "synchronous SQLite" comments are reduced to one line in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 16:17:41 -06:00
#!/usr/bin/env python3
"""
Merge the CSVs written by scripts/api_perf.py into one cross-host table.
python3 scripts/api_perf_compare.py perf_results/*.csv
python3 scripts/api_perf_compare.py perf_results/*.csv --sort hot --top 25
python3 scripts/api_perf_compare.py perf_results/*.csv --markdown > perf_results/summary.md
For each endpoint and host prints cold (ms, first request after an API restart) and hot median
(ms, over the hot passes). When several CSVs exist for the same host the newest one wins.
"""
import argparse
import csv
import statistics
from collections import defaultdict
from pathlib import Path
def load(paths):
# host -> newest file
newest = {}
for p in paths:
host = p.stem.rsplit('_', 2)[0]
if host not in newest or p.stat().st_mtime > newest[host].stat().st_mtime:
newest[host] = p
data = defaultdict(lambda: defaultdict(lambda: dict(cold=None, cold_each=None, hot=[], status=set())))
order = []
for host, p in sorted(newest.items()):
with open(p) as f:
for row in csv.DictReader(f):
d = data[row['name']][host]
ms = float(row['ms'])
if row['phase'] == 'cold':
d['cold'] = ms
elif row['phase'] == 'cold-each':
d['cold_each'] = ms
elif row['phase'] == 'hot':
d['hot'].append(ms)
d['status'].add(row['status'])
if row['name'] not in order:
order.append(row['name'])
return sorted(newest), data, order
def fmt(v):
if v is None:
return '-'
return f'{v:,.0f}'
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('csvs', nargs='+', type=Path)
ap.add_argument('--sort', choices=['catalog', 'hot', 'cold'], default='catalog',
help='catalog order (default), or by worst hot median / worst cold across hosts.')
ap.add_argument('--top', type=int, help='Only print the first N rows after sorting.')
ap.add_argument('--markdown', action='store_true')
args = ap.parse_args()
hosts, data, order = load(args.csvs)
def hot_med(name, host):
h = data[name][host]['hot']
return statistics.median(h) if h else None
def worst(name, key):
vals = [data[name][h][key] if key == 'cold' else hot_med(name, h) for h in hosts]
vals = [v for v in vals if v is not None]
return max(vals) if vals else -1
names = list(order)
if args.sort == 'hot':
names.sort(key=lambda n: -worst(n, 'hot'))
elif args.sort == 'cold':
names.sort(key=lambda n: -worst(n, 'cold'))
if args.top:
names = names[:args.top]
cols = ['endpoint'] + [f'{h} cold' for h in hosts] + [f'{h} hot' for h in hosts]
rows = []
for n in names:
row = [n]
for h in hosts:
d = data[n][h]
c = d['cold_each'] if d['cold_each'] is not None else d['cold']
bad = d['status'] and d['status'] != {'200'}
row.append(fmt(c) + ('*' if d['cold_each'] is not None else '') + (' !' if bad else ''))
for h in hosts:
row.append(fmt(hot_med(n, h)))
rows.append(row)
if args.markdown:
print('| ' + ' | '.join(cols) + ' |')
print('|' + '|'.join(['---'] + ['---:'] * (len(cols) - 1)) + '|')
for r in rows:
print('| ' + ' | '.join(r) + ' |')
else:
widths = [max(len(str(x)) for x in col) for col in zip(cols, *rows)]
line = ' '.join(c.ljust(w) if i == 0 else c.rjust(w) for i, (c, w) in enumerate(zip(cols, widths)))
print(line)
print('-' * len(line))
for r in rows:
print(' '.join(c.ljust(w) if i == 0 else c.rjust(w) for i, (c, w) in enumerate(zip(r, widths))))
print('\nms; cold = first request after API restart (* = own restart via --cold-each); hot = median of hot passes;'
' ! = non-200 seen')
if __name__ == '__main__':
main()