mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
* 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>
109 lines
4 KiB
Python
109 lines
4 KiB
Python
#!/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()
|