Skip to content

Commit 8a74903

Browse files
committed
feat(snapshot): add transactional publication receipts and ref CAS core
1 parent 706051f commit 8a74903

20 files changed

Lines changed: 1561 additions & 34 deletions

File tree

.github/workflows/base.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,5 @@ jobs:
312312
cargo test -p jupiter --lib namespace_storage --locked -j 2
313313
- name: Snapshot contracts, PostgreSQL transactions and million-binding gate
314314
run: cargo test -p ceres --lib snapshot --locked -j 2 -- --include-ignored --nocapture
315+
- name: Publication receipts, ref CAS, rollback and concurrency on both databases
316+
run: cargo test -p jupiter --lib publication_storage --locked -j 2 -- --include-ignored --nocapture

ceres/src/application/snapshot/radix/database/postgres_tests.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ async fn postgres_snapshot_nodes_scope_proofs_and_radix_transactions() {
2222
parsed.host_str(),
2323
Some("localhost" | "127.0.0.1" | "[::1]")
2424
));
25-
assert!(
26-
parsed.path().trim_start_matches('/').ends_with("_test"),
27-
"test database name must end in _test"
25+
assert_eq!(
26+
parsed.path(),
27+
"/snapshot_test",
28+
"use the explicit disposable test database"
2829
);
2930
let schema = format!("snapshot_test_{}", uuid::Uuid::new_v4().simple());
3031
let control = Database::connect(
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Namespace publication transaction core
2+
3+
Status: implemented storage core, 2026-09-06; **not an enabled publisher API**.
4+
The application composer, all production writer integrations, release-policy
5+
enforcement, prepare/retention pins and authorization remain required before
6+
the namespace capability can be announced.
7+
8+
## Ownership and ordering
9+
10+
`PublicationStorage::begin(request, expected_head, writer_epoch)` owns one
11+
database transaction. Its first write reserves the unique
12+
`(actor_domain, operation_id)` row. A duplicate committed request returns its
13+
receipt before exposing any ref-writing handle. Reusing the same committed key
14+
with a different request digest or instance is a conflict. Failed/aborted
15+
transactions leave no operation reservation or success receipt.
16+
17+
The request digest is supplied by a trusted application adapter and MUST cover
18+
the complete canonical mutation plan: fixed base/head, expected refs, binding
19+
policy/read set and prepared content identities. The storage facade cannot
20+
infer these fields from an opaque digest. The authenticated actor domain must
21+
not be accepted from untrusted request JSON. Receipt reads require current
22+
authorization independently of the operation key.
23+
24+
A ready result owns `PublicationTransaction`. Writers can borrow its underlying
25+
transaction for conditional refs, prepared metadata, scope attestations and
26+
index nodes, but cannot obtain ownership and independently commit it. Explicit
27+
abort or dropping the owner rolls back. Publication's `finish` is the only
28+
commit path exposed by this wrapper.
29+
30+
`finish` validates the prepared view identity/byte bound and same instance, then
31+
stages insert-only view bytes, conditional head update, publication history,
32+
operation result and outbox event. They commit together with the borrowed
33+
transaction's ref changes. A database error after head CAS still rolls back
34+
the head, view and refs. No notification is dispatched before COMMIT.
35+
36+
## Compare-and-swap and receipts
37+
38+
The head condition includes instance, expected sequence, expected view ID and
39+
writer epoch. Bootstrap is an insert-if-absent head, not an upsert. Sequences and
40+
epochs are positive SQL BIGINT values and sequence increment checks overflow.
41+
42+
When the descriptor is unchanged, the operation may be a no-op for namespace
43+
publication: preserve sequence/view and do not insert publication/outbox rows.
44+
It STILL executes the head/epoch fence. For example, a non-selected branch may
45+
change without changing the default namespace view. Determining that the view
46+
really represents the complete post-write state belongs to the application;
47+
the storage facade must not be used to hide a selected-ref mutation.
48+
49+
`GitDbStorage::update_ref_if_unchanged` adds one conditional SQL update on
50+
repo ID, fully qualified ref name and expected object ID, returning whether
51+
exactly one row changed. It accepts the publication transaction and does not
52+
silently rebase/retry. Existing legacy writers are not yet switched to this
53+
method. The caller must abort the whole publication if any required ref/read
54+
condition fails.
55+
56+
A COMMIT error is reported as an uncertain outcome, not a proven rollback.
57+
Look up the original actor/operation/request digest on a new connection before
58+
retrying. Receipt replay never dispatches a second ref mutation or outbox
59+
event. Outbox rows have unique event IDs and pending/delivered state, but the
60+
delivery worker and external side effects are not implemented by this core.
61+
62+
A writer_epoch column does not fence an old binary that never checks it.
63+
Maintenance cutover and an audit of every production writer remain G04/G05
64+
requirements; a passing storage test cannot establish those conditions.
65+
66+
## Schema and reproduction
67+
68+
The additive migration `m20260906_160000_namespace_publication` creates
69+
namespace_view, namespace_head, namespace_publication, snapshot_operation and
70+
namespace_outbox. It creates no initial head/catalog and enables no feature.
71+
Generated Callisto fields were produced with sea-orm-cli 2.0.2 from the actual
72+
SQLite migration schema; PostgreSQL tests verify the same runtime schema.
73+
74+
View payloads are bounded to 16 KiB in SQL and checked against their SHA-256 ID.
75+
The application supplies the already validated namespace-manifest-v1 codec.
76+
An opaque-byte storage fixture is not proof that the manifest describes the
77+
actual native/import objects. No foreign-key cascade from a mutable ref or
78+
registry path deletes published metadata. Retention/GC and referential audits
79+
must be supplied by the full publisher before deployment.
80+
81+
Use the explicit loopback disposable PostgreSQL URL described in
82+
[jupiter-migrate](../../jupiter-migrate/README.md), then run:
83+
84+
```bash
85+
cargo test -p jupiter --lib publication_storage --locked -- --include-ignored --nocapture
86+
cargo test -p jupiter-migrate --lib snapshot --locked -- --include-ignored --nocapture
87+
```
88+
89+
The six publication tests cover SQLite lifecycle, duplicate-key concurrency and
90+
expected-old competition, plus PostgreSQL lifecycle/reconnect, concurrent
91+
duplicates/expected-old writers and an independent-connection epoch change.
92+
Shared lifecycle checks also inject failure after head CAS, drop an uncommitted
93+
transaction, reject different-payload replay, preserve old views and verify
94+
no-op ref writes. They use the REAL import_refs and new publication tables.
95+
PostgreSQL tests create fresh random schemas, retain diagnostics and never
96+
refresh a supplied database. Tests do not cover a process/host power loss,
97+
external payload durability, notification delivery, source/path permissions or
98+
release-policy bypass through actual production routes.
99+
100+
The CI focused snapshot job runs these PostgreSQL tests explicitly instead of
101+
silently skipping ignored tests. MG06/MG09/MG15 have additional storage-level
102+
evidence; the broader acceptance IDs remain incomplete until application and
103+
real-service integration are tested.

docs/spec/namespace-snapshot-spec.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Mega 提供两个可独立验收的能力:
5757

5858
### 3.2 Scope 证明
5959

60-
拟新增 `source_commit_scope`唯一键 `(source_id, scope_path, algorithm, commit_oid)`,保存 root_tree_oid、证明类型和可审计来源(产生该对象的 ref mutation/父 scope 映射/已发布 root)。
60+
已添加 `source_commit_scope`逻辑唯一键 `(source_id, scope_path, algorithm, commit_oid)`,保存 root_tree_oid、证明类型和可审计来源(产生该对象的 ref mutation/父 scope 映射/已发布 root)。实际数据库索引使用 scope 的 SHA-256 key 并核对完整路径,避免 PostgreSQL 长路径 btree 限制;所有生产创建/merge 入口的证明写入仍需接入
6161

6262
证明在原生 root/子 scope commit 创建、scope clone 派生、CL 接收、merge 生成路径 commits 时一起记录。已知 root commit 可沿已验证 root 历史建立 root-scope 关系;不能把任意存在于 `mega_commit` 的对象默认视为 `/`
6363

@@ -77,15 +77,17 @@ tag 解析返回 ref OID、必要的 annotated-tag peeling 链与最终 commit
7777

7878
路径以组件匹配;`/rust` 不匹配 `/rust_v1`。v1 采用有效 UTF-8 路径组件,不做大小写折叠/Unicode 归一化;拒绝 NUL、`.``..`、重复分隔等非规范输入,非 UTF-8 名称明确返回 unsupported,不静默改名。Git 路径语义不套用 Windows 路径规则。路径编码规则进入 schema 版本。
7979

80-
建议实现持久化压缩 byte-radix/Merkle trie:组件间使用禁止出现在名称中的 NUL 作为内部边界,内部节点最多 256 个分支,value 保存独立 binding digest。节点大小、最长路径、递归深度有硬上限;压缩长 label 必须仍受节点上限约束
80+
已实现的 [索引基础](namespace-index-v1.md) 是持久化压缩 byte-radix/Merkle trie:组件间使用禁止出现在名称中的 NUL 作为内部边界,内部节点最多 256 个分支,value 保存独立 binding digest。节点大小、最长路径有硬上限,遍历不依赖递归;压缩长 label 仍受节点上限约束。公开分页 cursor、组合策略和保留遍历尚未由这层实现
8181

8282
仅说“重写祖先节点”还不够:根节点若内嵌百万 children,单次更新仍是 O(R)。首个索引 PR 必须证明节点 fanout/大小受限,更新 b 个 binding 的成本受变动 key 长度和受限节点数控制;持久化旧节点继续被旧 view 引用。
8383

8484
按 prefix seek 和分页,不在每次 mount 或每页 readdir 扫描全 registry。cursor 绑定 view_id、prefix、最后排序 key、schema 与查询参数并防篡改;续页重新鉴权。ScorpioFS directory handle 绑定该 view,不能跨 view 使用 cookie。
8585

8686
百万 binding 测试记录 node reads/writes、bytes、峰值内存和分页工作量,不只报告平均耗时。初始全量建索引允许 O(R),在线单点发布和小工作集 mount 不允许。
8787

88-
## 5. 元数据模型(拟新增,不是现有数据库表)
88+
## 5. 元数据模型(目标模型,部分已落地)
89+
90+
当前 additive schema 已包含 source/scope、namespace_node、view/head/publication、operation 与 outbox;binding_head、pin 及完整业务发布策略尚未落地。表存在不代表已部署或启用完整 namespace capability。
8991

9092
| 表/存储 | 关键字段与约束 | 用途 |
9193
| --- | --- | --- |
@@ -108,6 +110,8 @@ tag 解析返回 ref OID、必要的 annotated-tag peeling 链与最终 commit
108110

109111
## 6. 发布事务与并发约束
110112

113+
已实现的 [publication storage core](namespace-publication-core.md) 包含操作预留/回执、head CAS、同事务 ref 条件写、view/publication/outbox 持久化与两个数据库的故障/并发测试。当前所有生产 writer 尚未接入,组合策略、prepare pin、对象保留与 HTTP 能力仍未完成;下面是完整应用协议,不把存储核心测试等同于全链路交付。
114+
111115
统一应用服务接收 `PublishPlan {operation_id, expected_head, ref_read_set, binding_read_set, prepared_objects, native_change?, binding_changes}`,返回 `PublicationReceipt {seq, view_id, outcome}`。HTTP endpoint 名称不决定领域模型;Git 与网页编辑调用同一服务。
112116

113117
```text

jupiter-migrate/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,31 @@ under an America/Los_Angeles session. The focused CI job also runs the ignored
107107
million-binding index test. These gates do not validate publication, leases, GC,
108108
writer fencing or the entire workspace; those remain separate acceptance work.
109109

110+
## Publication metadata generation and tests
111+
112+
The additive `m20260906_160000_namespace_publication` migration creates five more
113+
tables without initializing a head or enabling an API. Generate only its models
114+
from a new disposable schema (again use a fresh `<temp>` absolute directory):
115+
116+
```bash
117+
cargo run -p jupiter-migrate --example snapshot_schema -- <temp>/publication.db
118+
sea-orm-cli generate entity -u sqlite://<temp>/publication.db -o <temp>/entities --tables namespace_view,namespace_head,namespace_publication,snapshot_operation,namespace_outbox --with-serde both --entity-format dense
119+
```
120+
121+
Copy the five generated models, merge registries and preserve `entity_ext` as
122+
above. All counters use BIGINT and timestamps are timezone-aware. The snapshot
123+
migration roundtrip test now checks all nine snapshot tables. The UTC upgrade
124+
regression locates the UTC migration by name rather than assuming it is last.
125+
126+
`cargo test -p jupiter --lib publication_storage --locked -- --include-ignored
127+
--nocapture` explicitly runs both SQLite and disposable PostgreSQL tests using
128+
the same guarded URL. They cover the real `import_refs` table participating in
129+
the publication transaction, operation replay, CAS, rollback after head update,
130+
concurrent duplicate/expected-old requests and reconnect receipt lookup. See
131+
[publication core](../docs/spec/namespace-publication-core.md) for the precise
132+
evidence boundary: all production writers, composition, pins, authorization and
133+
outbox delivery remain separate requirements.
134+
110135
## Library API reference
111136

112137
```rust
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//! Publication metadata only. No initial head, feature flag or historical
2+
//! catalog is synthesized. All fields use portable SQLite/PostgreSQL types.
3+
4+
use sea_orm_migration::{prelude::*, schema::*};
5+
6+
#[derive(DeriveMigrationName)]
7+
pub struct Migration;
8+
9+
#[async_trait::async_trait]
10+
impl MigrationTrait for Migration {
11+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
12+
manager
13+
.create_table(
14+
Table::create()
15+
.table(Meta::NamespaceView)
16+
.if_not_exists()
17+
.col(string(Meta::ViewId).primary_key())
18+
.col(string(Meta::InstanceId))
19+
.col(var_binary(Meta::CanonicalBytes, 16384))
20+
.col(timestamp_with_time_zone(Meta::CreatedAt))
21+
.check(Expr::cust("length(canonical_bytes) <= 16384"))
22+
.to_owned(),
23+
)
24+
.await?;
25+
manager
26+
.create_table(
27+
Table::create()
28+
.table(Meta::NamespaceHead)
29+
.if_not_exists()
30+
.col(string(Meta::InstanceId).primary_key())
31+
.col(big_integer(Meta::PublicationSeq))
32+
.col(string(Meta::ViewId))
33+
.col(big_integer(Meta::WriterEpoch))
34+
.check(Expr::cust("publication_seq > 0 AND writer_epoch > 0"))
35+
.to_owned(),
36+
)
37+
.await?;
38+
manager.create_table(Table::create()
39+
.table(Meta::NamespacePublication).if_not_exists()
40+
.col(string(Meta::InstanceId))
41+
.col(big_integer(Meta::PublicationSeq))
42+
.col(string(Meta::ViewId))
43+
.col(ColumnDef::new(Meta::ParentSeq).big_integer().null())
44+
.col(ColumnDef::new(Meta::ParentViewId).string().null())
45+
.col(big_integer(Meta::WriterEpoch))
46+
.col(string(Meta::ActorDomain))
47+
.col(string(Meta::OperationId))
48+
.col(string(Meta::Reason))
49+
.col(timestamp_with_time_zone(Meta::CreatedAt))
50+
.primary_key(Index::create().col(Meta::InstanceId).col(Meta::PublicationSeq))
51+
.check(Expr::cust("publication_seq > 0 AND writer_epoch > 0"))
52+
.check(Expr::cust("(parent_seq IS NULL AND parent_view_id IS NULL) OR (parent_seq IS NOT NULL AND parent_seq > 0 AND parent_view_id IS NOT NULL)"))
53+
.to_owned()).await?;
54+
manager.create_table(Table::create()
55+
.table(Meta::SnapshotOperation).if_not_exists()
56+
.col(string(Meta::ActorDomain))
57+
.col(string(Meta::OperationId))
58+
.col(string(Meta::InstanceId))
59+
.col(string(Meta::RequestDigest))
60+
.col(ColumnDef::new(Meta::PublicationSeq).big_integer().null())
61+
.col(ColumnDef::new(Meta::ViewId).string().null())
62+
.col(ColumnDef::new(Meta::Outcome).string().null())
63+
.col(timestamp_with_time_zone(Meta::CreatedAt))
64+
.primary_key(Index::create().col(Meta::ActorDomain).col(Meta::OperationId))
65+
.check(Expr::cust("(publication_seq IS NULL AND view_id IS NULL AND outcome IS NULL) OR (publication_seq IS NOT NULL AND publication_seq > 0 AND view_id IS NOT NULL AND outcome IS NOT NULL AND outcome IN ('published', 'no_op'))"))
66+
.to_owned()).await?;
67+
manager
68+
.create_table(
69+
Table::create()
70+
.table(Meta::NamespaceOutbox)
71+
.if_not_exists()
72+
.col(string(Meta::EventId).primary_key())
73+
.col(string(Meta::InstanceId))
74+
.col(big_integer(Meta::PublicationSeq))
75+
.col(string(Meta::ViewId))
76+
.col(boolean(Meta::Delivered).default(false))
77+
.col(timestamp_with_time_zone(Meta::CreatedAt))
78+
.check(Expr::cust("publication_seq > 0"))
79+
.to_owned(),
80+
)
81+
.await?;
82+
manager
83+
.create_index(
84+
Index::create()
85+
.name("namespace_outbox_pending")
86+
.table(Meta::NamespaceOutbox)
87+
.col(Meta::Delivered)
88+
.col(Meta::CreatedAt)
89+
.to_owned(),
90+
)
91+
.await
92+
}
93+
94+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
95+
// Disposable-test DDL rollback only; application rollback retains history.
96+
for table in [
97+
Meta::NamespaceOutbox,
98+
Meta::SnapshotOperation,
99+
Meta::NamespacePublication,
100+
Meta::NamespaceHead,
101+
Meta::NamespaceView,
102+
] {
103+
manager
104+
.drop_table(Table::drop().table(table).to_owned())
105+
.await?;
106+
}
107+
Ok(())
108+
}
109+
}
110+
111+
#[derive(DeriveIden)]
112+
enum Meta {
113+
NamespaceView,
114+
NamespaceHead,
115+
NamespacePublication,
116+
SnapshotOperation,
117+
NamespaceOutbox,
118+
ViewId,
119+
InstanceId,
120+
CanonicalBytes,
121+
CreatedAt,
122+
PublicationSeq,
123+
WriterEpoch,
124+
ParentSeq,
125+
ParentViewId,
126+
ActorDomain,
127+
OperationId,
128+
Reason,
129+
RequestDigest,
130+
Outcome,
131+
EventId,
132+
Delivered,
133+
}

jupiter-migrate/src/migration/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ mod m20260811_100000_create_campsite_member_identity;
107107
mod m20260906_120000_snapshot_source_identity;
108108
mod m20260906_140000_namespace_nodes;
109109
mod m20260906_145000_snapshot_utc_timestamps;
110+
mod m20260906_160000_namespace_publication;
110111
mod runner;
111112
#[cfg(test)]
112113
mod snapshot_tests;
@@ -203,6 +204,7 @@ impl MigratorTrait for Migrator {
203204
Box::new(m20260906_120000_snapshot_source_identity::Migration),
204205
Box::new(m20260906_140000_namespace_nodes::Migration),
205206
Box::new(m20260906_145000_snapshot_utc_timestamps::Migration),
207+
Box::new(m20260906_160000_namespace_publication::Migration),
206208
]
207209
}
208210
}

0 commit comments

Comments
 (0)