File size: 14,508 Bytes
aa0a3e7
 
 
0538c9f
aa0a3e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691c80b
aa0a3e7
 
 
 
691c80b
aa0a3e7
8afccde
 
 
aa0a3e7
 
 
 
 
 
 
8afccde
0538c9f
 
aa0a3e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691c80b
aa0a3e7
0538c9f
 
8afccde
aa0a3e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0538c9f
 
 
 
aa0a3e7
 
0538c9f
691c80b
 
 
aa0a3e7
 
 
 
0538c9f
8afccde
 
 
 
0538c9f
 
 
 
 
 
 
 
 
8afccde
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0538c9f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa0a3e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691c80b
aa0a3e7
691c80b
aa0a3e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691c80b
aa0a3e7
691c80b
 
 
aa0a3e7
691c80b
 
 
aa0a3e7
 
 
 
 
 
 
 
 
 
 
 
691c80b
 
 
 
 
aa0a3e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
import asyncio
import json
import random
import threading
import time
from typing import Any

from elasticsearch import Elasticsearch
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, Response

from .bucket_snapshot import snapshot_status as bucket_snapshot_status
from .config import APP_ROOT, DATA_ROOT, ES_URL, INDEX_NAME
from .doc_store import DOC_DB, available_years, warmup_db
from .facet_store import FACET_DB, list_facets, sources_payload
from .indexer import sidecars_ready
from .search_logic import SearchRequest
from .storage_lock import serving_lock


router = APIRouter()
es = Elasticsearch(ES_URL, request_timeout=120)
INDEX_STATUS_PATH = DATA_ROOT / "index-status"
INDEX_SWITCH_STATE_PATH = DATA_ROOT / "index-switch.json"
STATUS_CACHE_TTL_SECONDS = 5
WARMUP_READY_POLL_SECONDS = 5
SEARCH_CACHE_TTL_SECONDS = 30
SEARCH_CACHE_MAX_ENTRIES = 128
LITERAL_CACHE_TTL_SECONDS = 300
LITERAL_CACHE_MAX_ENTRIES = 16
LITERAL_CACHE_MAX_TOTAL_IDS = 250000
FACET_FIELDS = {"source", "author", "tag", "type", "archive"}
FACET_CACHE_TTL_SECONDS = 600
active_user_requests = 0
last_user_activity = time.monotonic()
activity_lock = asyncio.Lock()
status_response_cache: tuple[float, dict[str, Any]] | None = None
search_response_cache: dict[tuple[Any, ...], tuple[float, dict[str, Any]]] = {}
literal_search_cache: dict[tuple[Any, ...], tuple[float, tuple[str, ...]]] = {}
search_cache_lock = threading.Lock()
search_inflight: dict[tuple[Any, ...], threading.Event] = {}
facet_response_cache: dict[tuple[Any, ...], tuple[float, dict[str, Any]]] = {}
sources_response_cache: tuple[float, dict[str, Any]] | None = None
sources_response_cache_generation: tuple[tuple[int, int, int, int] | None, ...] | None = None
_has_index_data = False


async def track_request(request: Request, call_next):
    tracked = tracks_user_activity(request.url.path)
    if tracked:
        global active_user_requests, last_user_activity
        async with activity_lock:
            active_user_requests += 1
            last_user_activity = time.monotonic()
    try:
        return await call_next(request)
    finally:
        if tracked:
            async with activity_lock:
                active_user_requests = max(0, active_user_requests - 1)
                last_user_activity = time.monotonic()


def tracks_user_activity(path: str) -> bool:
    return (
        path == "/api/search"
        or path == "/api/random"
        or path.startswith("/api/preview/")
        or path.startswith("/api/download/")
    )


@router.get("/api/health")

def health():
    result = cached_status_payload(include_es=True)
    return JSONResponse(result, status_code=200 if result.get("ok") else 503)

def warm_static_files() -> bool:
    paths = [APP_ROOT / "static" / name for name in ("index.html", "app.js", "style.css")]
    try:
        return all(path.read_bytes() for path in paths)
    except Exception:
        return False

def clear_response_caches() -> None:
    global sources_response_cache, sources_response_cache_generation, status_response_cache
    facet_response_cache.clear()
    with search_cache_lock:
        search_response_cache.clear()
        literal_search_cache.clear()
    sources_response_cache = None
    sources_response_cache_generation = None
    status_response_cache = None

def serving_generation_token() -> tuple[tuple[int, int, int, int] | None, ...]:
    values = []
    for path in (DOC_DB, FACET_DB):
        try:
            stat = path.stat()
            values.append((stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns))
        except OSError:
            values.append(None)
    return tuple(values)


