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
|
|
|
import asyncio
|
2023-05-29 18:04:03 -06:00
|
|
|
import urllib.parse
|
|
|
|
|
from http import HTTPStatus
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
from sanic import Request, response, Blueprint
|
2023-05-29 18:04:03 -06:00
|
|
|
from sanic_ext import validate
|
|
|
|
|
from sanic_ext.extensions.openapi import openapi
|
|
|
|
|
|
|
|
|
|
from wrolpi import lang
|
2024-04-17 11:25:26 -06:00
|
|
|
from wrolpi.api_utils import json_response
|
2023-05-29 18:04:03 -06:00
|
|
|
from wrolpi.common import logger
|
|
|
|
|
from wrolpi.downloader import download_manager
|
|
|
|
|
from wrolpi.events import Events
|
|
|
|
|
from . import lib, schema
|
2024-01-01 11:31:17 -07:00
|
|
|
from .models import Zims
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
zim_bp = Blueprint('Zim', '/api/zim')
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.get('/')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='List all known Zim files',
|
|
|
|
|
response=schema.GetZimsResponse,
|
|
|
|
|
)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def get_zims(request: Request):
|
|
|
|
|
session = request.ctx.session
|
|
|
|
|
zims = lib.get_zims(session)
|
|
|
|
|
resp = {
|
|
|
|
|
'zims': [i.__json__() for i in zims],
|
|
|
|
|
}
|
2023-05-29 18:04:03 -06:00
|
|
|
return json_response(resp)
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.delete('/<zim_ids:[0-9,]+>')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Delete all Zim files',
|
|
|
|
|
)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def delete_zims(request: Request, zim_ids: str):
|
|
|
|
|
session = request.ctx.session
|
|
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
zim_ids = [int(i) for i in zim_ids.split(',')]
|
2025-12-10 21:16:10 -07:00
|
|
|
lib.delete_zims(session, zim_ids)
|
2023-05-29 18:04:03 -06:00
|
|
|
return response.empty()
|
|
|
|
|
|
|
|
|
|
|
2025-02-02 10:16:11 -07:00
|
|
|
@zim_bp.post('/<zim_id:[0-9]+>/auto_search')
|
|
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Change if a Zim file will be searched by default.',
|
|
|
|
|
body=schema.ZimAutoSearchRequest,
|
|
|
|
|
)
|
|
|
|
|
@validate(schema.ZimAutoSearchRequest)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def post_set_zim_auto_search(request: Request, zim_id: int, body: schema.ZimAutoSearchRequest):
|
|
|
|
|
session = request.ctx.session
|
2025-02-02 10:16:11 -07:00
|
|
|
zim_id = int(zim_id)
|
2025-12-10 21:16:10 -07:00
|
|
|
lib.set_zim_auto_search(session, zim_id, body.auto_search)
|
2025-02-02 10:16:11 -07:00
|
|
|
return response.empty()
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 14:08:26 -06:00
|
|
|
@zim_bp.post('/search')
|
|
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Search all Zim files that have auto_search enabled',
|
|
|
|
|
body=schema.ZimSearchRequest,
|
|
|
|
|
)
|
|
|
|
|
@validate(schema.ZimSearchRequest)
|
2026-10-03 15:42:06 -06:00
|
|
|
async def search_all_zims(request: Request, body: schema.ZimSearchRequest):
|
|
|
|
|
# libzim searches are synchronous and slow; run off the event loop. The request's Session is used
|
|
|
|
|
# in the worker thread; the handler awaits the thread, so the Session has one user at a time.
|
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
|
|
|
def search():
|
2026-10-03 15:42:06 -06:00
|
|
|
return lib.search_all_zims(request.ctx.session, body.search_str, tag_names=body.tag_names,
|
|
|
|
|
offset=body.offset, limit=body.limit)
|
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
|
|
|
|
|
|
|
|
results = await asyncio.to_thread(search)
|
2026-03-20 14:08:26 -06:00
|
|
|
return json_response({'zims': results})
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.post('/search/<zim_id:[0-9]+>')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Search all entries of a Zim',
|
|
|
|
|
body=schema.ZimSearchRequest,
|
|
|
|
|
response=schema.ZimSearchResponse,
|
|
|
|
|
)
|
|
|
|
|
@validate(schema.ZimSearchRequest)
|
2026-10-03 15:42:06 -06:00
|
|
|
async def search_zim(request: Request, zim_id: int, body: schema.ZimSearchRequest):
|
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
|
|
|
def search():
|
2026-10-03 15:42:06 -06:00
|
|
|
return lib.headline_zim(request.ctx.session, body.search_str, zim_id, tag_names=body.tag_names,
|
|
|
|
|
offset=body.offset, limit=body.limit)
|
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
|
|
|
|
|
|
|
|
headlines = await asyncio.to_thread(search)
|
2023-05-29 18:04:03 -06:00
|
|
|
return json_response({'zim': headlines})
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.get('/<zim_id:[0-9]+>/entry/<zim_path:[ -~/]*>')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Read the entry at `zim_path` from the Zim file',
|
|
|
|
|
)
|
|
|
|
|
async def get_zim_entry(_: Request, zim_id: int, zim_path: str):
|
|
|
|
|
zim_path = urllib.parse.unquote(zim_path)
|
|
|
|
|
entry = lib.get_entry(zim_path, zim_id=zim_id)
|
|
|
|
|
content = bytes(entry.get_item().content)
|
|
|
|
|
try:
|
|
|
|
|
resp = response.html(content.decode('UTF-8'))
|
|
|
|
|
logger.debug(f'Returning ZIM HTML response {repr(str(zim_path))}')
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
resp = response.raw(content)
|
|
|
|
|
logger.debug(f'Returning ZIM raw response {repr(str(zim_path))}')
|
|
|
|
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.post('/tag')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Tag a Zim entry',
|
|
|
|
|
body=schema.TagZimEntry,
|
|
|
|
|
)
|
|
|
|
|
@validate(schema.TagZimEntry)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def post_zim_tag(request: Request, body: schema.TagZimEntry):
|
|
|
|
|
session = request.ctx.session
|
|
|
|
|
|
|
|
|
|
await lib.add_tag(session, body.tag_name, body.zim_id, body.zim_entry)
|
2023-05-29 18:04:03 -06:00
|
|
|
return response.empty(HTTPStatus.CREATED)
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.post('/untag')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Untag a Zim entry',
|
|
|
|
|
body=schema.TagZimEntry,
|
|
|
|
|
)
|
|
|
|
|
@validate(schema.TagZimEntry)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def post_zim_untag(request: Request, body: schema.TagZimEntry):
|
|
|
|
|
session = request.ctx.session
|
|
|
|
|
|
|
|
|
|
await lib.untag(session, body.tag_name, body.zim_id, body.zim_entry)
|
2023-05-29 18:04:03 -06:00
|
|
|
return response.empty(HTTPStatus.NO_CONTENT)
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.get('/subscribe')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Retrieve Zim subscriptions',
|
|
|
|
|
response=schema.ZimSubscriptions,
|
|
|
|
|
)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def get_zim_subscriptions(request: Request):
|
2023-05-29 18:04:03 -06:00
|
|
|
catalog = lib.get_kiwix_catalog()
|
2025-12-10 21:16:10 -07:00
|
|
|
session = request.ctx.session
|
|
|
|
|
subscriptions = {i: j.__json__() for i, j in lib.get_kiwix_subscriptions(session).items()}
|
2023-05-29 18:04:03 -06:00
|
|
|
resp = dict(
|
|
|
|
|
catalog=catalog,
|
|
|
|
|
iso_639_codes=lang.ISO_639_CODES,
|
|
|
|
|
subscriptions=subscriptions,
|
|
|
|
|
)
|
|
|
|
|
return json_response(resp)
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.post('/subscribe')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Subscribe to a particular Kiwix Zim',
|
2023-07-17 17:32:29 -06:00
|
|
|
body=schema.ZimSubscribeRequest,
|
2023-05-29 18:04:03 -06:00
|
|
|
)
|
2023-07-17 17:32:29 -06:00
|
|
|
@validate(schema.ZimSubscribeRequest)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def post_zim_subscribe(request: Request, body: schema.ZimSubscribeRequest):
|
|
|
|
|
session = request.ctx.session
|
|
|
|
|
|
|
|
|
|
await lib.subscribe(session, body.name, body.language)
|
2024-12-11 11:52:33 -07:00
|
|
|
if download_manager.disabled or download_manager.stopped:
|
2023-05-29 18:04:03 -06:00
|
|
|
# Downloads are disabled, warn the user.
|
|
|
|
|
Events.send_downloads_disabled('Download created. But, downloads are disabled.')
|
2023-07-17 17:32:29 -06:00
|
|
|
Events.send_created(f'Zim subscription created')
|
2023-05-29 18:04:03 -06:00
|
|
|
return response.empty(HTTPStatus.CREATED)
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.delete('/subscribe/<subscription_id:[0-9]+>')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Unsubscribe to a particular Kiwix Zim',
|
|
|
|
|
)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def delete_zim_subscription(request: Request, subscription_id: int):
|
|
|
|
|
session = request.ctx.session
|
|
|
|
|
|
|
|
|
|
await lib.unsubscribe(session, subscription_id)
|
2023-07-17 17:32:29 -06:00
|
|
|
Events.send_deleted(f'Zim subscription deleted')
|
2023-05-29 18:04:03 -06:00
|
|
|
return response.empty(HTTPStatus.NO_CONTENT)
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.get('/outdated')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Returns the outdated and current Zim files',
|
|
|
|
|
response=schema.OutdatedZims,
|
|
|
|
|
)
|
|
|
|
|
async def get_outdated_zims(_: Request):
|
|
|
|
|
outdated, current = lib.find_outdated_zim_files()
|
|
|
|
|
d = dict(outdated=outdated, current=current)
|
|
|
|
|
return json_response(d)
|
|
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.delete('/outdated')
|
2023-05-29 18:04:03 -06:00
|
|
|
@openapi.definition(
|
|
|
|
|
summary='Remove all outdated Zims, if any.'
|
|
|
|
|
)
|
|
|
|
|
async def delete_outdated_zims(_: Request):
|
2026-09-12 15:18:13 -06:00
|
|
|
try:
|
|
|
|
|
deleted_count = await lib.remove_outdated_zim_files()
|
|
|
|
|
finally:
|
|
|
|
|
# The flag must match disk whether or not the delete succeeded.
|
2023-07-17 17:32:29 -06:00
|
|
|
lib.flag_outdated_zim_files()
|
2026-09-12 15:18:13 -06:00
|
|
|
if deleted_count:
|
2023-07-17 17:32:29 -06:00
|
|
|
Events.send_deleted(f'Deleted {deleted_count} outdated Zims')
|
2023-05-29 18:04:03 -06:00
|
|
|
return response.empty(HTTPStatus.NO_CONTENT)
|
2024-01-01 11:31:17 -07:00
|
|
|
|
|
|
|
|
|
2024-04-17 11:25:26 -06:00
|
|
|
@zim_bp.post('/search_estimates')
|
2024-01-01 11:31:17 -07:00
|
|
|
@validate(json=schema.SearchEstimateRequest)
|
2025-12-10 21:16:10 -07:00
|
|
|
async def post_search_estimates(request: Request, body: schema.SearchEstimateRequest):
|
2024-01-01 11:31:17 -07:00
|
|
|
"""Get an estimated count of FileGroups/Zims which may or may not have been tagged."""
|
|
|
|
|
|
|
|
|
|
if not body.search_str and not body.tag_names:
|
|
|
|
|
return response.empty(HTTPStatus.BAD_REQUEST)
|
|
|
|
|
|
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
|
|
|
def estimate():
|
2026-10-03 15:42:06 -06:00
|
|
|
session = request.ctx.session
|
|
|
|
|
if body.tag_names:
|
|
|
|
|
# Get actual count of entries tagged with the tag names.
|
|
|
|
|
counts = Zims.entries_with_tags(session, body.tag_names)
|
|
|
|
|
else:
|
|
|
|
|
# Get estimates using libzim.
|
|
|
|
|
counts = Zims.estimate(session, body.search_str)
|
|
|
|
|
return [dict(estimate=count, **zim.__json__()) for zim, count in counts.items()]
|
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
|
|
|
|
|
|
|
|
zims_estimates = await asyncio.to_thread(estimate)
|
2024-01-01 11:31:17 -07:00
|
|
|
|
|
|
|
|
ret = dict(
|
|
|
|
|
zims_estimates=zims_estimates
|
|
|
|
|
)
|
|
|
|
|
return json_response(ret)
|