mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-09-14 11:06:13 +05:00
505 lines
21 KiB
Python
505 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import json
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from collections import defaultdict
|
|
from collections.abc import Iterable
|
|
from contextlib import contextmanager
|
|
from functools import wraps
|
|
from pathlib import Path
|
|
from typing import Any, Protocol, runtime_checkable
|
|
|
|
from asgiref.sync import sync_to_async
|
|
from django.db import IntegrityError
|
|
from django.utils import timezone
|
|
|
|
from abx_dl.events import PROCESS_EXIT_SKIPPED, ArchiveResultEvent, ProcessCompletedEvent, ProcessStartedEvent, SnapshotEvent
|
|
from abx_dl.output_files import guess_mimetype
|
|
from abx_dl.services.base import BaseService
|
|
|
|
from .process_service import parse_event_datetime
|
|
|
|
|
|
def _perf_trace(label):
|
|
def decorator(func):
|
|
if inspect.iscoroutinefunction(func):
|
|
|
|
@wraps(func)
|
|
async def async_wrapper(*args, **kwargs):
|
|
if os.environ.get("ARCHIVEBOX_PERF_TRACE") != "1":
|
|
return await func(*args, **kwargs)
|
|
started_at = time.perf_counter()
|
|
try:
|
|
return await func(*args, **kwargs)
|
|
finally:
|
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
|
print(f"PERF_TRACE label={label} ms={elapsed_ms:.3f}", file=sys.stderr, flush=True)
|
|
|
|
return async_wrapper
|
|
|
|
@wraps(func)
|
|
def sync_wrapper(*args, **kwargs):
|
|
if os.environ.get("ARCHIVEBOX_PERF_TRACE") != "1":
|
|
return func(*args, **kwargs)
|
|
started_at = time.perf_counter()
|
|
try:
|
|
return func(*args, **kwargs)
|
|
finally:
|
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
|
print(f"PERF_TRACE label={label} ms={elapsed_ms:.3f}", file=sys.stderr, flush=True)
|
|
|
|
return sync_wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
@contextmanager
|
|
def _perf_span(label: str):
|
|
if os.environ.get("ARCHIVEBOX_PERF_TRACE") != "1":
|
|
yield
|
|
return
|
|
started_at = time.perf_counter()
|
|
try:
|
|
yield
|
|
finally:
|
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
|
print(f"PERF_TRACE label={label} ms={elapsed_ms:.3f}", file=sys.stderr, flush=True)
|
|
|
|
|
|
@runtime_checkable
|
|
class ModelDumpable(Protocol):
|
|
def model_dump(self) -> dict[str, Any]: ...
|
|
|
|
|
|
def _collect_output_metadata(plugin_dir: Path) -> tuple[dict[str, dict], int, str]:
|
|
exclude_names = {"stdout.log", "stderr.log", "process.pid", "hook.pid", "listener.pid"}
|
|
output_files: dict[str, dict] = {}
|
|
mime_sizes: dict[str, int] = defaultdict(int)
|
|
total_size = 0
|
|
|
|
if not plugin_dir.exists():
|
|
return output_files, total_size, ""
|
|
|
|
for file_path in plugin_dir.rglob("*"):
|
|
if not file_path.is_file():
|
|
continue
|
|
if ".hooks" in file_path.parts:
|
|
continue
|
|
if file_path.name in exclude_names:
|
|
continue
|
|
try:
|
|
stat = file_path.stat()
|
|
except OSError:
|
|
continue
|
|
mime_type = guess_mimetype(file_path) or "application/octet-stream"
|
|
relative_path = str(file_path.relative_to(plugin_dir))
|
|
output_files[relative_path] = {
|
|
"extension": file_path.suffix.lower().lstrip("."),
|
|
"mimetype": mime_type,
|
|
"size": stat.st_size,
|
|
}
|
|
mime_sizes[mime_type] += stat.st_size
|
|
total_size += stat.st_size
|
|
|
|
output_mimetypes = ",".join(mime for mime, _size in sorted(mime_sizes.items(), key=lambda item: item[1], reverse=True))
|
|
return output_files, total_size, output_mimetypes
|
|
|
|
|
|
def _coerce_output_file_size(value: Any) -> int:
|
|
try:
|
|
return max(int(value or 0), 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _normalize_output_files(raw_output_files: Any) -> dict[str, dict]:
|
|
def _enrich_metadata(path: str, metadata: dict[str, Any]) -> dict[str, Any]:
|
|
normalized = dict(metadata)
|
|
if "extension" not in normalized:
|
|
normalized["extension"] = Path(path).suffix.lower().lstrip(".")
|
|
if "mimetype" not in normalized:
|
|
guessed = guess_mimetype(path)
|
|
if guessed:
|
|
normalized["mimetype"] = guessed
|
|
return normalized
|
|
|
|
if raw_output_files is None:
|
|
return {}
|
|
|
|
if isinstance(raw_output_files, str):
|
|
try:
|
|
raw_output_files = json.loads(raw_output_files)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
if isinstance(raw_output_files, dict):
|
|
normalized: dict[str, dict] = {}
|
|
for path, metadata in raw_output_files.items():
|
|
if not path:
|
|
continue
|
|
metadata_dict = dict(metadata) if isinstance(metadata, dict) else {}
|
|
metadata_dict.pop("path", None)
|
|
normalized[str(path)] = _enrich_metadata(str(path), metadata_dict)
|
|
return normalized
|
|
|
|
if not isinstance(raw_output_files, Iterable):
|
|
return {}
|
|
|
|
normalized: dict[str, dict] = {}
|
|
for item in raw_output_files:
|
|
if isinstance(item, str):
|
|
normalized[item] = _enrich_metadata(item, {})
|
|
continue
|
|
if isinstance(item, ModelDumpable):
|
|
item = item.model_dump()
|
|
if not isinstance(item, dict):
|
|
continue
|
|
path = str(item.get("path") or "").strip()
|
|
if not path:
|
|
continue
|
|
normalized[path] = _enrich_metadata(path, {key: value for key, value in item.items() if key != "path" and value not in (None, "")})
|
|
|
|
return normalized
|
|
|
|
|
|
def _has_structured_output_metadata(output_files: dict[str, dict]) -> bool:
|
|
return any(any(key in metadata for key in ("extension", "mimetype", "size")) for metadata in output_files.values())
|
|
|
|
|
|
def _summarize_output_files(output_files: dict[str, dict]) -> tuple[int, str]:
|
|
mime_sizes: dict[str, int] = defaultdict(int)
|
|
total_size = 0
|
|
|
|
for metadata in output_files.values():
|
|
if not isinstance(metadata, dict):
|
|
continue
|
|
size = _coerce_output_file_size(metadata.get("size"))
|
|
mimetype = str(metadata.get("mimetype") or "").strip()
|
|
total_size += size
|
|
if mimetype and size:
|
|
mime_sizes[mimetype] += size
|
|
|
|
output_mimetypes = ",".join(mime for mime, _size in sorted(mime_sizes.items(), key=lambda item: item[1], reverse=True))
|
|
return total_size, output_mimetypes
|
|
|
|
|
|
def _resolve_output_metadata(raw_output_files: Any, plugin_dir: Path) -> tuple[dict[str, dict], int, str]:
|
|
normalized_output_files = _normalize_output_files(raw_output_files)
|
|
if normalized_output_files and _has_structured_output_metadata(normalized_output_files):
|
|
output_size, output_mimetypes = _summarize_output_files(normalized_output_files)
|
|
return normalized_output_files, output_size, output_mimetypes
|
|
return _collect_output_metadata(plugin_dir)
|
|
|
|
|
|
def _normalize_status(status: str) -> str:
|
|
if status == "noresult":
|
|
return "noresults"
|
|
return status or "failed"
|
|
|
|
|
|
def _normalize_snapshot_title(candidate: str, *, snapshot_url: str) -> str:
|
|
title = " ".join(line.strip() for line in str(candidate or "").splitlines() if line.strip()).strip()
|
|
if not title:
|
|
return ""
|
|
if title.lower() in {"pending...", "no title found"}:
|
|
return ""
|
|
if title == snapshot_url:
|
|
return ""
|
|
if "/" in title and title.lower().endswith(".txt"):
|
|
return ""
|
|
return title
|
|
|
|
|
|
def _extract_snapshot_title(snapshot_output_dir: str, plugin: str, output_str: str, *, snapshot_url: str) -> str:
|
|
if plugin != "title":
|
|
return ""
|
|
|
|
title_file = Path(snapshot_output_dir) / "title" / "title.txt"
|
|
if title_file.exists():
|
|
try:
|
|
file_title = _normalize_snapshot_title(title_file.read_text(encoding="utf-8"), snapshot_url=snapshot_url)
|
|
except OSError:
|
|
file_title = ""
|
|
if file_title:
|
|
return file_title
|
|
|
|
return _normalize_snapshot_title(output_str, snapshot_url=snapshot_url)
|
|
|
|
|
|
def _should_update_snapshot_title(current_title: str, next_title: str, *, snapshot_url: str) -> bool:
|
|
current = (current_title or "").strip()
|
|
if not current or current.lower() == "pending..." or current == snapshot_url:
|
|
return True
|
|
return len(next_title) > len(current)
|
|
|
|
|
|
def _status_for_process_without_archive_result(event: ProcessCompletedEvent) -> str:
|
|
if event.exit_code == PROCESS_EXIT_SKIPPED:
|
|
return "skipped"
|
|
if event.exit_code in {128 + signal.SIGHUP, 128 + signal.SIGINT, 128 + signal.SIGTERM}:
|
|
# This fallback only runs when a snapshot hook exited before emitting a
|
|
# structured ArchiveResult. A polite shutdown signal means the runner
|
|
# was interrupted during ownership transfer, not that the extractor
|
|
# produced a durable negative result. Keep the hook queued so the next
|
|
# runner can retry the exact work item instead of sealing in a transient
|
|
# process-lifecycle failure.
|
|
return "queued"
|
|
if event.exit_code != 0:
|
|
return "failed"
|
|
return "noresults"
|
|
|
|
|
|
def _iter_archiveresult_records(stdout: str) -> list[dict]:
|
|
records: list[dict] = []
|
|
for raw_line in stdout.splitlines():
|
|
line = raw_line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
record = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if record.get("type") == "ArchiveResult":
|
|
records.append(record)
|
|
return records
|
|
|
|
|
|
@_perf_trace("archivebox.ArchiveResultService._save_archiveresult_event_sync")
|
|
def _save_archiveresult_event_to_db(
|
|
event: ArchiveResultEvent,
|
|
process_started: ProcessStartedEvent | None,
|
|
) -> None:
|
|
"""Project one ArchiveResultEvent with a single thread-sensitive ORM hop.
|
|
|
|
Django's async ORM still delegates each query to sync Django work. The hot
|
|
search/index maintenance path was paying that handoff separately for
|
|
Snapshot lookup, Process lookup, ArchiveResult lookup, update, and title
|
|
checks. Keep the public ArchiveResultEvent path intact, but run the DB
|
|
projection as one short synchronous block so SQLite sees the same indexed
|
|
reads/writes without per-query asyncio/threadpool churn.
|
|
"""
|
|
from archivebox.core.models import ArchiveResult, Snapshot
|
|
from archivebox.machine.models import Process
|
|
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.snapshot_lookup"):
|
|
snapshot = Snapshot.objects.filter(id=event.snapshot_id).select_related("crawl", "crawl__created_by").first()
|
|
if snapshot is None:
|
|
return
|
|
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.plugin_dir"):
|
|
plugin_dir = (
|
|
Path(process_started.output_dir)
|
|
if process_started is not None and process_started.output_dir
|
|
else Path(snapshot.output_dir) / event.plugin
|
|
)
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.resolve_output_metadata"):
|
|
output_files, output_size, output_mimetypes = _resolve_output_metadata(event.output_files, plugin_dir)
|
|
|
|
process = None
|
|
if process_started is not None:
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.process_lookup"):
|
|
started_at = parse_event_datetime(process_started.start_ts)
|
|
if started_at is None:
|
|
raise ValueError("ProcessStartedEvent.start_ts is required")
|
|
process_query = Process.objects.filter(
|
|
pwd=process_started.output_dir,
|
|
cmd=[process_started.hook_path, *process_started.hook_args],
|
|
started_at=started_at,
|
|
)
|
|
if process_started.pid:
|
|
process_query = process_query.filter(pid=process_started.pid)
|
|
process = process_query.order_by("-modified_at").first()
|
|
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.prepare_defaults"):
|
|
start_ts = parse_event_datetime(event.start_ts)
|
|
end_ts = parse_event_datetime(event.end_ts) or timezone.now()
|
|
defaults = {
|
|
"status": _normalize_status(event.status),
|
|
"output_str": event.output_str,
|
|
"output_json": event.output_json,
|
|
"output_files": output_files,
|
|
"output_size": output_size,
|
|
"output_mimetypes": output_mimetypes,
|
|
"start_ts": start_ts or timezone.now(),
|
|
"end_ts": end_ts,
|
|
}
|
|
if process is not None:
|
|
defaults["process_id"] = process.id
|
|
if event.error:
|
|
defaults["notes"] = event.error
|
|
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.result_lookup"):
|
|
result = ArchiveResult.objects.filter(
|
|
snapshot=snapshot,
|
|
plugin=event.plugin,
|
|
hook_name=event.hook_name,
|
|
).first()
|
|
if result is None:
|
|
try:
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.result_create"):
|
|
result = ArchiveResult.objects.create(
|
|
snapshot=snapshot,
|
|
plugin=event.plugin,
|
|
hook_name=event.hook_name,
|
|
**defaults,
|
|
)
|
|
except IntegrityError:
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.result_get_after_integrity"):
|
|
result = ArchiveResult.objects.get(
|
|
snapshot=snapshot,
|
|
plugin=event.plugin,
|
|
hook_name=event.hook_name,
|
|
)
|
|
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.diff_fields"):
|
|
update_fields = []
|
|
for field, value in defaults.items():
|
|
if result.__dict__[field] != value:
|
|
setattr(result, field, value)
|
|
update_fields.append(field)
|
|
if update_fields:
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.result_update"):
|
|
result.save(update_fields=[*update_fields, "modified_at"])
|
|
|
|
if result.status == ArchiveResult.StatusChoices.QUEUED:
|
|
# ArchiveResult has no retry_at column. If a shutdown/takeover projects
|
|
# a killed hook back to QUEUED, wake the parent Snapshot/Crawl so the
|
|
# next runner retries that exact hook instead of waiting on a stale
|
|
# active-state lease.
|
|
snapshot.update_and_requeue(retry_at=timezone.now())
|
|
|
|
if result.status in (ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS):
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.title_update"):
|
|
title_output_str = result.output_str if result.status == ArchiveResult.StatusChoices.SUCCEEDED else ""
|
|
next_title = _extract_snapshot_title(str(plugin_dir.parent), event.plugin, title_output_str, snapshot_url=snapshot.url)
|
|
if next_title and _should_update_snapshot_title(snapshot.title or "", next_title, snapshot_url=snapshot.url):
|
|
snapshot.title = next_title
|
|
snapshot.save(update_fields=["title", "modified_at"])
|
|
|
|
# Parser output becomes durable when its ArchiveResult reaches a final
|
|
# state. Project at that same lifecycle boundary so every completed parser
|
|
# can enrich an already-discovered URL before Snapshot/Crawl completion.
|
|
# create_discovered_snapshots() owns cross-parser dedupe and metadata merge.
|
|
if (
|
|
result.status in (ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS)
|
|
and (plugin_dir / "urls.jsonl").exists()
|
|
):
|
|
from .snapshot_service import project_discovered_snapshots
|
|
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.project_discovered_snapshots"):
|
|
project_discovered_snapshots(str(snapshot.id))
|
|
|
|
|
|
def mark_archiveresult_started(event: ProcessStartedEvent, *, snapshot_id: str, process_id: str) -> None:
|
|
"""Advance an existing queued hook row after its OS process is persisted."""
|
|
from archivebox.core.models import ArchiveResult
|
|
|
|
started_at = parse_event_datetime(event.start_ts)
|
|
if started_at is None:
|
|
raise ValueError("ProcessStartedEvent.start_ts is required")
|
|
ArchiveResult.objects.filter(
|
|
snapshot_id=snapshot_id,
|
|
plugin=event.plugin_name,
|
|
hook_name=event.hook_name,
|
|
status=ArchiveResult.StatusChoices.QUEUED,
|
|
).update(
|
|
status=ArchiveResult.StatusChoices.STARTED,
|
|
start_ts=started_at,
|
|
end_ts=None,
|
|
process_id=process_id,
|
|
modified_at=timezone.now(),
|
|
)
|
|
|
|
|
|
class ArchiveResultService(BaseService):
|
|
LISTENS_TO = [ArchiveResultEvent, ProcessCompletedEvent]
|
|
EMITS = []
|
|
|
|
def __init__(self, bus):
|
|
self._completed_process_event_ids: set[str] = set()
|
|
self._save_locks: dict[tuple[str, str, str], asyncio.Lock] = {}
|
|
super().__init__(bus)
|
|
self.bus.on(ArchiveResultEvent, self.on_ArchiveResultEvent__save_to_db)
|
|
self.bus.on(ProcessCompletedEvent, self.on_ProcessCompletedEvent__save_to_db)
|
|
|
|
@_perf_trace("archivebox.ArchiveResultService.on_ArchiveResultEvent__save_to_db")
|
|
async def on_ArchiveResultEvent__save_to_db(self, event: ArchiveResultEvent) -> None:
|
|
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.find_process_started"):
|
|
process_started = await self.bus.find(
|
|
ProcessStartedEvent,
|
|
past=True,
|
|
future=False,
|
|
where=lambda candidate: self.bus.event_is_child_of(event, candidate),
|
|
)
|
|
|
|
key = (str(event.snapshot_id), event.plugin, event.hook_name)
|
|
lock = self._save_locks.setdefault(key, asyncio.Lock())
|
|
async with lock:
|
|
await sync_to_async(_save_archiveresult_event_to_db, thread_sensitive=True)(event, process_started)
|
|
|
|
@_perf_trace("archivebox.ArchiveResultService.on_ProcessCompletedEvent__save_to_db")
|
|
async def on_ProcessCompletedEvent__save_to_db(self, event: ProcessCompletedEvent) -> None:
|
|
if event.event_id in self._completed_process_event_ids:
|
|
return
|
|
self._completed_process_event_ids.add(event.event_id)
|
|
|
|
if not event.hook_name.startswith("on_Snapshot"):
|
|
return
|
|
with _perf_span("archivebox.ArchiveResultService.on_ProcessCompletedEvent.find_snapshot_event"):
|
|
snapshot_event = await self.bus.find(
|
|
SnapshotEvent,
|
|
past=True,
|
|
future=False,
|
|
where=lambda candidate: self.bus.event_is_child_of(event, candidate),
|
|
)
|
|
if snapshot_event is None:
|
|
return
|
|
|
|
with _perf_span("archivebox.ArchiveResultService.on_ProcessCompletedEvent.parse_stdout_records"):
|
|
records = _iter_archiveresult_records(event.stdout)
|
|
if records:
|
|
if len(records) > 1:
|
|
raise RuntimeError(
|
|
f"Hook {event.plugin_name}:{event.hook_name} emitted {len(records)} ArchiveResult records; expected exactly one",
|
|
)
|
|
for record in records:
|
|
record_status = _normalize_status(record.get("status") or "")
|
|
record_failed = record_status == "failed" or (not record_status and event.exit_code not in (0, PROCESS_EXIT_SKIPPED))
|
|
with _perf_span("archivebox.ArchiveResultService.on_ProcessCompletedEvent.emit_archive_result_record"):
|
|
await event.emit(
|
|
ArchiveResultEvent(
|
|
snapshot_id=record.get("snapshot_id") or snapshot_event.snapshot_id,
|
|
plugin=record.get("plugin") or event.plugin_name,
|
|
hook_name=record.get("hook_name") or event.hook_name,
|
|
status=record_status,
|
|
output_str=record.get("output_str") or "",
|
|
output_json=record.get("output_json") if isinstance(record.get("output_json"), dict) else None,
|
|
output_files=event.output_files,
|
|
start_ts=event.start_ts,
|
|
end_ts=event.end_ts,
|
|
error=record.get("error") or (event.stderr if record_failed else ""),
|
|
),
|
|
).now()
|
|
return
|
|
|
|
process_failed = _status_for_process_without_archive_result(event) == "failed"
|
|
with _perf_span("archivebox.ArchiveResultService.on_ProcessCompletedEvent.emit_archive_result_fallback"):
|
|
await event.emit(
|
|
ArchiveResultEvent(
|
|
snapshot_id=snapshot_event.snapshot_id,
|
|
plugin=event.plugin_name,
|
|
hook_name=event.hook_name,
|
|
status=_status_for_process_without_archive_result(event),
|
|
output_str=event.stderr if process_failed else "",
|
|
output_files=event.output_files,
|
|
start_ts=event.start_ts,
|
|
end_ts=event.end_ts,
|
|
error=event.stderr if process_failed else "",
|
|
),
|
|
).now()
|