def search_cache_key(body: SearchRequest, page: int, page_size: int) -> tuple[Any, ...]:
    def values(plural: list[str], singular: str | None = None) -> tuple[str, ...]:
        selected = plural or ([singular] if singular else [])
        return tuple(sorted(set(str(item).strip() for item in selected if str(item).strip())))

    return (
        serving_generation_token(),
        body.q.strip(), page, page_size, body.exact, body.fulltext, body.sort,
        values(body.sources, body.source), values(body.exclude_sources),
        values(body.authors, body.author), values(body.exclude_authors),
        values(body.tags, body.tag), values(body.exclude_tags), body.archive_id,
        body.publication_type, body.date_from, body.date_to,
        tuple(tuple(sorted(term.items())) for term in body.date_terms),
    )


def literal_cache_key(body: SearchRequest) -> tuple[Any, ...]:
    return search_cache_key(body, 0, 0)


def cached_search_response(key: tuple[Any, ...]) -> dict[str, Any] | None:
    now = time.monotonic()
    with search_cache_lock:
        cached = search_response_cache.get(key)
        if cached and now - cached[0] < SEARCH_CACHE_TTL_SECONDS:
            return dict(cached[1])
    return None


def cached_literal_ids(key: tuple[Any, ...]) -> tuple[str, ...] | None:
    now = time.monotonic()
    with search_cache_lock:
        cached = literal_search_cache.get(key)
        if cached and now - cached[0] < LITERAL_CACHE_TTL_SECONDS:
            return cached[1]
        if cached:
            literal_search_cache.pop(key, None)
    return None


def store_literal_ids(key: tuple[Any, ...], doc_ids: list[str]) -> None:
    values = tuple(doc_ids)
    if len(values) > LITERAL_CACHE_MAX_TOTAL_IDS:
        return
    with search_cache_lock:
        literal_search_cache[key] = (time.monotonic(), values)
        while (
            len(literal_search_cache) > LITERAL_CACHE_MAX_ENTRIES
            or sum(len(item[1]) for item in literal_search_cache.values()) > LITERAL_CACHE_MAX_TOTAL_IDS
        ):
            oldest_key = min(literal_search_cache, key=lambda item: literal_search_cache[item][0])
            literal_search_cache.pop(oldest_key, None)


def begin_search(key: tuple[Any, ...]) -> tuple[bool, threading.Event]:
    with search_cache_lock:
        event = search_inflight.get(key)
        if event is not None:
            return False, event
        event = threading.Event()
        search_inflight[key] = event
        return True, event


def finish_search(key: tuple[Any, ...], event: threading.Event) -> None:
    with search_cache_lock:
        if search_inflight.get(key) is event:
            search_inflight.pop(key, None)
            event.set()


def store_search_response(key: tuple[Any, ...], result: dict[str, Any]) -> None:
    with search_cache_lock:
        search_response_cache[key] = (time.monotonic(), result)
        if len(search_response_cache) > SEARCH_CACHE_MAX_ENTRIES:
            oldest_key = min(search_response_cache, key=lambda item: search_response_cache[item][0])
            search_response_cache.pop(oldest_key, None)

def warmup_elasticsearch() -> bool:
    try:
        with serving_lock():
            es.search(
                index=INDEX_NAME,
                size=0,
                track_total_hits=False,
                query={"match_all": {}},
                _source=False,
            )
        return True
    except Exception:
        return False

def run_auxiliary_warmup(full: bool = False) -> dict[str, Any]:
    elasticsearch_warmed = False
    preview_warmed = False
    facets_warmed = False
    static_warmed = False
    if full:
        clear_response_caches()
    try:
        elasticsearch_warmed = warmup_elasticsearch()
    except Exception:
        pass
    try:
        with serving_lock():
            preview_warmed = warmup_db()
    except Exception:
        pass
    try:
        with serving_lock():
            cached_sources_payload()
            available_years()
            for kind in ("source", "author", "tag"):
                cached_facet_payload(kind, 1, 200, "")
            facets_warmed = True
    except Exception:
        pass
    static_warmed = warm_static_files()
    ok = elasticsearch_warmed and preview_warmed and facets_warmed and static_warmed
    return {
        "ok": ok,
        "elasticsearch_warmed": elasticsearch_warmed,
        "preview_warmed": preview_warmed,
        "facets_warmed": facets_warmed,
        "static_warmed": static_warmed,
    }

async def user_is_active() -> bool:
    async with activity_lock:
        return active_user_requests > 0 or time.monotonic() - last_user_activity < 90

async def auxiliary_warmup_loop() -> None:
    while True:
        await asyncio.sleep(random.randint(300, 540))
        if not await asyncio.to_thread(serving_generation_ready):
            continue
        if await user_is_active():
            continue
        await asyncio.to_thread(run_auxiliary_warmup, False)

def serving_generation_ready() -> bool:
    return index_status() == "ready" and index_ready()

async def initialize_when_ready() -> None:
    while not await asyncio.to_thread(serving_generation_ready):
        await asyncio.sleep(WARMUP_READY_POLL_SECONDS)
    await asyncio.to_thread(run_auxiliary_warmup, True)


@router.get("/api/ping")
def ping():
    return Response(status_code=204, headers={"Cache-Control": "no-store"})


def index_progress() -> dict[str, Any] | None:
    try:
        return json.loads((DATA_ROOT / "index-progress.json").read_text(encoding="utf-8"))
    except Exception:
        return None

