Skip to content

[metadata-protocol] ensureOverlayIndex 先 DROP 后 CREATE:partial 索引建失败时 sys_metadata 会静默地失去覆盖层唯一约束 #6418

Description

@baozhoutao

发现于 #5839 的实施(照该范式写 sys_view_definition 的同类迁移时逐行读到)。不在 PR #6415 范围内——那条只新增 sys_view_definition 的迁移,没有改动 ensureOverlayIndex 本身。

事实

packages/metadata-protocol/src/protocol.tsensureOverlayIndex()(约 L2519 起)顺序是「先删、后建」:

try { await exec("DROP INDEX IF EXISTS idx_sys_metadata_overlay_active"); } catch { /* best-effort */ }
const partialSql =
    "CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active " +
    "ON sys_metadata (type, name, organization_id, COALESCE(package_id, '')) " +
    "WHERE state = 'active'";
const fallbackSql =
    "CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active " +
    "ON sys_metadata (type, name, organization_id, package_id)";
try {
    await exec(partialSql);
} catch (err: any) {
    const msg = err instanceof Error ? err.message : String(err);
    if (/partial|where clause|syntax/i.test(msg)) {
        try { await exec(fallbackSql); } catch { /* ignore — non-essential optimization */ }
    }
    // "already exists" or anything else: best-effort
}

两个问题叠在一起:

  1. DROP 已经执行,CREATE 可能失败——失败后没有任何把旧索引补回来的动作;
  2. 降级目标不是唯一索引——fallbackSql 建的是普通复合索引(CREATE INDEX,非 UNIQUE),所以即便降级成功,覆盖层唯一性也没有了。

而且降级只在错误信息匹配 /partial|where clause|syntax/i 时才触发:存量行冲突UNIQUE constraint failed / duplicate key value)不匹配这三个词,于是走到最后那句 // best-effort 注释,什么都不做——此时旧索引已经被删除,新索引没建成,降级也没执行。

后果

在支持 partial index 的方言(SQLite / Postgres)上,如果 sys_metadata 里存在违反 (type, name, organization_id, COALESCE(package_id,'')) 的活跃行,这次启动之后该表一个唯一约束都不剩,且日志里没有任何提示(catch 块全是空注释)。ADR-0005 的覆盖层唯一性是元数据正确性的基础——同一 (type, name, org, package) 出现两条 active 行时,getMetaItem 取到哪一条不确定。

idx_sys_metadata_overlay_draft 那一段是同样的形状,同样的问题。

触发前提(为什么今天不一定有人踩到)

需要「索引建立之前就已经存在的重复活跃行」——索引一旦建成,约束本身就阻止新的重复产生。可能的来源:该索引引入之前的老库、out-of-band 建表的部署、以及从 MySQL(该索引一直没能建成)迁到 Postgres 的库。范围窄,但不是死代码路径。

MySQL 上反而是安全的,不过是偶然的:DROP INDEX IF EXISTS <name> 不是合法 MySQL 语法(MySQL 要 ALTER TABLE … DROP INDEXDROP INDEX … ON <table>),所以 DROP 先失败、旧索引得以幸存。安全性依赖于一条语法恰好不被接受,而不是依赖设计。

建议修法(已有现成参照)

PR #6415sys_view_definition 写同类迁移时采用了「先探针、后替换」的顺序,可直接照搬:先用一个临时索引名把 partial 索引建一次,确证当前方言与数据都能接受,成功后才 DROP 旧索引并以正式名重建;任何建不成的情况下旧索引原样保留。见 packages/metadata-protocol/src/migrations/view-definition-active-index.ts 的模块头注释("Why it PROBES before dropping anything")与对应测试。

冲突行的处置口径建议对齐 ADR-0120 D4 —— SqlDriver.createNullSafeUniqueIndex 已有先例:不阻断启动,在 error 级点名「哪几列没有生效」并指向 os migrate plan。当前实现的空 catch 是这条口径的反面。

⚠️ 注意 sys_metadata 的降级目标不能简单改成全量 UNIQUE:该表合法地允许同一 (type,name,org,package) 同时存在一条 active 行和一条 draft 行(正是 idx_sys_metadata_overlay_draft 存在的原因),全量 UNIQUE 会把它们判为冲突。这也是它与 sys_view_definition 的关键差别——后者的全量 UNIQUE 就是今天的现状,可以安全地作为降级目标。

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions