ArchiveBox v0.7.2/v0.8.6 升级 v0.9.0 迁移路径修复指南:Django 数据库迁移全流程解析
2026/9/20 22:42:36 网站建设 项目流程

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_archiveresultcore_snapshotcore_tagcrawls_crawl)做了大规模字段重命名与类型重构。如果迁移实现不到位,升级过程中会出现以下典型数据丢失:

  • extractor字段数据没有复制到plugin字段;
  • output字段数据没有复制到output_str字段;
  • 时间戳字段(added/updated)没有被正确转换;
  • Tag 主键从 UUID 转换为 INTEGER 时丢失外键关联(core_snapshot_tags断裂)。

这些字段并不是简单的改名:v0.9.0 的pluginoutput_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_archiveresultid(INTEGER)、uuidextractoroutputcmdpwdcmd_versionstart_tsend_tsstatussnapshot_id
core_snapshotidurltimestamptitleaddedupdatedcrawl_id
core_tagid(INTEGER)、nameslug
crawls_crawlv0.7.2 中尚不存在

v0.8.6rc0

关键字段
core_archiveresultidabid(不是 uuid!)、extractoroutputcreated_atmodified_atretry_atstatus
core_snapshotidurlbookmarked_atcreated_atmodified_atcrawl_idstatusretry_at
core_tagid(UUID/CHAR!)、nameslugabidcreated_atmodified_atcreated_by_id
crawls_crawlidseed_idpersona(VARCHAR)、max_depthtags_strstatusretry_at

v0.9.0 目标 schema

关键字段
core_archiveresultid(INTEGER)、uuidplugin(替代 extractor)、output_str(替代 output)、hook_namecreated_atmodified_atoutput_filesoutput_jsonoutput_sizeoutput_mimetypesretry_at
core_snapshotidurlbookmarked_at(替代 added)、created_atmodified_at(替代 updated)、crawl_idparent_snapshot_idstatusretry_atcurrent_stepdepthfs_version
core_tagid(INTEGER!)、nameslugcreated_atmodified_atcreated_by_id
crawls_crawlidurls(替代 seed_id)、persona_id(替代 persona)、labelnotesoutput_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_counttest_migration_preserves_tagstest_migration_preserves_archiveresults(同时断言每条 ArchiveResult 都关联到新machine_process记录)、test_tag_associations_preserved_after_migrationtest_timestamps_preserved_after_migration等,均可在 archivebox/tests/test_migrations_08_to_09.py 中找到。

四、迁移哲学:最小手工 SQL

原文档的 Migration Philosophy 提出了一个关键原则:SQL 阶段只做"表重建保数据",不做字段重命名;字段映射交给 Django 的AddField+RunPython数据复制,最后用SeparateDatabaseAndState让 Django 状态与真实库同步。五步流程如下:

  1. 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
  1. 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;
  1. Python:新旧字段间复制数据
if 'extractor' in cols and 'plugin' in cols: cursor.execute("UPDATE core_archiveresult SET plugin = COALESCE(extractor, '')")
  1. SQL:删除旧列/旧表RemoveField交给 Django)

  2. 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,其余未知状态回落为failednormalize_cmd()把命令行统一序列化为 JSON 数组。
  • ArchiveResult(PART 1):新建core_archiveresult_new时只保留旧字段名extractoroutput(第 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_atcreated_atmodified_atdownloaded_at,第 288-290 行)。v0.7.2 源的added/updated在 SQL 中完成语义映射:bookmarked_at优先用timestamp的 unixepoch 转换、回落addedupdated被重命名为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新增pluginoutput_strhook_nameoutput_filesoutput_jsonoutput_sizeoutput_mimetypesconfigretry_at等新列(第 107-167 行);
  • 关键AddField之后紧接RunPython(copy_old_fields_to_new)(第 270-273 行),执行extractor → pluginoutput → output_strCOALESCE复制(第 26-32 行),并用start_ts/end_ts回填缺失的created_at/modified_at(第 37-45 行);
  • 复制完成后才RemoveField删除旧列extractoroutput(第 275-282 行);
  • Snapshot 的configcurrent_stepdepthnotesparent_snapshotstatus等新字段因 0023 已建好真实列,这里用SeparateDatabaseAndStatestate-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_seedseed.uri灌入新表的urls列(第 130-137 行);
  • 对 UUID 类外键统一执行REPLACE(id, '-', '')去连字符归一化(第 31-62 行),包括crawls_crawlschedule.template_idpersona_idschedule_id以及core_snapshot.crawl_id
  • 新建表后重建索引(第 143-147 行)。

六、生成迁移 vs 应用迁移:两个目录,两种命令

原文档强调一个极易犯错的点:makemigrations 与应用迁移必须在不同目录执行

生成迁移(创建新迁移)——永远在 archivebox/ 包目录下执行

cd archivebox/ ./manage.py makemigrations ./manage.py makemigrations --check # 验证没有未反映到迁移的状态变化

archivebox/manage.py 第 10 行明确白名单了makemigrationsmigratestartappsquashmigrationsgenerate_stubstest六个开发命令;开发者跑其他命令时会收到提示改用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 重建表必须沿用旧字段名(extractoroutput),若提前建pluginoutput_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 schemacopying 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)说明
extractorplugin改名,0025 中COALESCE复制
outputoutput_str改名,0025 中COALESCE复制
addedbookmarked_at改名,同时用于推导created_at
updatedmodified_at改名;0023 中updated同时映射为downloaded_at
abiduuid仅 v0.8.6,字段改名(UUID 生成见 archivebox/uuid_compat.py)
Tag.id (UUID)Tag.id (INTEGER)仅 v0.8.6,类型转换并重写关联表
seed_idurlsCrawl 表,仅 v0.8.6,LEFT JOIN seeds_seed取值
persona(VARCHAR)persona_id(UUID FK)Crawl 表,仅 v0.8.6
status(如successstatussucceeded枚举归一化,见normalize_status()
(无)cmdmachine_process.cmd0027 将 ArchiveResult 的命令元数据迁入 Process 记录

九、当前状态与修复清单

原文档记录截至 2025-01-01 的状态:三个场景迁移均能"跑通"但存在数据丢失。对照当前仓库源码,原文档列出的修复项大多已落地:

  1. 0023 的 CREATE TABLE 已改用旧字段名(extractoroutputaddedupdated语义),并新增assert_rebuild_row_count防丢断言;
  2. 0025 已在所有AddField之后加入RunPython(copy_old_fields_to_new)复制extractor→pluginoutput→output_str
  3. Snapshot 时间戳转换、Tag UUID→INTEGER 转换、core_snapshot_tags重写均已在 0023 PART 2/PART 3 实现;
  4. 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→pluginoutput→output_str(抽查前 5 行)
  • v0.7.2 迁移完成added→bookmarked_atupdated→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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询