From 3871a4ac7ea4f47da43bb9e50f8727320166bed4 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 1 Jun 2026 03:12:47 -0700 Subject: [PATCH] release: archivebox 0.9.33rc68 --- .../0018_freeze_crawl_config_snapshots.py | 54 +++++++-- archivebox/personas/admin.py | 2 - archivebox/personas/models.py | 11 -- archivebox/services/runner.py | 114 +++++++++++++++--- archivebox/services/snapshot_service.py | 2 - archivebox/tests/test_cli_add.py | 2 +- archivebox/tests/test_cli_piping.py | 3 + etc/package.json | 2 +- pyproject.toml | 8 +- 9 files changed, 149 insertions(+), 49 deletions(-) diff --git a/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py b/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py index b341e18a..05cbb79b 100644 --- a/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py +++ b/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py @@ -1,29 +1,59 @@ +import json + +from django.conf import settings from django.db import migrations +BATCH_SIZE = 1000 + + +def _config_cache_key(config): + return json.dumps(config or {}, sort_keys=True, separators=(",", ":"), default=str) + + +def _flush_updates(Crawl, db_alias, pending): + if pending: + Crawl.objects.using(db_alias).bulk_update(pending, ["config"], batch_size=BATCH_SIZE) + pending.clear() + + def freeze_existing_crawl_configs(apps, schema_editor): from archivebox.config.common import build_crawl_config_snapshot from archivebox.personas.models import Persona - from django.contrib.auth import get_user_model Crawl = apps.get_model("crawls", "Crawl") - User = get_user_model() + auth_app_label, auth_model_name = settings.AUTH_USER_MODEL.split(".", 1) + User = apps.get_model(auth_app_label, auth_model_name) db_alias = schema_editor.connection.alias + rows = Crawl.objects.using(db_alias).values_list("id", "persona_id", "created_by_id", "config") + persona_ids = {persona_id for _, persona_id, _, _ in rows if persona_id} + user_ids = {user_id for _, _, user_id, _ in rows if user_id} + personas = {persona.pk: persona for persona in Persona.objects.using(db_alias).filter(pk__in=persona_ids)} + users = {user.pk: user for user in User.objects.using(db_alias).filter(pk__in=user_ids)} - for crawl in Crawl.objects.using(db_alias).select_related("persona", "created_by").iterator(chunk_size=200): - current_config = dict(crawl.config or {}) - persona = Persona.objects.using(db_alias).filter(pk=crawl.persona_id).first() - user = User.objects.using(db_alias).filter(pk=crawl.created_by_id).first() - frozen_config = build_crawl_config_snapshot( - user=user, - persona=persona, - overrides=current_config, - ) + frozen_cache = {} + pending = [] + for crawl_id, persona_id, user_id, current_config in rows.iterator(chunk_size=BATCH_SIZE): + current_config = dict(current_config or {}) + cache_key = (persona_id, user_id, _config_cache_key(current_config)) + if cache_key not in frozen_cache: + frozen_cache[cache_key] = build_crawl_config_snapshot( + user=users.get(user_id), + persona=personas.get(persona_id), + overrides=current_config, + ) + frozen_config = frozen_cache[cache_key] if frozen_config != current_config: - Crawl.objects.using(db_alias).filter(pk=crawl.pk).update(config=frozen_config) + pending.append(Crawl(id=crawl_id, config=frozen_config)) + if len(pending) >= BATCH_SIZE: + _flush_updates(Crawl, db_alias, pending) + + _flush_updates(Crawl, db_alias, pending) class Migration(migrations.Migration): + atomic = False + dependencies = [ ("crawls", "0017_drop_stale_crawl_limit_columns"), ] diff --git a/archivebox/personas/admin.py b/archivebox/personas/admin.py index 2685b22e..143cf457 100644 --- a/archivebox/personas/admin.py +++ b/archivebox/personas/admin.py @@ -92,14 +92,12 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin): "
" "
Persona root{}
" "
chrome_profile{}
" - "
chrome_extensions{}
" "
chrome_downloads{}
" "
cookies.txt{}
" "
auth.json{}
" "
", obj.path, obj.CHROME_USER_DATA_DIR, - obj.CHROME_EXTENSIONS_DIR, obj.CHROME_DOWNLOADS_DIR, obj.COOKIES_FILE or (obj.path / "cookies.txt"), obj.AUTH_STORAGE_FILE or (obj.path / "auth.json"), diff --git a/archivebox/personas/models.py b/archivebox/personas/models.py index 1fe18761..5b471069 100644 --- a/archivebox/personas/models.py +++ b/archivebox/personas/models.py @@ -4,7 +4,6 @@ Persona management for ArchiveBox. A Persona represents a browser profile/identity used for archiving. Each persona has its own: - Chrome user data directory (for cookies, localStorage, extensions, etc.) -- Chrome extensions directory - Cookies file - Config overrides """ @@ -66,7 +65,6 @@ class Persona(ModelWithConfig): Each persona provides: - CHROME_USER_DATA_DIR: Chrome profile directory - - CHROME_EXTENSIONS_DIR: Installed extensions directory - CHROME_DOWNLOADS_DIR: Chrome downloads directory - COOKIES_FILE: Cookies file for wget/curl - config: JSON field with persona-specific config overrides @@ -124,11 +122,6 @@ class Persona(ModelWithConfig): """Derived path to Chrome user data directory for this persona.""" return str(self.path / "chrome_profile") - @property - def CHROME_EXTENSIONS_DIR(self) -> str: - """Derived path to Chrome extensions directory for this persona.""" - return str(self.path / "chrome_extensions") - @property def CHROME_DOWNLOADS_DIR(self) -> str: """Derived path to Chrome downloads directory for this persona.""" @@ -153,7 +146,6 @@ class Persona(ModelWithConfig): Returns dict with: - All values from self.config JSONField - CHROME_USER_DATA_DIR (derived from persona path) - - CHROME_EXTENSIONS_DIR (derived from persona path) - CHROME_DOWNLOADS_DIR (derived from persona path) - COOKIES_FILE (derived from persona path, if file exists) - AUTH_STORAGE_FILE (derived from persona path, if file exists) @@ -164,8 +156,6 @@ class Persona(ModelWithConfig): # Add derived paths (don't override if explicitly set in config) if "CHROME_USER_DATA_DIR" not in derived: derived["CHROME_USER_DATA_DIR"] = self.CHROME_USER_DATA_DIR - if "CHROME_EXTENSIONS_DIR" not in derived: - derived["CHROME_EXTENSIONS_DIR"] = self.CHROME_EXTENSIONS_DIR if "CHROME_DOWNLOADS_DIR" not in derived: derived["CHROME_DOWNLOADS_DIR"] = self.CHROME_DOWNLOADS_DIR if "COOKIES_FILE" not in derived and self.COOKIES_FILE: @@ -182,7 +172,6 @@ class Persona(ModelWithConfig): """Create persona directories if they don't exist.""" self.path.mkdir(parents=True, exist_ok=True) (self.path / "chrome_profile").mkdir(parents=True, exist_ok=True) - (self.path / "chrome_extensions").mkdir(parents=True, exist_ok=True) (self.path / "chrome_downloads").mkdir(parents=True, exist_ok=True) def cleanup_chrome_profile(self, profile_dir: Path) -> bool: diff --git a/archivebox/services/runner.py b/archivebox/services/runner.py index 123e118b..3ce6bb7f 100644 --- a/archivebox/services/runner.py +++ b/archivebox/services/runner.py @@ -1273,8 +1273,6 @@ def run_snapshot_maintenance(snapshot_id: str) -> bool: snapshot.retry_at = timezone.now() if has_queued_results else None snapshot.save(update_fields=["retry_at", "modified_at"]) snapshot.write_index_jsonl() - snapshot.write_json_details() - snapshot.write_html_details() return True @@ -1433,7 +1431,7 @@ def run_due_snapshot(snapshot, *, lock_seconds: int, interactive_interrupts: boo snapshot.finalize_completed_upload_results() maintenance_ran = False if snapshot.fs_migration_needed: - # Final snapshots can still need filesystem/json maintenance after + # Final snapshots can still need filesystem/index maintenance after # a data-dir migration, but queued ArchiveResult rows are the actual # runnable work. Do the metadata rewrite first, then continue into # the targeted plugin path in the same tick so large migrations do @@ -1442,6 +1440,7 @@ def run_due_snapshot(snapshot, *, lock_seconds: int, interactive_interrupts: boo snapshot.refresh_from_db() selected_plugins = queued_plugins_for_snapshot(str(snapshot.id)) if selected_plugins: + search_only_plugins = all(plugin.startswith("search_backend_") for plugin in selected_plugins) _runner_console_line(crawl_id=snapshot.crawl_id, snapshot=snapshot) run_crawl( str(snapshot.crawl_id), @@ -1450,6 +1449,21 @@ def run_due_snapshot(snapshot, *, lock_seconds: int, interactive_interrupts: boo process_discovered_snapshots_inline=True, interactive_interrupts=interactive_interrupts, ) + if search_only_plugins: + from archivebox.core.models import ArchiveResult + + has_queued_results = ArchiveResult.objects.filter( + snapshot_id=snapshot.id, + status=ArchiveResult.StatusChoices.QUEUED, + ).exists() + if not has_queued_results: + type(snapshot).objects.filter( + pk=snapshot.pk, + status=snapshot.StatusChoices.SEALED, + ).update( + retry_at=None, + modified_at=timezone.now(), + ) return True if maintenance_ran: return True @@ -1678,8 +1692,8 @@ def _run_due_snapshot_id(snapshot_id, *, lock_seconds: int, interactive_interrup return True -def _run_due_queued_download_result( - download_plugin_names: frozenset[str], +def _run_due_queued_plugin_result( + plugin_names: frozenset[str], *, crawl_id: str | None, lock_seconds: int, @@ -1688,11 +1702,11 @@ def _run_due_queued_download_result( ) -> bool: from archivebox.core.models import ArchiveResult, Snapshot - if not download_plugin_names: + if not plugin_names: return False queued_results = ArchiveResult.objects.filter( status=ArchiveResult.StatusChoices.QUEUED, - plugin__in=download_plugin_names, + plugin__in=plugin_names, snapshot__status=Snapshot.StatusChoices.SEALED, snapshot__retry_at__lte=timezone.now(), ) @@ -1726,6 +1740,53 @@ def _run_due_binary() -> bool: return True +def _fast_forward_same_path_snapshot_fs_versions(batch_size: int = 10000) -> bool: + from django.db import connection + + from archivebox.core.models import Snapshot, ArchiveResult + + now = timezone.now() + current_version = Snapshot._fs_current_version() + same_path_versions = ("0.9.0", "0.9.1", "0.9.2", "0.9.3") + with connection.cursor() as cursor: + cursor.execute( + """ + UPDATE core_snapshot + SET fs_version = %s, + retry_at = CASE + WHEN EXISTS ( + SELECT 1 + FROM core_archiveresult + WHERE core_archiveresult.snapshot_id = core_snapshot.id + AND core_archiveresult.status = %s + ) + THEN retry_at + ELSE NULL + END, + modified_at = %s + WHERE id IN ( + SELECT id + FROM core_snapshot + WHERE status = %s + AND retry_at <= %s + AND fs_version IN (%s, %s, %s, %s) + ORDER BY retry_at, created_at + LIMIT %s + ) + """, + [ + current_version, + ArchiveResult.StatusChoices.QUEUED, + now, + Snapshot.StatusChoices.SEALED, + now, + *same_path_versions, + batch_size, + ], + ) + return bool(cursor.rowcount) + + def run_pending_crawls( *, daemon: bool = False, @@ -1747,6 +1808,7 @@ def run_pending_crawls( for plugin_name, plugin_config in plugin_configs.items() if plugin_config.get("output_mimetypes") and not plugin_name.startswith("search_backend_") ) + search_plugin_names = frozenset(plugin_name for plugin_name in plugin_configs if plugin_name.startswith("search_backend_")) last_recovery_at = 0.0 last_retention_at = 0.0 last_analyze_at = 0.0 @@ -1773,7 +1835,7 @@ def run_pending_crawls( # Final-state download rows are always first: they have no parent crawl # scheduler of their own, and leaving them behind makes the global # counters report stale queued work while new crawls continue. - if _run_due_queued_download_result( + if _run_due_queued_plugin_result( download_plugin_names, crawl_id=crawl_id, lock_seconds=60, @@ -1782,13 +1844,22 @@ def run_pending_crawls( ): continue - # Other final-state snapshot work comes next: search backfills, - # filesystem/json maintenance, and upload finalization should drain - # before starting or resuming regular crawl work. + if _fast_forward_same_path_snapshot_fs_versions(): + continue + + # Final-state snapshot maintenance comes before normal crawl work: + # filesystem/index maintenance and upload finalization should drain + # promptly, but pure search backend backfills are deferred below so + # they do not starve live crawls. sealed_snapshots = Snapshot.objects.filter( retry_at__lte=timezone.now(), status=Snapshot.StatusChoices.SEALED, ) + if search_plugin_names: + sealed_snapshots = sealed_snapshots.exclude( + archiveresult__status=ArchiveResult.StatusChoices.QUEUED, + archiveresult__plugin__in=search_plugin_names, + ) if crawl_id: sealed_snapshots = sealed_snapshots.filter(crawl_id=crawl_id) if _run_due_snapshot_query( @@ -1868,11 +1939,13 @@ def run_pending_crawls( ): continue - # Final fallback uses only the retry_at scheduler index and selects an - # id first. The active/paused/sealed parent-specific branches above get - # first priority, so this stays broad without hydrating wide rows or - # forcing SQLite into a slow status/join plan. - due_snapshots = Snapshot.objects.filter(retry_at__lte=timezone.now()) + # Final active-state fallback uses only the retry_at scheduler index and + # selects an id first. Keep final SEALED rows out of this broad path so + # large filesystem/index backfills cannot starve newly queued crawls. + due_snapshots = Snapshot.objects.filter( + retry_at__lte=timezone.now(), + status__in=Snapshot.OPEN_STATES, + ) if maintenance_only: due_snapshots = due_snapshots.filter(status=Snapshot.StatusChoices.PAUSED) if crawl_id: @@ -1885,6 +1958,15 @@ def run_pending_crawls( ): continue + if _run_due_queued_plugin_result( + search_plugin_names, + crawl_id=crawl_id, + lock_seconds=60, + interactive_interrupts=interactive_interrupts, + runtime_config=runtime_config, + ): + continue + if not maintenance_only: if _run_due_crawl_status( Crawl.StatusChoices.SEALED, diff --git a/archivebox/services/snapshot_service.py b/archivebox/services/snapshot_service.py index a983c1b3..a548adb7 100644 --- a/archivebox/services/snapshot_service.py +++ b/archivebox/services/snapshot_service.py @@ -41,8 +41,6 @@ def finalize_completed_snapshot(snapshot_id: str) -> None: snapshot.refresh_from_db() snapshot.write_index_jsonl() - snapshot.write_json_details() - snapshot.write_html_details() def _crawl_limit_stop_reason(crawl) -> str: diff --git a/archivebox/tests/test_cli_add.py b/archivebox/tests/test_cli_add.py index 2fa72f88..d25f2471 100644 --- a/archivebox/tests/test_cli_add.py +++ b/archivebox/tests/test_cli_add.py @@ -296,7 +296,7 @@ def test_add_records_url_filter_overrides_on_crawl(tmp_path, process, disable_ex assert crawl.config["URL_ALLOWLIST"] == "example.com,*.example.com" assert crawl.config["URL_DENYLIST"] == "static.example.com" - assert (tmp_path / "personas" / "Default" / "chrome_extensions").is_dir() + assert not (tmp_path / "personas" / "Default" / "chrome_extensions").exists() def test_add_duplicate_url_creates_separate_crawls(tmp_path, process, disable_extractors_dict): diff --git a/archivebox/tests/test_cli_piping.py b/archivebox/tests/test_cli_piping.py index 0d581fab..a58f24ef 100644 --- a/archivebox/tests/test_cli_piping.py +++ b/archivebox/tests/test_cli_piping.py @@ -272,6 +272,7 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive): snapshot_stdout, snapshot_stderr, snapshot_code = run_archivebox_cmd( ["snapshot", "create", url], data_dir=initialized_archive, + env=PIPE_TEST_ENV, ) assert snapshot_code == 0, snapshot_stderr @@ -279,6 +280,7 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive): ["archiveresult", "create", "--plugin=favicon"], stdin=snapshot_stdout, data_dir=initialized_archive, + env=PIPE_TEST_ENV, ) assert ar_create_code == 0, ar_create_stderr @@ -293,6 +295,7 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive): list_stdout, list_stderr, list_code = run_archivebox_cmd( ["archiveresult", "list", "--plugin=favicon"], data_dir=initialized_archive, + env=PIPE_TEST_ENV, ) assert list_code == 0, list_stderr _assert_stdout_is_jsonl_only(list_stdout) diff --git a/etc/package.json b/etc/package.json index eb363c7c..b2d2a746 100644 --- a/etc/package.json +++ b/etc/package.json @@ -1,6 +1,6 @@ { "name": "archivebox", - "version": "0.9.33rc67", + "version": "0.9.33rc68", "repository": "github:ArchiveBox/ArchiveBox", "license": "MIT", "dependencies": { diff --git a/pyproject.toml b/pyproject.toml index fdf74c93..78d6dff0 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "archivebox" -version = "0.9.33rc67" +version = "0.9.33rc68" requires-python = ">=3.13" description = "Self-hosted internet archiving solution." authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}] @@ -79,9 +79,9 @@ dependencies = [ ### Extractor dependencies (optional binaries detected at runtime via shutil.which) ### Binary/Package Management "abxbus==2.5.8", # EventBus API - "abxpkg>=1.11.94", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm - "abx-plugins>=1.11.101", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring - "abx-dl>=1.11.101", # shared ArchiveBox downloader package with blocking install preflight + "abxpkg>=1.11.113", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm + "abx-plugins>=1.11.116", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring + "abx-dl>=1.11.116", # shared ArchiveBox downloader package with blocking install preflight ### UUID7 backport for Python <3.14 "uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13 ]