ScyllaDB 对齐当前持久存储契约的 Delta¶
2026-09-28 起,本文
ScyllaMailboxStore的 dispatch/chunk/source/水位表与分阶段 GC 部分已被 ADR-0022 取代(分片前沿 + 写时间戳单调写 + 按页推进,热路径零 LWT);MessageStore 与保留期证明 语义仍以本文为准(bucket 证明表改为mailbox_retention_bucket_v2,按 epoch 分行)。日期:2026-08-23
所有权:qim-storeScylla 后端
状态:代码、纯 Rust 契约与 writer/mailbox 显式后端接线已完成;真实三节点已确认 3 UN/RF3。首次 migration 发现默认 Tablets 不支持 LWT,已显式禁用;本次 retention V4 同行活动 ledger 与分 phase、固定 deadline 的 TTL GC 修复后须在同一隔离三节点门禁重跑,尚不可视为发布完成。
Design Intent¶
ScyllaMessageStore只实现现有MessageStore;调用者继续经DurableMessageCommitter完成RESERVED → RECORDED → OUTBOXED → COMMITTED,后端不拥有 ACK、Kafka 发布或 fanout。ScyllaMailboxStore只实现现有MailboxStore;Kafka offset、64-lane manifest、重复日志与水位证明在 store 内收敛,调用者不接触 CQL/LWT。- 所有稳定领域对象使用现有版本化编码(
CommitIntent::encode_store、MessageRecord::encode_store、RedisEntry编码和 dispatch manifest 编码),避免两套业务字段和迁移漂移。 - 仅“首次事实”和“有序水位”使用
LOCAL_SERIALLWT;同值 record/entry 写、读取和分页使用 prepared statement +LOCAL_QUORUM。 connect只建立会话和 prepare;显式migrate才创建/验证 schema,且 DC/RF 从显式配置取得,绝不在进程连接时变更 schema 或ALTER KEYSPACE。
实现使用 lockfile 锁定的 scylla 1.8.0 Rust 驱动:优点是 prepared CQL、LOCAL_QUORUM/
LOCAL_SERIAL 与 typed rows 能把一致性要求收敛在存储层;代价是连接池与驱动依赖比 Redis
客户端更重,且需要隔离集群做真实契约验证。未使用 uuid;协议已有的 16 字节身份保持 blob,
避免引入第二种 UUID 格式与转换语义。
变化概要¶
旧 store/scylla 的 CommitRequest、分配后 ACK、进程内 TCP fanout 与当前 durable commit
状态机不兼容。本 delta 新建 crates/qim-store/src/scylla/,以当前两个 trait 为唯一公开
边界;不向 Redis、writer 或 mailbox service 泄漏驱动配置、表名、Paxos 行或 bucket 规则。
配置与一致性¶
ScyllaConfig 仅包含显式 contact point、keyspace、本地 DC、RF;ShardEpoch 与
MailboxGcConfig 由 ScyllaMailboxStore 构造参数显式给出。生产构造拒绝 rf < 3、
空 DC、非法 keyspace;测试可显式传隔离 QIM_TEST_SCYLLA_*。migrate:
- 以参数化
NetworkTopologyStrategy和TABLETS = {'enabled': false}创建 keyspace;当前 LWT 所需 vnodes,若已存在则先从system_schema.keyspaces验证目标 DC 的 RF,再检查system_schema.scylla_keyspaces:Scylla 6.2 对 vnodes keyspace 返回无行,该已验证语义视为 Tablets 已禁用;若扩展行存在,仅明确storage_type=vnodes可接受,null/Tablets/未知值一律失败。不隐式 ALTER。 - 每张表用
CREATE TABLE IF NOT EXISTS,schema/DDL query 使用LOCAL_QUORUM。显式 migration 还会查询system_schema.columns,仅对既有表缺失的 GC nullable bigint 列执行受控ALTER TABLE ... ADD,使新代码可以 prepare;已有 V1/V2/V3/null retention proof 仍在读路径 fail closed, 运维必须离线回填/切换才能让旧用户恢复,应用不会猜测或重写旧 proof。 readiness真实读取 system schema 中的 keyspace,并复核目标 DC/RF;连接或 prepare 成功不能伪绿。
所有准备语句设置 LOCAL_QUORUM;仅 IF NOT EXISTS、IF state/offset/version = ?
语句额外设置 LOCAL_SERIAL。所有 u64 → bigint 必须经统一转换,bit63 非零立即返回
Invariant/MessageStoreError::InvariantViolation,不写入 CQL。
MessageStore schema 与操作¶
| 表 | 主键与保存事实 | 原子性/保留 |
|---|---|---|
message_commit |
((tenant_id, sender_id, client_message_id));stable request identity blob、完整 CommitIntent blob、state、created_at、committed_at |
reserve INSERT IF NOT EXISTS + LOCAL_SERIAL;辅助提交事实 TTL 2h |
conversation_sequence |
((tenant_id, conversation_id));已发出的最大 seq |
初始化 IF NOT EXISTS、逐号 IF next = ?;不预租未发号段 |
message_record |
((tenant_id, conversation_id, seq_bucket), conversation_seq);版本化 canonical MessageRecord |
同值 upsert;表级 TTL=0,Ephemeral 用逐行 TTL |
history_index |
((tenant_id, conversation_id), conversation_seq);与 record 同 TTL 的序列索引 |
用于 O(1) earliest 与方向页;索引/record 任一缺失均报损坏 |
commit_recovery |
按小时分区的 (created_at, CommitKey) |
固化 reserve 后写入;扫描后重读 commit 并过滤终态;TTL 与辅助事实一致 |
方法映射:
reserve先计算稳定身份,读到已有 commit 时字节相同才返回首次 intent,任何不同返回Conflict。首次先写有 TTL 的 recovery index,再用单号 CAS 与 LWT 建立完整RESERVEDcommit;这保证 kill -9 落在 commit 成功与后续步骤之间时恢复扫描已有入口。竞争/分配失败会留下 TTL orphan,scanner 遇到load_commit=None必须跳过;CAS/LWT 竞争只会留下合法 history 空洞,不会复用 seq。put_record只接受该 commit 的 immutable reserved intent;record 和 history index 以同一逐行 TTL 同值覆盖,随后条件状态更新为RECORDED。异状态、异 intent 或半成品都显式失败。mark_outbox_published仅接受同一message_id,把 publication 写回 intent 后条件推进为OUTBOXED;mark_committed只允许OUTBOXED,推进为COMMITTED并保留原坐标。load_commit与load_commit_for_request直接解码 commit blob;后者必重新比较 stable identity。scan_recoverable_commits有小时 bucket、CQLLIMIT和 max-item 上限;只返回RESERVED/RECORDED/OUTBOXED且早于 cutoff 的完整 intent,重复/终态索引行被过滤。gc_committed_auxiliary与gc_expired_history对 TTL 管理的表执行有界、可观测的到期整理/计数;不对MessageStore发显式DELETE,Default、TenantCustom、ComplianceHold不猜保留期。history_page在任何读取前调用HistoryAuthorizer;按OLDER/NEWER、anchor、max_items、max_bytes 读取 index,再批量取 record。首条可超过字节预算,随后严格截断;缺 index、缺 record、解码失败、坐标不一致均返回错误,字段逐字节由 canonical record 还原。conversation_summaries是为消除 qsession 对RedisMessageStore具体 API 的必要 trait 扩展:Memory/Redis/Scylla 共用未读判定向量。Scylla 先读 history index head 和最多UNREAD_PRECISE_LIMIT的窗口,再以有界并发读取 canonical record;只计非本人且Normal/Edited的非 control 消息,超窗固定返回上限并标记非精确。
MailboxStore schema 与操作¶
| 表 | 主键与保存事实 | 原子性/保留 |
|---|---|---|
mailbox_entry |
((tenant_id, user_id), mailbox_seq);整组的版本化 Entry blob |
单行承载完整事件组,重放同值覆盖没有残留 member;固定绝对到期 TTL |
mailbox_retention_meta / mailbox_retention_bucket |
meta 按 user 保存 retention_format_version=4、inspected_through_bucket、expired_gap_boundary(i64::MIN = 尚无 gap)、ledger_generation 与规范 active_ledger blob;bucket 保存绝对过期与最大 seq |
存活 entry 为 bucket proof → 同行 active ledger → entry;ledger 存在即要求 bucket proof 存在且不小于 ledger,缺失 fail closed。ledger 的固定头 QRL\x01、u16 数量、按日严格升序的 (day,max_seq,expiry) 三元组有精确长度校验,最多 32 项,损坏/超限均拒绝读取 |
lane_dispatch |
((shard, lane), observed_offset) + dispatch_id |
逐 lane 保留每个真实 source offset(含空 lane);offset 上的条件插入会拒绝两个 dispatch 冒充同一 source record,完成事实用于重算连续 W,不能把 hole 压缩掉 |
dispatch_observation |
((shard, dispatch_id), observed_offset);recorded_at |
replay 的全量反向索引;先于各 lane source 写入。GC marker 后枚举全部 observation,才可对每一 lane/source 及 index 调度 TTL |
completed_dispatch |
(shard, completed_at, dispatch_id);max_observed_offset、gc_scheduled_at |
GC 的时间有序候选索引;候选还必须读取 progress 的 last_seen_at,调度 TTL 前逐 lane 验证 W 与 W_floor 已越过 max_observed_offset;首次 GC deadline 先镜像到此行,供 progress 已 TTL 的恢复路径使用 |
mailbox_meta |
((tenant_id, user_id));trim watermark |
IF trim < ? 单调推进 |
dispatch_progress |
((shard, dispatch_id));digest、canonical offset、mailbox seq、manifest blob、最大 observed offset、完成/最后观察时间、gc_scheduled_at、gc_phase |
首建 IF NOT EXISTS;重复只接受相同 digest/manifest,CAS 单调更新 max observed,且每次 replay 都刷新 last_seen_at;GC marker 非空时所有 replay/续写 fail closed |
dispatch_chunk |
((shard, dispatch_id, lane), chunk_id);完成事实 |
仅 manifest 已声明的 chunk 可 IF NOT EXISTS 完成 |
lane_watermark |
((shard, lane));W 与 W_floor 的 source offset |
expected previous 的条件 LWT,绝不以最大值跨 source hole |
方法映射:
append_batch验证完整 event group、epoch 和同 seq;组内先规范为(mailbox_seq,event_ordinal,event_id),乱序输入不会泄漏为后端差异;同 seq 的 ordinal 重复会 fail closed,解码/range 也以相同键排序并拒绝损坏 blob。随后按(user, mailbox_seq)覆盖整组,绝不把部分组暴露给 range scan。写入带 TTL 的存活 entry 前,先写 V4 per-user 日桶 bucket proof,再以同一 meta LWT 原子消费已过期活动日、合并其 gap、登记/抬高当前日并递增ledger_generation,最后写 entry,重试不续期。稀疏 bucket 行本身无法区分空日和 proof 丢失:巡检只由 active ledger 驱动,ledger 存在但 bucket 缺失/回退即CorruptEntry;无 ledger 的日期可安全视作从未持久存活 entry,故长期空用户不会不可用。append 自己裁剪已过期 ledger,持续收信而不拉取的用户也不会让 blob 按天无界增长。旧 V1/V2/V3、缺列、null proof 一律 fail closed。已过期日志重放只先写 bucket proof,随后 CAS 合并expired_gap_boundary并成功返回,不登记活动 ledger;绝不提升 trim。begin_dispatch首次以 observed offset 合成 canonicalMailboxSeq;同dispatch_id的重复日志重用首次 receipt,digest/manifest 不同返回DispatchConflict,每次重复均以 CAS 刷新last_seen_at,并仅单调提高max_observed_log_offset。每个 observation 先INSERT IF NOT EXISTS进 per-dispatch index,再写 64 lane source;因此 GC 不会遗漏 canonical/max 之间的重复 offset。若 progress 的gc_scheduled_at非空,读取、replay、chunk、watermark 续写全都返回GcNotSafe;progress_seen/progress_complete的 LWT 也要求 marker 为 null,封住读取 marker 前的竞态刷新。append_chunk先验证 receipt/manifest,再写单用户完整事件组;chunk_completed/mark_chunk_completed只读写持久 completion,后者不写用户条目。dispatch_progress读取并验证 manifest,再合并 lane chunk completion;缺失返回None,半损坏返回CorruptEntry。range_scan先验证两端 epoch;after > up_to按 Redis 语义立即返回(空, after)。没有 V4 retention meta 的干净用户在 trim 检查后返回(空, up_to)。其余读取前 lazy inspect 已到绝对过期时刻的 active ledger:每项必须找到同日 bucket proof,才把实际bucket_max_seq合并进独立expired_gap_boundary,不改全局 trim;meta LWT 同时比较 version、generation、巡检点、gap 与完整 ledger blob 并原子移除已巡检项。并发 append 若先注册 generation,inspect 的 LWT 必失败并重读;inspect 若先完成,append 会看到已巡检日并在自己的 LWT 补 gap。因此不会出现 ledger TTL ABA 或旧 gap 覆盖新 seq。created_at与mailbox_seq不单调,故过期高序号桶不能裁掉低序号存活桶;当after_seq < max(trim, expired_gap_boundary)时返回CursorExpired,ledger/bucket 损坏返回CorruptEntry,TTL 空页绝不伪造covered=up_to。随后用共享cut_batch只在 seq 边界切页。truncate_before只 LWT 提升 trim;mailbox_entry的物理回收仅依赖绝对 TTL + TWCS(gc_grace_seconds=86400),禁止任何范围DELETE/tombstone。读取永远先看 trim。lane_watermark读取持久 W/W_floor 后,以 W_floor 作为已 GC 前缀锚点,CQL 只扫描observed_offset > floor的 source completion;要求重算值严格等于 W,且 W/重算值均不得小于 floor,不一致拒绝 ready。advance_watermark只在该 lane manifest 全完成且 W 正好等于 expected previous 时,CAS 推进到此次 observed offset。重复 offset 可幂等,source offset hole 只能停住水位。gc_completed_dispatches只选择完成且last_seen_at <= cutoff的 dispatch;必须先确认 64 lane 的 W 与 W_floor 都跨过最大 observed offset,未满足则保留候选。通过该 gate 后先以last_seen_at + max_observed_offset + completed_at + gc_scheduled_at=nullLWT 固化 progressgc_scheduled_at(即唯一 deadline),紧接着以同一条件事实把同一值镜像到 completed index;CAS 未应用即不动任何 child/source proof。progress 已 marker、index 尚未镜像时可用 progress 的固定值补写;progress 已 TTL 时只能从 index marker 取剩余 TTL;两边都缺失或值不一致均 fail closed,绝不重新给 60 秒。随后gc_phase从 null 条件推进为 1;对每条 chunk/source/observation/progress/completed proof,先以 LWT 锁住原事实,再在每个整行INSERT ... USING TTLupsert 前计算remaining=max(1, ceil((deadline-now)/s))。不能用UPDATE USING TTL:Scylla 的 TTL 仅作用于非主键列,初始无 TTL INSERT 的 row liveness 会遗留 regular column 为 NULL 的幽灵行。扫描dispatch_observation的全部 offset,逐 lane 重写每项 source,随后重写 observation index;再以条件 LWT 推进 phase=2;最后才以同一剩余 TTL 重写 progress、completed index。重试绝不刷新 deadline:每次读到任一需回收列的正 TTL(包括 deadline 后的首次 TTL=1 补偿)即跳过,故周期扫描不能续租。没有任何服务端显式删除语句。进程可在任一点崩溃:marker 无 phase 时从 phase 1 起步;phase 1 允许已经到期的 child/source/observation 缺失,不把已 TTL 的索引当永久损坏。兼容已部署的旧 UPDATE-TTL:只有 completed index 仍保存的首次 GC marker 才是安全证据,可按该固定 deadline 扫描同 dispatch 的 observation、lane source 与 chunk,并将recorded_at、source 三列、completed_at、progress 必填列或 completedmax_observed_offset全 NULL 的完整 skeleton 整行占位 upsert 后回收;partial NULL 一律CorruptEntry。markerless progress/completed 全 NULL 行无法区分旧 GC 与真实损坏,必须 fail closed,交由离线迁移或重放,绝不猜 deadline 自动清理。
迁移与兼容¶
不复用旧 qim_message/qim_mailbox 表:旧行的 CommitRequest 不能证明 stable identity、
Outbox publication 或当前 dispatch manifest。部署采用新 keyspace/versioned 表名或离线
导入;Redis 仍为默认运行后端,Scylla 已由 writer/mailbox 的显式配置路径装配,绝不静默
回退。MessageRecord、Entry、manifest 均使用当前既有版本化二进制编码,新增字段先由编码
自身的尾部兼容规则处理。
服务接线¶
qim-writer 与 qim-mailbox 读取同一组显式变量:QIM_STORE_BACKEND=redis|scylla
(默认 redis),以及 Scylla 模式必填的 QIM_SCYLLA_HOSTS(逗号分隔)、
QIM_SCYLLA_KEYSPACE、QIM_SCYLLA_DC、QIM_SCYLLA_RF。启动顺序是显式
migrate_config(创建/验证,并仅对已知 GC nullable 列显式补列;绝不 ALTER KEYSPACE)→
connect/prepare → readiness/水位自检;
任一步失败均退出,绝不回退把消息或邮箱数据写入 Redis。writer 在 Scylla 模式仍连接 Redis,
但仅用于既有 writer-id/HLC 租约和群成员授权;消息事实源保持 Arc<dyn MessageStore>。
mailbox 的 durable state 为 Arc<dyn MailboxStore>,Scylla 模式不建立 Redis 邮箱连接。
测试与门禁¶
- 纯 Rust:codec 的 u128/blob、bit63、manifest/entry、过期高序号桶与存活低序号桶的 V4 gap→
CursorExpired向量、旧版/缺 proof fail closed、active-ledger generation 的规范编码/线性化登记,以及 marker/phase/固定 deadline TTL 的三段崩溃恢复策略与正 TTL 不续租测试。 - 专门门禁:
cargo test -p qim-store --test scylla_contract -- --ignored。它要求QIM_TEST_SCYLLA_ADDR/KEYSPACE/DC/RF;任何变量缺失都会明确 panic/失败,不能标绿。 - 纯 Rust 的
tests/message_store.rs已加入经dyn MessageStore调用的共享会话摘要向量; Redis 与 Scylla 均复用同一收敛 helper。Scylla 专属 ignored 门禁覆盖显式 migration/readiness、 stable dispatch、整组 entry、干净用户/epoch 短路、active ledger 发现人为删除的 bucket proof、过期高序号/存活低序号的 gap(并直接断言 meta trim 未被自然 TTL 推进)、三次 observation 全索引,以及旧 UPDATE-TTL 产生的 marker-present observation/source/chunk/progress/completed skeleton 全部经整行 TTL 消失、markerless completed skeleton fail-closed、range scan 与 watermark;三节点门禁仍须补 LWT 双实例、source hole 和 RF/DC 故障注入。 - 发布前仍必须在隔离三节点执行 M-SCYLLA-03、完整 E2E、故障恢复和性能门禁。当前环境已具备三节点资源并确认 3 UN/RF3;首次 migration 因默认 Tablets 不支持 LWT 已 fail closed,修复后的真实结果为待重跑,不得标为 cgroup 阻断。
风险与范围¶
- 最大风险是 CQL 跨分区不能提供 Redis Lua 的单事务;实现通过“不可变事实先持久化、幂等写、条件状态推进、恢复重读”收敛,不能伪称 exactly-once。
- 生产 schema 权限、实际 DC 名和三节点故障语义只能由部署门禁验证。
- 本专项仅为 writer/mailbox 增加显式后端选择与共同 trait 装配;不修改 qsession、Kafka/Redpanda、CLAUDE 或 git 分支。