steam-backlog-enforcer/steam_backlog_enforcer/_web_sse.py
Krzysztof kuhy Rudnicki 57d262b711
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Pylint gate 10 over the whole package
Raise the pylint hook to --fail-under=10.0 and make it lint the whole
package on every run (pass_filenames: false): over the staged subset a
9.99 tree passed at commit time.

Ports de2aea3 (9b9342ba-1) onto main, minus its web/ and requirements.txt
bumps and the iptables dedupe f1822f1 already landed; _hltb_confidence and
_hltb_search keep main's 429 handling. _search_batch splits out
_acquire_auth / _search_headers, and _stats.py is back to 250 lines.

Drops the min-similarity-lines = 8 floor (0 duplicate-code hits at the
default) and sets max-attributes = 15 for the flat persisted records, as
kuhy decided. Tests stay outside pylint, as at every site already at 10.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:57:35 +01:00

115 lines
4.3 KiB
Python

"""``GET /api/jobs/{id}/events``: a job's ``events.jsonl`` as Server-Sent Events.
Each event is one unnamed frame — ``id: <seq>`` and ``data: <JobEvent JSON>``
— so ``EventSource.onmessage`` sees every type and a reconnect resumes with
``Last-Event-ID``. That header wins over ``?after=``: ``EventSource`` keeps
the URL it was opened with, so on a reconnect ``after`` is stale.
The stream ends once the job is over: right after the final ``state`` event
that follows ``result`` (the runner writes them back to back), or a short
grace after ``result`` if that state never comes. It also ends when the
server stands down; the browser reconnects to its replacement and resumes.
The server only reads files here, so a job is unaffected by any of this.
"""
from __future__ import annotations
import contextlib
from http import HTTPStatus
import json
import time
from typing import TYPE_CHECKING, Any, Final
from steam_backlog_enforcer._web_errors import ApiError, from_job_error
from steam_backlog_enforcer._web_process import RETIRING
from steam_backlog_enforcer.jobs._errors import JobError
from steam_backlog_enforcer.jobs._events import TERMINAL_STATES
from steam_backlog_enforcer.jobs._view import read_job, read_job_events
if TYPE_CHECKING:
from http.server import BaseHTTPRequestHandler
from steam_backlog_enforcer._web_io import Request
_POLL_SECONDS: Final = 0.25
_KEEPALIVE_SECONDS: Final = 15.0
_RESULT_GRACE_SECONDS: Final = 2.0
_RETRY_MS: Final = 2000
def resume_after(request: Request) -> int:
"""The last seq the client has: ``Last-Event-ID``, else ``?after=``.
Raises:
ApiError: ``invalid_params`` for a non-numeric value.
"""
raw = request.headers.get("Last-Event-ID") or request.query.get("after") or "0"
try:
return max(0, int(raw))
except ValueError:
msg = f"after must be an event seq, got {raw!r}"
raise ApiError(msg, code="invalid_params") from None
def _frame(event: dict[str, Any]) -> bytes:
"""One SSE frame for *event*."""
return f"id: {event['seq']}\ndata: {json.dumps(event)}\n\n".encode()
def _is_terminal_state(event: dict[str, Any]) -> bool:
"""Whether *event* is the job's final ``state`` transition."""
return event.get("type") == "state" and event.get("state") in TERMINAL_STATES
def _pump(handler: BaseHTTPRequestHandler, job_id: str, after: int) -> None:
"""Write events as they appear until the job is over or we retire."""
last_write = time.monotonic()
result_at: float | None = None
while not RETIRING.is_set():
events = read_job_events(job_id, after)
for event in events:
handler.wfile.write(_frame(event))
after = event["seq"]
if event.get("type") == "result":
result_at = time.monotonic()
if _is_terminal_state(event):
return
now = time.monotonic()
if events:
handler.wfile.flush()
last_write = now
elif result_at is not None and now - result_at > _RESULT_GRACE_SECONDS:
return
elif result_at is None and read_job(job_id)["state"] in TERMINAL_STATES:
return # Resumed past the end of a finished job: nothing more.
elif now - last_write >= _KEEPALIVE_SECONDS:
handler.wfile.write(b": keep-alive\n\n")
handler.wfile.flush()
last_write = now
time.sleep(_POLL_SECONDS)
def stream_job_events(handler: BaseHTTPRequestHandler, request: Request) -> None:
"""Serve the SSE stream for ``request.args[0]`` (auth already checked).
Raises:
ApiError: Before any byte is sent, for an unknown job or bad ``after``.
"""
job_id = request.args[0]
after = resume_after(request)
try:
read_job(job_id)
except JobError as exc:
raise from_job_error(exc) from None
handler.close_connection = True
handler.send_response(HTTPStatus.OK)
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-store")
handler.send_header("X-Accel-Buffering", "no")
handler.end_headers()
# The browser going away is not an error; it will reconnect if it cares.
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
handler.wfile.write(f"retry: {_RETRY_MS}\n\n".encode())
_pump(handler, job_id, after)
handler.wfile.flush()