Compare commits

...
Author SHA1 Message Date
Nick Sweeting 42f8a56a9d Trust ready OpenCode processes 2026-09-03 00:59:06 -07:00
Nick Sweeting 650697f3c2 Make OpenCode readiness checks race-safe 2026-09-03 00:17:53 -07:00
Nick Sweeting 91aa70cc73 Track OpenCode readiness separately from liveness 2026-09-03 00:14:34 -07:00
Nick Sweeting 8a12e3399c Honor the OpenCode recovery deadline 2026-09-02 23:57:59 -07:00
Nick Sweeting 944907bfee Keep OpenCode proxy traffic out of recovery 2026-09-02 23:56:46 -07:00
Nick Sweeting 7f1767a2db Avoid restarting OpenCode on transient health misses 2026-09-02 23:49:58 -07:00
ArchiveBox Release Bot 3327edba24 Bump release version to 0.9.35rc418 2026-09-03 06:24:45 +00:00
Nick Sweeting a71ef00b79 Reload stale OpenCode frames after server recovery (#1860)
## Summary

- monitor the proxied OpenCode health endpoint from the stable
ArchiveBox parent page
- reload the iframe once after an unavailable -> healthy transition
- trigger an immediate check again when the browser comes online or the
tab becomes visible

## Why

A real RC416 -> RC417 DigestBox restart left the already-open OpenCode
1.17.14 SPA showing its fatal error page against the newly started
OpenCode 1.17.15 server. A full page reload recovered immediately. The
parent ArchiveBox document survives the container restart, so it can
perform that one iframe reload automatically after the server is healthy
again.

## Verification

- `test_opencode_agent_superuser_gets_admin_wrapper` passes with
assertions for the recovery monitor
- focused pre-commit hooks pass
- real stale-tab restart reproduction will be repeated on DigestBox
after release

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Reloads stale OpenCode iframes after the ArchiveBox server recovers from
a restart, so the previously fatal error page is replaced automatically.
The parent page polls the OpenCode health endpoint every 3 seconds,
reloading the iframe once when the server transitions from unavailable
to healthy or when the server version changes, and also re-checks
immediately when the browser comes online or the tab becomes visible.

- Health monitoring uses a separate endpoint that reports status without
starting the server; recovery restarts a running but unhealthy OpenCode
process, and polling skips while hidden when the server is down.

**Verification**
- `test_opencode_agent_superuser_gets_admin_wrapper` passes with
assertions for the recovery monitor.
- Focused pre-commit hooks pass.

<sup>Written for commit a33b333b6a.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1860?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-02 23:23:56 -07:00
Nick Sweeting a33b333b6a Keep OpenCode recovery nonblocking 2026-09-02 23:19:30 -07:00
Nick Sweeting 00e867d949 Separate OpenCode health monitoring from recovery 2026-09-02 23:02:59 -07:00
Nick Sweeting 69ca67b37d Make OpenCode recovery detect server changes 2026-09-02 22:47:15 -07:00
Nick Sweeting 4149b6f578 Reload stale OpenCode frames after server recovery 2026-09-02 22:37:53 -07:00
ArchiveBox Release Bot 2d23264bd6 Bump release version to 0.9.35rc417 2026-09-03 05:27:30 +00:00
Nick Sweeting e9feb8162a Keep OpenCode SSE reconnects off blocked ASGI request threads (#1859)
## Summary

- return OpenCode SSE headers before waiting for a stopped server to
restart
- run reconnect-driven OpenCode startup in a non-thread-sensitive worker
inside the async stream
- remove the obsolete global `ThreadSensitiveContext` monkeypatch now
that asgiref 3.12.1 implements non-blocking shutdown upstream
- add a real ASGI regression test that holds the real startup lock and
verifies headers arrive before restart can finish

## Why

After deploying RC416, a stale `/admin/agent` tab successfully recovered
across the container restart, but Daphne logged three
`CurrentThreadExecutor already quit or is broken` exceptions while early
EventSource reconnects were cancelled during synchronous OpenCode
startup. Normal proxy traffic subsequently returned 200 and no data was
lost, but the request cleanup path was not clean.

## Verification

- `uv run pytest archivebox/tests/test_opencode_agent.py -q` — 15 passed
- focused SSE tests after final refactor — 2 passed
- `uv run prek run --all-files` — passed
- no OpenCode server remained after fixture teardown

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Makes OpenCode SSE reconnects return headers before the stopped server
finishes restarting, so early EventSource reconnects no longer cancel
while startup runs on a blocked ASGI request thread. This removes the
`CurrentThreadExecutor already quit or is broken` exceptions Daphne
logged after container restarts.

- Runs reconnect-driven OpenCode startup in a non-thread-sensitive
worker inside the async stream instead of blocking the request thread.
- Surfaces OpenCode startup failures as an error event on the SSE stream
instead of returning an error response.
- Removes the obsolete global `ThreadSensitiveContext` monkeypatch from
`archivebox/core/asgi.py`; asgiref 3.12.1 handles non-blocking shutdown
upstream.
- Adds an ASGI regression test that holds the real startup lock,
verifies headers arrive before restart can finish, avoids starting a
background server, and joins the startup worker before teardown.

<sup>Written for commit 7d7e219962.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1859?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-02 22:26:39 -07:00
Nick Sweeting 7d7e219962 Join the SSE startup worker before test teardown 2026-09-02 22:21:52 -07:00
Nick Sweeting 24470ca551 Surface OpenCode startup failures on the event stream 2026-09-02 22:11:40 -07:00
Nick Sweeting ff2766c007 Keep SSE disconnect test from starting a background server 2026-09-02 22:10:42 -07:00
Nick Sweeting 75cf3f1261 Keep OpenCode SSE startup off ASGI request threads 2026-09-02 22:02:03 -07:00
ArchiveBox Release Bot b44ab723d2 Bump release version to 0.9.35rc416 2026-09-03 04:33:09 +00:00
Nick Sweeting ce529842e3 Stop the full OpenCode process group (#1858)
* Stop the full OpenCode process group

* Fall back when process-group signaling fails

* Guarantee fallback test process cleanup
2026-09-02 21:31:03 -07:00
ArchiveBox Release Bot 378133e44c Bump release version to 0.9.35rc415 2026-09-03 03:43:52 +00:00
Nick Sweeting 3954119a0e Keep embedded OpenCode proxy resilient (#1857)
* Keep embedded OpenCode proxy resilient

* Serialize OpenCode startup readiness

* Re-run review after dependency rebase
2026-09-02 20:43:21 -07:00
ArchiveBox Release Bot 1e53928ed7 Bump release version to 0.9.35rc414 2026-09-03 03:39:41 +00:00
ArchiveBox Release Bot 6241102ea7 Bump for abx-dl 1.12.261 2026-09-03 03:38:39 +00:00
ArchiveBox Release Bot 9a00c2f0b4 Bump release version to 0.9.35rc413 2026-09-03 02:49:30 +00:00
Nick Sweeting 4a31c409ca Avoid OpenCode proxy startup stalls (#1856)
* Avoid OpenCode proxy startup stalls

* Handle concurrent OpenCode startup and read failures

* Hide OpenCode upstream errors from clients
2026-09-02 19:49:01 -07:00
ArchiveBox Release Bot 72f6c70ffa Bump for abx-dl 1.12.260 2026-09-03 02:47:35 +00:00
ArchiveBox Release Bot 08f8baabbd Bump release version to 0.9.35rc412 2026-09-03 01:46:21 +00:00
Nick Sweeting 31bdeb4534 Allow same-origin OpenCode agent framing (#1855)
* Allow same-origin OpenCode agent framing

* Reuse OpenCode proxy prefix for framing policy
2026-09-02 18:45:51 -07:00
ArchiveBox Release Bot e7ab7b087b Bump release version to 0.9.35rc411 2026-09-03 00:25:50 +00:00
Nick Sweeting 480ad08cf0 Fix snapshot backfill orchestration regressions (#1854)
* fix snapshot backfill orchestration regressions

* address snapshot orchestration review

* fix admin process projection fixture

* fix noresult projection fixture
2026-09-02 17:25:31 -07:00
ArchiveBox Release Bot b799742f51 Bump release version to 0.9.35rc410 2026-09-02 22:19:25 +00:00
ArchiveBox Release Bot 5c34a68ad8 Bump for abx-dl 1.12.259 2026-09-02 22:19:08 +00:00
ArchiveBox Release Bot d6106a456f Bump release version to 0.9.35rc409 2026-09-02 22:07:06 +00:00
Nick Sweeting ebaeb9ea3f refactor: simplify snapshot maintenance orchestration (#1853)
## Summary

- remove archivebox update and internal pseudo crawl URL paths
- parse submitted documents directly through abx-dl and project
discovered snapshots at depth zero
- keep ArchiveBox ownership at Crawl to Snapshot while abx-dl owns
Snapshot to ArchiveResult execution
- replace queued ArchiveResult search backfills with indexed
missing-result discovery and snapshot-scoped execution that leaves
sealed snapshots sealed
- make update schedules direct maintenance dispatches instead of
synthetic crawls
- keep filesystem migrations explicit, resumable, keyset-batched, and
indexed by fs_version
- preserve live Process and ArchiveResult projection for the progress UI

## Related PRs

- ArchiveBox/abx-dl#21 provides direct input parsing and explicit
event-bus orchestration
- ArchiveBox/abx-plugins#60 removes the obsolete pseudo URL and Sonic
consolidation branches

## Verification

- abx-dl full suite: 167 passed
- ArchiveBox runner and ArchiveResult projection suite: 89 passed
- add, API, and UI input coverage: 78 passed
- schedule suite: 9 passed
- filesystem migration coverage: 9 passed
- live progress coverage: 16 passed
- real https://sweeting.me abx-dl run: 80.74 seconds, 34 succeeded, 11
noresults, 0 failed, no surviving processes
- commit hooks and diff checks passed

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Simplifies snapshot orchestration by having `abx-dl` parse crawl inputs
and run snapshot hooks directly, replacing internal pseudo-URLs and
pre-created `ArchiveResult` queues. Scheduled maintenance now dispatches
directly, while concurrent snapshot completion preserves retry work
owned by the active run.

- Store submitted URLs and imported documents directly on `Crawl`;
remove pseudo-URL and input-root snapshots, and reject multiline URL
items.
- Keep Crawl-to-Snapshot ownership in ArchiveBox while `abx-dl` owns
Snapshot-to-ArchiveResult execution.
- Discover missing indexed results and dispatch snapshot-scoped
maintenance for sealed snapshots without changing their lifecycle.
- Preserve unbounded maintenance pagination and run filesystem
migrations explicitly in resumable keyset batches using indexed
`fs_version`.
- Bound retry scheduling and correlate completion leases so stale or
concurrent completions cannot consume newer retry work.
- Preserve live `Process` and `ArchiveResult` projections for progress
reporting.

**Migration**

- Apply the database migration before rollout; it removes `current_step`
and adds the `fs_version` index.

<sup>Written for commit a29dd2edbc.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1853?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-02 15:04:47 -07:00
Nick Sweeting 0a9d8fd525 Merge remote-tracking branch 'origin/dev' into refactor/simplify-snapshot-orchestration
# Conflicts:
#	archivebox/services/runner.py
2026-09-02 15:04:37 -07:00
Nick Sweeting a29dd2edbc test: exercise keyset maintenance pagination 2026-09-02 14:51:37 -07:00
Nick Sweeting ec4b8ef2ef fix: preserve unbounded maintenance pagination 2026-09-02 14:43:13 -07:00
Nick Sweeting e878007b6a fix: bound snapshot scheduling ownership 2026-09-02 14:35:57 -07:00
ArchiveBox Release Bot ae9399b5fd Bump release version to 0.9.35rc408 2026-09-02 21:26:05 +00:00
Nick Sweeting 2e0c0f14e9 Keep UUID migration check migration-only 2026-09-02 14:25:51 -07:00
Nick Sweeting 35e014ae90 fix: correlate snapshot completion leases 2026-09-02 14:18:00 -07:00
ArchiveBox Release Bot baa3e37c59 Bump release version to 0.9.35rc407 2026-09-02 21:09:04 +00:00
Nick Sweeting b7b4f34090 Keep CI dependency roots isolated 2026-09-02 14:06:15 -07:00
ArchiveBox Release Bot ec1d3381b3 Bump release version to 0.9.35rc406 2026-09-02 21:06:05 +00:00
ArchiveBox Release Bot 410d5db1bf Bump for abx-dl 1.12.257 2026-09-02 21:05:45 +00:00
Nick Sweeting 8bc9f40e9f fix: harden concurrent snapshot retries 2026-09-02 13:52:42 -07:00
ArchiveBox Release Bot c791eabfb0 Bump release version to 0.9.35rc405 2026-09-02 20:39:29 +00:00
Nick Sweeting 1a5c6686c2 Provide build headers to LDAP docs example 2026-09-02 13:39:08 -07:00
Nick Sweeting a85d417d0f fix: preserve queued snapshot retry work 2026-09-02 13:33:21 -07:00
ArchiveBox Release Bot 68f5caaa29 Bump release version to 0.9.35rc404 2026-09-02 20:27:46 +00:00
ArchiveBox Release Bot c3b99ceece Bump for abx-dl 1.12.256 2026-09-02 20:27:27 +00:00
ArchiveBox Release Bot ac7598aae2 Bump release version to 0.9.35rc403 2026-09-02 19:56:48 +00:00
Nick Sweeting 9ee0dcf165 fix: address orchestration review findings 2026-09-02 12:56:23 -07:00
ArchiveBox Release Bot 1c3b4bc5ce Bump for abx-dl 1.12.254 2026-09-02 19:56:13 +00:00
ArchiveBox Release Bot 344dc86d83 Bump release version to 0.9.35rc402 2026-09-02 19:43:41 +00:00
Nick Sweeting 718beb3020 Limit docs build dependencies to setup examples 2026-09-02 12:43:17 -07:00
ArchiveBox Release Bot 27efbe6bb0 Bump release version to 0.9.35rc401 2026-09-02 19:31:11 +00:00
ArchiveBox Release Bot 94a9a4d444 Bump for abx-dl 1.12.252 2026-09-02 19:30:09 +00:00
ArchiveBox Release Bot 970c1adedf Bump release version to 0.9.35rc400 2026-09-02 19:28:37 +00:00
Nick Sweeting 40e6ded0f7 Scope machine service test to liteparse 2026-09-02 12:27:56 -07:00
ArchiveBox Release Bot 3416a1f707 Bump release version to 0.9.35rc399 2026-09-02 19:21:59 +00:00
Nick Sweeting 30998a0d16 Stop installing build dependencies in docs jobs 2026-09-02 12:18:30 -07:00
Nick Sweeting 809bd938a3 Preflight plugin dependencies before crawl hooks 2026-09-02 12:18:06 -07:00
Nick Sweeting 2bba758314 refactor: simplify snapshot maintenance orchestration 2026-09-02 12:05:09 -07:00
ArchiveBox Release Bot 0a8f15e130 Bump release version to 0.9.35rc398 2026-09-02 15:51:53 +00:00
ArchiveBox Release Bot cb0155f88a Bump for abx-dl 1.12.251 2026-09-02 15:51:31 +00:00
ArchiveBox Release Bot eeb47938c4 Bump release version to 0.9.35rc397 2026-09-02 15:26:39 +00:00
ArchiveBox Release Bot 1769f8b6e3 Bump for abx-dl 1.12.250 2026-09-02 15:26:21 +00:00
ArchiveBox Release Bot 71c410e791 Bump release version to 0.9.35rc396 2026-09-02 14:22:17 +00:00
ArchiveBox Release Bot 2b6f379453 Bump for abx-dl 1.12.249 2026-09-02 14:21:55 +00:00
ArchiveBox Release Bot 9e9a04bec8 Bump release version to 0.9.35rc395 2026-09-02 14:13:04 +00:00
ArchiveBox Release Bot f45f7e33a6 Bump for abx-dl 1.12.248 2026-09-02 14:12:40 +00:00
ArchiveBox Release Bot 8c0cb7499f Bump release version to 0.9.35rc394 2026-09-02 13:51:11 +00:00
ArchiveBox Release Bot bd7775e4f4 Bump for abx-dl 1.12.246 2026-09-02 13:50:49 +00:00
ArchiveBox Release Bot 8dbec73ee6 Bump release version to 0.9.35rc393 2026-09-02 13:38:01 +00:00
ArchiveBox Release Bot 8702aa33cc Bump for abx-dl 1.12.245 2026-09-02 13:37:34 +00:00
ArchiveBox Release Bot 11df983090 Bump release version to 0.9.35rc392 2026-09-02 13:15:51 +00:00
ArchiveBox Release Bot 8ed30dddb0 Bump for abx-dl 1.12.244 2026-09-02 13:15:29 +00:00
ArchiveBox Release Bot 045d7fd886 Bump release version to 0.9.35rc391 2026-09-02 12:59:18 +00:00
ArchiveBox Release Bot b4421b8f92 Bump for abx-dl 1.12.243 2026-09-02 12:58:39 +00:00
ArchiveBox Release Bot 1594184239 Bump release version to 0.9.35rc390 2026-09-02 12:34:31 +00:00
ArchiveBox Release Bot 79b958e482 Bump for abx-dl 1.12.242 2026-09-02 12:34:16 +00:00
ArchiveBox Release Bot a512884e9e Bump release version to 0.9.35rc389 2026-09-02 12:24:12 +00:00
Nick Sweeting 663017924b refactor: consume canonical abx-dl execution plans 2026-09-02 05:23:11 -07:00
ArchiveBox Release Bot b27f75647c Bump release version to 0.9.35rc388 2026-09-02 12:20:19 +00:00
ArchiveBox Release Bot 6d7d069cc0 Bump for abx-dl 1.12.241 2026-09-02 12:19:42 +00:00
ArchiveBox Release Bot f4aa4d31c5 Bump release version to 0.9.35rc387 2026-09-02 12:08:57 +00:00
Nick Sweeting 8ed80ded07 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 05:08:07 -07:00
Nick Sweeting f3a8c6945d Clarify targeted plugin maintenance contract 2026-09-02 04:59:40 -07:00
Nick Sweeting 07975eccc7 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 04:59:27 -07:00
ArchiveBox Release Bot 1af2e8f544 Bump release version to 0.9.35rc386 2026-09-02 11:59:24 +00:00
ArchiveBox Release Bot d6b1b269ca Bump for abx-dl 1.12.240 2026-09-02 11:59:08 +00:00
Nick Sweeting e7b3f1c485 Adopt canonical abx-dl execution plan API 2026-09-02 04:44:55 -07:00
Nick Sweeting 23b91d6840 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 04:34:29 -07:00
Nick Sweeting 95a7e64dd7 Centralize queued plugin execution 2026-09-02 04:34:22 -07:00
ArchiveBox Release Bot 071bc0cce6 Bump release version to 0.9.35rc385 2026-09-02 11:14:34 +00:00
Nick Sweeting 3e58261cd5 Expose yt-dlp media metadata for snapshot cards (#1819)
Supersedes #1818.

This keeps yt-dlp-specific player rendering out of ArchiveBox core. Core
now:

- exposes media type metadata to plugin card templates: `is_video`,
`is_audio`, `is_browser_playable`
- sorts yt-dlp media with browser-playable video/audio first
- prefers browser-playable yt-dlp outputs before thumbnails and
non-browser containers when choosing the primary card output
- avoids pinning resumed snapshots to stale selected hook maps, fixing a
pause/resume runner regression surfaced by CI
- makes `update --index-only` discover the configured search backend
even when `PLUGINS` is restricted for test/runtime isolation
- stabilizes CI-only recursive/takeover/public-UI tests that were
failing due external/heavy plugin coverage, hard-kill recovery races,
and transient full-server `/add/` startup responses

The actual click-to-load player UI lives in the companion plugin PR:
ArchiveBox/abx-plugins#34.

Verification:
```text
pytest archivebox/tests/test_ui_admin_snapshot.py -q
48 passed

pytest archivebox/tests/test_cli_update_reindex_snapshots.py -q
9 passed

pytest -xvs archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py --basetemp=tests/out --ignore=archivebox/pkgs
7 passed

pytest -xvs archivebox/tests/test_takeover_util.py --basetemp=tests/out --ignore=archivebox/pkgs
19 passed

pytest -xvs archivebox/tests/test_takeover_util.py::test_live_add_update_jobs_survive_server_and_cli_owner_exits --basetemp=tests/out --ignore=archivebox/pkgs
1 passed

pytest -xvs archivebox/tests/test_ui_public_snapshot.py --basetemp=tests/out --ignore=archivebox/pkgs
6 passed

pytest -q archivebox/tests/test_recursive_crawl.py::test_recursive_crawl_depth_two_all_plugins_runs_snapshots_in_parallel --basetemp=tests/out --ignore=archivebox/pkgs
1 passed
```
2026-09-02 04:14:15 -07:00
ArchiveBox Release Bot 0f1a402940 Bump release version to 0.9.35rc384 2026-09-02 11:05:26 +00:00
Nick Sweeting 5bead960b3 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 04:04:59 -07:00
Nick Sweeting d5ff47b7e6 Refresh documentation snippet inventory 2026-09-02 04:04:45 -07:00
ArchiveBox Release Bot 457e616d1d Bump release version to 0.9.35rc383 2026-09-02 11:03:09 +00:00
Nick Sweeting 7f9d9ca2c1 Remove transient ArchiveResult migration blip 2026-09-02 04:02:47 -07:00
ArchiveBox Release Bot dbb9f45f53 Bump release version to 0.9.35rc382 2026-09-02 10:50:22 +00:00
Nick Sweeting ef7d590655 Eliminate request-side SQLite write transactions (#1846)
## Summary

- replace request-side `get_or_create()` / M2M transaction boundaries
with autocommit insert-conflict resolution and guarded CAS updates
- remove the ArchiveResult upload `transaction.atomic()` /
`select_for_update()` path while preserving exact `(snapshot, plugin,
hook_name)` identity and parent output-size accounting
- keep intermediate chunk uploads filesystem-only; persist ArchiveResult
metadata once the file is complete
- reuse the same insert-conflict helpers in pending-result creation,
event projection, recovery, tag projection, and admin tag editing

## Why

Browser screenshot/MHTML uploads were colliding with the runner on
SQLite. Django helper methods opened hidden read-then-write
transactions, and every chunk rewrote ArchiveResult/Snapshot rows. A 50+
MiB upload therefore repeatedly entered the SQLite writer path and could
hit an immediate WAL read-to-write upgrade failure.

The new path uses short autocommit statements. Unique constraints
resolve create races, `modified_at` guards ArchiveResult/Snapshot
updates, and a losing writer re-reads and re-merges before retrying the
CAS. Runner lifecycle locks and event serialization remain unchanged.

## Validation

- `uv run pytest archivebox/tests/test_api_v1_core_*.py -q` (38 passed)
- focused runner/service suite (27 passed)
- `uv run pytest archivebox/tests/test_ui_admin_snapshot.py -q` (57
passed)
- tag/list/admin suites (44 passed)
- `uv run prek run --all-files`

No migrations.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Eliminates request-side SQLite write transactions so large browser
uploads no longer hit WAL read-to-write upgrade failures while the
runner holds the writer lock.

- Request-side create/update paths now use autocommit statements with
unique-constraint insert-conflict resolution and `modified_at`-guarded
compare-and-swap updates.
- ArchiveResult identity is now `(snapshot, plugin, hook_name)`;
migration `0054` drops the previously published plugin-unique constraint
so a plugin can keep several sibling hooks.
- A losing CAS writer re-reads and re-merges current state before
retrying; after three attempts the request fails with HTTP 409.
- Intermediate chunk uploads write to the filesystem only; the
`ArchiveResult` row is persisted once the upload completes, keeping the
parent snapshot's output-size accounting in sync.
- `create_snapshot` no longer blocks on the crawl lifecycle lock, and
`archivebox run` recreates exact hook requests rather than re-queueing a
plugin.
- Reused the insert-conflict helpers in pending-result creation, event
projection, recovery, tag projection, and admin tag editing; admin tag
edits now attribute new tags to the acting user.

<sup>Written for commit fbff88fb0e.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1846?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-02 03:50:03 -07:00
Nick Sweeting 0c6f017f0a Preserve ArchiveResult hook history through migrations 2026-09-02 03:49:53 -07:00
Nick Sweeting fbff88fb0e Merge remote-tracking branch 'origin/dev' into audit-pr1846
# Conflicts:
#	archivebox/cli/archivebox_extract.py
#	archivebox/core/recovery_util.py
#	archivebox/services/archive_result_service.py
#	archivebox/services/runner.py
2026-09-01 15:43:48 -07:00
ArchiveBox Release Bot 07df9c76a8 Bump release version to 0.9.35rc381 2026-09-01 22:08:21 +00:00
Nick Sweeting 97136703f6 docs: state the collection concurrency contract 2026-09-01 15:07:58 -07:00
ArchiveBox Release Bot 2f761615f0 Bump release version to 0.9.35rc380 2026-09-01 22:07:50 +00:00
ArchiveBox Release Bot 8dc91f90be Bump for abx-dl 1.12.233 2026-09-01 22:07:20 +00:00
ArchiveBox Release Bot 2af88da0d4 Bump release version to 0.9.35rc379 2026-09-01 22:07:14 +00:00
Nick Sweeting 032c20bdf5 cleanup: delete obsolete model lifecycle helpers 2026-09-01 15:06:48 -07:00
ArchiveBox Release Bot d42b530caf Bump release version to 0.9.35rc378 2026-09-01 22:04:10 +00:00
Nick Sweeting 53c300a387 cleanup: remove active crawl heartbeat plumbing 2026-09-01 15:03:29 -07:00
ArchiveBox Release Bot 0498a87884 Bump release version to 0.9.35rc377 2026-09-01 21:57:03 +00:00
ArchiveBox Release Bot e53c9cca7a Bump for abx-dl 1.12.231 2026-09-01 21:56:45 +00:00
ArchiveBox Release Bot 743ca335ad Bump release version to 0.9.35rc376 2026-09-01 21:48:47 +00:00
Nick Sweeting 2d8de8931b fix: keep transformed previews readable at full width 2026-09-01 14:46:18 -07:00
Nick Sweeting c0e09fa22a test: require readable transformed HTML previews 2026-09-01 14:46:18 -07:00
Nick Sweeting e1e5b74cc4 Merge remote-tracking branch 'origin/dev' into audit-pr1819-clean 2026-09-01 11:55:33 -07:00
Nick Sweeting 1f6c568ecc Merge remote-tracking branch 'origin/dev' into audit-pr1819-clean 2026-09-01 11:53:56 -07:00
Nick Sweeting d142a77c62 Merge remote-tracking branch 'origin/dev' into audit-pr1846 2026-09-01 11:53:49 -07:00
Nick Sweeting f9c94c2c52 Merge current dev and retain focused yt-dlp metadata changes 2026-09-01 11:49:55 -07:00
Nick Sweeting c2ce82964d Expose yt-dlp media metadata for snapshot cards 2026-09-01 11:49:16 -07:00
Nick Sweeting 5b99586c1f Preserve published ArchiveResult migration history 2026-09-01 11:44:54 -07:00
Nick Sweeting c817da7af6 Merge remote-tracking branch 'origin/dev' into audit-pr1846 2026-09-01 11:38:24 -07:00
Nick Sweeting 97486882bd Address Cubic review feedback 2026-08-28 21:56:23 -07:00
Nick Sweeting c062a3c54f Eliminate request-side SQLite write transactions 2026-08-28 21:56:18 -07:00
Nick Sweeting bdc236f076 Revert "fix: project one archive result per plugin" 2026-08-28 21:56:13 -07:00
FellowTraveler 534b467ee8 Allow staticfile recursive crawl no-response failures 2026-07-07 03:14:40 -05:00
FellowTraveler dc61f27632 Align recursive UI tests with direct URL depth 2026-07-07 03:14:40 -05:00
FellowTraveler a2b08bdb36 Fix remaining PR CI test expectations 2026-07-07 03:14:40 -05:00
FellowTraveler ffba3533b8 Fix public add import newline expectation 2026-07-07 03:13:59 -05:00
FellowTraveler 03af59da85 Fix cli remove count expectation 2026-07-07 03:13:59 -05:00
FellowTraveler 9e82159a20 Fix direct URL crawl CI expectations 2026-07-07 03:13:59 -05:00
FellowTraveler e107b42075 Fix internal input crawl maintenance tests 2026-07-07 03:13:05 -05:00
FellowTraveler 01a6ed1cd7 Use queued plugin set for targeted maintenance 2026-07-07 03:13:05 -05:00
FellowTraveler 081f39e004 Prioritize queued search maintenance 2026-07-07 03:13:05 -05:00
FellowTraveler 1bd42f37e3 Claim paused snapshots for search maintenance 2026-07-07 03:13:05 -05:00
FellowTraveler 00aa3d572f Update tests for internal crawl root 2026-07-07 03:13:05 -05:00
FellowTraveler 8e7dacf74f Fix queued plugin query construction 2026-07-07 03:13:05 -05:00
FellowTraveler a5fdb9bf7f Handle internal root crawl test cases 2026-07-07 03:13:05 -05:00
FellowTraveler bd4c585bd2 Keep crawl file input via stdin 2026-07-07 03:13:05 -05:00
FellowTraveler d08858dc03 Preserve auto parser coverage for imports 2026-07-07 03:13:05 -05:00
FellowTraveler df3e23e3e8 Queue parser hooks for internal input snapshots 2026-07-07 03:12:14 -05:00
FellowTraveler 68c4c2e7a4 Select input parser for internal snapshots at runtime 2026-07-07 03:11:14 -05:00
FellowTraveler c6c3d6742e Avoid status race in API workflow search 2026-07-07 03:10:35 -05:00
FellowTraveler cd422b201b Use stable URL filter in API workflow test 2026-07-07 03:10:35 -05:00
FellowTraveler 9618b39d6e Ensure restricted add runs input parser 2026-07-07 03:10:35 -05:00
FellowTraveler 37a5f205ca Use normalized URL in API workflow test 2026-07-07 03:10:35 -05:00
FellowTraveler 648db077e8 Fix internal input parser hook scheduling 2026-07-07 03:10:35 -05:00
FellowTraveler aa1a8fb9f3 Expose yt-dlp media metadata for snapshot cards 2026-07-07 03:10:35 -05:00
94 changed files with 3095 additions and 4122 deletions

No files matched your search

+3 -2
View File
@@ -99,9 +99,10 @@ jobs:
if [[ '${{ matrix.environment }}' != macos ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docs_binaries")
fi
if [[ '${{ matrix.environment }}' == ubuntu ]]; then
if [[ '${{ matrix.name }}' == ubuntu-AGENTS || '${{ matrix.name }}' == ubuntu-skills-archivebox-SKILL || '${{ matrix.name }}' == ubuntu-docs-Configuration || '${{ matrix.name }}' == ubuntu-docs-Setting-up-Authentication ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-build.json:required_binaries")
elif [[ '${{ matrix.environment }}' == docker ]]; then
fi
if [[ '${{ matrix.environment }}' == docker ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries")
elif [[ '${{ matrix.environment }}' == macos ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-macos-brew.json:required_binaries")
+7
View File
@@ -15,6 +15,13 @@ ArchiveBox is the full self-hosted web archiving app. Keep this repo on the `dev
- Trace root causes from observed behavior. Do not paper over failures with retries, wider timeouts, broad fallbacks, or looser assertions.
- Read `README.md` for the full setup, CLI, Docker, API, and release surface.
## Concurrency Contract
- A collection has one orchestrator at a time. Local PID/process checks may warn about obvious same-machine duplicates, but must not claim to enforce ownership across machines or shared filesystems.
- SQLite remains supported with concurrent short writes from CLI and server processes. Keep the SQLite database on a local filesystem, never NFS/SMB, and use short autocommit/CAS updates instead of long transactions or database locks.
- Network calls, hook execution, filesystem migrations, and other long work belong in the orchestrator. Never hold a database transaction or lock across that work.
- PostgreSQL and shared data directories are the path to future multi-machine scheduling. Coordinate that work through database-backed per-crawl/per-snapshot claims; do not introduce file leases or timer-based orchestrator election.
## Development Setup
```bash
+2 -2
View File
@@ -10,10 +10,10 @@
# --build-context abx-plugins=../abx-plugins \
# -t archivebox/abx-dl:dev
# docker buildx build . -f Dockerfile \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.229 \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.261 \
# -t archivebox:multistage
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.229
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.261
FROM archivebox/sonic:1.4.9 AS sonic
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
+10 -1
View File
@@ -133,6 +133,15 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
validate_url(args.urls[0])
except ValueError:
submitted_urls = args.urls[0]
else:
submitted_urls = []
for url in args.urls:
try:
submitted_urls.append(validate_url(url))
except ValueError:
continue
if not submitted_urls:
raise HttpError(400, "No valid URLs were submitted")
crawl, snapshots = add(
urls=submitted_urls,
snapshot_ids=args.snapshot_ids,
@@ -156,7 +165,7 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
"crawl_id": str(crawl.id),
"num_snapshots": len(snapshot_ids),
"snapshot_ids": snapshot_ids,
"queued_urls": args.urls,
"queued_urls": args.urls if isinstance(submitted_urls, str) else submitted_urls,
}
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
+117 -118
View File
@@ -11,7 +11,7 @@ from uuid import UUID
from typing import Union, Any, Annotated
from datetime import datetime, time
from django.db import transaction
from django.db import IntegrityError
from django.db.models import Model, Q
from django.http import HttpRequest, HttpResponse
from django.http.multipartparser import MultiPartParser, MultiPartParserError
@@ -517,52 +517,53 @@ def create_archiveresult(
normalized_status = ArchiveResult.normalize_status(status)
parsed_output_json = _parse_archiveresult_output_json(output_json)
hook = hook_name or ARCHIVERESULT_UPLOAD_HOOK_NAME
existing_result = ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin_name).first()
existing_output_files = dict(existing_result.output_files or {}) if existing_result else {}
output_files = _write_archiveresult_files(
result_lookup = {
"snapshot": snapshot,
"plugin": plugin_name,
"hook_name": hook,
}
uploaded_output_files = _write_archiveresult_files(
request,
snapshot,
plugin_name,
existing_output_files=existing_output_files,
allow_empty=True,
)
now = timezone.now()
with transaction.atomic():
Snapshot.objects.select_for_update().get(pk=snapshot.pk)
existing_result = ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin_name).first()
if existing_result:
output_files = {
**dict(existing_result.output_files or {}),
**output_files,
}
result = existing_result
else:
existing_result = None
result = ArchiveResult(
snapshot=snapshot,
plugin=plugin_name,
hook_name=hook,
)
if (
existing_result
and normalized_status == ArchiveResult.StatusChoices.STARTED
and existing_result.status != ArchiveResult.StatusChoices.STARTED
):
normalized_status = existing_result.status
result = ArchiveResult.objects.filter(**result_lookup).first()
for _attempt in range(3):
output_files = {
**(result.output_file_map() if result else {}),
**uploaded_output_files,
}
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
output_file_paths = list(output_files.keys())
result.status = normalized_status
result.hook_name = hook
result.output_str = output_str or (output_file_paths[0] if output_file_paths else "")
result.output_json = parsed_output_json
result.output_files = output_files
result.output_size = output_size
result.output_mimetypes = output_mimetypes
result.start_ts = result.start_ts or now
result.end_ts = now
result.save()
result_status = normalized_status
if result and result_status == ArchiveResult.StatusChoices.STARTED and result.status != ArchiveResult.StatusChoices.STARTED:
result_status = result.status
now = timezone.now()
values = {
"status": result_status,
"output_str": output_str or (output_file_paths[0] if output_file_paths else ""),
"output_json": parsed_output_json,
"output_files": output_files,
"output_size": output_size,
"output_mimetypes": output_mimetypes,
"start_ts": result.start_ts or now if result else now,
"end_ts": now,
}
if result:
if result.safe_update(values):
break
continue
result, created = ArchiveResult.get_or_create_by_hook(
snapshot,
plugin_name,
hook,
defaults=values,
)
if created:
break
else:
raise HttpError(409, "ArchiveResult changed while upload metadata was being updated")
if result.status != ArchiveResult.StatusChoices.STARTED:
_queue_archiveresult_snapshot_maintenance(snapshot)
@@ -576,44 +577,46 @@ def patch_archiveresult(
):
"""Append or replace files on an existing ArchiveResult."""
result = ArchiveResult.objects.select_related("snapshot__crawl__created_by").get(_uuid_ref_query("id", archiveresult_id))
output_files = _write_archiveresult_files(
uploaded_output_files = _write_archiveresult_files(
request,
result.snapshot,
result.plugin,
existing_output_files=result.output_file_map(),
)
latest_result = ArchiveResult.objects.only("output_files", "status").get(pk=result.pk)
output_files = {
**latest_result.output_file_map(),
**output_files,
}
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
update_fields = ["output_files", "output_size", "output_mimetypes", "end_ts", "modified_at"]
result.output_files = output_files
result.output_size = output_size
result.output_mimetypes = output_mimetypes
result.end_ts = timezone.now()
output_str = _get_archiveresult_upload_form_value(request, "output_str")
status = _get_archiveresult_upload_form_value(request, "status")
output_json = _get_archiveresult_upload_form_value(request, "output_json")
if output_str:
result.output_str = output_str
update_fields.append("output_str")
if status:
normalized_status = ArchiveResult.normalize_status(status)
if normalized_status == ArchiveResult.StatusChoices.STARTED and latest_result.status != ArchiveResult.StatusChoices.STARTED:
normalized_status = latest_result.status
result.status = normalized_status
update_fields.append("status")
elif latest_result.status == ArchiveResult.StatusChoices.QUEUED and ArchiveResult.output_files_upload_complete(output_files):
result.status = ArchiveResult.StatusChoices.SUCCEEDED
update_fields.append("status")
if output_json:
result.output_json = _parse_archiveresult_output_json(output_json)
update_fields.append("output_json")
parsed_output_json = _parse_archiveresult_output_json(output_json) if output_json else None
if not ArchiveResult.output_files_upload_complete(uploaded_output_files):
result.output_files = {**result.output_file_map(), **uploaded_output_files}
result.output_size, result.output_mimetypes = _summarize_archiveresult_output_files(result.output_files)
return result
for _attempt in range(3):
output_files = {**result.output_file_map(), **uploaded_output_files}
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
values: dict[str, Any] = {
"output_files": output_files,
"output_size": output_size,
"output_mimetypes": output_mimetypes,
"end_ts": timezone.now(),
}
if output_str:
values["output_str"] = output_str
if status:
normalized_status = ArchiveResult.normalize_status(status)
if normalized_status == ArchiveResult.StatusChoices.STARTED and result.status != ArchiveResult.StatusChoices.STARTED:
normalized_status = result.status
values["status"] = normalized_status
elif result.status == ArchiveResult.StatusChoices.QUEUED:
values["status"] = ArchiveResult.StatusChoices.SUCCEEDED
if output_json:
values["output_json"] = parsed_output_json
if result.safe_update(values):
break
else:
raise HttpError(409, "ArchiveResult changed while upload metadata was being updated")
result.save(update_fields=update_fields)
if result.status != ArchiveResult.StatusChoices.STARTED:
_queue_archiveresult_snapshot_maintenance(result.snapshot)
@@ -930,52 +933,47 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
created_by=request.user if isinstance(request.user, User) else None,
)
# Browser clients sync metadata and upload capture artifacts immediately
# after queueing a URL. The runner holds the crawl lifecycle lock for the
# whole crawl, so idempotently posting an already-created snapshot must not
# wait behind that potentially long-running archive job.
existing_snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
if existing_snapshot is not None and (status is None or existing_snapshot.status == status):
if data.title is not None and existing_snapshot.title != data.title:
Snapshot.objects.filter(pk=existing_snapshot.pk).update(title=data.title, modified_at=timezone.now())
existing_snapshot.title = data.title
if tags:
existing_snapshot.save_tags(tags)
setattr(request, "with_archiveresults", False)
return existing_snapshot
# Browser uploads must not wait behind the runner's crawl-wide lifecycle
# lock. The unique insert recovery and CAS update below are the request-side
# coordination boundary for this idempotent metadata sync.
snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
if snapshot is None:
try:
snapshot = Snapshot.objects.create(
url=data.url,
crawl=crawl,
depth=data.depth,
title=data.title,
timestamp=str(timezone.now().timestamp()),
status=status or Snapshot.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
except IntegrityError:
snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
if snapshot is None:
raise
with crawl_lifecycle_lock(str(crawl.id)):
snapshot_defaults = {
"depth": data.depth,
"title": data.title,
"timestamp": str(timezone.now().timestamp()),
"status": status or Snapshot.StatusChoices.QUEUED,
"retry_at": timezone.now(),
}
snapshot, _ = Snapshot.objects.get_or_create(
url=data.url,
crawl=crawl,
defaults=snapshot_defaults,
for _attempt in range(3):
updates: dict[str, Any] = {}
if data.title is not None and snapshot.title != data.title:
updates["title"] = data.title
if status is not None and snapshot.status != status:
updates["status"] = status
if not updates or snapshot.safe_update(updates, extra_filter={"modified_at": snapshot.modified_at}):
break
else:
raise HttpError(409, "Snapshot changed while metadata was being updated")
if tags:
snapshot.save_tags(
tags,
created_by=request.user if isinstance(request.user, User) else None,
)
update_fields: list[str] = []
if data.title is not None and snapshot.title != data.title:
snapshot.title = data.title
update_fields.append("title")
if status is not None and snapshot.status != status:
snapshot.status = status
update_fields.append("status")
if update_fields:
update_fields.append("modified_at")
snapshot.save(update_fields=update_fields)
if tags:
snapshot.save_tags(tags)
try:
snapshot.ensure_crawl_symlink()
except Exception:
pass
try:
snapshot.ensure_crawl_symlink()
except Exception:
pass
setattr(request, "with_archiveresults", False)
return snapshot
@@ -1023,7 +1021,10 @@ def patch_snapshot(request: HttpRequest, snapshot_id: str, data: SnapshotUpdateS
update_fields.append("retry_at")
if tags is not None:
snapshot.save_tags(normalize_tag_list(tags))
snapshot.save_tags(
normalize_tag_list(tags),
created_by=request.user if isinstance(request.user, User) else None,
)
if payload.get("status") == Snapshot.StatusChoices.SEALED:
snapshot.cancel()
@@ -1410,8 +1411,7 @@ def tags_add_to_snapshot(request: HttpRequest, data: TagSnapshotRequestSchema):
else:
raise HttpError(400, "Either tag_name or tag_id is required")
# Add the tag to the snapshot
snapshot.tags.add(tag.pk)
snapshot.add_tag_ids([tag.pk])
return {
"success": True,
@@ -1439,8 +1439,7 @@ def tags_remove_from_snapshot(request: HttpRequest, data: TagSnapshotRequestSche
else:
raise HttpError(400, "Either tag_name or tag_id is required")
# Remove the tag from the snapshot
snapshot.tags.remove(tag.pk)
snapshot.remove_tag_ids([tag.pk])
return {
"success": True,
+7 -4
View File
@@ -1,6 +1,5 @@
__package__ = "archivebox.api"
import json
from pathlib import Path
from uuid import UUID
from datetime import datetime
@@ -23,7 +22,7 @@ from archivebox.core.permissions import (
)
from archivebox.config.common import get_config
from archivebox.crawls.models import Crawl
from archivebox.misc.util import filter_queryset_by_uuid_substring
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url
from .auth import API_AUTH_METHODS, authenticated_user_from_request
@@ -113,7 +112,10 @@ def get_crawls(request: HttpRequest):
@router.post("/crawls", response=CrawlSchema, url_name="create_crawl")
def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
urls = [url.strip() for url in data.urls if url and url.strip()]
try:
urls = [validate_url(url) for url in data.urls if url and url.strip()]
except ValueError as err:
raise HttpError(400, str(err)) from err
if not urls:
raise HttpError(400, "At least one URL is required")
if data.max_depth not in (0, 1, 2, 3, 4):
@@ -121,9 +123,10 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
tags = normalize_tag_list(data.tags, data.tags_str)
config = dict(data.config or {})
config.setdefault("PARSER", "url_list")
config.setdefault("PERMISSIONS", str(get_config().PERMISSIONS))
crawl = Crawl.objects.create(
urls="\n".join(json.dumps({"type": "CrawlSeed", "url": url, "depth": 0}, separators=(",", ":")) for url in urls),
urls="\n".join(urls),
max_depth=data.max_depth,
tags_str=",".join(tags),
label=data.label,
+14 -31
View File
@@ -6,7 +6,6 @@ __command__ = "archivebox add"
import sys
import os
import json
from pathlib import Path
from typing import Any, TYPE_CHECKING
@@ -71,17 +70,7 @@ def add(
created_by_id: int | None = None,
config: dict[str, Any] | None = None,
) -> tuple[Crawl, QuerySet[Snapshot]]:
"""Add a new URL or list of URLs to your archive.
The flow is:
1. Save URLs to sources file
2. Create Crawl with URLs and max_depth
3. Crawl runner creates Snapshots from Crawl URLs (depth=0)
4. Crawl runner runs parser extractors on root snapshots
5. Parser extractors output to urls.jsonl
6. URLs are added to Crawl.urls and child Snapshots are created
7. Repeat until max_depth is reached
"""
"""Add a URL list or imported URL document to a new Crawl."""
from rich import print
@@ -125,12 +114,12 @@ def add(
created_by_id = created_by_id or get_or_create_system_user_pk()
started_at = timezone.now()
use_internal_input_root = isinstance(urls, str)
source_text = (
urls
if use_internal_input_root
else "\n".join(json.dumps({"type": "CrawlSeed", "url": str(url), "depth": 0}, separators=(",", ":")) for url in urls)
)
if isinstance(urls, str):
source_text = urls
else:
from archivebox.misc.util import validate_url
source_text = "\n".join(validate_url(str(url)) for url in urls)
# 2. Create a new Crawl with inline URLs
# Foreground add must claim runner ownership before publishing runnable
@@ -187,12 +176,7 @@ def add(
try:
crawl = Crawl.objects.create(
urls=source_text,
# Stdin/import text gets an extra hop because the synthetic
# archivebox://internal root lives at depth 0 and parser-discovered
# URLs land at depth 1; direct URL args become the depth=0 input
# snapshots themselves so --depth=N matches the deepest hop the user
# asked for.
max_depth=depth + 1 if use_internal_input_root else depth,
max_depth=depth,
tags_str=tag,
persona_id=persona_obj.id,
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
@@ -213,9 +197,8 @@ def add(
print(f"[green]\\[+] Created Crawl {crawl.id} with max_depth={depth}[/green]")
print(f" [dim]First URL: {first_url}[/dim]")
# 3. The runner will create Snapshots from all URLs after claiming the Crawl
# Parser extractors run on snapshots and discover more URLs
# Discovered URLs become child Snapshots (depth+1)
# The runner parses Crawl.urls after claiming the Crawl, then persists the
# resulting depth-0 Snapshot facts. Recursive discoveries start at depth 1.
if index_only:
print("[yellow]\\[*] Index-only mode - URLs queued, runner not started[/yellow]")
@@ -421,13 +404,13 @@ def main(**kwargs):
if only_new is not None:
kwargs["config"] = {"ONLY_NEW": bool(only_new)}
if extract:
from abx_dl.models import discover_plugins, plugins_matching_output
from archivebox.plugins.discovery import get_plugin_catalog
all_plugins = discover_plugins()
catalog = get_plugin_catalog()
tokens = [token.strip() for token in extract.split(",") if token.strip()]
plugin_names = {name.lower(): name for name in all_plugins}
plugin_names = {name.lower(): name for name in catalog}
selected = [plugin_names[token.lower()] for token in tokens if token.lower() in plugin_names]
selected += plugins_matching_output(all_plugins, tokens)
selected += catalog.matching_output(tokens)
if not selected:
raise click.UsageError(f"No plugins found matching extract types: {extract}")
existing = [token.strip() for token in (kwargs.get("plugins") or "").split(",") if token.strip()]
+18 -22
View File
@@ -6,13 +6,13 @@ archivebox archiveresult <action> [args...] [--filters]
Manage ArchiveResult records (plugin extraction results).
Actions:
create - Create ArchiveResults for Snapshots (queue extractions)
create - Emit plugin extraction request records for Snapshots
list - List ArchiveResults as JSONL (with optional filters)
update - Update ArchiveResults from stdin JSONL
delete - Delete ArchiveResults from stdin JSONL
Examples:
# Create ArchiveResults for snapshots (queue for extraction)
# Emit extraction requests; `archivebox run` schedules their parent snapshots
archivebox snapshot list --status=queued | archivebox archiveresult create
archivebox archiveresult create --plugin=screenshot --snapshot-id=<uuid>
@@ -20,9 +20,6 @@ Examples:
archivebox archiveresult list --status=failed
archivebox archiveresult list --plugin=screenshot --status=succeeded
# Update (reset failed extractions to queued)
archivebox archiveresult list --status=failed | archivebox archiveresult update --status=queued
# Delete
archivebox archiveresult list --plugin=singlefile | archivebox archiveresult delete --yes
@@ -67,7 +64,7 @@ def create_archiveresults(
Reads Snapshot records from stdin and emits ArchiveResult request JSONL.
Pass-through: Non-Snapshot/ArchiveResult records are output unchanged.
If --plugin is specified, only emits requests for that plugin.
Otherwise, emits one request for each enabled Snapshot plugin.
Otherwise, emits requests for all enabled snapshot hooks.
Exit codes:
0: Success
@@ -144,19 +141,20 @@ def create_archiveresults(
created_count = 0
for snapshot in snapshots:
if plugin:
config = get_config(crawl=snapshot.crawl, snapshot=snapshot)
hooks = [
hook
for hook in discover_hooks("Snapshot", filter_disabled=not plugin, config=config)
if not plugin or hook.parent.name == plugin
]
for hook_path in hooks:
hook_name = hook_path.stem
plugin_name = hook_path.parent.name
if not is_tty:
write_record(build_archiveresult_request(snapshot.id, plugin, status=status))
write_record(build_archiveresult_request(snapshot.id, plugin_name, hook_name=hook_name, status=status))
created_count += 1
else:
config = get_config(crawl=snapshot.crawl, snapshot=snapshot)
hooks = discover_hooks("Snapshot", filter_disabled=True, config=config)
for plugin_name in dict.fromkeys(hook_path.parent.name for hook_path in hooks):
if not is_tty:
write_record(build_archiveresult_request(snapshot.id, plugin_name, status=status))
created_count += 1
rprint(f"[green]Created {created_count} archive result request records[/green]", file=sys.stderr)
rprint(f"[green]Created {created_count} extraction request records[/green]", file=sys.stderr)
return 0
@@ -254,11 +252,9 @@ def update_archiveresults(
# Apply updates from CLI flags
if status:
if status == ArchiveResult.StatusChoices.QUEUED:
result.reset_for_retry()
else:
result.status = status
result.save(update_fields=["status", "modified_at"])
result.status = status
result.save()
updated_count += 1
if not is_tty:
@@ -342,7 +338,7 @@ def main():
@click.option("--plugin", "-p", help="Plugin name (e.g., screenshot, singlefile)")
@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
def create_cmd(snapshot_id: str | None, plugin: str | None, status: str):
"""Create ArchiveResults for Snapshots from stdin JSONL."""
"""Emit Snapshot plugin extraction requests as JSONL."""
sys.exit(create_archiveresults(snapshot_id=snapshot_id, plugin=plugin, status=status))
+136 -388
View File
@@ -1,95 +1,135 @@
#!/usr/bin/env python3
"""
archivebox extract [snapshot_ids...] [--plugins=NAMES]
Run plugins on Snapshots. Accepts snapshot IDs as arguments, from stdin, or via JSONL.
Input formats:
- Snapshot UUIDs (one per line)
- JSONL: {"type": "Snapshot", "id": "...", "url": "..."}
- JSONL: {"type": "ArchiveResult", "snapshot_id": "...", "plugin": "..."}
Output (JSONL):
{"type": "ArchiveResult", "id": "...", "snapshot_id": "...", "plugin": "...", "status": "..."}
Examples:
# Extract specific snapshot
archivebox extract 01234567-89ab-cdef-0123-456789abcdef
# Pipe from snapshot command
archivebox snapshot https://example.com | archivebox extract
# Run specific plugins only
archivebox extract --plugins=screenshot,singlefile 01234567-89ab-cdef-0123-456789abcdef
# Chain commands
archivebox crawl https://example.com | archivebox snapshot | archivebox extract
"""
"""Run abx-dl snapshot hooks for existing ArchiveBox snapshots."""
__package__ = "archivebox.cli"
__command__ = "archivebox extract"
import sys
from collections import defaultdict
from itertools import product
from contextlib import redirect_stdout
import rich_click as click
def process_archiveresult_by_id(archiveresult_id: str) -> int:
"""
Re-run extraction for a single ArchiveResult by ID.
def _resolve_requests(records: list[dict], plugins: str) -> tuple[dict[str, set[str]], set[str]]:
"""Resolve CLI records to snapshot-level execution requests.
ArchiveResults are projected status rows, not queued work items. Re-running
a single result means resetting that row and queueing its parent snapshot
through the shared crawl runner with the corresponding plugin selected.
ArchiveResult input is accepted as a convenient reference to its parent
Snapshot and plugin. It is never reset or converted into queued work.
"""
from rich import print as rprint
from archivebox.core.models import ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.misc.jsonl import TYPE_ARCHIVERESULT
explicit_plugins = {name.strip() for name in plugins.split(",") if name.strip()}
requested: dict[str, set[str]] = defaultdict(set)
missing: set[str] = set()
for record in records:
record_type = record.get("type")
record_id = str(record.get("id") or "")
if record_type == TYPE_ARCHIVERESULT and record.get("snapshot_id"):
requested[str(record["snapshot_id"])].update(explicit_plugins or {str(record.get("plugin") or "")})
requested[str(record["snapshot_id"])].discard("")
continue
if record_type == TYPE_ARCHIVERESULT:
result = ArchiveResult.objects.filter(_uuid_ref_query("id", record_id)).only("snapshot_id", "plugin").first()
if result is not None:
requested[str(result.snapshot_id)].update(explicit_plugins or {result.plugin})
continue
snapshot_id = str(record.get("snapshot_id") or record_id or "")
snapshot = Snapshot.objects.filter(id=snapshot_id).only("id").first() if snapshot_id else None
if snapshot is None and record.get("url"):
snapshot = Snapshot.objects.filter(url=record["url"]).order_by("-created_at").only("id").first()
# Bare UUID CLI arguments are parsed as Snapshot records because the
# input layer cannot know which model owns them. Preserve the public
# convenience of passing an ArchiveResult ID by trying that reference
# only after the Snapshot lookup misses.
if snapshot is None and record_id and not record.get("url"):
result = ArchiveResult.objects.filter(_uuid_ref_query("id", record_id)).only("snapshot_id", "plugin").first()
if result is not None:
requested[str(result.snapshot_id)].update(explicit_plugins or {result.plugin})
continue
if snapshot is None:
missing.add(snapshot_id or str(record.get("url") or ""))
continue
requested[str(snapshot.id)].update(explicit_plugins)
return requested, missing
def _run_snapshot_requests(requested: dict[str, set[str]], *, wait: bool, show_progress: bool) -> int:
from django.utils import timezone
from rich import print as rprint
from archivebox.core.models import Snapshot
from archivebox.services.runner import run_crawl
try:
archiveresult = ArchiveResult.objects.get(_uuid_ref_query("id", archiveresult_id))
except ArchiveResult.DoesNotExist:
rprint(f"[red]ArchiveResult {archiveresult_id} not found[/red]", file=sys.stderr)
snapshots = {str(snapshot.id): snapshot for snapshot in Snapshot.objects.filter(id__in=requested).select_related("crawl")}
if not snapshots:
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
return 1
rprint(f"[blue]Extracting {archiveresult.plugin} for {archiveresult.snapshot.url}[/blue]", file=sys.stderr)
from archivebox.config.common import get_config
from archivebox.plugins.discovery import get_enabled_plugins
try:
archiveresult.reset_for_retry()
snapshot = archiveresult.snapshot
snapshot.queue_for_extraction()
crawl = snapshot.crawl
if not crawl.claim_processing_lock(lock_seconds=10):
rprint(
f"[yellow]Crawl {crawl.id} is already owned by another runner[/yellow]",
file=sys.stderr,
)
return 1
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is not None and not plugin_names:
plugin_names.update(get_enabled_plugins(config=get_config(crawl=snapshot.crawl, snapshot=snapshot)))
run_crawl(str(snapshot.crawl_id), snapshot_ids=[str(snapshot.id)], selected_plugins=[archiveresult.plugin])
archiveresult.refresh_from_db()
if wait and any("search_backend_sonic" in plugin_names for plugin_names in requested.values()):
from archivebox.core.takeover_util import ensure_daemon_stack
if archiveresult.status == ArchiveResult.StatusChoices.SUCCEEDED:
print(f"[green]Extraction succeeded: {archiveresult.output_str}[/green]")
return 0
elif archiveresult.status == ArchiveResult.StatusChoices.NORESULTS:
print(f"[dim]Extraction completed with no results: {archiveresult.output_str}[/dim]")
return 0
elif archiveresult.status == ArchiveResult.StatusChoices.FAILED:
print(f"[red]Extraction failed: {archiveresult.output_str}[/red]", file=sys.stderr)
return 1
else:
# Still in progress or backoff - not a failure
print(f"[yellow]Extraction status: {archiveresult.status}[/yellow]")
return 0
with redirect_stdout(sys.stderr):
ensure_daemon_stack(reason="Sonic snapshot indexing")
except Exception as e:
print(f"[red]Extraction error: {type(e).__name__}: {e}[/red]", file=sys.stderr)
return 1
# Explicit extraction resumes open/paused snapshots at the snapshot level.
# Sealed snapshots stay sealed during targeted maintenance backfills.
if wait:
for snapshot in snapshots.values():
if snapshot.status != Snapshot.StatusChoices.SEALED:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
if not wait:
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is None:
continue
if plugin_names:
snapshot.schedule_plugin_run(plugin_names, when=timezone.now())
elif snapshot.status == Snapshot.StatusChoices.SEALED:
snapshot.update_and_requeue(retry_at=timezone.now())
else:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
if show_progress:
rprint(f"[blue]Queued {len(snapshots)} snapshots for extraction[/blue]", file=sys.stderr)
return 0
grouped: dict[tuple[str, tuple[str, ...]], list[str]] = defaultdict(list)
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is not None:
grouped[(str(snapshot.crawl_id), tuple(sorted(plugin_names)))].append(snapshot_id)
for (crawl_id, plugin_names), snapshot_ids in grouped.items():
run_crawl(
crawl_id,
snapshot_ids=sorted(snapshot_ids),
selected_plugins=list(plugin_names) or None,
show_progress=show_progress,
)
return 0
def run_plugins(
@@ -99,356 +139,64 @@ def run_plugins(
wait: bool = True,
emit_results: bool = True,
show_progress: bool = True,
preserve_queued: bool = False,
) -> int:
"""
Run plugins on Snapshots from input.
Reads Snapshot IDs or JSONL from args/stdin, runs plugins, outputs JSONL.
Exit codes:
0: Success
1: Failure
"""
"""Execute selected plugins through the snapshot-level runner."""
from rich import print as rprint
from django.utils import timezone
from archivebox.misc.jsonl import (
read_args_or_stdin,
write_record,
TYPE_SNAPSHOT,
TYPE_ARCHIVERESULT,
)
from archivebox.core.models import Snapshot
from archivebox.core.models import ArchiveResult
from archivebox.services.runner import run_crawl
from archivebox.misc.jsonl import read_args_or_stdin, write_record
is_tty = sys.stdout.isatty()
# Parse comma-separated plugins list once (reused in creation and filtering)
plugins_list = [p.strip() for p in plugins.split(",") if p.strip()] if plugins else []
# Parse stdin/args exactly once per CLI invocation.
# `main()` may already have consumed stdin to distinguish Snapshot input from
# ArchiveResult IDs; if so, it must pass the parsed records through here
# instead of asking this helper to reread an already-drained pipe.
if records is None:
records = list(read_args_or_stdin(args))
if not records:
rprint("[yellow]No snapshots provided. Pass snapshot IDs as arguments or via stdin.[/yellow]", file=sys.stderr)
return 1
# Gather snapshot IDs and optional plugin constraints to process
snapshot_ids = set()
requested_plugins_by_snapshot: dict[str, set[str]] = defaultdict(set)
for record in records:
record_type = record.get("type")
if record_type == TYPE_SNAPSHOT:
snapshot_id = record.get("id")
if snapshot_id:
snapshot_ids.add(str(snapshot_id))
elif record.get("url"):
# Look up by URL (get most recent if multiple exist)
snap = Snapshot.objects.filter(url=record["url"]).order_by("-created_at").first()
if snap:
snapshot_ids.add(str(snap.id))
else:
rprint(f"[yellow]Snapshot not found for URL: {record['url']}[/yellow]", file=sys.stderr)
elif record_type == TYPE_ARCHIVERESULT:
snapshot_id = record.get("snapshot_id")
if snapshot_id:
snapshot_ids.add(str(snapshot_id))
plugin_name = record.get("plugin")
if plugin_name and not plugins_list:
requested_plugins_by_snapshot[str(snapshot_id)].add(str(plugin_name))
elif "id" in record:
# Assume it's a snapshot ID
snapshot_ids.add(str(record["id"]))
if not snapshot_ids:
rprint("[red]No valid snapshot IDs found in input[/red]", file=sys.stderr)
requested, missing = _resolve_requests(records, plugins)
for value in sorted(missing):
rprint(f"[yellow]Snapshot or ArchiveResult not found: {value}[/yellow]", file=sys.stderr)
if not requested:
return 1
existing_snapshots = list(Snapshot.objects.filter(id__in=snapshot_ids).values_list("id", "crawl_id"))
existing_snapshot_ids = {str(snapshot_id) for snapshot_id, _crawl_id in existing_snapshots}
existing_crawl_ids = {str(crawl_id) for _snapshot_id, crawl_id in existing_snapshots}
missing_snapshot_ids = sorted(str(snapshot_id) for snapshot_id in snapshot_ids - existing_snapshot_ids)
for snapshot_id in missing_snapshot_ids:
rprint(f"[yellow]Snapshot {snapshot_id} not found[/yellow]", file=sys.stderr)
# Queue only the target plugin rows. Bulk updates keep large reindex runs
# from doing one SELECT+UPDATE per snapshot/plugin before hooks even start.
requested_pairs: set[tuple[str, str]] = set()
if plugins_list:
requested_pairs.update((snapshot_id, plugin_name) for snapshot_id, plugin_name in product(existing_snapshot_ids, plugins_list))
else:
requested_pairs.update(
(snapshot_id, plugin_name)
for snapshot_id, plugin_names in requested_plugins_by_snapshot.items()
if snapshot_id in existing_snapshot_ids
for plugin_name in plugin_names
)
queued_rows: set[tuple[str, str]] = set()
if preserve_queued and requested_pairs:
queued_rows = {
(str(snapshot_id), plugin_name)
for snapshot_id, plugin_name in ArchiveResult.objects.filter(
snapshot_id__in=existing_snapshot_ids,
plugin__in={plugin_name for _snapshot_id, plugin_name in requested_pairs},
status=ArchiveResult.StatusChoices.QUEUED,
).values_list("snapshot_id", "plugin")
}
rows_to_queue = requested_pairs - queued_rows
reset_fields = {
"status": ArchiveResult.StatusChoices.QUEUED,
"hook_name": "",
"output_str": "",
"output_json": None,
"output_files": {},
"output_size": 0,
"output_mimetypes": "",
"start_ts": None,
"end_ts": None,
"modified_at": timezone.now(),
}
if rows_to_queue and plugins_list:
rows_to_reset_by_plugin: dict[str, set[str]] = defaultdict(set)
for snapshot_id, plugin_name in rows_to_queue:
rows_to_reset_by_plugin[plugin_name].add(snapshot_id)
for plugin_name, plugin_snapshot_ids in rows_to_reset_by_plugin.items():
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name).update(
**reset_fields,
)
elif rows_to_queue and requested_plugins_by_snapshot:
snapshot_ids_by_plugin: dict[str, set[str]] = defaultdict(set)
for snapshot_id, plugin_name in rows_to_queue:
snapshot_ids_by_plugin[plugin_name].add(snapshot_id)
for plugin_name, plugin_snapshot_ids in snapshot_ids_by_plugin.items():
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name).update(
**reset_fields,
)
existing_rows = (
{
(str(snapshot_id), plugin_name)
for snapshot_id, plugin_name in ArchiveResult.objects.filter(
snapshot_id__in=existing_snapshot_ids,
plugin__in={plugin_name for _snapshot_id, plugin_name in rows_to_queue},
).values_list("snapshot_id", "plugin")
}
if rows_to_queue
else set()
)
missing_rows = rows_to_queue - existing_rows
if missing_rows:
ArchiveResult.objects.bulk_create(
[
ArchiveResult(
snapshot_id=snapshot_id,
plugin=plugin_name,
hook_name="",
status=ArchiveResult.StatusChoices.QUEUED,
)
for snapshot_id, plugin_name in sorted(missing_rows)
],
batch_size=500,
)
processed_count = len(existing_snapshot_ids)
queue_at = timezone.now()
if existing_snapshot_ids:
if requested_pairs:
# Search indexing on a sealed Snapshot is the only targeted plugin
# allowed to bypass the normal lifecycle. Every other requested
# plugin requeues its Snapshot through the unified lifecycle.
affected_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name in rows_to_queue}
if preserve_queued and queued_rows:
queued_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name in queued_rows}
affected_snapshot_ids.update(
str(snapshot_id)
for snapshot_id in Snapshot.objects.filter(id__in=queued_snapshot_ids)
.filter(retry_at__gt=queue_at)
.values_list("id", flat=True)
)
affected_snapshot_ids.update(
str(snapshot_id)
for snapshot_id in Snapshot.objects.filter(id__in=queued_snapshot_ids, retry_at__isnull=True).values_list(
"id",
flat=True,
)
)
requested_plugins_by_id: dict[str, set[str]] = defaultdict(set)
for snapshot_id, plugin_name in requested_pairs:
requested_plugins_by_id[snapshot_id].add(plugin_name)
for snapshot in Snapshot.objects.filter(id__in=affected_snapshot_ids).only("id", "status", "modified_at"):
# Guard the read-time status so we never bump retry_at on a
# row that's been re-queued / started by a concurrent runner.
plugin_names = requested_plugins_by_id.get(str(snapshot.id), set())
sealed_search_backfill = (
snapshot.status == Snapshot.StatusChoices.SEALED
and plugin_names
and all(plugin_name.startswith("search_backend_") for plugin_name in plugin_names)
)
if sealed_search_backfill:
snapshot.safe_update(
{"retry_at": queue_at, "modified_at": queue_at},
refresh=False,
extra_filter={"status": snapshot.status},
)
else:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=queue_at,
current_step=0,
)
else:
# No plugin rows were requested, so this is a full snapshot retry.
for snapshot in Snapshot.objects.filter(id__in=existing_snapshot_ids).only("id", "status", "retry_at", "modified_at"):
snapshot.safe_update(
{
"status": Snapshot.StatusChoices.QUEUED,
"retry_at": queue_at,
"current_step": 0,
"modified_at": queue_at,
},
refresh=False,
extra_filter={"status": snapshot.status},
)
if existing_crawl_ids and not requested_pairs:
from archivebox.crawls.models import Crawl
for crawl in Crawl.objects.filter(id__in=existing_crawl_ids).only("id", "status", "retry_at", "modified_at"):
update_fields = {
"retry_at": queue_at,
"modified_at": queue_at,
}
if crawl.status != Crawl.StatusChoices.STARTED:
update_fields["status"] = Crawl.StatusChoices.QUEUED
crawl.safe_update(
update_fields,
refresh=False,
extra_filter={"status": crawl.status},
)
if processed_count == 0:
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
return 1
if show_progress:
rprint(f"[blue]Queued {processed_count} snapshots for extraction[/blue]", file=sys.stderr)
# Run orchestrator if --wait (default)
if wait:
if show_progress:
rprint("[blue]Running plugins...[/blue]", file=sys.stderr)
snapshot_ids_by_crawl: dict[str, set[str]] = defaultdict(set)
for snapshot_id, crawl_id in existing_snapshots:
snapshot_ids_by_crawl[str(crawl_id)].add(str(snapshot_id))
for crawl_id, crawl_snapshot_ids in snapshot_ids_by_crawl.items():
from archivebox.crawls.models import Crawl
crawl = Crawl.objects.get(id=crawl_id)
if not crawl.claim_processing_lock(lock_seconds=10):
rprint(
f"[yellow]Crawl {crawl_id} is already owned by another runner[/yellow]",
file=sys.stderr,
)
return 1
selected_plugins = (
plugins_list
or sorted(
{plugin for snapshot_id in crawl_snapshot_ids for plugin in requested_plugins_by_snapshot.get(str(snapshot_id), set())},
)
or None
)
run_crawl(
crawl_id,
snapshot_ids=sorted(crawl_snapshot_ids),
selected_plugins=selected_plugins,
show_progress=show_progress,
)
if not emit_results:
return 0
# Output results as JSONL (when piped) or human-readable (when TTY)
for snapshot_id in snapshot_ids:
try:
snapshot = Snapshot.objects.get(id=snapshot_id)
results = snapshot.archiveresult_set.all()
if plugins_list:
results = results.filter(plugin__in=plugins_list)
for result in results:
if is_tty:
status_color = {
"succeeded": "green",
"failed": "red",
"skipped": "yellow",
}.get(result.status, "dim")
rprint(
f" [{status_color}]{result.status}[/{status_color}] {result.plugin} → {result.output_str or ''}",
file=sys.stderr,
)
else:
write_record(result.to_json())
except Snapshot.DoesNotExist:
continue
exit_code = _run_snapshot_requests(requested, wait=wait, show_progress=show_progress)
if exit_code or not emit_results:
return exit_code
is_tty = sys.stdout.isatty()
for snapshot in Snapshot.objects.filter(id__in=requested):
results = snapshot.archiveresult_set.all()
requested_plugins = requested[str(snapshot.id)]
if requested_plugins:
results = results.filter(plugin__in=requested_plugins)
for result in results:
if is_tty:
color = {"succeeded": "green", "failed": "red", "skipped": "yellow"}.get(result.status, "dim")
rprint(f" [{color}]{result.status}[/{color}] {result.plugin} → {result.output_str or ''}", file=sys.stderr)
else:
write_record(result.to_json())
return 0
def is_archiveresult_id(value: str) -> bool:
"""Check if value resolves to an ArchiveResult ID."""
from archivebox.core.models import ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
return ArchiveResult.objects.filter(_uuid_ref_query("id", value)).exists()
def process_archiveresult_by_id(archiveresult_id: str) -> int:
"""Re-run the parent Snapshot plugin referenced by an ArchiveResult."""
return run_plugins((), records=[{"id": archiveresult_id}], wait=True)
@click.command()
@click.option("--plugins", "--plugin", "-p", default="", help="Comma-separated list of plugins to run (e.g., screenshot,singlefile)")
@click.option("--plugins", "--plugin", "-p", default="", help="Comma-separated list of plugins to run")
@click.option("--wait/--no-wait", default=True, help="Wait for plugins to complete (default: wait)")
@click.argument("args", nargs=-1)
def main(plugins: str, wait: bool, args: tuple):
"""Run plugins on Snapshots, or process existing ArchiveResults by ID"""
"""Run plugins on Snapshots; ArchiveResult IDs select their parent plugin."""
from archivebox.misc.jsonl import read_args_or_stdin
# Read all input
records = list(read_args_or_stdin(args))
if not records:
from rich import print as rprint
rprint("[yellow]No Snapshot IDs or ArchiveResult IDs provided. Pass as arguments or via stdin.[/yellow]", file=sys.stderr)
sys.exit(1)
# Check if input looks like existing ArchiveResult IDs to process
all_are_archiveresult_ids = all(is_archiveresult_id(r.get("id") or r.get("url", "")) for r in records)
if all_are_archiveresult_ids:
# Process existing ArchiveResults by ID
from rich import print as rprint
exit_code = 0
for record in records:
archiveresult_id = record.get("id") or record.get("url")
if not isinstance(archiveresult_id, str):
rprint(f"[red]Invalid ArchiveResult input: {record}[/red]", file=sys.stderr)
exit_code = 1
continue
result = process_archiveresult_by_id(archiveresult_id)
if result != 0:
exit_code = result
sys.exit(exit_code)
else:
# Default behavior: run plugins on Snapshots from input
sys.exit(run_plugins(args, records=records, plugins=plugins, wait=wait))
sys.exit(run_plugins(args, records=records, plugins=plugins, wait=wait))
if __name__ == "__main__":
+5 -5
View File
@@ -17,12 +17,12 @@ def _resolve_install_targets(
requested_names: tuple[str, ...],
) -> tuple[list[str], list[str]]:
"""Resolve plugin names and declared binary aliases, leaving unknown tokens raw."""
from abx_dl.models import discover_plugins, filter_plugins
from archivebox.plugins.discovery import get_plugin_catalog
plugins = discover_plugins(runtime="archivebox")
plugin_names_by_lower = {plugin_name.lower(): plugin_name for plugin_name in plugins}
catalog = get_plugin_catalog()
plugin_names_by_lower = {plugin_name.lower(): plugin_name for plugin_name in catalog}
plugin_names_by_binary_alias: dict[str, set[str]] = {}
for plugin_name, plugin in plugins.items():
for plugin_name, plugin in catalog.items():
for required_binary in plugin.config.required_binaries:
aliases = {required_binary.name.lower()}
template_match = re.fullmatch(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", required_binary.name)
@@ -47,7 +47,7 @@ def _resolve_install_targets(
else:
raw_binary_names.append(name)
selected_plugins = filter_plugins(plugins, requested_plugins, include_providers=True) if requested_plugins else {}
selected_plugins = catalog.select(requested_plugins) if requested_plugins else catalog.select([])
selected_plugin_names = {name.lower() for name in selected_plugins}
raw_binary_names = [name for name in raw_binary_names if name.lower() not in selected_plugin_names]
return sorted(selected_plugins), sorted(set(raw_binary_names))
+16 -45
View File
@@ -72,8 +72,8 @@ def process_stdin_records() -> int:
Outputs JSONL of all processed records (for chaining).
Handles any record type: Crawl, Snapshot, ArchiveResult.
Auto-cascades: Crawl → Snapshots → ArchiveResults.
Handles Crawl and Snapshot work records. ArchiveResult records are accepted
as references to their parent Snapshot and plugin.
Returns exit code (0 = success, 1 = error).
"""
@@ -90,6 +90,7 @@ def process_stdin_records() -> int:
)
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import Snapshot, ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
from archivebox.crawls.models import Crawl
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
from archivebox.machine.models import Binary
@@ -156,33 +157,15 @@ def process_stdin_records() -> int:
queued_count += 1
elif record_type == TYPE_ARCHIVERESULT:
if record_id:
# Existing archiveresult - re-queue
try:
archiveresult = ArchiveResult.objects.get(id=record_id)
except ArchiveResult.DoesNotExist:
archiveresult = None
else:
archiveresult = None
snapshot_id = record.get("snapshot_id")
plugin_name = record.get("plugin")
snapshot = None
if archiveresult:
if archiveresult.status in [
ArchiveResult.StatusChoices.FAILED,
ArchiveResult.StatusChoices.SKIPPED,
ArchiveResult.StatusChoices.NORESULTS,
ArchiveResult.StatusChoices.BACKOFF,
]:
archiveresult.reset_for_retry()
snapshot = archiveresult.snapshot
plugin_name = plugin_name or archiveresult.plugin
elif snapshot_id:
try:
snapshot = Snapshot.objects.get(id=snapshot_id)
except Snapshot.DoesNotExist:
snapshot = None
snapshot_id = str(record.get("snapshot_id") or "")
plugin_name = str(record.get("plugin") or "")
archiveresult = None
if not snapshot_id and record_id:
archiveresult = ArchiveResult.objects.filter(_uuid_ref_query("id", str(record_id))).select_related("snapshot").first()
if archiveresult:
snapshot_id = str(archiveresult.snapshot_id)
plugin_name = plugin_name or archiveresult.plugin
snapshot = Snapshot.objects.filter(id=snapshot_id).first() if snapshot_id else None
if snapshot:
snapshot.queue_for_extraction()
@@ -190,7 +173,7 @@ def process_stdin_records() -> int:
snapshot_ids_by_crawl[crawl_id].add(str(snapshot.id))
if plugin_name:
plugin_names_by_crawl[crawl_id].add(str(plugin_name))
output_records.append(record if not archiveresult else archiveresult.to_json())
output_records.append(archiveresult.to_json() if archiveresult else record)
queued_count += 1
elif record_type in {TYPE_BINARYREQUEST, TYPE_BINARY}:
@@ -248,7 +231,6 @@ def process_stdin_records() -> int:
crawl_id,
snapshot_ids=None if crawl_id in full_crawl_ids else sorted(snapshot_ids_by_crawl[crawl_id]),
selected_plugins=None if crawl_id in run_all_plugins_for_crawl else sorted(plugin_names_by_crawl[crawl_id]),
selected_plugins_are_explicit=False,
)
return 0
@@ -257,7 +239,6 @@ def run_runner(
daemon: bool = False,
crawl_id: str | None = None,
maintenance_only: bool = False,
maintenance_batch_size: int | None = None,
) -> int:
"""
Run the background runner loop.
@@ -324,7 +305,6 @@ def run_runner(
crawl_id=crawl_id,
maintenance_only=maintenance_only,
interactive_interrupts=interactive_interrupts,
**({"maintenance_batch_size": maintenance_batch_size} if maintenance_batch_size else {}),
)
return 0
except KeyboardInterrupt:
@@ -349,13 +329,6 @@ def run_runner(
@click.option("--snapshot-id", help="Run one snapshot through its crawl")
@click.option("--binary-id", help="Run one queued binary install directly on the bus")
@click.option("--maintenance-only", is_flag=True, help="Only process sealed Snapshot maintenance and search-index backfills")
@click.option(
"--maintenance-batch-size",
type=int,
default=None,
hidden=True,
help="Limit queued maintenance snapshots claimed per scheduler tick",
)
@click.option("--no-stdin", is_flag=True, hidden=True, help="Run the scheduler even when stdin is not a TTY")
def main(
daemon: bool,
@@ -363,7 +336,6 @@ def main(
snapshot_id: str,
binary_id: str,
maintenance_only: bool,
maintenance_batch_size: int | None,
no_stdin: bool,
):
"""
@@ -388,7 +360,7 @@ def main(
),
foreground_parent_watchdog(enabled=False),
):
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only))
except KeyboardInterrupt:
sys.exit(0)
@@ -417,17 +389,16 @@ def main(
daemon=False,
crawl_id=crawl_id,
maintenance_only=maintenance_only,
maintenance_batch_size=maintenance_batch_size,
),
)
if maintenance_only:
sys.exit(run_runner(daemon=daemon, maintenance_only=True, maintenance_batch_size=maintenance_batch_size))
sys.exit(run_runner(daemon=daemon, maintenance_only=True))
if not no_stdin and not sys.stdin.isatty():
sys.exit(process_stdin_records())
else:
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only))
def run_snapshot_worker(snapshot_id: str) -> int:
+14 -6
View File
@@ -37,6 +37,7 @@ def schedule(
"created_schedule_ids": [],
"disabled_count": 0,
"run_all_enqueued": 0,
"run_all_maintained": 0,
"active_schedule_ids": [],
}
@@ -57,12 +58,12 @@ def schedule(
created_by_id = get_or_create_system_user_pk()
is_update_schedule = not import_path
template_urls = import_path or "archivebox://update"
template_urls = import_path or ""
template_label = (f"Scheduled import: {template_urls}" if import_path else "Scheduled ArchiveBox update")[:64]
template_notes = (
f"Created by archivebox schedule for {template_urls}"
if import_path
else "Created by archivebox schedule to queue recurring archivebox://update maintenance crawls."
else "Created by archivebox schedule to run recurring ArchiveBox maintenance."
)
template = Crawl.objects.create(
@@ -76,7 +77,6 @@ def schedule(
retry_at=None,
config={
"DEPTH": 0 if is_update_schedule else depth,
"SCHEDULE_KIND": "update" if is_update_schedule else "crawl",
# Caller-supplied overrides (e.g. {"ONLY_NEW": False}) win over the
# template defaults. Anything left unset falls through to the
# standard config stack at crawl-resolution time.
@@ -86,6 +86,7 @@ def schedule(
crawl_schedule = CrawlSchedule.objects.create(
template=template,
schedule=schedule_str,
config={**template.config, "SCHEDULE_KIND": "update" if is_update_schedule else "crawl"},
is_enabled=True,
label=template_label,
notes=template_notes,
@@ -119,12 +120,19 @@ def schedule(
if run_all:
enqueued = 0
maintained = 0
now = timezone.now()
for scheduled_crawl in schedules:
scheduled_crawl.enqueue(queued_at=now)
enqueued += 1
if scheduled_crawl.dispatch(queued_at=now) is None:
maintained += 1
else:
enqueued += 1
result["run_all_enqueued"] = enqueued
result["run_all_maintained"] = maintained
print(f"[green]\\[*] Enqueued {enqueued} scheduled crawl(s) immediately.[/green]")
if maintained:
run_pending_crawls(maintenance_only=True)
print(f"[green]\\[*] Ran {maintained} scheduled maintenance update(s).[/green]")
if enqueued:
print(
"[yellow]\\[*] Start `archivebox server`, `archivebox run --daemon`, or `archivebox schedule --foreground` to process the queued crawls.[/yellow]",
@@ -171,7 +179,7 @@ def schedule(
@click.option("--clear", is_flag=True, help="Disable all currently enabled schedules")
@click.option("--show", is_flag=True, help="Print all currently enabled schedules")
@click.option("--foreground", "-f", is_flag=True, help="Run the global crawl runner in the foreground (no crontab required)")
@click.option("--run-all", is_flag=True, help="Enqueue all enabled schedules immediately and process them once")
@click.option("--run-all", is_flag=True, help="Dispatch all enabled schedules immediately")
@click.argument("import_path", required=False)
@docstring(schedule.__doc__)
def main(**kwargs):
+114 -372
View File
@@ -77,107 +77,71 @@ def reindex_snapshots(
) -> dict[str, Any]:
from archivebox.cli.archivebox_extract import run_plugins
from archivebox.core.models import ArchiveResult, Snapshot
from abx_dl.models import discover_plugins
from django.db.models import Exists, OuterRef
# Search backfill is the one maintenance hook allowed to execute without
# reopening a Snapshot. Restrict that exception to already-sealed rows;
# every open lifecycle state remains owned by the normal runner lifecycle.
snapshots = snapshots.filter(status=Snapshot.StatusChoices.SEALED)
stats: dict[str, Any] = {"processed": 0, "requested": 0, "queued": 0, "skipped_queued": 0, "reindexed": 0, "snapshot_ids": []}
records: list[dict[str, str]] = []
plugins_by_name = discover_plugins(runtime="archivebox")
required_hooks_by_plugin = {
plugin_name: frozenset(hook.name for hook in plugins_by_name[plugin_name].filter_hooks("Snapshot"))
for plugin_name in search_plugins
if plugin_name in plugins_by_name
}
stats: dict[str, Any] = {"processed": 0, "requested": 0, "queued": 0, "reindexed": 0, "snapshot_ids": []}
print(f"[*] Reindexing missing search indexes with: {', '.join(search_plugins)}")
total = snapshots.count()
print(f"[*] Reindexing {total} snapshots with search plugins: {', '.join(search_plugins)}")
def run_batch() -> None:
if not records:
return
if wait_for_turn:
wait_for_turn()
batch_records = list(records)
snapshot_ids = {record["snapshot_id"] for record in batch_records}
plugin_names = {record["plugin"] for record in batch_records}
queued_rows = {
(str(snapshot_id), plugin_name, hook_name)
for snapshot_id, plugin_name, hook_name in ArchiveResult.objects.filter(
snapshot_id__in=snapshot_ids,
plugin__in=plugin_names,
status=ArchiveResult.StatusChoices.QUEUED,
).values_list("snapshot_id", "plugin", "hook_name")
}
records_to_queue = []
for record in batch_records:
snapshot_id = record["snapshot_id"]
plugin_name = record["plugin"]
required_hooks = required_hooks_by_plugin.get(plugin_name, frozenset())
if required_hooks and all((snapshot_id, plugin_name, hook_name) in queued_rows for hook_name in required_hooks):
stats["skipped_queued"] += 1
continue
records_to_queue.append(record)
if not records_to_queue:
print(
f" [{stats['processed']}/{total}] Already queued {len(batch_records)} index jobs",
)
records.clear()
return
# `archivebox update --index-only` intentionally breaks the usual
# "runner discovers work" rule by inserting synthetic queued
# ArchiveResult rows for search backends. run_plugins() keeps this as
# statement-sized UPDATE/bulk_create work, then bumps Snapshot.retry_at
# so the orchestrator owns actual hook execution. Paused snapshots stay
# PAUSED; run_due_snapshot restores retry_at=MAX after targeted rows
# finish.
exit_code = run_plugins(
args=(),
records=records_to_queue,
wait=False,
emit_results=False,
show_progress=False,
preserve_queued=True,
completed_statuses = [ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS]
for plugin_name in search_plugins:
completed_result = ArchiveResult.objects.filter(
snapshot_id=OuterRef("pk"),
plugin=plugin_name,
status__in=completed_statuses,
)
if exit_code != 0:
raise SystemExit(exit_code)
stats["queued"] += len(records_to_queue)
print(
f" [{stats['processed']}/{total}] Queued {len(records_to_queue)} index jobs for orchestrator",
)
records.clear()
for snapshot in snapshots.select_related("crawl").paged_iterator(chunk_size=batch_size):
try:
stats["processed"] += 1
if _get_snapshot_crawl(snapshot) is None:
continue
candidates = snapshots.annotate(has_completed_index=Exists(completed_result)).filter(has_completed_index=False).order_by("id")
after_id = None
while True:
if wait_for_turn:
wait_for_turn()
page = candidates.filter(id__gt=after_id) if after_id is not None else candidates
batch = list(page.select_related(None).only("id", "timestamp")[:batch_size])
if not batch:
break
after_id = batch[-1].id
records = [{"type": "Snapshot", "id": str(snapshot.id)} for snapshot in batch]
stats["processed"] += len(batch)
stats["requested"] += len(batch)
if collect_ids:
stats["snapshot_ids"].append(str(snapshot.id))
for plugin_name in search_plugins:
records.append(
{
"type": "ArchiveResult",
"snapshot_id": str(snapshot.id),
"plugin": plugin_name,
},
)
stats["requested"] += 1
if len(records) >= batch_size:
run_batch()
except KeyboardInterrupt as err:
err.archivebox_resume = snapshot.timestamp
raise
run_batch()
stats["snapshot_ids"].extend(str(snapshot.id) for snapshot in batch)
exit_code = run_plugins(
args=(),
records=records,
plugins=plugin_name,
wait=True,
emit_results=False,
show_progress=False,
)
if exit_code != 0:
raise SystemExit(exit_code)
stats["reindexed"] += len(batch)
print(f" [{plugin_name}] indexed {stats['reindexed']} missing snapshots")
return stats
def run_scheduled_maintenance(*, batch_size: int = 500) -> dict[str, Any]:
"""Queue stale filesystem rows and backfill missing search facts."""
from archivebox.core.models import Snapshot
filesystem_stats = process_all_db_snapshots(batch_size=batch_size)
search_plugins = _get_search_indexing_plugins()
search_stats = (
reindex_snapshots(
Snapshot.objects.filter(fs_version=Snapshot._fs_current_version()),
search_plugins=search_plugins,
batch_size=batch_size,
)
if search_plugins
else {"processed": 0, "requested": 0, "queued": 0, "reindexed": 0, "snapshot_ids": []}
)
return {"filesystem": filesystem_stats, "search": search_stats}
@enforce_types
def update(
filter_patterns: Iterable[str] = (),
@@ -203,9 +167,9 @@ def update(
Update snapshots: migrate old dirs, reconcile DB, and re-queue for archiving.
Three-phase operation (without filters):
- Phase 1: Drain old archive/ dirs by moving to new fs location (0.8.x → 0.9.x)
- Phase 2: O(n) scan over entire DB from most recent to least recent
- No orphan scans needed (trust 1:1 mapping between DB and filesystem after phase 1)
- Phase 1: Drain legacy archive/ directories into the current layout
- Phase 2: Select only stale fs_version rows through the indexed column
- Phase 3: Run queued snapshot-level filesystem maintenance until idle
With filters: Only phase 2 (DB query), no filesystem operations.
Without filters: All phases (full update).
@@ -232,7 +196,6 @@ def update(
from archivebox.core.takeover_util import (
command_owns_foreground_runner,
current_command,
foreground_runner_owner,
standby_until_foreground_runner_needed,
)
from archivebox.workers.supervisord_util import run_runner_worker, stop_own_supervisord_process
@@ -291,11 +254,6 @@ def update(
while True:
do_migrate = migrate_only or not index_only
do_index = index_only or not migrate_only
do_run_until_idle = do_migrate or do_index
ran_post_migrate_runner = False
full_update_empty = False
maintenance_work_queued = False
runner_work_queued = False
if do_migrate:
if (
@@ -327,13 +285,11 @@ def update(
after=after,
resume=resume,
batch_size=batch_size,
queue_for_archiving=do_run_until_idle,
queue_for_archiving=True,
wait_for_turn=wait_for_turn,
)
print_stats(stats)
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
maintenance_work_queued = stats.get("queued", 0) > 0
runner_work_queued = runner_work_queued or maintenance_work_queued
else:
stats_combined = {"phase1": {}, "phase2": {}}
@@ -343,162 +299,54 @@ def update(
batch_size=batch_size,
)
print("[*] Phase 2: Processing all database snapshots (most recent first)...")
print("[*] Phase 2: Selecting database snapshots with stale filesystem versions...")
stats_combined["phase2"] = process_all_db_snapshots(
batch_size=batch_size,
resume=resume,
wait_for_turn=wait_for_turn,
)
print_combined_stats(stats_combined)
full_update_empty = (
stats_combined["phase1"].get("processed", 0) == 0 and stats_combined["phase2"].get("snapshots", 0) == 0
)
maintenance_work_queued = any(
(
stats_combined["phase1"].get("queued", 0),
stats_combined["phase2"].get("queued", 0),
stats_combined["phase2"].get("crawls_sealed", 0),
),
)
runner_work_queued = runner_work_queued or maintenance_work_queued
if do_run_until_idle:
# Filesystem migration is maintenance on existing
# Snapshot rows: Snapshot.save() moves archive/<ts> to
# the current output_dir and preserves the lifecycle
# status. Drain those retry_at ticks before queuing
# search backfill below. Otherwise the sealed search
# runner branch correctly sees queued ArchiveResult
# rows first, runs the targeted plugins, and may leave
# the fs_version maintenance tick hidden behind that
# plugin work until another update pass.
if full_update_empty:
print("[*] No snapshots or legacy archive directories found; skipping filesystem maintenance runner.")
elif not maintenance_work_queued:
print("[*] No filesystem maintenance work queued; skipping filesystem maintenance runner.")
else:
print("[*] Phase 3: Running filesystem maintenance until idle...")
if full_update_empty:
pass
elif not maintenance_work_queued:
pass
elif is_filtered_update:
if not touched_snapshot_ids:
print("[*] No matching snapshots queued work for the runner.")
for snapshot_id in sorted(touched_snapshot_ids):
run_scoped_runner("--snapshot-id", snapshot_id)
else:
run_scoped_runner("--maintenance-only", "--maintenance-batch-size", str(batch_size))
ran_post_migrate_runner = True
if do_index:
if full_update_empty:
print("[*] No snapshots found; skipping search indexing backfill.")
else:
search_plugins = _get_search_indexing_plugins()
if not search_plugins:
print("[*] No search indexing plugins are available, nothing to backfill.")
else:
snapshots = _build_filtered_snapshots_queryset(
filter_patterns=filter_patterns,
filter_type=filter_type,
status=status,
url__icontains=url__icontains,
url__istartswith=url__istartswith,
tag=tag,
crawl_id=crawl_id,
limit=limit,
sort=sort,
search=search,
before=before,
after=after,
resume=resume,
)
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from archivebox.core.models import ArchiveResult, Snapshot
scoped_snapshot_ids = snapshots.order_by().values("id") if is_filtered_update else None
queued_index_results = ArchiveResult.objects.filter(
status=ArchiveResult.StatusChoices.QUEUED,
plugin__in=search_plugins,
)
if scoped_snapshot_ids is not None:
queued_index_results = queued_index_results.filter(snapshot_id__in=scoped_snapshot_ids)
if queued_index_results.exists():
runner_work_queued = True
now = timezone.now()
queued_result_for_snapshot = queued_index_results.filter(snapshot_id=OuterRef("pk"))
snapshots_to_wake = (
Snapshot.objects.filter(status=Snapshot.StatusChoices.SEALED)
.annotate(
has_queued_index_result=Exists(queued_result_for_snapshot),
)
.filter(
has_queued_index_result=True,
)
.filter(
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
)
)
if scoped_snapshot_ids is not None:
snapshots_to_wake = snapshots_to_wake.filter(id__in=scoped_snapshot_ids)
woken_count = snapshots_to_wake.update(
retry_at=now,
modified_at=now,
)
print(
"[*] Existing queued search index jobs found; "
f"skipping backfill scan and waking {woken_count} snapshot(s) for the runner.",
)
else:
collect_index_ids = (
is_filtered_update
or foreground_runner_owner(
data_dir=CONSTANTS.DATA_DIR,
exclude_id=command.id,
)
is not None
)
stats = reindex_snapshots(
snapshots,
search_plugins=search_plugins,
batch_size=batch_size,
collect_ids=collect_index_ids,
wait_for_turn=wait_for_turn,
)
print_index_stats(stats)
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
runner_work_queued = runner_work_queued or stats["queued"] > 0
if do_run_until_idle and (do_index or not ran_post_migrate_runner):
# Search/index backfill intentionally queues targeted
# ArchiveResult rows without reopening sealed snapshots.
# This second runner pass drains those plugin
# rows after filesystem maintenance has had its own turn.
# For a normal unfiltered `archivebox update`, keep the
# historical final pass broad enough to resume genuinely
# queued/interrupted crawl work after maintenance is done.
if full_update_empty:
print("[*] No snapshots found; skipping queued/interrupted crawl runner.")
elif not runner_work_queued:
print("[*] No queued/interrupted crawl work found; skipping queued/interrupted crawl runner.")
else:
print("[*] Phase 3: Running queued/interrupted crawl work until idle...")
if full_update_empty:
pass
elif not runner_work_queued:
pass
elif touched_snapshot_ids and is_filtered_update:
if not touched_snapshot_ids:
print("[*] No matching snapshots queued work for the runner.")
# The due selectors are indexed and cheap when empty, so
# always drain them instead of preceding the runner with
# whole-table counts merely to decide whether to call it.
print("[*] Phase 3: Running filesystem maintenance until idle...")
if is_filtered_update:
for snapshot_id in sorted(touched_snapshot_ids):
run_scoped_runner("--snapshot-id", snapshot_id)
elif migrate_only:
run_scoped_runner("--maintenance-only")
else:
run_scoped_runner(
*(["--maintenance-only", "--maintenance-batch-size", str(batch_size)] if index_only or migrate_only else []),
run_scoped_runner()
if do_index:
search_plugins = _get_search_indexing_plugins()
if not search_plugins:
print("[*] No search indexing plugins are available, nothing to backfill.")
else:
snapshots = _build_filtered_snapshots_queryset(
filter_patterns=filter_patterns,
filter_type=filter_type,
status=status,
url__icontains=url__icontains,
url__istartswith=url__istartswith,
tag=tag,
crawl_id=crawl_id,
limit=limit,
sort=sort,
search=search,
before=before,
after=after,
resume=resume,
)
stats = reindex_snapshots(
snapshots,
search_plugins=search_plugins,
batch_size=batch_size,
collect_ids=is_filtered_update,
wait_for_turn=wait_for_turn,
)
print_index_stats(stats)
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
if not continuous:
break
@@ -737,18 +585,8 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, wait_for_turn=None) -> dict[str, int]:
"""
O(n) scan over entire DB from most recent to least recent.
For each snapshot:
1. Reconcile index.json with DB (merge titles, tags, archive results)
2. Mark migrated snapshots sealed unless explicitly re-queued elsewhere
No orphan detection needed - we trust 1:1 mapping between DB and filesystem
after Phase 1 has drained all old archive/ directories.
"""
"""Queue only snapshots whose indexed filesystem version is stale."""
from archivebox.core.models import Snapshot
from archivebox.crawls.models import Crawl
from django.db.models import Q
from django.utils import timezone
@@ -761,128 +599,35 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w
"sealed": 0,
"crawls_sealed": 0,
}
current_fs_version = Snapshot._fs_current_version()
queryset = Snapshot.objects.all()
queryset = Snapshot.objects.filter(fs_version__in=Snapshot._FS_VERSION_MIGRATION_PATHS)
if resume:
queryset = queryset.filter(timestamp__lte=resume)
total = queryset.count()
stats["snapshots"] = total
print(f"[*] Processing {total} snapshots from database (most recent first)...")
def update_in_batches(rows, *, label: str, **updates) -> int:
updated = 0
checked = 0
while True:
if wait_for_turn:
wait_for_turn()
batch = list(rows.only("id", "modified_at").order_by("-timestamp")[:batch_size])
if not batch:
if updated:
print(f" [{label}] complete: {updated} rows updated")
return updated
checked += len(batch)
print(f" [{label}] updating next {len(batch)} rows (seen {checked})...")
for snapshot in batch:
# This maintenance scan intentionally bypasses save(); it is
# only normalizing scheduler fields, and Snapshot.save() may
# do filesystem migration work that belongs in the runner.
# Guard each single-row UPDATE with modified_at so stale scan
# pages cannot overwrite newer runner/admin writes.
updated += int(
snapshot.safe_update(
updates,
refresh=False,
extra_filter={"modified_at": snapshot.modified_at},
),
)
print(f" [{label}] updated {updated} rows so far")
now = timezone.now()
updated_rows = update_in_batches(
queryset.exclude(
status__in=[
Snapshot.StatusChoices.QUEUED,
Snapshot.StatusChoices.STARTED,
Snapshot.StatusChoices.PAUSED,
Snapshot.StatusChoices.SEALED,
],
),
label="snapshot status normalization",
status=Snapshot.StatusChoices.SEALED,
retry_at=None,
modified_at=now,
)
stats["sealed"] += updated_rows
stats["updated_db"] += updated_rows
fs_version_rows = queryset.exclude(fs_version=current_fs_version).filter(Q(retry_at__isnull=True) | Q(retry_at__gt=now))
stale_batch = []
def queue_stale_fs_batch() -> None:
if not stale_batch:
return
initial_now = timezone.now()
rows_to_wake = queryset.filter(Q(retry_at__isnull=True) | Q(retry_at__gt=initial_now))
after_id = None
while True:
if wait_for_turn:
wait_for_turn()
page = rows_to_wake.filter(id__gt=after_id) if after_id is not None else rows_to_wake
batch = list(page.only("id", "fs_version", "modified_at").order_by("id")[:batch_size])
if not batch:
break
after_id = batch[-1].id
now = timezone.now()
# Do not bump fs_version here. The orchestrator calls Snapshot.save(),
# which performs the idempotent filesystem migration and commits the new
# fs_version in the same serialized worker path as normal crawls.
updated = 0
for snapshot in stale_batch:
# Each row gets its own short autocommit UPDATE because this scan
# can touch millions of snapshots while a server is also alive.
# The modified_at predicate is the CAS guard: if the runner or
# admin changed the snapshot after paged_iterator read it, skip it
# and let the newer state decide whether migration is still due.
for snapshot in batch:
updated += int(
snapshot.safe_update(
{
"retry_at": now,
"modified_at": now,
},
{"retry_at": now, "modified_at": now},
refresh=False,
extra_filter={"fs_version": snapshot.fs_version},
),
)
stats["processed"] += len(stale_batch)
stats["processed"] += len(batch)
stats["updated_db"] += updated
stats["queued"] += updated
print(f" [{stats['processed']}/{total}] Queued {updated} filesystem migrations for orchestrator...")
stale_batch.clear()
for snapshot in (
fs_version_rows.only("id", "crawl_id", "timestamp", "fs_version", "modified_at")
.order_by("-timestamp")
.paged_iterator(chunk_size=batch_size)
):
try:
stale_batch.append(snapshot)
if len(stale_batch) >= batch_size:
queue_stale_fs_batch()
except KeyboardInterrupt as err:
err.archivebox_resume = snapshot.timestamp
raise
queue_stale_fs_batch()
now = timezone.now()
# Crawls with no open child snapshots are already finished. Seal them here
# instead of waking the foreground runner; otherwise migration/update can
# accidentally re-enter full crawl execution for historical rows.
stats["crawls_sealed"] = (
Crawl.objects.filter(
status__in=Crawl.RUNNABLE_STATES,
)
.exclude(
snapshot_set__status__in=Snapshot.OPEN_STATES,
)
.update(
status=Crawl.StatusChoices.SEALED,
retry_at=None,
modified_at=now,
)
)
stats["updated_db"] += stats["crawls_sealed"]
print(f" Queued {stats['queued']} stale filesystem snapshots so far...")
stats["snapshots"] = stats["processed"]
return stats
@@ -942,8 +687,6 @@ def process_filtered_snapshots(
stats["snapshot_ids"].append(str(snapshot.id))
update_values = {}
updated = 0
if not isinstance(snapshot.current_step, int):
update_values["current_step"] = 0
if queue_for_archiving:
update_values.update(
{
@@ -1029,10 +772,9 @@ def print_index_stats(stats: dict[str, Any]) -> None:
print(f"""
[green]Search Reindex Complete[/green]
Scanned rows: {stats["processed"]}
Requested jobs: {stats.get("requested", stats["queued"])}
Queued index jobs: {stats["queued"]}
Already queued: {stats.get("skipped_queued", 0)}
Missing index runs: {stats["processed"]}
Requested runs: {stats.get("requested", 0)}
Indexed snapshots: {stats.get("reindexed", 0)}
""")
+3 -4
View File
@@ -265,15 +265,14 @@ def version(
seen_failures: set[str] = set()
seen_rows: set[tuple[str, str, str, str]] = set()
from archivebox.plugins.discovery import get_enabled_plugins
from archivebox.plugins.discovery import get_enabled_plugins, get_plugin_catalog
from abx_dl.config import get_required_binary_requests
from abx_dl.dependencies import resolve_binary_requests
from abx_dl.models import discover_plugins, filter_plugins
from abx_dl.orchestrator import create_bus
from abxpkg.binary_service import BinaryEvent, BinaryService
plugins = discover_plugins(runtime="archivebox")
enabled_plugins = filter_plugins(plugins, get_enabled_plugins(config=config), include_providers=True)
plugins = get_plugin_catalog()
enabled_plugins = plugins.select(get_enabled_plugins(config=config))
enabled_plugin_names = set(enabled_plugins)
runtime_config = normalize_runtime_config(config.for_crawl(), json_safe=False)
derived_config: dict[str, object] = {}
+1 -1
View File
@@ -160,7 +160,7 @@ def _coerce_from_str_dict(file_config: dict[str, str]) -> dict[str, Any]:
so they round-trip through INI's string-only storage. When reading the
file back into ``Machine.config`` (a JSONField that holds native types)
those strings have to be decoded — otherwise downstream consumers like
``_emit_machine_config`` → ``MachineEvent`` → abx-dl see a JSON string
``MachineEvent`` → abx-dl see a JSON string
where they expect a dict and raise ``TypeError``.
Declared fields go through pydantic-settings' own ``field_is_complex`` /
``prepare_field_value`` so they're decoded per annotation. Undeclared
+3 -4
View File
@@ -441,6 +441,7 @@ class ArchivingConfig(BaseConfigSet):
default="",
description="Comma-separated plugin selection for this run. Empty means use enabled plugin defaults.",
)
PARSER: str = Field(default="auto", description="Parser used to read the submitted Crawl input document.")
ONLY_NEW: bool = Field(default=True)
INDEX_ONLY: bool = Field(default=False)
@@ -852,13 +853,11 @@ class ArchiveBoxBaseConfig(
disabled_plugins = [plugin_name for plugin_name, enabled_key in enabled_config_keys.items() if not getattr(self, enabled_key)]
selected_plugin_roots = plugin_names
if selected_plugin_roots:
from abx_dl.models import discover_plugins, filter_plugins
from archivebox.plugins.discovery import get_plugin_catalog
selected_plugins = set(
filter_plugins(
discover_plugins(runtime="archivebox"),
get_plugin_catalog().select(
sorted(selected_plugin_roots),
include_providers=True,
disabled_names=disabled_plugins,
),
)
+12 -13
View File
@@ -346,19 +346,8 @@ class SnapshotAdminForm(forms.ModelForm):
# Parse and save tags from tags_editor
tags_str = self.cleaned_data.get("tags_editor", "")
if tags_str:
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
tags = []
for name in tag_names:
tag, _ = Tag.objects.get_or_create(
name__iexact=name,
defaults={"name": name},
)
tag = Tag.objects.filter(name__iexact=name).first() or tag
tags.append(tag)
instance.tags.set(tags)
else:
instance.tags.clear()
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
instance.save_tags(tag_names)
return instance
@@ -367,6 +356,16 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
form = SnapshotAdminForm
raw_id_fields = ("crawl", "parent_snapshot")
list_select_related = ()
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
tags_str = form.cleaned_data.get("tags_editor", "")
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
form.instance.save_tags(
tag_names,
created_by=request.user if request.user.is_authenticated else None,
)
list_display = (
"permissions_badge",
"created_at",
-59
View File
@@ -13,64 +13,5 @@ from archivebox.config.django import setup_django
setup_django(check_db=True)
def _patch_thread_sensitive_context_shutdown() -> None:
"""Stop ``ThreadSensitiveContext.__aexit__`` from blocking the daphne loop.
Django 6.0's ASGIHandler wraps every request in ``async with
ThreadSensitiveContext():`` (django/core/handlers/asgi.py:169). On exit
asgiref calls ``executor.shutdown()`` with the default ``wait=True``
(asgiref/sync.py:148), which is a *synchronous* ``Thread.join()`` inside
an async function — so it blocks the daphne event loop until the
executor's worker thread exits.
That's normally fine because the request handler has already awaited
every ``sync_to_async`` it submitted, so the worker is idle and dies as
soon as the shutdown sentinel reaches it. The blocking turns into a
problem when a client disconnects mid-request:
* ``SyncToAsync.__call__`` shields the executor work with
``await asyncio.shield(exec_coro)`` (asgiref/sync.py:506) so that the
sync DB call doesn't get torn down halfway through.
* On cancellation it calls ``exec_coro.cancel()`` (line 522) which only
flips the asyncio ``Future`` to cancelled — the underlying thread
keeps running the SQL query.
* Control unwinds to ``__aexit__`` while the orphaned thread is still
mid-query. ``shutdown(wait=True)`` then blocks the event loop until
that orphan finishes.
Under heavy SQLite contention (the load-test scenario that surfaced
this on cabbage) those orphan threads can take 30 seconds each waiting
for write locks, and the daphne loop is single-threaded — so every
such orphan stalls every other in-flight request, healthchecks time
out, and the container goes ``unhealthy``.
Switching to ``shutdown(wait=False)`` queues the sentinel and returns
immediately; the worker thread still exits cleanly once its current
task finishes, and asgiref's ``WeakKeyDictionary`` releases the
executor as soon as the request's context is GC'd. No per-request
teardown guarantee is lost — there was no caller relying on it.
"""
from asgiref import sync as _asgiref_sync
original_aexit = _asgiref_sync.ThreadSensitiveContext.__aexit__
async def __aexit__(self, exc, value, tb): # type: ignore[no-redef]
if not self.token:
return
executor = _asgiref_sync.SyncToAsync.context_to_thread_executor.pop(self, None)
if executor is not None:
executor.shutdown(wait=False)
_asgiref_sync.SyncToAsync.thread_sensitive_context.reset(self.token)
# Idempotent: only patch once even if asgi.py is reloaded.
if getattr(original_aexit, "_archivebox_patched", False):
return
__aexit__._archivebox_patched = True # type: ignore[attr-defined]
_asgiref_sync.ThreadSensitiveContext.__aexit__ = __aexit__
_patch_thread_sensitive_context_shutdown()
# Standard Django ASGI application (no websockets/channels needed)
application = get_asgi_application()
+9 -2
View File
@@ -89,8 +89,15 @@ def AdminCookieIsolationMiddleware(get_response):
response = get_response(request)
if request.path == "/admin" or request.path.startswith("/admin/"):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
from archivebox.opencode.views import _PROXY_PREFIX
is_opencode_proxy = request.path == _PROXY_PREFIX or request.path.startswith(f"{_PROXY_PREFIX}/")
if is_opencode_proxy:
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
else:
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
config = request.__dict__.get("archivebox_config")
if config is None or config.SERVER_SECURITY_MODE == "auto":
@@ -1,71 +0,0 @@
from django.db import migrations, models
from django.db.models import Sum
def consolidate_archiveresults_per_plugin(apps, schema_editor):
ArchiveResult = apps.get_model("core", "ArchiveResult")
Snapshot = apps.get_model("core", "Snapshot")
duplicate_groups = ArchiveResult.objects.values("snapshot_id", "plugin").annotate(count=models.Count("id")).filter(count__gt=1)
affected_snapshot_ids = set()
for group in duplicate_groups.iterator(chunk_size=200):
rows = list(
ArchiveResult.objects.filter(
snapshot_id=group["snapshot_id"],
plugin=group["plugin"],
).order_by("created_at", "id"),
)
winner = max(
rows,
key=lambda row: (
bool(row.output_files),
int(row.output_size or 0),
row.modified_at,
str(row.id),
),
)
output_files = {}
mimetypes = set()
for row in rows:
output_files.update(row.output_files or {})
mimetypes.update(part.strip() for part in (row.output_mimetypes or "").split(",") if part.strip())
winner.output_files = output_files
winner.output_size = max(
sum(int(metadata.get("size") or 0) for metadata in output_files.values() if isinstance(metadata, dict)),
*(int(row.output_size or 0) for row in rows),
)
winner.output_mimetypes = ",".join(sorted(mimetypes))
winner.start_ts = min((row.start_ts for row in rows if row.start_ts), default=None)
winner.end_ts = max((row.end_ts for row in rows if row.end_ts), default=None)
winner.save(update_fields=["output_files", "output_size", "output_mimetypes", "start_ts", "end_ts"])
ArchiveResult.objects.filter(id__in=[row.id for row in rows if row.id != winner.id]).delete()
affected_snapshot_ids.add(group["snapshot_id"])
for snapshot_id in affected_snapshot_ids:
total = ArchiveResult.objects.filter(snapshot_id=snapshot_id).aggregate(total=Sum("output_size"))["total"] or 0
Snapshot.objects.filter(id=snapshot_id).update(output_size=total)
class Migration(migrations.Migration):
dependencies = [
("core", "0051_postgres_url_pattern_ops_index"),
]
operations = [
migrations.RemoveConstraint(
model_name="archiveresult",
name="unique_archiveresult_per_snapshot_hook",
),
migrations.RunPython(
consolidate_archiveresults_per_plugin,
reverse_code=migrations.RunPython.noop,
),
migrations.AddConstraint(
model_name="archiveresult",
constraint=models.UniqueConstraint(
fields=("snapshot", "plugin"),
name="unique_archiveresult_per_snapshot_plugin",
),
),
]
@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0052_unique_archiveresult_per_snapshot_plugin"),
("core", "0051_postgres_url_pattern_ops_index"),
]
operations = [
@@ -0,0 +1,26 @@
# Generated by Django 6.1 on 2026-09-02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0053_alter_archiveresult_options"),
]
operations = [
migrations.RemoveField(
model_name="snapshot",
name="current_step",
),
migrations.AlterField(
model_name="snapshot",
name="fs_version",
field=models.CharField(
db_index=True,
default="0.9.0",
help_text='Filesystem version of this snapshot (e.g., "0.7.0", "0.8.0", "0.9.4").',
max_length=10,
),
),
]
+265 -385
View File
@@ -12,10 +12,10 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
from django.db import models, transaction
from django.db.models import Case, F, Q, QuerySet, Sum, Value, When
from django.db import IntegrityError, models, transaction
from django.db.models import F, Q, QuerySet, Sum, Value
from django.db.models.fields.json import KT
from django.db.models.functions import Coalesce, Concat
from django.db.models.functions import Coalesce
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.functional import cached_property
@@ -85,6 +85,19 @@ class Tag(ModelWithUUID):
modified_at = models.DateTimeField(auto_now=True)
name = models.CharField(unique=True, blank=False, max_length=100)
@classmethod
def get_or_create_by_name(cls, name: str, *, defaults: Mapping[str, Any] | None = None) -> tuple["Tag", bool]:
tag = cls.objects.filter(name__iexact=name).first()
if tag:
return tag, False
try:
return cls.objects.create(name=name, **(defaults or {})), True
except IntegrityError:
tag = cls.objects.filter(name__iexact=name).first()
if tag is None:
raise
return tag, False
snapshot_set: models.Manager["Snapshot"]
class Meta(ModelWithUUID.Meta):
@@ -139,7 +152,7 @@ class Tag(ModelWithUUID):
if not name:
return None
tag, _ = Tag.objects.get_or_create(name=name)
tag, _ = Tag.get_or_create_by_name(name)
# Auto-attach to snapshot if in overrides
if overrides and "snapshot" in overrides and tag:
@@ -525,8 +538,6 @@ class SnapshotManager(models.Manager.from_queryset(SnapshotQuerySet)): # ty: ig
class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHealthStats, ModelWithQueue):
BROWSER_EXTENSION_UPLOAD_HOOK_NAME = "on_Snapshot__archivebox_browser_extension_upload"
INTERNAL_INPUT_URL = "archivebox://internal"
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
modified_at = models.DateTimeField(auto_now=True)
@@ -554,14 +565,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
fs_version = models.CharField(
max_length=10,
default="0.9.0",
help_text='Filesystem version of this snapshot (e.g., "0.7.0", "0.8.0", "0.9.0"). Used to trigger lazy migration on save().',
)
current_step = models.PositiveSmallIntegerField(
default=0,
db_index=True,
help_text="Current hook step being executed (0-9). Used for sequential hook execution.",
help_text='Filesystem version of this snapshot (e.g., "0.7.0", "0.8.0", "0.9.4").',
)
retry_at = ModelWithQueue.RetryAtField(default=timezone.now)
status = ModelWithQueue.StatusField(
choices=ModelWithQueue.StatusChoices,
@@ -606,13 +612,25 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
archiveresult_set: models.Manager["ArchiveResult"]
def add_tag_ids(self, tag_ids: Iterable[int | str]) -> None:
tag_ids = [tag_id for tag_id in dict.fromkeys(tag_ids) if tag_id]
for tag_id in tag_ids:
try:
SnapshotTag(snapshot_id=self.pk, tag_id=tag_id).save(force_insert=True)
except IntegrityError:
# Only the unique (snapshot, tag) conflict is idempotent. Do
# not hide foreign-key or other integrity failures.
if SnapshotTag.objects.filter(snapshot_id=self.pk, tag_id=tag_id).exists():
continue
raise
def remove_tag_ids(self, tag_ids: Iterable[int | str]) -> int:
tag_ids = [tag_id for tag_id in dict.fromkeys(tag_ids) if tag_id]
if not tag_ids:
return
SnapshotTag.objects.bulk_create(
[SnapshotTag(snapshot_id=self.pk, tag_id=tag_id) for tag_id in tag_ids],
ignore_conflicts=True,
)
return 0
# QuerySet.delete() wraps even a fast through-table DELETE in atomic().
# SnapshotTag has no delete hooks or child rows, so issue the same
# idempotent DELETE as one autocommit statement.
return SnapshotTag.objects.filter(snapshot_id=self.pk, tag_id__in=tag_ids)._raw_delete(SnapshotTag.objects.db)
class Meta(
ModelWithDeleteAfter.Meta,
@@ -705,20 +723,51 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return self.update_and_requeue(
status=self.StatusChoices.QUEUED,
retry_at=when or timezone.now(),
current_step=0,
)
def schedule_plugin_run(self, plugins: Iterable[str], *, when=None) -> bool:
"""Persist one snapshot-scoped plugin request until abx-dl completes it."""
plugin_names = sorted({name.strip() for name in plugins if name.strip()})
if not plugin_names:
return False
retry_at = when or timezone.now()
for _attempt in range(8):
current = type(self).objects.select_related("crawl").get(pk=self.pk)
pending_plugins = {str(name).strip() for name in (current.config or {}).get("RETRY_PLUGINS", []) if str(name).strip()}
config = {**(current.config or {}), "RETRY_PLUGINS": sorted(pending_plugins | set(plugin_names))}
status = current.status if current.status == self.StatusChoices.SEALED else self.StatusChoices.QUEUED
updated = (
type(self)
.objects.filter(
pk=self.pk,
config=current.config,
status=current.status,
retry_at=current.retry_at,
)
.update(
config=config,
status=status,
retry_at=retry_at,
modified_at=timezone.now(),
)
)
if updated:
crawl = current.crawl
break
else:
raise RuntimeError(f"Snapshot {self.pk} changed repeatedly while scheduling plugins")
self.refresh_from_db()
if status in self.RUNNABLE_STATES and self.crawl_id:
crawl_status = crawl.StatusChoices.STARTED if crawl.status == crawl.StatusChoices.STARTED else crawl.StatusChoices.QUEUED
crawl.update_and_requeue(status=crawl_status, retry_at=retry_at)
return True
def pause(self, *, save: bool = True) -> bool:
paused = super().pause(save=save)
if paused and self.pk:
ArchiveResult.pause_queryset(self.archiveresult_set.all())
return paused
return super().pause(save=save)
def resume(self, *, when: datetime | None = None, save: bool = True) -> bool:
resumed = super().resume(when=when, save=save)
if resumed and self.pk:
ArchiveResult.resume_queryset(self.archiveresult_set.all(), when=when)
return resumed
return super().resume(when=when, save=save)
def restore_paused_scheduler_marker(self) -> None:
"""
@@ -792,20 +841,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
modified_at=now,
)
def reset_abandoned_results(self) -> tuple[int, int]:
reset_count = 0
running_count = 0
for result in self.archiveresult_set.filter(
status__in=[ArchiveResult.StatusChoices.STARTED, ArchiveResult.StatusChoices.BACKOFF],
).select_related("process"):
process = result.process
if process is not None and process.is_running:
running_count += 1
continue
result.reset_for_retry()
reset_count += 1
return reset_count, running_count
def start_processing(self) -> bool:
"""Atomically move a claimed queued Snapshot into its active lease."""
owned_retry_at = self.retry_at
@@ -855,12 +890,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if self.status == self.StatusChoices.PAUSED:
return False
if self.status == self.StatusChoices.QUEUED:
results = self.archiveresult_set.all()
if results.exists() and not results.exclude(status__in=ArchiveResult.FINAL_STATES).exists():
return self.seal()
return bool(self.url) and self.start_processing()
if self.status == self.StatusChoices.STARTED and self.is_finished_processing():
return self.seal()
# abx-dl emits SnapshotCompletedEvent after the complete hook sequence;
# ArchiveResult projection state never drives Snapshot completion.
return False
def cancel(self) -> None:
@@ -972,7 +1004,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@property
def binary_set(self):
"""Get all Binary objects used by processes related to this snapshot."""
from archivebox.machine.models import Binary
return Binary.objects.filter(process_set__archiveresult__snapshot_id=self.id).distinct()
@@ -997,9 +1028,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return False
def validate_url_for_archiving(self, *, config: Mapping[str, Any] | Any | None = None) -> None:
if self.is_internal_input_url():
return
try:
validate_url(self.url or "")
except ValueError as err:
@@ -1008,9 +1036,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if self.is_archivebox_internal_url(self.url, config=config):
raise ValidationError({"url": "ArchiveBox cannot archive its own admin, web, api, or snapshot URLs."})
def is_internal_input_url(self) -> bool:
return (self.url or "").strip() == self.INTERNAL_INPUT_URL and self.depth == 0 and bool(self.crawl_id)
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
validate_url_field = self._state.adding or update_fields is None or "url" in update_fields
@@ -1038,26 +1063,12 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if self._state.adding or update_fields is None or "notes" in update_fields:
self.notes = sanitize_html_text(self.notes)
# Migrate filesystem if needed (happens automatically on save)
existing_snapshot = self.pk and not self._state.adding
if existing_snapshot and self.fs_migration_needed:
self.migrate_filesystem_to_current_version()
update_fields = kwargs.get("update_fields")
if update_fields is not None:
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "fs_version", "modified_at"]))
elif existing_snapshot:
current_dir = self.get_storage_path_for_version(self._fs_current_version())
source_dir = Path(self.output_dir)
if source_dir.exists() and source_dir != current_dir and not source_dir.is_symlink():
self.migrate_filesystem_to_current_version(source_dir=source_dir)
super().save(*args, **kwargs)
from django.db import transaction
def finish_snapshot_save():
self.remove_legacy_archive_symlink()
self.ensure_crawl_symlink()
self.reconcile_filesystem_links()
crawl = Crawl.objects.filter(pk=self.crawl_id).first()
if crawl is None:
return
@@ -1074,50 +1085,13 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
Tag.objects.bulk_create(missing_tags, ignore_conflicts=True)
tags_by_name = {tag.name: tag for tag in Tag.objects.filter(name__in=crawl_tag_names)}
self.add_tag_ids([tag.pk for name in crawl_tag_names if (tag := tags_by_name.get(name))])
# Snapshot.save() normally appends newly created URLs to Crawl.urls
# so legacy/direct crawls can keep their queue text in sync. For
# internal-input crawls that would corrupt the original submitted
# import text; parsed URLs are represented by child Snapshot rows.
if crawl.has_internal_input_root():
return
if not crawl.url_passes_filters(self.url, snapshot=self, use_effective_config=False):
return
# Best-effort skip if our URL is already recorded on the crawl;
# the atomic UPDATE below is what actually prevents clobbering.
crawl.refresh_from_db(fields=["urls"])
if self.url in {url for _raw_line, url in crawl._iter_url_lines() if url}:
return
now = timezone.now()
# Atomic append: SQLite reads `urls` inside the UPDATE statement,
# so concurrent appends never clobber each other (no read-then-write
# window, no CAS retry needed). A rare duplicate URL on a race is
# harmless — downstream consumers dedupe via Snapshot uniqueness.
text = models.TextField()
type(crawl).objects.filter(pk=crawl.pk).update(
urls=Case(
When(Q(urls="") | Q(urls__isnull=True), then=Value(self.url, output_field=text)),
default=Concat(
"urls",
Value("\n", output_field=text),
Value(self.url, output_field=text),
output_field=text,
),
output_field=text,
),
modified_at=now,
)
crawl.modified_at = now
# Crawl.urls remains the original submitted source. Snapshot rows
# are the normalized work queue and discovery projection.
# get_or_create/update_or_create wrap save() in atomic(); defer filesystem
# work and crawl maintenance so SQLite commits before touching the disk.
transaction.on_commit(finish_snapshot_save)
migration_cleanup = self.__dict__.get("_pending_fs_migration_cleanup")
if migration_cleanup:
old_dir, new_dir = migration_cleanup
transaction.on_commit(lambda: self._cleanup_old_migration_dir(old_dir, new_dir))
delattr(self, "_pending_fs_migration_cleanup")
# =========================================================================
# Filesystem Migration Methods
# =========================================================================
@@ -1159,30 +1133,41 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
def migrate_filesystem_to_current_version(self, source_dir: Path | None = None, config: "ArchiveBoxBaseConfig | None" = None) -> None:
"""
Copy legacy snapshot output into the current layout and defer old-dir cleanup.
Copy legacy snapshot output into the current layout and safely remove the old tree.
The ordering is intentionally crash-safe:
1. Copy from the legacy directory into the new directory idempotently.
2. Verify the new directory has every old file.
3. Convert metadata in the new directory.
4. Update fs_version in memory for the caller to save.
5. Cleanup is scheduled only after the DB commit succeeds.
4. Remove the verified legacy source.
5. Persist fs_version last, so an interruption remains selected by the
indexed stale-version query and resumes naturally.
Re-running this method also reconciles a legacy timestamp directory
left behind when a machine stopped after the database commit but before
the on-commit cleanup callback ran.
"""
current = self.fs_version
target = self._fs_current_version()
cleanup: tuple[Path, Path] | None = None
runtime_config = config or get_config()
if source_dir and current == target:
if current == target:
current_dir = self.get_storage_path_for_version(target)
cleanup = self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir, target_dir=current_dir)
legacy_dir = Path(source_dir) if source_dir else CONSTANTS.ARCHIVE_DIR / self.timestamp
cleanup = self._fs_migrate_legacy_to_0_9_0(source_dir=legacy_dir, target_dir=current_dir)
crawl_dir = self.crawl.output_dir
old_crawl_dir = crawl_dir.with_name(str(uuid.UUID(hex=self.crawl.id.hex)))
if old_crawl_dir.exists() and not crawl_dir.exists() and not old_crawl_dir.is_symlink():
crawl_dir.parent.mkdir(parents=True, exist_ok=True)
old_crawl_dir.rename(crawl_dir)
if current_dir.exists():
self.hydrate_archiveresult_output_metadata(snapshot_dir=current_dir)
if cleanup:
self._pending_fs_migration_cleanup = cleanup
old_dir, new_dir = cleanup
if not self._cleanup_old_migration_dir(old_dir, new_dir):
raise SnapshotMigrationError(f"Could not clean up verified migration directory: {old_dir}")
self.reconcile_filesystem_links()
return
while current != target:
@@ -1206,8 +1191,19 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
self.fs_version = current
source_dir = None
target_dir = self.get_storage_path_for_version(target)
if target_dir.exists():
self.hydrate_archiveresult_output_metadata(snapshot_dir=target_dir)
if cleanup:
self._pending_fs_migration_cleanup = cleanup
old_dir, new_dir = cleanup
if not self._cleanup_old_migration_dir(old_dir, new_dir):
raise SnapshotMigrationError(f"Could not clean up verified migration directory: {old_dir}")
if self.pk:
now = timezone.now()
type(self).objects.filter(pk=self.pk).update(fs_version=target, modified_at=now)
self.modified_at = now
self.reconcile_filesystem_links()
def _fs_migrate_from_0_7_0_to_0_9_0(self, source_dir: Path | None = None, config: "ArchiveBoxBaseConfig | None" = None):
return self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir, config=config)
@@ -1253,7 +1249,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if old_dir == new_dir:
self.convert_index_json_to_jsonl(output_dir=new_dir)
self.hydrate_archiveresult_output_metadata(snapshot_dir=new_dir)
return None
if old_dir.is_symlink():
@@ -1264,13 +1259,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if not points_to_target:
raise SnapshotMigrationError(f"Legacy output symlink does not point to the expected target: {old_dir}")
self.convert_index_json_to_jsonl(output_dir=new_dir)
self.hydrate_archiveresult_output_metadata(snapshot_dir=new_dir)
return None
return (old_dir, new_dir)
if not old_dir.exists():
if new_dir.exists():
self.convert_index_json_to_jsonl(output_dir=new_dir)
self.hydrate_archiveresult_output_metadata(snapshot_dir=new_dir)
return None
return None
@@ -1282,7 +1275,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
pass
else:
self.convert_index_json_to_jsonl(output_dir=new_dir)
self.hydrate_archiveresult_output_metadata(snapshot_dir=new_dir)
return (old_dir, new_dir)
def copy_file_without_overwriting(source: str, destination: str):
@@ -1328,17 +1320,51 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
# Convert index.json to index.jsonl in the new directory.
self.convert_index_json_to_jsonl(output_dir=new_dir)
self.hydrate_archiveresult_output_metadata(snapshot_dir=new_dir)
return (old_dir, new_dir)
def _cleanup_old_migration_dir(self, old_dir: Path, new_dir: Path):
@staticmethod
def _migration_trees_match(old_dir: Path, new_dir: Path) -> bool:
"""Verify every legacy entry exists unchanged at the destination."""
import filecmp
if old_dir.is_symlink():
try:
return new_dir.exists() and old_dir.resolve() == new_dir.resolve()
except OSError:
return False
if not old_dir.exists():
return True
if not new_dir.is_dir() or new_dir.is_symlink():
return False
for source in old_dir.rglob("*"):
destination = new_dir / source.relative_to(old_dir)
if source.is_symlink():
copied = destination.is_symlink() and destination.readlink() == source.readlink()
elif source.is_dir():
copied = destination.is_dir() and not destination.is_symlink()
elif source.is_file():
copied = destination.is_file() and not destination.is_symlink() and filecmp.cmp(source, destination, shallow=False)
else:
copied = False
if not copied:
return False
return True
def _cleanup_old_migration_dir(self, old_dir: Path, new_dir: Path) -> bool:
"""Delete the old directory after its contents are verified at the new path."""
import logging
import shutil
from archivebox.config.permissions import SudoPermission
if not self._migration_trees_match(old_dir, new_dir):
logging.getLogger("archivebox.migration").warning(
f"Refusing to remove unverified migration directory {old_dir}",
)
return False
# Delete old directory
if old_dir.exists() and not old_dir.is_symlink():
try:
@@ -1350,11 +1376,12 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
logging.getLogger("archivebox.migration").warning(
f"Could not remove old migration directory {old_dir}: {e}",
)
return
return False
# Older migration runs may already have left a timestamp projection.
if old_dir.is_symlink():
old_dir.unlink(missing_ok=True)
return True
# =========================================================================
# Path Calculation and Migration Helpers
@@ -1786,8 +1813,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
def _merge_tags_from_index(self, index_data: dict):
"""Merge tags - union of both sources."""
from django.db import transaction
index_tags = set(index_data.get("tags", "").split(",")) if index_data.get("tags") else set()
index_tags = {t.strip() for t in index_tags if t.strip()}
@@ -1795,14 +1820,13 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
new_tags = index_tags - db_tags
if new_tags:
with transaction.atomic():
for tag_name in new_tags:
tag, _ = Tag.objects.get_or_create(name=tag_name)
self.add_tag_ids([tag.pk])
for tag_name in new_tags:
tag, _ = Tag.get_or_create_by_name(tag_name)
self.add_tag_ids([tag.pk])
def _merge_archive_results_from_index(self, index_data: dict, update_existing: bool = True):
"""Merge ArchiveResults one row per plugin; retries update the existing row."""
existing = {ar.plugin: ar for ar in ArchiveResult.objects.filter(snapshot=self)}
"""Merge ArchiveResults one row per hook; retries update the existing row."""
existing = {(ar.plugin, ar.hook_name): ar for ar in ArchiveResult.objects.filter(snapshot=self)}
if update_existing:
for archiveresult in existing.values():
normalized_status = ArchiveResult.normalize_status(archiveresult.status)
@@ -1865,7 +1889,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
output_mimetypes = result_data.get("output_mimetypes", "")
hook_name = result_data.get("hook_name", "")
existing_result = existing.get(plugin)
existing_result = existing.get((plugin, hook_name))
if existing_result:
if not update_existing:
return
@@ -1874,9 +1898,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if existing_result.status != status:
existing_result.status = status
update_fields.append("status")
if hook_name and existing_result.hook_name != hook_name:
existing_result.hook_name = hook_name
update_fields.append("hook_name")
if output_str and existing_result.output_str != output_str:
existing_result.output_str = output_str
update_fields.append("output_str")
@@ -1933,7 +1954,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
end_ts=end_ts,
process=process,
)
existing[plugin] = archiveresult
existing[(plugin, hook_name)] = archiveresult
def write_index_json(self):
"""Write index.json in 0.9.x format (deprecated, use write_index_jsonl)."""
@@ -2263,15 +2284,18 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
for tag in dup.tags.all():
keeper.add_tag_ids([tag.pk])
# Move ArchiveResults while preserving the one-row-per-plugin invariant.
# Move each hook result, merging only an exact identity collision.
for result in ArchiveResult.objects.filter(snapshot=dup):
existing = ArchiveResult.objects.filter(snapshot=keeper, plugin=result.plugin).first()
existing = ArchiveResult.objects.filter(
snapshot=keeper,
plugin=result.plugin,
hook_name=result.hook_name,
).first()
if existing is None:
result.snapshot = keeper
result.save(update_fields=["snapshot", "modified_at"])
continue
prefer_result = result.output_size > existing.output_size
prior_output_sizes = (existing.output_size, result.output_size)
output_files = {**(existing.output_files or {}), **(result.output_files or {})}
existing.output_files = output_files
existing.output_size = max(
@@ -2280,13 +2304,15 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
for metadata in output_files.values()
if isinstance(metadata, dict)
),
*prior_output_sizes,
existing.output_size,
result.output_size,
)
if prefer_result:
if result.modified_at >= existing.modified_at:
existing.status = result.status
existing.hook_name = result.hook_name
existing.output_str = result.output_str
existing.output_json = result.output_json
existing.start_ts = result.start_ts
existing.end_ts = result.end_ts
existing.output_mimetypes = ",".join(
sorted(
{
@@ -2667,6 +2693,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if points_to_current_path:
legacy_path.unlink(missing_ok=True)
def reconcile_filesystem_links(self) -> None:
"""Repair filesystem projections after a save or migration."""
self.remove_legacy_archive_symlink()
self.ensure_crawl_symlink()
@cached_property
def legacy_archive_path(self) -> str:
return f"{CONSTANTS.ARCHIVE_DIR_NAME}/{self.timestamp}"
@@ -2765,22 +2796,13 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
def archive_size(self):
return int(self.output_size or 0)
def save_tags(self, tags: Iterable[str] = ()) -> None:
tags_id = [Tag.objects.get_or_create(name=tag)[0].pk for tag in tags if tag.strip()]
self.tags.clear()
self.add_tag_ids(tags_id)
def save_tags(self, tags: Iterable[str] = (), *, created_by: Any = None) -> None:
from archivebox.core.tag_util import get_or_create_tag
def pending_archiveresults(self) -> QuerySet["ArchiveResult"]:
return self.archiveresult_set.exclude(status__in=ArchiveResult.FINAL_OR_ACTIVE_STATES)
def run(self) -> list["ArchiveResult"]:
"""
Execute snapshot by creating pending ArchiveResults for all enabled hooks.
Returns:
list[ArchiveResult]: Newly created pending results
"""
return self.create_pending_archiveresults()
tag_ids = {get_or_create_tag(tag, created_by=created_by)[0].pk for tag in tags if tag.strip()}
existing_tag_ids = set(SnapshotTag.objects.filter(snapshot_id=self.pk).values_list("tag_id", flat=True))
self.remove_tag_ids(existing_tag_ids - tag_ids)
self.add_tag_ids(tag_ids - existing_tag_ids)
def finalize_output_metadata(self) -> None:
"""
@@ -3028,67 +3050,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return snapshot
def create_pending_archiveresults(self, hooks: Iterable[tuple[str, str]] | None = None) -> list["ArchiveResult"]:
"""
Create ArchiveResult records for all enabled hooks.
Uses the hooks system to discover available hooks from:
- abx_plugins/plugins/*/on_Snapshot__*.{py,sh,js}
- data/custom_plugins/*/on_Snapshot__*.{py,sh,js}
Creates one ArchiveResult per plugin. A queued plugin row runs all of that
plugin's Snapshot hooks; hook_name records the most recently completed hook.
"""
try:
self.validate_url_for_archiving()
except ValidationError as err:
rprint(f"[yellow][!] Skipping blocked snapshot URL: {(self.url or '')[:120]}... ({err})[/yellow]")
return []
if hooks is None:
from archivebox.config.common import get_config
from archivebox.plugins.hooks import discover_hooks
# Compatibility path for direct model callers. The runner passes its
# abx-dl hook inventory explicitly so queued rows match execution.
config = get_config(crawl=self.crawl, snapshot=self)
hooks = ((hook_path.parent.name, hook_path.stem) for hook_path in discover_hooks("Snapshot", config=config))
archiveresults = []
for plugin in dict.fromkeys(plugin for plugin, _hook_name in hooks):
# ArchiveResult output is one filesystem directory per plugin, so all
# hooks and retries update this row in place instead of creating siblings.
archiveresult, _created = ArchiveResult.objects.get_or_create(
snapshot=self,
plugin=plugin,
defaults={
"hook_name": "",
"status": ArchiveResult.INITIAL_STATE,
},
)
if archiveresult.hook_name == self.BROWSER_EXTENSION_UPLOAD_HOOK_NAME:
archiveresult.hook_name = ""
archiveresult.status = ArchiveResult.INITIAL_STATE
archiveresult.save(update_fields=["hook_name", "status", "modified_at"])
if archiveresult.status == ArchiveResult.INITIAL_STATE:
archiveresults.append(archiveresult)
return archiveresults
def is_finished_processing(self) -> bool:
"""
Check if all ArchiveResults are finished.
Note: This is only called for observability/progress tracking.
The shared runner owns execution and does not poll this.
"""
# Check if any ARs are still pending/started
pending = self.archiveresult_set.exclude(
status__in=ArchiveResult.FINAL_STATES,
).exists()
return not pending
def get_progress_stats(self) -> dict:
"""
Get progress statistics for this snapshot's archiving process.
@@ -3149,34 +3110,18 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
}
def retry_failed_archiveresults(self) -> int:
"""
Reset failed ArchiveResults to queued for retry.
Returns count of ArchiveResults reset.
"""
retryable_results = ArchiveResult.objects.filter(
snapshot=self,
status=ArchiveResult.StatusChoices.FAILED,
"""Queue the parent Snapshot to rerun plugins with failed facts."""
plugins = list(
self.archiveresult_set.filter(status=ArchiveResult.StatusChoices.FAILED)
.exclude(plugin="")
.order_by("plugin")
.values_list("plugin", flat=True)
.distinct(),
)
now = timezone.now()
count = retryable_results.update(
status=ArchiveResult.StatusChoices.QUEUED,
hook_name="",
output_str="",
output_json=None,
output_files={},
output_size=0,
output_mimetypes="",
start_ts=None,
end_ts=None,
modified_at=now,
)
if count > 0:
self.refresh_from_db(fields=["modified_at", "retry_at", "status"])
self.queue_for_extraction(when=now)
return count
if not plugins:
return 0
self.schedule_plugin_run(plugins)
return len(plugins)
# =========================================================================
# URL Helper Properties (migrated from Link schema)
@@ -3850,6 +3795,27 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
"backoff": cls.StatusChoices.BACKOFF,
}.get(str(status or "").strip().lower(), cls.StatusChoices.FAILED)
@classmethod
def get_or_create_by_hook(
cls,
snapshot: Snapshot,
plugin: str,
hook_name: str,
*,
defaults: Mapping[str, Any] | None = None,
) -> tuple["ArchiveResult", bool]:
lookup = {"snapshot": snapshot, "plugin": plugin, "hook_name": hook_name}
result = cls.objects.filter(**lookup).first()
if result:
return result, False
try:
return cls.objects.create(**lookup, **(defaults or {})), True
except IntegrityError:
result = cls.objects.filter(**lookup).first()
if result is None:
raise
return result, False
@staticmethod
def output_files_upload_complete(output_files: dict[str, dict[str, Any]]) -> bool:
if not output_files:
@@ -3984,7 +3950,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
models.Index(fields=["-start_ts", "-id"], name="archiveresult_start_idx"),
]
constraints: ClassVar[list[models.BaseConstraint]] = [
models.UniqueConstraint(fields=["snapshot", "plugin"], name="unique_archiveresult_per_snapshot_plugin"),
models.UniqueConstraint(fields=["snapshot", "plugin", "hook_name"], name="unique_archiveresult_per_snapshot_hook"),
]
def __str__(self):
@@ -4095,16 +4061,16 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
except ArchiveResult.DoesNotExist:
pass
# Get or create by snapshot_id + plugin. The filesystem has one output
# directory per plugin, so every hook and retry updates that same DB row.
# Get or create by the durable scheduler identity. Hooks in one plugin
# share an output directory, while retries update only their exact row.
try:
snapshot = Snapshot.objects.get(id=snapshot_id)
result, _ = ArchiveResult.objects.get_or_create(
snapshot=snapshot,
plugin=plugin,
result, _ = ArchiveResult.get_or_create_by_hook(
snapshot,
plugin,
record.get("hook_name", ""),
defaults={
"hook_name": record.get("hook_name", ""),
"status": record.get("status", "queued"),
"output_str": record.get("output_str", ""),
},
@@ -4168,13 +4134,40 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
),
)
def safe_update(self, update_fields: Mapping[str, Any], *, refresh: bool = True) -> bool:
"""Compare-and-swap one loaded ArchiveResult without opening a transaction."""
expected_modified_at = self.modified_at
previous_output_size = int(self.output_size or 0)
values = dict(update_fields)
values.setdefault("modified_at", timezone.now())
updated = type(self).objects.filter(pk=self.pk, modified_at=expected_modified_at).update(**values)
if updated == 1:
for field, value in values.items():
setattr(self, field, value)
if "output_size" in values:
size_delta = int(values["output_size"] or 0) - previous_output_size
if size_delta:
Snapshot.objects.filter(pk=self.snapshot_id).update(
output_size=F("output_size") + size_delta,
modified_at=timezone.now(),
)
if refresh:
try:
self.refresh_from_db()
except type(self).DoesNotExist:
pass
return updated == 1
def schedule_delete_cleanup(self, *, using: str | None = None) -> None:
"""Remove shared plugin output and refresh persisted Snapshot metadata after commit."""
snapshot_id = self.snapshot_id
plugin = self.plugin
paths = self.validate_output_paths_for_delete(self.output_paths_for_delete())
def cleanup() -> None:
type(self).delete_output_paths(paths)
results = type(self).objects.using(using) if using else type(self).objects
if not results.filter(snapshot_id=snapshot_id, plugin=plugin).exists():
type(self).delete_output_paths(paths)
type(self).refresh_snapshot_output_sizes({snapshot_id})
snapshot = Snapshot.objects.filter(pk=snapshot_id).first()
if snapshot:
@@ -4206,82 +4199,10 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
def get_absolute_url(self):
return f"/{self.snapshot.archive_path}/{self.plugin}"
def reset_for_retry(self, *, save: bool = True) -> None:
self.status = self.StatusChoices.QUEUED
self.hook_name = ""
self.retry_at = None
self.output_str = ""
self.output_json = None
self.output_files = {}
self.output_size = 0
self.output_mimetypes = ""
self.start_ts = None
self.end_ts = None
if save:
self.save(
update_fields=[
"status",
"hook_name",
"retry_at",
"output_str",
"output_json",
"output_files",
"output_size",
"output_mimetypes",
"start_ts",
"end_ts",
"modified_at",
],
)
@property
def is_paused(self) -> bool:
return self.status == self.StatusChoices.PAUSED
@classmethod
def pause_queryset(cls, queryset) -> int:
return queryset.exclude(status__in=[*cls.FINAL_STATES, cls.StatusChoices.PAUSED]).update(
status=cls.StatusChoices.PAUSED,
retry_at=RETRY_AT_MAX,
modified_at=timezone.now(),
)
@classmethod
def resume_queryset(cls, queryset, *, when: datetime | None = None) -> int:
return queryset.filter(status=cls.StatusChoices.PAUSED).update(
status=cls.StatusChoices.QUEUED,
retry_at=when or timezone.now(),
modified_at=timezone.now(),
)
def pause(self, *, save: bool = True) -> bool:
if self.status in self.FINAL_STATES:
return False
if self.is_paused:
return False
self.status = self.StatusChoices.PAUSED
self.retry_at = RETRY_AT_MAX
if save:
self.pause_queryset(type(self).objects.filter(pk=self.pk))
self.refresh_from_db()
return True
def resume(self, *, when: datetime | None = None, save: bool = True) -> bool:
if not self.is_paused:
return False
self.status = self.StatusChoices.QUEUED
self.retry_at = when or timezone.now()
if save:
self.resume_queryset(type(self).objects.filter(pk=self.pk), when=self.retry_at)
self.refresh_from_db()
return True
@property
def plugin_module(self) -> Any | None:
# Hook scripts are now used instead of Python plugin modules
# The plugin name maps to hooks in abx_plugins/plugins/{plugin}/
return None
@staticmethod
def _normalize_output_files(raw_output_files: Any) -> dict[str, dict[str, Any]]:
from abx_dl.output_files import OutputManifest
@@ -4301,14 +4222,6 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
def output_file_paths(self) -> list[str]:
return list(self.output_file_map().keys())
def output_file_count(self) -> int:
return len(self.output_file_paths())
def output_size_from_files(self) -> int:
from abx_dl.output_files import OutputManifest
return OutputManifest.from_value(self.output_files).total_size
def update_output_metadata_from_filesystem(self, snapshot_dir: Path | None = None, save: bool = True) -> bool:
from abx_dl.output_files import OutputManifest, output_file_from_path
@@ -4369,9 +4282,6 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
self.save(update_fields=["output_files", "output_size", "output_mimetypes", "modified_at"])
return True
def output_exists(self) -> bool:
return os.path.exists(Path(self.snapshot_dir) / self.plugin)
@staticmethod
def _looks_like_output_path(raw_output: str | None, plugin_name: str | None = None) -> bool:
value = str(raw_output or "").strip()
@@ -4470,12 +4380,26 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
if Path(candidate).name.lower() == preferred_name:
return candidate
ext_groups = (
(".html", ".htm", ".mhtml", ".mht", ".pdf"),
(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"),
(".json", ".jsonl", ".txt", ".md", ".csv", ".tsv"),
(".mp4", ".webm", ".mp3", ".opus", ".ogg", ".wav"),
)
plugin_lower = (plugin_name or "").lower()
if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl"):
# yt-dlp commonly emits a thumbnail plus several media formats.
# Prefer something browsers can play directly; otherwise cards can
# select a large MKV or thumbnail that the plugin player cannot use.
ext_groups = (
(".mp4", ".webm", ".m4v", ".ogv"),
(".mp3", ".m4a", ".aac", ".opus", ".ogg", ".wav", ".flac"),
(".mkv", ".mov", ".avi", ".flv", ".wmv", ".mpg", ".mpeg", ".ts", ".m2ts", ".mts", ".3gp", ".3g2"),
(".html", ".htm", ".mhtml", ".mht", ".pdf"),
(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"),
(".json", ".jsonl", ".txt", ".md", ".csv", ".tsv", ".srt", ".vtt"),
)
else:
ext_groups = (
(".html", ".htm", ".mhtml", ".mht", ".pdf"),
(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"),
(".json", ".jsonl", ".txt", ".md", ".csv", ".tsv"),
(".mp4", ".webm", ".mp3", ".opus", ".ogg", ".wav"),
)
for ext_group in ext_groups:
group_candidates = [candidate for candidate in candidates if Path(candidate).suffix.lower() in ext_group]
if group_candidates:
@@ -4633,50 +4557,6 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
process = self.process_record
return process.timeout if process else 120
def save_search_index(self):
pass
def _set_binary_from_cmd(self, cmd: list) -> None:
"""
Find Binary for command and set binary FK.
Tries matching by absolute path first, then by binary name.
Only matches binaries on the current machine.
"""
if not cmd:
return
from archivebox.machine.models import Machine
bin_path_or_name = cmd[0] if isinstance(cmd, list) else cmd
machine = Machine.current()
# Try matching by absolute path first
binary = Binary.objects.filter(
abspath=bin_path_or_name,
machine=machine,
).first()
if binary:
process = self.process_record
if process:
process.binary = binary
process.save()
return
# Fallback: match by binary name
bin_name = Path(bin_path_or_name).name
binary = Binary.objects.filter(
name=bin_name,
machine=machine,
).first()
if binary:
process = self.process_record
if process:
process.binary = binary
process.save()
def _url_passes_filters(self, url: str) -> bool:
"""Check if URL passes URL_ALLOWLIST and URL_DENYLIST config filters.
+47 -73
View File
@@ -10,6 +10,11 @@ def _is_signal_interrupted_exit(exit_code: int | None) -> bool:
return exit_code is not None and (exit_code < 0 or exit_code >= 128)
def _canonical_hook_name(hook_name: str) -> str:
hook_name = Path(hook_name).name
return Path(hook_name).stem if Path(hook_name).suffix in {".py", ".js", ".sh"} else hook_name
def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str | None = None) -> dict[str, int]:
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
@@ -31,9 +36,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
"crawls_queued_without_retry_at": 0,
"snapshots_queued_without_retry_at": 0,
"snapshots_sealed_with_extension_uploads_only": 0,
"archiveresults_backoff": 0,
"snapshots_queued_plugin_rows_waiting_on_stale_lease": 0,
"archiveresults_started_without_running_process": 0,
"archiveresults_interrupted_without_running_process": 0,
"archiveresults_missing_for_orphaned_hook_processes": 0,
"snapshots_started_without_running_results": 0,
"crawls_started_with_due_snapshots": 0,
@@ -41,10 +44,10 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
"crawls_started_without_active_snapshots": 0,
}
running_archiveresults = ArchiveResult.objects.filter(
snapshot_id=OuterRef("pk"),
status=ArchiveResult.StatusChoices.STARTED,
process__status=Process.StatusChoices.RUNNING,
running_hook_processes = Process.objects.filter(
archiveresult__snapshot_id=OuterRef("pk"),
process_type=Process.TypeChoices.HOOK,
status=Process.StatusChoices.RUNNING,
)
active_child_snapshots = Snapshot.objects.filter(
crawl_id=OuterRef("pk"),
@@ -104,49 +107,18 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
retry_at=now,
modified_at=now,
)
backoff_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.BACKOFF, **result_filter)
orphaned_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED, **result_filter).exclude(
process__status=Process.StatusChoices.RUNNING,
)
# ArchiveResult has no retry_at scheduler. Wake only the parent Snapshots
# for result rows we are about to repair, then requeue those exact rows.
# Using subqueries keeps million-row recovery in SQLite instead of building
# Python ID lists or scanning all sealed snapshots.
Snapshot.objects.filter(
id__in=backoff_results.values("snapshot_id"),
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
retry_at__isnull=True,
).update(retry_at=now, modified_at=now)
# ArchiveResult rows are projections, never work items. Close interrupted
# projections as failed and wake their parent Snapshot so abx-dl can replay
# the snapshot-level sequence. Indexed subqueries keep this bounded.
Snapshot.objects.filter(
id__in=orphaned_results.values("snapshot_id"),
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
retry_at__isnull=True,
status=Snapshot.StatusChoices.STARTED,
).update(retry_at=now, modified_at=now)
cleaned["archiveresults_backoff"] = backoff_results.update(status=ArchiveResult.StatusChoices.QUEUED, modified_at=now)
# Targeted plugin rows on final/paused Snapshots are scheduled through the
# parent Snapshot.retry_at. retry_at=NULL is the normal idle marker for a
# sealed Snapshot and must not be interpreted as queued work just because
# old/synthetic ArchiveResult rows exist. If takeover kills the runner
# after it leases the Snapshot but before queued ArchiveResult rows finish,
# the rows remain QUEUED while retry_at sits in the future. Recovery runs
# only after this runner has won the single-runner gate, so it can safely
# unlock those stale plugin leases for immediate processing instead of
# waiting out the previous owner's full lock timeout.
queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED, **result_filter)
cleaned["snapshots_queued_plugin_rows_waiting_on_stale_lease"] = (
Snapshot.objects.filter(
id__in=queued_plugin_results.values("snapshot_id"),
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
)
.filter(retry_at__gt=now)
.update(retry_at=now, modified_at=now)
)
# Impossible state repair: STARTED ArchiveResults without a live Process
# have no owner left to emit completion. Requeue only the result row; the
# snapshot/crawl schedulers will pick up normal retry processing.
cleaned["archiveresults_started_without_running_process"] = orphaned_results.update(
status=ArchiveResult.StatusChoices.QUEUED,
process=None,
cleaned["archiveresults_interrupted_without_running_process"] = orphaned_results.update(
status=ArchiveResult.StatusChoices.FAILED,
modified_at=now,
)
orphaned_hook_processes = Process.objects.filter(
@@ -168,14 +140,23 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
orphaned_hook_processes = orphaned_hook_processes.filter(snapshot_pwd_filter)
else:
orphaned_hook_processes = orphaned_hook_processes.none()
for process in orphaned_hook_processes.only("id", "pwd", "cmd", "process_type", "status", "started_at", "ended_at").order_by(
"-started_at",
"-id",
):
for process in orphaned_hook_processes.only(
"id",
"pwd",
"cmd",
"process_type",
"status",
"exit_code",
"stdout",
"stderr",
"started_at",
"ended_at",
).order_by("-started_at", "-id"):
hook_script_name = process.hook_script_name
if not hook_script_name or not process.pwd:
continue
plugin_dir = Path(process.pwd)
hook_name = _canonical_hook_name(hook_script_name)
if crawl_snapshot_ids is not None and plugin_dir.parent.name not in crawl_snapshot_ids:
continue
try:
@@ -187,19 +168,20 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
continue
if snapshot is None:
continue
result, created = ArchiveResult.objects.get_or_create(
snapshot=snapshot,
plugin=plugin_dir.name,
result, created = ArchiveResult.get_or_create_by_hook(
snapshot,
plugin_dir.name,
hook_name,
defaults={
"hook_name": Path(hook_script_name).stem,
"status": ArchiveResult.StatusChoices.QUEUED,
"status": ArchiveResult.StatusChoices.FAILED,
},
)
if created or result.status == ArchiveResult.StatusChoices.QUEUED:
process_is_newer = bool(process.started_at and (result.start_ts is None or process.started_at >= result.start_ts))
if created or process_is_newer:
requeue_snapshot = False
# A runner can die after the hook Process exits but before the
# ProcessCompletedEvent projector links/finalizes ArchiveResult.
# Reconstruct the plugin row from its newest durable Process row.
# Reconstruct only that exact hook row from the durable Process row.
manifest = OutputManifest.scan(plugin_dir, containment_root=snapshot.output_dir)
output_files = manifest.as_mapping()
output_size = manifest.total_size
@@ -209,23 +191,21 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
for record in Process.parse_records_from_text(process.stdout or "")
if record.get("type") == "ArchiveResult"
and (record.get("plugin") or plugin_dir.name) == plugin_dir.name
and (record.get("hook_name") or Path(hook_script_name).stem) == Path(hook_script_name).stem
and _canonical_hook_name(str(record.get("hook_name") or hook_name)) == hook_name
]
emitted_result = emitted_records[-1] if emitted_records else {}
result.hook_name = Path(hook_script_name).stem
result.process = process
result.start_ts = process.started_at
result.end_ts = process.ended_at
if _is_signal_interrupted_exit(process.exit_code):
# The owning runner died or was asked to stop while the hook was
# still active. Keep the work item queued so takeover retries the
# same hook; treating an unknown signal exit as success would
# silently skip unfinished side effects.
# The Process is a durable fact; record the interruption as a
# failure and wake the parent Snapshot for replay.
result.output_files = {}
result.output_size = 0
result.output_mimetypes = ""
result.output_str = ""
result.status = ArchiveResult.StatusChoices.QUEUED
result.output_json = None
result.status = ArchiveResult.StatusChoices.FAILED
requeue_snapshot = True
else:
result.output_files = output_files
@@ -249,7 +229,6 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
)
result.save(
update_fields=[
"hook_name",
"process",
"start_ts",
"end_ts",
@@ -266,7 +245,6 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
if created:
cleaned["archiveresults_missing_for_orphaned_hook_processes"] += 1
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
started_snapshots = Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED).filter(
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
**snapshot_filter,
@@ -280,8 +258,8 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
# We only unlock scheduling; normal Snapshot runner code owns the next
# transition and side effects.
cleaned["snapshots_started_without_running_results"] = (
started_snapshots.annotate(has_running_results=Exists(running_archiveresults))
.filter(has_running_results=False)
started_snapshots.annotate(has_running_process=Exists(running_hook_processes))
.filter(has_running_process=False)
.update(
retry_at=now,
modified_at=now,
@@ -338,13 +316,9 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
"Finishing {count} browser-extension Snapshot(s) that received uploaded files before server extractors started "
"(uploaded and server-created results will remain together on the same Snapshot)."
),
"archiveresults_backoff": (
"Retrying {count} extractor result(s) that were waiting to retry "
"(ArchiveBox may have been interrupted before it was able to try them again; affected outputs will be retried)."
),
"archiveresults_started_without_running_process": (
"Retrying {count} extractor result(s) that were interrupted before finishing "
"(ArchiveBox may have been interrupted before it was able to save their final status; partial files will be overwritten with fresh results upon retry)."
"archiveresults_interrupted_without_running_process": (
"Closing {count} interrupted extractor result projection(s) "
"(their parent Snapshots are resumed through the normal snapshot-level runner)."
),
"snapshots_started_without_running_results": (
"Resuming {count} Snapshot(s) that were interrupted before finishing "
+2 -9
View File
@@ -152,15 +152,8 @@ def get_or_create_tag(name: str, created_by: User | None = None) -> tuple[Tag, b
if not normalized_name:
raise ValueError("Tag name is required")
existing = Tag.objects.filter(name__iexact=normalized_name).first()
if existing:
return existing, False
tag = Tag.objects.create(
name=normalized_name,
created_by=created_by,
)
return tag, True
defaults = {"created_by": created_by} if created_by is not None else None
return Tag.get_or_create_by_name(normalized_name, defaults=defaults)
def rename_tag(tag: Tag, name: str) -> Tag:
+21 -2
View File
@@ -32,7 +32,7 @@ _TEXT_PREVIEW_EXTS = (".json", ".jsonl", ".txt", ".csv", ".tsv", ".xml", ".yml",
_IMAGE_PREVIEW_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif")
_STATIC_URL_SAFE = "/@-._~!$&'()*+,;="
_MEDIA_FILE_EXTS = {
_VIDEO_FILE_EXTS = {
".mp4",
".webm",
".mkv",
@@ -49,6 +49,9 @@ _MEDIA_FILE_EXTS = {
".3gp",
".3g2",
".ogv",
}
_AUDIO_FILE_EXTS = {
".mp3",
".m4a",
".aac",
@@ -66,6 +69,10 @@ _MEDIA_FILE_EXTS = {
".dts",
}
_MEDIA_FILE_EXTS = _VIDEO_FILE_EXTS | _AUDIO_FILE_EXTS
_BROWSER_VIDEO_FILE_EXTS = {".mp4", ".webm", ".m4v", ".ogv"}
_BROWSER_AUDIO_FILE_EXTS = {".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".wav", ".flac"}
def _normalize_output_files(output_files: Any) -> dict[str, dict[str, Any]]:
from abx_dl.output_files import OutputManifest
@@ -154,15 +161,27 @@ def _list_media_files(result) -> list[dict]:
for rel_path, size in candidates:
href = str(Path(result.plugin) / rel_path)
suffix = rel_path.suffix.lower()
media_type = "video" if suffix in _VIDEO_FILE_EXTS else "audio"
media_files.append(
{
"name": rel_path.name,
"path": href,
"size": size,
"media_type": media_type,
"is_video": media_type == "video",
"is_audio": media_type == "audio",
"is_browser_playable": suffix in _BROWSER_VIDEO_FILE_EXTS or suffix in _BROWSER_AUDIO_FILE_EXTS,
},
)
media_files.sort(key=lambda item: item["name"].lower())
media_files.sort(
key=lambda item: (
0 if item["is_video"] and item["is_browser_playable"] else 1 if item["is_audio"] and item["is_browser_playable"] else 2,
-int(item.get("size") or 0),
item["name"].lower(),
),
)
return media_files
+17 -16
View File
@@ -691,26 +691,27 @@ def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[s
plugin = parts[0]
plugin_relpath = posixpath.join(*parts[1:])
result = (
ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED)
.only("plugin", "output_files")
.first()
results = list(
ArchiveResult.objects.filter(
snapshot=snapshot,
plugin=plugin,
status=ArchiveResult.StatusChoices.SUCCEEDED,
).only("plugin", "output_files"),
)
if not result:
if not results:
return rel_path, None
if not result.output_files:
return rel_path, result
output_files = result.output_files or {}
for candidate in (plugin_relpath, rel_path):
file_info = output_files.get(candidate)
if not isinstance(file_info, dict):
continue
if file_info.get("root_relative"):
return candidate, result
return rel_path, result
for result in results:
output_files = result.output_files or {}
for candidate in (plugin_relpath, rel_path):
file_info = output_files.get(candidate)
if not isinstance(file_info, dict):
continue
if file_info.get("root_relative"):
return candidate, result
return rel_path, result
return rel_path, result
return rel_path, results[0]
def _plugin_full_preview_response(
+24 -82
View File
@@ -90,6 +90,8 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
@property
def last_run_at(self):
if self.kind == "update":
return self.modified_at
latest_crawl = self.crawl_set.order_by("-created_at").first()
if latest_crawl:
return latest_crawl.created_at
@@ -105,6 +107,22 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
now = now or timezone.now()
return self.is_enabled and self.next_run_at <= now
@property
def kind(self) -> str:
return str((self.config or {}).get("SCHEDULE_KIND") or "crawl")
def dispatch(self, queued_at=None) -> "Crawl | None":
"""Run maintenance directly or enqueue one ordinary Crawl."""
queued_at = queued_at or timezone.now()
if self.kind == "update":
from archivebox.cli.archivebox_update import run_scheduled_maintenance
run_scheduled_maintenance()
type(self).objects.filter(pk=self.pk).update(modified_at=queued_at)
self.modified_at = queued_at
return None
return self.enqueue(queued_at=queued_at)
def enqueue(self, queued_at=None) -> "Crawl":
from archivebox.config.common import build_crawl_config_snapshot
@@ -112,10 +130,11 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
template = self.template
label = template.label or self.label
persona = template.persona if template.persona_id else None
crawl_config = {key: value for key, value in (self.config or {}).items() if key != "SCHEDULE_KIND"}
return Crawl.objects.create(
urls=template.urls,
config=build_crawl_config_snapshot(persona=persona, overrides=self.config or {}),
config=build_crawl_config_snapshot(persona=persona, overrides=crawl_config),
max_depth=template.max_depth,
tags_str=template.tags_str,
persona_id=template.persona_id,
@@ -217,7 +236,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
def resume(self, *, when=None, save: bool = True) -> bool:
resumed = super().resume(when=when, save=save)
if resumed and self.pk:
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.models import Snapshot
resume_at = when or timezone.now()
active_snapshots = self.snapshot_set.filter(
@@ -228,7 +247,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
retry_at=resume_at,
modified_at=timezone.now(),
)
ArchiveResult.resume_queryset(ArchiveResult.objects.filter(snapshot__crawl=self), when=resume_at)
return resumed
def cancel(self) -> None:
@@ -269,8 +287,8 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
now = timezone.now()
# Parent pause is a scheduler command. Wake child rows only; each
# Snapshot runner claim performs the real pause transition and cascades
# its own ArchiveResults, keeping request/admin transactions tiny.
# Snapshot runner claim performs the real pause transition, keeping
# request/admin transactions tiny.
active_children = self.snapshot_set.filter(
status__in=Snapshot.RUNNABLE_STATES,
)
@@ -515,18 +533,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return []
return [url for _raw_line, url in self._iter_url_lines() if url]
def has_internal_input_root(self) -> bool:
"""Return True when Crawl.urls is preserved source text, not the work queue.
The runner creates a synthetic root snapshot for raw import text so
parser hooks use the same Snapshot lifecycle as every other extractor.
In that mode the submitted text must remain in Crawl.urls forever;
parsed URLs live as child Snapshot rows and should not be appended back.
"""
from archivebox.core.models import Snapshot
return self.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL, depth=0).exists()
@staticmethod
def normalize_domain(value: str) -> str:
candidate = (value or "").strip().lower()
@@ -796,15 +802,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
"deleted_snapshots": filter_result["deleted_snapshots"],
}
def get_system_task(self) -> str | None:
urls = self.get_urls_list()
if len(urls) != 1:
return None
system_url = urls[0].strip().lower()
if system_url.startswith("archivebox://"):
return system_url
return None
def resolve_persona(self):
from archivebox.personas.models import Persona
@@ -968,14 +965,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
if self.status == self.StatusChoices.SEALED:
return []
# Internal-input crawls preserve the submitted text verbatim in
# Crawl.urls. The root snapshot's parser hooks are the only supported
# path for turning that text into child snapshots, otherwise a later
# runner pass could reinterpret plain URL-looking lines as direct
# depth-0 work and bypass format-specific metadata parsing.
if self.has_internal_input_root():
return []
created_snapshots = []
crawl_tag_names = self.current_tag_names()
tags_by_name: dict[str, Tag] = {}
@@ -1191,7 +1180,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
),
}
if tag_names:
tag_ids = [Tag.objects.get_or_create(name=tag_name)[0].pk for tag_name in tag_names]
tag_ids = [Tag.get_or_create_by_name(tag_name)[0].pk for tag_name in tag_names]
snapshot.add_tag_ids(tag_ids)
existing_scope = Snapshot.objects if bool(self._config_value(config, "ONLY_NEW", True)) else self.snapshot_set
@@ -1248,16 +1237,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
if not created_snapshots:
return []
crawl_urls = {url for _raw_line, url in self._iter_url_lines() if url}
new_url_lines = [snapshot.url for snapshot in created_snapshots if snapshot.url not in crawl_urls]
# For internal-input crawls, Crawl.urls is the immutable source text.
# Child snapshots are the parsed/indexed representation, so appending
# discovered URLs here would both duplicate state and destroy the exact
# import artifact users submitted through CLI/API/UI.
if new_url_lines and not self.has_internal_input_root():
self.urls = (self.urls.rstrip() + "\n" + "\n".join(new_url_lines)).lstrip("\n")
self.save(update_fields=["urls", "modified_at"])
tag_names_by_url: dict[str, set[str]] = {}
for snapshot in created_snapshots:
tag_names = {
@@ -1293,43 +1272,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return created_snapshots
def install_declared_binaries(self, binary_names: set[str], machine=None) -> None:
"""Install crawl-declared binaries through their unified lifecycle."""
from archivebox.crawls.locks import binary_lifecycle_lock
from archivebox.machine.models import Binary, Machine
if not binary_names:
return
machine = machine or Machine.current()
binaries = Binary.objects.filter(machine=machine, name__in=binary_names).order_by("name")
for binary in binaries:
with binary_lifecycle_lock(str(binary.id)):
binary.refresh_from_db()
if binary.status == Binary.StatusChoices.INSTALLED:
continue
binary.update_and_requeue(retry_at=timezone.now())
binary.refresh_from_db()
binary.install_claimed(lock_seconds=600)
unresolved_binaries = list(
Binary.objects.filter(
machine=machine,
name__in=binary_names,
)
.exclude(
status=Binary.StatusChoices.INSTALLED,
)
.order_by("name"),
)
if unresolved_binaries:
binary_details = ", ".join(
f"{binary.name} (status={binary.status}, retry_at={binary.retry_at})" for binary in unresolved_binaries
)
raise RuntimeError(
f"Crawl dependencies failed to install before continuing: {binary_details}",
)
def is_finished(self) -> bool:
"""Check if crawl is finished (all snapshots sealed or no snapshots exist)."""
from archivebox.core.models import Snapshot
+18 -7
View File
@@ -380,29 +380,40 @@ def log_sqlite_lock_holders(console: Any, *, db_path: Path = CONSTANTS.DATABASE_
def sqlite_lock_error(error: BaseException) -> bool:
from django.db import OperationalError as DjangoOperationalError
return isinstance(error, (SQLiteOperationalError, DjangoOperationalError)) and "database is locked" in str(error).lower()
message = str(error).lower()
return isinstance(error, (SQLiteOperationalError, DjangoOperationalError)) and (
"database is locked" in message or "database table is locked" in message
)
def retry_sqlite_locks(action: Callable[[], Any], *, label: str, stderr: TextIO | None = None) -> Any:
from django.conf import settings
from django.db import OperationalError, connections
from rich.console import Console
console = Console(file=stderr or None, stderr=stderr is None)
started_at = time.monotonic()
retry_timeout = settings.CONFIG.SQLITE_LOCK_RETRY_TIMEOUT
retry_interval = settings.CONFIG.SQLITE_LOCK_RETRY_INTERVAL
while True:
try:
return action()
except OperationalError as err:
if "database is locked" not in str(err).lower():
raise
except SQLiteOperationalError as err:
except (OperationalError, SQLiteOperationalError) as err:
if not sqlite_lock_error(err):
raise
if retry_timeout:
remaining = retry_timeout - (time.monotonic() - started_at)
if remaining <= 0:
raise
sleep_for = min(retry_interval, remaining)
else:
sleep_for = retry_interval
connections.close_all()
console.print(f"[yellow][*] SQLite database is locked while {label}; retrying in 5s...[/yellow]")
console.print(f"[yellow][*] SQLite database is locked while {label}; retrying in {sleep_for:g}s...[/yellow]")
log_sqlite_lock_holders(console)
with console.status("[yellow]Waiting for SQLite database lock to clear...[/yellow]", spinner="dots"):
time.sleep(5.0)
time.sleep(sleep_for)
@contextmanager
+20
View File
@@ -33,6 +33,26 @@ from archivebox.misc.logging_util import printable_filesize
_HASHES_CACHE: dict[Path, tuple[float, dict[str, str]]] = {}
IMG_SRC_ATTR_RE = re.compile(r'(<img\b[^>]*?\s(?:src|data-src)=["\'])([^"\']+)(["\'])', re.IGNORECASE)
TRANSFORMED_HTML_PREVIEW_STYLE = """<style id="archivebox-static-html-preview-style">
html {
width: 100%;
min-width: 100%;
background: #fff;
}
body {
box-sizing: border-box;
width: min(100%, 72rem);
max-width: none;
min-height: 100vh;
margin: 0 auto;
padding: clamp(1rem, 3vw, 2rem);
background: #fff;
color: #111827;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.55;
}
body > * {
max-width: 100%;
}
img:not([width]):not([height]) {
max-width: min(100%, 12rem);
max-height: 12rem;
+4 -1
View File
@@ -192,7 +192,10 @@ def validate_url_length(url: str) -> str:
def validate_url(url: str) -> str:
url = validate_url_length((url or "").strip())
url = (url or "").strip()
if "\n" in url or "\r" in url:
raise ValueError("URL must be a single line.")
url = validate_url_length(url)
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https") or not parsed.hostname:
raise ValueError("URL must start with http:// or https:// and include a hostname.")
@@ -206,6 +206,78 @@
});
})();
</script>
<script>
(() => {
const frame = document.querySelector(".opencode-agent-frame");
if (!frame) return;
const healthUrl = "{{ proxy_prefix|escapejs }}/global/health";
const monitorUrl = "{{ proxy_prefix|escapejs }}/_archivebox/health";
const fetchOptions = {
cache: "no-store",
credentials: "same-origin",
redirect: "manual",
};
let checking = false;
let waking = false;
let wasUnavailable = false;
let healthyChecks = 0;
let knownVersion = "{{ opencode_version|escapejs }}";
const wake = () => {
if (waking) return;
waking = true;
fetch(healthUrl, fetchOptions)
.catch(() => {})
.finally(() => { waking = false; });
};
const check = async () => {
if (checking || (document.hidden && wasUnavailable)) return;
checking = true;
try {
const response = await fetch(monitorUrl, fetchOptions);
if (!response.ok || response.redirected) {
wasUnavailable = true;
healthyChecks = 0;
if (response.status === 503) {
wake();
}
return;
}
const health = await response.json();
if (!health || health.healthy !== true) {
wasUnavailable = true;
healthyChecks = 0;
return;
}
const currentVersion = typeof health.version === "string" ? health.version : "";
if (knownVersion && currentVersion && currentVersion !== knownVersion) {
wasUnavailable = true;
}
if (currentVersion) {
knownVersion = currentVersion;
}
if (wasUnavailable && ++healthyChecks >= 2) {
wasUnavailable = false;
healthyChecks = 0;
frame.contentWindow.location.reload();
}
} catch {
wasUnavailable = true;
healthyChecks = 0;
} finally {
checking = false;
}
};
window.setInterval(check, 3000);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) check();
});
check();
})();
</script>
{% endif %}
</div>
{% endblock %}
+2 -1
View File
@@ -1,10 +1,11 @@
from django.urls import path, re_path
from archivebox.opencode.views import agent_view, opencode_proxy_view
from archivebox.opencode.views import agent_health_view, agent_view, opencode_proxy_view
urlpatterns = [
path("", agent_view, name="opencode-agent"),
path("opencode/_archivebox/health", agent_health_view, name="opencode-health"),
re_path(
r"^opencode(?:/(?P<path>.*))?$",
opencode_proxy_view,
+183 -106
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
import atexit
import base64
import logging
import os
import re
import signal
import shutil
import subprocess
import threading
@@ -18,11 +20,13 @@ from abx_plugins.plugins import opencode as opencode_plugin
from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
from archivebox.core.routes_util import build_admin_url, get_api_base_url, get_base_url
from asgiref.sync import sync_to_async
from django.http import (
Http404,
HttpRequest,
HttpResponse,
HttpResponseForbidden,
JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import redirect, render
@@ -30,7 +34,10 @@ from django.views.decorators.csrf import csrf_exempt
_PROCESS: subprocess.Popen | None = None
_PROCESS_READY: subprocess.Popen | None = None
_PROCESS_LOCK = threading.Lock()
_SESSION_LOCK = threading.Lock()
_LOGGER = logging.getLogger(__name__)
_PROXY_PREFIX = "/admin/agent/opencode"
_PROXY_PREFIX_REGEX = _PROXY_PREFIX.replace("/", r"\/")
_PROXY_PREFIX_NO_SLASH_REGEX = _PROXY_PREFIX.lstrip("/").replace("/", r"\/")
@@ -39,7 +46,6 @@ _CONFIG_PATH = Path(opencode_plugin.__file__).with_name("config.json")
_TEXT_CONTENT_TYPES = (
"text/",
"application/javascript",
"application/json",
"application/x-javascript",
)
_HOP_BY_HOP_HEADERS = {
@@ -83,20 +89,33 @@ You are running inside an ArchiveBox collection directory.
"""
def _signal_owned_process(process: subprocess.Popen, sig: signal.Signals) -> None:
try:
os.killpg(process.pid, sig)
except OSError:
try:
process.send_signal(sig)
except ProcessLookupError:
pass
def _stop_owned_process(process: subprocess.Popen | None = None) -> None:
global _PROCESS
global _PROCESS, _PROCESS_READY
owned_process = process or _PROCESS
if owned_process is None:
return
if owned_process.poll() is None:
owned_process.terminate()
_signal_owned_process(owned_process, signal.SIGCONT)
_signal_owned_process(owned_process, signal.SIGTERM)
try:
owned_process.wait(timeout=5)
except subprocess.TimeoutExpired:
owned_process.kill()
_signal_owned_process(owned_process, signal.SIGKILL)
owned_process.wait()
if _PROCESS is owned_process:
_PROCESS = None
if _PROCESS_READY is owned_process:
_PROCESS_READY = None
atexit.register(_stop_owned_process)
@@ -196,18 +215,19 @@ def _settings(config: dict) -> dict:
host = str(_config_value(config, "OPENCODE_HOST", "127.0.0.1"))
port = int(_config_value(config, "OPENCODE_PORT", 4096))
default_data_dir = _archivebox_data_dir_default()
workdir = Path(
str(_config_value(config, "OPENCODE_WORKDIR", default_data_dir)),
).expanduser()
opencode_dir = Path(
str(_config_value(config, "OPENCODE_STATE_DIR", workdir / "opencode")),
str(_config_value(config, "OPENCODE_STATE_DIR", default_data_dir / "opencode")),
).expanduser()
workdir = Path(
str(_config_value(config, "OPENCODE_WORKDIR", opencode_dir / "workdir")),
).expanduser()
binary = str(_config_value(config, "OPENCODE_BINARY", "opencode"))
timeout = int(_config_value(config, "OPENCODE_TIMEOUT", 30))
timeout = int(_config_value(config, "OPENCODE_TIMEOUT", 120))
return {
"host": host,
"port": port,
"origin": f"http://{host}:{port}",
"archivebox_data_dir": default_data_dir,
"workdir": workdir,
"opencode_dir": opencode_dir,
"config_home": opencode_dir / "config",
@@ -287,7 +307,7 @@ def _ensure_project_files(settings: dict) -> None:
if not editable_skill_path.exists():
editable_skill_path.write_text(
_ARCHIVEBOX_SKILL.format(
archivebox_data_dir=workdir,
archivebox_data_dir=settings["archivebox_data_dir"],
archivebox_base_url=settings.get("archivebox_base_url", ""),
archivebox_admin_url=settings.get("archivebox_admin_url", ""),
archivebox_api_url=settings.get("archivebox_api_url", ""),
@@ -352,21 +372,14 @@ def _ensure_default_session(settings: dict) -> str:
return session_id
def _recent_session_id(settings: dict) -> str:
workdir = str(settings["workdir"].resolve())
try:
response = requests.get(
f"{settings['origin']}/session",
params={"directory": workdir, "roots": "true", "limit": 1},
timeout=settings["timeout"],
)
response.raise_for_status()
sessions = response.json()
if sessions:
return str(sessions[0].get("id") or "")
except requests.RequestException:
pass
return ""
def _owned_process_running() -> bool:
process = _PROCESS
return process is not None and process.poll() is None
def _owned_process_ready() -> bool:
process = _PROCESS_READY
return process is not None and process is _PROCESS and process.poll() is None
def _health(settings: dict) -> bool:
@@ -380,35 +393,58 @@ def _health(settings: dict) -> bool:
return False
def _opencode_version(settings: dict) -> str:
try:
response = requests.get(
f"{settings['origin']}/global/health",
timeout=2,
)
response.raise_for_status()
data = response.json()
return str(data.get("version") or "") if isinstance(data, dict) else ""
except (requests.RequestException, ValueError):
return ""
def _ensure_opencode(settings: dict) -> tuple[bool, str]:
global _PROCESS
global _PROCESS, _PROCESS_READY
started_process: subprocess.Popen | None = None
workdir = settings["workdir"].resolve()
try:
binary, git_binary, binary_env = _resolve_binary(
settings["binary"],
settings["config"],
)
except RuntimeError as err:
return False, str(err)
env = {
**os.environ,
**binary_env,
"ARCHIVEBOX_BASE_URL": str(settings.get("archivebox_base_url", "")),
"ARCHIVEBOX_ADMIN_URL": str(settings.get("archivebox_admin_url", "")),
"ARCHIVEBOX_API_URL": str(settings.get("archivebox_api_url", "")),
"BROWSER": "false",
"GIT_CEILING_DIRECTORIES": str(workdir),
"HOME": str(settings["home"]),
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
"XDG_CONFIG_HOME": str(settings["config_home"]),
"XDG_DATA_HOME": str(settings["data_home"]),
"XDG_STATE_HOME": str(settings["state_home"]),
"XDG_CACHE_HOME": str(settings["cache_home"]),
}
with _PROCESS_LOCK:
if _owned_process_ready():
return True, ""
if _health(settings):
if _owned_process_running():
_PROCESS_READY = _PROCESS
return True, ""
if _owned_process_running():
_stop_owned_process(_PROCESS)
try:
binary, git_binary, binary_env = _resolve_binary(
settings["binary"],
settings["config"],
)
except RuntimeError as err:
return False, str(err)
env = {
**os.environ,
**binary_env,
"ARCHIVEBOX_BASE_URL": str(settings.get("archivebox_base_url", "")),
"ARCHIVEBOX_ADMIN_URL": str(settings.get("archivebox_admin_url", "")),
"ARCHIVEBOX_API_URL": str(settings.get("archivebox_api_url", "")),
"BROWSER": "false",
"GIT_CEILING_DIRECTORIES": str(workdir),
"HOME": str(settings["home"]),
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
"XDG_CONFIG_HOME": str(settings["config_home"]),
"XDG_DATA_HOME": str(settings["data_home"]),
"XDG_STATE_HOME": str(settings["state_home"]),
"XDG_CACHE_HOME": str(settings["cache_home"]),
}
settings["workdir"].mkdir(parents=True, exist_ok=True)
settings["config_home"].mkdir(parents=True, exist_ok=True)
settings["data_home"].mkdir(parents=True, exist_ok=True)
@@ -435,10 +471,6 @@ def _ensure_opencode(settings: dict) -> tuple[bool, str]:
)
if _health(settings):
try:
_ensure_default_session(settings)
except (requests.RequestException, RuntimeError, ValueError) as err:
return False, f"OpenCode project initialization failed: {err}"
return True, ""
binary_abspath = binary.loaded_abspath
@@ -461,9 +493,9 @@ def _ensure_opencode(settings: dict) -> tuple[bool, str]:
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
_PROCESS_READY = None
started_process = _PROCESS
except FileNotFoundError:
return False, f"OpenCode binary not found: {settings['binary']}"
@@ -471,15 +503,13 @@ def _ensure_opencode(settings: dict) -> tuple[bool, str]:
deadline = time.monotonic() + settings["timeout"]
while time.monotonic() < deadline:
if _health(settings):
try:
_ensure_default_session(settings)
except (requests.RequestException, RuntimeError, ValueError) as err:
_stop_owned_process(started_process)
return False, f"OpenCode project initialization failed: {err}"
_PROCESS_READY = started_process
return True, ""
if started_process and started_process.poll() is not None:
if _PROCESS is started_process:
_PROCESS = None
if _PROCESS_READY is started_process:
_PROCESS_READY = None
return False, "OpenCode exited before the web server became ready."
time.sleep(0.25)
@@ -501,9 +531,21 @@ def agent_view(request: HttpRequest):
settings["archivebox_admin_url"] = admin_url
settings["archivebox_api_url"] = api_url
ok, error = _ensure_opencode(settings)
recent_session_id = ""
opencode_version = ""
if ok:
try:
with _SESSION_LOCK:
recent_session_id = _ensure_default_session(settings)
opencode_version = _opencode_version(settings)
if not opencode_version:
ok = False
error = "OpenCode health check did not report its version."
except (requests.RequestException, RuntimeError, ValueError) as err:
ok = False
error = f"OpenCode project initialization failed: {err}"
from archivebox.core.admin_site import archivebox_admin
recent_session_id = _recent_session_id(settings) if ok else ""
context = {
**archivebox_admin.each_context(request),
"title": "Agent",
@@ -515,6 +557,8 @@ def agent_view(request: HttpRequest):
# open the durable session URL so a fresh browser does not land on the
# transient new-session route and appear to have lost prior sessions.
"proxy_url": _project_route(settings["workdir"], recent_session_id),
"proxy_prefix": _PROXY_PREFIX,
"opencode_version": opencode_version,
"workdir": str(settings["workdir"].resolve()),
"recent_session_id": recent_session_id,
}
@@ -526,6 +570,23 @@ def agent_view(request: HttpRequest):
)
def agent_health_view(request: HttpRequest):
config = _machine_config()
_require_enabled(config)
auth_response = _require_superuser(request)
if auth_response:
return auth_response
version = _opencode_version(_settings(config))
healthy = bool(version)
response = JsonResponse(
{"healthy": healthy, "version": version},
status=200 if healthy else 503,
)
response.headers["Cache-Control"] = "no-store"
return response
def _proxy_url(settings: dict, path: str | None) -> str:
rel = "/" if not path else f"/{path}"
return urljoin(settings["origin"], rel)
@@ -557,19 +618,34 @@ def _request_params(request: HttpRequest) -> tuple[tuple[str, str], ...]:
return tuple((key, str(value)) for key, value in dict(request.GET).items())
async def _event_chunks(request: HttpRequest, settings: dict, path: str | None):
async def _event_chunks(
settings: dict,
path: str | None,
method: str,
params: tuple[tuple[str, str], ...],
headers: dict[str, str],
):
if not _owned_process_ready():
ok, error = await sync_to_async(_ensure_opencode, thread_sensitive=False)(settings)
if not ok:
_LOGGER.warning("OpenCode event stream unavailable: %s", error)
yield b'event: error\ndata: {"error":"OpenCode upstream unavailable"}\n\n'
return
timeout = httpx.Timeout(settings["timeout"], read=None)
url = _proxy_url(settings, path)
method = request.method or "GET"
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
async with client.stream(
method,
url,
params=_request_params(request),
headers=_request_headers(request, settings),
) as upstream:
async for chunk in upstream.aiter_raw(chunk_size=512):
yield chunk
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
async with client.stream(
method,
url,
params=params,
headers=headers,
) as upstream:
async for chunk in upstream.aiter_raw(chunk_size=512):
yield chunk
except httpx.RequestError as err:
_LOGGER.warning("OpenCode event stream ended: %s", err)
def _rewrite_text(body: bytes, settings: dict) -> bytes:
@@ -633,6 +709,15 @@ def _response_headers(upstream: requests.Response, settings: dict) -> dict[str,
return headers
def _proxy_error_response(error: Exception | str) -> HttpResponse:
_LOGGER.warning("OpenCode upstream request failed: %s", error)
return HttpResponse(
b"OpenCode upstream request failed.",
status=502,
content_type="text/plain; charset=utf-8",
)
@csrf_exempt
def opencode_proxy_view(request: HttpRequest, path: str | None = None):
config = _machine_config()
@@ -651,23 +736,27 @@ def opencode_proxy_view(request: HttpRequest, path: str | None = None):
settings["archivebox_base_url"] = base_url
settings["archivebox_admin_url"] = admin_url
settings["archivebox_api_url"] = api_url
ok, error = _ensure_opencode(settings)
if not ok:
return HttpResponse(
error.encode(),
status=502,
content_type="text/plain; charset=utf-8",
)
if request.method == "GET" and (path or "").endswith("/event"):
response = StreamingHttpResponse(
_event_chunks(request, settings, path),
_event_chunks(
settings,
path,
request.method or "GET",
_request_params(request),
_request_headers(request, settings),
),
content_type="text/event-stream",
)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Accel-Buffering"] = "no"
return response
if path == "global/health" or not _owned_process_ready():
ok, error = _ensure_opencode(settings)
if not ok:
return _proxy_error_response(error)
try:
method = request.method or "GET"
upstream = requests.request(
@@ -677,39 +766,27 @@ def opencode_proxy_view(request: HttpRequest, path: str | None = None):
data=request.body if method not in {"GET", "HEAD"} else None,
headers=_request_headers(request, settings),
stream=True,
timeout=(settings["timeout"], None),
timeout=settings["timeout"],
allow_redirects=False,
)
except requests.RequestException as err:
return HttpResponse(
str(err).encode(),
status=502,
content_type="text/plain; charset=utf-8",
)
return _proxy_error_response(err)
try:
body = upstream.content
except requests.RequestException as err:
upstream.close()
return _proxy_error_response(err)
content_type = upstream.headers.get("Content-Type", "")
is_event_stream = content_type.startswith("text/event-stream")
is_text = not is_event_stream and any(content_type.startswith(prefix) for prefix in _TEXT_CONTENT_TYPES)
headers = _response_headers(upstream, settings)
if is_text:
body = _rewrite_text(upstream.content, settings)
response = HttpResponse(
body,
status=upstream.status_code,
content_type=content_type or "text/plain; charset=utf-8",
)
elif is_event_stream:
response = StreamingHttpResponse(
upstream.iter_lines(chunk_size=1),
status=upstream.status_code,
content_type=content_type or "text/event-stream",
)
else:
response = StreamingHttpResponse(
upstream.iter_content(chunk_size=64 * 1024),
status=upstream.status_code,
content_type=content_type or "application/octet-stream",
)
if any(content_type.startswith(prefix) for prefix in _TEXT_CONTENT_TYPES):
body = _rewrite_text(body, settings)
response = HttpResponse(
body,
status=upstream.status_code,
content_type=content_type or "application/octet-stream",
)
for key, value in headers.items():
response.headers[key] = value
response.headers["Cache-Control"] = "no-store"
+8 -64
View File
@@ -88,70 +88,14 @@ def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any
return get_plugin_config_resolver().enabled_plugin_names_from_flat(dict(config.items()))
def discover_plugins_that_provide_interface(
module_name: str,
required_attrs: list[str],
plugin_prefix: str | None = None,
) -> dict[str, Any]:
"""
Discover plugins that provide a specific Python module with required interface.
This enables dynamic plugin discovery for features like search backends,
storage backends, etc. without hardcoding imports.
"""
import importlib.util
backends = {}
for plugin_dir in iter_plugin_dirs():
plugin_name = plugin_dir.name
if plugin_prefix and not plugin_name.startswith(plugin_prefix):
continue
module_path = plugin_dir / f"{module_name}.py"
if not module_path.exists():
continue
try:
spec = importlib.util.spec_from_file_location(
f"archivebox.dynamic_plugins.{plugin_name}.{module_name}",
module_path,
)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if not all(attr in vars(module) for attr in required_attrs):
continue
if plugin_prefix:
backend_name = plugin_name[len(plugin_prefix) :]
else:
backend_name = plugin_name
backends[backend_name] = module
except Exception:
continue
return backends
def get_search_backends() -> dict[str, Any]:
"""
Discover all available search backend plugins.
Search backends must provide a search.py module with:
- search(query: str) -> List[str] (returns snapshot IDs)
- flush(snapshot_ids: Iterable[str]) -> None
"""
return discover_plugins_that_provide_interface(
module_name="search",
required_attrs=["search", "flush"],
plugin_prefix="search_backend_",
)
def get_search_backends():
"""Return plugins that declare both standalone search commands."""
catalog = get_plugin_catalog()
return {
plugin.name.removeprefix("search_backend_"): plugin
for plugin in catalog.values()
if catalog.command(plugin.name, "search") is not None and catalog.command(plugin.name, "flush") is not None
}
@lru_cache(maxsize=1)
+3 -173
View File
@@ -8,40 +8,13 @@ from __future__ import annotations
__package__ = "archivebox.plugins"
import os
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, TypeGuard, runtime_checkable
from typing import Any
from asgiref.sync import async_to_sync
from abx_dl.models import parse_hook_filename
from abx_dl.execution import execute_hook
from abx_dl.models import Hook, parse_hook_filename
from archivebox.config.constants import CONSTANTS
from archivebox.config.version import VERSION
from archivebox.misc.util import fix_url_from_markdown, sanitize_extracted_url
from archivebox.plugins.discovery import ConfigLookup, get_enabled_plugins, get_plugin_catalog, get_plugin_special_config
if TYPE_CHECKING:
from archivebox.machine.models import Process
@runtime_checkable
class ConfigDump(Protocol):
def as_dict(self) -> dict[str, Any]: ...
def _has_config_dump(config: object) -> TypeGuard[ConfigDump]:
return isinstance(config, ConfigDump)
def _config_to_overrides(config: ConfigLookup | Mapping[str, Any] | None) -> dict[str, Any]:
if config is None:
return {}
if _has_config_dump(config):
return dict(config.as_dict())
return dict(config.items())
from archivebox.plugins.discovery import ConfigLookup, get_enabled_plugins, get_plugin_catalog
def is_background_hook(hook_name: str) -> bool:
@@ -76,149 +49,6 @@ def discover_hooks(
return [hook.path for _plugin, hook in get_plugin_catalog().hooks(normalized, names=names)]
def _catalog_hook(script: Path) -> Hook:
script = script.resolve()
for plugin in get_plugin_catalog().values():
for hook in plugin.hooks:
if hook.path.resolve() == script:
return hook
parsed = parse_hook_filename(script.name)
if parsed is None:
raise ValueError(f"Not a valid plugin hook filename: {script.name}")
event, order, is_background = parsed
return Hook(
name=script.name,
event=event,
plugin_name=script.parent.name,
path=script,
order=order,
is_background=is_background,
)
def _hook_environment(config: ConfigLookup | Mapping[str, Any] | None, **config_scope: Any) -> tuple[dict[str, str], Any]:
from archivebox.config.common import (
ArchiveBoxConfig,
_archivebox_config_input_names,
get_config,
normalize_runtime_config,
)
overrides = _config_to_overrides(config)
resolved = get_config(overrides=overrides, **config_scope)
runtime = normalize_runtime_config(
resolved.for_crawl_runtime(runtime_overrides=overrides),
json_safe=False,
)
runtime.update(normalize_runtime_config(overrides, json_safe=False))
env = os.environ.copy()
config_input_names = _archivebox_config_input_names()
for key in config_input_names:
env.pop(key, None)
env.pop("PLUGINS", None)
env["PATH"] = os.environ.get("PATH", "")
env["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
env["LIBRARY_VERSION"] = VERSION
env.setdefault("MACHINE_ID", os.environ.get("MACHINE_ID", CONSTANTS.MACHINE_ID))
canonical_config_keys = set(ArchiveBoxConfig.model_fields)
for key, value in runtime.items():
if key == "PATH" or value is None:
continue
if key in config_input_names and key not in canonical_config_keys:
continue
if isinstance(value, bool):
env[key] = "true" if value else "false"
elif isinstance(value, (dict, list)):
import json
env[key] = json.dumps(value)
else:
env[key] = str(value)
node_modules_dir = runtime.get("NODE_MODULES_DIR")
lib_dir = runtime.get("ABXPKG_LIB_DIR")
if not node_modules_dir and lib_dir:
node_modules_dir = Path(lib_dir) / "pnpm" / "packages" / "chrome" / "node_modules"
if node_modules_dir:
env["NODE_MODULES_DIR"] = str(node_modules_dir)
env["NODE_MODULE_DIR"] = str(node_modules_dir)
node_path = [part for part in str(runtime.get("NODE_PATH") or "").split(os.pathsep) if part]
if str(node_modules_dir) not in node_path:
node_path.append(str(node_modules_dir))
env["NODE_PATH"] = os.pathsep.join(node_path)
return env, resolved
def run_hook(
script: Path,
output_dir: Path,
config: ConfigLookup | Mapping[str, Any] | None = None,
timeout: int | None = None,
parent: Process | None = None,
**kwargs: Any,
) -> Process:
"""Compatibility adapter for finite direct calls; abx-dl owns execution."""
from archivebox.machine.models import Process
from archivebox.services.process_service import ProcessService as PersistedProcessService
from archivebox.services.process_service import parse_event_datetime
from abx_dl.orchestrator import create_bus
# Preserve the old direct-call contract: hooks are children of an explicit
# parent, or of the current ArchiveBox process when one can be identified.
# This belongs on the DB projection adapter, not in hook CLI arguments.
if parent is None:
try:
parent = Process.current()
except Exception:
parent = None
config_scope = {key.removeprefix("config_"): kwargs.pop(key) for key in list(kwargs) if key.startswith("config_")}
env, resolved = _hook_environment(config, **config_scope)
hook = _catalog_hook(script)
if timeout is None:
timeout = get_plugin_special_config(hook.plugin_name, resolved)["timeout"]
timeout = min(int(timeout or 300), int(CONSTANTS.MAX_HOOK_RUNTIME_SECONDS))
bus = create_bus(name=f"ArchiveBoxHook_{hook.plugin_name}", total_timeout=float(timeout) + 30.0)
PersistedProcessService(bus, parent_process_id=str(parent.id) if parent is not None else None)
async def execute_and_close():
try:
return await execute_hook(
hook,
output_dir=output_dir,
env=env,
arguments=kwargs,
timeout=timeout,
bus=bus,
process_type=Process.TypeChoices.HOOK,
)
finally:
await bus.wait_until_idle()
await bus.destroy(clear=False)
completed = async_to_sync(execute_and_close)()
started_at = parse_event_datetime(completed.start_ts)
process = Process.objects.filter(pid=completed.pid or None, started_at=started_at).order_by("-modified_at").first()
if process is None:
raise RuntimeError(f"Hook {hook.full_name} completed without an ArchiveBox Process projection")
return process
def extract_records_from_process(process: Process) -> list[dict[str, Any]]:
"""Return hook JSONL records with generic catalog identity attached."""
records = process.get_records()
plugin_name = Path(process.pwd).name if process.pwd else "unknown"
plugin_hook = next((str(arg) for arg in process.cmd if Path(str(arg)).name.startswith("on_")), "")
hook_name = Path(plugin_hook).name if plugin_hook else "unknown"
for record in records:
record.setdefault("plugin", plugin_name)
record.setdefault("hook_name", hook_name)
record.setdefault("plugin_hook", plugin_hook)
return records
def collect_urls_from_plugins(snapshot_dir: Path) -> list[dict[str, Any]]:
"""Read the durable urls.jsonl interface emitted by parser plugins."""
urls: list[dict[str, Any]] = []
+6 -2
View File
@@ -30,12 +30,16 @@ def progress_endpoint(scope: Literal["crawl", "snapshot"] | None = None, object_
@lru_cache(maxsize=1)
def _live_progress_plugin_names() -> tuple[frozenset[str], frozenset[str]]:
plugin_configs = discover_plugin_configs()
indexing_plugin_names = frozenset(
plugin_name
for plugin_name, plugin_config in plugin_configs.items()
if {"search", "flush"}.issubset(plugin_config.get("commands", {}))
)
download_plugin_names = frozenset(
plugin_name
for plugin_name, plugin_config in plugin_configs.items()
if plugin_config.get("output_mimetypes") and not plugin_name.startswith("search_backend_")
if plugin_config.get("output_mimetypes") and plugin_name not in indexing_plugin_names
)
indexing_plugin_names = frozenset(plugin_name for plugin_name in plugin_configs if plugin_name.startswith("search_backend_"))
return download_plugin_names, indexing_plugin_names
+13 -21
View File
@@ -1,7 +1,7 @@
__package__ = "archivebox.search"
import json
import os
from contextlib import contextmanager
from typing import Any
from archivebox.config.common import get_config
@@ -10,29 +10,21 @@ from archivebox.config.common import get_config
_search_backends_cache: dict | None = None
@contextmanager
def search_backend_env(config: dict[str, Any] | None = None, **config_kwargs: Any):
"""Temporarily expose resolved search config through os.environ for backend code."""
def search_backend_command_env(config: dict[str, Any] | None = None, **config_kwargs: Any) -> dict[str, str]:
"""Serialize resolved application config for a standalone plugin command."""
config = config or get_config(**config_kwargs)
updates = {}
env = os.environ.copy()
for key, value in config.items():
key = str(key)
if not (key.startswith("SEARCH_BACKEND_") or key.endswith("_BINARY")):
continue
if value is None:
continue
if isinstance(value, (str, int, float, bool, os.PathLike)):
updates[key] = str(value)
previous = {key: os.environ.get(key) for key in updates}
os.environ.update(updates)
try:
yield
finally:
for key, value in previous.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
if isinstance(value, bool):
env[key] = "true" if value else "false"
elif isinstance(value, (dict, list, tuple)):
env[key] = json.dumps(value)
elif isinstance(value, (str, int, float, os.PathLike)):
env[key] = str(value)
return env
def normalize_search_backend_name(backend_name: str | None) -> str:
@@ -41,7 +33,7 @@ def normalize_search_backend_name(backend_name: str | None) -> str:
def get_available_backends() -> dict:
"""Discover search backend plugin modules and cache them in memory."""
"""Discover search-capable plugins and cache their catalog entries."""
global _search_backends_cache
if _search_backends_cache is None:
@@ -53,7 +45,7 @@ def get_available_backends() -> dict:
def get_backend(config: dict[str, Any] | None = None, **config_kwargs: Any) -> Any:
"""Resolve the configured search backend module."""
"""Resolve the configured search-capable plugin."""
config = config or get_config(**config_kwargs)
backend_name = normalize_search_backend_name(config.SEARCH_BACKEND_ENGINE)
backends = get_available_backends()
+38 -15
View File
@@ -8,7 +8,7 @@ from django.db.models import Case, IntegerField, Q, QuerySet, Value, When
from archivebox.config.common import get_config
from archivebox.misc.logging import stderr
from archivebox.misc.util import enforce_types
from archivebox.search.backends import get_available_backends, get_backend, normalize_search_backend_name, search_backend_env
from archivebox.search.backends import get_available_backends, get_backend, normalize_search_backend_name, search_backend_command_env
from archivebox.search.config import get_search_mode, get_search_mode_backend, get_search_mode_base
@@ -278,18 +278,27 @@ def iter_query_search_ids(
# --csv/--json. Keep daemon diagnostics on stderr.
with redirect_stdout(sys.stderr):
ensure_daemon_stack(reason="search query")
with search_backend_env(config=config):
if backend_name == "ripgrep":
ids = backend.iter_search(query, search_mode=search_mode_base)
else:
ids = backend.search(query)
for snapshot_id in ids:
if snapshot_id in seen:
continue
seen.add(snapshot_id)
yield snapshot_id
if max_results and len(seen) >= max_results:
return
from abx_dl.execution import iter_plugin_command
from archivebox.config.constants import CONSTANTS
from archivebox.plugins.discovery import get_plugin_catalog
command = get_plugin_catalog().command(backend.name, "search")
if command is None:
raise RuntimeError(f'Plugin "{backend.name}" does not expose a search command')
ids = iter_plugin_command(
command,
arguments={"query": query, "search_mode": search_mode_base},
env=search_backend_command_env(config=config),
cwd=CONSTANTS.DATA_DIR,
timeout=max(1, int(config.get("TIMEOUT", 60))) * 4,
)
for snapshot_id in ids:
if snapshot_id in seen:
continue
seen.add(snapshot_id)
yield snapshot_id
if max_results and len(seen) >= max_results:
return
successful_backends += 1
except Exception as err:
errors.append(err)
@@ -318,8 +327,22 @@ def flush_search_index(snapshots: QuerySet, config: dict[str, Any] | None = None
snapshot_pks = [str(pk) for pk in snapshots.values_list("pk", flat=True)]
try:
with search_backend_env(config=config):
backend.flush(snapshot_pks)
from abx_dl.execution import iter_plugin_command
from archivebox.config.constants import CONSTANTS
from archivebox.plugins.discovery import get_plugin_catalog
command = get_plugin_catalog().command(backend.name, "flush")
if command is None:
raise RuntimeError(f'Plugin "{backend.name}" does not expose a flush command')
list(
iter_plugin_command(
command,
stdin=snapshot_pks,
env=search_backend_command_env(config=config),
cwd=CONSTANTS.DATA_DIR,
timeout=max(1, int(config.get("TIMEOUT", 60))) * 4,
),
)
except Exception as err:
stderr()
stderr(
+34 -169
View File
@@ -2,10 +2,7 @@ from __future__ import annotations
import asyncio
import inspect
import json
import os
import re
import signal
import sys
import time
from contextlib import contextmanager
@@ -14,10 +11,9 @@ from pathlib import Path
from typing import Any
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.events import ArchiveResultEvent, ProcessStartedEvent
from abx_dl.output_files import OutputManifest
from abx_dl.services.base import BaseService
@@ -87,11 +83,6 @@ def _normalize_status(status: str) -> str:
return status or "failed"
def _snapshot_hook_order(hook_name: str) -> int:
match = re.match(r"^on_Snapshot__(\d+)", hook_name or "")
return int(match.group(1)) if match else -1
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:
@@ -128,37 +119,6 @@ def _should_update_snapshot_title(current_title: str, next_title: str, *, snapsh
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,
@@ -209,7 +169,6 @@ def _save_archiveresult_event_to_db(
start_ts = parse_event_datetime(event.start_ts)
end_ts = parse_event_datetime(event.end_ts) or timezone.now()
defaults = {
"hook_name": event.hook_name,
"status": _normalize_status(event.status),
"output_str": event.output_str,
"output_json": event.output_json,
@@ -224,48 +183,13 @@ def _save_archiveresult_event_to_db(
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,
).first()
if result is None:
try:
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.result_create"):
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin=event.plugin,
**defaults,
)
except IntegrityError:
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.result_get_after_integrity"):
result = ArchiveResult.objects.get(
snapshot=snapshot,
plugin=event.plugin,
)
if result.output_files:
merged_output_files = {**result.output_files, **defaults["output_files"]}
defaults["output_files"], defaults["output_size"], defaults["output_mimetypes"] = _manifest_metadata(
OutputManifest.from_value(merged_output_files),
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.result_get_or_create"):
result, _created = ArchiveResult.get_or_create_by_hook(
snapshot,
event.plugin,
event.hook_name,
defaults=defaults,
)
defaults["output_size"] = max(defaults["output_size"], int(result.output_size or 0))
defaults["output_mimetypes"] = ",".join(
dict.fromkeys(
mimetype.strip()
for value in (defaults["output_mimetypes"], result.output_mimetypes)
for mimetype in value.split(",")
if mimetype.strip()
),
)
if result.start_ts and defaults["start_ts"]:
defaults["start_ts"] = min(result.start_ts, defaults["start_ts"])
if result.end_ts and defaults["end_ts"]:
defaults["end_ts"] = max(result.end_ts, defaults["end_ts"])
if _snapshot_hook_order(result.hook_name) > _snapshot_hook_order(event.hook_name):
for field in ("hook_name", "status", "output_str", "output_json", "process_id", "notes"):
if field in result.__dict__:
defaults[field] = result.__dict__[field]
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.diff_fields"):
update_fields = []
@@ -277,13 +201,6 @@ def _save_archiveresult_event_to_db(
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 ""
@@ -307,36 +224,45 @@ def _save_archiveresult_event_to_db(
def mark_archiveresult_started(event: ProcessStartedEvent, *, snapshot_id: str, process_id: str) -> None:
"""Advance an existing queued plugin row after its OS process is persisted."""
from archivebox.core.models import ArchiveResult
"""Project a running abx-dl hook after its OS process is persisted."""
from archivebox.core.models import ArchiveResult, Snapshot
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,
status=ArchiveResult.StatusChoices.QUEUED,
).update(
hook_name=event.hook_name,
status=ArchiveResult.StatusChoices.STARTED,
start_ts=started_at,
end_ts=None,
process_id=process_id,
modified_at=timezone.now(),
snapshot = Snapshot.objects.filter(id=snapshot_id).first()
if snapshot is None:
return
result, _created = ArchiveResult.get_or_create_by_hook(
snapshot,
event.plugin_name,
event.hook_name,
defaults={
"status": ArchiveResult.StatusChoices.STARTED,
"start_ts": started_at,
"end_ts": None,
"process_id": process_id,
},
)
if result.start_ts is not None and started_at <= result.start_ts:
return
result.status = ArchiveResult.StatusChoices.STARTED
result.start_ts = started_at
result.end_ts = None
result.process_id = process_id
result.save(update_fields=["status", "start_ts", "end_ts", "process_id", "modified_at"])
class ArchiveResultService(BaseService):
LISTENS_TO = [ArchiveResultEvent, ProcessCompletedEvent]
"""Project abx-dl ArchiveResult facts into Django models."""
LISTENS_TO = [ArchiveResultEvent]
EMITS = []
def __init__(self, bus):
self._completed_process_event_ids: set[str] = set()
self._save_locks: dict[tuple[str, str], asyncio.Lock] = {}
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:
@@ -348,68 +274,7 @@ class ArchiveResultService(BaseService):
where=lambda candidate: self.bus.event_is_child_of(event, candidate),
)
key = (str(event.snapshot_id), event.plugin)
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()
+7 -11
View File
@@ -43,10 +43,10 @@ def normalize_process_env(env: dict) -> dict:
if is_sensitive_config_key(key) or (key in config_input_names and key not in allowed_config_keys):
normalized.pop(key, None)
if selected_plugins:
from abx_dl.models import discover_plugins, filter_plugins
from archivebox.config.common import _plugin_enabled_config_keys
from archivebox.plugins.discovery import get_plugin_catalog
selected_plugins = set(filter_plugins(discover_plugins(runtime="archivebox"), sorted(selected_plugins), include_providers=True))
selected_plugins = set(get_plugin_catalog().select(sorted(selected_plugins)))
for plugin_name, enabled_key in _plugin_enabled_config_keys().items():
normalized.setdefault(enabled_key, "True" if plugin_name in selected_plugins else "False")
return normalized
@@ -61,12 +61,8 @@ class ProcessService(BaseService):
]
EMITS: ClassVar[list[type[BaseEvent]]] = []
def __init__(self, bus, *, parent_process_id: str | None = None):
def __init__(self, bus):
self._iface = None
# A direct run_hook() call owns a private bus, so its caller-supplied
# parent applies to every process projected from that bus. Crawl buses
# leave this unset and derive their hierarchy from lifecycle events.
self.parent_process_id = parent_process_id
self._completed_queue: asyncio.Queue[ProcessCompletedEvent | None] = asyncio.Queue()
self._completed_worker: asyncio.Task | None = None
super().__init__(bus)
@@ -104,7 +100,7 @@ class ProcessService(BaseService):
process = await Process.objects.acreate(
machine=iface.machine,
iface=iface,
parent_id=self.parent_process_id,
parent_id=None,
process_type=process_type,
worker_type=worker_type,
pwd=event.output_dir,
@@ -138,7 +134,7 @@ class ProcessService(BaseService):
hook_path=event.hook_path,
)
await Process.objects.filter(id=process.id).aupdate(
parent_id=self.parent_process_id or process.parent_id,
parent_id=process.parent_id,
pwd=process.pwd,
cmd=process.cmd,
env=process.env,
@@ -225,7 +221,7 @@ class ProcessService(BaseService):
await Process.objects.acreate(
machine=iface.machine,
iface=iface,
parent_id=self.parent_process_id,
parent_id=None,
process_type=process_type,
worker_type=worker_type,
pwd=event.output_dir,
@@ -246,7 +242,7 @@ class ProcessService(BaseService):
updates = {
"machine_id": iface.machine_id,
"iface_id": iface.id,
"parent_id": self.parent_process_id or process.parent_id,
"parent_id": process.parent_id,
"pwd": event.output_dir,
"env": process_env,
"pid": event.pid or process.pid,
File diff suppressed because it is too large. Load diff
+78 -32
View File
@@ -1,12 +1,10 @@
from __future__ import annotations
import sys
import asyncio
from pathlib import Path
from asgiref.sync import sync_to_async
from django.utils import timezone
from django.core.exceptions import ValidationError
from rich import print as rprint
from abx_dl.events import SnapshotCompletedEvent, SnapshotEvent
from abx_dl.limits import CrawlLimitState
from abx_dl.services.base import BaseService
@@ -44,6 +42,9 @@ def project_discovered_snapshots(snapshot_id: str) -> list:
def finalize_completed_snapshot(
snapshot_id: str,
*,
owned_retry_at,
was_sealed: bool,
consumed_retry_plugins: list[str] | None = None,
output_dir=None,
crawl_limit_stop_reason: str | None = None,
) -> None:
@@ -73,13 +74,40 @@ def finalize_completed_snapshot(
modified_at=timezone.now(),
)
if snapshot.status == Snapshot.StatusChoices.QUEUED:
snapshot.advance_lifecycle()
snapshot.refresh_from_db()
if snapshot.status == Snapshot.StatusChoices.STARTED and snapshot.is_finished_processing():
# SnapshotCompletedEvent is abx-dl's authoritative signal that the complete
# snapshot hook sequence (including cleanup) finished. ArchiveResult rows
# are projections of that work, never prerequisites used to decide whether
# the Snapshot may seal.
if not was_sealed and snapshot.status == Snapshot.StatusChoices.STARTED and snapshot.retry_at == owned_retry_at:
snapshot.seal()
snapshot.refresh_from_db()
consumed_plugins = sorted({str(name).strip() for name in (consumed_retry_plugins or []) if str(name).strip()})
if consumed_plugins and snapshot.status == Snapshot.StatusChoices.SEALED:
for _attempt in range(3):
current = Snapshot.objects.only("status", "retry_at", "config").get(pk=snapshot.pk)
pending_plugins = sorted(
{str(name).strip() for name in (current.config or {}).get("RETRY_PLUGINS", []) if str(name).strip()},
)
if pending_plugins != consumed_plugins:
break
owned_filter = {"retry_at": owned_retry_at} if was_sealed else {"retry_at__isnull": True}
updates: dict[str, object] = {
"config": {key: value for key, value in current.config.items() if key != "RETRY_PLUGINS"},
"modified_at": timezone.now(),
}
if was_sealed:
updates["retry_at"] = None
updated = Snapshot.objects.filter(
pk=current.pk,
status=Snapshot.StatusChoices.SEALED,
config=current.config,
**owned_filter,
).update(**updates)
if updated:
snapshot.refresh_from_db()
break
snapshot.write_index_jsonl(output_dir=output_dir)
@@ -100,6 +128,8 @@ class SnapshotService(BaseService):
def __init__(self, bus, *, crawl_id: str):
self.crawl_id = crawl_id
self._run_ownership: dict[str, tuple[str, object, bool, list[str]]] = {}
self._ownership_lock = asyncio.Lock()
super().__init__(bus)
self.bus.on(SnapshotEvent, self.on_SnapshotEvent)
self.bus.on(SnapshotCompletedEvent, self.on_SnapshotCompletedEvent)
@@ -112,33 +142,49 @@ class SnapshotService(BaseService):
if snapshot is not None:
if snapshot.is_paused:
return
was_sealed = snapshot.status == Snapshot.StatusChoices.SEALED
if snapshot.status == Snapshot.StatusChoices.QUEUED:
if not await snapshot.archiveresult_set.aexists():
from archivebox.services.runner import snapshot_hooks_for_pending_archiveresults
hooks = await sync_to_async(snapshot_hooks_for_pending_archiveresults, thread_sensitive=True)(snapshot)
await sync_to_async(snapshot.create_pending_archiveresults, thread_sensitive=True)(hooks=hooks)
try:
await sync_to_async(snapshot.advance_lifecycle, thread_sensitive=True)()
except ValidationError as err:
if "ArchiveBox cannot archive its own admin, web, api, or snapshot URLs." not in str(err):
raise
await Snapshot.objects.filter(id=snapshot.id).aupdate(
status=Snapshot.StatusChoices.SEALED,
retry_at=None,
modified_at=timezone.now(),
)
rprint(
f"[red][X] Refusing to archive ArchiveBox internal URL for security: {snapshot.url}[/red]",
file=sys.stderr,
)
return
await sync_to_async(snapshot.advance_lifecycle, thread_sensitive=True)()
await sync_to_async(snapshot.refresh_from_db, thread_sensitive=True)()
elif snapshot.status != Snapshot.StatusChoices.STARTED:
elif snapshot.status not in (Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED):
return
if snapshot.status != Snapshot.StatusChoices.STARTED:
return
await sync_to_async(snapshot.ensure_crawl_symlink, thread_sensitive=True)()
if snapshot.status == Snapshot.StatusChoices.STARTED:
await sync_to_async(snapshot.ensure_crawl_symlink, thread_sensitive=True)()
retry_plugins = [str(name).strip() for name in (snapshot.config or {}).get("RETRY_PLUGINS", []) if str(name).strip()]
async with self._ownership_lock:
self._run_ownership[str(event.snapshot_id)] = (str(event.event_id), snapshot.retry_at, was_sealed, retry_plugins)
async def renew_lease(self, snapshot_id: str, lease_until) -> bool | None:
from archivebox.core.models import Snapshot
async with self._ownership_lock:
ownership = self._run_ownership.get(str(snapshot_id))
if ownership is None or ownership[2]:
return None
event_id, owned_retry_at, was_sealed, retry_plugins = ownership
updated = await Snapshot.objects.filter(
id=snapshot_id,
status=Snapshot.StatusChoices.STARTED,
retry_at=owned_retry_at,
).aupdate(
retry_at=lease_until,
modified_at=timezone.now(),
)
if updated:
self._run_ownership[str(snapshot_id)] = (event_id, lease_until, was_sealed, retry_plugins)
return bool(updated)
async def on_SnapshotCompletedEvent(self, event: SnapshotCompletedEvent) -> None:
await sync_to_async(finalize_completed_snapshot, thread_sensitive=True)(event.snapshot_id)
snapshot_id = str(event.snapshot_id)
async with self._ownership_lock:
ownership = self._run_ownership.get(snapshot_id)
if ownership is None or ownership[0] != str(event.event_parent_id):
return
self._run_ownership.pop(snapshot_id, None)
_, owned_retry_at, was_sealed, retry_plugins = ownership
await sync_to_async(finalize_completed_snapshot, thread_sensitive=True)(
event.snapshot_id,
owned_retry_at=owned_retry_at,
was_sealed=was_sealed,
consumed_retry_plugins=retry_plugins,
)
+5 -3
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from asgiref.sync import sync_to_async
from abx_dl.events import TagEvent
from abx_dl.services.base import BaseService
@@ -13,10 +15,10 @@ class TagService(BaseService):
self.bus.on(TagEvent, self.on_TagEvent__save_to_db)
async def on_TagEvent__save_to_db(self, event: TagEvent) -> None:
from archivebox.core.models import Snapshot, SnapshotTag, Tag
from archivebox.core.models import Snapshot, Tag
snapshot = await Snapshot.objects.filter(id=event.snapshot_id).afirst()
if snapshot is None:
return
tag, _ = await Tag.objects.aget_or_create(name=event.name)
await SnapshotTag.objects.aget_or_create(snapshot=snapshot, tag=tag)
tag, _ = await sync_to_async(Tag.get_or_create_by_name, thread_sensitive=True)(event.name)
await sync_to_async(snapshot.add_tag_ids, thread_sensitive=True)([tag.pk])
+68
View File
@@ -1660,6 +1660,74 @@ def resolve_abxpkg_binary_env(
return {str(key): str(value) for key, value in payload.items()}
def run_test_hook(
script: Path,
output_dir: Path,
config: dict[str, Any] | None = None,
timeout: int = 60,
**arguments: Any,
):
"""Execute a shipped finite hook through abx-dl and ArchiveBox's real DB projector."""
import asyncio
from abx_dl.execution import execute_hook
from abx_dl.orchestrator import create_bus
from archivebox.machine.models import Process
from archivebox.plugins.discovery import get_plugin_catalog
from archivebox.services.process_service import ProcessService, parse_event_datetime
resolved_script = script.resolve()
hook = next(
(hook for plugin in get_plugin_catalog().values() for hook in plugin.hooks if hook.path.resolve() == resolved_script),
None,
)
assert hook is not None, f"shipped hook is not in the plugin catalog: {script}"
assert not hook.is_background, f"run_test_hook only supports finite hooks: {hook.full_name}"
env = os.environ.copy()
for key, value in (config or {}).items():
if value is None:
continue
if isinstance(value, bool):
env[key] = "true" if value else "false"
elif isinstance(value, (dict, list, tuple)):
env[key] = json.dumps(value)
else:
env[key] = str(value)
if env.get("NODE_MODULES_DIR"):
env.setdefault("NODE_MODULE_DIR", env["NODE_MODULES_DIR"])
bus = create_bus(name=f"test_hook_{hook.plugin_name}", total_timeout=float(timeout) + 30.0)
ProcessService(bus)
async def execute_and_close():
try:
return await execute_hook(
hook,
output_dir=output_dir,
env=env,
arguments=arguments,
timeout=timeout,
bus=bus,
process_type=Process.TypeChoices.HOOK,
)
finally:
await bus.wait_until_idle()
await bus.destroy(clear=False)
completed = asyncio.run(execute_and_close())
process = (
Process.objects.filter(
pid=completed.pid or None,
started_at=parse_event_datetime(completed.start_ts),
)
.order_by("-modified_at")
.first()
)
assert process is not None, f"hook completed without an ArchiveBox Process projection: {hook.full_name}"
return process
def resolve_abxpkg_chrome_env(lib_dir: Path, env: dict[str, str] | None = None) -> dict[str, str]:
from abx_plugins import get_plugins_dir
+25 -12
View File
@@ -264,10 +264,31 @@ def test_basic_success_case_request(client, tmp_path, api_headers):
assert response.status_code == 200, response.content
assert response.json()["success"] is True
crawl = Crawl.objects.get()
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": submitted_url, "depth": 0}
assert crawl.urls == submitted_url
assert Snapshot.objects.count() == 0
def test_api_cli_add_filters_invalid_items_from_multi_url_batch(client, tmp_path, api_headers):
init_archive(tmp_path)
submitted_url = "https://example.com/api-cli-add-valid-batch-item"
response = api_client_request(
client,
"post",
"/api/v1/cli/add",
payload={
"urls": [submitted_url, "not a URL", "https://example.org\nhttps://example.net"],
"plugins": "__archivebox_test_no_plugins__",
"index_only": True,
},
headers=api_headers,
)
assert response.status_code == 200, response.content
assert response.json()["result"]["queued_urls"] == [submitted_url]
assert Crawl.objects.get().urls == submitted_url
@pytest.mark.timeout(180)
def test_api_cli_add_concurrent_first_time_default_persona_creation(tmp_path):
"""Concurrent live API add requests should share one first-created Default persona."""
@@ -322,10 +343,7 @@ def test_api_cli_add_concurrent_first_time_default_persona_creation(tmp_path):
crawls = list(Crawl.objects.order_by("urls").values_list("urls", flat=True))
assert Snapshot.objects.count() == 0
expected_crawl_sources = sorted(
json.dumps({"type": "CrawlSeed", "url": url, "depth": 0}, separators=(",", ":")) for url in submitted_urls
)
assert crawls == expected_crawl_sources
assert crawls == sorted(submitted_urls)
@pytest.mark.timeout(360)
@@ -366,15 +384,10 @@ def test_api_cli_add_import_text_formats_preserve_metadata_and_crawl_inner_urls(
stop_archivebox_process(api_server)
api_server = None
run_queued_crawls(tmp_path, env=env, timeout=240)
with use_archivebox_db(tmp_path):
root_counts = {
str(crawl.id): crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).count() for crawl in Crawl.objects.all()
}
assert root_counts and all(count == 1 for count in root_counts.values()), root_counts
with use_archivebox_db(tmp_path):
for crawl in Crawl.objects.all():
root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL)
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
assert not crawl.snapshot_set.filter(url__startswith="archivebox://").exists()
root_input = (crawl.output_dir / "input" / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
assert root_input == crawl.urls
api_server = start_api_server_without_runner(tmp_path, env, port)
assert_expected_import_snapshots(tmp_path, expected_urls)
@@ -34,6 +34,8 @@ def test_schedule_api_creates_schedule_via_view_request(api_admin_user):
assert response["success"] is True
assert response["result_format"] == "json"
assert response["result"]["run_all_enqueued"] == 0
assert response["result"]["run_all_maintained"] == 0
assert CrawlSchedule.objects.count() == 1
assert len(response["result"]["created_schedule_ids"]) == 1
@@ -3,6 +3,7 @@ from datetime import timedelta
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import connection
from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
@@ -14,7 +15,7 @@ from archivebox.tests.conftest import api_client_request
pytestmark = pytest.mark.django_db(transaction=True)
def test_archiveresult_upload_upserts_one_snapshot_plugin_result(client, api_admin_user, api_headers):
def test_archiveresult_upload_upserts_by_snapshot_plugin_and_hook(client, api_admin_user, api_headers):
crawl = Crawl.objects.create(urls="https://example.com", created_by=api_admin_user)
snapshot = Snapshot.objects.create(url="https://example.com/unified-result", crawl=crawl)
@@ -39,13 +40,130 @@ def test_archiveresult_upload_upserts_one_snapshot_plugin_result(client, api_adm
assert server_response.status_code == 200, server_response.content
assert extension_update.status_code == 200, extension_update.content
assert extension_update.json()["id"] == extension_response.json()["id"]
assert server_response.json()["id"] == extension_response.json()["id"]
assert server_response.json()["id"] != extension_response.json()["id"]
results = ArchiveResult.objects.filter(snapshot=snapshot, plugin="screenshot")
assert results.count() == 1
result = results.get()
assert result.hook_name == "on_Snapshot__archivebox_browser_extension_upload"
assert set(result.output_files) == {"browser.png", "server.png", "browser-2.png"}
results = ArchiveResult.objects.filter(snapshot=snapshot, plugin="screenshot").order_by("hook_name")
assert results.count() == 2
extension_result = results.get(hook_name="on_Snapshot__archivebox_browser_extension_upload")
server_result = results.get(hook_name="on_Snapshot__50_screenshot")
assert set(extension_result.output_files) == {"browser.png", "browser-2.png"}
assert set(server_result.output_files) == {"server.png"}
snapshot.refresh_from_db()
assert snapshot.output_size == len(b"browser") + len(b"browser-2") + len(b"server")
def test_archiveresult_create_does_not_open_a_database_transaction(client, api_admin_user, api_headers):
crawl = Crawl.objects.create(urls="https://example.com", created_by=api_admin_user)
snapshot = Snapshot.objects.create(url="https://example.com/autocommit-result", crawl=crawl)
with CaptureQueriesContext(connection) as queries:
response = client.post(
"/api/v1/core/archiveresults",
{
"snapshot_id": str(snapshot.id),
"plugin": "chrome_extension_screenshot",
"files": SimpleUploadedFile("screenshot.png", b"screenshot", content_type="image/png"),
"output_paths": "screenshot.png",
},
**api_headers,
)
assert response.status_code == 200, response.content
if connection.vendor == "sqlite":
transaction_queries = [query["sql"] for query in queries if query["sql"].strip().upper() in {"BEGIN", "COMMIT"}]
assert transaction_queries == []
def test_intermediate_archiveresult_chunks_only_write_to_disk(client, api_admin_user, api_headers):
crawl = Crawl.objects.create(urls="https://example.com", created_by=api_admin_user)
snapshot = Snapshot.objects.create(url="https://example.com/chunked-result", crawl=crawl)
create_response = client.post(
"/api/v1/core/archiveresults",
{
"snapshot_id": str(snapshot.id),
"plugin": "chrome_extension_mhtml",
"status": ArchiveResult.StatusChoices.STARTED,
},
**api_headers,
)
assert create_response.status_code == 200, create_response.content
result = ArchiveResult.objects.get(pk=create_response.json()["id"])
original_modified_at = result.modified_at
with CaptureQueriesContext(connection) as queries:
chunk_response = client.patch(
f"/api/v1/core/archiveresult/{result.id}",
encode_multipart(
BOUNDARY,
{
"files": SimpleUploadedFile("snapshot.mhtml.part-000000", b"first", content_type="multipart/related"),
"chunk_output_path": "snapshot.mhtml",
"chunk_index": "0",
"chunk_count": "2",
"chunk_offset": "0",
"chunk_total_size": "11",
"mime_type": "multipart/related",
"status": ArchiveResult.StatusChoices.STARTED,
},
),
content_type=MULTIPART_CONTENT,
**api_headers,
)
assert chunk_response.status_code == 200, chunk_response.content
result.refresh_from_db()
assert result.output_files == {}
assert result.output_size == 0
assert result.modified_at == original_modified_at
writes = [query["sql"] for query in queries if query["sql"].lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE"))]
assert writes == []
final_response = client.patch(
f"/api/v1/core/archiveresult/{result.id}",
encode_multipart(
BOUNDARY,
{
"files": SimpleUploadedFile("snapshot.mhtml.part-000001", b"-final", content_type="multipart/related"),
"chunk_output_path": "snapshot.mhtml",
"chunk_index": "1",
"chunk_count": "2",
"chunk_offset": "5",
"chunk_total_size": "11",
"mime_type": "multipart/related",
"status": ArchiveResult.StatusChoices.SUCCEEDED,
},
),
content_type=MULTIPART_CONTENT,
**api_headers,
)
assert final_response.status_code == 200, final_response.content
result.refresh_from_db()
assert result.status == ArchiveResult.StatusChoices.SUCCEEDED
assert result.output_size == 11
assert result.output_files["snapshot.mhtml"]["upload"]["complete"] is True
snapshot.refresh_from_db()
assert snapshot.output_size == 11
def test_archiveresult_safe_update_rejects_stale_writers(api_admin_user):
crawl = Crawl.objects.create(urls="https://example.com", created_by=api_admin_user)
snapshot = Snapshot.objects.create(url="https://example.com/cas-result", crawl=crawl)
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="chrome_extension_dom",
hook_name=Snapshot.BROWSER_EXTENSION_UPLOAD_HOOK_NAME,
status=ArchiveResult.StatusChoices.STARTED,
output_size=1,
)
stale_result = ArchiveResult.objects.get(pk=result.pk)
assert result.safe_update({"output_size": 2}) is True
assert stale_result.safe_update({"output_size": 3}) is False
stale_result.refresh_from_db()
snapshot.refresh_from_db()
assert stale_result.output_size == 2
assert snapshot.output_size == 2
def test_archiveresult_upload_api_queues_snapshot_maintenance_without_finalizing(client, api_admin_user, api_headers):
@@ -102,7 +102,7 @@ def test_snapshot_pause_wins_over_concurrent_runner_lease(api_admin_user):
assert snapshot.retry_at == RETRY_AT_MAX
def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_finished_rows(
def test_snapshot_pause_resume_api_leaves_archiveresult_facts_unchanged(
request,
tmp_path,
client,
@@ -159,10 +159,10 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_
url=blocking_http_server.url,
status=Snapshot.StatusChoices.QUEUED,
retry_at=now,
config={"PLUGINS": "wget"},
)
Crawl.objects.filter(pk=snapshot.crawl_id).update(status=Crawl.StatusChoices.STARTED, retry_at=now)
snapshot.refresh_from_db()
[started_result] = snapshot.create_pending_archiveresults(hooks=[("wget", _snapshot_hook_name("wget"))])
errors = []
def run_snapshot():
@@ -185,10 +185,13 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_
request.addfinalizer(finish_runner)
blocking_http_server.request_started.wait()
assert errors == []
started_result.refresh_from_db()
started_result = ArchiveResult.objects.get(snapshot=snapshot, plugin="wget")
assert started_result.status == ArchiveResult.StatusChoices.STARTED
[queued_result] = snapshot.create_pending_archiveresults(
hooks=[("parse_txt_urls", "on_Snapshot__71_parse_txt_urls")],
queued_result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="parse_txt_urls",
hook_name="on_Snapshot__71_parse_txt_urls",
status=ArchiveResult.StatusChoices.QUEUED,
)
invalid_response = api_client_request(
@@ -222,8 +225,8 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_
row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[queued_result.id, started_result.id])
}
assert active_rows == {
"parse_txt_urls": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
"wget": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
"parse_txt_urls": (ArchiveResult.StatusChoices.QUEUED, None),
"wget": (ArchiveResult.StatusChoices.STARTED, None),
}
finished_rows = {
@@ -262,12 +265,7 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_
resumed_rows = {
row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[queued_result.id, started_result.id])
}
assert resumed_rows["parse_txt_urls"][0] == ArchiveResult.StatusChoices.QUEUED
assert resumed_rows["parse_txt_urls"][1] is not None
assert resumed_rows["parse_txt_urls"][1] != RETRY_AT_MAX
assert resumed_rows["wget"][0] == ArchiveResult.StatusChoices.QUEUED
assert resumed_rows["wget"][1] is not None
assert resumed_rows["wget"][1] != RETRY_AT_MAX
assert resumed_rows == active_rows
assert ArchiveResult.objects.get(id=succeeded_result.id).status == ArchiveResult.StatusChoices.SUCCEEDED
assert ArchiveResult.objects.get(id=failed_result.id).status == ArchiveResult.StatusChoices.FAILED
@@ -317,8 +315,11 @@ def test_targeted_extract_retries_one_failed_archiveresult_through_normal_snapsh
assert "wget failed (exit=4)" in wget_result.output_str
Snapshot.objects.filter(pk=snapshot.pk).update(url=recursive_test_site["root_url"])
snapshot.refresh_from_db()
[unrelated_result] = snapshot.create_pending_archiveresults(
hooks=[("parse_txt_urls", "on_Snapshot__71_parse_txt_urls")],
unrelated_result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="parse_txt_urls",
hook_name="on_Snapshot__71_parse_txt_urls",
status=ArchiveResult.StatusChoices.QUEUED,
)
snapshot.output_dir.mkdir(parents=True, exist_ok=True)
(snapshot.output_dir / "source.txt").write_text("finished row must survive targeted retry", encoding="utf-8")
@@ -343,7 +344,7 @@ def test_targeted_extract_retries_one_failed_archiveresult_through_normal_snapsh
assert snapshot.status == Snapshot.StatusChoices.PAUSED
assert snapshot.retry_at == RETRY_AT_MAX
assert ArchiveResult.objects.get(id=wget_result.id).status == ArchiveResult.StatusChoices.FAILED
assert ArchiveResult.objects.get(id=unrelated_result.id).status == ArchiveResult.StatusChoices.PAUSED
assert ArchiveResult.objects.get(id=unrelated_result.id).status == ArchiveResult.StatusChoices.QUEUED
finished_row = ArchiveResult.objects.get(id=finished_result.id)
finished_output_path = Path(snapshot.output_dir) / finished_row.plugin / next(iter(finished_row.output_files))
assert finished_output_path.is_file()
@@ -365,10 +366,9 @@ def test_targeted_extract_retries_one_failed_archiveresult_through_normal_snapsh
with use_archivebox_db(tmp_path):
snapshot = Snapshot.objects.get(id=snapshot_id)
assert snapshot.status == Snapshot.StatusChoices.STARTED
assert snapshot.retry_at is not None
assert snapshot.retry_at != RETRY_AT_MAX
assert snapshot.crawl.status == snapshot.crawl.StatusChoices.STARTED
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.retry_at is None
assert snapshot.crawl.status == snapshot.crawl.StatusChoices.SEALED
retried_wget = ArchiveResult.objects.get(id=wget_result.id)
assert retried_wget.status == ArchiveResult.StatusChoices.SUCCEEDED
@@ -376,8 +376,8 @@ def test_targeted_extract_retries_one_failed_archiveresult_through_normal_snapsh
assert retried_wget.output_files
unrelated = ArchiveResult.objects.get(id=unrelated_result.id)
assert unrelated.status == ArchiveResult.StatusChoices.PAUSED
assert unrelated.retry_at == RETRY_AT_MAX
assert unrelated.status == ArchiveResult.StatusChoices.QUEUED
assert unrelated.retry_at is None
finished = ArchiveResult.objects.get(id=finished_result.id)
assert finished.status == ArchiveResult.StatusChoices.SUCCEEDED
@@ -2,6 +2,8 @@ import threading
import time
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
from archivebox.core.models import Snapshot
from archivebox.crawls.locks import crawl_lifecycle_lock
@@ -85,3 +87,66 @@ def test_existing_snapshot_metadata_sync_does_not_wait_for_active_crawl(client,
assert response.status_code == 200, response.content
assert response.json()["id"] == str(snapshot.id)
assert elapsed < 1
def test_new_snapshot_creation_does_not_open_a_database_transaction(client, api_admin_user, api_headers):
url = "https://example.com/browser-extension-new-snapshot"
crawl = Crawl.objects.create(urls=url, created_by=api_admin_user)
with CaptureQueriesContext(connection) as queries:
response = client.post(
"/api/v1/core/snapshots",
data={
"url": url,
"crawl_id": str(crawl.id),
"depth": 0,
"status": Snapshot.StatusChoices.QUEUED,
"tags": ["browser-extension-upload"],
},
content_type="application/json",
**api_headers,
)
assert response.status_code == 200, response.content
assert Snapshot.objects.filter(url=url, crawl=crawl).count() == 1
assert Snapshot.objects.get(url=url, crawl=crawl).tags.filter(name="browser-extension-upload").exists()
if connection.vendor == "sqlite":
transaction_queries = [query["sql"] for query in queries if query["sql"].strip().upper() in {"BEGIN", "COMMIT"}]
assert transaction_queries == []
def test_new_snapshot_creation_does_not_wait_for_active_crawl(client, api_admin_user, api_headers):
url = "https://example.com/browser-extension-new-snapshot-active-crawl"
crawl = Crawl.objects.create(urls=url, created_by=api_admin_user)
lock_acquired = threading.Event()
release_lock = threading.Event()
def hold_active_crawl_lock():
with crawl_lifecycle_lock(str(crawl.id)):
lock_acquired.set()
release_lock.wait(timeout=3)
holder = threading.Thread(target=hold_active_crawl_lock)
holder.start()
assert lock_acquired.wait(timeout=1)
started_at = time.monotonic()
response = client.post(
"/api/v1/core/snapshots",
data={
"url": url,
"crawl_id": str(crawl.id),
"depth": 0,
"status": Snapshot.StatusChoices.QUEUED,
"tags": ["browser-extension-upload"],
},
content_type="application/json",
**api_headers,
)
elapsed = time.monotonic() - started_at
release_lock.set()
holder.join(timeout=3)
assert response.status_code == 200, response.content
assert Snapshot.objects.filter(url=url, crawl=crawl).count() == 1
assert elapsed < 1
@@ -1,4 +1,6 @@
import pytest
from django.db import IntegrityError, connection
from django.test.utils import CaptureQueriesContext
from archivebox.core.models import Snapshot, Tag
from archivebox.crawls.models import Crawl
@@ -13,13 +15,39 @@ def test_basic_success_case_request(client, api_admin_user, api_headers):
snapshot = Snapshot.objects.create(url="https://example.com/tag-add", crawl=crawl)
tag = Tag.objects.create(name="api-basic-add-tag", created_by=api_admin_user)
response = api_client_request(
client,
"post",
"/api/v1/core/tags/add-to-snapshot/",
payload={"snapshot_id": str(snapshot.id), "tag_id": tag.id},
headers=api_headers,
)
with CaptureQueriesContext(connection) as queries:
response = api_client_request(
client,
"post",
"/api/v1/core/tags/add-to-snapshot/",
payload={"snapshot_id": str(snapshot.id), "tag_id": tag.id},
headers=api_headers,
)
assert response.status_code == 200, response.content
assert response.json()["success"] is True
assert snapshot.tags.filter(pk=tag.pk).exists()
if connection.vendor == "sqlite":
transaction_queries = [query["sql"] for query in queries if query["sql"].strip().upper() in {"BEGIN", "COMMIT"}]
assert transaction_queries == []
def test_add_tag_ids_reraises_non_duplicate_integrity_errors(snapshot):
with pytest.raises(IntegrityError):
snapshot.add_tag_ids([2**31 - 1])
def test_add_tag_ids_treats_existing_snapshot_tag_as_idempotent(snapshot, admin_user):
tag = Tag.objects.create(name="already-attached", created_by=admin_user)
snapshot.add_tag_ids([tag.pk])
snapshot.add_tag_ids([tag.pk])
assert snapshot.tags.filter(pk=tag.pk).count() == 1
def test_save_tags_without_creator_uses_tag_model_default(snapshot):
snapshot.save_tags(["model-default-creator"])
tag = Tag.objects.get(name="model-default-creator")
assert tag.created_by_id is not None
@@ -1,4 +1,6 @@
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
from archivebox.core.models import Snapshot, Tag
from archivebox.crawls.models import Crawl
@@ -14,13 +16,18 @@ def test_basic_success_case_request(client, api_admin_user, api_headers):
tag = Tag.objects.create(name="api-basic-remove-tag", created_by=api_admin_user)
snapshot.tags.add(tag)
response = api_client_request(
client,
"post",
"/api/v1/core/tags/remove-from-snapshot/",
payload={"snapshot_id": str(snapshot.id), "tag_id": tag.id},
headers=api_headers,
)
with CaptureQueriesContext(connection) as queries:
response = api_client_request(
client,
"post",
"/api/v1/core/tags/remove-from-snapshot/",
payload={"snapshot_id": str(snapshot.id), "tag_id": tag.id},
headers=api_headers,
)
assert response.status_code == 200, response.content
assert response.json()["success"] is True
assert not snapshot.tags.filter(pk=tag.pk).exists()
if connection.vendor == "sqlite":
transaction_queries = [query["sql"] for query in queries if query["sql"].strip().upper() in {"BEGIN", "COMMIT"}]
assert transaction_queries == []
@@ -12,7 +12,7 @@ from django.utils import timezone
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.tests.test_orm_helpers import use_archivebox_db
from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook, _snapshot_hook_name
from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook
from archivebox.workers.models import RETRY_AT_MAX
from .conftest import (
@@ -114,6 +114,44 @@ def test_basic_success_case_request(client, tmp_path, api_admin_user, api_header
assert response.status_code == 200, response.content
def test_create_crawl_rejects_multiline_url_item(client, api_headers):
response = client.post(
"/api/v1/crawls/crawls",
data=json.dumps({"urls": ["https://example.com/one\nhttps://example.net/two"]}),
content_type="application/json",
**api_headers,
)
assert response.status_code == 400, response.content
assert not Crawl.objects.exists()
def test_create_crawl_marks_structured_urls_as_url_list(client, api_headers):
response = client.post(
"/api/v1/crawls/crawls",
data=json.dumps({"urls": ["https://example.com/one"], "config": {"PLUGINS": "title"}}),
content_type="application/json",
**api_headers,
)
assert response.status_code == 200, response.content
crawl = Crawl.objects.get()
assert crawl.urls == "https://example.com/one"
assert crawl.config["PARSER"] == "url_list"
def test_create_crawl_preserves_explicit_parser(client, api_headers):
response = client.post(
"/api/v1/crawls/crawls",
data=json.dumps({"urls": ["https://example.com/one"], "config": {"PARSER": "txt"}}),
content_type="application/json",
**api_headers,
)
assert response.status_code == 200, response.content
assert Crawl.objects.get().config["PARSER"] == "txt"
def test_crawl_pause_wins_over_concurrent_runner_lease(api_admin_user):
crawl = Crawl.objects.create(urls="https://example.com/crawl-pause-race", created_by=api_admin_user)
claimed_until = timezone.now() + timedelta(seconds=60)
@@ -126,7 +164,7 @@ def test_crawl_pause_wins_over_concurrent_runner_lease(api_admin_user):
assert crawl.retry_at == RETRY_AT_MAX
def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snapshot_results_alone(
def test_crawl_pause_resume_api_leaves_archiveresult_facts_unchanged(
request,
tmp_path,
client,
@@ -210,9 +248,12 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap
)
now = timezone.now()
Crawl.objects.filter(pk=crawl_id).update(status=Crawl.StatusChoices.STARTED, retry_at=now)
Snapshot.objects.filter(pk=active_snapshot.pk).update(status=Snapshot.StatusChoices.QUEUED, retry_at=now)
Snapshot.objects.filter(pk=active_snapshot.pk).update(
status=Snapshot.StatusChoices.QUEUED,
retry_at=now,
config={"PLUGINS": "wget"},
)
active_snapshot.refresh_from_db()
[active_started] = active_snapshot.create_pending_archiveresults(hooks=[("wget", _snapshot_hook_name("wget"))])
errors = []
def run_snapshot():
@@ -235,10 +276,13 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap
request.addfinalizer(finish_runner)
blocking_http_server.request_started.wait()
assert errors == []
active_started.refresh_from_db()
active_started = ArchiveResult.objects.get(snapshot=active_snapshot, plugin="wget")
assert active_started.status == ArchiveResult.StatusChoices.STARTED
[active_queued] = active_snapshot.create_pending_archiveresults(
hooks=[("parse_txt_urls", "on_Snapshot__71_parse_txt_urls")],
active_queued = ArchiveResult.objects.create(
snapshot=active_snapshot,
plugin="parse_txt_urls",
hook_name="on_Snapshot__71_parse_txt_urls",
status=ArchiveResult.StatusChoices.QUEUED,
)
pause_response = api_client_request(
client,
@@ -260,11 +304,11 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap
assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
assert sealed_snapshot.retry_at is None
paused_rows = {
unchanged_rows = {
row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[active_queued.id, active_started.id])
}
assert paused_rows["parse_txt_urls"] == (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX)
assert paused_rows["wget"] == (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX)
assert unchanged_rows["parse_txt_urls"] == (ArchiveResult.StatusChoices.QUEUED, None)
assert unchanged_rows["wget"] == (ArchiveResult.StatusChoices.STARTED, None)
active_done_row = ArchiveResult.objects.get(id=active_done.id)
sealed_done_row = ArchiveResult.objects.get(id=sealed_done.id)
@@ -302,12 +346,7 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap
resumed_rows = {
row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[active_queued.id, active_started.id])
}
assert resumed_rows["parse_txt_urls"][0] == ArchiveResult.StatusChoices.QUEUED
assert resumed_rows["parse_txt_urls"][1] is not None
assert resumed_rows["parse_txt_urls"][1] != RETRY_AT_MAX
assert resumed_rows["wget"][0] == ArchiveResult.StatusChoices.QUEUED
assert resumed_rows["wget"][1] is not None
assert resumed_rows["wget"][1] != RETRY_AT_MAX
assert resumed_rows == unchanged_rows
assert ArchiveResult.objects.get(id=active_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED
assert ArchiveResult.objects.get(id=sealed_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED
assert active_done_path.is_file()
+34 -46
View File
@@ -19,9 +19,9 @@ pytestmark = pytest.mark.django_db(transaction=True)
def _snapshot_hook_name(plugin_name: str) -> str:
from abx_dl.models import discover_plugins
from archivebox.plugins.discovery import get_plugin_catalog
plugin = discover_plugins().get(plugin_name)
plugin = get_plugin_catalog().get(plugin_name)
assert plugin is not None, f"missing test plugin {plugin_name}"
hooks = plugin.filter_hooks("Snapshot")
assert hooks, f"missing Snapshot hooks for {plugin_name}"
@@ -47,15 +47,16 @@ def _run_shipped_snapshot_hook(
"""Run one shipped hook through the production process/result bus services."""
import asyncio
from abx_dl.models import discover_plugins
from abx_dl.services.process_service import ProcessService as HookProcessService
from abx_dl.services.archive_result_service import ArchiveResultService as HookArchiveResultService
from abx_plugins.plugins.base.utils import get_hydrated_required_binaries
from archivebox.core.models import ArchiveResult
from archivebox.machine.models import Process
from archivebox.plugins.discovery import get_plugin_catalog
from archivebox.services.archive_result_service import ArchiveResultService
from archivebox.services.process_service import ProcessService as PersistedProcessService
discovered_plugin = discover_plugins().get(plugin)
discovered_plugin = get_plugin_catalog().get(plugin)
assert discovered_plugin is not None, f"missing test plugin {plugin}"
matching_hooks = [hook for hook in discovered_plugin.filter_hooks("Snapshot") if hook.name == hook_name or hook.path.name == hook_name]
assert len(matching_hooks) == 1, f"missing or ambiguous Snapshot hook {plugin}:{hook_name}"
@@ -76,6 +77,7 @@ def _run_shipped_snapshot_hook(
output_dir.mkdir(parents=True, exist_ok=True)
bus = create_bus(name=f"test_real_{plugin}_{snapshot.id}")
HookProcessService(bus, emit_jsonl=False, interactive_tty=False)
HookArchiveResultService(bus, emit_jsonl=False)
PersistedProcessService(bus)
ArchiveResultService(bus)
@@ -230,7 +232,7 @@ def test_archiveresult_event_retry_updates_existing_hook_row(tmp_path, hermetic_
_cleanup_machine_process_rows()
def test_archiveresult_duplicate_plugin_rows_are_rejected():
def test_archiveresult_duplicate_hook_rows_are_rejected():
from django.db import IntegrityError, transaction
from archivebox.core.models import ArchiveResult
@@ -246,45 +248,35 @@ def test_archiveresult_duplicate_plugin_rows_are_rejected():
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="wget",
hook_name="on_Snapshot__99_other_wget_hook",
hook_name="on_Snapshot__06_wget.finite.bg",
status=ArchiveResult.StatusChoices.SUCCEEDED,
)
def test_archivewebpage_lifecycle_hooks_project_one_plugin_output():
def test_archiveresult_event_create_uses_one_result_lookup():
from abx_dl.events import ArchiveResultEvent
from archivebox.core.models import ArchiveResult
from django.db import connection
from django.test.utils import CaptureQueriesContext
from archivebox.services.archive_result_service import _save_archiveresult_event_to_db
snapshot = _create_snapshot()
_save_archiveresult_event_to_db(
ArchiveResultEvent(
snapshot_id=str(snapshot.id),
plugin="archivewebpage",
hook_name="on_Snapshot__16_archivewebpage_start",
status="succeeded",
output_str="recording started",
output_files=[OutputFile(path="recording.json", extension="json", mimetype="application/json", size=175)],
),
None,
)
_save_archiveresult_event_to_db(
ArchiveResultEvent(
snapshot_id=str(snapshot.id),
plugin="archivewebpage",
hook_name="on_Snapshot__65_archivewebpage_stop",
status="succeeded",
output_str="archivewebpage.wacz",
output_files=[OutputFile(path="archivewebpage.wacz", extension="wacz", size=2048)],
),
None,
event = ArchiveResultEvent(
snapshot_id=str(snapshot.id),
plugin="review-query-count",
hook_name="on_Snapshot__99_review.py",
status="failed",
)
result = ArchiveResult.objects.get(snapshot=snapshot, plugin="archivewebpage")
assert result.hook_name == "on_Snapshot__65_archivewebpage_stop"
assert result.output_str == "archivewebpage.wacz"
assert set(result.output_files) == {"recording.json", "archivewebpage.wacz"}
assert ArchiveResult.objects.filter(snapshot=snapshot, plugin="archivewebpage").count() == 1
with CaptureQueriesContext(connection) as queries:
_save_archiveresult_event_to_db(event, None)
result_lookups = [
query["sql"]
for query in queries
if query["sql"].lstrip().upper().startswith("SELECT") and 'FROM "core_archiveresult"' in query["sql"]
]
assert len(result_lookups) == 1
def test_process_completed_projects_failed_archiveresult_from_shipped_hook(tmp_path, hermetic_lib_dir):
@@ -301,7 +293,6 @@ def test_process_completed_projects_failed_archiveresult_from_shipped_hook(tmp_p
assert result.status == ArchiveResult.StatusChoices.FAILED
assert result.process_id == process.id
assert "Chrome session" in result.output_str
assert result.output_str in result.notes
_cleanup_machine_process_rows()
@@ -470,20 +461,17 @@ def test_retry_failed_archiveresults_requeues_snapshot_in_queued_state():
reset_count = snapshot.retry_failed_archiveresults()
snapshot.refresh_from_db()
result = ArchiveResult.objects.get(snapshot=snapshot, plugin="chrome")
result = ArchiveResult.objects.get(snapshot=snapshot, plugin="chrome", hook_name="on_Snapshot__11_chrome_wait")
assert reset_count == 1
assert snapshot.status == Snapshot.StatusChoices.QUEUED
assert snapshot.retry_at is not None
assert snapshot.current_step == 0
assert result.status == ArchiveResult.StatusChoices.QUEUED
assert result.hook_name == ""
assert result.output_str == ""
assert result.output_json is None
assert result.output_files == {}
assert result.output_size == 0
assert result.output_mimetypes == ""
assert result.start_ts is None
assert result.end_ts is None
assert snapshot.config["RETRY_PLUGINS"] == ["chrome"]
assert "PLUGINS" not in snapshot.config
assert result.status == ArchiveResult.StatusChoices.FAILED
assert result.output_str == "timed out"
assert result.output_files == {"stderr.log": {}}
assert result.output_size == 123
assert result.output_mimetypes == "text/plain"
assert ArchiveResult.objects.get(snapshot=snapshot, plugin="ublock").status == ArchiveResult.StatusChoices.SKIPPED
assert ArchiveResult.objects.get(snapshot=snapshot, plugin="forumdl").status == ArchiveResult.StatusChoices.NORESULTS
snapshot.refresh_from_db()
+11 -19
View File
@@ -225,7 +225,7 @@ def test_add_single_url_records_url_in_crawl(initialized_archive):
crawl = Crawl.objects.get()
snapshots = list(Snapshot.objects.all())
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
assert crawl.urls == "https://example.com"
assert crawl.get_urls_list() == ["https://example.com"]
assert snapshots == []
@@ -295,13 +295,10 @@ def test_add_stdin_import_formats_preserve_metadata_and_crawl_inner_urls(initial
assert crawl.urls == source_text
run_queued_crawls(initialized_archive, env=env, timeout=240)
with use_archivebox_db(initialized_archive):
root_counts = {str(crawl.id): crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).count() for crawl in Crawl.objects.all()}
assert root_counts and all(count == 1 for count in root_counts.values()), root_counts
with use_archivebox_db(initialized_archive):
for crawl in Crawl.objects.all():
root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL)
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
assert not crawl.snapshot_set.filter(url__startswith="archivebox://").exists()
root_input = (crawl.output_dir / "input" / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
assert root_input == crawl.urls
assert_expected_import_snapshots(initialized_archive, expected_urls)
@@ -387,7 +384,7 @@ def test_run_rejects_file_url_injected_directly_into_crawl_urls_with_db_update(i
status=Crawl.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
bad_jsonl = json.dumps({"type": "CrawlSeed", "url": file_url, "depth": 0, "tags": "sql-file-url"})
bad_jsonl = json.dumps({"type": "Snapshot", "url": file_url, "depth": 0, "tags": "sql-file-url"})
Crawl.objects.filter(pk=crawl.pk).update(urls=bad_jsonl)
result = run_archivebox_cmd(
@@ -480,7 +477,7 @@ def test_add_bg_queues_direct_url_snapshot(initialized_archive):
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at is not None
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
assert crawl.urls == "https://example.com"
assert snapshots == []
@@ -622,7 +619,7 @@ def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(initi
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at is None
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
assert crawl.urls == "https://example.com"
assert snapshot_urls == set()
run_queued_crawls(initialized_archive, env)
@@ -633,7 +630,7 @@ def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(initi
assert crawl.status == Crawl.StatusChoices.SEALED
assert crawl.retry_at is None
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
assert crawl.urls == "https://example.com"
assert snapshot_urls == set()
@@ -658,9 +655,7 @@ def test_add_index_only_rejects_archivebox_internal_urls(initialized_archive):
crawl = Crawl.objects.get()
snapshot_urls = set(Snapshot.objects.values_list("url", flat=True))
assert [json.loads(line) for line in crawl.urls.splitlines()] == [
{"type": "CrawlSeed", "url": url, "depth": 0} for url in internal_urls
]
assert crawl.urls.splitlines() == internal_urls
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at is None
assert snapshot_urls == set()
@@ -713,10 +708,7 @@ def test_add_multiple_urls_single_command(initialized_archive):
crawl = Crawl.objects.get()
snapshots = list(Snapshot.objects.order_by("url").values_list("url", "depth"))
assert [json.loads(line) for line in crawl.urls.splitlines()] == [
{"type": "CrawlSeed", "url": "https://example.com", "depth": 0},
{"type": "CrawlSeed", "url": "https://example.org", "depth": 0},
]
assert crawl.urls.splitlines() == ["https://example.com", "https://example.org"]
assert snapshots == []
@@ -994,7 +986,7 @@ def test_add_index_only_queues_crawl_without_starting_runner(initialized_archive
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at is None
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
assert crawl.urls == "https://example.com"
assert snapshots == []
@@ -1012,7 +1004,7 @@ def test_add_index_only_creates_direct_url_snapshot(initialized_archive):
crawl = Crawl.objects.get()
snapshot = Snapshot.objects.get()
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
assert crawl.urls == "https://example.com"
assert snapshot.url == "https://example.com"
assert snapshot.depth == 0
+6 -9
View File
@@ -61,11 +61,10 @@ class TestArchiveResultCreate:
ar = next(r for r in records if r["type"] == "ArchiveResult")
assert ar["plugin"] == "title"
# Queue projection is one row per plugin, while a plugin can contain
# several ordered hooks. The runner records the concrete hook only
# after execution; inventing one here would make the pending row claim
# work that has not run and reintroduce hook-level duplicate results.
assert ar["hook_name"] == ""
# Hook identity is known at scheduling time and is the durable retry
# key. Emitting it here prevents multiple hooks in one plugin from
# collapsing into the same ArchiveResult row.
assert ar["hook_name"] == "on_Snapshot__54_title"
assert "id" not in ar
def test_create_with_specific_plugin(self, initialized_archive):
@@ -92,11 +91,9 @@ class TestArchiveResultCreate:
assert code == 0
records = parse_jsonl_output(stdout2)
ar_records = [r for r in records if r.get("type") == "ArchiveResult"]
assert len(ar_records) >= 1
assert len(ar_records) == 1
assert all(record["plugin"] == "screenshot" for record in ar_records)
# A requested plugin is the schedulable unit; its concrete hook is an
# execution result, not input metadata on this pre-execution request.
assert all(record["hook_name"] == "" for record in ar_records)
assert [record["hook_name"] for record in ar_records] == ["on_Snapshot__51_screenshot"]
def test_create_pass_through_crawl(self, initialized_archive):
"""Pass-through Crawl records unchanged."""
+69 -6
View File
@@ -22,12 +22,16 @@ def create_extract_snapshot(initialized_archive, env, url="https://example.com")
)
def test_extract_archiveresult_record_queues_one_plugin_result(initialized_archive):
def test_extract_archiveresult_record_queues_parent_snapshot_plugin(initialized_archive):
env = cli_env(PLUGINS="archivewebpage")
create_extract_snapshot(initialized_archive, env)
with use_archivebox_db(initialized_archive):
snapshot_id = Snapshot.objects.values_list("id", flat=True).first()
snapshot = Snapshot.objects.get()
snapshot.pause()
snapshot.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.PAUSED
snapshot_id = snapshot.id
hook_name = "on_Snapshot__65_archivewebpage_stop"
request = json.dumps(
@@ -49,10 +53,69 @@ def test_extract_archiveresult_record_queues_one_plugin_result(initialized_archi
assert result.returncode == 0, result.stderr or result.stdout
with use_archivebox_db(initialized_archive):
rows = list(
ArchiveResult.objects.filter(snapshot_id=snapshot_id, plugin="archivewebpage").values_list("hook_name", "status"),
)
assert rows == [("", ArchiveResult.StatusChoices.QUEUED)]
snapshot = Snapshot.objects.get(id=snapshot_id)
rows = list(ArchiveResult.objects.filter(snapshot_id=snapshot_id, plugin="archivewebpage"))
assert rows == []
assert snapshot.status == Snapshot.StatusChoices.QUEUED
assert snapshot.retry_at is not None
assert snapshot.config["RETRY_PLUGINS"] == ["archivewebpage"]
def test_extract_no_wait_keeps_sealed_snapshot_sealed(initialized_archive):
env = cli_env(PLUGINS="title")
create_extract_snapshot(initialized_archive, env)
with use_archivebox_db(initialized_archive):
snapshot = Snapshot.objects.get()
snapshot.status = Snapshot.StatusChoices.SEALED
snapshot.retry_at = None
snapshot.save(update_fields=["status", "retry_at", "modified_at"])
snapshot_id = snapshot.id
crawl_id = snapshot.crawl_id
snapshot.crawl.status = snapshot.crawl.StatusChoices.SEALED
snapshot.crawl.retry_at = None
snapshot.crawl.save(update_fields=["status", "retry_at", "modified_at"])
request = json.dumps(
{
"type": "ArchiveResult",
"snapshot_id": str(snapshot_id),
"plugin": "title",
"hook_name": "on_Snapshot__60_title",
},
)
result = run_archivebox_cmd(
["extract", "--no-wait"],
cwd=initialized_archive,
input=f"{request}\n",
env=env,
timeout=30,
)
assert result.returncode == 0, result.stderr or result.stdout
with use_archivebox_db(initialized_archive):
snapshot.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.retry_at is not None
assert snapshot.config["RETRY_PLUGINS"] == ["title"]
assert not ArchiveResult.objects.filter(snapshot=snapshot).exists()
run_result = run_archivebox_cmd(
["run", f"--crawl-id={crawl_id}"],
cwd=initialized_archive,
env=env,
timeout=90,
)
assert run_result.returncode == 0, run_result.stderr or run_result.stdout
with use_archivebox_db(initialized_archive):
snapshot.refresh_from_db()
result = ArchiveResult.objects.get(snapshot=snapshot, plugin="title")
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.retry_at is None
assert "RETRY_PLUGINS" not in snapshot.config
assert result.status == ArchiveResult.StatusChoices.SUCCEEDED
assert result.output_str == "Example Domain"
def test_extract_runs_on_snapshot_id(initialized_archive):
-2
View File
@@ -232,7 +232,6 @@ def test_list_limit_zero_streams_one_million_snapshots_without_materializing(ini
fs_version,
crawl_id,
config,
current_step,
depth,
notes,
num_uses_failed,
@@ -256,7 +255,6 @@ def test_list_limit_zero_streams_one_million_snapshots_without_materializing(ini
%s,
'{}',
0,
0,
'',
0,
0,
+2 -2
View File
@@ -38,7 +38,7 @@ PIPE_TEST_ENV = {
def run_real_txt_parser(tmp_path, text):
"""Run the shipped text parser and return its real snapshot output directory."""
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = tmp_path / "parser-snapshot"
staticfile_dir = snap_dir / "staticfile"
@@ -47,7 +47,7 @@ def run_real_txt_parser(tmp_path, text):
output_dir.mkdir(parents=True)
(staticfile_dir / "input.txt").write_text(text, encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
+42 -422
View File
@@ -240,7 +240,7 @@ class TestRunWithArchiveResult:
"""Tests for `archivebox run` with ArchiveResult input."""
@pytest.mark.django_db(transaction=True)
def test_run_treats_no_id_archiveresult_request_as_plugin_work(self, initialized_archive):
def test_run_treats_no_id_archiveresult_as_parent_snapshot_plugin_request(self, initialized_archive):
import json
from archivebox.core.models import ArchiveResult
@@ -286,10 +286,11 @@ class TestRunWithArchiveResult:
)
assert len(rows) == 1
assert rows[0][0] != missing_hook
assert rows[0][0].startswith("on_Snapshot__")
assert rows[0][1] in ArchiveResult.FINAL_STATES
def test_run_requeues_failed_archiveresult(self, initialized_archive):
"""Run re-queues a failed ArchiveResult."""
"""Run uses a failed ArchiveResult as a parent Snapshot/plugin reference."""
url = create_test_url()
# Create snapshot and archive result
@@ -901,43 +902,6 @@ class TestRecoverOrchestratorState:
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.core.models import ArchiveResult, Snapshot
from archivebox.core.recovery_util import recover_orchestrator_state
from archivebox.crawls.models import Crawl
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="search_backend_sqlite",
hook_name="on_Snapshot__90_index_sqlite",
status=ArchiveResult.StatusChoices.BACKOFF,
)
recovered = recover_orchestrator_state()
result.refresh_from_db()
snapshot.refresh_from_db()
crawl.refresh_from_db()
assert recovered["archiveresults_backoff"] == 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.core.models import ArchiveResult, Snapshot
@@ -976,6 +940,7 @@ class TestRecoverOrchestratorState:
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at is not None
@pytest.mark.django_db(transaction=True)
def test_recover_orchestrator_state_leaves_stale_queued_final_rows_for_runner(self):
from datetime import timedelta
@@ -985,12 +950,13 @@ class TestRecoverOrchestratorState:
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.recovery_util import recover_orchestrator_state
from archivebox.crawls.models import Crawl
from archivebox.services.runner import run_due_crawl, run_due_snapshot
from archivebox.services.runner import run_due_snapshot
old = timezone.now() - timedelta(hours=13)
crawl = Crawl.objects.create(
urls="https://example.com",
created_by_id=get_or_create_system_user_pk(),
config={"PLUGINS": "__archivebox_test_no_plugins__"},
status=Crawl.StatusChoices.QUEUED,
retry_at=old,
)
@@ -1029,16 +995,11 @@ class TestRecoverOrchestratorState:
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.retry_at is None
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at == old
assert run_due_crawl(crawl, lock_seconds=60) is True
crawl.refresh_from_db()
assert crawl.status == Crawl.StatusChoices.SEALED
assert crawl.retry_at is None
def test_run_due_snapshot_seals_queued_snapshot_with_final_results(self):
@pytest.mark.django_db(transaction=True)
def test_run_due_snapshot_runs_snapshot_without_consulting_final_result_rows(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
@@ -1049,6 +1010,7 @@ class TestRecoverOrchestratorState:
crawl = Crawl.objects.create(
urls="https://example.com",
created_by_id=get_or_create_system_user_pk(),
config={"PLUGINS": "__archivebox_test_no_plugins__"},
status=Crawl.StatusChoices.STARTED,
retry_at=timezone.now(),
)
@@ -1072,74 +1034,6 @@ class TestRecoverOrchestratorState:
assert snapshot.retry_at is None
assert snapshot.downloaded_at is not None
def test_create_pending_archiveresults_creates_one_plugin_row_not_hook_rows(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import 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=timezone.now(),
)
results = snapshot.create_pending_archiveresults(
hooks=[
("chrome", "on_Snapshot__00_chrome_launch.daemon.bg"),
("chrome", "on_Snapshot__01_chrome_tab.daemon.bg"),
("chrome", "on_Snapshot__30_chrome_navigate"),
("title", "on_Snapshot__54_title"),
],
)
assert [(result.plugin, result.hook_name) for result in results] == [("chrome", ""), ("title", "")]
assert ArchiveResult.objects.filter(snapshot=snapshot).count() == 2
def test_snapshot_hooks_for_pending_archiveresults_respects_disabled_plugins_when_plugins_empty(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import Snapshot
from archivebox.crawls.models import Crawl
from archivebox.services.runner import snapshot_hooks_for_pending_archiveresults
crawl = Crawl.objects.create(
urls="https://example.com",
created_by_id=get_or_create_system_user_pk(),
config={
"CLAUDECHROME_ENABLED": False,
"CLAUDECODEEXTRACT_ENABLED": False,
"CLAUDECODECLEANUP_ENABLED": False,
"SEARCH_BACKEND_SQLITE_ENABLED": False,
},
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(),
)
hooks = snapshot_hooks_for_pending_archiveresults(snapshot)
queued_plugins = {plugin for plugin, _hook_name in hooks}
assert "title" in queued_plugins
assert "claudechrome" not in queued_plugins
assert "claudecodeextract" not in queued_plugins
assert "claudecodecleanup" not in queued_plugins
assert "search_backend_sqlite" not in queued_plugins
def test_run_due_snapshot_pauses_child_when_parent_is_paused(self):
from django.utils import timezone
@@ -1174,7 +1068,7 @@ class TestRecoverOrchestratorState:
result.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.PAUSED
assert snapshot.retry_at == RETRY_AT_MAX
assert result.status == ArchiveResult.StatusChoices.PAUSED
assert result.status == ArchiveResult.StatusChoices.QUEUED
assert snapshot.archiveresult_set.count() == 1
def test_parent_status_transitions_schedule_children_to_follow_parent_status(self):
@@ -1232,7 +1126,7 @@ class TestRecoverOrchestratorState:
sealed_started_child.refresh_from_db()
assert paused_child.status == Snapshot.StatusChoices.PAUSED
assert paused_child.retry_at == RETRY_AT_MAX
assert paused_result.status == ArchiveResult.StatusChoices.PAUSED
assert paused_result.status == ArchiveResult.StatusChoices.QUEUED
assert sealed_child.status == Snapshot.StatusChoices.PAUSED
assert sealed_child.retry_at is not None
assert sealed_child.retry_at <= timezone.now()
@@ -1673,153 +1567,6 @@ class TestRecoverOrchestratorState:
assert snapshot.output_dir.joinpath("index.html").read_text(encoding="utf-8") == "legacy archive"
assert not legacy_dir.exists()
@pytest.mark.django_db(transaction=True)
def test_run_due_snapshot_runs_queued_plugin_after_fs_migration(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.services.runner import run_due_snapshot
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=timezone.now(),
)
Snapshot.objects.filter(pk=snapshot.pk).update(fs_version="0.9.0")
snapshot.refresh_from_db()
snapshot.output_dir.mkdir(parents=True, exist_ok=True)
title_dir = snapshot.output_dir / "title"
title_dir.mkdir(parents=True, exist_ok=True)
(title_dir / "title.txt").write_text("Example Domain\n", encoding="utf-8")
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="search_backend_sqlite",
hook_name="on_Snapshot__90_index_sqlite",
status=ArchiveResult.StatusChoices.QUEUED,
)
assert run_due_snapshot(snapshot, lock_seconds=60) is True
snapshot.refresh_from_db()
result.refresh_from_db()
assert snapshot.fs_version == Snapshot._fs_current_version()
assert result.status in ArchiveResult.FINAL_STATES
assert result.status != ArchiveResult.StatusChoices.QUEUED
assert result.start_ts is not None
assert result.end_ts is not None
@pytest.mark.django_db(transaction=True)
def test_run_due_snapshot_runs_plugin_for_obsolete_queued_hook_name(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.services.runner import run_due_snapshot
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=timezone.now(),
)
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="singlefile",
hook_name="on_Snapshot__50_singlefile.py",
status=ArchiveResult.StatusChoices.QUEUED,
)
assert run_due_snapshot(snapshot, lock_seconds=60) is True
result.refresh_from_db()
snapshot.refresh_from_db()
assert result.status in ArchiveResult.FINAL_STATES
assert result.hook_name != "on_Snapshot__50_singlefile.py"
assert snapshot.retry_at is None
@pytest.mark.django_db(transaction=True)
def test_run_due_snapshot_skips_disabled_queued_plugins_and_seals_started_snapshot(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.services.runner import run_due_snapshot
crawl = Crawl.objects.create(
urls="https://example.com",
created_by_id=get_or_create_system_user_pk(),
config={
"CLAUDECHROME_ENABLED": False,
"CLAUDECODEEXTRACT_ENABLED": False,
"CLAUDECODECLEANUP_ENABLED": False,
"SEARCH_BACKEND_SQLITE_ENABLED": False,
},
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(),
downloaded_at=timezone.now(),
)
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="title",
hook_name="on_Snapshot__01_title",
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_str="Example Domain",
)
stale_results = [
ArchiveResult.objects.create(
snapshot=snapshot,
plugin=plugin,
hook_name=hook_name,
status=ArchiveResult.StatusChoices.QUEUED,
)
for plugin, hook_name in (
("claudechrome", "on_Snapshot__47_claudechrome"),
("claudecodeextract", "on_Snapshot__58_claudecodeextract"),
("claudecodecleanup", "on_Snapshot__92_claudecodecleanup"),
("search_backend_sqlite", "on_Snapshot__90_index_sqlite"),
)
]
stale_result_ids = [result.id for result in stale_results]
stale_plugins = [result.plugin for result in stale_results]
assert run_due_snapshot(snapshot, lock_seconds=60) is True
snapshot.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.retry_at is None
assert not snapshot.archiveresult_set.filter(
plugin__in=stale_plugins,
status=ArchiveResult.StatusChoices.QUEUED,
).exists()
for result in ArchiveResult.objects.filter(id__in=stale_result_ids):
assert result.status == ArchiveResult.StatusChoices.SKIPPED
assert result.start_ts is not None
assert result.end_ts is not None
assert "disabled by this Snapshot/Crawl config" in result.output_str
@pytest.mark.django_db(transaction=True)
@pytest.mark.timeout(300)
@pytest.mark.parametrize("chrome_isolation", ["crawl", "snapshot"])
@@ -1829,10 +1576,7 @@ class TestRecoverOrchestratorState:
recursive_test_site,
chrome_isolation,
):
from pathlib import Path
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.machine.models import Process
from archivebox.core.models import ArchiveResult
from archivebox.tests.test_orm_helpers import use_archivebox_db
env = cli_env(disable_extractors=True)
@@ -1870,19 +1614,13 @@ class TestRecoverOrchestratorState:
snapshot_id = navigate_record["snapshot_id"]
with use_archivebox_db(initialized_archive):
snapshot = Snapshot.objects.get(id=snapshot_id)
tab_processes = [
process
for process in Process.objects.filter(pwd=str(Path(snapshot.output_dir) / "chrome")).order_by("-started_at")
if Path(process.hook_script_name or "").stem == "on_Snapshot__01_chrome_tab.daemon.bg"
]
assert tab_processes
first_tab_process_id = tab_processes[0].id
plugin_result = ArchiveResult.objects.get(
tab_result = ArchiveResult.objects.get(
snapshot_id=snapshot_id,
plugin="chrome",
hook_name="on_Snapshot__01_chrome_tab.daemon.bg",
)
assert plugin_result.hook_name == "on_Snapshot__30_chrome_navigate"
first_tab_process_id = tab_result.process_id
assert first_tab_process_id is not None
update_process = run_archivebox_cmd(
["archiveresult", "update", "--status=queued"],
@@ -1916,18 +1654,18 @@ class TestRecoverOrchestratorState:
navigate_result = ArchiveResult.objects.get(
snapshot_id=snapshot_id,
plugin="chrome",
hook_name="on_Snapshot__30_chrome_navigate",
)
tab_result = ArchiveResult.objects.get(
snapshot_id=snapshot_id,
plugin="chrome",
hook_name="on_Snapshot__01_chrome_tab.daemon.bg",
)
snapshot = Snapshot.objects.get(id=snapshot_id)
tab_processes = [
process
for process in Process.objects.filter(pwd=str(Path(snapshot.output_dir) / "chrome")).order_by("-started_at")
if Path(process.hook_script_name or "").stem == "on_Snapshot__01_chrome_tab.daemon.bg"
]
assert run_process.returncode == 0
assert navigate_result.status == ArchiveResult.StatusChoices.SUCCEEDED
assert navigate_result.hook_name == "on_Snapshot__30_chrome_navigate"
assert tab_processes[0].id != first_tab_process_id
assert tab_result.process_id is not None
assert tab_result.process_id != first_tab_process_id
def test_recover_orchestrator_state_ignores_sealed_downloaded_snapshot_without_results(self):
from django.utils import timezone
@@ -1962,6 +1700,7 @@ class TestRecoverOrchestratorState:
assert crawl.status == Crawl.StatusChoices.SEALED
assert crawl.retry_at is None
@pytest.mark.django_db(transaction=True)
def test_recover_orchestrator_state_unlocks_started_snapshot_with_final_results_for_runner(self):
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
@@ -1972,6 +1711,7 @@ class TestRecoverOrchestratorState:
crawl = Crawl.objects.create(
urls="https://example.com",
created_by_id=get_or_create_system_user_pk(),
config={"PLUGINS": "__archivebox_test_no_plugins__"},
status=Crawl.StatusChoices.STARTED,
retry_at=None,
)
@@ -2062,7 +1802,7 @@ class TestRunDueCrawlState:
assert crawl.retry_at == now
assert crawl.snapshot_set.count() == 0
def test_maintenance_only_runner_ignores_disabled_queued_results_on_sealed_snapshots(self):
def test_maintenance_only_runner_clears_snapshot_tick_without_scheduling_archive_results(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
@@ -2096,7 +1836,7 @@ class TestRunDueCrawlState:
result.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.fs_version == Snapshot._fs_current_version()
assert snapshot.retry_at is not None
assert snapshot.retry_at is None
assert result.status == ArchiveResult.StatusChoices.QUEUED
def test_snapshot_start_writes_short_future_lease(self):
@@ -2126,131 +1866,6 @@ class TestRunDueCrawlState:
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.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import 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.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,
)
snapshot.reset_abandoned_results()
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_finished_parser_result_projects_children_before_resume_seals_snapshot(self):
from importlib.resources import files
from pathlib import Path
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.plugins.hooks import extract_records_from_process, run_hook
from archivebox.services.runner import run_due_snapshot
crawl = Crawl.objects.create(
urls="Plain text import containing https://example.org/\n",
max_depth=1,
created_by_id=get_or_create_system_user_pk(),
status=Crawl.StatusChoices.STARTED,
retry_at=timezone.now(),
)
root = Snapshot.objects.create(
url=Snapshot.INTERNAL_INPUT_URL,
crawl=crawl,
depth=0,
status=Snapshot.StatusChoices.STARTED,
retry_at=timezone.now(),
)
staticfile_dir = root.output_dir / "staticfile"
parser_dir = root.output_dir / "parse_txt_urls"
staticfile_dir.mkdir(parents=True, exist_ok=True)
parser_dir.mkdir(parents=True, exist_ok=True)
(staticfile_dir / "input.txt").write_text(
"Plain text import containing https://example.org/\n",
encoding="utf-8",
)
hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py")))
process = run_hook(
hook_path,
parser_dir,
config={"ABXPKG_LIB_DIR": str(root.output_dir.parent.parent / "lib"), "SNAP_DIR": str(root.output_dir)},
timeout=30,
url=root.url,
depth=root.depth,
snapshot_id=str(root.id),
)
process.refresh_from_db()
assert process.exit_code == 0, process.stderr
result_record = next(record for record in extract_records_from_process(process) if record.get("type") == "ArchiveResult")
ArchiveResult.objects.create(
snapshot=root,
process=process,
plugin=result_record["plugin"],
hook_name=result_record["hook_name"],
status=result_record["status"],
output_str=result_record.get("output_str", ""),
output_files={"urls.jsonl": {"size": (parser_dir / "urls.jsonl").stat().st_size}},
)
assert run_due_snapshot(root, lock_seconds=60)
root.refresh_from_db()
child = Snapshot.objects.get(crawl=crawl, url="https://example.org/")
assert root.status == Snapshot.StatusChoices.SEALED
assert child.parent_snapshot_id == root.id
assert child.status == Snapshot.StatusChoices.QUEUED
def test_due_started_snapshot_with_live_child_extends_lease_without_reset(self):
import os
from datetime import datetime
@@ -2589,7 +2204,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
assert crawl.status == Crawl.StatusChoices.STARTED
assert crawl.retry_at == future
def test_recovery_requeues_started_archiveresult_without_process(self):
def test_recovery_closes_interrupted_result_and_requeues_parent_snapshot(self):
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.recovery_util import recover_orchestrator_state
@@ -2617,9 +2232,12 @@ class TestRecoverOrchestratorStateRedFailureModes:
recover_orchestrator_state()
result.refresh_from_db()
assert result.status == ArchiveResult.StatusChoices.QUEUED
snapshot.refresh_from_db()
assert result.status == ArchiveResult.StatusChoices.FAILED
assert snapshot.status == Snapshot.StatusChoices.STARTED
assert snapshot.retry_at is not None
def test_recovery_requeues_started_archiveresult_with_exited_process(self):
def test_recovery_closes_started_archiveresult_with_exited_process(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
@@ -2658,9 +2276,11 @@ class TestRecoverOrchestratorStateRedFailureModes:
recover_orchestrator_state()
result.refresh_from_db()
assert result.status == ArchiveResult.StatusChoices.QUEUED
snapshot.refresh_from_db()
assert result.status == ArchiveResult.StatusChoices.FAILED
assert snapshot.retry_at is not None
def test_recovery_requeues_sealed_snapshot_started_result_with_exited_process_result_too(self):
def test_recovery_does_not_reopen_sealed_snapshot_for_interrupted_result_projection(self):
from django.utils import timezone
from archivebox.base_models.models import get_or_create_system_user_pk
@@ -2706,10 +2326,10 @@ class TestRecoverOrchestratorStateRedFailureModes:
snapshot.refresh_from_db()
result.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.retry_at is not None
assert result.status == ArchiveResult.StatusChoices.QUEUED
assert snapshot.retry_at is None
assert result.status == ArchiveResult.StatusChoices.FAILED
def test_recovery_requeues_started_snapshot_result_before_unlocking_snapshot(self):
def test_recovery_closes_result_projection_before_unlocking_snapshot(self):
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.recovery_util import recover_orchestrator_state
@@ -2738,7 +2358,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
snapshot.refresh_from_db()
result.refresh_from_db()
assert result.status == ArchiveResult.StatusChoices.QUEUED
assert result.status == ArchiveResult.StatusChoices.FAILED
assert snapshot.retry_at is not None
def test_crawl_runner_load_run_state_does_not_return_future_retry_snapshots(self):
+4 -2
View File
@@ -56,9 +56,11 @@ def test_schedule_without_import_path_creates_maintenance_schedule(initialized_a
assert "Created scheduled maintenance update" in result.stdout
with use_archivebox_db(initialized_archive):
row = Crawl.objects.order_by("-created_at").values_list("urls", "status").first()
schedule_row = CrawlSchedule.objects.select_related("template").get()
assert row == ("archivebox://update", "sealed")
assert schedule_row.kind == "update"
assert schedule_row.template.urls == ""
assert schedule_row.template.status == "sealed"
def test_schedule_creates_enabled_db_schedule(initialized_archive):
+3 -3
View File
@@ -24,7 +24,7 @@ def test_update_runs_successfully_on_empty_archive(initialized_archive):
assert result.returncode == 0, output
assert "Phase 1: Draining old archive/ directories" in output
assert "Phase 2: Processing all database snapshots" in output
assert "Phase 2: Selecting database snapshots with stale filesystem versions" in output
assert "Updated DB rows: 0" in output
assert "Sealed crawls: 0" in output
@@ -52,7 +52,7 @@ def test_update_reconciles_existing_snapshots(initialized_archive):
output = result.stdout + result.stderr
assert result.returncode == 0, output
assert "Phase 2: Processing all database snapshots" in output
assert "Phase 2: Selecting database snapshots with stale filesystem versions" in output
assert "Updated DB rows:" in output
with use_archivebox_db(initialized_archive):
@@ -147,7 +147,7 @@ def test_update_seals_migrated_snapshots(initialized_archive):
output = result.stdout + result.stderr
assert result.returncode == 0, output
assert "Phase 2: Processing all database snapshots" in output
assert "Phase 2: Selecting database snapshots with stale filesystem versions" in output
assert "Reindexing" not in output
# Check that snapshot remains archived instead of being queued for a full re-crawl.
@@ -15,84 +15,6 @@ from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
def test_only_sealed_search_backfill_bypasses_snapshot_lifecycle():
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.cli.archivebox_extract import run_plugins
from archivebox.core.models import ArchiveResult
from archivebox.crawls.models import Crawl
search_crawl = Crawl.objects.create(
urls="https://example.com/search",
created_by_id=get_or_create_system_user_pk(),
status=Crawl.StatusChoices.SEALED,
)
search_snapshot = Snapshot.objects.create(
url="https://example.com/search",
crawl=search_crawl,
status=Snapshot.StatusChoices.SEALED,
)
extract_crawl = Crawl.objects.create(
urls="https://example.com/extract",
created_by_id=get_or_create_system_user_pk(),
status=Crawl.StatusChoices.SEALED,
)
extract_snapshot = Snapshot.objects.create(
url="https://example.com/extract",
crawl=extract_crawl,
status=Snapshot.StatusChoices.SEALED,
)
assert (
run_plugins(
args=(),
records=[
{
"type": "ArchiveResult",
"snapshot_id": str(search_snapshot.id),
"plugin": "search_backend_sqlite",
},
],
wait=False,
emit_results=False,
show_progress=False,
)
== 0
)
search_crawl.refresh_from_db()
search_snapshot.refresh_from_db()
assert search_crawl.status == Crawl.StatusChoices.SEALED
assert search_snapshot.status == Snapshot.StatusChoices.SEALED
assert search_snapshot.archiveresult_set.filter(
plugin="search_backend_sqlite",
status=ArchiveResult.StatusChoices.QUEUED,
).exists()
assert (
run_plugins(
args=(),
records=[
{
"type": "ArchiveResult",
"snapshot_id": str(extract_snapshot.id),
"plugin": "wget",
},
],
wait=False,
emit_results=False,
show_progress=False,
)
== 0
)
extract_crawl.refresh_from_db()
extract_snapshot.refresh_from_db()
assert extract_crawl.status == Crawl.StatusChoices.QUEUED
assert extract_snapshot.status == Snapshot.StatusChoices.QUEUED
assert extract_snapshot.archiveresult_set.filter(
plugin="wget",
status=ArchiveResult.StatusChoices.QUEUED,
).exists()
def test_update_imports_orphaned_snapshots(tmp_path, initialized_archive):
"""Test that archivebox update imports real legacy archive directories."""
env = cli_env(disable_extractors=True)
@@ -196,7 +118,8 @@ def test_update_migrates_every_declared_filesystem_version(tmp_path, initialized
assert snapshot.status == Snapshot.StatusChoices.QUEUED
assert snapshot.retry_at is not None
result.refresh_from_db()
assert result.output_files
# Migration projects filesystem facts without changing result lifecycle.
assert "singlefile.html" in result.output_files
assert result.output_size > 0
if legacy_layout:
assert (migrated_dir / "existing-user-output.bin").read_bytes() == b"preserve interrupted migration output"
@@ -215,15 +138,16 @@ def test_update_migrates_every_declared_filesystem_version(tmp_path, initialized
@pytest.mark.django_db(transaction=True)
def test_reindex_snapshots_resets_existing_search_results_and_reruns_requested_plugins():
def test_reindex_snapshots_runs_only_missing_sealed_search_indexes():
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.cli.archivebox_update import reindex_snapshots
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
crawl = Crawl.objects.create(
urls="https://example.com",
urls="https://example.com\nhttps://example.org",
created_by_id=get_or_create_system_user_pk(),
status=Crawl.StatusChoices.SEALED,
)
snapshot = Snapshot.objects.create(
url="https://example.com",
@@ -250,12 +174,20 @@ def test_reindex_snapshots_resets_existing_search_results_and_reruns_requested_p
(output_dir / "title" / "title.txt").write_text("Example Domain")
(output_dir / "dom").mkdir(parents=True, exist_ok=True)
(output_dir / "dom" / "output.html").write_text("<html><body>Example searchable text</body></html>")
missing_snapshot = Snapshot.objects.create(
url="https://example.org",
crawl=crawl,
status=Snapshot.StatusChoices.SEALED,
)
missing_output_dir = missing_snapshot.output_dir
(missing_output_dir / "title").mkdir(parents=True, exist_ok=True)
(missing_output_dir / "title" / "title.txt").write_text("Missing Search Index")
original_engine = os.environ.get("SEARCH_BACKEND_ENGINE")
os.environ["SEARCH_BACKEND_ENGINE"] = "sqlite"
try:
stats = reindex_snapshots(
Snapshot.objects.filter(id__in=(snapshot.id, paused_snapshot.id)),
Snapshot.objects.filter(id__in=(snapshot.id, missing_snapshot.id, paused_snapshot.id)).select_related("crawl"),
search_plugins=["search_backend_sqlite"],
batch_size=10,
)
@@ -267,15 +199,92 @@ def test_reindex_snapshots_resets_existing_search_results_and_reruns_requested_p
result.refresh_from_db()
snapshot.refresh_from_db()
missing_snapshot.refresh_from_db()
assert stats["processed"] == 1
assert stats["queued"] == 1
assert stats["reindexed"] == 0
assert result.status == ArchiveResult.StatusChoices.QUEUED
assert result.output_str == ""
assert result.output_json is None
assert stats["requested"] == 1
assert stats["queued"] == 0
assert stats["reindexed"] == 1
assert result.status == ArchiveResult.StatusChoices.SUCCEEDED
assert result.output_str == "old index hit"
assert result.output_json == {"indexed": True}
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert missing_snapshot.status == Snapshot.StatusChoices.SEALED
assert missing_snapshot.archiveresult_set.filter(
plugin="search_backend_sqlite",
status__in=[ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS],
).exists()
assert not paused_snapshot.archiveresult_set.exists()
@pytest.mark.django_db(transaction=True)
def test_update_schedule_backfills_missing_search_index_without_reopening_snapshot():
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl, CrawlSchedule
user_id = get_or_create_system_user_pk()
template = Crawl.objects.create(
urls="",
created_by_id=user_id,
status=Crawl.StatusChoices.SEALED,
)
schedule = CrawlSchedule.objects.create(
template=template,
schedule="daily",
config={"SCHEDULE_KIND": "update"},
created_by_id=user_id,
)
crawl = Crawl.objects.create(
urls="https://example.com/scheduled-search",
created_by_id=user_id,
status=Crawl.StatusChoices.SEALED,
)
snapshot = Snapshot.objects.create(
url="https://example.com/scheduled-search",
crawl=crawl,
status=Snapshot.StatusChoices.SEALED,
fs_version=Snapshot._fs_current_version(),
)
stale_snapshot = Snapshot.objects.create(
url="https://example.com/stale-scheduled-search",
crawl=crawl,
status=Snapshot.StatusChoices.SEALED,
fs_version=next(iter(Snapshot._FS_VERSION_MIGRATION_PATHS)),
retry_at=None,
)
(snapshot.output_dir / "title").mkdir(parents=True, exist_ok=True)
(snapshot.output_dir / "title" / "title.txt").write_text("Scheduled Search Index")
original_engine = os.environ.get("SEARCH_BACKEND_ENGINE")
os.environ["SEARCH_BACKEND_ENGINE"] = "sqlite"
try:
assert schedule.dispatch() is None
first_modified_at = schedule.modified_at
assert schedule.dispatch() is None
finally:
if original_engine is None:
os.environ.pop("SEARCH_BACKEND_ENGINE", None)
else:
os.environ["SEARCH_BACKEND_ENGINE"] = original_engine
snapshot.refresh_from_db()
stale_snapshot.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert (
ArchiveResult.objects.filter(
snapshot=snapshot,
plugin="search_backend_sqlite",
status__in=[ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS],
).count()
== 1
)
assert not ArchiveResult.objects.filter(snapshot=snapshot, status=ArchiveResult.StatusChoices.QUEUED).exists()
assert stale_snapshot.retry_at is not None
assert not ArchiveResult.objects.filter(snapshot=stale_snapshot, plugin="search_backend_sqlite").exists()
assert schedule.modified_at >= first_modified_at
@pytest.mark.django_db
def test_build_filtered_snapshots_queryset_respects_resume_cutoff():
from archivebox.base_models.models import get_or_create_system_user_pk
+14 -6
View File
@@ -67,7 +67,7 @@ def test_enqueue_discovered_snapshots_refreshes_crawl_limits(tmp_path):
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.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
from archivebox.services.runner import CrawlRunner
crawl = Crawl.objects.create(
@@ -92,7 +92,7 @@ def test_enqueue_discovered_snapshots_refreshes_crawl_limits(tmp_path):
encoding="utf-8",
)
hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py")))
process = run_hook(
process = run_test_hook(
hook_path,
parser_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
@@ -195,7 +195,7 @@ def test_seal_snapshot_cancels_queued_descendants_after_crawl_max_size():
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
from archivebox.services.snapshot_service import SnapshotService
from abx_dl.events import SnapshotCompletedEvent
from abx_dl.events import SnapshotCompletedEvent, SnapshotEvent
from abx_dl.orchestrator import create_bus
crawl = Crawl.objects.create(
@@ -231,17 +231,25 @@ def test_seal_snapshot_cancels_queued_descendants_after_crawl_max_size():
)
bus = create_bus(name=f"test_snapshot_limit_cancel_{str(crawl.id).replace('-', '_')}")
service = SnapshotService(bus, crawl_id=str(crawl.id))
SnapshotService(bus, crawl_id=str(crawl.id))
try:
async def emit_event() -> None:
await service.on_SnapshotCompletedEvent(
SnapshotCompletedEvent(
snapshot_event = bus.emit(
SnapshotEvent(
url=root.url,
snapshot_id=str(root.id),
output_dir=str(root.output_dir),
),
)
await snapshot_event.now()
completed_event = SnapshotCompletedEvent(
url=root.url,
snapshot_id=str(root.id),
output_dir=str(root.output_dir),
)
completed_event.event_parent_id = snapshot_event.event_id
await bus.emit(completed_event).now()
asyncio.run(emit_event())
finally:
+96 -93
View File
@@ -271,45 +271,6 @@ def test_snapshot_started_state_keeps_retry_at_lease():
assert snapshot.retry_at > before
@pytest.mark.django_db(transaction=True)
def test_system_update_crawl_runs_database_maintenance_without_snapshot_work():
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
from django.utils import timezone
owner_id = get_or_create_system_user_pk()
archived_crawl = Crawl.objects.create(
urls="https://example.com",
created_by_id=owner_id,
status=Crawl.StatusChoices.SEALED,
retry_at=None,
)
archived_snapshot = Snapshot.objects.create(
url="https://example.com",
crawl=archived_crawl,
status=Snapshot.StatusChoices.SEALED,
retry_at=None,
)
Snapshot.objects.filter(pk=archived_snapshot.pk).update(fs_version=0, retry_at=None)
maintenance_crawl = Crawl.objects.create(
urls="archivebox://update",
created_by_id=owner_id,
status=Crawl.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
snapshot_ids = CrawlRunner(maintenance_crawl, show_progress=False).load_run_state()
maintenance_crawl.refresh_from_db()
archived_snapshot.refresh_from_db()
assert snapshot_ids == []
assert maintenance_crawl.snapshot_set.count() == 0
assert archived_snapshot.status == Snapshot.StatusChoices.SEALED
assert archived_snapshot.retry_at is not None
@pytest.mark.django_db(transaction=True)
def test_crawl_start_event_keeps_retry_at_lease():
from abx_dl.events import CrawlStartEvent
@@ -657,6 +618,35 @@ def test_crawl_runner_empty_plugin_selection_emits_lifecycle_and_seals_crawl(tmp
assert snapshot.archiveresult_set.count() == 0
@pytest.mark.django_db(transaction=True)
def test_crawl_runner_preflights_plugins_before_crawl_lifecycle(tmp_path):
from abx_dl.events import CrawlEvent, CrawlSetupEvent, InstallEvent
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.services.runner import CrawlRunner
crawl = Crawl.objects.create(
urls="https://example.com",
config={
"ABXPKG_LIB_DIR": str(tmp_path / "lib"),
"PLUGINS": "base",
"CHROME_BINARY": "",
},
created_by_id=get_or_create_system_user_pk(),
)
runner = CrawlRunner(crawl)
event_order = []
runner.bus.on(InstallEvent, lambda _event: event_order.append(InstallEvent))
runner.bus.on(CrawlEvent, lambda _event: event_order.append(CrawlEvent))
runner.bus.on(CrawlSetupEvent, lambda _event: event_order.append(CrawlSetupEvent))
asyncio.run(runner.run())
assert event_order.count(InstallEvent) == 1
assert event_order.index(InstallEvent) < event_order.index(CrawlEvent)
assert event_order.index(InstallEvent) < event_order.index(CrawlSetupEvent)
@pytest.mark.django_db(transaction=True)
def test_crawl_runner_resolves_persona_and_crawl_config_for_each_live_snapshot():
from abx_dl.events import SnapshotCompletedEvent
@@ -1123,60 +1113,13 @@ def test_crawl_completed_event_seals_finished_crawl():
assert crawl.retry_at is None
@pytest.mark.django_db(transaction=True)
def test_snapshot_completed_event_defers_finished_crawl_seal():
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.snapshot_service import SnapshotService
from abx_dl.events import SnapshotCompletedEvent
from abx_dl.orchestrator import create_bus
from django.utils import timezone
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=None,
)
bus = create_bus(name=f"test_snapshot_completed_finished_crawl_{str(crawl.id).replace('-', '_')}")
service = SnapshotService(bus, crawl_id=str(crawl.id))
try:
async def emit_completed() -> None:
await service.on_SnapshotCompletedEvent(
SnapshotCompletedEvent(
url="https://example.com",
snapshot_id=str(snapshot.id),
output_dir=str(snapshot.output_dir),
),
)
asyncio.run(emit_completed())
finally:
asyncio.run(bus.destroy())
snapshot.refresh_from_db()
crawl.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert crawl.status == Crawl.StatusChoices.STARTED
assert crawl.retry_at is not None
@pytest.mark.django_db(transaction=True)
def test_snapshot_completed_event_bus_defers_finished_crawl_seal():
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.snapshot_service import SnapshotService
from abx_dl.events import SnapshotCompletedEvent
from abx_dl.events import SnapshotCompletedEvent, SnapshotEvent
from abx_dl.orchestrator import create_bus
from django.utils import timezone
@@ -1194,20 +1137,25 @@ def test_snapshot_completed_event_bus_defers_finished_crawl_seal():
)
bus = create_bus(name=f"test_snapshot_completed_bus_finished_crawl_{str(crawl.id).replace('-', '_')}")
service = SnapshotService(bus, crawl_id=str(crawl.id))
assert service is not None
SnapshotService(bus, crawl_id=str(crawl.id))
try:
async def emit_completed() -> None:
emitted = bus.emit(
SnapshotCompletedEvent(
snapshot_event = bus.emit(
SnapshotEvent(
url="https://example.com",
snapshot_id=str(snapshot.id),
output_dir=str(snapshot.output_dir),
),
)
await emitted.wait()
await emitted.event_results_list()
await snapshot_event.now()
completed_event = SnapshotCompletedEvent(
url="https://example.com",
snapshot_id=str(snapshot.id),
output_dir=str(snapshot.output_dir),
)
completed_event.event_parent_id = snapshot_event.event_id
await bus.emit(completed_event).now()
asyncio.run(emit_completed())
finally:
@@ -1219,3 +1167,58 @@ def test_snapshot_completed_event_bus_defers_finished_crawl_seal():
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert crawl.status == Crawl.StatusChoices.STARTED
assert crawl.retry_at is not None
@pytest.mark.django_db(transaction=True)
def test_delayed_snapshot_completion_cannot_seal_new_run():
from datetime import timedelta
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.snapshot_service import SnapshotService
from abx_dl.events import SnapshotCompletedEvent, SnapshotEvent
from abx_dl.orchestrator import create_bus
from asgiref.sync import sync_to_async
from django.utils import timezone
crawl = Crawl.objects.create(urls="https://example.com", created_by_id=get_or_create_system_user_pk())
old_retry_at = timezone.now() + timedelta(minutes=5)
new_retry_at = old_retry_at + timedelta(minutes=5)
snapshot = Snapshot.objects.create(
url="https://example.com",
crawl=crawl,
status=Snapshot.StatusChoices.STARTED,
retry_at=old_retry_at,
)
bus = create_bus(name=f"test_delayed_snapshot_completion_{str(crawl.id).replace('-', '_')}")
SnapshotService(bus, crawl_id=str(crawl.id))
try:
async def emit_runs() -> None:
old_event = bus.emit(
SnapshotEvent(url=snapshot.url, snapshot_id=str(snapshot.id), output_dir=str(snapshot.output_dir)),
)
await old_event.now()
await sync_to_async(Snapshot.objects.filter(pk=snapshot.pk).update, thread_sensitive=True)(retry_at=new_retry_at)
new_event = bus.emit(
SnapshotEvent(url=snapshot.url, snapshot_id=str(snapshot.id), output_dir=str(snapshot.output_dir)),
)
await new_event.now()
completed_event = SnapshotCompletedEvent(
url=snapshot.url,
snapshot_id=str(snapshot.id),
output_dir=str(snapshot.output_dir),
)
completed_event.event_parent_id = old_event.event_id
await bus.emit(completed_event).now()
asyncio.run(emit_runs())
finally:
asyncio.run(bus.wait_until_idle())
asyncio.run(bus.destroy())
snapshot.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.STARTED
assert snapshot.retry_at == new_retry_at
+1 -1
View File
@@ -80,7 +80,7 @@ def test_crawl_service_run_processes_queued_crawl_and_applies_crawl_config(tmp_p
assert queued_state["retry_at"] is not None
assert queued_state["config"]["PLUGINS"] == "wget,parse_html_urls"
assert queued_state["config"]["URL_DENYLIST"] == "/contact$"
# add --bg seeds Crawl.urls as CrawlSeed JSONL and returns; the runner
# add --bg stores the submitted URL list in Crawl.urls and returns; the runner
# materializes Snapshot rows + applies URL_DENYLIST when it claims the
# crawl, not at add time. The post-run assertions below verify those.
assert queued_state["snapshots"] == []
+16 -75
View File
@@ -80,14 +80,14 @@ class TestJSONLParsing:
@staticmethod
def run_hashes_hook(tmp_path):
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = tmp_path / "hash-snapshot"
output_dir = snap_dir / "hashes"
output_dir.mkdir(parents=True)
(snap_dir / "source.txt").write_text("real parser input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
@@ -100,7 +100,7 @@ class TestJSONLParsing:
@staticmethod
def run_parser_hook(tmp_path):
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = tmp_path / "parser-snapshot"
staticfile_dir = snap_dir / "staticfile"
@@ -112,7 +112,7 @@ class TestJSONLParsing:
encoding="utf-8",
)
hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
@@ -182,35 +182,6 @@ class TestJSONLParsing:
assert len(records) == 1
assert records[0]["type"] == "ArchiveResult"
def test_direct_hook_preserves_explicit_parent_process(self, tmp_path):
"""The compatibility adapter must retain the caller's process hierarchy."""
from archivebox.machine.models import Machine, Process
from archivebox.plugins.hooks import run_hook
parent = Process.objects.create(
machine=Machine.current(),
process_type=Process.TypeChoices.CLI,
status=Process.StatusChoices.RUNNING,
)
snap_dir = tmp_path / "parented-snapshot"
output_dir = snap_dir / "hashes"
output_dir.mkdir(parents=True)
(snap_dir / "source.txt").write_text("parented hook input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
hook_path,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
timeout=30,
parent=parent,
url="https://example.com/parented-hook",
)
process.refresh_from_db()
assert process.exit_code == 0, process.stderr
assert process.parent_id == parent.id
class TestRequiredBinaryConfigHandling:
"""Test that required_binaries keep configured XYZ_BINARY values intact."""
@@ -446,7 +417,7 @@ class TestHookExecution:
@pytest.mark.django_db(transaction=True)
def test_real_js_hook_runs_through_abxpkg_shebang(self, tmp_path, hermetic_lib_dir):
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
from archivebox.services.runner import run_install
lib_dir = hermetic_lib_dir
@@ -456,7 +427,7 @@ class TestHookExecution:
snap_dir = crawl_dir / "snapshot"
hook_path = Path(str(files("abx_plugins.plugins.chrome").joinpath("on_CrawlSetup__89_chrome_kill_zombies.js")))
process = run_hook(
process = run_test_hook(
hook_path,
crawl_dir / "chrome",
config={
@@ -551,7 +522,7 @@ class TestSnapshotHookOutput:
[(True, "succeeded"), (False, "skipped")],
)
def test_hashes_hook_emits_real_archive_result(self, tmp_path, enabled, expected_status):
from archivebox.plugins.hooks import extract_records_from_process, run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = tmp_path / f"snapshot-{expected_status}"
output_dir = snap_dir / "hashes"
@@ -559,7 +530,7 @@ class TestSnapshotHookOutput:
(snap_dir / "source.txt").write_text("real hook protocol input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={
@@ -573,51 +544,21 @@ class TestSnapshotHookOutput:
process.refresh_from_db()
assert process.exit_code == 0, process.stderr
records = extract_records_from_process(process)
records = process.get_records()
assert len(records) == 1
assert records[0]["type"] == "ArchiveResult"
assert records[0]["status"] == expected_status
assert records[0]["plugin"] == "hashes"
assert records[0]["hook_name"] == hook_path.name
assert records[0]["plugin_hook"] == str(hook_path)
assert process.cmd[0] == str(hook_path)
if enabled:
assert (output_dir / "hashes.json").is_file()
else:
assert records[0]["output_str"] == "HASHES_ENABLED=False"
class TestPluginMetadata:
"""Test that plugin metadata is added to JSONL records."""
@pytest.mark.django_db(transaction=True)
def test_python_hook_metadata_comes_from_executed_shipped_hook(self, tmp_path):
from archivebox.plugins.hooks import extract_records_from_process, run_hook
snap_dir = tmp_path / "snapshot-metadata"
output_dir = snap_dir / "hashes"
output_dir.mkdir(parents=True)
(snap_dir / "source.txt").write_text("metadata", encoding="utf-8")
script = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
script,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
timeout=30,
url="https://example.com/metadata",
)
process.refresh_from_db()
assert process.exit_code == 0, process.stderr
records = extract_records_from_process(process)
assert records[0]["plugin"] == "hashes"
assert records[0]["hook_name"] == script.name
assert records[0]["plugin_hook"] == str(script)
@pytest.mark.django_db(transaction=True)
def test_run_hook_exports_singular_node_modules_dir_with_colon_node_path(tmp_path, hermetic_lib_dir):
def test_abx_dl_hook_execution_exports_singular_node_modules_dir_with_colon_node_path(tmp_path, hermetic_lib_dir):
"""Hook subprocesses must get a real NODE_MODULES_DIR even when NODE_PATH has multiple entries."""
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
from archivebox.services.runner import run_install
lib_dir = hermetic_lib_dir
@@ -632,7 +573,7 @@ def test_run_hook_exports_singular_node_modules_dir_with_colon_node_path(tmp_pat
crawl_dir = tmp_path / "crawl"
output_dir = crawl_dir / "chrome"
hook_path = Path(str(files("abx_plugins.plugins.chrome").joinpath("on_CrawlSetup__89_chrome_kill_zombies.js")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={
@@ -654,16 +595,16 @@ def test_run_hook_exports_singular_node_modules_dir_with_colon_node_path(tmp_pat
@pytest.mark.django_db(transaction=True)
def test_run_hook_executes_python_hooks_through_abxpkg_shebang(tmp_path):
def test_abx_dl_executes_python_hooks_through_abxpkg_shebang(tmp_path):
"""ArchiveBox treats Python hooks as opaque abxpkg-launched executables."""
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = tmp_path / "snapshot"
output_dir = snap_dir / "hashes"
output_dir.mkdir(parents=True)
(snap_dir / "source.txt").write_text("real runtime hook input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={
+1
View File
@@ -13,6 +13,7 @@ pytestmark = pytest.mark.django_db(transaction=True)
def _runtime_env(data_dir: Path, **extra: str) -> dict[str, str]:
return {
"ABXPKG_LIB_DIR": str(data_dir / "lib"),
"PLUGINS": "liteparse",
"LITEPARSE_ENABLED": "True",
"TIMEOUT": "180",
"ABXPKG_INSTALL_TIMEOUT": "180",
@@ -1,53 +0,0 @@
import pytest
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
pytestmark = pytest.mark.django_db(transaction=True)
def test_migration_consolidates_plugin_rows_and_enforces_uniqueness():
try:
executor = MigrationExecutor(connection)
executor.migrate([("core", "0051_postgres_url_pattern_ops_index")])
old_apps = executor.loader.project_state([("core", "0051_postgres_url_pattern_ops_index")]).apps
Crawl = old_apps.get_model("crawls", "Crawl")
Snapshot = old_apps.get_model("core", "Snapshot")
ArchiveResult = old_apps.get_model("core", "ArchiveResult")
crawl = Crawl.objects.create(urls="https://example.com")
snapshot = Snapshot.objects.create(url="https://example.com/migration", crawl=crawl, output_size=10)
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="responses",
hook_name="browser-upload",
status="succeeded",
output_files={"browser.png": {"size": 7}},
output_size=7,
output_mimetypes="image/png",
)
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="responses",
hook_name="server-capture",
status="noresults",
output_files={"server.json": {"size": 3}},
output_size=3,
output_mimetypes="application/json",
)
executor = MigrationExecutor(connection)
executor.migrate([("core", "0052_unique_archiveresult_per_snapshot_plugin")])
new_apps = executor.loader.project_state([("core", "0052_unique_archiveresult_per_snapshot_plugin")]).apps
Snapshot = new_apps.get_model("core", "Snapshot")
ArchiveResult = new_apps.get_model("core", "ArchiveResult")
snapshot = Snapshot.objects.get(id=snapshot.id)
result = ArchiveResult.objects.get(snapshot=snapshot, plugin="responses")
assert ArchiveResult.objects.filter(snapshot=snapshot, plugin="responses").count() == 1
assert result.status == "succeeded"
assert set(result.output_files) == {"browser.png", "server.json"}
assert result.output_size == 10
assert snapshot.output_size == 10
assert set(result.output_mimetypes.split(",")) == {"application/json", "image/png"}
finally:
MigrationExecutor(connection).migrate([("core", "0052_unique_archiveresult_per_snapshot_plugin")])
+1 -1
View File
@@ -403,7 +403,7 @@ def test_update_saves_migrated_snapshots_without_foreign_key_errors(archive_07):
result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=60)
result = run_archivebox_migration_cmd(work_dir, ["update", "--migrate-only"], timeout=60)
output = result.stdout + result.stderr
assert result.returncode == 0, f"Update failed after migration: {result.stderr}"
assert "FOREIGN KEY constraint failed" not in output
+9 -5
View File
@@ -92,7 +92,7 @@ def convert_legacy_tags_to_uuid(conn: sqlite3.Connection) -> None:
@pytest.mark.parametrize("uuid_tags", (False, True), ids=("integer-tags", "uuid-tags"))
def test_migration_preserves_extended_08_metadata(migration_08_data, uuid_tags):
def test_migration_preserves_supported_extended_08_metadata(migration_08_data, uuid_tags):
work_dir, db_path, original_data = migration_08_data
snapshot = original_data["snapshots"][1]
parent = original_data["snapshots"][0]
@@ -152,11 +152,12 @@ def test_migration_preserves_extended_08_metadata(migration_08_data, uuid_tags):
snapshot_row = conn.execute(
"""
SELECT depth, config, notes, num_uses_failed, num_uses_succeeded,
parent_snapshot_id, current_step, fs_version
parent_snapshot_id, fs_version
FROM core_snapshot WHERE id = ?
""",
(snapshot["id"],),
).fetchone()
snapshot_columns = {row[1] for row in conn.execute("PRAGMA table_info(core_snapshot)")}
result_notes = conn.execute(
"SELECT notes FROM core_archiveresult WHERE id = REPLACE(?, '-', '')",
(archiveresult["uuid"],),
@@ -174,9 +175,9 @@ def test_migration_preserves_extended_08_metadata(migration_08_data, uuid_tags):
metadata["num_uses_failed"],
metadata["num_uses_succeeded"],
parent["id"],
metadata["current_step"],
metadata["fs_version"],
)
assert "current_step" not in snapshot_columns
assert result_notes == ("legacy archive result notes",)
assert migrated_tag == tag_metadata
assert migrated_counts == expected_counts
@@ -482,7 +483,7 @@ def test_hyphenated_crawl_ids_are_normalized_before_snapshot_saves(migration_08_
assert hyphenated_crawls == 0
assert hyphenated_snapshot_refs == 0
result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=60)
result = run_archivebox_migration_cmd(work_dir, ["update", "--migrate-only"], timeout=60)
output = result.stdout + result.stderr
assert result.returncode == 0, f"Update failed after migration: {result.stderr}"
assert "FOREIGN KEY constraint failed" not in output
@@ -898,7 +899,10 @@ def test_update_preserves_legacy_plugin_directory_without_output_files(migration
(snapshot["id"],),
).fetchall()
conn.close()
assert rows == [("", "{}", "")]
assert len(rows) == 2
assert all(output_str == "" for output_str, _output_files, _hook_name in rows)
assert len({hook_name for _output_str, _output_files, hook_name in rows}) == 2
assert sum(not hook_name for _output_str, _output_files, hook_name in rows) == 1
def test_07_filesystem_hop_preserves_complete_output_tree(tmp_path):
+227 -15
View File
@@ -1,11 +1,16 @@
import asyncio
import os
import signal
import socket
import subprocess
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import quote
import pytest
import requests
from asgiref.testing import ApplicationCommunicator
from archivebox.tests.conftest import ADMIN_TEST_HOST, run_archivebox_cmd
@@ -104,10 +109,7 @@ def live_opencode(opencode_archive_config):
try:
yield SimpleNamespace(config=opencode_archive_config, settings=settings, process=process)
finally:
if views._PROCESS and views._PROCESS.poll() is None:
views._PROCESS.terminate()
views._PROCESS.wait(timeout=10)
views._PROCESS = None
views._stop_owned_process()
def test_opencode_disabled_route_does_not_start_server(client, initialized_archive):
@@ -125,6 +127,20 @@ def test_opencode_disabled_route_does_not_start_server(client, initialized_archi
assert views._PROCESS is None or views._PROCESS.poll() is not None
def test_stop_owned_process_falls_back_for_stopped_process_without_dedicated_group():
from archivebox.opencode import views
process = subprocess.Popen(["sleep", "60"])
try:
process.send_signal(signal.SIGSTOP)
views._stop_owned_process(process)
assert process.returncode == -signal.SIGTERM
finally:
if process.poll() is None:
process.kill()
process.wait()
def test_opencode_agent_requires_superuser_when_enabled(client, db, django_user_model, live_opencode):
response = client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST)
assert response.status_code == 302
@@ -164,19 +180,58 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
from archivebox.opencode import views
response = admin_client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST)
recent_session_id = views._recent_session_id(live_opencode.settings)
recent_session_id = response.context["recent_session_id"]
session_path = views._project_route(live_opencode.config.data_dir, recent_session_id)
assert response.status_code == 200
assert recent_session_id
assert f'<iframe src="{views._project_route(live_opencode.config.data_dir, recent_session_id)}"'.encode() in response.content
assert f'<iframe src="{session_path}"'.encode() in response.content
assert b'id="header"' in response.content
assert b'id="progress-monitor"' in response.content
assert response.context["proxy_prefix"] == views._PROXY_PREFIX
assert response.context["opencode_version"]
assert b"const healthUrl" in response.content
assert b"/admin/agent/opencode/global/health" in response.content
assert b"/admin/agent/opencode/_archivebox/health" in response.content
assert b'redirect: "manual"' in response.content
assert b"let waking = false" in response.content
assert b"wake();" in response.content
assert b"frame.contentWindow.location.reload()" in response.content
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'"
session = admin_client.get(
session_path,
HTTP_HOST=ADMIN_TEST_HOST,
HTTP_SEC_FETCH_SITE="same-origin",
)
assert session.status_code == 200
assert session.headers["X-Frame-Options"] == "SAMEORIGIN"
assert session.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
def test_opencode_health_monitor_does_not_start_server(admin_client, live_opencode):
from archivebox.opencode import views
views._stop_owned_process()
response = admin_client.get(
"/admin/agent/opencode/_archivebox/health",
HTTP_HOST=ADMIN_TEST_HOST,
)
assert response.status_code == 503
assert response.json() == {"healthy": False, "version": ""}
assert response.headers["Cache-Control"] == "no-store"
assert views._PROCESS is None
def test_opencode_proxy_serves_real_project_and_session(admin_client, live_opencode):
workdir = str(live_opencode.config.data_dir.resolve())
encoded_workdir = quote(workdir)
agent = admin_client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST)
assert agent.status_code == 200
project = admin_client.get(
f"/admin/agent/opencode/project/current?directory={encoded_workdir}",
HTTP_HOST=ADMIN_TEST_HOST,
@@ -202,6 +257,93 @@ def test_opencode_proxy_serves_real_project_and_session(admin_client, live_openc
assert b"id" in sessions.content
def test_opencode_proxy_restarts_server_for_an_existing_agent_page(admin_client, live_opencode):
from archivebox.opencode import views
old_process = views._PROCESS
views._stop_owned_process()
response = admin_client.get(
"/admin/agent/opencode/global/health",
HTTP_HOST=ADMIN_TEST_HOST,
HTTP_SEC_FETCH_SITE="same-origin",
)
assert response.status_code == 200
assert views._PROCESS is not None
assert views._PROCESS is not old_process
assert views._PROCESS.poll() is None
def test_concurrent_opencode_startup_waits_until_server_is_ready(live_opencode):
from archivebox.opencode import views
views._stop_owned_process()
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(views._ensure_opencode, [live_opencode.settings] * 2))
assert results == [(True, ""), (True, "")]
assert views._health(live_opencode.settings)
def test_opencode_does_not_probe_or_replace_a_ready_owned_process(live_opencode):
from archivebox.opencode import views
process = views._PROCESS
settings = {**live_opencode.settings, "port": _free_port()}
settings["origin"] = f"http://{settings['host']}:{settings['port']}"
ok, error = views._ensure_opencode(settings)
assert ok, error
assert process is not None
assert views._PROCESS is process
assert process.poll() is None
def test_opencode_proxy_does_not_wait_for_recovery_lock(admin_client, live_opencode):
from archivebox.opencode import views
workdir = quote(str(live_opencode.config.data_dir.resolve()))
assert views._owned_process_ready()
executor = ThreadPoolExecutor(max_workers=1)
views._PROCESS_LOCK.acquire()
try:
request = executor.submit(
admin_client.get,
f"/admin/agent/opencode/path?directory={workdir}",
HTTP_HOST=ADMIN_TEST_HOST,
HTTP_SEC_FETCH_SITE="same-origin",
)
response = request.result(timeout=5)
finally:
views._PROCESS_LOCK.release()
executor.shutdown(wait=True)
assert response.status_code == 200
assert str(live_opencode.config.data_dir.resolve()).encode() in response.content
def test_opencode_proxy_waits_for_owned_process_readiness(admin_client, live_opencode):
from archivebox.opencode import views
process = views._PROCESS
assert process is not None
views._PROCESS_READY = None
workdir = quote(str(live_opencode.config.data_dir.resolve()))
response = admin_client.get(
f"/admin/agent/opencode/path?directory={workdir}",
HTTP_HOST=ADMIN_TEST_HOST,
HTTP_SEC_FETCH_SITE="same-origin",
)
assert response.status_code == 200
assert views._PROCESS is process
assert views._PROCESS_READY is process
def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode):
response = admin_client.get(
"/admin/agent/opencode/global/event",
@@ -211,10 +353,64 @@ def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode):
assert response.status_code == 200
assert response.streaming
assert response.is_async
assert response.headers["X-Accel-Buffering"] == "no"
assert response.headers["Cache-Control"] == "no-store"
def test_opencode_proxy_sse_returns_headers_before_restart_finishes(admin_client, live_opencode):
from archivebox.core.asgi import application
from archivebox.opencode import views
from django.conf import settings as django_settings
owned_process = views._PROCESS
assert owned_process is not None
session_cookie_name = django_settings.SESSION_COOKIE_NAME
session_cookie = admin_client.cookies[session_cookie_name].value
path = "/admin/agent/opencode/global/event"
async def request_event_stream():
communicator = ApplicationCommunicator(
application,
{
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": [
(b"host", ADMIN_TEST_HOST.encode()),
(b"cookie", f"{session_cookie_name}={session_cookie}".encode()),
(b"sec-fetch-site", b"same-origin"),
],
"client": ("127.0.0.1", 12345),
"server": ("127.0.0.1", 8000),
},
)
views._PROCESS_LOCK.acquire()
views._PROCESS = None
try:
await communicator.send_input({"type": "http.request", "body": b"", "more_body": False})
response_start = await communicator.receive_output(timeout=2)
assert response_start["type"] == "http.response.start"
assert response_start["status"] == 200
finally:
try:
await communicator.send_input({"type": "http.disconnect"})
await communicator.wait(timeout=5)
finally:
views._PROCESS = owned_process
views._PROCESS_LOCK.release()
await asyncio.get_running_loop().shutdown_default_executor()
asyncio.run(request_event_stream())
assert views._PROCESS is owned_process
assert owned_process.poll() is None
def test_opencode_starts_with_isolated_state(live_opencode):
workdir = str(live_opencode.config.data_dir.resolve())
state_dir = live_opencode.config.state_dir
@@ -238,21 +434,37 @@ def test_opencode_starts_with_isolated_state(live_opencode):
def test_opencode_state_dir_is_separate_from_workdir(tmp_path):
from archivebox.opencode import views
workdir = tmp_path / "data"
settings = views._settings({"OPENCODE_WORKDIR": str(workdir)})
workdir = tmp_path / "workdir"
state_dir = tmp_path / "state"
settings = views._settings(
{
"OPENCODE_WORKDIR": str(workdir),
"OPENCODE_STATE_DIR": str(state_dir),
},
)
views._ensure_project_files(settings)
assert settings["workdir"] == workdir
assert settings["opencode_dir"] == workdir / "opencode"
assert settings["config_home"] == workdir / "opencode" / "config"
assert settings["data_home"] == workdir / "opencode" / "data"
assert settings["state_home"] == workdir / "opencode" / "state"
editable_skill = workdir / "opencode" / "SKILL.md"
loaded_skill = workdir / "opencode" / "config" / "opencode" / "skills" / "archivebox" / "SKILL.md"
assert settings["opencode_dir"] == state_dir
assert settings["config_home"] == state_dir / "config"
assert settings["data_home"] == state_dir / "data"
assert settings["state_home"] == state_dir / "state"
editable_skill = state_dir / "SKILL.md"
loaded_skill = state_dir / "config" / "opencode" / "skills" / "archivebox" / "SKILL.md"
assert editable_skill.exists()
assert loaded_skill.is_symlink()
assert loaded_skill.resolve() == editable_skill.resolve()
assert f"ArchiveBox collection directory: {workdir.resolve()}" in editable_skill.read_text()
assert f"ArchiveBox collection directory: {settings['archivebox_data_dir']}" in editable_skill.read_text()
def test_opencode_default_workdir_does_not_scan_the_collection():
from archivebox.opencode import views
settings = views._settings({})
assert settings["opencode_dir"] == settings["archivebox_data_dir"] / "opencode"
assert settings["workdir"] == settings["opencode_dir"] / "workdir"
assert settings["timeout"] == 120
def test_opencode_rewrites_vite_preload_assets():
@@ -11,14 +11,14 @@ pytestmark = pytest.mark.django_db(transaction=True)
class TestProcessRuntimePaths:
def test_hook_processes_use_isolated_runtime_dir(self, tmp_path):
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = tmp_path / "snapshot"
output_dir = snap_dir / "hashes"
output_dir.mkdir(parents=True)
(snap_dir / "source.txt").write_text("real runtime path input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
+2 -2
View File
@@ -493,10 +493,10 @@ def test_recursive_crawl_depth_two_all_plugins_runs_snapshots_in_parallel(
):
"""Run a bounded real depth=2 crawl with all plugins enabled and verify parallel snapshot execution."""
from abx_dl.models import discover_plugins
from archivebox.plugins.discovery import get_plugin_catalog
root_url = recursive_test_site["root_url"]
plugin_selection = ",".join(sorted(plugin for plugin in discover_plugins().keys() if not plugin.startswith("claude")))
plugin_selection = ",".join(sorted(plugin for plugin in get_plugin_catalog() if not plugin.startswith("claude")))
env = os.environ.copy()
for preinstalled_path_key in (
"CHROME_BINARY",
+11 -11
View File
@@ -118,8 +118,8 @@ def configure_ripgrep_search_backend(lib_dir: Path) -> None:
)
def test_search_backend_env_exposes_resolved_runtime_config(tmp_path):
from archivebox.search.backends import search_backend_env
def test_search_backend_command_env_serializes_config_without_mutating_process_env(tmp_path):
from archivebox.search.backends import search_backend_command_env
old_env = os.environ.get("SEARCH_BACKEND_SONIC_HOST_NAME")
os.environ["SEARCH_BACKEND_SONIC_HOST_NAME"] = "old-host"
@@ -136,15 +136,14 @@ def test_search_backend_env_exposes_resolved_runtime_config(tmp_path):
)
try:
with search_backend_env(config=config):
assert os.environ["SEARCH_BACKEND_ENGINE"] == "sonic"
assert os.environ["SEARCH_BACKEND_SONIC_HOST_NAME"] == "sonic"
assert os.environ["SEARCH_BACKEND_SONIC_PORT"] == "1491"
assert os.environ["SEARCH_BACKEND_SONIC_PASSWORD"] == "SecretPassword"
assert os.environ["RIPGREP_BINARY"] == str(tmp_path / "env" / "bin" / "rg")
assert "IGNORED_NONE_VALUE" not in os.environ
assert "UNRELATED_BINARY_NAME" not in os.environ
env = search_backend_command_env(config=config)
assert env["SEARCH_BACKEND_ENGINE"] == "sonic"
assert env["SEARCH_BACKEND_SONIC_HOST_NAME"] == "sonic"
assert env["SEARCH_BACKEND_SONIC_PORT"] == "1491"
assert env["SEARCH_BACKEND_SONIC_PASSWORD"] == "SecretPassword"
assert env["RIPGREP_BINARY"] == str(tmp_path / "env" / "bin" / "rg")
assert "IGNORED_NONE_VALUE" not in env
assert env["UNRELATED_BINARY_NAME"] == "rg"
assert os.environ["SEARCH_BACKEND_SONIC_HOST_NAME"] == "old-host"
finally:
if old_env is None:
@@ -859,6 +858,7 @@ class TestSearchBackendsE2E:
sonic_port = get_free_port()
env = cli_env(
live=True,
ABXPKG_LIB_DIR=str(lib_dir),
PLUGINS="wget,search_backend_ripgrep,search_backend_sqlite,search_backend_sonic",
SAVE_TITLE="True",
WGET_ENABLED="True",
@@ -0,0 +1,108 @@
from pathlib import Path
import pytest
from django.db import connection
from archivebox.config import CONSTANTS
from archivebox.core.models import Snapshot, SnapshotMigrationError
pytestmark = pytest.mark.django_db(transaction=True)
def _make_legacy_snapshot(snapshot: Snapshot) -> tuple[Path, Path]:
current_dir = snapshot.get_storage_path_for_version(snapshot._fs_current_version())
legacy_dir = CONSTANTS.ARCHIVE_DIR / snapshot.timestamp
Snapshot.objects.filter(pk=snapshot.pk).update(fs_version="0.8.0")
snapshot.refresh_from_db()
current_dir.mkdir(parents=True, exist_ok=True)
legacy_dir.mkdir(parents=True, exist_ok=True)
(legacy_dir / "unknown" / "nested").mkdir(parents=True)
(legacy_dir / "unknown" / "payload.bin").write_bytes(b"filesystem migration payload\x00\xff")
return legacy_dir, current_dir
def test_ordinary_snapshot_save_does_not_migrate_directories(snapshot):
legacy_dir, current_dir = _make_legacy_snapshot(snapshot)
snapshot.title = "Metadata-only update"
snapshot.save()
snapshot.refresh_from_db()
assert snapshot.fs_version == "0.8.0"
assert legacy_dir.exists()
assert not (current_dir / "unknown" / "payload.bin").exists()
def test_filesystem_migration_repairs_crawl_link(snapshot):
legacy_dir, current_dir = _make_legacy_snapshot(snapshot)
crawl_link = Path(snapshot.crawl.output_dir) / "snapshots" / Snapshot.extract_domain_from_url(snapshot.url) / str(snapshot.id)
crawl_link.unlink(missing_ok=True)
snapshot.migrate_filesystem_to_current_version()
snapshot.refresh_from_db()
assert snapshot.fs_version == snapshot._fs_current_version()
assert not legacy_dir.exists()
assert (current_dir / "unknown" / "payload.bin").read_bytes() == b"filesystem migration payload\x00\xff"
assert crawl_link.is_symlink()
assert crawl_link.resolve() == current_dir.resolve()
def test_filesystem_migration_resumes_after_shutdown_before_cleanup(snapshot, monkeypatch):
legacy_dir, current_dir = _make_legacy_snapshot(snapshot)
real_cleanup = Snapshot._cleanup_old_migration_dir
def simulate_shutdown_before_cleanup(self, old_dir, new_dir):
raise RuntimeError("simulated shutdown")
monkeypatch.setattr(Snapshot, "_cleanup_old_migration_dir", simulate_shutdown_before_cleanup)
with pytest.raises(RuntimeError, match="simulated shutdown"):
snapshot.migrate_filesystem_to_current_version()
snapshot.refresh_from_db()
assert snapshot.fs_version == "0.8.0"
assert legacy_dir.exists()
assert (current_dir / "unknown" / "payload.bin").read_bytes() == b"filesystem migration payload\x00\xff"
monkeypatch.setattr(Snapshot, "_cleanup_old_migration_dir", real_cleanup)
snapshot.migrate_filesystem_to_current_version()
snapshot.refresh_from_db()
assert snapshot.fs_version == snapshot._fs_current_version()
assert not legacy_dir.exists()
assert (current_dir / "unknown" / "payload.bin").read_bytes() == b"filesystem migration payload\x00\xff"
def test_filesystem_migration_cleans_legacy_source_when_version_is_current(snapshot, monkeypatch):
legacy_dir, current_dir = _make_legacy_snapshot(snapshot)
monkeypatch.setattr(Snapshot, "_cleanup_old_migration_dir", lambda *_args: True)
snapshot.migrate_filesystem_to_current_version()
snapshot.refresh_from_db()
assert snapshot.fs_version == snapshot._fs_current_version()
assert legacy_dir.exists()
monkeypatch.undo()
snapshot.migrate_filesystem_to_current_version()
assert not legacy_dir.exists()
assert (current_dir / "unknown" / "payload.bin").read_bytes() == b"filesystem migration payload\x00\xff"
def test_fs_version_has_database_index():
assert Snapshot._meta.get_field("fs_version").db_index is True
constraints = connection.introspection.get_constraints(connection.cursor(), Snapshot._meta.db_table)
assert any(index["index"] and index["columns"] == ["fs_version"] for index in constraints.values())
def test_resume_refuses_to_overwrite_changed_legacy_output(snapshot, monkeypatch):
legacy_dir, current_dir = _make_legacy_snapshot(snapshot)
monkeypatch.setattr(Snapshot, "_cleanup_old_migration_dir", lambda *_args: True)
snapshot.migrate_filesystem_to_current_version()
(legacy_dir / "unknown" / "payload.bin").write_bytes(b"changed after database commit")
with pytest.raises(SnapshotMigrationError, match="overwrite a different output"):
snapshot.migrate_filesystem_to_current_version()
assert legacy_dir.exists()
assert (current_dir / "unknown" / "payload.bin").read_bytes() == b"filesystem migration payload\x00\xff"
+85 -3
View File
@@ -1,7 +1,9 @@
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from django.db import close_old_connections
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.tests.conftest import run_archivebox_cmd
@@ -40,7 +42,78 @@ def _snapshot_state(cwd: Path, url: str) -> dict[str, object]:
}
def test_snapshot_merge_consolidates_plugin_output_identity(admin_user):
def test_snapshot_completion_preserves_retry_scheduled_during_active_run(tmp_path, admin_user):
from datetime import timedelta
from django.utils import timezone
from archivebox.crawls.models import Crawl
from archivebox.services.snapshot_service import finalize_completed_snapshot
crawl = Crawl.objects.create(urls="https://example.com/retry-race", created_by=admin_user)
owned_retry_at = timezone.now() + timedelta(minutes=10)
snapshot = Snapshot.objects.create(
url="https://example.com/retry-race",
crawl=crawl,
status=Snapshot.StatusChoices.STARTED,
retry_at=owned_retry_at,
config={"RETRY_PLUGINS": ["title"]},
)
snapshot.schedule_plugin_run(["title"])
finalize_completed_snapshot(
str(snapshot.id),
owned_retry_at=owned_retry_at,
was_sealed=False,
consumed_retry_plugins=["title"],
output_dir=tmp_path,
)
snapshot.refresh_from_db()
assert snapshot.status == Snapshot.StatusChoices.QUEUED
assert snapshot.retry_at is not None
assert snapshot.retry_at < owned_retry_at
assert snapshot.config["RETRY_PLUGINS"] == ["title"]
def test_concurrent_plugin_scheduling_durably_merges_every_request(admin_user):
from archivebox.crawls.models import Crawl
from django.db import connection
if connection.vendor != "sqlite":
pytest.skip("exercises SQLite concurrent-writer scheduling")
crawl = Crawl.objects.create(urls="https://example.com/plugin-race", created_by=admin_user)
snapshot = Snapshot.objects.create(url="https://example.com/plugin-race", crawl=crawl)
plugins = ["title", "wget", "screenshot", "pdf"]
def schedule(plugin: str) -> bool:
close_old_connections()
try:
return Snapshot.objects.get(pk=snapshot.pk).schedule_plugin_run([plugin])
finally:
close_old_connections()
with ThreadPoolExecutor(max_workers=len(plugins)) as pool:
results = list(pool.map(schedule, plugins))
snapshot.refresh_from_db()
assert results == [True] * len(plugins)
assert snapshot.config["RETRY_PLUGINS"] == sorted(plugins)
def test_snapshot_keyset_iterator_reads_more_than_eight_pages(admin_user):
from archivebox.crawls.models import Crawl
crawl = Crawl.objects.create(urls="https://example.com/pages", created_by=admin_user)
snapshots = [Snapshot.objects.create(url=f"https://example.com/pages/{idx}", crawl=crawl) for idx in range(10)]
yielded_ids = [snapshot.id for snapshot in crawl.snapshot_set.order_by("id").paged_iterator(chunk_size=1)]
assert yielded_ids == sorted(snapshot.id for snapshot in snapshots)
def test_snapshot_merge_consolidates_only_exact_hook_identity(admin_user):
from archivebox.crawls.models import Crawl
keeper_crawl = Crawl.objects.create(urls="https://example.com", created_by=admin_user)
@@ -64,15 +137,24 @@ def test_snapshot_merge_consolidates_plugin_output_identity(admin_user):
output_files={"archivewebpage.wacz": {"size": 7}},
output_size=7,
)
ArchiveResult.objects.create(
snapshot=duplicate,
plugin="archivewebpage",
hook_name="on_Snapshot__16_archivewebpage_start",
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_str="recording started",
)
Snapshot._merge_snapshots([keeper, duplicate])
assert not Snapshot.objects.filter(pk=duplicate.pk).exists()
results = ArchiveResult.objects.filter(snapshot=keeper, plugin="archivewebpage")
assert results.count() == 1
stop_result = results.get()
assert results.count() == 2
stop_result = results.get(hook_name="on_Snapshot__65_archivewebpage_stop")
assert stop_result.status == ArchiveResult.StatusChoices.SUCCEEDED
assert stop_result.output_str == "archivewebpage.wacz"
assert set(stop_result.output_files) == {"older.wacz", "archivewebpage.wacz"}
assert results.filter(hook_name="on_Snapshot__16_archivewebpage_start").exists()
@pytest.mark.timeout(180)
@@ -34,6 +34,12 @@ def test_validate_url_length_accepts_exactly_max():
assert validate_url(url) == url
def test_validate_url_strips_outer_newlines_but_rejects_internal_newlines():
assert validate_url("\nhttps://example.com\r\n") == "https://example.com"
with pytest.raises(ValueError, match="single line"):
validate_url("https://example.com\nhttps://example.org")
def test_validate_url_length_rejects_over_max():
url = _make_long_url(MAX_URL_LENGTH + 1)
with pytest.raises(ValueError, match="too long"):
+6 -16
View File
@@ -3,7 +3,6 @@
import os
import json
import re
import signal
import subprocess
import sys
@@ -416,17 +415,10 @@ def test_live_server_keeps_http_runtime_while_update_runs_real_sqlite_indexer(tm
encoding="utf-8",
errors="replace",
)
worker_name_match = re.search(r"Worker (worker_runner_update_\d+):", update_stdout)
assert worker_name_match, update_stdout
wait_for_log(
tmp_path / "logs" / f"{worker_name_match.group(1)}.log",
"Stopping older ArchiveBox runner process",
)
supervisord_text = wait_for_log_count(supervisord_log, runner_spawn_text, runner_spawn_count + 1, timeout=30)
runner_pid_after = int(re.findall(r"spawned: 'worker_runner' with pid (\d+)", supervisord_text)[-1])
assert runner_pid_after != runner_pid_before
assert pid_is_alive(runner_pid_after)
supervisord_text = supervisord_log.read_text(encoding="utf-8", errors="replace")
assert supervisord_text.count(runner_spawn_text) == runner_spawn_count
assert worker_pid_from_log(server_log, "worker_runner") == runner_pid_before
assert pid_is_alive(runner_pid_before)
with use_archivebox_db(tmp_path):
indexed_results = list(
@@ -439,7 +431,7 @@ def test_live_server_keeps_http_runtime_while_update_runs_real_sqlite_indexer(tm
server = None
wait_for_pid_to_disappear(daphne_pid_before, timeout=20)
wait_for_pid_to_disappear(sonic_pid_before, timeout=20)
wait_for_pid_to_disappear(runner_pid_after, timeout=20)
wait_for_pid_to_disappear(runner_pid_before, timeout=20)
assert_no_processes_for_data_dir(tmp_path, timeout=12)
finally:
if server is not None:
@@ -479,10 +471,9 @@ def test_live_update_yields_to_server_then_reclaims_real_sqlite_indexing(tmp_pat
update_supervisor_match = wait_for_log_pattern(update_log, r"Supervisord connected \(pid=(\d+)\)", timeout=90)
update_supervisor_pid_before = int(update_supervisor_match.group(1))
update_sonic_pid_before = wait_for_worker_pid_from_log(update_log, "worker_sonic", timeout=90)
update_runner_pid_before = wait_for_worker_pid_from_log(update_log, f"worker_runner_update_{update_proc.pid}", timeout=90)
assert pid_is_alive(update_supervisor_pid_before)
assert pid_is_alive(update_sonic_pid_before)
assert pid_is_alive(update_runner_pid_before)
assert "worker_runner_update_" not in update_log.read_text(encoding="utf-8", errors="replace")
assert "worker_daphne" not in update_log.read_text(encoding="utf-8", errors="replace")
server = start_archivebox_server(tmp_path, port=port, log_name="server-takes-real-sqlite-update.log", env=env)
@@ -507,7 +498,6 @@ def test_live_update_yields_to_server_then_reclaims_real_sqlite_indexing(tmp_pat
assert pid_is_alive(server_sonic_pid)
wait_for_pid_to_disappear(update_supervisor_pid_before, timeout=30)
wait_for_pid_to_disappear(update_sonic_pid_before, timeout=30)
wait_for_pid_to_disappear(update_runner_pid_before, timeout=30)
stop_archivebox_process(server, signal.SIGTERM)
server = None
+2 -2
View File
@@ -565,7 +565,7 @@ def test_add_view_queues_crawl_for_background_runner(client, admin_user):
assert crawl is not None
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at is not None
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
assert crawl.urls == "https://example.com"
assert crawl.snapshot_set.count() == 0
@@ -599,7 +599,7 @@ def test_add_view_start_paused_creates_paused_crawl_without_snapshots(client, ad
assert crawl is not None
assert crawl.status == Crawl.StatusChoices.PAUSED
assert crawl.retry_at == RETRY_AT_MAX
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com/paused", "depth": 0}
assert crawl.urls == "https://example.com/paused"
assert crawl.snapshot_set.count() == 0
assert crawl.config.get("INDEX_ONLY") is not True
+4 -8
View File
@@ -241,11 +241,7 @@ def test_add_view_restarts_stopped_supervisord_runner(tmp_path, recursive_test_s
crawl = Crawl.objects.order_by("-created_at").first()
assert crawl is not None
assert crawl.tags_str == "restart-supervised-runner"
assert json.loads(crawl.urls) == {
"type": "CrawlSeed",
"url": recursive_test_site["root_url"],
"depth": 0,
}
assert crawl.urls == recursive_test_site["root_url"]
finally:
stop_server(tmp_path)
@@ -482,8 +478,8 @@ def test_public_add_view_import_text_formats_preserve_metadata_and_resume_withou
with use_archivebox_db(tmp_path):
for crawl in Crawl.objects.order_by("created_at"):
root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL)
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
assert not crawl.snapshot_set.filter(url__startswith="archivebox://").exists()
root_input = (crawl.output_dir / "input" / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
assert root_input == crawl.urls
start_archivebox_server(tmp_path, env=env, port=port)
@@ -659,7 +655,7 @@ def test_add_view_post_creates_schedule_over_server(tmp_path, recursive_test_sit
schedule = CrawlSchedule.objects.select_related("template").order_by("-created_at").first()
row = None
if schedule:
template_url = json.loads(schedule.template.urls)["url"]
template_url = schedule.template.urls.strip()
row = (schedule.schedule, template_url, schedule.template.tags_str)
assert row == ("daily", recursive_test_site["root_url"], "web-ui")
@@ -17,6 +17,7 @@ pytestmark = pytest.mark.django_db(transaction=True)
def projected_noresults(snapshot, cached_abxpkg_lib_dir):
from abx_dl.events import ProcessEvent, SnapshotEvent
from abx_dl.orchestrator import create_bus
from abx_dl.services.archive_result_service import ArchiveResultService as HookArchiveResultService
from abx_dl.services.process_service import ProcessService as HookProcessService
from archivebox.core.models import ArchiveResult
from archivebox.services.archive_result_service import ArchiveResultService
@@ -33,6 +34,7 @@ def projected_noresults(snapshot, cached_abxpkg_lib_dir):
(staticfile_dir / "input.txt").write_text("plain text without links", encoding="utf-8")
bus = create_bus(name=f"test_admin_noresults_{snapshot.id}")
HookProcessService(bus, emit_jsonl=False, interactive_tty=False)
HookArchiveResultService(bus, emit_jsonl=False)
PersistedProcessService(bus)
ArchiveResultService(bus)
@@ -142,8 +144,7 @@ class TestArchiveResultAdminListView:
snapshot.refresh_from_db()
assert snapshot.output_size == 0
def test_duplicate_plugin_result_is_rejected_without_touching_output(self, snapshot):
from django.db import IntegrityError, transaction
def test_deleting_sibling_hook_preserves_shared_plugin_output(self, snapshot):
from archivebox.core.models import ArchiveResult
output_dir = Path(snapshot.output_dir) / "responses"
@@ -157,13 +158,14 @@ class TestArchiveResultAdminListView:
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_size=18,
)
with pytest.raises(IntegrityError), transaction.atomic():
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="responses",
hook_name="on_Snapshot__24_responses.daemon.bg.replayed",
status=ArchiveResult.StatusChoices.NORESULTS,
)
duplicate = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="responses",
hook_name="on_Snapshot__24_responses.daemon.bg.replayed",
status=ArchiveResult.StatusChoices.NORESULTS,
)
duplicate.delete()
assert ArchiveResult.objects.filter(pk=primary.pk).exists()
assert output_file.read_text() == "captured response\n"
+6 -6
View File
@@ -15,13 +15,13 @@ from archivebox.tests.conftest import cli_env, run_archivebox_cmd
from archivebox.tests.conftest import install_real_binary
pytestmark = pytest.mark.django_db
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture
def real_hook_result(tmp_path):
from archivebox.core.models import ArchiveResult
from archivebox.plugins.hooks import extract_records_from_process, run_hook
from archivebox.tests.conftest import run_test_hook
snapshot = _create_snapshot()
snap_dir = Path(snapshot.output_dir)
@@ -29,7 +29,7 @@ def real_hook_result(tmp_path):
output_dir.mkdir(parents=True, exist_ok=True)
(snap_dir / "source.txt").write_text("real admin link hook input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={
@@ -45,12 +45,12 @@ def real_hook_result(tmp_path):
)
process.refresh_from_db()
assert process.exit_code == 0, process.stderr
record = extract_records_from_process(process)[0]
record = process.get_records()[0]
hashes_file = output_dir / "hashes.json"
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin=record["plugin"],
hook_name=record["hook_name"],
plugin="hashes",
hook_name=hook_path.name,
process=process,
status=record["status"],
output_str=record["output_str"],
+4 -2
View File
@@ -19,14 +19,14 @@ pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture
def real_exited_hook_process(tmp_path):
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = tmp_path / "snapshot"
output_dir = snap_dir / "hashes"
output_dir.mkdir(parents=True)
(snap_dir / "source.txt").write_text("real admin hook input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
@@ -43,6 +43,7 @@ def real_exited_hook_process(tmp_path):
def real_projected_hash_result(snapshot, cached_abxpkg_lib_dir):
from abx_dl.events import ProcessEvent, SnapshotEvent
from abx_dl.orchestrator import create_bus
from abx_dl.services.archive_result_service import ArchiveResultService as HookArchiveResultService
from abx_dl.services.process_service import ProcessService as HookProcessService
from archivebox.core.models import ArchiveResult
from archivebox.machine.models import Process
@@ -59,6 +60,7 @@ def real_projected_hash_result(snapshot, cached_abxpkg_lib_dir):
(snapshot.output_dir / "source.txt").write_text("real admin projection input", encoding="utf-8")
bus = create_bus(name=f"test_admin_hashes_{snapshot.id}")
HookProcessService(bus, emit_jsonl=False, interactive_tty=False)
HookArchiveResultService(bus, emit_jsonl=False)
PersistedProcessService(bus)
ArchiveResultService(bus)
+83 -21
View File
@@ -18,7 +18,7 @@ from django.urls import reverse
from archivebox.core.middleware import ADMIN_LOGIN_HINT_COOKIE
from archivebox.tests.conftest import ADMIN_TEST_HOST
from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook, _snapshot_hook_name
from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook
pytestmark = pytest.mark.django_db(transaction=True)
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -98,9 +98,9 @@ def running_wget_projection(snapshot, blocking_http_server):
retry_at=now,
downloaded_at=None,
url=blocking_http_server.url,
config={"PLUGINS": "wget"},
)
snapshot.refresh_from_db()
[result] = snapshot.create_pending_archiveresults(hooks=[("wget", _snapshot_hook_name("wget"))])
errors = []
def run_snapshot():
@@ -115,7 +115,7 @@ def running_wget_projection(snapshot, blocking_http_server):
runner.start()
blocking_http_server.request_started.wait()
assert errors == []
result.refresh_from_db()
result = ArchiveResult.objects.get(snapshot=snapshot, plugin="wget")
assert result.status == ArchiveResult.StatusChoices.STARTED
yield result
blocking_http_server.release_response.set()
@@ -324,6 +324,35 @@ def test_snapshot_admin_preview_uses_extension_screenshot_when_standard_screensh
assert "chrome_extension_screenshot/screenshot-2.png" not in preview["fallback_list"]
def test_snapshot_admin_attributes_new_tags_to_authenticated_user(client, snapshot, admin_user):
from archivebox.core.models import Tag
client.force_login(admin_user)
response = client.post(
reverse("admin:core_snapshot_change", args=[snapshot.pk]),
{
"url": snapshot.url,
"title": snapshot.title or "",
"tags_editor": "admin-created-tag",
"permissions_config": "private",
"status": snapshot.status,
"retry_at": "",
"bookmarked_at_0": snapshot.bookmarked_at.date().isoformat(),
"bookmarked_at_1": snapshot.bookmarked_at.time().isoformat(),
"crawl": str(snapshot.crawl_id),
"config": '{"SAVE_ARCHIVE_DOT_ORG": "false"}',
"notes": "",
"_save": "Save",
},
HTTP_HOST=ADMIN_TEST_HOST,
)
assert response.status_code == 302, response.context and response.context["adminform"].form.errors
tag = Tag.objects.get(name="admin-created-tag")
assert tag.created_by == admin_user
assert snapshot.tags.filter(pk=tag.pk).exists()
class TestSnapshotProgressStats:
"""Tests for Snapshot.get_progress_stats() method."""
@@ -370,7 +399,6 @@ class TestSnapshotProgressStats:
def test_snapshot_admin_progress_uses_expected_hook_total_not_observed_result_count(
self,
snapshot,
real_hash_projection,
running_wget_projection,
):
from archivebox.core.admin_site import archivebox_admin
@@ -379,7 +407,6 @@ class TestSnapshotProgressStats:
from archivebox.config.common import get_config
from django.urls import resolve
assert real_hash_projection[1].status == ArchiveResult.StatusChoices.SUCCEEDED
assert running_wget_projection.status == ArchiveResult.StatusChoices.STARTED
prefetched_snapshot = Snapshot.objects.prefetch_related("archiveresult_set").get(pk=snapshot.pk)
@@ -393,13 +420,13 @@ class TestSnapshotProgressStats:
stats = admin._get_progress_stats(prefetched_snapshot)
html = str(admin.status_with_progress(prefetched_snapshot))
assert expected_total > 2
assert expected_total > 1
assert stats["total"] == expected_total
assert stats["succeeded"] == 1
assert stats["succeeded"] == 0
assert stats["running"] == 1
assert stats["pending"] == expected_total - 2
assert stats["percent"] == int(100 / expected_total)
assert f"1/{expected_total} hooks" in html
assert stats["pending"] == expected_total - 1
assert stats["percent"] == 0
assert f"0/{expected_total} hooks" in html
def test_get_progress_stats_sealed(self, snapshot):
"""Test progress stats for sealed snapshot."""
@@ -485,10 +512,46 @@ class TestSnapshotProgressStats:
assert _count_media_files(result) == 2
assert _list_media_files(result) == [
{"name": "audio.mp3", "path": "ytdlp/audio.mp3", "size": 222},
{"name": "video.mp4", "path": "ytdlp/video.mp4", "size": 111},
{
"name": "video.mp4",
"path": "ytdlp/video.mp4",
"size": 111,
"media_type": "video",
"is_video": True,
"is_audio": False,
"is_browser_playable": True,
},
{
"name": "audio.mp3",
"path": "ytdlp/audio.mp3",
"size": 222,
"media_type": "audio",
"is_video": False,
"is_audio": True,
"is_browser_playable": True,
},
]
def test_ytdlp_discover_outputs_prefers_browser_playable_video(self, snapshot):
from archivebox.core.models import ArchiveResult
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="ytdlp",
status="succeeded",
output_files={
"thumbnail.jpg": {"size": 20_000, "mimetype": "image/jpeg", "extension": "jpg"},
"large.mkv": {"size": 10_000, "mimetype": "video/x-matroska", "extension": "mkv"},
"small.mp4": {"size": 111, "mimetype": "video/mp4", "extension": "mp4"},
},
output_size=30_111,
)
outputs = snapshot.discover_outputs(include_filesystem_fallback=False)
ytdlp_output = next(output for output in outputs if output["name"] == "ytdlp")
assert ytdlp_output["path"] == "ytdlp/small.mp4"
def test_discover_outputs_falls_back_to_hashes_index_without_filesystem_walk(
self,
snapshot,
@@ -928,10 +991,9 @@ class TestSnapshotOutputDeletion:
from archivebox.core.models import ArchiveResult
first = self._create_output(snapshot, size=11)
second = self._create_output(snapshot, plugin="singlefile", hook_name="on_Snapshot__50_singlefile.py", size=13)
second = self._create_output(snapshot, hook_name="on_Snapshot__51_screenshot_retry.py", size=13)
kept = self._create_output(snapshot, plugin="pdf", hook_name="on_Snapshot__60_pdf.py", size=7)
deleted_dir = Path(first.output_dir)
second_deleted_dir = Path(second.output_dir)
kept_dir = Path(kept.output_dir)
hashes_dir = Path(snapshot.output_dir) / "hashes"
hashes_dir.mkdir(parents=True, exist_ok=True)
@@ -964,7 +1026,6 @@ class TestSnapshotOutputDeletion:
assert not ArchiveResult.objects.filter(pk__in=[first.pk, second.pk]).exists()
assert ArchiveResult.objects.filter(pk=kept.pk).exists()
assert not deleted_dir.exists()
assert not second_deleted_dir.exists()
assert kept_dir.exists()
snapshot.refresh_from_db()
assert snapshot.output_size == 7
@@ -1290,7 +1351,10 @@ class TestAdminSnapshotListView:
assert response.status_code == 302
assert response["Location"].endswith(f"/admin/core/snapshot/{snapshot.pk}/change/")
failed.refresh_from_db()
assert failed.status == ArchiveResult.StatusChoices.QUEUED
snapshot.refresh_from_db()
assert failed.status == ArchiveResult.StatusChoices.FAILED
assert snapshot.status == snapshot.StatusChoices.QUEUED
assert snapshot.config["RETRY_PLUGINS"] == ["title"]
def test_list_redo_failed_action_requeues_failed_archiveresults_only(
self,
@@ -1328,14 +1392,12 @@ class TestAdminSnapshotListView:
failed.refresh_from_db()
succeeded.refresh_from_db()
snapshot.refresh_from_db()
assert failed.status == ArchiveResult.StatusChoices.QUEUED
assert failed.output_str == ""
assert failed.output_files == {}
assert failed.output_size == 0
assert failed.output_mimetypes == ""
assert failed.status == ArchiveResult.StatusChoices.FAILED
assert failed.output_str
assert succeeded.status == ArchiveResult.StatusChoices.SUCCEEDED
assert succeeded.output_str == succeeded_output
assert snapshot.status == snapshot.StatusChoices.QUEUED
assert snapshot.config["RETRY_PLUGINS"] == ["title"]
def test_archive_now_action_uses_original_snapshot_url_without_timestamp_suffix(self, client, admin_user, snapshot):
from archivebox.crawls.models import Crawl
+22 -12
View File
@@ -14,21 +14,21 @@ from django.utils import timezone
from archivebox.tests.conftest import ADMIN_TEST_HOST
from archivebox.tests.conftest import cli_env, resolve_abxpkg_binary_env, run_archivebox_cmd
from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook, _snapshot_hook_name
from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture
def real_unscoped_hook_process(tmp_path):
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = tmp_path / "snapshot"
output_dir = snap_dir / "hashes"
output_dir.mkdir(parents=True)
(snap_dir / "source.txt").write_text("real live progress input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
@@ -55,7 +55,7 @@ def real_snapshot_hook_projection(snapshot, cached_abxpkg_lib_dir):
@pytest.fixture
def real_second_snapshot_hook_process(snapshot, tmp_path):
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
snap_dir = Path(snapshot.output_dir)
staticfile_dir = snap_dir / "staticfile"
@@ -64,7 +64,7 @@ def real_second_snapshot_hook_process(snapshot, tmp_path):
output_dir.mkdir(parents=True, exist_ok=True)
(staticfile_dir / "input.txt").write_text("plain text without links", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py")))
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)},
@@ -78,7 +78,7 @@ def real_second_snapshot_hook_process(snapshot, tmp_path):
@pytest.fixture
def real_crawl_setup_process(snapshot, hermetic_lib_dir):
from archivebox.plugins.hooks import run_hook
from archivebox.tests.conftest import run_test_hook
from archivebox.services.runner import run_install
hook_path = Path(str(files("abx_plugins.plugins.chrome").joinpath("on_CrawlSetup__89_chrome_kill_zombies.js")))
@@ -86,7 +86,7 @@ def real_crawl_setup_process(snapshot, hermetic_lib_dir):
run_install(plugin_names=["chrome"])
binary_env = resolve_abxpkg_binary_env(hermetic_lib_dir, deps_from=config_path)
output_dir = Path(snapshot.crawl.output_dir) / "chrome"
process = run_hook(
process = run_test_hook(
hook_path,
output_dir,
config={
@@ -179,7 +179,12 @@ class TestLiveProgressView:
ended_at=now - timedelta(hours=1),
modified_at=now - timedelta(hours=1),
)
snapshot.create_pending_archiveresults(hooks=[("chrome", "on_Snapshot__11_chrome_wait")])
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="chrome",
hook_name="on_Snapshot__11_chrome_wait",
status=ArchiveResult.StatusChoices.QUEUED,
)
response = client.get("/progress.json", HTTP_HOST=ADMIN_TEST_HOST)
@@ -218,9 +223,9 @@ class TestLiveProgressView:
created_at=now - timedelta(hours=2),
downloaded_at=None,
url=blocking_http_server.url,
config={"PLUGINS": "wget"},
)
snapshot.refresh_from_db()
[result] = snapshot.create_pending_archiveresults(hooks=[("wget", _snapshot_hook_name("wget"))])
errors = []
def run_snapshot():
@@ -236,7 +241,7 @@ class TestLiveProgressView:
try:
blocking_http_server.request_started.wait()
assert errors == []
result.refresh_from_db()
result = ArchiveResult.objects.get(snapshot=snapshot, plugin="wget")
assert result.status == ArchiveResult.StatusChoices.STARTED
Snapshot.objects.filter(pk=snapshot.pk).update(modified_at=timezone.now())
@@ -257,7 +262,7 @@ class TestLiveProgressView:
assert result.status in (ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS)
def test_live_progress_hides_finished_cancelled_crawl(self, client, admin_user, crawl, snapshot):
from archivebox.core.models import Snapshot
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
now = timezone.now()
@@ -272,7 +277,12 @@ class TestLiveProgressView:
downloaded_at=None,
modified_at=now,
)
snapshot.create_pending_archiveresults(hooks=[("singlefile", "on_Snapshot__50_singlefile")])
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="singlefile",
hook_name="on_Snapshot__50_singlefile",
status=ArchiveResult.StatusChoices.QUEUED,
)
client.force_login(admin_user)
response = client.get(reverse("live_progress"), HTTP_HOST=ADMIN_TEST_HOST)
+13 -5
View File
@@ -15,22 +15,26 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.django_db
def test_archiveresult_relpath_uses_plugin_result_that_owns_output(admin_user):
def test_archiveresult_relpath_uses_sibling_hook_that_owns_output(admin_user):
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.views import _resolve_archiveresult_relpath
from archivebox.crawls.models import Crawl
crawl = Crawl.objects.create(urls="https://example.com", created_by=admin_user)
snapshot = Snapshot.objects.create(url="https://example.com", crawl=crawl)
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="screenshot",
hook_name="on_Snapshot__archivebox_browser_extension_upload",
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_files={"browser.png": {"size": 7}},
)
server_result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="screenshot",
hook_name="on_Snapshot__50_screenshot",
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_files={
"browser.png": {"size": 7},
"screenshot.png": {"size": 6, "root_relative": True},
},
output_files={"screenshot.png": {"size": 6, "root_relative": True}},
)
resolved_path, result = _resolve_archiveresult_relpath(snapshot, "screenshot/screenshot.png")
@@ -134,6 +138,8 @@ def test_static_html_and_markdown_preview_images_rewrite_to_captured_responses(t
assert "ETag" not in response.headers
assert "max-age=60" in response.headers["Cache-Control"]
assert b"archivebox-static-html-preview-style" in response.content
assert b"width: min(100%, 72rem)" in response.content
assert b"min-height: 100vh" in response.content
assert b"img:not([width]):not([height])" in response.content
assert b"a > img:not([width]):not([height])" in response.content
assert b'src="../responses/all/20260722T061544__GET__https_3A_2F_2Fsweeting.me_2Fimages_2Ftwitter.png"' in response.content
@@ -163,6 +169,8 @@ def test_static_html_and_markdown_preview_images_rewrite_to_captured_responses(t
assert "ETag" not in response.headers
assert "max-age=60" in response.headers["Cache-Control"]
assert b"archivebox-static-html-preview-style" in response.content
assert b"width: min(100%, 72rem)" in response.content
assert b"min-height: 100vh" in response.content
assert b'src="../responses/all/20260722T061544__GET__https_3A_2F_2Fsweeting.me_2Fimages_2Ftwitter.png"' in response.content
+1 -1
View File
@@ -82,7 +82,7 @@ stateDiagram-v2
SEALED --> [*]
```
A crawl owns a set of snapshots. The runner creates or discovers those snapshots and projects crawl events while the row is `STARTED`; sealing waits for their normal lifecycle to finish. Pausing also schedules child snapshots to pause, and resuming returns the crawl to the runnable queue. The `archivebox://update` sentinel remains control-plane work: the runner invokes database maintenance directly and never turns it into a Snapshot.
A crawl owns a set of snapshots. The runner creates or discovers those snapshots and projects crawl events while the row is `STARTED`; sealing waits for their normal lifecycle to finish. Pausing also schedules child snapshots to pause, and resuming returns the crawl to the runnable queue. Scheduled maintenance is dispatched directly by `CrawlSchedule`; it does not create a synthetic crawl or snapshot.
## `Snapshot` Queue Lifecycle
+2 -2
View File
@@ -29,11 +29,11 @@ Accepted schedule formats:
- Aliases: `minute`, `hour`, `day`, `week`, `month`, `year`, `daily`, `weekly`, `monthly`, `yearly`
- Cron expressions: e.g. `0 */6 * * *`
`archivebox schedule --run-all` enqueues every enabled schedule immediately.
`archivebox schedule --run-all` dispatches every enabled schedule immediately: crawl schedules are enqueued, while maintenance schedules run directly.
`archivebox schedule --foreground` runs the global orchestrator in the foreground, which is useful outside `archivebox server` if you want a dedicated long-running scheduler/worker process without the web UI.
Running `archivebox schedule --every=day` with no `import_path` creates a recurring maintenance schedule that queues `archivebox://update` crawls.
Running `archivebox schedule --every=day` with no `import_path` creates a recurring maintenance schedule. The scheduler dispatches its bounded database/filesystem maintenance directly instead of creating a synthetic Crawl or Snapshot.
## Docker Compose
@@ -51,10 +51,6 @@
- ```{autodoc2-docstring} archivebox.services.runner._is_nonfatal_setup_hook
:summary:
```
* - {py:obj}`_discover_archivebox_plugins <archivebox.services.runner._discover_archivebox_plugins>`
- ```{autodoc2-docstring} archivebox.services.runner._discover_archivebox_plugins
:summary:
```
* - {py:obj}`_runner_task_context <archivebox.services.runner._runner_task_context>`
- ```{autodoc2-docstring} archivebox.services.runner._runner_task_context
:summary:
@@ -63,10 +59,6 @@
- ```{autodoc2-docstring} archivebox.services.runner._is_external_task_cancelled
:summary:
```
* - {py:obj}`_emit_machine_config <archivebox.services.runner._emit_machine_config>`
- ```{autodoc2-docstring} archivebox.services.runner._emit_machine_config
:summary:
```
* - {py:obj}`_run_event_now <archivebox.services.runner._run_event_now>`
- ```{autodoc2-docstring} archivebox.services.runner._run_event_now
:summary:
@@ -251,13 +243,6 @@
```
````
````{py:function} _discover_archivebox_plugins() -> dict[str, abx_dl.models.Plugin]
:canonical: archivebox.services.runner._discover_archivebox_plugins
```{autodoc2-docstring} archivebox.services.runner._discover_archivebox_plugins
```
````
````{py:function} _runner_task_context() -> contextvars.Context
:canonical: archivebox.services.runner._runner_task_context
@@ -272,14 +257,6 @@
```
````
````{py:function} _emit_machine_config(bus, *, config: dict[str, typing.Any], derived_config: dict[str, typing.Any], parent_event=None) -> None
:canonical: archivebox.services.runner._emit_machine_config
:async:
```{autodoc2-docstring} archivebox.services.runner._emit_machine_config
```
````
````{py:function} _run_event_now(event, timeout: float | None = None)
:canonical: archivebox.services.runner._run_event_now
:async:
@@ -342,14 +319,6 @@
````
````{py:method} runtime_plugins() -> dict[str, abx_dl.models.Plugin]
:canonical: archivebox.services.runner.CrawlRunner.runtime_plugins
```{autodoc2-docstring} archivebox.services.runner.CrawlRunner.runtime_plugins
```
````
````{py:property} allow_maintenance_on_inactive_crawl
:canonical: archivebox.services.runner.CrawlRunner.allow_maintenance_on_inactive_crawl
:type: bool
+2 -2
View File
@@ -74,8 +74,8 @@ version = 2
# docs/ArchiveBox-Architecture-Diagrams.md
"7442dd337fd0a1dd-1" = "illustration"
"9d6f8ac3043ff691-1" = "illustration"
"8c8802244e05cfb7-1" = "illustration"
"d0eb428affe5bb44-1" = "illustration"
"9529bb7bc396b6b0-1" = "illustration"
"66018350af060c17-1" = "illustration"
"7d1d068e1e754a75-1" = "illustration"
# docs/Changelog.md
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.35rc375",
"version": "0.9.35rc418",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {
+5 -5
View File
@@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.35rc375"
version = "0.9.35rc418"
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 (runtime binaries resolved through abxpkg)
### Binary/Package Management
"abxbus==2.5.56", # direct imports only; version constrained by abx-dl -> abx-plugins
"abxpkg==1.12.110", # direct imports only; version constrained by abx-dl -> abx-plugins
"abx-plugins==1.12.201", # direct imports only; version constrained by abx-dl
"abx-dl==1.12.229", # shared ArchiveBox downloader package
"abxpkg==1.12.117", # direct imports only; version constrained by abx-dl -> abx-plugins
"abx-plugins==1.12.224", # direct imports only; version constrained by abx-dl
"abx-dl==1.12.261", # shared ArchiveBox downloader package
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
]
@@ -342,7 +342,7 @@ Donate = "https://github.com/ArchiveBox/ArchiveBox/wiki/Donations"
[tool.bumpver]
current_version = "v0.9.35rc375"
current_version = "v0.9.35rc418"
version_pattern = "vMAJOR.MINOR.PATCH[PYTAGNUM]"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"
Generated
+15 -15
View File
@@ -13,18 +13,18 @@ supported-markers = [
]
[options]
exclude-newer = "2026-08-27T21:24:33.484964441Z"
exclude-newer = "2026-08-29T03:38:36.94663628Z"
exclude-newer-span = "P5D"
[options.exclude-newer-package]
abxbus = { timestamp = "2026-09-01T21:24:32.484979343Z", span = "PT1S" }
abxbus = { timestamp = "2026-09-03T03:38:35.946648252Z", span = "PT1S" }
abx-plugins = "2100-01-01T00:00:00Z"
abx-dl = "2100-01-01T00:00:00Z"
abxpkg = "2100-01-01T00:00:00Z"
[[package]]
name = "abx-dl"
version = "1.12.229"
version = "1.12.261"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abx-plugins", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -38,14 +38,14 @@ dependencies = [
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/5d/299aacbb31a52e632130d671a014594ac75416c724275ffbb057b7a39abd/abx_dl-1.12.229.tar.gz", hash = "sha256:654040499258c5ec5303729e05b05ed272c894b68462c1ff94402d1443896354", size = 88727 }
sdist = { url = "https://files.pythonhosted.org/packages/20/89/ae38d5835c616e35501a6cc570d95bf1bd599b53f8e8b429a21dd31d5226/abx_dl-1.12.261.tar.gz", hash = "sha256:1a2acb018a40a6eda430aeeb95a66c0e062761c4dc37b90c3a163e3623beb413", size = 86795 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/a7/29444fe362a1f76226a8170c0d5dac3574bcbfef4dc1942eea39d5a96bee/abx_dl-1.12.229-py3-none-any.whl", hash = "sha256:ca37e74776dfe97530c4e5eabc7620794aca261af3b711547c9f5cca7cc36c73", size = 93102 },
{ url = "https://files.pythonhosted.org/packages/3f/ba/bd8199e047616e60ceca67983531b9ed60557bcc8becfc33bc869200ff53/abx_dl-1.12.261-py3-none-any.whl", hash = "sha256:8b615fa933137fc062d11aac64032d1768afd5da3e1fbb4a8bba2733b99932a2", size = 92074 },
]
[[package]]
name = "abx-plugins"
version = "1.12.201"
version = "1.12.224"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abxbus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -54,9 +54,9 @@ dependencies = [
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/34/772b2ecfcee8682400aa4252d288faed8fdb55c45f00f0b4f1f6864cd084/abx_plugins-1.12.201.tar.gz", hash = "sha256:3e2256467f183397a06830ca98e7e324c5ac34590e4c3b016f67e5ec1d9cae29", size = 259914, upload-time = "2026-09-01T21:17:33.061Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d2/40/ed022667886e0760c2aabe9f9948ae8a960a207358ad29ecd320ce1e3553/abx_plugins-1.12.224.tar.gz", hash = "sha256:6ff56bbbe6555dc0116505b436365416c1df46429479e221beb7c6f2d677645a", size = 258239, upload-time = "2026-09-03T03:27:38.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/79/f1bce1735324848ba088666b6b2d7bdb941a74adb3fc258906ff227307df/abx_plugins-1.12.201-py3-none-any.whl", hash = "sha256:6c3d48e99b926eb184001269f60660a800c4e122f192b56990e8554e8816f9b7", size = 413743, upload-time = "2026-09-01T21:17:31.318Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f9/60612b0f8b187f3d39b1a0991f884d0ae9ce111010129a36fc6252fe1ed7/abx_plugins-1.12.224-py3-none-any.whl", hash = "sha256:c36d94d2455676b9bb04df66650e4fbf3cabe20999b9c22c4551038678428058", size = 411959, upload-time = "2026-09-03T03:27:40.383Z" },
]
[[package]]
@@ -75,7 +75,7 @@ wheels = [
[[package]]
name = "abxpkg"
version = "1.12.110"
version = "1.12.117"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -83,9 +83,9 @@ dependencies = [
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/25/152a0e2dd37494cab5116629b4a3f26232dda6641e7b79f8eb7d1e9bfd75/abxpkg-1.12.110.tar.gz", hash = "sha256:94777e6ea76944b6577c46aaf4d845cdc68cfbdfe4c58ca24cd02071e367a660", size = 239950, upload-time = "2026-08-31T01:25:16.393Z" }
sdist = { url = "https://files.pythonhosted.org/packages/00/35/bbfaccae109328c716cea4e0cdbf2d2dc5975f448203bb3f4daa605591a6/abxpkg-1.12.117.tar.gz", hash = "sha256:4d88cc293f343eee2be7ecaab1203dfbc20571a4a39b2afa2f866c9ef8032dec", size = 239387, upload-time = "2026-09-02T19:57:26.142Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/95/49a113ceccf1d1ac33a90a0f8735abd43919f6cf2543917542acbdd61ea3/abxpkg-1.12.110-py3-none-any.whl", hash = "sha256:01e3e95c0af1a8fe775dee07eb16fa540a4e7007c3961a52e11d57bc2eae2430", size = 254023, upload-time = "2026-08-31T01:25:14.833Z" },
{ url = "https://files.pythonhosted.org/packages/73/46/372be1f8fa915eb254ad865b97fb41073acdb5573dbce3e23a6083417d87/abxpkg-1.12.117-py3-none-any.whl", hash = "sha256:76ec8afaac7b2d7aece7bdda2bbf606e6373db51eb4dcb906008052d09e59577", size = 253749, upload-time = "2026-09-02T19:57:27.437Z" },
]
[[package]]
@@ -120,7 +120,7 @@ wheels = [
[[package]]
name = "archivebox"
version = "0.9.35rc375"
version = "0.9.35rc418"
source = { editable = "." }
dependencies = [
{ name = "abx-dl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -219,10 +219,10 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "abx-dl", specifier = "==1.12.229" },
{ name = "abx-plugins", specifier = "==1.12.201" },
{ name = "abx-dl", specifier = "==1.12.261" },
{ name = "abx-plugins", specifier = "==1.12.224" },
{ name = "abxbus", specifier = "==2.5.56" },
{ name = "abxpkg", specifier = "==1.12.110" },
{ name = "abxpkg", specifier = "==1.12.117" },
{ name = "archivebox", extras = ["sonic", "ldap", "debug"], marker = "extra == 'all'" },
{ name = "atomicwrites", specifier = "==1.4.1" },
{ name = "base32-crockford", specifier = ">=0.3.0" },