CivitAI 的dbRead/dbWrite双客户端路由:当服务层在运行时选择数据库客户端时,测试 mock 如何正确拆分
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
本文解析 CivitAI 仓库中dbRead/dbWrite数据库客户端别名拆分的一个特殊场景:部分 service 不在源码中固定使用哪个客户端,而是在运行时通过「入参传递」或「复制延迟探测」动态选择。读完本文,你将掌握如何在共享 mock 迁移(shared-mock migration)中为这类"路由不可读"的测试桶确定正确的客户端绑定,理解负断言(negative assertion)为何会静默失效,以及如何验证一次拆分是否真正生效——这些方法论对任何采用读写分离架构的测试体系都直接适用。
背景:共享 mock 迁移中的"路由不可读"桶
CivitAI 的 server 端通过 src/server/db/client 暴露两个 Prisma 客户端别名:dbRead(读副本)与dbWrite(主库)。大量服务测试用如下方式把两者指向同一个本地 mock:
vi.mock('~/server/db/client', () => ({ dbRead: mockDb, dbWrite: mockDb }));这种"一个 mock 同时服务两个客户端"的写法在迁移到共享 mock 时需要拆开:为被测模块找到形如dbRead.model.method或dbWrite.model.method的调用点,把断言绑定到对应客户端。对多数文件,读一遍被测模块源码就能完成拆分。
但在src/server/services/__tests__的 a–m 字母段(分支perf/test-mock-migration-services-a-m)中,有一批文件的路由决策无法用通常方式从生产源码读出。CivitAI 仓库中 claudedocs/services-a-m-parameterised-client-analysis.md 记录了这次逐案例分析(撰写于 2026-08-15,桶分类在分支基准提交17f994221e上验证)。阻碍"读源码即得路由"这一常规手段的机制有两个:
- 客户端是参数——服务函数接收
db选项,调用链上只出现db.model.method; - 客户端由复制延迟决定——
getDbWithoutLag依据运行时 Redis 状态在dbRead/dbWrite之间二选一。
两者叠加后,grep 'dbRead.appBlockPublishRequest.findFirst'一无所获,且负断言的错误路由不会让测试变红——所以必须逐案例写清。
机制一:客户端是参数(以block-registry.service.ts为例)
src/server/services/block-registry.service.ts 中有五个调用点按入参选择客户端。文档基于分支基准时引用的行号为:1362、:1819、:1887、:1928与:2032;在当前仓库中,这些模式仍完整存在(行号随后续提交有偏移):
const db = opts.db === 'read' ? dbRead : dbWrite; // 多数站点:缺省为 dbWrite const db = opts?.db === 'write' ? dbWrite : dbRead; // 一个站点:缺省被"反转"为 dbRead选定的db会被向下传递,例如applyPinnedVersion(live, appBlockId, pinnedVersion, db)。后果是:测试实际走哪个客户端,是"测试调用"的事实,而不是"服务源码"的事实。grep 模块名找不到任何dbRead.xxx/dbWrite.xxx的直接拼写,路由只能从入口函数的缺省分支推出来。
为什么错误路由会"静默"
正断言在调落到另一个客户端时会变红(mock 上没有该调用记录),但负断言不会。expect(mockDb.appBlockReview.create).not.toHaveBeenCalled()在create被路由到代码从未触碰的客户端时平凡通过——断言没有验证任何东西。这批六个文件共携带 14 个此类负断言,因此"跑一遍套件"根本无法捕获错误猜测。这是整份分析最核心的警示:负断言在错误路由下是惰性的(inert),不是通过的证据。
路由决策表:六个文件,八个入口
文档作者最初的判断是"这些需要逐测试人工决策,可能要长期手工维护"。逐文件读完后的修正结论是:这里没有测试传递过db选项(用db: 'read'/db: 'write'grep 各文件,零命中),所以每个入口都落到自己的缺省值,而缺省值是固定的、可静态确定的:
| 入口 | 不传db选项时的客户端 |
|---|---|
BlockRegistry.resolveBlockInstance | dbWrite(opts.db === 'read' ? dbRead : dbWrite) |
BlockRegistry.applyPinnedVersion | 继承调用方——从resolveBlockInstance来即dbWrite |
BlockRegistry.getFeaturedBlocks | 仅dbRead.$queryRaw |
BlockRegistry.getMarketplaceMeta | dbRead.appBlock.findUnique |
BlockRegistry.setMarketplaceMeta | dbWrite.appBlock.* |
upsertAppBlockReview | findUnique/create/update用dbWrite;findMany与第二个findUnique用dbRead |
setAppReviewExcluded | dbWrite.appBlockReview.update |
bustAppRatingCache | dbRead.appBlock.findUnique、dbRead.blockUserSubscription.findFirst |
其中**"反转缺省"站点是最大的陷阱**:四个站点缺省dbWrite,唯独一个站点缺省dbRead。任何从前四个学到"缺省即写库"并套用到第五个的人都会得到一次静默的错误路由。结论是:检查具体站点,不要相信模式。
机制二:客户端由复制延迟决定(更常见,也更隐蔽)
第二个机制是 src/server/db/db-lag-helpers.ts 中的getDbWithoutLag(该文件在仓库中位于第 46 行起):
export async function getDbWithoutLag(type?: LaggingType, id?: number | string) { if (env.REPLICATION_LAG_DELAY <= 0) return dbRead; // 生产环境:恒走此分支(默认 0) if (type === undefined || id === undefined || id === null) { return isHighReplicationLagMode() ? dbWrite : dbRead; } return (await lagTracker.isStale(lagKey(type, id))) ? dbWrite : dbRead; }其设计意图是 read-your-writes:写操作通过preventReplicationLag(type, id)在 Redis 中为特定实体打标(见同文件 L65-L68),读路径若发现该实体刚被写过(isStale),就切到dbWrite读主库,避免读到滞后副本。同文件还提供 Kysely 孪生版getKyselyWithoutLag、批量版getDbWithoutLagBatch以及针对模型/版本双键的preventModelVersionLagBatch。
凡是经由getDbWithoutLag触达的查询(本 slice 中为model-version.service.getVersionById),源码里没有固定客户端,常规 grep 的结果是"两个都对"(BOTH)。
测试环境下的行为陷阱(OC-317,已被当前仓库修复)
文档用 🔴 标记了一个更深层的问题:REPLICATION_LAG_DELAY在 src/env/server-schema.ts 中是z.coerce.number().default(0)键,但当时缺席于TEST_ENV_DEFAULTS。于是测试中的规范化 env 读到的是undefined而非0——而undefined <= 0是false,0 <= 0是true。结果是:每一条未 mockdb-lag-helpers却走到getDbWithoutLag的测试,都会进入生产环境永远不会走的"陈旧性分支",最终落在 Redis 读取决定的那个客户端上。文档当时还统计了面:73 个 zod 默认键中 59 个缺席TEST_ENV_DEFAULTS,其中 40 个是数字或布尔默认值,undefined与真实默认值不等价。
值得对照的是当前仓库的状态:src/tests/mocks/env.mock.ts 中TEST_ENV_DEFAULTS现在直接展开...schemaDefaults()——"从 schema 自身的.default()推导、再叠加测试覆写",注释明确写道:
/** * 🔴 The schema-derived base means a key gaining a `.default()` in the schema * automatically appears here — the hand-enumerated divergence that caused OC-317 * (REPLICATION_LAG_DELAY missing, read as undefined under test) cannot recur. */ export const TEST_ENV_DEFAULTS: Record<string, unknown> = { // Schema-derived defaults (every .default() from serverSchema) ...schemaDefaults(), ...也就是说,文档记录的机制二缺陷属于共享 mock 层的一次性修复项(文档原话:"Fixing that is a shared-mock change, not a slice change"),当前仓库已通过"默认值从 schema 自动派生"的结构性手段消除了复发可能。
受影响文件:两个是真,三个是误报
经入口级核实,本 slice 中真正"不能靠肉眼拆分"的文件是两个,而不是最初整模块扫描时归入此桶的五个:
| 文件 | 入口 | 原因 |
|---|---|---|
model-version.blue-buzz-purchase | earlyAccessPurchase | 经由getVersionById,其内部是forceWriteDb ? dbWrite : await getDbWithoutLag(…) |
model-version.purge-by-hash | publishModelVersionById | 直接调用getDbWithoutLag |
其余三个是误分类的普通转换:
model-version.deregister的deleteVersionById只用dbWrite,model-version.service中别处的dbRead拼写属于该测试从未调用的函数;model-file.service与model-file-scan.service干脆完全不含getDbWithoutLag。
由此提炼出整份分析的方法论核心:整模块扫描得出的 "BOTH" 不是裁决,而是一个未回答的问题。真正裁决的是测试实际导入的入口——只有当该入口本身把选择推迟到运行时(调用方的db选项,或复制延迟探测)时,文件才属于这一桶;仅因大模块里恰好两种拼写都出现,不构成理由。
另一个 ⚠️ 级结论:本 slice 中"已经安全转换"的文件之所以安全是巧合而非判断。contest-entry-base-model-gate、contest-entry-resource-gate、article-locked-properties、model-locked-properties、model-flag-side-effects与model-version.linked-component全都直接 mock 了~/server/db/db-lag-helpers,在任何上述机制生效之前就把客户端钉死了。安全与不安全的转换之间,仅以"测试是否碰巧 stub 了该模块"为界——所以"哪些文件转换得干净"不能用来反推"哪些文件有风险"。当前仓库的 src/server/services/tests中仍可看到这批测试对db-lag-helpers与db/client的 mock 并存。
逐文件路由结论(六个测试文件)
block-registry.pinned-version.test.ts(4 个用例)
驱动resolveBlockInstance与applyPinnedVersion,均不传db选项 →blockUserSubscription.findUnique与appBlockPublishRequest.findFirst走dbWrite。
文档同时保留了一条已更正的错误结论作为反面教材:最初记录"model.findUnique在源码中直接解析为dbRead",并于 2026-08-22 更正——resolveBlockInstance在:1362(基准提交行号)取得客户端参数后,下游全部使用这个局部变量,包括db.model.findUnique;被引用的dbRead.model.findUnique拼写实际位于另一处(:2617),属于这些测试从未调用的函数。基于错误结论把model/modelVersion路由到dbRead后,代码在一个从未被触碰的客户端上读到规范的null,if (!model) return null处处触发——跨两个文件六个测试全部expected null not to be null,最终十五处站点改回dbWrite。完整事故记录见 claudedocs/services-a-m-handover.md 的 "The red, and the routing table that caused it" 一节。
这条事故的教训指向本文档本身:引用是存在的、看起来是核对过的、但被引用的行不在实际调用路径上——而表格其余部分是正确的,这反而让它更容易被信任。核对路由结论必须对照测试实际驱动的调用路径,而不是文件里恰好相同的拼写。
block-registry.resolve-instance.test.ts(27 个用例)
全部经由resolveBlockInstance,不传db选项 →blockUserSubscription.findUnique/.findFirst与platformDefaultBlock.*走dbWrite;model.findUnique与modelVersion.findFirst在源码中拼写为dbRead。 🔴 负断言expect(mockDb.blockUserSubscription.findUnique).not.toHaveBeenCalled()必须改绑到mockDbWrite。
block-registry.marketplace-meta.test.ts(14 个用例)
三个入口对应不同的客户端:getFeaturedBlocks→dbRead.$queryRaw;getMarketplaceMeta→dbRead.appBlock.findUnique;setMarketplaceMeta→dbWrite.appBlock.*。这是六个文件中唯一appBlock.findUnique真实出现在两个客户端上的文件——拆分必须跟着每个用例的入口走,而不是跟着表名/路径走。 🔴 负断言expect(mockDb.appBlock.update).not.toHaveBeenCalled()(×3):update是dbWrite-only,机械路由即可安全处理。
block-registry.spend-cap-config.test.ts(15 个用例)
appBlock.update→dbWrite;appBlock.findUnique→ 按入口随上述规则。🔴 与上一文件相同的appBlock.update负断言,推理相同。
appBlockReview.service.test.ts(17 个用例)
upsertAppBlockReview对appBlockReview.findUnique两个客户端都用:存在性检查走dbWrite,后续读取走dbRead。仅按路径拆分在这里是错的,必须按"用例断言的是哪一次调用"来拆。 🔴appBlockReview.update(×2)与.create(×4)的负断言——在upsertAppBlockReview内两者都是dbWrite-only,这六条机械路由是安全的。
appBlockReview.collaborator-self-review.test.ts(11 个用例)
appBlock.findUnique与blockUserSubscription.findFirst→dbRead(经bustAppRatingCache);appBlockReview.create→dbWrite。 🔴appCollaborator.findMany的负断言在两个客户端的服务源码中都不出现——作者未能裁决,且明确没有猜,要求先读用例再路由。
不确定项:诚实的边界声明
文档单列了三个未决项,这本身是分析文档质量的体现:
appCollaborator.findMany的来源——可能经由其他模块或另一个参数化客户端触达,未解决;- 是否有用例依赖"两个客户端是同一对象"这一事实——别名 mock 把两者合一,某个测试可能在断言"通过另一个客户端发出的调用"而无人察觉(同 slice 的
model-appeal用例正是一层之下的这种形态:一个刻意分离的事务客户端,且这种依赖在 diff 中不可见); resolveBlockInstance的 27 个用例并未逐个读过——只解析了入口与缺省值,未解析每个用例的意图。
如何验证一次拆分:跑一遍套件是不够的
文档最后给出的验证流程,针对"负断言静默通过"这一根本风险:
- 先路由正断言并运行——正断言的错误路由是可见的;
- 对每个负断言,把它翻转为正断言,绑定到你认为代码使用的那个客户端,并确认它"该失败时确实失败"。一个无法被制造出失败的断言不是被路由了,而是惰性的;
residual-mocks.mjs门禁与收集计数在两种情况下都会干净——它们检测的是缺失(absent),不是空转(vacuity)。门禁能力的边界见 docs/testing/shared-module-mock-migration.md 的 "What the gate does NOT catch" 一节。
小结:两条可复用的工程经验
这份分析的价值不在某个仓库的某六个文件,而在两个可以泛化的结论:
- 参数化/运行时选择的依赖,其"使用事实"归属于调用方而非被调方。静态工具(grep、整模块扫描)给出的 "BOTH" 或 "零命中" 都是问题而非答案;裁决单位是测试实际导入的入口及其缺省分支——并且缺省分支必须逐站点核对,因为同一文件里可能混着
opts.db === 'read' ? dbRead : dbWrite与opts.db === 'write' ? dbWrite : dbRead两种语义相反的形式。 - 负断言是 mock 拆分中风险最高的一类:它在错误路由下平凡通过,且门禁工具(残留 mock 检查、计数校验)只检测缺失不检测空转。"翻转负断言确认其能失败"是把惰性断言变回真实断言的最小成本手段。
结合当前仓库状态看,机制二的测试环境缺陷(OC-317)已由 src/tests/mocks/env.mock.ts 的 schema 派生默认值机制在结构性层面修复;而机制一的调用点模式(block-registry.service.ts中的四处opts.db === 'read' ? dbRead : dbWrite与一处反转形式)在当前源码中依然存在——任何后续对这批桶做 mock 改造的工作,仍应以本文的路由表与验证流程为基线。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考