Coverage for src/lilbee/crawler/task.py: 100%
115 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
1"""Background crawl task management: start, track, and query crawl operations."""
3import asyncio
4import logging
5import threading
6import uuid
7from dataclasses import dataclass, field
8from datetime import UTC, datetime
9from enum import StrEnum
11from lilbee.core.config.enums import CrawlRenderMode
12from lilbee.crawler import crawl_and_save
13from lilbee.runtime.progress import (
14 CrawlPageEvent,
15 CrawlPageFailedEvent,
16 DetailedProgressCallback,
17 EventType,
18 ProgressEvent,
19)
21log = logging.getLogger(__name__)
23# Maximum completed tasks to retain in memory before evicting oldest.
24_MAX_COMPLETED_TASKS = 100
26# Maximum per-page failure reasons stored on a task; pages_failed keeps the
27# true count so a large all-failing crawl doesn't grow task memory unboundedly.
28_MAX_FAILURE_REASONS = 20
31class TaskStatus(StrEnum):
32 """Lifecycle states for a crawl task."""
34 PENDING = "pending"
35 RUNNING = "running"
36 DONE = "done"
37 FAILED = "failed"
38 CANCELLED = "cancelled"
41@dataclass
42class CrawlTask:
43 """Tracks a single crawl operation.
45 depth / max_pages follow the crawl_and_save three-state convention: None =
46 unbounded, 0 (depth only) = single URL, positive int = explicit cap.
47 """
49 task_id: str
50 url: str
51 depth: int | None
52 max_pages: int | None
53 render_mode: CrawlRenderMode | None = None
54 include_subdomains: bool = False
55 status: TaskStatus = TaskStatus.PENDING
56 pages_crawled: int = 0
57 pages_total: int | None = None
58 pages_failed: int = 0
59 failure_reasons: list[str] = field(default_factory=list)
60 error: str | None = None
61 started_at: str = ""
62 finished_at: str = ""
63 # Polled by crawl_and_save between pages and by the follow-on sync between
64 # files, so a stop lands on a boundary with the pages already fetched saved.
65 # Cancelling _async_task instead would abort mid-page and lose them.
66 cancel: threading.Event = field(default_factory=threading.Event, repr=False)
67 _async_task: asyncio.Task[None] | None = field(default=None, repr=False, init=False)
70class TaskRegistry:
71 """In-memory registry of active and completed crawl tasks.
72 A single module-level instance (_registry) is used because task tracking
73 is inherently per-process state (asyncio.Task references, etc.).
74 """
76 def __init__(self) -> None:
77 self.tasks: dict[str, CrawlTask] = {}
79 def clear(self) -> None:
80 """Remove all tasks from the registry."""
81 self.tasks.clear()
84_registry = TaskRegistry()
87def now_iso() -> str:
88 """Current UTC time as ISO 8601 string."""
89 return datetime.now(UTC).isoformat()
92def make_progress_updater(task: CrawlTask) -> DetailedProgressCallback:
93 """Return a progress callback that updates task fields from crawl events."""
95 def _on_progress(event_type: EventType, data: ProgressEvent) -> None:
96 if event_type == EventType.CRAWL_PAGE:
97 if not isinstance(data, CrawlPageEvent):
98 raise TypeError(f"Expected CrawlPageEvent, got {type(data).__name__}")
99 task.pages_crawled = data.current
100 task.pages_total = data.total
101 elif event_type == EventType.CRAWL_PAGE_FAILED:
102 if not isinstance(data, CrawlPageFailedEvent):
103 raise TypeError(f"Expected CrawlPageFailedEvent, got {type(data).__name__}")
104 task.pages_failed += 1
105 if len(task.failure_reasons) < _MAX_FAILURE_REASONS:
106 task.failure_reasons.append(f"{data.url}: {data.reason}")
108 return _on_progress
111async def run_crawl(task: CrawlTask) -> None:
112 """Execute crawl, save results, and trigger sync."""
113 task.status = TaskStatus.RUNNING
114 task.started_at = now_iso()
115 progress = make_progress_updater(task)
117 try:
118 paths = await crawl_and_save(
119 task.url,
120 depth=task.depth,
121 max_pages=task.max_pages,
122 on_progress=progress,
123 cancel=task.cancel,
124 include_subdomains=task.include_subdomains,
125 render_mode=task.render_mode,
126 )
127 if task.cancel.is_set():
128 task.status = TaskStatus.CANCELLED
129 task.pages_crawled = task.pages_crawled or len(paths)
130 task.finished_at = now_iso()
131 log.info("Crawl cancelled: %s after %d files", task.url, len(paths))
132 return
133 task.status = TaskStatus.DONE
134 task.pages_crawled = task.pages_crawled or len(paths)
135 task.finished_at = now_iso()
136 log.info("Crawl complete: %s → %d files", task.url, len(paths))
137 try:
138 from lilbee.data.ingest import sync
140 await sync(quiet=True, cancel=task.cancel)
141 except Exception:
142 log.warning("Post-crawl sync failed for %s", task.url, exc_info=True)
143 except Exception as exc:
144 task.status = TaskStatus.FAILED
145 task.error = str(exc)
146 task.finished_at = now_iso()
147 log.warning("Crawl failed: %s: %s", task.url, exc)
148 finally:
149 task._async_task = None
152def _evict_completed() -> None:
153 """Remove completed tasks with the earliest ``finished_at`` when over cap.
155 Finish order diverges from start order whenever short tasks complete
156 while a long one is still running, so sort on ``finished_at``
157 rather than dict insertion order.
158 """
159 done_statuses = (TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED)
160 tasks = _registry.tasks
161 completed = [(tid, t) for tid, t in tasks.items() if t.status in done_statuses]
162 excess = len(completed) - _MAX_COMPLETED_TASKS
163 if excess <= 0:
164 return
165 completed.sort(key=lambda pair: pair[1].finished_at)
166 for tid, _ in completed[:excess]:
167 del tasks[tid]
170def start_crawl(
171 url: str,
172 depth: int | None = None,
173 max_pages: int | None = None,
174 render_mode: CrawlRenderMode | None = None,
175 *,
176 include_subdomains: bool = False,
177) -> str:
178 """Create a crawl task and launch it as an asyncio background task.
180 Defaults to whole-site unbounded recursion. Pass depth=0 for single URL.
181 ``render_mode`` of ``None`` defers to ``cfg.crawl_render_mode``.
182 ``include_subdomains`` widens whole-site scope to the host's subdomains.
183 Returns the task_id for status polling.
184 """
185 _evict_completed()
186 task_id = uuid.uuid4().hex[:12]
187 task = CrawlTask(
188 task_id=task_id,
189 url=url,
190 depth=depth,
191 max_pages=max_pages,
192 render_mode=render_mode,
193 include_subdomains=include_subdomains,
194 )
195 _registry.tasks[task_id] = task
196 task._async_task = asyncio.create_task(run_crawl(task))
197 return task_id
200def get_task(task_id: str) -> CrawlTask | None:
201 """Look up a crawl task by ID."""
202 return _registry.tasks.get(task_id)
205def cancel_crawl(task_id: str) -> bool:
206 """Ask a running crawl to stop, returning whether it was still running.
208 Cooperative: the crawl stops at the next page boundary and keeps what it
209 already saved, so the pages fetched so far still reach the corpus. A task
210 that already finished is left alone.
211 """
212 task = _registry.tasks.get(task_id)
213 if task is None or task.status not in (TaskStatus.PENDING, TaskStatus.RUNNING):
214 return False
215 task.cancel.set()
216 return True
219def list_tasks() -> list[CrawlTask]:
220 """Return all tracked crawl tasks (active and completed)."""
221 return list(_registry.tasks.values())
224def clear_tasks() -> None:
225 """Remove all tasks from the registry (for testing)."""
226 _registry.clear()