|
10 | 10 | import asyncio |
11 | 11 | import hmac |
12 | 12 | import ipaddress |
| 13 | +import json |
13 | 14 | import logging |
14 | 15 | import os |
15 | 16 | import time |
|
34 | 35 |
|
35 | 36 | log = logging.getLogger(__name__) |
36 | 37 |
|
| 38 | +_INGEST_MAX_BODY_BYTES = 16 * 1024 * 1024 |
| 39 | +_INGEST_QUEUE_MAX_ITEMS = 64 |
| 40 | + |
| 41 | + |
| 42 | +async def _read_limited_body(request: Request) -> bytes: |
| 43 | + """Read at most ``_INGEST_MAX_BODY_BYTES`` without buffering more.""" |
| 44 | + raw_length = request.headers.get("content-length") |
| 45 | + if raw_length and raw_length.isdecimal() and int(raw_length) > _INGEST_MAX_BODY_BYTES: |
| 46 | + raise HTTPException(status_code=413, detail="ingest body too large") |
| 47 | + |
| 48 | + chunks: list[bytes] = [] |
| 49 | + body_size = 0 |
| 50 | + async for chunk in request.stream(): |
| 51 | + body_size += len(chunk) |
| 52 | + if body_size > _INGEST_MAX_BODY_BYTES: |
| 53 | + raise HTTPException(status_code=413, detail="ingest body too large") |
| 54 | + chunks.append(chunk) |
| 55 | + return b"".join(chunks) |
| 56 | + |
| 57 | + |
| 58 | +async def _read_limited_json(request: Request) -> object: |
| 59 | + return json.loads(await _read_limited_body(request)) |
| 60 | + |
| 61 | + |
37 | 62 | # --------------------------------------------------------------------------- |
38 | 63 | # Write queue — decouple HTTP ingest latency from SQLite write latency. |
39 | 64 | # |
|
46 | 71 | # at a time, the event loop is never blocked, and HTTP latency is near-zero. |
47 | 72 | # --------------------------------------------------------------------------- |
48 | 73 |
|
49 | | -_ingest_queue: asyncio.Queue[bytes] = asyncio.Queue() |
50 | | -_QUEUE_WARN_THRESHOLD = 500 # log a warning if the backlog grows this large |
| 74 | +_ingest_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=_INGEST_QUEUE_MAX_ITEMS) |
| 75 | +_QUEUE_WARN_THRESHOLD = 48 |
51 | 76 |
|
52 | 77 | # Single-writer thread pool: exactly one thread owns the write connection. |
53 | 78 | # Using max_workers=1 ensures serial SQLite writes with no concurrent access. |
@@ -216,21 +241,17 @@ async def otlp_ingest(request: Request): |
216 | 241 | (3-5 MB payloads) don't block the event loop while parsing. |
217 | 242 | """ |
218 | 243 | try: |
219 | | - body_bytes = await request.body() |
| 244 | + body_bytes = await _read_limited_body(request) |
220 | 245 | except ClientDisconnect: |
221 | | - # BSP exporter timed out and closed the connection before we read the |
222 | | - # body — log at WARNING (not ERROR) since this is a transient backpressure |
223 | | - # signal, not a bug. The BSP will retry on the next export cycle. |
224 | 246 | log.warning("[otlp_ingest] Client disconnected before body was read — BSP may retry") |
225 | 247 | return JSONResponse(status_code=499, content={"error": "client disconnected"}) |
226 | 248 | qsize = _ingest_queue.qsize() |
227 | 249 | if qsize >= _QUEUE_WARN_THRESHOLD: |
228 | | - log.warning( |
229 | | - "[otlp_ingest] Write queue backlog: %d pending — " |
230 | | - "SQLite may not be keeping up with ingest rate.", |
231 | | - qsize, |
232 | | - ) |
233 | | - await _ingest_queue.put(body_bytes) |
| 250 | + log.warning("[otlp_ingest] Write queue backlog: %d pending", qsize) |
| 251 | + try: |
| 252 | + _ingest_queue.put_nowait(body_bytes) |
| 253 | + except asyncio.QueueFull: |
| 254 | + return JSONResponse(status_code=503, content={"error": "ingest queue is full"}) |
234 | 255 | return JSONResponse(content={"queued": True}) |
235 | 256 |
|
236 | 257 |
|
@@ -278,7 +299,8 @@ async def journal_messages_ingest(request: Request): |
278 | 299 | """ |
279 | 300 | import sqlite3 |
280 | 301 |
|
281 | | - body = await request.json() |
| 302 | + body = await _read_limited_json(request) |
| 303 | + |
282 | 304 | if not isinstance(body, list): |
283 | 305 | return JSONResponse( |
284 | 306 | status_code=400, |
@@ -315,7 +337,8 @@ async def journal_call_ingest(request: Request): |
315 | 337 | """ |
316 | 338 | import sqlite3 |
317 | 339 |
|
318 | | - body = await request.json() |
| 340 | + body = await _read_limited_json(request) |
| 341 | + |
319 | 342 | if not isinstance(body, dict) or not body.get("call_id") or not body.get("session_id"): |
320 | 343 | return JSONResponse( |
321 | 344 | status_code=400, |
@@ -361,7 +384,8 @@ async def journal_blocks_ingest(request: Request): |
361 | 384 | """ |
362 | 385 | import sqlite3 |
363 | 386 |
|
364 | | - body = await request.json() |
| 387 | + body = await _read_limited_json(request) |
| 388 | + |
365 | 389 | if not isinstance(body, list): |
366 | 390 | return JSONResponse( |
367 | 391 | status_code=400, |
|
0 commit comments