def index_status() -> str:
    try:
        return INDEX_STATUS_PATH.read_text(encoding="utf-8").strip() or "unknown"
    except Exception:
        return "unknown"

def cached_status_payload(include_es: bool = False) -> dict[str, Any]:
    global status_response_cache
    now = time.monotonic()
    current_status = index_status()
    if (
        status_response_cache
        and now - status_response_cache[0] < STATUS_CACHE_TTL_SECONDS
        and status_response_cache[1].get("index_status") == current_status
    ):
        result = dict(status_response_cache[1])
    else:
        status = current_status
        ready = status == "ready" and index_ready() and FACET_DB.exists()
        result = {
            "ok": ready,
            "index_ready": ready,
            "document_count": document_count(),
            "index_status": status,
            "progress": index_progress(),
            "bucket_snapshot": bucket_snapshot_status(),
        }
        status_response_cache = (now, result)
        result = dict(result)
    if include_es:
        try:
            result["es"] = es.ping()
        except Exception:
            result["es"] = False
        result["ok"] = bool(result["ok"] and result["es"])
    return result

def index_ready() -> bool:
    try:
        data = es.get(index=INDEX_NAME, id="__meta__")
        return bool(data.get("found"))
    except Exception:
        return False

def serving_generation_valid() -> bool:
    for attempt in range(5):
        try:
            metadata = es.get(index=INDEX_NAME, id="__meta__").get("_source", {})
            if metadata and sidecars_ready(metadata):
                return True
        except Exception:
            pass
        if attempt < 4:
            time.sleep(0.2)
    return False

def document_count() -> int:
    try:
        if not es.indices.exists(index=INDEX_NAME):
            return 0
        return int(es.count(index=INDEX_NAME, query={"exists": {"field": "doc_id"}}).get("count", 0))
    except Exception:
        return 0

_has_index_data: bool = False

def has_index_data() -> bool:
    global _has_index_data
    if _has_index_data:
        return True
    try:
        if es.indices.exists(index=INDEX_NAME):
            _has_index_data = True
            return True
        return False
    except Exception:
        return False
FACET_FIELDS = {"source", "author", "tag", "type", "archive"}
FACET_CACHE_TTL_SECONDS = 600
facet_response_cache: dict[tuple[Any, ...], tuple[float, dict[str, Any]]] = {}
sources_response_cache: tuple[float, dict[str, Any]] | None = None
sources_response_cache_generation: tuple[tuple[int, int, int, int] | None, ...] | None = None

def cached_facet_payload(kind: str, page: int, page_size: int, q: str) -> dict[str, Any]:
    now = time.monotonic()
    key = (serving_generation_token(), kind, page, page_size, q)
    cached = facet_response_cache.get(key)
    if cached and now - cached[0] < FACET_CACHE_TTL_SECONDS:
        return dict(cached[1])
    data = list_facets(kind, page, page_size, q)
    facet_response_cache[key] = (now, data)
    if len(facet_response_cache) > 300:
        oldest_key = min(facet_response_cache, key=lambda item: facet_response_cache[item][0])
        facet_response_cache.pop(oldest_key, None)
    return dict(data)

def cached_sources_payload() -> dict[str, Any]:
    global sources_response_cache, sources_response_cache_generation
    now = time.monotonic()
    generation = serving_generation_token()
    if (sources_response_cache and sources_response_cache_generation == generation
            and now - sources_response_cache[0] < FACET_CACHE_TTL_SECONDS):
        return dict(sources_response_cache[1])
    result = sources_payload()
    result["years"] = [{"name": str(year), "value": year, "count": 0} for year in available_years()]
    sources_response_cache = (now, result)
    sources_response_cache_generation = generation
    return dict(result)

@router.get("/api/facet/{kind}")

def facet(kind: str, page: int = 1, page_size: int = 200, q: str = ""):
    if kind not in FACET_FIELDS:
        return JSONResponse({"error": "unknown facet"}, status_code=404)
    status_data = cached_status_payload()
    status = str(status_data.get("index_status") or "unknown")
    if status == "restoring" or not FACET_DB.exists():
        return {"items": [], "total": 0, "page": max(1, page), "page_size": page_size, "has_more": False, "indexing": status != "ready", "index_status": status}
    with serving_lock():
        data = cached_facet_payload(kind, max(1, page), min(max(1, page_size), 500), q.strip())
    data.update({"indexing": not bool(status_data.get("index_ready")), "index_status": status})
    return data
@router.get("/api/sources")

def sources():
    status_data = cached_status_payload()
    status = str(status_data.get("index_status") or "unknown")
    if status == "restoring" or not FACET_DB.exists():
        return {"sources": [], "authors": [], "tags": [], "archives": [], "types": [], "years": [], "indexing": status != "ready", "index_status": status}
    with serving_lock():
        result = cached_sources_payload()
    result.update({"indexing": not bool(status_data.get("index_ready")), "index_status": status})
    return result