Skip to content

Commit bcb62ee

Browse files
committed
Wire the per-engine worker to serve web search and keep-alive
Extend run_engine_worker so one warm context services three kinds of work on a single shared pace+cooldown budget, in priority order: an on-demand WEB search (preempts news) -> a due news topic -> an idle NEWS keep-alive warm-up. - WebSearchQueue: in-process FCFS handoff; a producer submits a query and gets a Future, the worker drains it between scrapes and hands results back. This is the internal/testable milestone; a cross-process bridge replaces it when web search is wired to the API/WS (same submit/poll shape). - Loop picks exactly one action per turn, then converges on the recycle check; the idle branch bounds its sleep by the next news/keep-alive due time (and a short web-poll slice when a queue is attached) so nothing is slept through. - _serve_oneoff runs web/keep-alive on the engine's live context, feeding any block into the same cooldown tracker but staying out of the metrics window (they aren't tracked-topic content). A benched engine defers both. Off unless wired: web_queue defaults to None and keep-alive is config-gated, so main.py's workers are unchanged. WebSearchQueue has unit tests.
1 parent 3abb0e1 commit bcb62ee

3 files changed

Lines changed: 291 additions & 34 deletions

File tree

scraper/webqueue.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""In-process FCFS queue of on-demand web searches for one engine.
2+
3+
On-demand web search reuses each engine's existing warm worker (see
4+
``scraper/worker.py``): a producer submits a query and gets a ``Future``, the
5+
engine's worker drains the queue between news scrapes — preempting due news —
6+
and hands the parsed results back through the future.
7+
8+
Today the producer and the worker live in the *same* process (this is the
9+
internal, testable milestone), so a thread-safe ``queue.Queue`` + ``Future`` is
10+
all that's needed. When web search is wired to the API/WebSocket the request
11+
originates in a different process, at which point this in-process queue is
12+
replaced by a cross-process bridge (a job queue + result hand-back) behind the
13+
same ``submit``/``poll`` shape — see TODO.md.
14+
"""
15+
16+
import queue
17+
from concurrent.futures import Future
18+
from dataclasses import dataclass, field
19+
20+
from common.model import NewsEntry
21+
22+
23+
@dataclass
24+
class WebSearchJob:
25+
"""One pending web search and the future its result is delivered through."""
26+
27+
query: str
28+
future: "Future[list[NewsEntry]]" = field(default_factory=Future)
29+
30+
31+
class WebSearchQueue:
32+
"""Thread-safe FCFS handoff of web searches from producers to one engine's
33+
worker. ``submit`` is called by producers; ``poll`` by the worker thread."""
34+
35+
def __init__(self) -> None:
36+
self._jobs: "queue.Queue[WebSearchJob]" = queue.Queue()
37+
38+
def submit(self, query: str) -> "Future[list[NewsEntry]]":
39+
"""Enqueue a web search; returns a future that resolves to its results
40+
(or raises if the worker failed to serve it)."""
41+
job = WebSearchJob(query=query)
42+
self._jobs.put(job)
43+
return job.future
44+
45+
def poll(self) -> WebSearchJob | None:
46+
"""The next pending job (FCFS), or None if the queue is empty. Never
47+
blocks — the worker polls between its other work."""
48+
try:
49+
return self._jobs.get_nowait()
50+
except queue.Empty:
51+
return None
52+
53+
def pending(self) -> int:
54+
"""Approximate number of queued jobs (for metrics/tests)."""
55+
return self._jobs.qsize()

scraper/worker.py

Lines changed: 172 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import random
3838
import threading
3939
import time
40+
from concurrent.futures import InvalidStateError
4041
from dataclasses import dataclass, field
4142
from datetime import datetime, timezone
4243
from typing import Callable
@@ -52,12 +53,20 @@
5253

5354
from .browser import new_context, profile_dir_for
5455
from .cooldown import EngineCooldownTracker
56+
from .keepalive import DEFAULT_QUERIES, KeepAliveHeartbeat
5557
from .saturation import SharedEngineState
56-
from .scraper import scrape_topic
57-
from .sources import SearchSource
58+
from .scraper import scrape_pages, scrape_topic
59+
from .sources import SearchSource, SearchVertical
60+
from .webqueue import WebSearchQueue
5861

5962
logger = logging.getLogger(__name__)
6063

64+
# When a web-search queue is attached, cap idle waits to this slice so a freshly
65+
# submitted (user-facing) web search is picked up promptly rather than slept
66+
# through. The deferred cross-process bridge can replace this poll with a proper
67+
# wakeup; in-process it's a cheap 1 Hz check while the worker is otherwise idle.
68+
_WEB_POLL_SLICE = 1.0
69+
6170

6271
def _naive_utc_now() -> datetime:
6372
return datetime.now(timezone.utc).replace(tzinfo=None)
@@ -232,18 +241,63 @@ def _active_topic_names() -> set[str]:
232241
return {topic.name for topic in db.get_topics()}
233242

234243

244+
def _serve_oneoff(
245+
context,
246+
source: SearchSource,
247+
query: str,
248+
vertical: SearchVertical,
249+
*,
250+
pacer: _Pacer,
251+
stop_event: threading.Event,
252+
cooldown: EngineCooldownTracker | None,
253+
shared_state: SharedEngineState,
254+
max_pages: int | None,
255+
) -> tuple[list[NewsEntry], list[ScraperLog]]:
256+
"""Pace, then run one ad-hoc query on the engine's live context for a
257+
non-tracked vertical: an on-demand WEB search, or a NEWS keep-alive warm-up.
258+
259+
Shares the engine's pace floor and feeds any block signal into the same
260+
cooldown tracker as a normal scrape (these requests ride one budget), but
261+
deliberately does NOT touch the metrics window — they aren't tracked-topic
262+
content. Returns the parsed entries and the per-page logs.
263+
"""
264+
pacer.wait(stop_event)
265+
if stop_event.is_set():
266+
return [], []
267+
page = context.new_page()
268+
try:
269+
entries, logs = scrape_pages(
270+
page, source, query, vertical=vertical, max_result_pages=max_pages
271+
)
272+
finally:
273+
page.close()
274+
if cooldown is not None:
275+
cooldown.record(source.name, logs)
276+
for snap in cooldown.snapshot():
277+
shared_state.update(snap)
278+
return entries, logs
279+
280+
235281
def run_engine_worker(
236282
source: SearchSource,
237283
profile: FingerprintProfile,
238284
proxy: dict | None,
239285
shared_state: SharedEngineState,
240286
stop_event: threading.Event,
287+
web_queue: WebSearchQueue | None = None,
241288
) -> None:
242289
"""Run one engine's scheduler loop until ``stop_event`` is set.
243290
244291
Owns its own Playwright instance and browser context (so the sync API stays
245292
single-threaded per worker) and its own cooldown tracker and topic schedule
246293
(single writer, so no locking needed around them).
294+
295+
The one context services three kinds of work on one shared pace+cooldown
296+
budget, in priority order: an on-demand WEB search from ``web_queue``
297+
(user-facing, preempts news) → a due news topic → an idle NEWS keep-alive
298+
warm-up. Web search and keep-alive are off unless wired: pass a ``web_queue``
299+
to enable web search, and set ``scraper.keepalive.enabled`` to fire the
300+
heartbeat.
247301
"""
248302
interval = scraper_config.scrape_interval
249303
jitter = scraper_config.pacing_jitter_ratio
@@ -258,9 +312,20 @@ def run_engine_worker(
258312
if scraper_config.cooldown_enabled
259313
else None
260314
)
315+
heartbeat = (
316+
KeepAliveHeartbeat(
317+
interval=scraper_config.keepalive_interval_seconds,
318+
jitter_ratio=scraper_config.keepalive_jitter_ratio,
319+
queries=scraper_config.keepalive_queries or DEFAULT_QUERIES,
320+
)
321+
if scraper_config.keepalive_enabled
322+
else None
323+
)
261324
logger.info(
262325
f"[{source.name}] worker starting (topic interval {interval}s, pace floor "
263-
f"{min_interval:.1f}s/request, cooldown {'on' if cooldown else 'off'})"
326+
f"{min_interval:.1f}s/request, cooldown {'on' if cooldown else 'off'}, "
327+
f"keep-alive {'on' if heartbeat else 'off'}, "
328+
f"web search {'on' if web_queue is not None else 'off'})"
264329
)
265330

266331
try:
@@ -294,49 +359,122 @@ def run_engine_worker(
294359
next_tick = time.monotonic() + interval
295360

296361
# Engine-level cooldown gate: while benched, schedule nothing —
297-
# sleep until the backoff window allows a probe (or the next tick).
362+
# sleep until the backoff window allows a probe (or the next
363+
# tick). This also defers web/keep-alive: a benched session would
364+
# only CAPTCHA, and (once wired) the dispatcher routes the web
365+
# request to a healthy engine instead.
298366
if cooldown is not None and cooldown.decide(source.name) == "skip":
299367
wait = min(
300368
cooldown.remaining(source.name), next_tick - time.monotonic()
301369
)
302370
stop_event.wait(max(wait, 0.0))
303371
continue
304372

305-
topic, sleep_for = schedule.next_due()
306-
if topic is None:
307-
# Nothing due: wait for the head to come due, but never past
308-
# the next housekeeping tick so flush/reconcile stay on time.
309-
bound = next_tick - time.monotonic()
310-
wait = bound if sleep_for is None else min(sleep_for, bound)
311-
stop_event.wait(max(wait, 0.0))
373+
# Pick exactly one action this turn, in priority order, then
374+
# converge on the shared post-scrape (recycle) handling. The
375+
# idle branch is the only one that sleeps and skips it.
376+
job = web_queue.poll() if web_queue is not None else None
377+
topic, sleep_for = (None, None)
378+
if job is None:
379+
topic, sleep_for = schedule.next_due()
380+
381+
if job is not None:
382+
# Priority 1 — on-demand web search preempts news. Serve one
383+
# queued job per turn (FCFS) and hand its results back.
384+
entries, err = [], None
385+
try:
386+
entries, _logs = _serve_oneoff(
387+
context,
388+
source,
389+
job.query,
390+
SearchVertical.WEB,
391+
pacer=pacer,
392+
stop_event=stop_event,
393+
cooldown=cooldown,
394+
shared_state=shared_state,
395+
max_pages=scraper_config.max_pages,
396+
)
397+
except Exception as e:
398+
err = e
399+
logger.exception(
400+
"[%s] web search '%s' failed", source.name, job.query
401+
)
402+
try: # never leave the caller's future hung
403+
if err is not None:
404+
job.future.set_exception(err)
405+
else:
406+
job.future.set_result(entries)
407+
except InvalidStateError:
408+
pass # caller already cancelled/timed out
409+
if heartbeat is not None:
410+
heartbeat.record_activity()
411+
scrapes_since_recycle += 1
412+
413+
elif topic is not None:
414+
# Priority 2 — a due news topic.
415+
pacer.wait(stop_event)
416+
if stop_event.is_set():
417+
break
418+
try:
419+
entries, logs = scrape_topic(
420+
context.new_page,
421+
[source],
422+
topic,
423+
strategy="all",
424+
max_result_pages=scraper_config.max_pages,
425+
cooldown=cooldown,
426+
)
427+
window.add(entries, logs)
428+
except Exception as e:
429+
window.error = f"{type(e).__name__}: {e}"
430+
window.success = False
431+
logger.exception(
432+
"[%s] scrape of '%s' failed", source.name, topic
433+
)
434+
finally:
435+
schedule.reschedule(topic)
436+
if cooldown is not None:
437+
for snap in cooldown.snapshot():
438+
shared_state.update(snap)
439+
if heartbeat is not None:
440+
heartbeat.record_activity()
441+
scrapes_since_recycle += 1
442+
443+
elif heartbeat is not None and heartbeat.due():
444+
# Priority 3 — nothing due; fire an idle keep-alive warm-up.
445+
query = heartbeat.next_query()
446+
logger.info(f"[{source.name}] keep-alive warm-up: '{query}'")
447+
_serve_oneoff(
448+
context,
449+
source,
450+
query,
451+
SearchVertical.NEWS,
452+
pacer=pacer,
453+
stop_event=stop_event,
454+
cooldown=cooldown,
455+
shared_state=shared_state,
456+
max_pages=1, # one page is enough to warm the session
457+
)
458+
heartbeat.record_activity()
459+
scrapes_since_recycle += 1
460+
461+
else:
462+
# Idle: wait for the head topic to come due, but never past
463+
# the next housekeeping tick, the next keep-alive, or (with
464+
# web search enabled) a short web-poll slice.
465+
waits = [next_tick - time.monotonic()]
466+
if sleep_for is not None:
467+
waits.append(sleep_for)
468+
if heartbeat is not None:
469+
waits.append(heartbeat.seconds_until_due())
470+
if web_queue is not None:
471+
waits.append(_WEB_POLL_SLICE)
472+
stop_event.wait(max(min(waits), 0.0))
312473
continue
313474

314-
pacer.wait(stop_event)
315475
if stop_event.is_set():
316476
break
317477

318-
try:
319-
entries, logs = scrape_topic(
320-
context.new_page,
321-
[source],
322-
topic,
323-
strategy="all",
324-
max_result_pages=scraper_config.max_pages,
325-
cooldown=cooldown,
326-
)
327-
window.add(entries, logs)
328-
except Exception as e:
329-
window.error = f"{type(e).__name__}: {e}"
330-
window.success = False
331-
logger.exception("[%s] scrape of '%s' failed", source.name, topic)
332-
finally:
333-
schedule.reschedule(topic)
334-
335-
if cooldown is not None:
336-
for snap in cooldown.snapshot():
337-
shared_state.update(snap)
338-
339-
scrapes_since_recycle += 1
340478
if scrapes_since_recycle >= scraper_config.browser_recycle_cycles:
341479
logger.info(
342480
f"[{source.name}] recycling context after "

tests/test_webqueue.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Tests for the in-process web-search queue (scraper/webqueue.py).
2+
3+
The queue is the producer→worker handoff for on-demand web search: a producer
4+
``submit``s a query and gets a Future; the worker ``poll``s FCFS and resolves it.
5+
"""
6+
7+
from scraper.webqueue import WebSearchQueue
8+
9+
10+
def test_poll_empty_returns_none():
11+
q = WebSearchQueue()
12+
assert q.poll() is None
13+
assert q.pending() == 0
14+
15+
16+
def test_submit_returns_pending_future():
17+
q = WebSearchQueue()
18+
fut = q.submit("us iran")
19+
assert not fut.done()
20+
assert q.pending() == 1
21+
22+
23+
def test_poll_is_fcfs():
24+
q = WebSearchQueue()
25+
q.submit("first")
26+
q.submit("second")
27+
assert q.poll().query == "first"
28+
assert q.poll().query == "second"
29+
assert q.poll() is None
30+
31+
32+
def test_worker_resolves_future_through_the_job():
33+
# Simulates the worker side: poll a job, then set its result; the producer
34+
# reads it back off the future it was handed at submit time.
35+
q = WebSearchQueue()
36+
fut = q.submit("weather")
37+
38+
job = q.poll()
39+
assert job.query == "weather"
40+
job.future.set_result(["a", "b"])
41+
42+
assert fut.done()
43+
assert fut.result() == ["a", "b"]
44+
45+
46+
def test_worker_can_propagate_failure():
47+
q = WebSearchQueue()
48+
fut = q.submit("boom")
49+
job = q.poll()
50+
job.future.set_exception(RuntimeError("blocked"))
51+
52+
import pytest
53+
54+
with pytest.raises(RuntimeError, match="blocked"):
55+
fut.result()
56+
57+
58+
def test_pending_tracks_drain():
59+
q = WebSearchQueue()
60+
q.submit("a")
61+
q.submit("b")
62+
assert q.pending() == 2
63+
q.poll()
64+
assert q.pending() == 1

0 commit comments

Comments
 (0)