ArchiveBox v0.7.2/v0.8.6 升级 v0.9.0 迁移路径修复指南:Django 数据库迁移全流程解析
【免费下载链接】ArchiveBox🗃 Open source self-hosted web archiving. Takes URLs/browser history/bookmarks/Pocket/Pinboard/etc., saves HTML, JS, PDFs, media, and more...项目地址: https://gitcode.com/gh_mirrors/ar/ArchiveBox
导读
本文以仓库内 old/TODO_fix_migration_path.md 为核心主线,系统讲解 ArchiveBox 从 v0.7.2 / v0.8.6rc0 升级到 v0.9.0 时数据库迁移路径的设计思路、踩坑清单与验证方法。你会掌握三套旧版本 schema 的差异、SeparateDatabaseAndState双轨迁移的正确用法、如何用"最小手工 SQL + Django 状态同步"避免升级丢数据,以及如何在仓库测试基建(archivebox/tests/migrations_helpers.py)之上复现并验证三类升级场景。
一、核心问题:v0.7.2 → v0.9.0 升级会丢数据
v0.9.0 对核心表(core_archiveresult、core_snapshot、core_tag、crawls_crawl)做了大规模字段重命名与类型重构。如果迁移实现不到位,升级过程中会出现以下典型数据丢失:
extractor字段数据没有复制到plugin字段;output字段数据没有复制到output_str字段;- 时间戳字段(
added/updated)没有被正确转换; - Tag 主键从 UUID 转换为 INTEGER 时丢失外键关联(
core_snapshot_tags断裂)。
这些字段并不是简单的改名:v0.9.0 的plugin、output_str是由 DjangoAddField操作以默认值新增的列,若在 SQL 阶段直接写新列名,后续AddField会用默认值覆盖已复制好的数据,导致"迁移成功但内容全空"。这正是本文要解决的核心矛盾。
二、三个版本的表结构差异
理解迁移必须先吃透三套 schema。下表整理自 old/TODO_fix_migration_path.md 的 Schema Version Differences 章节,并与当前源码(archivebox/core/migrations/0023_upgrade_to_0_9_0.py)中的字段处理一一对照。
v0.7.2(迁移 0022 之后)
| 表 | 关键字段 |
|---|---|
core_archiveresult | id(INTEGER)、uuid、extractor、output、cmd、pwd、cmd_version、start_ts、end_ts、status、snapshot_id |
core_snapshot | id、url、timestamp、title、added、updated、crawl_id |
core_tag | id(INTEGER)、name、slug |
crawls_crawl | v0.7.2 中尚不存在 |
v0.8.6rc0
| 表 | 关键字段 |
|---|---|
core_archiveresult | id、abid(不是 uuid!)、extractor、output、created_at、modified_at、retry_at、status… |
core_snapshot | id、url、bookmarked_at、created_at、modified_at、crawl_id、status、retry_at… |
core_tag | id(UUID/CHAR!)、name、slug、abid、created_at、modified_at、created_by_id |
crawls_crawl | id、seed_id、persona(VARCHAR)、max_depth、tags_str、status、retry_at… |
v0.9.0 目标 schema
| 表 | 关键字段 |
|---|---|
core_archiveresult | id(INTEGER)、uuid、plugin(替代 extractor)、output_str(替代 output)、hook_name、created_at、modified_at、output_files、output_json、output_size、output_mimetypes、retry_at… |
core_snapshot | id、url、bookmarked_at(替代 added)、created_at、modified_at(替代 updated)、crawl_id、parent_snapshot_id、status、retry_at、current_step、depth、fs_version… |
core_tag | id(INTEGER!)、name、slug、created_at、modified_at、created_by_id |
crawls_crawl | id、urls(替代 seed_id)、persona_id(替代 persona)、label、notes、output_dir… |
仓库内 archivebox/tests/migrations_helpers.py 完整定义了SCHEMA_0_4(第 26 行)、SCHEMA_0_7(第 50 行)与SCHEMA_0_8(第 187 行)三套建表脚本,是理解"迁移前数据库长什么样"的第一手资料,可用于搭建可重复的迁移测试环境。
三、迁移测试方法论:三个场景 + 数据完整性校验
3.1 三种必测场景
无论迁移代码怎么写,都必须跑通以下三个场景(摘自原文档 How to Test Migrations):
场景 1:全新安装(Fresh Install)
rm -rf /tmp/test_fresh && mkdir -p /tmp/test_fresh DATA_DIR=/tmp/test_fresh python -m archivebox init DATA_DIR=/tmp/test_fresh python -m archivebox status场景 2:v0.7.2 升级
rm -rf /tmp/test_v072 && mkdir -p /tmp/test_v072 cp /path/to/archivebox-v0.7.2/data/index.sqlite3 /tmp/test_v072/ DATA_DIR=/tmp/test_v072 python -m archivebox init DATA_DIR=/tmp/test_v072 python -m archivebox status场景 3:v0.8.6rc0 升级
rm -rf /tmp/test_v086 && mkdir -p /tmp/test_v086 cp /path/to/archivebox-v0.8.6rc0/data/index.sqlite3 /tmp/test_v086/ DATA_DIR=/tmp/test_v086 python -m archivebox init DATA_DIR=/tmp/test_v086 python -m archivebox status原文档中提到的/Users/squash/Local/Code/...是开发者本机路径,不具备可移植性;仓库的做法是在 archivebox/tests/migrations_helpers.py 中用SCHEMA_0_7/SCHEMA_0_8建表、用seed_0_7_data()/seed_0_8_data()灌入真实感数据(含用户、标签、快照、ArchiveResult、Seed/Crawl 关联、API Token 等),再通过run_archivebox_migration_cmd()执行init。对应测试用例见 archivebox/tests/test_migrations_fresh.py 与 archivebox/tests/test_migrations_08_to_09.py。
3.2 数据完整性校验 SQL
每次迁移后,用 sqlite3 对比"原始库"与"迁移后库"的字段级数据:
# 检查 ArchiveResult 数据是否保留(原库 extractor/output vs 新库 plugin/output_str) echo "=== ORIGINAL ===" sqlite3 /path/to/original.db "SELECT id, extractor, output, status FROM core_archiveresult LIMIT 5;" echo "=== MIGRATED ===" sqlite3 /tmp/test_vXXX/index.sqlite3 "SELECT id, plugin, output_str, status FROM core_archiveresult LIMIT 5;" # 检查 Snapshot 时间戳语义 echo "=== ORIGINAL SNAPSHOTS ===" sqlite3 /path/to/original.db "SELECT id, url, title, added, updated FROM core_snapshot LIMIT 5;" echo "=== MIGRATED SNAPSHOTS ===" sqlite3 /tmp/test_vXXX/index.sqlite3 "SELECT id, url, title, bookmarked_at, created_at, modified_at FROM core_snapshot LIMIT 5;" # 检查 Tag 及快照-标签关联数量 echo "=== ORIGINAL TAGS ===" sqlite3 /path/to/original.db "SELECT * FROM core_tag;" echo "=== MIGRATED TAGS ===" sqlite3 /tmp/test_vXXX/index.sqlite3 "SELECT * FROM core_tag;" sqlite3 /tmp/test_vXXX/index.sqlite3 "SELECT COUNT(*) FROM core_snapshot_tags;"关键校验点:
- 行数一致;
- 所有 URL、标题、时间戳原样保留;
- 所有
extractor值复制到plugin; - 所有
output值复制到output_str; - 所有标签关联完整(v0.8.6 的 Tag ID 需从 UUID 转为 INTEGER 且关联表同步重写)。
仓库测试对上述校验做了自动化:test_migration_preserves_snapshot_count、test_migration_preserves_tags、test_migration_preserves_archiveresults(同时断言每条 ArchiveResult 都关联到新machine_process记录)、test_tag_associations_preserved_after_migration、test_timestamps_preserved_after_migration等,均可在 archivebox/tests/test_migrations_08_to_09.py 中找到。
四、迁移哲学:最小手工 SQL
原文档的 Migration Philosophy 提出了一个关键原则:SQL 阶段只做"表重建保数据",不做字段重命名;字段映射交给 Django 的AddField+RunPython数据复制,最后用SeparateDatabaseAndState让 Django 状态与真实库同步。五步流程如下:
- Python:探测现有 schema 版本
def get_table_columns(table_name): cursor = connection.cursor() cursor.execute(f"PRAGMA table_info({table_name})") return {row[1] for row in cursor.fetchall()} cols = get_table_columns('core_archiveresult') has_extractor = 'extractor' in cols has_plugin = 'plugin' in cols- SQL:迁移期间重建表结构
CREATE TABLE core_archiveresult_new (...); INSERT INTO core_archiveresult_new SELECT ... FROM core_archiveresult; DROP TABLE core_archiveresult; ALTER TABLE core_archiveresult_new RENAME TO core_archiveresult;- Python:新旧字段间复制数据
if 'extractor' in cols and 'plugin' in cols: cursor.execute("UPDATE core_archiveresult SET plugin = COALESCE(extractor, '')")SQL:删除旧列/旧表(
RemoveField交给 Django)Django:注册最终状态
migrations.SeparateDatabaseAndState( database_operations=[...], # 你的 SQL/Python 迁移 state_operations=[...] # 告诉 Django 最终 schema 长什么样 )这套"五步法"在仓库中已经完整落地,下面结合源码逐文件解析。
五、关键迁移文件深度解析
5.1 core/migrations/0023_upgrade_to_0_9_0.py:SQL 重建三张核心表
该迁移的职责是"用旧字段名重建表、逐行搬运数据、带行数断言地替换旧表"。关键实现(archivebox/core/migrations/0023_upgrade_to_0_9_0.py):
- 防丢数据硬约束:
assert_rebuild_row_count()(第 22-32 行)在DROP TABLE之前比较源表与目标表行数,不一致直接raise RuntimeError,拒绝用残缺数据替换旧表。 - 状态归一化:
normalize_status()(第 47-58 行)把success/succeded统一映射为succeeded,其余未知状态回落为failed;normalize_cmd()把命令行统一序列化为 JSON 数组。 - ArchiveResult(PART 1):新建
core_archiveresult_new时只保留旧字段名extractor、output(第 111-112 行),并按来源分三条分支复制:v0.7.2(有uuid)、v0.8.6rc0(有abid)、两者皆无(生成新 UUID)。UUID 使用 archivebox/uuid_compat.py 提供的uuid7()生成。 - Snapshot(PART 2):
core_snapshot_new直接采用 v0.9.0 的新时间戳字段(bookmarked_at、created_at、modified_at、downloaded_at,第 288-290 行)。v0.7.2 源的added/updated在 SQL 中完成语义映射:bookmarked_at优先用timestamp的 unixepoch 转换、回落added;updated被重命名为downloaded_at(第 343-364 行)。 - Tag(PART 3):先通过
PRAGMA table_info判断id列类型(第 472-477 行)。若为 CHAR/UUID(v0.8.6rc0),建立uuid_to_int_map映射、以顺序整数重写core_tag.id,并重建core_snapshot_tags关联表(第 489-531 行);若为 INTEGER 则原样搬运并保留审计字段。 - Postgres 分支:SQLite 特有的
PRAGMA/sqlite_master/ 表重建在 PostgreSQL 上不可用,迁移开头vendor != "sqlite"直接返回(第 68-69 行),最后统一由_pg_sync_schema通过 archivebox/misc/db.py 的rebuild_models_from_migration_state()按迁移状态重同步空表。
5.2 core/migrations/0025:AddField + RunPython 数据复制
0025 是"数据不丢失"的第二半程(archivebox/core/migrations/0025_alter_archiveresult_options_alter_snapshot_options_and_more.py):
- 通过常规
AddField新增plugin、output_str、hook_name、output_files、output_json、output_size、output_mimetypes、config、retry_at等新列(第 107-167 行); - 关键:
AddField之后紧接RunPython(copy_old_fields_to_new)(第 270-273 行),执行extractor → plugin、output → output_str的COALESCE复制(第 26-32 行),并用start_ts/end_ts回填缺失的created_at/modified_at(第 37-45 行); - 复制完成后才
RemoveField删除旧列extractor、output(第 275-282 行); - Snapshot 的
config、current_step、depth、notes、parent_snapshot、status等新字段因 0023 已建好真实列,这里用SeparateDatabaseAndState的state-onlyAddField声明(第 175-240 行),避免 SQLite 以 0023 之前的状态重建表、用默认值覆盖已迁移行; - 末尾同样以
_pg_sync_schema收尾(第 365 行)。
5.3 crawls/migrations/0002_upgrade_from_0_8_6.py:Crawl 表升级
v0.8.6 的crawls_crawl通过seed_id外键引用独立的seeds_seed表,v0.9.0 将其扁平化为urls文本字段(archivebox/crawls/migrations/0002_upgrade_from_0_8_6.py):
- 先探测列名,确认是 v0.8.6 schema(有
seed_id且无urls,第 85-86 行)才执行升级,天然支持条件化跳过; - 用
LEFT JOIN seeds_seed把seed.uri灌入新表的urls列(第 130-137 行); - 对 UUID 类外键统一执行
REPLACE(id, '-', '')去连字符归一化(第 31-62 行),包括crawls_crawlschedule.template_id、persona_id、schedule_id以及core_snapshot.crawl_id; - 新建表后重建索引(第 143-147 行)。
六、生成迁移 vs 应用迁移:两个目录,两种命令
原文档强调一个极易犯错的点:makemigrations 与应用迁移必须在不同目录执行。
生成迁移(创建新迁移)——永远在 archivebox/ 包目录下执行:
cd archivebox/ ./manage.py makemigrations ./manage.py makemigrations --check # 验证没有未反映到迁移的状态变化archivebox/manage.py 第 10 行明确白名单了makemigrations、migrate、startapp、squashmigrations、generate_stubs、test六个开发命令;开发者跑其他命令时会收到提示改用archivebox init/archivebox server等 CLI。这从工具层面强制了"不要从数据目录直接跑 Django 命令"的纪律。
应用迁移(测试迁移)——永远在数据目录内用archivebox init:
# 错误示范: cd /some/data/dir ../path/to/archivebox/manage.py migrate # 正确做法: DATA_DIR=/some/data/dir python -m archivebox init原因:archivebox init会建立数据目录结构、在正确的DATA_DIR上下文中执行迁移、创建必要文件并校验安装。
七、十大常见坑(Gotchas)
1. ❌ 不要在 0023 的 SQL 里创建新字段
0023 重建表必须沿用旧字段名(extractor、output),若提前建plugin、output_str,0025 的AddField会用默认值覆盖已复制数据。仓库 0023 第 111-112 行正是旧字段名写法。
2. ❌ 不要在 0023 里复制数据到新字段
字段复制应放在 0025 的AddField之后的RunPython中,否则同样会被默认值覆盖。仓库copy_old_fields_to_new严格遵循此顺序。
3. ❌ 不要用"空表"判断全新安装
全新安装会跑 0001-0022 建出空的老 schema 表,0023 必须照常重建表结构。正确做法是探测列名而非行数。0023 先查sqlite_master确认表存在,行数为 0 时跳过复制循环但仍执行表重建与断言(archivebox/core/migrations/0023_upgrade_to_0_9_0.py 第 76-93 行)。
4. ❌ 不要从数据目录生成迁移
makemigrations 必须在 archivebox/ 包目录执行,理由见上一节。
5. ❌ 不要用 WHERE 子句"跳过"不存在的列
-- 错误:即使 WHERE 为假,SQLite 仍会解析 uuid 列,报 "no such column" INSERT INTO new_table SELECT uuid FROM old_table WHERE EXISTS (SELECT 1 FROM pragma_table_info('old_table') WHERE name='uuid');正确做法是在 Python 侧探测列名后选择对应 SQL:
if 'uuid' in get_table_columns('old_table'): cursor.execute("INSERT INTO new_table SELECT uuid FROM old_table") else: cursor.execute("INSERT INTO new_table SELECT abid as uuid FROM old_table")6. ❌ 不要混用 UUID 与 INTEGER 的 Tag ID
v0.8.6rc0 的Tag.id是 UUID,v0.9.0 需要 INTEGER。转换必须三步走:建旧 UUID → 新 INTEGER 映射 → 重写core_tag→ 重写core_snapshot_tags。0023 PART 3 已实现并带copied_snapshot_tags != len(snapshot_tags)的防丢断言。
7. ❌ 不要忘记 SeparateDatabaseAndState
手工改了真实库,必须用state_operations告诉 Django 最终状态,否则makemigrations --check永远报"未反映的变更":
migrations.SeparateDatabaseAndState( database_operations=[ migrations.RunPython(my_sql_function), ], state_operations=[ migrations.RemoveField('archiveresult', 'extractor'), migrations.RemoveField('archiveresult', 'output'), ], )0023、0025 都大量使用该模式。
8. ✅ 要打印调试信息
print(f'Migrating ArchiveResult from v0.7.2 schema...') print(f'DEBUG: has_uuid={has_uuid}, has_abid={has_abid}, row_count={row_count}')0023 中保留了类似输出(如Rebuilding core tables from 0.8.x abid schema、copying 44 ArchiveResults...),便于判断走了哪条迁移分支。注意:正式发布前应清理调试 print。
9. ✅ 要测全部三个场景
全新安装、v0.7.2 升级(文档记录为 12 snapshots / 44 archiveresults / 2 tags)、v0.8.6rc0 升级(14 snapshots / 多个 UUID Tag)。仓库中 archivebox/tests/test_migrations_fresh.py、archivebox/tests/test_migrations_07_to_09.py、archivebox/tests/test_migrations_08_to_09.py 已覆盖。
10. ✅ 要验证没有未反映的迁移
cd archivebox/ ./manage.py makemigrations --check # 期望输出:No changes detected八、字段映射参考表
下表继承自原文档 Reference 章节,并补充了当前实现细节:
| 旧字段(v0.7.2 / v0.8.6) | 新字段(v0.9.0) | 说明 |
|---|---|---|
extractor | plugin | 改名,0025 中COALESCE复制 |
output | output_str | 改名,0025 中COALESCE复制 |
added | bookmarked_at | 改名,同时用于推导created_at |
updated | modified_at | 改名;0023 中updated同时映射为downloaded_at |
abid | uuid | 仅 v0.8.6,字段改名(UUID 生成见 archivebox/uuid_compat.py) |
| Tag.id (UUID) | Tag.id (INTEGER) | 仅 v0.8.6,类型转换并重写关联表 |
seed_id | urls | Crawl 表,仅 v0.8.6,LEFT JOIN seeds_seed取值 |
persona(VARCHAR) | persona_id(UUID FK) | Crawl 表,仅 v0.8.6 |
status(如success) | status(succeeded) | 枚举归一化,见normalize_status() |
| (无) | cmd→machine_process.cmd | 0027 将 ArchiveResult 的命令元数据迁入 Process 记录 |
九、当前状态与修复清单
原文档记录截至 2025-01-01 的状态:三个场景迁移均能"跑通"但存在数据丢失。对照当前仓库源码,原文档列出的修复项大多已落地:
- 0023 的 CREATE TABLE 已改用旧字段名(
extractor、output、added、updated语义),并新增assert_rebuild_row_count防丢断言; - 0025 已在所有
AddField之后加入RunPython(copy_old_fields_to_new)复制extractor→plugin、output→output_str; - Snapshot 时间戳转换、Tag UUID→INTEGER 转换、
core_snapshot_tags重写均已在 0023 PART 2/PART 3 实现; - crawls/0002 保持"基于 schema 探测的条件升级"策略,并补充了 UUID 去连字符归一化。
因此,将本文视为"迁移设计蓝图 + 当前实现对照"来阅读:表格、五步法、十大坑依然是你评估任何新 schema 变更迁移质量的通用清单。若你基于旧版数据目录做升级实验,仍建议按第三节的校验 SQL 逐项核对。
十、测试检查清单
- 全新安装创建正确 schema
- 全新安装有 0 snapshots、0 archiveresults
- v0.7.2 迁移保留全部 snapshots(仓库测试见 archivebox/tests/test_migrations_07_to_09.py)
- v0.7.2 迁移保留全部 archiveresults 与 tags
- v0.7.2 迁移完成
extractor→plugin、output→output_str(抽查前 5 行) - v0.7.2 迁移完成
added→bookmarked_at、updated→modified_at(对比时间戳) - v0.8.6 迁移保留全部 snapshots 与 crawl 关联(含无 crawl 的快照被 0024 分配默认 crawl)
- v0.8.6 迁移将 Tag ID 从 UUID 转为 INTEGER 且保留
core_snapshot_tags关系 - v0.8.6 迁移将
abid转为uuid字段 - 每条 ArchiveResult 迁移后关联一条
machine_process记录(见test_migration_creates_process_records) ./manage.py makemigrations --check无未反映变更- 所有迁移无报错,
archivebox status显示正确的 snapshot 统计
结语
ArchiveBox v0.9.0 的迁移路径是"SQL 保数据、Django 管状态、测试守底线"三者配合的典型案例:0023 用最小手工 SQL 完成表重建与数据搬运,0025 在AddField之后用RunPython完成新旧字段映射,SeparateDatabaseAndState保证 Django 状态与真实库不脱节,而 archivebox/tests 下按 schema 版本构造的 fixture 与断言,则让"升级不丢数据"从口号变成可重复验证的工程事实。理解这条路径,不仅能安全完成 ArchiveBox 的旧版升级,也能为任何 Django 项目的大版本字段重构提供可复用的方法论。
【免费下载链接】ArchiveBox🗃 Open source self-hosted web archiving. Takes URLs/browser history/bookmarks/Pocket/Pinboard/etc., saves HTML, JS, PDFs, media, and more...项目地址: https://gitcode.com/gh_mirrors/ar/ArchiveBox
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考