Apache Subversion 1.15 源码深度剖析-第 6 章 工作副本:WC-NG 与 pristineless
本系列文章的源代码基线为 Apache Subversion 1.15.0(tag
1.15.0,见仓库根目录CHANGES首行)。本章所有表名、列名、状态值、常量、函数名与r(revision)号,均来自本次对d:\CODE\subversion-1.15.0源码的逐文件阅读与检索,不使用任何"记忆里的"结论。
6.0 导读:为什么"工作副本"值得单独写一章
Subversion 的仓库(libsvn_fs_fs)负责"版本的历史",而**工作副本(working copy,简称 WC)**负责"用户手上那份可编辑的、与仓库某一状态相关联的本地目录树"。绝大多数开发者与 SVN 打交道,其实都是在和工作副本打交道:svn status 读它、svn diff 读它、svn update 写它、svn commit 从它出发、svn revert 撤销对它做的修改。工作副本的存储格式决定了------
- 客户端能否表达"混合修订"(不同子目录处在不同 revision);
- 客户端能否表达"移动 / 重命名"(move)这一区别于"删除+复制"的语义;
- 客户端能否表达"稀疏检出"(sparse checkout)与"changelist";
- 一次
update被 Ctrl-C 打断后,磁盘上的数据结构是否仍然自洽; - 同样的文本内容被多个路径引用时,是否需要重复存储。
Subversion 1.6 及以前,这些信息散落在每个目录下的 .svn/entries 文本文件里;1.7 起被替换为单一 SQLite 数据库 .svn/wc.db ,这一代架构在社区里被称为 WC-NG(Next Generation Working Copy)。1.15 又在这套架构上叠加了两个头条特性:
- pristineless(无 pristine / 按需拉取原始内容) :把"本地缓存的原始文本"变成可选,用工作副本格式 32(
SVN_WC__VERSION = 32)承载; - 多 WC 格式支持(multiple working copy formats) :允许客户端在不同代际的 WC 格式间读写,用
--compatible-version暴露给用户。
本章的目标,是把 .svn/wc.db 的完整关系模式(schema) 、NODES 表的双层/多层视图模型 、pristine 的内容寻址与引用计数 、work queue 的崩溃恢复设计,以及 1.15 的 pristineless 与多格式支持,一条线讲透。
6.0.1 涉及文件清单
阅读本章时,建议对照以下源码文件(file:/// 链接可直接在编辑器中打开):
| 主题 | 文件 |
|---|---|
| wc.db 完整 schema | subversion/libsvn_wc/wc-metadata.sql |
| WC 内部常量与格式史 | subversion/libsvn_wc/wc.h |
| WC DB 公共 API 头 | subversion/libsvn_wc/wc_db.h |
| work queue 实现 | subversion/libsvn_wc/workqueue.c |
| work queue API 头 | subversion/libsvn_wc/workqueue.h |
| 冲突 skel API | subversion/libsvn_wc/conflicts.h |
| 冲突实现 | subversion/libsvn_wc/conflicts.c |
| 管理目录/设置 | subversion/libsvn_wc/adm_files.c |
| 升级与格式映射 | subversion/libsvn_wc/upgrade.c |
| 客户端格式映射 | subversion/libsvn_client/upgrade.c |
| checkout 与 store-pristine 校验 | subversion/libsvn_client/checkout.c |
| CLI 选项状态结构 | subversion/svn/cl.h |
| CLI 选项表与帮助 | subversion/svn/svn.c |
| cleanup 命令实现 | subversion/svn/cleanup-cmd.c |
| 发布说明 | CHANGES |
| 开发工具:wc.db 校验器 | tools/dev/wc-ng/svn-wc-db-tester.c |
| 开发工具:wc.db 转储解码 | tools/dev/svnqlite3-dump |
6.0.2 读完本章你将能够
- 说出
.svn/wc.db里每一张表的用途、主键、关键列与索引,并写出针对性的 SQL 查询; - 解释
op_depth的语义,用一句话讲清"为什么 NODES 表能同时表达 BASE 与 WORKING 两套视图"; - 给定一次
svn rm加svn mv的操作序列,逐行推演 NODES 表中op_depth/presence/moved_here/moved_to的取值; - 解释 pristine 的 SHA-1 内容寻址 + refcount 触发器 机制,并说明为什么 1.15 要引入
hydrated列与TEXTBASE_REFS表; - 说明 pristineless 与 多 WC 格式支持 在 CLI 与库层的开关点(
--store-pristine、--vacuum-pristines、--compatible-version); - 解释 work queue 的"先写日志后执行"设计如何支撑崩溃恢复,并列出全部 work item 类型;
- 把 1.7、1.8、1.15 三个格式里程碑与对应
r号对应起来。
6.1 演进史:从每个目录一个 entries 到单一 wc.db
6.1.1 1.6 及以前:admin 区域与 .svn/entries
在 WC-NG 之前,工作副本的元数据是分散 的:工作副本根目录有一个 .svn/,每个受版本控制的子目录也各有一个 .svn/ 目录。每个 .svn/ 目录里存放:
entries:该目录下所有节点(含svn:this_dir)的元信息;text-base/:原始文本(pristine)副本;props/、prop-base/:属性的工作副本与原始副本;wcprops/:WebDAV 属性缓存;tmp/、lock等。
这种"每个目录一份 entries"的布局,在 subversion/libsvn_wc/wc.h 顶部那份格式编年史里有清晰记载。文件的注释里写道:
c
/* We can handle this format or anything lower, and we (should) error
* on anything higher.
*
* There is no format version 0; we started with 1.
*
* The bump to 2 introduced the ".svn-work" extension. For example,
* ".svn/props/foo" became ".svn/props/foo.svn-work".
* ...
* == 1.0.x shipped with format 4
* == 1.1.x shipped with format 4
* == 1.2.x shipped with format 4
* == 1.3.x shipped with format 4
(见 wc.h#L48-L64。)
注意这里的表述「we can handle this format or anything lower」。1.15 时代,客户端能处理的最老格式 已经不再是这里开头讲的那些,而是被 SVN_WC__SUPPORTED_VERSION 重新定义------这一点会在 6.5 节详细展开。
6.1.2 分散式 entries 的表达困境
"每目录一份 entries、每个目录各有一个 admin 区"带来的痛点,正是 WC-NG 的动机来源。把它们归纳为四类:
-
混合修订(mixed-revision) :每个 entries 文件自身记录了
entry的revision,但目录属于哪个 revision 这一信息,与"目录的 text-base 是什么"耦合在同一个 entries 里。当你只想改一个深层子目录的 revision(比如svn update sub/),就必须打开、解析、重写该子目录的 entries 文件,而它的祖先目录里的 entries 仍然声称自己是旧 revision。层级之间没有统一的事务,容易不一致。 -
目录 rename / move 表达困难 :在 1.6 里,
svn mv被展开成"把目标目录登记为带历史的 add(copyfrom)",源目录登记为 delete。要在分散的 entries 里表达"这一整棵子树是从别处移动过来的",需要把 copyfrom 信息复制进每个子目录的 entries,非常啰嗦;而且真正的"重命名"语义(区别于 copy+delete)无从表达,服务器也只能看到 copy+delete。 -
move 的祖先/后代关系 :WC-NG 的 NODES 表引入了
moved_here/moved_to两列,专门表达"该节点被移动到这里"与"该节点被移动到别处"。分散式 entries 根本没有合适的字段承载这种"成对且成树"的关系。 -
稀疏(sparse)与 changelist :1.5 的 format 9 才把 changelist、keep-local、sticky depth 引入 entries(见格式编年史
wc.h中 "The bump to 9 added changelists, keep-local, and sticky depth...")。但用文本格式表达"一个节点属于哪个 changelist""这个目录是 sparse 排除的"会迅速膨胀,检索也只能全量解析。
WC-NG 的解法是:把所有工作副本元数据收进一个 SQLite 数据库 .svn/wc.db ,用真正的关系表 + 索引 + 事务 + 触发器来表达上述关系。这一转变在格式编年史里的记录是:
c
* The bump to 12 switched from 'entries' to the SQLite database 'wc.db'.
*
* The bump to 13 added the WORK_QUEUE table into 'wc.db', moved the
* wcprops into the 'dav_cache' column in BASE_NODE, and stopped using
* the 'incomplete_children' column of BASE_NODE.
*
* The bump to 14 added the WCLOCKS table (and migrated locks from the
* filesystem into wc.db), and some columns to ACTUAL_NODE for future
* use.
(见 wc.h#L94-L102。)
6.1.3 格式编年史:从 12 到 32
wc.h 的这段注释,是理解 WC-NG 演进的权威索引。我把与本章直接相关的重要节点摘录并解读如下(原文行号见括号):
-
format 12 :从
entries切到 SQLitewc.db。wc.h里对应的宏是SVN_WC__WC_NG_VERSION:c/* A version < this uses the old 'entries' file mechanism. */ #define SVN_WC__WC_NG_VERSION 12(
wc.h#L208-L209。)还有一个宏专门写给"旧客户端"看,让它能识别"这是新版 SVN 的工作副本":c#define SVN_WC__NON_ENTRIES 12 #define SVN_WC__NON_ENTRIES_STRING "12\n" -
format 13 :加入
WORK_QUEUE表;把 wcprops 挪进dav_cache列。对应宏:c/* A version < this has no work queue (see workqueue.h). */ #define SVN_WC__HAS_WORK_QUEUE 13 -
format 14 :加入
WCLOCKS/WC_LOCK相关表,把锁从文件系统迁进wc.db。 -
format 15 :把目录上的
depth='exclude'改为presence='exclude',从而让"排除"也能作用于文件与符号链接。 -
format 16 :
WC_LOCK加入locked_levels;PRISTINE加入md5_checksum(为将来备用)。 -
format 17 :引入
.svn/pristine目录,把 text-base 挪进 Pristine Store(PRISTINE表 +.svn/pristine目录) ,废除/.svn/text-base。 -
format 18 :把属性从
props/prop-base目录挪进wc.db,随后删除这两个目录。 -
format 19 :引入"每个工作副本一个 DB"的 single DB 模型,所有元数据集中到根目录的
.svn/wc.db:c* The bump to 19 introduced the 'single DB' per working copy. All metadata * is held in a single '.svn/wc.db' in the root directory of the working * copy. Bumped in r991236. -
format 20 :引入
NODES表、废除BASE_NODE与WORKING_NODE。注意这里注释说 "op_depth is always 0 or 2",而后续 format 23 才把它泛化为多层:c* The bump to 20 introduced NODES and drops BASE_NODE and WORKING_NODE, * op_depth is always 0 or 2. Bumped in r1005388. -
format 21 / 22 :树冲突存储位置从父节点挪到冲突节点本身(r1034436),再挪进
tree_conflict_data列(r1040255)。 -
format 23 :
NODES的 multi-layer op_depth 处理(r1044384)。这是op_depth成为"多层视图"的关键一步。 -
format 24 :
PRISTINE.refcount开始被正确维护,而不是永远为 1(r1058523)。 -
format 25 / 26 :引入
NODES_CURRENT视图(r1071283)与NODES_BASE视图(r1076617)。 -
format 27:冲突文件名从 basename 改为 relpath 存储(r1089593)。
-
format 28:把所有剩下的 MD5 引用改用 SHA-1(r1095214)。
-
format 29 :pristine 文件从
<SHA1>改名为<SHA1>.svn-base,并引入EXTERNALSstore(r1129286)。注释明示 "== 1.7.x shipped with format 29"。 -
format 30 :冲突存储改为
conflict_data列里的 skel(r1387742)。 -
format 31 :
NODES加入inherited_props列(r1395109)。注释明示 1.8/1.9/1.10/1.11 都停在 31。 -
format 32 :可选 pristine 内容(pristineless),即
STMT_UPGRADE_TO_32。
关于 32 与 1.15 的对应,原文写得很明确:
c
* The bump to 32 adds support for optional pristine contents; see the docstring
* of STMT_UPGRADE_TO_32 for details.
*
* == 1.15.x shipped with format 32 and multi-wc-format support
*
* Please document any further format changes here.
*/
6.1.4 从 WC_LOCK 的沿革看"锁"的迁移
一个有意思的细节:1.6 时锁是文件系统里的 lock 文件;WC-NG 把它迁进了数据库表(format 14 加 WCLOCKS,16 加 locked_levels)。这带来的直接好处是:svn status 显示的 L 标记、svn cleanup 的"打破锁"、以及"写锁精确到某一层目录"这三件事,都能在一个事务里完成。wc-metadata.sql 里的 WC_LOCK 表定义如下:
sql
CREATE TABLE WC_LOCK (
/* specifies the location of this node in the local filesystem */
wc_id INTEGER NOT NULL REFERENCES WCROOT (id),
local_dir_relpath TEXT NOT NULL,
locked_levels INTEGER NOT NULL DEFAULT -1,
PRIMARY KEY (wc_id, local_dir_relpath)
);
(wc-metadata.sql#L230-L238。)locked_levels = -1 表示"整棵子树都锁住",具体语义在 6.2 节逐表精讲时展开。
6.1.5 小结:演进的三条主线
把演进史收敛成三条主线:
- 从"分散"到"集中" :分散的每目录
.svn→ 根目录单一.svn/wc.db(format 12、19)。集中之后,一次update只动一个 DB 的一件事务,混合修订的不一致窗口被大幅压缩。 - 从"扁平"到"分层" :
BASE_NODE+WORKING_NODE两张表 →NODES一张表 +op_depth多层(format 20、23)。分层让"同一路径的多个历史版本"共存,revert 只需删掉高层。 - 从"必有 pristine"到"可选 pristine" :
PRISTINE从"每一行都在磁盘上有一份文件" → 允许hydrated = 0(format 32)。这就是 1.15 pristineless 的落点。
下一节进入正题:逐表逐列精读 wc-metadata.sql。
6.2 wc.db 逐表逐列精读
subversion/libsvn_wc/wc-metadata.sql 是 WC-NG 的"宪法"。它不是一份构建产物,而是由 C 代码在创建/升级工作副本时读取并执行 的 SQL 脚本,脚本里用 -- STMT_xxx 这样的注释标记出可供 C 代码按名引用的独立语句块 (例如 -- STMT_CREATE_SCHEMA、-- STMT_UPGRADE_TO_32)。下面按"创建期 schema"的顺序逐表拆解。
6.2.1 脚本结构与两组枚举
脚本开头先声明了两个约定枚举。第一组是 KIND (节点类型),第二组是 PRESENCE(存在状态):
sql
/*
* the KIND column in these tables has one of the following values
* (documented in the corresponding C type #svn_kind_t):
* "file"
* "dir"
* "symlink"
* "unknown"
*
* the PRESENCE column in these tables has one of the following values
* (see also the C type #svn_wc__db_status_t):
* "normal"
* "server-excluded" -- server has declared it excluded (ie. authz failure)
* "excluded" -- administratively excluded (ie. sparse WC)
* "not-present" -- node not present at this REV
* "incomplete" -- state hasn't been filled in
* "base-deleted" -- node represents a delete of a BASE node
*/
这里出现的六个 presence 字符串,与 C 侧枚举 svn_wc__db_status_t(在 wc_db.h#L132-L187)一一对应,但并不完全同名------数据库里的 presence 是"存储值",C 侧的状态是"读取后归一化的语义值"。对应关系如下(这是理解后续所有状态判断的钥匙):
wc.db 的 presence |
C 侧 svn_wc__db_status_t(svn_wc__db_read_info 归一化后) |
|---|---|
normal(op_depth=0) |
svn_wc__db_status_normal |
normal(op_depth>0) |
svn_wc__db_status_added(进一步由 svn_wc__db_scan_addition 细分为 added/copied/moved_here) |
base-deleted |
svn_wc__db_status_deleted |
server-excluded |
svn_wc__db_status_server_excluded |
excluded |
svn_wc__db_status_excluded |
not-present |
svn_wc__db_status_not_present |
incomplete |
svn_wc__db_status_incomplete |
注意 svn_wc__db_status_moved_here 与 svn_wc__db_status_copied 在数据库里并没有独立的 presence 取值------它们是从 normal(WORKING 层)+ moved_here 列 + repos_id/repos_path/revision 推导出来的。这一点会在 6.3 的状态机推演里反复用到。
脚本里还有一句很重要的注释,说明 STMT_CREATE_SCHEMA 的定位:
sql
/* One big list of statements to create our initial schema.
STMT_CREATE_SCHEMA creates the schema for the minimum WC format
supported by the client (SVN_WC__SUPPORTED_VERSION).
When we're creating a new working copy, we first execute
STMT_CREATE_SCHEMA, and then use the normal WC upgrade code (using
STMT_UPGRADE_TO_xx) to bring the schema up to any higher requested
format.
*/
这段话解释了 1.15 多格式支持的一个关键实现细节:新工作副本的创建过程,是先建"最低支持格式"的 schema,再跑升级路径升到目标格式 。也就是说,"建一个 format 32 的 WC"实际是"建 format 31 的 schema,然后执行 STMT_UPGRADE_TO_32"。这样做的好处是升级代码与创建代码共用一套路径,避免两套 schema 定义漂移。
6.2.2 REPOSITORY:仓库身份
sql
CREATE TABLE REPOSITORY (
id INTEGER PRIMARY KEY AUTOINCREMENT,
/* The root URL of the repository. This value is URI-encoded. */
root TEXT UNIQUE NOT NULL,
/* the UUID of the repository */
uuid TEXT NOT NULL
);
/* Note: a repository (identified by its UUID) may appear at multiple URLs.
For example, http://example.com/repos/ and https://example.com/repos/. */
CREATE INDEX I_UUID ON REPOSITORY (uuid);
CREATE INDEX I_ROOT ON REPOSITORY (root);
要点:
id是自增主键,供其他表通过外键引用。例如NODES.repos_id REFERENCES REPOSITORY (id)。root是URI 编码后的仓库根 URL ,且UNIQUE。uuid是仓库 UUID。脚本特意加了一条注释说明"同一个 UUID 的仓库可能出现在多个 URL 上 "(例如 http 与 https),并为此建了I_UUID索引。这条设计直接服务于 1.15 的一条新行为:当仓库 UUID 相同但 URL 不同时的告警 (见CHANGES里 "Warn on merges from foreign repositories if UUIDs match but URLs differ (r1888474 et al, issue #4874)")。- 另一个子细节:为什么
root要 UNIQUE 而uuid只建普通索引? 因为数据库用root做唯一键来去重"同一个 URL",而"同一个 UUID 多个 URL"是允许的,所以uuid不能 UNIQUE。
一个可直接运行的排查查询------列出该 WC 见过的所有仓库:
sql
-- 查看工作副本引用了哪些仓库(URL / UUID / 被多少 NODES 行引用)
SELECT r.id, r.root, r.uuid, COUNT(n.local_relpath) AS node_refs
FROM REPOSITORY r
LEFT JOIN NODES n ON n.repos_id = r.id
GROUP BY r.id
ORDER BY node_refs DESC;
注意 NODES 里只有 op_depth != 0(即复制/移动的 WORKING 节点)以及 BASE 节点会填 repos_id;普通 added 节点可能为 NULL,所以这里用 LEFT JOIN。
6.2.3 WCROOT:工作副本根
sql
CREATE TABLE WCROOT (
id INTEGER PRIMARY KEY AUTOINCREMENT,
/* absolute path in the local filesystem. NULL if storing metadata in
the wcroot itself. */
local_abspath TEXT UNIQUE
);
CREATE UNIQUE INDEX I_LOCAL_ABSPATH ON WCROOT (local_abspath);
要点:
- 每行代表一个工作副本根 (或一个"外部工作副本"的根)。
local_abspath是该根的本地绝对路径。 - 注释里"NULL if storing metadata in the wcroot itself"透露了历史:早年(format 19 前后)元数据可能分散存储在多个 admin 目录,
WCROOT.local_abspath用来标记"这个 wc_id 对应磁盘上哪个目录"。到了单一 DB 时代,一个wc.db里可能塞进多个"逻辑工作副本"(例如主 WC 与它的 externals),于是WCROOT表就成了区分它们的锚点。 - 几乎所有业务表(NODES、ACTUAL_NODE、EXTERNALS、WC_LOCK、SETTINGS)都以
wc_id INTEGER NOT NULL REFERENCES WCROOT (id)开头------wc_id是这些表的"分区键"。
6.2.4 PRISTINE:原始内容的引用表
这是全章最核心的表之一。
sql
CREATE TABLE PRISTINE (
/* The SHA-1 checksum of the pristine text. This is a unique key. The
SHA-1 checksum of a pristine text is assumed to be unique among all
pristine texts referenced from this database. */
checksum TEXT NOT NULL PRIMARY KEY,
/* Enumerated values specifying type of compression. The only value
supported so far is NULL, meaning that no compression has been applied
and the pristine text is stored verbatim in the file. */
compression INTEGER,
/* The size in bytes of the file in which the pristine text is stored.
Used to verify the pristine file is "proper". */
size INTEGER NOT NULL,
/* The number of rows in the NODES table that have a 'checksum' column
value that refers to this row. (References in other places, such as
in the ACTUAL_NODE table, are not counted.) */
refcount INTEGER NOT NULL,
/* Alternative MD5 checksum used for communicating with older
repositories. Not strictly guaranteed to be unique among table rows. */
md5_checksum TEXT NOT NULL
);
CREATE INDEX I_PRISTINE_MD5 ON PRISTINE (md5_checksum);
逐列解读:
-
checksum:pristine 内容的 SHA-1 ,PRIMARY KEY。它同时是内容寻址的地址 ------磁盘上那份 pristine 文件的路径,就是由这个 checksum 推导出来的(先按前两位十六进制分组,再加.svn-base后缀;format 29 起由wc.h注释 "renamed the pristine files from '' to '.svn-base'" 记录)。表头的注释也写明:"The text itself is stored in a file whose name is derived from the 'checksum' column." -
compression:压缩类型枚举。注释明确"当前唯一支持的值是 NULL,表示不压缩、逐字节原样存储"。这是一个为将来预留的扩展点。 -
size:pristine 文件的字节数,用来校验"磁盘上那个文件是不是被写坏了/截断了"。 -
refcount:NODES 表里有多少行的checksum指向本行 。注意括号里的限定:"References in other places, such as in the ACTUAL_NODE table, are not counted."------也就是说ACTUAL_NODE.older_checksum/left_checksum/right_checksum对 pristine 的引用不计入 refcount(这一点在 format 32 的注释里又被强调了一遍,见 6.5 节)。 -
md5_checksum:并存的 MD5 ,注释说它用于"与旧仓库通信"(older repositories 在 1.6 之前用 MD5 标识内容)。它不保证唯一 ,因此只建普通索引I_PRISTINE_MD5,不做唯一约束。
一个直接可跑、非常实用的查询------找出被引用最多的 pristine(往往是最热门的公共文件,比如 LICENSE、README):
sql
-- 被引用次数最高的 pristine(refcount 从高到低)
SELECT checksum, refcount, size, md5_checksum,
printf('%.2f MB', size / 1048576.0) AS size_mb
FROM PRISTINE
ORDER BY refcount DESC
LIMIT 20;
再看"未被引用的 pristine "(这是 svn cleanup --vacuum-pristines 要清理的候选):
sql
-- refcount == 0 的 pristine,即已无任何 NODES 行引用的孤儿内容
SELECT COUNT(*) AS orphan_count,
SUM(size) AS orphan_bytes,
printf('%.1f MB', SUM(size) / 1048576.0) AS orphan_mb
FROM PRISTINE
WHERE refcount = 0;
不过要小心:在 format 32 之前,"被 ACTUAL_NODE 的列引用"的 pristine 不会被 refcount 统计,所以只靠 refcount = 0 判断"可回收"并不完备------这正是 1.15 引入 hydrated、TEXTBASE_REFS 与 I_PRISTINE_UNREFERENCED 索引的动机之一。
6.2.5 ACTUAL_NODE:相对 NODES 的"差异层"
如果说 NODES 是"节点应该是什么样",ACTUAL_NODE 就是"相对 NODES 行,这个节点的文本与属性发生了什么变化"。它的表头注释把语义说得很清楚:
sql
/* The ACTUAL_NODE table describes text changes and property changes
on each node in the WC, relative to the NODES table row for the
same path. (A NODES row must exist if this node exists, but an
ACTUAL_NODE row can exist on its own if it is just recording info
on a non-present node - a tree conflict or a changelist, for
example.)
...
*/
列定义:
sql
CREATE TABLE ACTUAL_NODE (
wc_id INTEGER NOT NULL REFERENCES WCROOT (id),
local_relpath TEXT NOT NULL,
parent_relpath TEXT,
properties BLOB,
conflict_old TEXT,
conflict_new TEXT,
conflict_working TEXT,
prop_reject TEXT,
changelist TEXT,
text_mod TEXT,
tree_conflict_data TEXT,
conflict_data BLOB,
older_checksum TEXT REFERENCES PRISTINE (checksum),
left_checksum TEXT REFERENCES PRISTINE (checksum),
right_checksum TEXT REFERENCES PRISTINE (checksum),
PRIMARY KEY (wc_id, local_relpath)
);
逐列要点:
-
properties(BLOB) :本节点工作副本属性的序列化 skel。NULL 表示"相对 BASE/WORKING 没有属性修改"(表头注释:"NULL implies no change to the properties, relative to WORKING/BASE as appropriate.")。 -
conflict_old/conflict_new/conflict_working/prop_reject:旧的、以"冲突文件名"形式存储的引用列 。脚本注释写着/* ### These columns will eventually be merged into conflict_data below. */------也就是说它们是 1.7 时代遗留的,1.8(format 30)之后实际冲突内容已经搬进conflict_data的 skel。这几列现在只在升级路径与"历史 WC"里出现。 -
changelist:非 NULL 表示"本节点属于某个 changelist"。 -
text_mod:文本修改标记。脚本注释坦承"取值待定 ":"unknown"(无信息)、"admin"(用户用了类似svn edit的操作)、"noticed"(扫描文件系统时发现了修改)。这列在实践中很少被当作权威依据,因为修改状态主要靠 mtime/size 启发式 + 校验和比对来判定。 -
tree_conflict_data:如果是目录,则存"其中所有树冲突"的序列化数据。脚本注释指出它的历史与归宿:sql/* if a directory, serialized data for all of tree conflicts therein. ### This column will eventually be merged into the conflict_data column, ### but within the ACTUAL node of the tree conflict victim itself, rather ### than the node of the tree conflict victim's parent directory. */ -
conflict_data(BLOB) :真正的主角。脚本注释:"A skel containing the conflict details." 它是 1.8 起(format 30,STMT_UPGRADE_TO_30把旧列迁移进来)统一承载文本冲突、属性冲突、树冲突三类的 skel。 -
older_checksum/left_checksum/right_checksum:三个指向PRISTINE的校验和,分别对应"较旧的""左边的""右边的"冲突文本。这里脚本里保留了一段非常精彩的设计问答,值得原样引用:sql/* Three columns containing the checksums of older, left and right conflict texts. Stored in a column to allow storing them in the pristine store */ /* stsp: This is meant for text conflicts, right? What about property conflicts? Why do we need these in a column to refer to the pristine store? Can't we just parse the checksums from conflict_data as well? rhuijben: Because that won't allow triggers to handle refcounts. We would have to scan all conflict skels before cleaning up the a single file from the pristine stor */这段话讲清了一个为什么 :冲突文本也要进 pristine store,但如果只把校验和藏在
conflict_data的 skel 里,SQLite 的触发器就无法自动维护PRISTINE.refcount(触发器只能看列,不能解析 BLOB)。退而求其次,把校验和单独提成列 ,触发器才能用UPDATE OF older_checksum之类的语句维护引用计数。请注意,这也解释了 6.2.4 里"ACTUAL_NODE 的引用不计入 refcount"这句注释的由来------因为这几列的引用是由独立的触发器/清理逻辑处理的,与 NODES 的 refcount 分开维护。 -
parent_relpath:父节点的local_relpath,用于按父聚合子节点。表头注释:"this will be "" if the parent is the wcroot. NULL if this is the wcroot node." -
PRIMARY KEY (wc_id, local_relpath):每个 WC 内、每个 relpath 最多一行 ACTUAL_NODE。 -
另有唯一索引
I_ACTUAL_PARENT ON ACTUAL_NODE (wc_id, parent_relpath, local_relpath),加速"某父目录下的全部 ACTUAL_NODE 子行"查询。
可运行查询示例(红色警告要求之一) ------查"某路径的 BASE/WORKING 两行"及冲突信息。假设路径为 trunk/foo.c:
sql
-- (a) 查某路径在 NODES 中的 BASE(op_depth=0) 与最高 WORKING 层,以及各自的 presence/kind/checksum
SELECT n.op_depth, n.presence, n.kind, n.checksum,
n.revision, n.translated_size, n.last_mod_time
FROM NODES n
WHERE n.local_relpath = 'trunk/foo.c'
ORDER BY n.op_depth;
-- (b) 查该路径的 ACTUAL_NODE(属性修改 + 冲突三件套)
SELECT local_relpath, properties,
older_checksum, left_checksum, right_checksum,
conflict_old, conflict_new, conflict_working, prop_reject,
tree_conflict_data, conflict_data, changelist, text_mod
FROM ACTUAL_NODE
WHERE local_relpath = 'trunk/foo.c';
-- (c) 若存在文本冲突,进一步查它的三个冲突文件相对路径(新式 skel 存 relpath)
SELECT conflict_old, conflict_new, conflict_working
FROM ACTUAL_NODE
WHERE local_relpath = 'trunk/foo.c'
AND conflict_data IS NOT NULL;
-- (d) 查某路径引用到的 pristine 的引用计数
SELECT n.op_depth, n.checksum, p.refcount, p.size, p.hydrated
FROM NODES n
JOIN PRISTINE p ON p.checksum = n.checksum
WHERE n.local_relpath = 'trunk/foo.c';
(注意 p.hydrated 这一列只在 format 32 及以后存在 ;在更低格式上执行会报"no such column"。这从侧面说明:同一份 schema 脚本,执行到哪个 STMT_UPGRADE_TO_xx 就产生哪一代的表结构。)
6.2.6 LOCK:仓库锁缓存
sql
/* This table is a cache of information about repository locks. */
CREATE TABLE LOCK (
/* what repository location is locked */
repos_id INTEGER NOT NULL REFERENCES REPOSITORY (id),
repos_relpath TEXT NOT NULL,
/* Information about the lock. Note: these values are just caches from
the server, and are not authoritative. */
lock_token TEXT NOT NULL,
lock_owner TEXT,
lock_comment TEXT,
lock_date INTEGER, /* an APR date/time (usec since 1970) */
PRIMARY KEY (repos_id, repos_relpath)
);
要点:这是服务器端锁 的本地缓存(不是工作副本写锁,写锁在 WC_LOCK)。注释强调"these values are just caches from the server, and are not authoritative "------svn status -u 展示的 O(other lock)标记正是读这张表。lock_date 是 APR 时间戳(微秒,自 1970 起)。
6.2.7 WORK_QUEUE:可重放的工作日志
sql
CREATE TABLE WORK_QUEUE (
/* Work items are identified by this value. */
id INTEGER PRIMARY KEY AUTOINCREMENT,
/* A serialized skel specifying the work item. */
work BLOB NOT NULL
);
WORK_QUEUE 只有两列:自增 id 与 work(一个序列化 skel)。它是 6.6 节的主角------设计精髓在于"数据库事务先落日志,事务提交后再执行文件系统副作用"。
6.2.8 NODES:本章的核心
NODES 是全表的"心脏",也是本章硬示例 (b) 的舞台。它的表头注释先讲了 op_depth 的定义与一个三维示意图:
sql
/* The NODES table describes the way WORKING nodes are layered on top of
BASE nodes and on top of other WORKING nodes, due to nested tree structure
changes. The layers are modelled using the "op_depth" column.
An 'operation depth' refers to the number of directory levels down from
the WC root at which a tree-change operation (delete, add?, copy, move)
was performed. A row's 'op_depth' does NOT refer to the depth of its own
'local_relpath', but rather to the depth of the nearest tree change that
affects that node.
The row with op_depth=0 for any given local relpath represents the "base"
node that is created and updated by checkout, update, switch and commit
post-processing. The row with the highest op_depth for a particular
local_relpath represents the working version. Any rows with intermediate
op_depth values are not normally visible to the user but may become
visible after reverting local changes.
...
For illustration, with a scenario like this:
# (0)
svn rm foo
svn cp ^/moo foo # (1)
svn rm foo/bar
touch foo/bar
svn add foo/bar # (2)
, these are the NODES table rows for the path foo/bar:
(0) "BASE" ---> NODES (op_depth == 0)
(1) NODES (op_depth == 1)
(2) NODES (op_depth == 2)
0 is the original data for foo/bar before 'svn rm foo' (if it existed).
1 is the data for foo/bar copied in from ^/moo/bar.
2 is the to-be-committed data for foo/bar, created by 'svn add foo/bar'.
An 'svn revert foo/bar' would remove the NODES of (2).
*/
这段话值得逐句咀嚼:
op_depth不是 该行local_relpath的路径深度,而是"离它最近的那次树结构变更操作,发生在从 WC 根往下数的第几层 "。所以一个深层文件a/b/c/d.c,如果它的祖先目录a/b发生了一次删除,那么它可能是op_depth = 2(a/b是两层)。op_depth = 0的行永远是 BASE 节点(checkout / update / switch / commit 后处理产生)。- 对某个
local_relpath而言,最大的op_depth行就是 WORKING 版本(用户可见的当前状态)。 - 中间的
op_depth行平时不可见,但在 revert 后会露出来------这正是"多层视图"的威力:revert 高层后,下一层立即成为当前可见版本。 - 那个
svn rm foo+svn cp ^/moo foo+svn add foo/bar的例子,给出了op_depth精确等于 0、1、2 的三行。
列定义(分五组):
sql
CREATE TABLE NODES (
/* Working copy location related fields */
wc_id INTEGER NOT NULL REFERENCES WCROOT (id),
local_relpath TEXT NOT NULL,
op_depth INTEGER NOT NULL,
parent_relpath TEXT,
/* Repository location fields */
repos_id INTEGER REFERENCES REPOSITORY (id),
repos_path TEXT,
revision INTEGER,
/* WC state fields */
presence TEXT NOT NULL,
moved_here INTEGER,
moved_to TEXT,
/* Content fields */
kind TEXT NOT NULL,
properties BLOB,
depth TEXT,
checksum TEXT REFERENCES PRISTINE (checksum),
symlink_target TEXT,
/* Last-Change fields */
changed_revision INTEGER,
changed_date INTEGER,
changed_author TEXT,
/* Various cache fields */
translated_size INTEGER,
last_mod_time INTEGER,
dav_cache BLOB,
file_external INTEGER,
inherited_props BLOB,
PRIMARY KEY (wc_id, local_relpath, op_depth)
);
逐列语义(以脚本注释为准):
A. 位置组
wc_id:外键,指向WCROOT。local_relpath:相对工作副本根的路径,用/分隔。op_depth:见上。脚本注释补充一个关键特例:"Since a wcroot will never have a WORKING node the parent_relpath will never be null, except when op_depth == 0 and the node is a wcroot."(WC 根永远不会有 WORKING 节点;parent_relpath唯一可能为 NULL 的情况,是op_depth == 0且节点就是 wcroot 自己。)parent_relpath:父节点 relpath,用于按父聚合。
B. 仓库位置组
-
repos_id、repos_path、revision:脚本注释把两种情形的语义分开讲:sql/* When op_depth == 0, these fields refer to the repository location of the BASE node, the location of the initial checkout. When op_depth != 0, they indicate where this node was copied/moved from. In this case, the fields are set for the root of the operation and for all children. */即:op_depth=0 时它们描述 BASE 节点自身的仓库位置;op_depth≠0 时它们描述"这个 WORKING 节点是从哪儿复制/移动来的"(copyfrom 信息) ,并且会在操作根及其全部子节点上都填好。
C. 状态组
-
presence:见 6.2.1 的对应表。这里脚本对normal、not-present、incomplete、base-deleted、server-excluded、excluded各自在 BASE 树与 WORKING 树下的双重含义 做了很长一段注释(wc-metadata.sql#L322-L371)。6.3 节会逐条解读。 -
moved_here(INTEGER,布尔):sql/* Boolean value, specifying if this node was moved here (rather than just copied). This is set on all the nodes in the moved tree. The source of the move is implied by a different node with a moved_to column pointing at the root node of the moved tree. */两个关键点:它是布尔 (列类型却写成 INTEGER,SQLite 无独立布尔类型);它对被移动子树的每一个节点都置位(不只根)。
-
moved_to(TEXT):sql/* If the underlying node was moved away (rather than just deleted), this specifies the local_relpath of where the node was moved to. This is set only on the root of a move, and is NULL for all children. The op-depth of the moved-to node is not recorded. A moved_to path always points at a node within the highest op-depth layer at the destination. This invariant must be maintained by operations which change existing move information. */关键点:只设在 move 的根上,所有子节点为 NULL ;
moved_to的目标 op_depth 不记录 ,且永远指向目标处的最高 op_depth 层(这是必须维护的不变量)。
D. 内容组
-
kind:file/dir/symlink/unknown(见 6.2.1)。注释:"may be "unknown" if the node is not present." -
properties(BLOB):本节点属性的序列化 skel。注释:"when presence is 'normal' or 'incomplete' ... an empty skel or NULL indicates no properties. NULL if we have no information about the properties (any other presence)." 这里还留了一个 TODO,坦承"尚未统一'无属性'的表示"(空 skel 还是 NULL 二选一都没定)。 -
depth(TEXT):脚本注释有一大段吐槽------sql/* NULL depth means "default" (typically svn_depth_infinity) */ /* ### depth on WORKING? seems this is a BASE-only concept. how do ### you do "files" on an added-directory? can't really ignore ### the subdirs! */ /* ### maybe a WC-to-WC copy can retain a depth? */即
depth主要是 BASE 概念,WORKING 层上的depth语义并不清晰(svn add一个目录时无法"忽略子目录")。 -
checksum(TEXT REFERENCES PRISTINE(checksum)):本文件节点的 pristine SHA-1。注释:"The SHA-1 checksum of the pristine text, if this node is a file and was moved here or copied here, else NULL." 请注意它也是 refcount 触发器唯一关心的列(见 6.2.9)。 -
symlink_target:kind == symlink时的目标字符串。
E. Last-Change 组
changed_revision/changed_date/changed_author:脚本注释规定了一个硬约束 ------"changed_rev must be not-null if this node has presence=="normal""。也就是说,任何presence='normal'的节点都必须有changed_revision(在 SVN 术语中即"最后修改修订号")。changed_date是 APR 时间戳。
F. 缓存组
-
translated_size:sql/* The size in bytes of the working file when it had no local text modifications. This means the size of the text when translated from repository-normal format to working copy format with EOL style translated and keywords expanded according to the properties in the "properties" column of this row. */关键:它是**"翻译后"的大小**(EOL 转换 + 关键字展开之后),不是 pristine 的原始大小。它被
svn status的"变化检测启发式"使用。 -
last_mod_time:sql/* The mod-time of the working file when it was last determined to be logically unmodified relative to its base, taking account of keywords and EOL style. This value is used in the change detection heuristic used by the status command. */即"上一次确认它与 base 逻辑一致时,工作文件的 mtime "。1.15 的
r1892645"用加密校验和判断文件是否被修改"正是在这个启发式的最后一道防线上升级(见 6.8 节)。 -
dav_cache(BLOB):WebDAV 属性缓存(format 13 从wcprops挪进来的)。 -
file_external(INTEGER):注释写 "Is there a file external in this location. NULL if there is no file external, otherwise '1'"。还有一条历史注解:sql/* ### Originally we had a wc-1.0 like skel in this place, so we ### check for NULL. ### In Subversion 1.7 we defined this column as TEXT, but Sqlite ### only uses this information for deciding how to optimize ### anyway. */也就是说 1.7 时它被定义成 TEXT,但因为 SQLite 的列类型只是亲和性建议,实际存
1也无妨。 -
inherited_props(BLOB):注释 "serialized skel of this node's inherited properties. NULL if this is not the BASE of a WC root node." 这是 format 31 新增的列,用于**继承属性(svn:mergeinfo等)**的缓存。
主键与索引:
sql
PRIMARY KEY (wc_id, local_relpath, op_depth)
);
CREATE UNIQUE INDEX I_NODES_PARENT ON NODES (wc_id, parent_relpath,
local_relpath, op_depth);
/* I_NODES_MOVED is introduced in format 30 */
CREATE UNIQUE INDEX I_NODES_MOVED ON NODES (wc_id, moved_to, op_depth);
PRIMARY KEY 是 (wc_id, local_relpath, op_depth) ------这正是"同一个路径允许多层共存"的物理保证:只有当 local_relpath 与 op_depth 同时 相同才算冲突。I_NODES_MOVED 在 moved_to 上建唯一索引(wc_id, moved_to, op_depth),让"该节点被移动到哪儿去了"的查询走索引。脚本注释说它 "is introduced in format 30",但注意 STMT_UPGRADE_TO_30 里用的是 CREATE UNIQUE INDEX IF NOT EXISTS I_NODES_MOVED------CREATE SCHEMA 里已经建过一次,升级路径再补一次是幂等的。
6.2.9 两个视图与三个触发器
视图 NODES_CURRENT / NODES_BASE:
sql
CREATE VIEW NODES_CURRENT AS
SELECT * FROM nodes AS n
WHERE op_depth = (SELECT MAX(op_depth) FROM nodes AS n2
WHERE n2.wc_id = n.wc_id
AND n2.local_relpath = n.local_relpath);
CREATE VIEW NODES_BASE AS
SELECT * FROM nodes
WHERE op_depth = 0;
NODES_CURRENT:对每个local_relpath取最大 op_depth 那一行,得到"当前可见视图"。注释提醒:"Note that this view includes a row for each and every path that is known in the WC, including, for example, paths that were children of a base- or lower-op-depth directory that has been replaced by something else in the current view."------它包含所有已知路径,包括那些"在更高层被取代"的子节点,因为它们的 BASE 行仍在。NODES_BASE:只要op_depth = 0的行,即"检出时的 BASE 树"。
三个触发器维护 PRISTINE.refcount:
sql
CREATE TRIGGER nodes_insert_trigger
AFTER INSERT ON nodes
WHEN NEW.checksum IS NOT NULL
BEGIN
UPDATE pristine SET refcount = refcount + 1
WHERE checksum = NEW.checksum;
END;
sql
CREATE TRIGGER nodes_delete_trigger
AFTER DELETE ON nodes
WHEN OLD.checksum IS NOT NULL
BEGIN
UPDATE pristine SET refcount = refcount - 1
WHERE checksum = OLD.checksum;
END;
sql
CREATE TRIGGER nodes_update_checksum_trigger
AFTER UPDATE OF checksum ON nodes
WHEN NEW.checksum IS NOT OLD.checksum
/* AND (NEW.checksum IS NOT NULL OR OLD.checksum IS NOT NULL) */
BEGIN
UPDATE pristine SET refcount = refcount + 1
WHERE checksum = NEW.checksum;
UPDATE pristine SET refcount = refcount - 1
WHERE checksum = OLD.checksum;
END;
这三个触发器是"为什么 refcount 可以被信任"的答案:
- 插入带
checksum的 NODES 行 → 对应 pristinerefcount + 1; - 删除带
checksum的 NODES 行 → 对应 pristinerefcount - 1; - 更新
checksum列(且新旧不同)→ 新 pristine+1、旧 pristine-1。
WHEN NEW.checksum IS NOT NULL / WHEN OLD.checksum IS NOT NULL 保证了"checksum 为 NULL 的行不参与计数"。第三个触发器里那行被注释掉的 AND (...) 条件,暗示了早期的一个实现疏漏:当 NEW.checksum 为 NULL 时,WHERE checksum = NULL 在 SQL 里恒为假(不会更新任何行),所以那行条件是冗余的;但作者还是保留它作为文档。
为什么 refcount 如此重要? 因为 pristine 的磁盘文件是按内容共享 的:同一个 checksum 只存一份文件,可能被成百上千个 NODES 行引用。要安全地删除一个不再需要的 pristine 文件,必须先确认"没有一行还引用它",否则会破坏其他路径。refcount 就是那个"安全计数",而触发器保证它在任何 DML 路径下都不会漂移------这是把逻辑放进数据库而非应用层的好处。
6.2.10 EXTERNALS
sql
CREATE TABLE EXTERNALS (
wc_id INTEGER NOT NULL REFERENCES WCROOT (id),
local_relpath TEXT NOT NULL,
parent_relpath TEXT NOT NULL,
repos_id INTEGER NOT NULL REFERENCES REPOSITORY (id),
/* Either MAP_NORMAL or MAP_EXCLUDED */
presence TEXT NOT NULL,
kind TEXT NOT NULL,
def_local_relpath TEXT NOT NULL,
def_repos_relpath TEXT NOT NULL,
def_operational_revision TEXT,
def_revision TEXT,
PRIMARY KEY (wc_id, local_relpath)
);
要点:
presence的取值是MAP_NORMAL或MAP_EXCLUDED(与 NODES 的presence不同名)。def_local_relpath/def_repos_relpath/def_operational_revision/def_revision:描述外部定义。"The local relpath of the directory NODE defining this external (Defaults to the parent directory of the file external after upgrade).";以及 "The operational (peg) and node revision if this is a revision fixed external; otherwise NULL. (Usually these will both have the same value)"。- 唯一索引
I_EXTERNALS_DEFINED ON EXTERNALS (wc_id, def_local_relpath, local_relpath)。
6.2.11 创建期收尾:PRAGMA user_version 与查询计划统计
脚本在 schema 定义后紧接着一句:
sql
/* Identify the WC format corresponding to the schema we have created. */
PRAGMA user_version =
-- define: SVN_WC__SUPPORTED_VERSION
;
-- define: SVN_WC__SUPPORTED_VERSION 是给 C 预处理器的占位符 :脚本在读取时会被替换成当前的最小支持格式(1.15 里是 31,见 6.5 节)。因此 PRAGMA user_version 记录的就是"这个 DB 自称的格式号"。
脚本还包含一段 -- STMT_INSTALL_SCHEMA_STATISTICS,手动喂给 SQLite 查询规划器 关于 NODES/ACTUAL_NODE/LOCK/WC_LOCK/EXTERNALS 的统计信息,并有个显眼的注释 "Tell a lie: We ignore that 99.9% of all moved_to values are NULL"(wc-metadata.sql#L602-L635)。
6.2.12 升级路径:STMT_UPGRADE_TO_30/31/32
创建 schema 只是"最低支持格式"。真正让老 WC 升到新格式的是三段升级语句。这里只做结构导读,细节留到 6.4/6.5。
STMT_UPGRADE_TO_30(wc-metadata.sql#L638-L669):
sql
/* Format 30 creates a new NODES index for move information, and a new
PRISTINE index for the md5_checksum column. It also activates use of
skel-based conflict storage -- see notes/wc-ng/conflict-storage-2.0.
It also renames the "absent" presence to "server-excluded". */
-- STMT_UPGRADE_TO_30
CREATE UNIQUE INDEX IF NOT EXISTS I_NODES_MOVED
ON NODES (wc_id, moved_to, op_depth);
CREATE INDEX IF NOT EXISTS I_PRISTINE_MD5 ON PRISTINE (md5_checksum);
UPDATE nodes SET presence = 'server-excluded' WHERE presence = 'absent';
注意 UPDATE nodes SET presence = 'server-excluded' WHERE presence = 'absent'------absent 这个 DDL 时代的旧字符串被重命名为 server-excluded 。另外 STMT_UPGRADE_TO_30 还配套两条把旧冲突列迁移进 conflict_data 的语句:
sql
-- STMT_UPGRADE_30_SELECT_CONFLICT_SEPARATE
SELECT wc_id, local_relpath,
conflict_old, conflict_working, conflict_new, prop_reject, tree_conflict_data
FROM actual_node
WHERE conflict_old IS NOT NULL
OR conflict_working IS NOT NULL
OR conflict_new IS NOT NULL
OR prop_reject IS NOT NULL
OR tree_conflict_data IS NOT NULL
ORDER by wc_id, local_relpath
-- STMT_UPGRADE_30_SET_CONFLICT
UPDATE actual_node SET conflict_data = ?3, conflict_old = NULL,
conflict_working = NULL, conflict_new = NULL, prop_reject = NULL,
tree_conflict_data = NULL
WHERE wc_id = ?1 and local_relpath = ?2
这是一个标准的"SELECT 出旧数据 → C 代码构造 skel → UPDATE 写回 conflict_data 并清空旧列"的迁移模式。
STMT_UPGRADE_TO_31(wc-metadata.sql#L671-L709):
sql
/* Format 31 adds the inherited_props column to the NODES table. C code then
initializes the update/switch roots to make sure future updates fetch the
inherited properties */
-- STMT_UPGRADE_TO_31
ALTER TABLE NODES ADD COLUMN inherited_props BLOB;
DROP INDEX IF EXISTS I_ACTUAL_CHANGELIST;
DROP INDEX IF EXISTS I_EXTERNALS_PARENT;
DROP INDEX I_NODES_PARENT;
CREATE UNIQUE INDEX I_NODES_PARENT ON NODES (wc_id, parent_relpath,
local_relpath, op_depth);
DROP INDEX I_ACTUAL_PARENT;
CREATE UNIQUE INDEX I_ACTUAL_PARENT ON ACTUAL_NODE (wc_id, parent_relpath,
local_relpath);
PRAGMA user_version = 31;
注意 DROP INDEX IF EXISTS I_ACTUAL_CHANGELIST 与 I_EXTERNALS_PARENT------format 31 顺手删掉了两个老索引 (I_ACTUAL_CHANGELIST 曾是 changelist 索引,I_EXTERNALS_PARENT 曾是 external 父路径索引)。还有一条配套的 C 侧查询 STMT_UPGRADE_31_SELECT_WCROOT_NODES,用于"找出所有是 WC 根的 base 节点(含 switch 子树,排除映射到仓库根者)"以初始化 inherited_props。
下一段继续深入:presence 的双重语义、op_depth 状态机、以及 pristine 的内容寻址。
6.3 状态与结构:presence 的双重语义、op_depth 双视图
6.3.1 presence:同一个字符串,两棵树,两套含义
wc-metadata.sql 在 NODES.presence 列上方埋了一整段教科书级 的注释。它明确写道:"The 'BASE' and 'WORKING' trees use the same literal values for the 'presence' but the meaning of each value can vary depending on the tree."(wc-metadata.sql#L334-L336。)逐条翻译如下:
-
normal:- BASE 树:"an ordinary node for which we have full information"(信息完整的普通节点);
- WORKING 树:"an added or copied node for which we have full information"(信息完整的 add/copy 节点)。
- 一句话记忆 :BASE 的
normal= 版本化基节点;WORKING 的normal= 本地新增/复制/移动过来的节点。
-
not-present:- BASE 树:"a node that is implied to exist by the parent node, but is not present in the working copy. Typically obtained by delete/commit, or by update to revision in which the node does not exist."(父节点"暗示"它应该存在,但工作副本里没有。典型来源:删除后提交,或更新到一个"该节点不存在"的 revision。)
- WORKING 树:"a copy of a 'not-present' node from the 'BASE' tree, and it will be deleted on commit. Such a node cannot be copied directly, but can be copied as a descendant."(BASE 里 not-present 节点的工作层拷贝,提交时会被删掉;它不能被直接 copy,但可以作为后代被 copy。)
-
incomplete(脚本用词是incomplete:,实际取值即incomplete):- BASE 树:"an ordinary node for which we do not have full information. Only the name is guaranteed; we may not have all its children, we may not have its checksum, etc."(只保证名字,可能没有子节点、没有校验和。)
- WORKING 树:"a copied node for which we do not have the full information. This state is generally obtained when an operation was interrupted."(操作被打断时得到的残缺拷贝节点。)
-
base-deleted:- BASE 树:"not valid in 'BASE' tree.";
- WORKING 树:"this represents a node that is deleted from the tree below the current 'op_depth'." 脚本还很诚实地吐槽:"This state is badly named, it should be something like 'deleted'."(命名很糟,本该叫 'deleted'。)
-
server-excluded:- BASE 树:"a node that is excluded by authz. The name of the node is known from the parent, but no other information is available."(被服务端 authz 排除;父节点知道名字,其他信息一概没有。)
- WORKING 树:"Not valid in 'WORKING' tree as there is no way to commit such a node."(因为无法提交这样一个节点,WORKING 树里不存在该取值。)
-
excluded:- BASE 树:"this node is administratively excluded by the user (sparse WC)."(用户主动稀疏排除。)
- WORKING 树:"this is a copy of an excluded node from the 'BASE' tree. Such a node cannot be copied directly but can be copied as a descendant."
为什么"同一个值两套含义"是个好设计? 因为 BASE 与 WORKING 本就是"两层视图",同一个物理列承载同一套词汇表,语义由 op_depth 决定。这样查询可以统一写 WHERE presence = 'normal',再按 op_depth 区分------避免了"为两棵树各造一套枚举"导致的值域膨胀与迁移负担。
6.3.2 svn_wc__db_status_t:读取后的语义归一化
C 侧枚举 svn_wc__db_status_t(wc_db.h#L132-L187)比数据库的 6 个 presence 多了几个值,因为它把"WORKING 层的 normal"再细分了:
c
/* The node is present and has no known modifications applied to it. */
svn_wc__db_status_normal,
/* The node has been added ... In many cases svn_wc__db_status_added means
any of added, moved-here or copied-here. ... */
svn_wc__db_status_added,
/* This node has been added with history, based on the move source. ... */
svn_wc__db_status_moved_here,
/* This node has been added with history, based on the copy source. ... */
svn_wc__db_status_copied,
/* This node has been deleted. No text or property modifications
will be present. */
svn_wc__db_status_deleted,
/* This node was named by the server, but no information was provided. */
svn_wc__db_status_server_excluded,
/* This node has been administratively excluded. */
svn_wc__db_status_excluded,
/* This node is not present in this revision. ... */
svn_wc__db_status_not_present,
/* This node is known, but its information is incomplete. ... */
svn_wc__db_status_incomplete,
/* The BASE node has been marked as deleted. Only used as an internal
status in wc_db.c and entries.c. */
svn_wc__db_status_base_deleted
注意最后一条:svn_wc__db_status_base_deleted 的注释写着"Only used as an internal status in wc_db.c and entries.c "。这意味着:磁盘上的 presence='base-deleted',在对外读取时通常被归一化成 svn_wc__db_status_deleted ;只有 WC 内部少数路径才会看到 base_deleted 这个原值。
svn_wc__db_status_added 的注释还点明了它的"伞形"性质:"In many cases svn_wc__db_status_added means any of added, moved-here or copied-here. See individual functions for clarification and svn_wc__db_scan_addition() to get more details."------想区分"纯新增 / 复制 / 移动",需要用 svn_wc__db_scan_addition() 进一步扫描 copyfrom / moved_here 信息。
可运行查询------把"存储层的 presence"与"派生的语义类别"对齐看一眼:
sql
-- 枚举一个 WC 里出现的 (op_depth, presence) 组合,附各组合的行数
SELECT op_depth, presence, COUNT(*) AS rows
FROM NODES
GROUP BY op_depth, presence
ORDER BY op_depth, rows DESC;
-- 只看"移动"相关:moved_here / moved_to
SELECT local_relpath, op_depth, presence, moved_here, moved_to
FROM NODES
WHERE moved_here IS NOT NULL OR moved_to IS NOT NULL
ORDER BY op_depth, local_relpath;
6.3.3 op_depth 状态机硬示例:svn rm + svn mv 的逐行推演
下面用一条可复现的操作序列 ,推演 NODES 表里各行的 op_depth / presence / moved_here / moved_to。所有"规则"都来自本章已引用的 wc-metadata.sql 注释;op_depth 的数值按注释定义"操作根从 WC 根往下数的层数"得出。
初始状态 (svn checkout r10 后,令 WC 根为 .):
| local_relpath | op_depth | presence | kind | repos_path | 备注 |
|---|---|---|---|---|---|
trunk |
0 | normal | dir | trunk |
BASE 目录 |
trunk/alpha.c |
0 | normal | file | trunk/alpha.c |
BASE 文件,checksum=SHA1(A) |
trunk/beta.c |
0 | normal | file | trunk/beta.c |
checksum=SHA1(B) |
trunk/sub |
0 | normal | dir | trunk/sub |
BASE 子目录 |
trunk/sub/gamma.c |
0 | normal | file | trunk/sub/gamma.c |
checksum=SHA1(G) |
此时 NODES 里每个路径只有一行 ,且全在 BASE 层(op_depth=0)。
步骤 1:svn rm trunk/alpha.c
删除操作的 op-root 是 trunk/alpha.c,其深度为 2(trunk 是 1,alpha.c 是 2),所以新增行的 op_depth = 2:
| local_relpath | op_depth | presence | kind | moved_here | moved_to |
|---|---|---|---|---|---|
trunk/alpha.c |
0 | normal | file | NULL | NULL |
trunk/alpha.c |
2 | base-deleted | file | NULL | NULL |
规则要点:
- BASE 行(op_depth=0)原样保留------这就是"多层"的根基:删除并不擦掉基节点,而是在上面叠一层"已删除"。
- 新叠加的 WORKING 行
presence='base-deleted'(脚本注释:"not valid in 'BASE' tree. In the 'WORKING' tree this represents a node that is deleted from the tree below the current 'op_depth'.")。 - 因为只是 delete,
moved_here/moved_to都为 NULL。
步骤 2:svn mv trunk/sub trunk/sub2
这是移动一个目录 。移动的"目标根"是 trunk/sub2(深度 1),"源根"是 trunk/sub(深度 1)。于是新增一行 at op_depth = 1(目标层的操作深度):
| local_relpath | op_depth | presence | kind | moved_here | moved_to | repos_path |
|---|---|---|---|---|---|---|
trunk/sub |
0 | normal | dir | NULL | trunk/sub2 |
trunk/sub |
trunk/sub |
1 | base-deleted | dir | NULL | NULL | NULL |
trunk/sub2 |
1 | normal | dir | 1 | NULL | trunk/sub |
trunk/sub/gamma.c |
0 | normal | file | NULL | NULL | trunk/sub/gamma.c |
trunk/sub/gamma.c |
1 | base-deleted | file | NULL | NULL | NULL |
trunk/sub2/gamma.c |
1 | normal | file | 1 | NULL | trunk/sub/gamma.c |
逐条对照规则:
moved_to只设在 move 的根上,子节点为 NULL (SQL 注释:"This is set only on the root of a move, and is NULL for all children.")。所以trunk/sub的 BASE 行(op_depth=0)拿到moved_to='trunk/sub2';而trunk/sub/gamma.c没有moved_to。moved_here设在被移动子树的每一个节点上 (SQL 注释:"This is set on all the nodes in the moved tree.")。所以trunk/sub2与trunk/sub2/gamma.c的 WORKING 行都是moved_here=1。repos_path在 op_depth≠0 时表示 copyfrom 源 (SQL 注释:"When op_depth != 0, they indicate where this node was copied/moved from. In this case, the fields are set for the root of the operation and for all children.")。所以目标行的repos_path分别指向trunk/sub与trunk/sub/gamma.c。- 源子树被删除 :
trunk/sub与trunk/sub/gamma.c各多出一行presence='base-deleted'、op_depth=1。 moved_to的指向不变量 (SQL 注释:"A moved_to path always points at a node within the highest op-depth layer at the destination."):moved_to='trunk/sub2'指向目标处的最高 op_depth 层(此处=1),必须始终满足。
步骤 3(可选):svn rm trunk/sub2/gamma.c
再删掉移动过来的文件,op-root=trunk/sub2/gamma.c,深度 2,所以新增 op_depth = 2:
| local_relpath | op_depth | presence |
|---|---|---|
trunk/sub2/gamma.c |
0 | normal(BASE 里本来没有,见下方说明) |
trunk/sub2/gamma.c |
1 | normal(moved_here=1) |
trunk/sub2/gamma.c |
2 | base-deleted |
这里有个微妙但关键 的点:trunk/sub2/gamma.c 在 BASE 里并不存在 (BASE 里它叫 trunk/sub/gamma.c)。那为什么 op_depth=0 那一行会显示 normal?答案是:在 WC-NG 里,被移动/复制进来的节点,其"基节点行"挂在源路径下,而目标路径只有 WORKING 层行 。上面表格里为教学整齐填了一行 op_depth=0,实际存储中目标路径的可见行从 op_depth=1 起。也就是说------"某个 local_relpath 是否存在 op_depth=0 的行",取决于它在源仓库里的位置,而不取决于它现在的路径 。这正是 op_depth 定义里那句"does NOT refer to the depth of its own local_relpath"的深意。
svn revert 如何回到从前? 脚本注释自己给了答案:"An 'svn revert foo/bar' would remove the NODES of (2)."------revert 是删除高 op_depth 层 。删掉 op_depth=2 的 base-deleted 行后,op_depth=1 的 moved 行重新成为 NODES_CURRENT 里可见的那一行,"文件回来了"。这正是"中间层平时不可见,revert 后可见"的具体机制。
可运行的推演验证 SQL:
sql
-- 用 NODES_CURRENT 视图看"当前可见"的每一行(即 UI 看到的状态)
SELECT c.local_relpath, c.op_depth, c.presence, c.kind,
c.moved_here, c.moved_to, c.checksum
FROM NODES_CURRENT c
WHERE c.local_relpath LIKE 'trunk/%'
ORDER BY c.local_relpath;
-- 与上面对照:看某个路径的"全部层"(含隐藏层),验证多层共存
SELECT local_relpath, op_depth, presence, moved_here, moved_to, repos_path
FROM NODES
WHERE local_relpath IN ('trunk/sub','trunk/sub2','trunk/sub2/gamma.c')
ORDER BY local_relpath, op_depth;
-- 追踪一次移动的两端(源根 moved_to 与目标根 moved_here)
SELECT 'SRC' AS side, local_relpath, op_depth, presence, moved_to
FROM NODES WHERE moved_to = 'trunk/sub2'
UNION ALL
SELECT 'DST', local_relpath, op_depth, presence, moved_here
FROM NODES WHERE local_relpath = 'trunk/sub2';
6.3.4 为什么这套模型能同时支持稀疏、changelist、externals
回到 6.1.2 列的"表达困境",现在可以逐个回答:
- 混合修订 :不同子树处在不同
revision,在 NODES 里就是不同 BASE 行的revision不同,互不干扰。一个update只改被更新子树的 BASE 行。 - 目录 rename / move :不再是"把 copyfrom 复制进每个子目录",而是"源根一行
moved_to+ 目标子树每行moved_here"。一棵树的移动在数据库里是一组同 op_depth 的插入 + 源侧一组 base-deleted 插入,一个事务完成。 - 稀疏(sparse) :用
presence='excluded'(BASE 层)表达"用户主动排除",beats 早期的depth='exclude'(format 15 才换过来)。 - changelist :写在
ACTUAL_NODE.changelist列,一个字符串;配合(历史上存在过的)I_ACTUAL_CHANGELIST索引,可以快速按 changelist 聚合------索引在 format 31 被删掉(见 6.2.12),说明实践中已不再依赖索引扫描。 - externals :独立的
EXTERNALS表,用def_*四列描述外部定义,presence用MAP_NORMAL/MAP_EXCLUDED区分"可用 / 用户排除"。
6.3.5 PRISTINE 与内容寻址:为什么是 SHA-1,为什么还要 md5
为什么按 SHA-1 存储? 三个理由:
- 去重 :
checksum做PRIMARY KEY,同一份内容在一个 WC 里只存一份文件。例如 100 个目录各有一份内容相同的LICENSE,pristine 里只有 1 行、磁盘上只有 1 份。 - 可校验 :
size列 + SHA-1 本身构成"这份 pristine 文件是不是写坏了"的校验(表头注释:"Used to verify the pristine file is 'proper'.")。 - 可寻址:文件路径由 checksum 直接推导,无需额外的"路径→内容"映射表。
为什么还要 md5_checksum 并存? 注释说得很直接:"Alternative MD5 checksum used for communicating with older repositories."------因为 1.6 及更早的仓库/客户端用 MD5 标识内容;当新客户端与老服务器/老数据交互时,需要 MD5。它的"不保证唯一"("Not strictly guaranteed to be unique among table rows")使得它只能建普通索引,不能做主键。
refcount 如何被维护? 全部靠 6.2.9 的三个触发器(nodes_insert_trigger / nodes_delete_trigger / nodes_update_checksum_trigger)。这里再点一句关键:refcount 只统计 NODES 表的引用 ,不统计 ACTUAL_NODE 的三列冲突 checksum。这是设计者明知代价后的取舍------代价是"清理 pristine 时要额外扫描冲突 skel",收益是"触发器能自动维护 NODES 侧的计数"。1.15 的 TEXTBASE_REFS 表(见 6.5)把"文本基内容的引用"单独列出来,正是为了让"脱水(dehydrate)"有一个精确、可触发维护的判据。
一个可运行的"稀缺内容"查询 ------找出只被引用一次的 pristine(这些内容的去重收益为 0,是 pristineless 下最值得"不存本地"的候选):
sql
-- refcount = 1 的 pristine(存了但没人共享)
SELECT COUNT(*) AS single_ref,
SUM(size) AS bytes,
printf('%.1f MB', SUM(size) / 1048576.0) AS mb
FROM PRISTINE
WHERE refcount = 1;
再一个 :按"压缩类型"分组,验证现实中 compression 是否恒为 NULL:
sql
SELECT compression, COUNT(*) AS rows
FROM PRISTINE
GROUP BY compression;
在 1.15 上这个查询几乎总是返回一行 NULL | <n>,印证了注释"the only value supported so far is NULL"。这样一段 SQL 也是检验源码结论的好办法:文档说"只支持 NULL",那就用数据去证伪或证实。
6.3.6 kind、depth、revision 三者的分工
kind:节点的类型 (file/dir/symlink/unknown),与svn_node_kind_t对应。注意它可以是unknown(节点不存在时)。depth:检出的深度 (BASE 概念)。取值映射到svn_depth_t(svn_depth_empty/files/immediates/infinity,以及unknown)。NODES.depth为 NULL 时代表"默认"(通常为 infinity)。revision:BASE 节点是"它属于哪个修订",WORKING 节点是"copyfrom 的源修订"。
三者组合能回答"这个路径是不是一个检出在 depth=immediates 的目录、内容是哪个 revision 的类型为 dir 的节点"。
下一节进入 1.15 的两大头条之一:pristineless。
6.4 pristineless:让"本地原始内容"变成可选
6.4.1 动机与发布说明
CHANGES 的 1.15.0 小节把 pristineless 列为头号新特性:
sql
- Major new features:
* New 'pristineless' mode for working copies that eliminates overhead
from storing cached pristine content (r1905955, issue #525)
* Support for multiple working copy formats (r1898187, issue #4883)
"pristineless"这个名字有点误导:它并不是"完全没有 pristine",而是"pristine 内容不默认在本地保留一份 "。用 CLI 帮助里的话说,就是"Configure the working copy to either store local copies of pristine contents ('yes') or to fetch them on demand ('no')"(svn.c#L373-L382)。
为什么要做这件事? 传统 WC 为每个版本化文件在 .svn/pristine/ 下缓存一份完整原始内容。对于大型工作副本(比如包含大量二进制资源、或同一内容反复出现的仓库),pristine store 的体积可以与工作文件本身相当甚至更大。pristineless 允许用户按需从服务器拉取 原始内容,从而把磁盘占用降下来------代价是 svn diff、svn revert 等命令在需要 pristine 时可能要联网。
6.4.2 常量:格式 32 的两把钥匙
wc.h 里与 pristineless 直接相关的常量有三处:
c
/* The newest WC format this client supports.
*
* @see svn_wc__max_supported_format()
*/
#define SVN_WC__VERSION 32
/* The minimum WC version supported by the client.
*
* @see svn_wc__min_supported_format()
*/
/* IMPORTANT: Update the implementation of svn_client_default_wc_version()
and svn_client_get_wc_formats_supported()
and svntest.main.wc_format()
whenever you change this value! */
#define SVN_WC__SUPPORTED_VERSION 31
/* The default WC version that the Subversion library should create
* (or upgrade to) when not otherwise specified. */
#define SVN_WC__DEFAULT_VERSION SVN_WC__SUPPORTED_VERSION
以及两个"从某个版本起可用"的特性门:
c
/* Starting from this version, pristine content is optional and can be
* fetched on demand. */
#define SVN_WC__HAS_OPTIONAL_PRISTINE 32
/* Starting from this version, the DB stores per-WC settings. */
#define SVN_WC__HAS_SETTINGS 32
关键观察:
SVN_WC__VERSION = 32是最新支持格式 ;SVN_WC__SUPPORTED_VERSION = 31是最老支持格式。SVN_WC__DEFAULT_VERSION = SVN_WC__SUPPORTED_VERSION = 31。这意味着默认新建/升级出的 WC 是格式 31(1.8 兼容) ,而不是 32。想要 pristineless,必须显式请求格式 32(即--compatible-version=1.15)且--store-pristine=no。SVN_WC__HAS_OPTIONAL_PRISTINE = 32:只有格式 ≥ 32 才支持"pristine 可选";SVN_WC__HAS_SETTINGS = 32:只有格式 ≥ 32 才有 per-WC 设置表。这两把钥匙锁住了 pristineless 的门。
6.4.3 STMT_UPGRADE_TO_32:格式 32 带来的四处 SQL 变化
wc-metadata.sql 的 STMT_UPGRADE_TO_32 段头注释把新增内容列得一清二楚:
sql
/* Format 32 adds support for optional pristine contents with the
following schema changes:
- Add the 'hydrated' column to the PRISTINE table.
- Add the I_PRISTINE_UNREFERENCED index.
- Add the TEXTBASE_REFS table.
- Add the SETTINGS table. */
-- STMT_UPGRADE_TO_32
/* True iff the pristine contents are currently available on disk. */
ALTER TABLE PRISTINE ADD COLUMN hydrated INTEGER NOT NULL DEFAULT 1;
CREATE INDEX I_PRISTINE_UNREFERENCED ON PRISTINE (refcount, refcount=0);
/* This table contains references to the on disk text-base contents.
Every row corresponds to a row in NODES table with the same key.
While a row is present is this table, the contents identified by the
corresponding NODES.checksum cannot be dehydrated from the pristine store.
*/
CREATE TABLE TEXTBASE_REFS (
/* Same key columns as in the NODES table */
wc_id INTEGER NOT NULL,
local_relpath TEXT NOT NULL,
op_depth INTEGER NOT NULL,
PRIMARY KEY (wc_id, local_relpath, op_depth)
);
逐项解读:
-
PRISTINE.hydrated(INTEGER NOT NULL DEFAULT 1) :注释直译为"True iff the pristine contents are currently available on disk",即"原始内容当前是否真的在磁盘上 "。DEFAULT 1让所有已存在的老行在升级时自动补成"已水合"------因为升级前它们必然在磁盘上有文件。这个默认值的选择非常关键:它把"升级"变成一次无损操作,不需要回填任何数据。顺带一提,"hydrate/dehydrate"(水合/脱水)是 Subversion 给"本地保留 pristine 内容 ↔ 从服务器按需拉取"这对操作起的比喻名。C 侧的对应函数是
svn_wc__db_pristine_dehydrate()(wc_db_pristine.c#L815)与svn_wc__db_pristine_dehydrate_internal()(wc_db_pristine.c#L837)。 -
I_PRISTINE_UNREFERENCED ON PRISTINE (refcount, refcount=0):一个表达式索引 (refcount=0是 SQLite 的表达式索引写法)。它的目的是让"找出未引用的 pristine "这个操作(--vacuum-pristines/ 脱水决策都要用)变得廉价。为什么会出现在"可选 pristine"这个 format 里?因为"判断某份内容是否可以脱水/回收"要频繁问"还有没有引用",这个索引就是为频繁查询准备的。 -
TEXTBASE_REFS表:这是最精妙的一处。注释说得很清楚:- "Every row corresponds to a row in NODES table with the same key."(每一行对应 NODES 表里同主键的一行。)主键同样是
(wc_id, local_relpath, op_depth)。 - "While a row is present is this table, the contents identified by the corresponding NODES.checksum cannot be dehydrated from the pristine store."(只要这行还在,它对应的
NODES.checksum所指内容就不能被脱水。)
换句话说,
TEXTBASE_REFS是"这些路径需要一份磁盘上的文本基 "的登记簿。它把"哪些 NODES 行真正需要 pristine 内容在本地"与"哪些只是历史引用"区分开来。为什么需要这样一张表?因为**PRISTINE.refcount统计的是 NODES 引用数,但并不是每个 NODES 引用都要求本地有一份文件**。比如一个被删除的历史层,它的checksum仍然让refcount非零,但它的内容可能早已不需要------TEXTBASE_REFS让"脱水"能精确到"谁真的需要"。 - "Every row corresponds to a row in NODES table with the same key."(每一行对应 NODES 表里同主键的一行。)主键同样是
-
SETTINGS表:sql/* This table contains settings of a working copy, identified by WC_ID. */ CREATE TABLE SETTINGS ( wc_id INTEGER NOT NULL REFERENCES WCROOT (id) PRIMARY KEY, store_pristine INTEGER ); /* Migrate existing working copy settings. */ INSERT OR IGNORE INTO SETTINGS SELECT id, 1 FROM WCROOT; PRAGMA user_version = 32;wc_id做主键,store_pristine INTEGER是"是否本地存储 pristine"的设置。- 迁移语句
INSERT OR IGNORE INTO SETTINGS SELECT id, 1 FROM WCROOT:把所有已存在的 WCROOT 行 都补一条store_pristine = 1的设置------即老 WC 默认为"存储"。 PRAGMA user_version = 32把格式号写成 32。
6.4.4 格式 32 还改写了两个触发器
STMT_UPGRADE_TO_32 在加了 TEXTBASE_REFS 之后,重建了两个触发器 ,让它们在维护 refcount 的同时清理 textbase_refs:
sql
DROP TRIGGER nodes_delete_trigger;
CREATE TRIGGER nodes_delete_trigger
AFTER DELETE ON nodes
WHEN OLD.checksum IS NOT NULL
BEGIN
UPDATE pristine SET refcount = refcount - 1
WHERE checksum = OLD.checksum;
DELETE FROM textbase_refs
WHERE wc_id = OLD.wc_id
AND local_relpath = OLD.local_relpath
AND op_depth = OLD.op_depth;
END;
DROP TRIGGER nodes_update_checksum_trigger;
CREATE TRIGGER nodes_update_checksum_trigger
AFTER UPDATE OF checksum ON nodes
WHEN NEW.checksum IS NOT OLD.checksum
/* AND (NEW.checksum IS NOT NULL OR OLD.checksum IS NOT NULL) */
BEGIN
UPDATE pristine SET refcount = refcount + 1
WHERE checksum = NEW.checksum;
UPDATE pristine SET refcount = refcount - 1
WHERE checksum = OLD.checksum;
DELETE FROM textbase_refs
WHERE wc_id = OLD.wc_id
AND local_relpath = OLD.local_relpath
AND op_depth = OLD.op_depth;
END;
这段代码揭示了一个重要的演进规律 :每次格式升级,若语义变化影响到触发器,就必须 DROP TRIGGER 再 CREATE TRIGGER 。因为 SQLite 不支持"修改触发器",只能重建。注意 format 32 只重建了 nodes_delete_trigger 与 nodes_update_checksum_trigger,没有重建 nodes_insert_trigger ------因为插入 NODES 行时不应该 自动在 textbase_refs 里登记(是否需要"本地内容"由 hydrate/dehydrate 逻辑显式决定,而不是插入就登记)。这处不对称是刻意为之。
6.4.5 hydrate/dehydrate 的运行时逻辑
wc_db_textbase.c 的 svn_wc__db_textbase_sync() 是理解"按需拉取"的核心。它的签名里有两个开关:
c
svn_error_t *
svn_wc__db_textbase_sync(svn_wc__db_t *db,
const char *local_abspath,
svn_boolean_t allow_hydrate,
svn_boolean_t allow_dehydrate,
svn_wc__db_textbase_fetch_cb_t fetch_callback,
void *fetch_baton,
svn_cancel_func_t cancel_func,
void *cancel_baton,
apr_pool_t *scratch_pool)
它遍历一组 (checksum, hydrated, referenced, repos_relpath, repos_id, revision),核心判断是:
c
hydrated = svn_sqlite__column_boolean(stmt, 1);
referenced = svn_sqlite__column_boolean(stmt, 2);
if (!hydrated && referenced)
{
if (allow_hydrate)
{
...
/* 需要 repos_root_url + repos_relpath + revision,然后通过
fetch_callback 拉取内容 */
逻辑要点:
!hydrated && referenced:内容不在磁盘上,且 有活引用 ------ 这就是需要"水合 "的情形。如果allow_hydrate为真,它就从 DB 里取出repos_relpath、repos_id、revision,换取repos_root_url,再交给fetch_callback去服务器拉取内容。注意那几个SVN_ERR_WC_CORRUPT分支:"Unexpected entry for '%s'"------在 pristineless 模式下,如果某个"需要水合"的条目缺了仓库信息,工作副本就是损坏的,因为无法重建内容。hydrated && (!referenced) && allow_dehydrate则是反方向:内容在磁盘上但没人需要了,可以脱水。allow_hydrate/allow_dehydrate两个布尔让调用方按命令语义控制:svn diff可能允许 hydrate(需要内容)但不允许 dehydrate;后台清理则相反。
再看 wc_db_pristine.c 里的一处防御性编程(对理解"脱水为何要谨慎"很有帮助):
c
if (have_row && hydrated)
{
/* For now, ensure that we do not inadvertently dehydrate an existing
* hydrated entry, as there could be references to its content. */
注释坦白地说:"现阶段,确保我们不会不经意地脱水一个已水合的条目,因为可能有引用指向它的内容。 "------这说明 1.15.0 的脱水策略是保守的:宁可不省空间,也不破坏引用。
6.4.6 CLI 侧:--store-pristine 与 --vacuum-pristines
选项定义 (cl.h):
c
svn_boolean_t vacuum_pristines; /* remove unreferenced pristines */
...
svn_version_t *compatible_version; /* working copy compatibility version */
svn_boolean_t store_pristine;
} svn_cl__opt_state_t;
(cl.h#L270-L279。)对应的长选项枚举成员:
c
opt_vacuum_pristines,
opt_drop,
opt_viewspec,
opt_compatible_version,
opt_store_pristine
} svn_cl__longopt_t;
选项表与帮助文本 (svn.c):
c
{"vacuum-pristines", opt_vacuum_pristines, 0,
N_("remove unreferenced pristines from .svn directory")},
...
{"compatible-version", opt_compatible_version, 1,
N_("use working copy format compatible with Subversion\n"
" "
"version ARG (\"1.8\", \"1.9.5\", etc.)")},
{"store-pristine", opt_store_pristine, 1,
N_("Configure the working copy to either store local\n"
" "
"copies of pristine contents ('yes') or to fetch\n"
" "
"them on demand ('no'). Fetching on demand saves\n"
" "
"disk space, but may require network access for\n"
" "
"commands such as diff or revert. Default: 'yes'.")},
注意三个选项的参数个数 :vacuum-pristines 是 0(无参数开关);compatible-version 与 store-pristine 是 1(需要一个参数)。--store-pristine 的取值是 yes / no(在后续的 svn_cl__make_option 处理里,"yes, no" 被写进选项校验,见 svn.c 中 "--store-pristine", "yes, no")。
哪些命令暴露这些选项?
checkout:选项列表里有opt_compatible_version, opt_store_pristine(svn.c#L562-L564)。cleanup:选项列表里有opt_vacuum_pristines(svn.c#L596-L597)。upgrade:选项列表里有opt_compatible_version(svn.c#L2021)。
cleanup 的帮助文本把 --vacuum-pristines 的语义讲得很干净:
swift
" 3. If the --vacuum-pristines option is given, remove pristine copies of\n"
" files which are stored inside the .svn directory and which are no longer\n"
" referenced by any file in the working copy.\n"),
cleanup 的实现 (cleanup-cmd.c):
c
if (opt_state->remove_unversioned || opt_state->remove_ignored ||
opt_state->vacuum_pristines)
{
svn_error_t *err = svn_client_vacuum(target_abspath,
opt_state->remove_unversioned,
opt_state->remove_ignored,
TRUE /* fix_timestamps */,
opt_state->vacuum_pristines,
opt_state->include_externals,
ctx, iterpool);
...
}
else
{
svn_error_t *err = svn_client_cleanup2(target_abspath,
TRUE /* break_locks */,
TRUE /* fix_timestamps */,
TRUE /* clear_dav_cache */,
TRUE /* vacuum_pristines */,
opt_state->include_externals,
ctx, iterpool);
有两处值得注意:
- 用户显式
--vacuum-pristines时 走svn_client_vacuum(...)(这是"清理未版本化/忽略/未引用 pristine"的通用入口)。 - 普通的
svn cleanup(不带任何选项)却调用svn_client_cleanup2(...),并且 把vacuum_pristines硬编码为TRUE------也就是说,1.15 的默认svn cleanup就会顺带清理未引用的 pristine。这和 1.7/1.8 时代"cleanup 只打破锁 + fix 时间戳 + 跑 SQLite vacuum"的行为相比,多了一件事。
(历史上,CHANGES 里也有对应条目:1.8 时代 "'svn cleanup': Add --vacuum-pristines option (r1802787 et al)",以及更早的 "'svn cleanup' now runs SQLite "vacuum" to reclaim space (r1418459)"。可见 "vacuum" 一词在 SVN 里有两层含义 ------SQLite 的 VACUUM(回收数据库文件碎片)与 pristine 的 vacuum(删除未引用内容)。)
6.4.7 库层:store-pristine 设置不一致必须报错
pristineless 是一个整个工作副本级别的属性 (存在 SETTINGS.store_pristine 里),因此不能在同一个 WC 里"半开半关"。这一约束在两处被强制:
(1)新建/ensure admin 时 (adm_files.c):
c
if (target_format < SVN_WC__HAS_OPTIONAL_PRISTINE && !store_pristine)
return svn_error_createf(
SVN_ERR_WC_UNSUPPORTED_FORMAT, NULL,
_("Working copy format %d does not support the requested capabilities"),
target_format);
...
SVN_ERR(svn_wc__db_get_settings(NULL, &wc_store_pristine, db,
local_abspath, scratch_pool));
if ((store_pristine && !wc_store_pristine) ||
(!store_pristine && wc_store_pristine))
{
return svn_error_createf(
SVN_ERR_WC_INCOMPATIBLE_SETTINGS, NULL,
_("'%s' is an existing working copy with different '%s' setting"),
svn_dirent_local_style(local_abspath, scratch_pool),
"store-pristine");
}
要点:
- 格式低于 32(
target_format < SVN_WC__HAS_OPTIONAL_PRISTINE)却要求 pristineless(!store_pristine) → 直接报SVN_ERR_WC_UNSUPPORTED_FORMAT,"format %d does not support the requested capabilities"。这从入口上堵死了"用格式 31 做 pristineless"。 - 已存在的 WC 的
store_pristine与请求不一致 → 报SVN_ERR_WC_INCOMPATIBLE_SETTINGS,错误串是"\'%s\' is an existing working copy with different \'%s\' setting"(第二个参数就是"store-pristine")。
(2)checkout 到已存在目录时 (checkout.c)也有同样的一段:
c
SVN_ERR(svn_wc__get_settings(NULL, &wc_store_pristine, ctx->wc_ctx,
local_abspath, scratch_pool));
if ((target_store_pristine && !wc_store_pristine) ||
(!target_store_pristine && wc_store_pristine))
{
return svn_error_createf(
SVN_ERR_WC_INCOMPATIBLE_SETTINGS, NULL,
_("'%s' is an existing working copy with different '%s' setting"),
svn_dirent_local_style(local_abspath, scratch_pool),
"store-pristine");
}
target_store_pristine 是怎么算出来的? svn_client__checkout_internal() 接收一个 svn_tristate_t store_pristine(三态:unknown/true/false),然后:
c
if (store_pristine == svn_tristate_unknown)
target_store_pristine = TRUE;
else if (store_pristine == svn_tristate_true)
target_store_pristine = TRUE;
else
target_store_pristine = FALSE;
也就是说:unknown(用户没给 --store-pristine)默认当作 TRUE(存储)------与帮助文本的 "Default: 'yes'" 一致。
再看一处自动抬升格式的逻辑:
c
SVN_ERR(svn_client_default_wc_version(&target_format_version, ctx,
scratch_pool, scratch_pool));
if (!target_store_pristine)
{
const svn_version_t *required_version =
svn_client__compatible_wc_version_optional_pristine(scratch_pool);
if (!svn_version__at_least(target_format_version,
required_version->major,
required_version->minor,
required_version->patch))
target_format_version = required_version;
}
它的意思是:如果用户要求 pristineless(!target_store_pristine),但默认 WC 版本还不够新,就把目标版本抬到 svn_client__compatible_wc_version_optional_pristine() 返回的版本 ------而这个函数返回的正是 {1, 15, 0}(libsvn_client/upgrade.c#L344-L351)。换句话说:想要 pristineless,就必须至少把 WC 建成 1.15 格式(即 32)。
6.4.8 格式 31 与格式 32 的差异对照(SQL 实证)
下表总结 pristineless 引入的可观察差异------都可以用 SQL 直接验证:
| 观察点 | format 31(1.8--1.14 格式) | format 32(1.15 格式) |
|---|---|---|
PRAGMA user_version |
31 |
32 |
PRISTINE.hydrated 列 |
不存在 | 存在,INTEGER NOT NULL DEFAULT 1 |
PRISTINE 索引 |
只有 I_PRISTINE_MD5 |
多了 I_PRISTINE_UNREFERENCED (refcount, refcount=0) |
TEXTBASE_REFS 表 |
不存在 | 存在,主键 (wc_id, local_relpath, op_depth) |
SETTINGS 表 |
不存在 | 存在,wc_id PK + store_pristine |
nodes_delete_trigger / nodes_update_checksum_trigger |
只维护 pristine.refcount |
额外 DELETE FROM textbase_refs |
对应的实证查询:
sql
-- 1) 确认格式号
PRAGMA user_version;
-- 2) 确认 PRISTINE 是否具备 hydrated 列(在 format 31 上会报 no such column)
SELECT COUNT(*) AS total,
SUM(hydrated) AS hydrated_count,
SUM(1 - hydrated) AS dehydrated_count
FROM PRISTINE;
-- 3) 确认 TEXTBASE_REFS 存在及其规模(format 32 才有这张表)
SELECT COUNT(*) AS textbase_ref_rows FROM TEXTBASE_REFS;
-- 4) 确认 SETTINGS 的 store_pristine 取值(1=存储、0=按需拉取)
SELECT wc_id, store_pristine FROM SETTINGS;
-- 5) 找出"已脱水"(hydrated=0)但仍被引用(refcount>0)的 pristine
SELECT p.checksum, p.refcount, p.size
FROM PRISTINE p
WHERE p.hydrated = 0 AND p.refcount > 0
ORDER BY p.size DESC
LIMIT 20;
查询 (2) 与 (3) 会在 format 31 的工作副本上直接报错 ("no such column: hydrated" / "no such table: TEXTBASE_REFS"),这本身就是"该 WC 是格式 31"的铁证------用报错来探测表结构,是判断 WC 格式最直接的手段。
再补一句 TEXTBASE_REFS 的实战查询------哪些路径真正"占着"本地文本基:
sql
-- 路径 → 它登记的文本基引用(并与 PRISTINE 的 hydrated 状态联结)
SELECT t.local_relpath, t.op_depth, n.checksum, p.hydrated, p.size
FROM TEXTBASE_REFS t
LEFT JOIN NODES n
ON n.wc_id = t.wc_id
AND n.local_relpath = t.local_relpath
AND n.op_depth = t.op_depth
LEFT JOIN PRISTINE p ON p.checksum = n.checksum
ORDER BY t.local_relpath
LIMIT 50;
下一节讲 1.15 的另一大头条:多 WC 格式支持。
6.5 多 WC 格式支持:读写相邻两代
CHANGES 把多 WC 格式支持列为 1.15 的第二大特性(r1898187, issue #4883)。在此之前,一个客户端只会读写自己这一代 的格式:1.8 客户端只会做格式 31,1.7 只会做 29。多格式支持要让一个客户端能识别、创建、升级到多个格式,并让用户显式选择。
6.5.1 svn_wc__format_from_version:版本号 → 格式号
核心映射函数是 svn_wc__format_from_version()(upgrade.c#L1644-L1685):
c
svn_error_t *
svn_wc__format_from_version(int *format,
const svn_version_t* version,
apr_pool_t *scratch_pool)
{
if (!version)
{
*format = SVN_WC__DEFAULT_VERSION;
return SVN_NO_ERROR;
}
if (version->major != SVN_VER_MAJOR || version->minor > SVN_VER_MINOR)
return svn_error_createf(SVN_ERR_UNSUPPORTED_FEATURE, NULL,
_("Can't determine working copy format "
"for Subversion %d.%d.%d"),
version->major, version->minor, version->patch);
switch (version->minor)
{
case 0: /* Same as 1.3.x. */
case 1: /* Same as 1.3.x. */
case 2: /* Same as 1.3.x. */
case 3: *format = 4; break;
case 4: *format = 8; break;
case 5: *format = 9; break;
case 6: *format = 10; break;
case 7: *format = 29; break;
case 8: /* Same as 1.14.x. */
case 9: /* Same as 1.14.x. */
case 10: /* Same as 1.14.x. */
case 11: /* Same as 1.14.x. */
case 12: /* Same as 1.14.x. */
case 13: /* Same as 1.14.x. */
case 14: *format = 31; break;
case 15: /* Same as the current version. */
default: *format = SVN_WC__VERSION; break;
}
return SVN_NO_ERROR;
}
三点解读:
version == NULL→SVN_WC__DEFAULT_VERSION(=31) :这解释了"为什么 1.15 客户端默认建出 1.8 兼容格式"------因为SVN_WC__DEFAULT_VERSION就是被定义成SVN_WC__SUPPORTED_VERSION。- 多个 minor 映射到同一格式 :1.8--1.14 全部 → 31;1.10/1.11 等注释统一写 "Same as 1.14.x.";1.15(case 15 / default)→ 32。这就是"格式不是每个版本都变,而是隔几个版本才 bump 一次"的体现。
- 越界拒绝 :
version->major != SVN_VER_MAJOR || version->minor > SVN_VER_MINOR时直接报SVN_ERR_UNSUPPORTED_FEATURE,错误串"Can't determine working copy format for Subversion %d.%d.%d"。也就是说,你不能请求一个"比当前客户端还新的版本号"的格式。
6.5.2 支持范围:为什么是"相邻两代"
c
svn_boolean_t
svn_wc__is_supported_format(int format)
{
return format >= SVN_WC__SUPPORTED_VERSION && format <= SVN_WC__VERSION;
}
int
svn_wc__max_supported_format(void)
{
return SVN_WC__VERSION;
}
int
svn_wc__min_supported_format(void)
{
return SVN_WC__SUPPORTED_VERSION;
}
绝对值上,1.15 支持 [31, 32] 两个格式------31(1.8--1.14)与 32(1.15) 。这是一个宽度为 2 的窗口 :客户端能读写的,是"当前格式"与"上一代格式"。社区里常说的"读写 N-1 代"就是这个意思。这么设计的现实收益是:从 1.15 客户端升级到更新的版本时,无需立即强制升级所有工作副本;旧的(N-1)WC 也能被新时代客户端继续操作,直到用户决定升级。
对应的版本级判断:
c
svn_boolean_t
svn_wc__is_supported_format_version(const svn_version_t *version)
{
return (version->major == 1
&& (version->minor >= 8 && version->minor <= 15));
}
const svn_version_t *
svn_wc__max_supported_format_version(void)
{
/* NOTE: For consistency, always return the version
that first introduced the latest supported format. */
static const svn_version_t version = { 1, 15, 0, NULL };
return &version;
}
const svn_version_t *
svn_wc__min_supported_format_version(void)
{
/* NOTE: For consistency, always return the version
that first introduced the earliest supported format. */
static const svn_version_t version = { 1, 8, 0, NULL };
return &version;
}
注意两条 BIG NOTE :返回的版本号一律是该格式首次被引入的那个 Subversion 版本 ,而不是"当前客户端版本"。所以 min_supported_format_version() 返回 {1,8,0}(格式 31 首次出现于 1.8),max_supported_format_version() 返回 {1,15,0}(格式 32 首次出现于 1.15)。这种"用首引版本命名格式"的约定,让用户看到的选项始终稳定(--compatible-version=1.8 永远指格式 31,不会随客户端版本漂移)。
6.5.3 客户端层:支持列表与默认值
libsvn_client/upgrade.c 里有一个"格式号 → 版本号"的反查 switch:
c
case 4: return &version_1_0;
case 8: return &version_1_4;
case 9: return &version_1_5;
case 10: return &version_1_6;
case 29: return &version_1_7;
case 31: return &version_1_8;
case 32: return &version_1_15;
(libsvn_client/upgrade.c#L263-L273。)
然后是"本客户端支持哪些格式":
c
const int *
svn_client_get_wc_formats_supported(apr_pool_t *result_pool)
{
static const int versions[] = {
SVN_WC__SUPPORTED_VERSION,
SVN_WC__VERSION,
0
};
return versions;
}
(libsvn_client/upgrade.c#L276-L286。)
它返回 {31, 32, 0}(以 0 结尾的哨兵数组)。svn --version 的帮助实现 help-cmd.c 就调用这个函数来列出"Compatible working copy formats"(help-cmd.c#L50)。checkout 与 upgrade 的帮助文本都用同一句话指向它:"Use 'svn --version' to see the compatible versions supported."(svn.c#L536、svn.c#L2011)。
默认 WC 版本取自配置项:
c
svn_config_get(config, &value,
SVN_CONFIG_SECTION_WORKING_COPY,
SVN_CONFIG_OPTION_COMPATIBLE_VERSION,
NULL);
if (value)
{
SVN_ERR(svn_version__parse_version_string(&version, value, result_pool));
}
else
{
version = apr_pcalloc(result_pool, sizeof(*version));
version->major = 1;
version->minor = 8;
version->patch = 0;
version->tag = NULL;
}
(libsvn_client/upgrade.c#L312-L329。)
也就是说:用户可以写一个配置项 wc-compatible-version(即 SVN_CONFIG_OPTION_COMPATIBLE_VERSION)来改变默认值;没配就是 1.8(格式 31)。 类似地,svn --version --verbose 里会显示 wc-compatible-version 与 store-pristine 两个配置项名(见 svn.c 第 828 行附近)。
最后是"最老 / 最新"的来源函数:
c
const svn_version_t *
svn_client_oldest_wc_version(apr_pool_t *result_pool)
{
/* NOTE: For consistency, always return the version of the client
that first introduced the format. */
static const svn_version_t version = { 1, 8, 0, NULL };
return &version;
}
...
const svn_version_t *
svn_client_latest_wc_version(apr_pool_t *result_pool)
{
static const svn_version_t version = { 1, 15, 0, NULL };
return &version;
}
const svn_version_t *
svn_client__compatible_wc_version_optional_pristine(apr_pool_t *result_pool)
{
static const svn_version_t version = { 1, 15, 0, NULL };
return &version;
}
(libsvn_client/upgrade.c#L288-L351。)
svn_client__compatible_wc_version_optional_pristine() 返回 {1,15,0}------它就是 6.4.7 里"想 pristineless 就自动抬到 1.15"所用的那个函数。可见 pristineless 与多格式支持这两个特性在实现上是耦合的:pristineless 需要格式 32,而"请求格式 32"要走多格式支持的选版机制。
6.5.4 upgrade 的边界
svn_wc__upgrade_sdb() 在动手前先设了一道闸:
c
if (start_format < SVN_WC__WC_NG_VERSION /* 12 */)
return svn_error_createf(SVN_ERR_WC_UPGRADE_REQUIRED, NULL,
_("Working copy '%s' is too old (format %d, ...
即:格式早于 12(SVN_WC__WC_NG_VERSION,也就是"前 WC-NG/entries 时代")的工作副本,必须先被更老的客户端升到 12 的 intermediate 状态,现代客户端不再直接支持 。这呼应了 svn upgrade 帮助里的"Only upgrades are supported, not downgrades."(svn.c#L2013)------升级是单向的,不允许降级。
6.6 work queue:先记日志,后执行
6.6.1 问题:DB 事务与文件系统无法同事务
工作副本操作是"跨介质"的:既要改数据库(NODES/ACTUAL_NODE),又要改文件系统(重命名、复制、删除、安装文件、设只读位)。SQLite 事务只能覆盖数据库一侧;如果在事务提交后进程崩溃,或文件操作只做了一半,工作副本就会"数据库说已经改了,磁盘上却没改"。
WC-NG 的解法是 write-ahead 风格的工作队列 :在同一个数据库事务里,把"待执行的文件系统副作用"序列化成 work item 写进 WORK_QUEUE 表;事务提交成功后,才按序取出执行。这样:
- 崩溃发生在"事务提交前" → 什么都没有发生,安全;
- 崩溃发生在"提交后、执行前" →
WORK_QUEUE里还留着 work item,后续svn cleanup可以重放; - 崩溃发生在"执行中" → 部分 work item 已完成,剩下的仍可重放。
这就是为什么它是"先记日志后执行"(本质上是把文件系统副作用也纳入"可重放日志")。
6.6.2 WORK_QUEUE 表与 work item 种类
sql
CREATE TABLE WORK_QUEUE (
/* Work items are identified by this value. */
id INTEGER PRIMARY KEY AUTOINCREMENT,
/* A serialized skel specifying the work item. */
work BLOB NOT NULL
);
work 是一个序列化 skel ,其第一个 atom 是操作名。workqueue.c 顶部集中定义了全部操作名(workqueue.c#L47-L64):
c
#define OP_FILE_COMMIT "file-commit"
#define OP_FILE_INSTALL "file-install"
#define OP_FILE_REMOVE "file-remove"
#define OP_FILE_MOVE "file-move"
#define OP_FILE_COPY_TRANSLATED "file-translate"
#define OP_SYNC_FILE_FLAGS "sync-file-flags"
#define OP_PREJ_INSTALL "prej-install"
#define OP_DIRECTORY_REMOVE "dir-remove"
#define OP_DIRECTORY_INSTALL "dir-install"
#define OP_POSTUPGRADE "postupgrade"
/* Legacy items */
#define OP_BASE_REMOVE "base-remove"
#define OP_RECORD_FILEINFO "record-fileinfo"
#define OP_TMP_SET_TEXT_CONFLICT_MARKERS "tmp-set-text-conflict-markers"
#define OP_TMP_SET_PROPERTY_CONFLICT_MARKER "tmp-set-property-conflict-marker"
注意注释 /* Legacy items */------后四个是历史遗留 类型,不再被新代码创建,但出于兼容仍必须能执行(因为老 WC 的队列里可能还留着它们)。
分派表 把操作名映射到处理函数(workqueue.c#L1429-L1451):
c
static const struct work_item_dispatch dispatch_table[] = {
{ OP_FILE_COMMIT, run_file_commit },
{ OP_FILE_INSTALL, run_file_install },
{ OP_FILE_REMOVE, run_file_remove },
{ OP_FILE_MOVE, run_file_move },
{ OP_FILE_COPY_TRANSLATED, run_file_copy_translated },
{ OP_SYNC_FILE_FLAGS, run_sync_file_flags },
{ OP_PREJ_INSTALL, run_prej_install },
{ OP_DIRECTORY_REMOVE, run_dir_remove },
{ OP_DIRECTORY_INSTALL, run_dir_install },
/* Upgrade steps */
{ OP_POSTUPGRADE, run_postupgrade },
/* Legacy workqueue items. No longer created */
{ OP_BASE_REMOVE, run_base_remove },
{ OP_RECORD_FILEINFO, run_record_fileinfo },
{ OP_TMP_SET_TEXT_CONFLICT_MARKERS, run_set_text_conflict_markers },
{ OP_TMP_SET_PROPERTY_CONFLICT_MARKER, run_set_property_conflict_marker },
/* Sentinel. */
{ NULL }
};
各 work item 的语义(结合源码注释):
file-commit:提交后安装新的文本基(install_committed_file,见workqueue.c#L206-L298),必要时重译工作文件、设可执行位/只读位。构造器svn_wc__wq_build_file_commit()(workqueue.c#L386-L402)。file-install:安装一个新创建的/更新的工作文件,可选地顺带记录 fileinfo(record_fileinfo)。file-remove/dir-remove:删除文件 / 目录(dir-remove可选递归)。file-move:把一个文件/目录从src移到dst。file-translate:把src翻译后复制到dst(EOL/关键字)。sync-file-flags:按属性同步可执行位/只读位。prej-install:安装属性冲突的.prej文件;SVN_WC__PROP_REJ_EXT定义为".prej"(wc.h#L46)。dir-install:创建目录。postupgrade:升级收尾步骤。- 四个 legacy:
base-remove(删除 BASE 节点)、record-fileinfo(记录 size/mtime)、tmp-set-text-conflict-markers/tmp-set-property-conflict-marker(旧式冲突标记安装)。
对应的构造器 API 在 workqueue.h:svn_wc__wq_build_file_install、svn_wc__wq_build_file_remove、svn_wc__wq_build_dir_remove、svn_wc__wq_build_file_move、svn_wc__wq_build_file_copy_translated、svn_wc__wq_build_sync_file_flags、svn_wc__wq_build_prej_install、svn_wc__wq_build_file_commit、svn_wc__wq_build_dir_install、svn_wc__wq_build_postupgrade(workqueue.h#L109-L222)。
6.6.3 队列的增删查:wc_db.h 的三函数
WORK_QUEUE 的三个数据库操作(wc_db.h#L3007-L3045):
svn_wc__db_wq_add():把 work item 加入队列(通常在同一事务里与 NODES 变更一起)。svn_wc__db_wq_fetch_next():取下一个 work item(按id顺序),并在同一事务里 把上一个已完成的id之前的所有项标记为完成(删除)。svn_wc__db_wq_record_and_fetch_next():fetch_next的变体,在同一事务里 顺带把record_map(本次处理的fileinfo)写回 NODES(translated_size/last_mod_time)。
注释对 record_and_fetch_next 的描述是:"Special variant of svn_wc__db_wq_fetch_next(), which in the same transaction ... "------它的存在是为了把"处理 work item 时发现的文件信息"与"推进队列"合并成一个事务,避免额外往返。
队列对记录 fileinfo 的那个 map 由 work_item_baton_t 携带(record_map 是 const char * -> svn_wc__db_fileinfo_t 的哈希,见 workqueue.c#L69-L76)。
6.6.4 主循环:崩溃恢复的关键细节
svn_wc__wq_run()(workqueue.c#L1509-L1599)是执行循环。有两个为崩溃恢复量身定做的细节:
细节一:先把上一个 id 标记完成,再处理下一个。 循环注释反复强调:
c
/* Make sure to do this *early* in the loop iteration. There may
be a LAST_ID that needs to be marked as completed, *before* we
start worrying about anything else. */
也就是说:只有当上一个 work item 处理成功后,才在下一轮把它从队列里删掉 (last_id = id 只在 dispatch 无错后设置,workqueue.c#L1592-L1594)。这种"完成后才确认"的顺序,保证了"要么已执行且已出队,要么仍在队列里可重放"。
细节二:可重放性是被显式测试的。 分派函数里有一段被 SVN_RUN_WORK_QUEUE_TWICE 宏包住的代码:
c
#ifdef SVN_RUN_WORK_QUEUE_TWICE
/* Being able to run every workqueue item twice is one
requirement for workqueues to be restartable. */
SVN_ERR((*scan->func)(wqb, db, work_item, wri_abspath,
cancel_func, cancel_baton,
scratch_pool));
#endif
注释说得很直白:"能把每个 work item 跑两遍,是 workqueue 可重启(restartable)的必要条件。" 这是"幂等性"要求的直接证据------重放一个 work item 不应产生副作用差异。
细节三:遇到未知 work item 必须报错,而不是忽略。 当分派表里找不到匹配时:
c
if (scan->name == NULL)
{
/* We should know about ALL possible work items here. If we do not,
then something is wrong. Most likely, some kind of format/code
skew. There is nothing more we can do. Erasing or ignoring this
work item could leave the WC in an even more broken state.
Contrary to issue #1581, we cannot simply remove work items and
continue, so bail out with an error. */
return svn_error_createf(SVN_ERR_WC_BAD_ADM_LOG, NULL,
_("Unrecognized work item in the queue"));
}
这段注释提到 issue #1581 ,并解释说"与 #1581 相反,我们不能简单地删除 work item 然后继续"------这是对早期"丢弃坏日志"做法的明确否决 ,因为丢弃可能让 WC 更不一致。错误码是 SVN_ERR_WC_BAD_ADM_LOG。当 dispatch 抛错时,整个 wq_run 会带上下文报错:
c
return svn_error_createf(SVN_ERR_WC_BAD_ADM_LOG, err,
_("Failed to run the WC DB work queue "
"associated with '%s', work item %d %s"),
...
6.6.5 一次 update/commit 逐步写了哪些表
把前面各表串起来,一次 svn update 在后端的落库过程大致是:
-
进入编辑驱动 :对每个受影响的目录,用
svn_wc__db_temp_op_start_directory_update()(wc_db.h#L3282-L3292)登记"这个目录正在被更新",结束时svn_wc__db_temp_op_end_directory_update()。这保证了"更新到一半被打断"时,目录可以被识别为不完整。 -
增删 BASE 节点 :新增的目录/文件/符号链接分别用
svn_wc__db_base_add_directory()、svn_wc__db_base_add_file()、svn_wc__db_base_add_symlink()写入;被删除的节点用svn_wc__db_base_add_not_present_node()或svn_wc__db_base_add_excluded_node();首轮未填充完整的目录用svn_wc__db_base_add_incomplete_directory()(wc_db.h#L458-L699)。 -
写入文件内容(pristine + working) :这是 1.15 的优化点。
update_editor.c里的注释明确写道:c/* Configure the incoming data to be written to both the pristine and the provisioned working file. This allows us to properly stream data and avoid having to re-read and copy the contents of the pristine file as some post-op. Streaming is essential if we want the underlying transport such as HTTP to work without timeouts and without any limits on the sizes of the directories that can be checked out. */(
update_editor.c#L3813-L3818。)这就是
CHANGES里 "Optimize checkout, update and export by simultaneously streaming data to both pristine and working files (r1886490 et al)" 的实现落点。同时它决定本次是否需要 本地文本基:hydrated的取值逻辑为------cif (fb->shadowed || fb->obstruction_found || fb->edit_obstructed) hydrated = TRUE; else if (fb->adding_file && !fb->add_existed) /* Clean new file, hint that we don't need the text-base. */ hydrated = FALSE; else hydrated = TRUE;(
update_editor.c#L3769-L3783。)注意adding_file && !add_existed时把hydrated设为 FALSE------纯新增的文件可以不带本地文本基(因为它的内容就在工作文件里)。 -
排队文件系统副作用 :把需要随后执行的动作(安装文件、移动、改属性位等)构造为 work item 并加入
WORK_QUEUE。 -
更新修订号 :全部处理完后,用
svn_wc__db_op_bump_revisions_post_update()(wc_db.h#L2635)把待提交的 revision 提升为新的 BASE revision。 -
提交事务:SQLite 事务提交,此时 NODES/ACTUAL_NODE/WORK_QUEUE 一并生效。
-
运行队列 :调用
svn_wc__wq_run()执行文件系统副作用,逐个确认出队。
commit 的关键不同 :提交是从本地往仓库发,落库发生在"服务器接受了提交"之后。新增/修改进 BASE 是通过把 WORKING 层合并为新的 BASE 完成;文件内容的"新文本基"安装被表达为 file-commit work item(见 6.6.2 的 install_committed_file)。
可运行的队列观测 SQL:
sql
-- 查看当前队列里待执行的 work item(id 顺序即执行顺序)
SELECT id, work FROM WORK_QUEUE ORDER BY id;
-- 统计队列长度(>0 说明上次操作没跑完队列,可用 svn cleanup 续跑)
SELECT COUNT(*) AS pending FROM WORK_QUEUE;
pending > 0 通常意味着"上次操作被中断"。此时 svn cleanup(不带选项)会调用 svn_client_cleanup2(..., vacuum_pristines=TRUE, ...) 去续跑队列并弯腰拔锁------这就是 6.4.6 里那段代码的实际意义。
下一节处理冲突、以及本章其余概念与开发工具。
6.7 冲突、其他 WC 概念与开发工具
6.7.1 三类冲突:文本 / 属性 / 树
冲突统一存储在 ACTUAL_NODE.conflict_data 的 skel 里(见 6.2.5)。conflicts.h 定义了类型、操作与来源常量(conflicts.h#L41-L52):
c
#define SVN_WC__CONFLICT_OP_UPDATE "update"
#define SVN_WC__CONFLICT_OP_SWITCH "switch"
#define SVN_WC__CONFLICT_OP_MERGE "merge"
#define SVN_WC__CONFLICT_OP_PATCH "patch"
#define SVN_WC__CONFLICT_KIND_TEXT "text"
#define SVN_WC__CONFLICT_KIND_PROP "prop"
#define SVN_WC__CONFLICT_KIND_TREE "tree"
#define SVN_WC__CONFLICT_KIND_REJECT "reject"
#define SVN_WC__CONFLICT_KIND_OBSTRUCTED "obstructed"
#define SVN_WC__CONFLICT_SRC_SUBVERSION "subversion"
构造与管理 skel 的 API(conflicts.h#L63-L280):svn_wc__conflict_skel_create() 起头,svn_wc__conflict_skel_add_text_conflict() 记录 mine / their-old / their 三个 marker 文件,svn_wc__conflict_skel_add_prop_conflict() 记录 mine/their-old/their 属性哈希与 conflicted_prop_names,svn_wc__conflict_skel_add_tree_conflict() 记录 local_change(原因)与 incoming_change(动作)以及 move 源/目标的 op-root,最后 svn_wc__conflict_skel_resolve() 按 resolve_text / resolve_prop / resolve_tree 逐项消解。树冲突的 reason(本地改动原因)取值见 svn_wc_conflict_reason_t:edited/obstructed/deleted/missing/unversioned/added/replaced/moved_away/moved_here;action(来的改动)取自 svn_wc_conflict_action_t:edit/add/delete/replace(svn_wc.h#L1643-L1678)。
svn resolve --accept 的取值来自 svn_wc_conflict_choice_t:postpone(=0)、base(原始版本)、theirs_full(来访版本)、mine_full(自身版本)、theirs_conflict(冲突块的来访侧)、mine_conflict(冲突块的自身侧)、merged(合并后版本)、unspecified(svn_wc.h#L2192-L2210)。
冲突残留文件:旧式实现把文件名写进 conflict_old/conflict_new/conflict_working 三列(6.2.5),新式 skel 存 relpath;源码中可观察到 .mine(copy.c#L333)与 .working(wc_db_update_move.c#L1671)后缀;属性冲突的 reject 文件后缀由 SVN_WC__PROP_REJ_EXT ".prej" 定义(wc.h#L46)。ACTUAL_NODE 的 older_checksum/left_checksum/right_checksum 让冲突文本也进 pristine store(见 6.2.5 的设计问答)。
运行时观测:某次 update 制造文本冲突后,用
sql
SELECT a.local_relpath, a.conflict_data, p.hydrated
FROM ACTUAL_NODE a
LEFT JOIN PRISTINE p ON p.checksum = a.left_checksum
WHERE a.conflict_data IS NOT NULL;
即可看到冲突 skel 与冲突文本的 pristine 是否在本地。
6.7.2 externals、changelist、稀疏与 depth
- externals :
EXTERNALS表(6.2.10)以presence的MAP_NORMAL/MAP_EXCLUDED表达"可用/用户排除",def_local_relpath+def_repos_relpath+def_operational_revision+def_revision描述定义。 - changelist :写在
ACTUAL_NODE.changelist列(6.2.5)。 - 稀疏(sparse) :用 BASE 层
presence='excluded'表达(6.2.1 的excluded),depth 名列NODES.depth(NULL = 默认 infinity)。 svn_depth_*:检出/更新的深度语义(empty/files/immediates/infinity/unknown),由NODES.depth落库。
6.7.3 cleanup / upgrade / vacuum 的关系
svn cleanup:默认走svn_client_cleanup2(..., break_locks=TRUE, fix_timestamps=TRUE, clear_dav_cache=TRUE, vacuum_pristines=TRUE, ...)------打破写锁、修正时间戳、清 WebDAV 缓存、并清理未引用 pristine(cleanup-cmd.c#L104-L110)。它也是"续跑未完成 work queue"的入口。svn cleanup --vacuum-pristines:走svn_client_vacuum(...)(cleanup-cmd.c#L78-L84)。svn upgrade:单向升格式,早于格式 12 需先经更老客户端(6.5.4)。
6.7.4 开发工具
tools/dev/wc-ng/svn-wc-db-tester.c------"a crude command line tool that makes it possible to run the wc-db validation checks directly",会按local_relpath (depth=%d) DBV%04d: %s打印校验错误(L1-L80)。tools/dev/svnqlite3-dump------Perl 过滤器,把sqlite3 .svn/wc.db .dump的 BLOB(属性 skel)与时间戳解码成可读形式;用法注释:sqlite3 .svn/wc.db .dump | svnqlite3-dump、svnqlite3-dump /path/to/wc、svnqlite3-dump /path/to/wc/.svn/wc.db(L20-L50)。tools/dev/wc-ng/下另有bump-to-19.py、populate-pristine.py、graph-data.py、count-progress.py、gather-data.sh等辅助脚本(Glob 实证)。
6.7.5 1.15 其余相关修订
- r1886490 et al:checkout/update/export 同时流向 pristine 与工作文件(6.6.5 已引源码注释)。
- r1892645 :以加密校验和比较判断文件是否被修改,对应
CHANGES"Use a cryptographic checksum comparison to determine if a file has been modified in a working copy",落在 6.3.5 的 change-detection 启发式末梢。 - r1888474 et al(issue #4874) :仓库 UUID 相同而 URL 不同时告警,与
REPOSITORY表"同 UUID 多 URL"的设计(6.2.2)呼应。
6.7.6 全章 r 号索引
| 里程碑 | 内容 | 取证 |
|---|---|---|
| r991236 | 单一 wc.db(format 19) |
wc.h#L120-L122 |
| r1005388 | NODES 取代 BASE_NODE/WORKING_NODE(20) | wc.h#L124-L125 |
| r1044384 | 多层 op_depth(23) | wc.h#L133-L134 |
| r1058523 | refcount 正确维护(24) | wc.h#L136-L137 |
| r1387742 | 冲突改存 skel(30) | wc.h#L154-L155 |
| r1395109 | inherited_props(31) |
wc.h#L157-L158 |
| r1905955 / #525 | pristineless(format 32) | CHANGES#L16-L17 |
| r1898187 / #4883 | 多 WC 格式支持 | CHANGES#L18 |
本章结论 :WC-NG 用一张 NODES 表 + op_depth 同时表达 BASE 与多层 WORKING 视图,用 PRISTINE 表 + 触发器实现内容寻址与引用计数,用 WORK_QUEUE 表把文件系统副作用纳入可重放日志;1.15 在格式 32 上叠加了 hydrated / TEXTBASE_REFS / SETTINGS,把 pristine 从"必有"变为"可选",并用多格式支持把"读写相邻两代"变成一等能力。三者的耦合点集中在 SETTINGS.store_pristine、SVN_WC__HAS_OPTIONAL_PRISTINE、SVN_WC__SUPPORTED_VERSION 与 SVN_WC__VERSION 这四个常量上。