""" Tests for archivebox run CLI command. Tests cover: - run with stdin JSONL (Crawl, Snapshot, ArchiveResult) - create-or-update behavior (records with/without id) - pass-through output (for chaining) """ import json import os import signal import subprocess import sys import time import pytest from archivebox.tests.conftest import ( run_archivebox_cmd, parse_jsonl_output, create_test_url, create_test_crawl_json, create_test_snapshot_json, ) RUN_TEST_ENV = { "PLUGINS": "favicon", "SAVE_FAVICON": "True", } class TestRunWithCrawl: """Tests for `archivebox run` with Crawl input.""" def test_run_with_new_crawl(self, initialized_archive): """Run creates and processes a new Crawl (no id).""" crawl_record = create_test_crawl_json() stdout, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(crawl_record), data_dir=initialized_archive, timeout=120, env=RUN_TEST_ENV, ) assert code == 0, f"Command failed: {stderr}" # Should output the created Crawl records = parse_jsonl_output(stdout) crawl_records = [r for r in records if r.get("type") == "Crawl"] assert len(crawl_records) >= 1 assert crawl_records[0].get("id") # Should have an id now def test_run_with_existing_crawl(self, initialized_archive): """Run re-queues an existing Crawl (with id).""" url = create_test_url() # First create a crawl stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive, env=RUN_TEST_ENV) crawl = parse_jsonl_output(stdout1)[0] # Run with the existing crawl stdout2, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(crawl), data_dir=initialized_archive, timeout=120, env=RUN_TEST_ENV, ) assert code == 0 records = parse_jsonl_output(stdout2) assert len(records) >= 1 class TestRunWithSnapshot: """Tests for `archivebox run` with Snapshot input.""" def test_run_with_new_snapshot(self, initialized_archive): """Run creates and processes a new Snapshot (no id, just url).""" snapshot_record = create_test_snapshot_json() stdout, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(snapshot_record), data_dir=initialized_archive, timeout=120, env=RUN_TEST_ENV, ) assert code == 0, f"Command failed: {stderr}" records = parse_jsonl_output(stdout) snapshot_records = [r for r in records if r.get("type") == "Snapshot"] assert len(snapshot_records) >= 1 assert snapshot_records[0].get("id") def test_run_with_existing_snapshot(self, initialized_archive): """Run re-queues an existing Snapshot (with id).""" url = create_test_url() # First create a snapshot stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive, env=RUN_TEST_ENV) snapshot = parse_jsonl_output(stdout1)[0] # Run with the existing snapshot stdout2, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(snapshot), data_dir=initialized_archive, timeout=120, env=RUN_TEST_ENV, ) assert code == 0 records = parse_jsonl_output(stdout2) assert len(records) >= 1 def test_run_with_plain_url(self, initialized_archive): """Run accepts plain URL records (no type field).""" url = create_test_url() url_record = {"url": url} stdout, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(url_record), data_dir=initialized_archive, timeout=120, env=RUN_TEST_ENV, ) assert code == 0 records = parse_jsonl_output(stdout) assert len(records) >= 1 class TestRunWithArchiveResult: """Tests for `archivebox run` with ArchiveResult input.""" def test_run_requeues_failed_archiveresult(self, initialized_archive): """Run re-queues a failed ArchiveResult.""" url = create_test_url() # Create snapshot and archive result stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive, env=RUN_TEST_ENV) snapshot = parse_jsonl_output(stdout1)[0] stdout2, _, _ = run_archivebox_cmd( ["archiveresult", "create", "--plugin=favicon"], stdin=json.dumps(snapshot), data_dir=initialized_archive, env=RUN_TEST_ENV, ) ar = next(r for r in parse_jsonl_output(stdout2) if r.get("type") == "ArchiveResult") # Update to failed ar["status"] = "failed" run_archivebox_cmd( ["archiveresult", "update", "--status=failed"], stdin=json.dumps(ar), data_dir=initialized_archive, env=RUN_TEST_ENV, ) # Now run should re-queue it stdout3, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(ar), data_dir=initialized_archive, timeout=120, env=RUN_TEST_ENV, ) assert code == 0 records = parse_jsonl_output(stdout3) ar_records = [r for r in records if r.get("type") == "ArchiveResult"] assert len(ar_records) >= 1 class TestRunPassThrough: """Tests for pass-through behavior in `archivebox run`.""" def test_run_passes_through_unknown_types(self, initialized_archive): """Run passes through records with unknown types.""" unknown_record = {"type": "Unknown", "id": "fake-id", "data": "test"} stdout, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(unknown_record), data_dir=initialized_archive, ) assert code == 0 records = parse_jsonl_output(stdout) unknown_records = [r for r in records if r.get("type") == "Unknown"] assert len(unknown_records) == 1 assert unknown_records[0]["data"] == "test" def test_run_outputs_all_processed_records(self, initialized_archive): """Run outputs all processed records for chaining.""" url = create_test_url() crawl_record = create_test_crawl_json(urls=[url]) stdout, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(crawl_record), data_dir=initialized_archive, timeout=120, env=RUN_TEST_ENV, ) assert code == 0 records = parse_jsonl_output(stdout) # Should have at least the Crawl in output assert len(records) >= 1 class TestRunMixedInput: """Tests for `archivebox run` with mixed record types.""" def test_run_handles_mixed_types(self, initialized_archive): """Run handles mixed Crawl/Snapshot/ArchiveResult input.""" crawl = create_test_crawl_json() snapshot = create_test_snapshot_json() unknown = {"type": "Tag", "id": "fake", "name": "test"} stdin = "\n".join( [ json.dumps(crawl), json.dumps(snapshot), json.dumps(unknown), ], ) stdout, stderr, code = run_archivebox_cmd( ["run"], stdin=stdin, data_dir=initialized_archive, timeout=120, env=RUN_TEST_ENV, ) assert code == 0 records = parse_jsonl_output(stdout) types = {r.get("type") for r in records} # Should have processed Crawl and Snapshot, passed through Tag assert "Crawl" in types or "Snapshot" in types or "Tag" in types class TestRunEmpty: """Tests for `archivebox run` edge cases.""" def test_run_empty_stdin(self, initialized_archive): """Run with empty stdin returns success.""" stdout, stderr, code = run_archivebox_cmd( ["run"], stdin="", data_dir=initialized_archive, ) assert code == 0 def test_run_no_records_to_process(self, initialized_archive): """Run with only pass-through records shows message.""" unknown = {"type": "Unknown", "id": "fake"} stdout, stderr, code = run_archivebox_cmd( ["run"], stdin=json.dumps(unknown), data_dir=initialized_archive, ) assert code == 0 assert "No records to process" in stderr class TestRunDaemonMode: @pytest.mark.parametrize("stdin_kind", ["malformed", "valid-snapshot"]) def test_run_daemon_ignores_piped_stdin_and_starts_real_runner(self, initialized_archive, db, stdin_kind): from archivebox.machine.models import Process from archivebox.core.models import Snapshot from archivebox.tests.test_orm_helpers import use_archivebox_db snapshot_url = None if stdin_kind == "valid-snapshot": snapshot_url = create_test_url() piped_stdin = json.dumps(create_test_snapshot_json(url=snapshot_url)) + "\n" else: piped_stdin = "{this is not jsonl}\n" env = os.environ.copy() env.update( { "DATA_DIR": str(initialized_archive), "USE_COLOR": "False", "SHOW_PROGRESS": "False", "USE_INDEXING_BACKEND": "False", }, ) proc = subprocess.Popen( [sys.executable, "-m", "archivebox", "run", "--daemon"], cwd=initialized_archive, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, ) assert proc.stdin is not None assert proc.stdout is not None assert proc.stderr is not None try: proc.stdin.write(piped_stdin) proc.stdin.close() deadline = time.monotonic() + 20 started = False while time.monotonic() < deadline: if proc.poll() is not None: stdout = proc.stdout.read() stderr = proc.stderr.read() pytest.fail(f"daemon exited before starting runner: code={proc.returncode}\nstdout={stdout}\nstderr={stderr}") with use_archivebox_db(initialized_archive): started = Process.objects.filter( process_type=Process.TypeChoices.ORCHESTRATOR, status=Process.StatusChoices.RUNNING, pid=proc.pid, ).exists() if started: break time.sleep(0.25) assert started is True if snapshot_url is not None: with use_archivebox_db(initialized_archive): assert not Snapshot.objects.filter(url=snapshot_url).exists() finally: if proc.poll() is None: os.killpg(proc.pid, signal.SIGTERM) try: proc.wait(timeout=15) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.wait(timeout=5) stdout = proc.stdout.read() stderr = proc.stderr.read() assert proc.returncode == 0, stdout + stderr assert "No records to process" not in stderr @pytest.mark.django_db class TestRecoverOrchestratorState: def test_recover_orchestrator_state_unlocks_started_crawl_with_pending_snapshot(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=None, ) Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=None, ) recovered = recover_orchestrator_state() crawl.refresh_from_db() assert recovered["unlocked_crawls"] == 1 assert crawl.status == Crawl.StatusChoices.STARTED assert crawl.retry_at is not None def test_recover_orchestrator_state_seals_started_crawl_with_finished_snapshots(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=None, ) Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.SEALED, retry_at=None, ) recovered = recover_orchestrator_state() crawl.refresh_from_db() assert recovered["sealed_crawls"] == 1 assert crawl.status == Crawl.StatusChoices.SEALED assert crawl.retry_at is None def test_recover_orchestrator_state_repairs_retry_at_status_invariants(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import recover_orchestrator_state user_id = get_or_create_system_user_pk() queued_crawl = Crawl.objects.create( urls="https://example.com/queued-crawl", created_by_id=user_id, status=Crawl.StatusChoices.QUEUED, retry_at=None, ) sealed_crawl = Crawl.objects.create( urls="https://example.com/sealed-crawl", created_by_id=user_id, status=Crawl.StatusChoices.SEALED, retry_at=timezone.now(), ) queued_snapshot = Snapshot.objects.create( url="https://example.com/queued-snapshot", crawl=queued_crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=None, ) sealed_snapshot = Snapshot.objects.create( url="https://example.com/sealed-snapshot", crawl=sealed_crawl, status=Snapshot.StatusChoices.SEALED, retry_at=timezone.now(), ) recovered = recover_orchestrator_state() queued_crawl.refresh_from_db() sealed_crawl.refresh_from_db() queued_snapshot.refresh_from_db() sealed_snapshot.refresh_from_db() assert recovered["queued_crawls_unlocked"] == 1 assert recovered["queued_snapshots_unlocked"] == 1 assert queued_crawl.status == Crawl.StatusChoices.QUEUED assert queued_crawl.retry_at is not None assert sealed_crawl.status == Crawl.StatusChoices.SEALED assert sealed_crawl.retry_at is not None assert queued_snapshot.status == Snapshot.StatusChoices.QUEUED assert queued_snapshot.retry_at is not None assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED assert sealed_snapshot.retry_at is not None def test_recover_orchestrator_state_requeues_backoff_archiveresults(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.SEALED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.SEALED, retry_at=None, ) result = ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.BACKOFF, ) recovered = recover_orchestrator_state() result.refresh_from_db() snapshot.refresh_from_db() crawl.refresh_from_db() assert recovered["requeued_archiveresults"] == 1 assert recovered["requeued_snapshots"] == 1 assert result.status == ArchiveResult.StatusChoices.QUEUED assert snapshot.status == Snapshot.StatusChoices.SEALED assert snapshot.retry_at is not None assert crawl.status == Crawl.StatusChoices.SEALED def test_recover_orchestrator_state_leaves_due_queued_snapshot_for_runner_even_with_final_results(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.QUEUED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=None, ) ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.SUCCEEDED, ) recovered = recover_orchestrator_state() snapshot.refresh_from_db() crawl.refresh_from_db() assert recovered["sealed_queued_snapshots"] == 0 assert recovered["sealed_queued_crawls"] == 0 assert snapshot.status == Snapshot.StatusChoices.QUEUED assert snapshot.retry_at is not None assert snapshot.downloaded_at is None assert crawl.status == Crawl.StatusChoices.QUEUED assert crawl.retry_at is not None def test_recover_orchestrator_state_seals_stale_queued_snapshot_with_final_results(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import recover_orchestrator_state old = timezone.now() - timedelta(hours=13) crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.QUEUED, retry_at=old, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=old, ) result = ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.SUCCEEDED, ) Crawl.objects.filter(pk=crawl.pk).update(modified_at=old) Snapshot.objects.filter(pk=snapshot.pk).update(modified_at=old) ArchiveResult.objects.filter(pk=result.pk).update(modified_at=old) recovered = recover_orchestrator_state() snapshot.refresh_from_db() crawl.refresh_from_db() assert recovered["sealed_queued_snapshots"] == 1 assert recovered["sealed_queued_crawls"] == 1 assert snapshot.status == Snapshot.StatusChoices.SEALED assert snapshot.retry_at is None assert snapshot.downloaded_at is not None assert crawl.status == Crawl.StatusChoices.SEALED assert crawl.retry_at is None def test_recover_orchestrator_state_raises_on_stale_active_crawl(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.services.runner import recover_orchestrator_state old = timezone.now() - timedelta(hours=13) crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.QUEUED, retry_at=old, ) Crawl.objects.filter(id=crawl.id).update(modified_at=old, retry_at=old) with pytest.raises(RuntimeError, match="Stuck crawl invariant violated"): recover_orchestrator_state() def test_recover_orchestrator_state_unlocks_started_snapshot_without_running_result(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.SEALED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.STARTED, retry_at=None, ) recovered = recover_orchestrator_state() snapshot.refresh_from_db() crawl.refresh_from_db() assert recovered["unlocked_snapshots"] == 1 assert snapshot.status == Snapshot.StatusChoices.STARTED assert snapshot.retry_at is not None assert crawl.status == Crawl.StatusChoices.SEALED assert crawl.retry_at is None def test_recover_orchestrator_state_requeues_sealed_snapshot_with_queued_results(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.SEALED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.SEALED, retry_at=None, ) ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.QUEUED, ) recovered = recover_orchestrator_state() snapshot.refresh_from_db() crawl.refresh_from_db() assert recovered["requeued_snapshots"] == 1 assert snapshot.status == Snapshot.StatusChoices.SEALED assert snapshot.retry_at is not None assert crawl.status == Crawl.StatusChoices.SEALED assert crawl.retry_at is None def test_recover_orchestrator_state_ignores_sealed_downloaded_snapshot_without_results(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.SEALED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.SEALED, downloaded_at=timezone.now(), retry_at=None, ) recovered = recover_orchestrator_state() snapshot.refresh_from_db() crawl.refresh_from_db() assert recovered["requeued_snapshots"] == 0 assert recovered["unlocked_snapshots"] == 0 assert snapshot.status == Snapshot.StatusChoices.SEALED assert snapshot.retry_at is None assert crawl.status == Crawl.StatusChoices.SEALED assert crawl.retry_at is None def test_recover_orchestrator_state_seals_started_snapshot_with_final_results(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.STARTED, retry_at=None, ) ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.SUCCEEDED, ) recovered = recover_orchestrator_state() snapshot.refresh_from_db() assert recovered["sealed_snapshots"] == 1 assert snapshot.status == Snapshot.StatusChoices.SEALED assert snapshot.retry_at is None @pytest.mark.django_db class TestRunDueCrawlState: def test_maintenance_only_runner_does_not_start_regular_queued_crawls(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.services.runner import run_pending_crawls now = timezone.now() crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.QUEUED, retry_at=now, ) assert run_pending_crawls(daemon=False, maintenance_only=True) == 0 crawl.refresh_from_db() assert crawl.status == Crawl.StatusChoices.QUEUED assert crawl.retry_at == now assert crawl.snapshot_set.count() == 0 def test_snapshot_start_writes_short_future_lease(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=timezone.now(), ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=timezone.now(), ) snapshot.sm.tick() snapshot.refresh_from_db() assert snapshot.status == Snapshot.StatusChoices.STARTED assert snapshot.retry_at is not None assert snapshot.retry_at > timezone.now() def test_abandoned_started_snapshot_results_are_reset_locally_for_resume(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import reset_abandoned_snapshot_results crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=timezone.now(), ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.STARTED, retry_at=timezone.now(), ) abandoned = ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.STARTED, output_str="partial output should be cleared", output_files={"partial.txt": {"size": 12}}, output_size=12, start_ts=timezone.now(), ) queued = ArchiveResult.objects.create( snapshot=snapshot, plugin="wget", hook_name="on_Snapshot__40_wget", status=ArchiveResult.StatusChoices.QUEUED, ) finished = ArchiveResult.objects.create( snapshot=snapshot, plugin="favicon", hook_name="on_Snapshot__01_favicon", status=ArchiveResult.StatusChoices.SUCCEEDED, output_str="keep me", output_files={"favicon.ico": {"size": 1}}, output_size=1, ) reset_abandoned_snapshot_results(snapshot) abandoned.refresh_from_db() queued.refresh_from_db() finished.refresh_from_db() assert abandoned.status == ArchiveResult.StatusChoices.QUEUED assert abandoned.output_str == "" assert abandoned.output_files == {} assert abandoned.output_size == 0 assert queued.status == ArchiveResult.StatusChoices.QUEUED assert finished.status == ArchiveResult.StatusChoices.SUCCEEDED assert finished.output_str == "keep me" assert finished.output_files == {"favicon.ico": {"size": 1}} def test_due_started_snapshot_with_live_child_extends_lease_without_reset(self): import os from datetime import datetime import psutil from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.machine.models import Machine, NetworkInterface, Process from archivebox.services.runner import run_due_snapshot now = timezone.now() os_proc = psutil.Process(os.getpid()) crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=now, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.STARTED, retry_at=now, ) process = Process.objects.create( machine=Machine.current(), iface=NetworkInterface.current(), process_type=Process.TypeChoices.HOOK, status=Process.StatusChoices.RUNNING, pid=os.getpid(), started_at=datetime.fromtimestamp(os_proc.create_time(), tz=timezone.get_current_timezone()), cmd=os_proc.cmdline(), pwd=str(snapshot.output_dir / "title"), ) result = ArchiveResult.objects.create( snapshot=snapshot, process=process, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.STARTED, output_str="live work should not be reset", output_files={"partial.txt": {"size": 12}}, output_size=12, ) assert run_due_snapshot(snapshot, lock_seconds=60) is True snapshot.refresh_from_db() result.refresh_from_db() assert snapshot.status == Snapshot.StatusChoices.STARTED assert snapshot.retry_at is not None assert snapshot.retry_at > now assert result.status == ArchiveResult.StatusChoices.STARTED assert result.output_str == "live work should not be reset" assert result.output_files == {"partial.txt": {"size": 12}} assert result.output_size == 12 def test_run_due_crawl_seals_finished_started_crawl(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import run_due_crawl crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=timezone.now(), ) Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.SEALED, retry_at=None, ) assert run_due_crawl(crawl, lock_seconds=10) is True crawl.refresh_from_db() assert crawl.status == Crawl.StatusChoices.SEALED assert crawl.retry_at is None def test_run_due_crawl_preserves_next_future_snapshot_retry(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import run_due_crawl future = timezone.now() + timedelta(hours=1) crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=timezone.now(), ) Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=future, ) assert run_due_crawl(crawl, lock_seconds=10) is True crawl.refresh_from_db() assert crawl.status == Crawl.StatusChoices.STARTED assert crawl.retry_at == future def test_run_due_crawl_preserves_next_future_started_snapshot_lease(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import run_due_crawl future = timezone.now() + timedelta(minutes=5) crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=timezone.now(), ) Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.STARTED, retry_at=future, ) assert run_due_crawl(crawl, lock_seconds=10) is True crawl.refresh_from_db() assert crawl.status == Crawl.StatusChoices.STARTED assert crawl.retry_at == future def test_run_due_crawl_unlocks_null_retry_queued_snapshot(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import run_due_crawl crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=timezone.now(), ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=None, ) assert run_due_crawl(crawl, lock_seconds=10) is True crawl.refresh_from_db() snapshot.refresh_from_db() assert crawl.status == Crawl.StatusChoices.STARTED assert crawl.retry_at is not None assert snapshot.retry_at is not None @pytest.mark.django_db class TestRecoverOrchestratorStateRedFailureModes: def test_recovery_does_not_seal_queued_snapshot_waiting_for_future_retry_even_with_final_results(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import recover_orchestrator_state future = timezone.now() + timedelta(days=1) crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=future, ) snapshot = Snapshot.objects.create( url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=future, ) ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.SUCCEEDED, ) recover_orchestrator_state() snapshot.refresh_from_db() assert snapshot.status == Snapshot.StatusChoices.QUEUED assert snapshot.retry_at == future def test_recovery_does_not_seal_queued_crawl_waiting_for_future_retry_even_with_finished_snapshots(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import recover_orchestrator_state future = timezone.now() + timedelta(days=1) crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.QUEUED, retry_at=future, ) Snapshot.objects.create(url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.SEALED, retry_at=None) recover_orchestrator_state() crawl.refresh_from_db() assert crawl.status == Crawl.StatusChoices.QUEUED assert crawl.retry_at == future def test_recovery_keeps_sealed_parent_when_future_retry_child_is_scheduled(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import recover_orchestrator_state future = timezone.now() + timedelta(days=1) crawl = Crawl.objects.create( urls="https://blog.sweeting.me", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.SEALED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://blog.sweeting.me", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=future, ) recover_orchestrator_state() crawl.refresh_from_db() snapshot.refresh_from_db() assert crawl.status == Crawl.StatusChoices.SEALED assert crawl.retry_at is None assert snapshot.retry_at == future def test_recovery_unlocks_started_parent_to_future_retry_child_not_now(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import recover_orchestrator_state future = timezone.now() + timedelta(days=1) crawl = Crawl.objects.create( urls="https://www.mathjax.org/", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=None, ) Snapshot.objects.create(url="https://www.mathjax.org/", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=future) recover_orchestrator_state() crawl.refresh_from_db() assert crawl.status == Crawl.StatusChoices.STARTED assert crawl.retry_at == future def test_recovery_requeues_started_archiveresult_without_process(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://www.mathjax.org/", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://www.mathjax.org/", crawl=crawl, status=Snapshot.StatusChoices.STARTED, retry_at=None, ) result = ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.STARTED, ) recover_orchestrator_state() result.refresh_from_db() assert result.status == ArchiveResult.StatusChoices.QUEUED def test_recovery_requeues_started_archiveresult_with_exited_process(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.machine.models import Machine, NetworkInterface, Process from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://revealjs.com/", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=None, ) snapshot = Snapshot.objects.create(url="https://revealjs.com/", crawl=crawl, status=Snapshot.StatusChoices.STARTED, retry_at=None) process = Process.objects.create( machine=Machine.current(refresh=True), iface=NetworkInterface.current(refresh=True), process_type=Process.TypeChoices.HOOK, worker_type="archiveresult", pwd=str(snapshot.output_dir / "title"), cmd=["python", "--version"], status=Process.StatusChoices.EXITED, retry_at=None, exit_code=0, ended_at=timezone.now(), ) result = ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.STARTED, process=process, ) recover_orchestrator_state() result.refresh_from_db() assert result.status == ArchiveResult.StatusChoices.QUEUED def test_recovery_requeues_sealed_snapshot_started_result_with_exited_process_result_too(self): from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.machine.models import Machine, NetworkInterface, Process from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://pdfobject.com/pdf/sample-3pp.pdf", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.SEALED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://pdfobject.com/pdf/sample-3pp.pdf", crawl=crawl, status=Snapshot.StatusChoices.SEALED, retry_at=None, ) process = Process.objects.create( machine=Machine.current(refresh=True), iface=NetworkInterface.current(refresh=True), process_type=Process.TypeChoices.HOOK, worker_type="archiveresult", pwd=str(snapshot.output_dir / "pdf"), cmd=["python", "--version"], status=Process.StatusChoices.EXITED, retry_at=None, exit_code=0, ended_at=timezone.now(), ) result = ArchiveResult.objects.create( snapshot=snapshot, plugin="pdf", hook_name="on_Snapshot__50_pdf", status=ArchiveResult.StatusChoices.STARTED, process=process, ) recover_orchestrator_state() snapshot.refresh_from_db() result.refresh_from_db() assert snapshot.status == Snapshot.StatusChoices.QUEUED assert result.status == ArchiveResult.StatusChoices.QUEUED def test_recovery_requeues_started_snapshot_result_before_unlocking_snapshot(self): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot from archivebox.services.runner import recover_orchestrator_state crawl = Crawl.objects.create( urls="https://mermaid-js.github.io/mermaid/", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=None, ) snapshot = Snapshot.objects.create( url="https://mermaid-js.github.io/mermaid/", crawl=crawl, status=Snapshot.StatusChoices.STARTED, retry_at=None, ) result = ArchiveResult.objects.create( snapshot=snapshot, plugin="title", hook_name="on_Snapshot__01_title", status=ArchiveResult.StatusChoices.STARTED, ) recover_orchestrator_state() snapshot.refresh_from_db() result.refresh_from_db() assert result.status == ArchiveResult.StatusChoices.QUEUED assert snapshot.retry_at is not None def test_crawl_runner_load_run_state_does_not_return_future_retry_snapshots(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import CrawlRunner future = timezone.now() + timedelta(days=1) crawl = Crawl.objects.create( urls="https://example.com", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=future, ) Snapshot.objects.create(url="https://example.com", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=future) runner = CrawlRunner(crawl, selected_plugins=[]) assert runner.load_run_state() == [] def test_crawl_runner_finalize_run_state_preserves_next_future_snapshot_retry(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot from archivebox.services.runner import CrawlRunner future = timezone.now() + timedelta(days=1) crawl = Crawl.objects.create( urls="https://blog.sweeting.me", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.STARTED, retry_at=None, ) Snapshot.objects.create(url="https://blog.sweeting.me", crawl=crawl, status=Snapshot.StatusChoices.QUEUED, retry_at=future) runner = CrawlRunner(crawl, selected_plugins=[]) runner.finalize_run_state() crawl.refresh_from_db() assert crawl.status == Crawl.StatusChoices.STARTED assert crawl.retry_at == future def test_recovery_raises_stale_due_crawl_even_with_recent_unrelated_process_path_containing_crawl_id(self): from datetime import timedelta from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.machine.models import Machine, NetworkInterface, Process from archivebox.services.runner import recover_orchestrator_state old = timezone.now() - timedelta(hours=13) crawl = Crawl.objects.create( urls="https://github.com/nodeca/pica", created_by_id=get_or_create_system_user_pk(), status=Crawl.StatusChoices.QUEUED, retry_at=old, ) Crawl.objects.filter(id=crawl.id).update(modified_at=old, retry_at=old) Process.objects.create( machine=Machine.current(refresh=True), iface=NetworkInterface.current(refresh=True), process_type=Process.TypeChoices.HOOK, worker_type="archiveresult", pwd=f"/tmp/not-an-archivebox-child/{crawl.id}/title", cmd=["python", "--version"], status=Process.StatusChoices.EXITED, retry_at=None, exit_code=0, ended_at=timezone.now(), ) with pytest.raises(RuntimeError, match="Stuck crawl invariant violated"): recover_orchestrator_state() def test_recovery_does_not_crash_on_invalid_utf8_process_logs(self, tmp_path): from datetime import timedelta from django.utils import timezone from archivebox.machine.models import Machine, NetworkInterface, Process from archivebox.services.runner import recover_orchestrator_state runtime_dir = tmp_path / "https_example_com" / ".hooks" / "on_Snapshot__01_title.py" runtime_dir.mkdir(parents=True) (runtime_dir / "stdout.log").write_bytes(b"\\xff\\xfe\\xfa") process = Process.objects.create( machine=Machine.current(refresh=True), iface=NetworkInterface.current(refresh=True), process_type=Process.TypeChoices.HOOK, worker_type="archiveresult", pwd=str(tmp_path / "https_example_com"), cmd=["on_Snapshot__01_title.py"], status=Process.StatusChoices.RUNNING, retry_at=None, pid=999999, started_at=timezone.now() - timedelta(hours=1), timeout=1, ) recover_orchestrator_state() process.refresh_from_db() assert process.status == Process.StatusChoices.EXITED