create-feltdb 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
- package/dist/server-source/crates/feltdb/src/application.rs +16 -0
- package/dist/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
- package/dist/server-source/crates/feltdb/src/lib.rs +556 -45
- package/dist/server-source/crates/feltdb/src/state_contract.rs +48 -3
- package/dist/server-source/crates/feltdb/src/state_model.rs +569 -4
- package/dist/server-source/crates/feltdb/src/state_trigger.rs +1 -0
- package/dist/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
- package/dist/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/server-source/crates/feltdb/src/workload.rs +480 -44
- package/dist/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
- package/dist/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
- package/dist/server-source/crates/feltdb/tests/local_process_ownership.rs +69 -0
- package/dist/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
- package/dist/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +49 -24
- package/dist/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
- package/dist/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
- package/dist/server-source/crates/feltdb-server/src/app_state.rs +14 -4
- package/dist/server-source/crates/feltdb-server/src/backup.rs +0 -4
- package/dist/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
- package/dist/server-source/crates/feltdb-server/src/causal.rs +905 -144
- package/dist/server-source/crates/feltdb-server/src/connections.rs +9 -6
- package/dist/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
- package/dist/server-source/crates/feltdb-server/src/lib.rs +1 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +418 -82
- package/dist/server-source/crates/feltdb-server/src/principals.rs +9 -501
- package/dist/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
- package/dist/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
- package/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
//! Evidence for generalized version-control semantics over data representations.
|
|
2
|
+
//!
|
|
3
|
+
//! This test suite adds no new capability. It checks that the same revision
|
|
4
|
+
//! machinery behaves the same for source-shaped, artifact-shaped, and
|
|
5
|
+
//! application-shaped state.
|
|
6
|
+
|
|
7
|
+
use feltdb::{
|
|
8
|
+
reconcile, ConflictClass, ConflictClassification, ReconciliationPlan, Relationship, SemanticDiff,
|
|
9
|
+
StateStore, StateTopology,
|
|
10
|
+
};
|
|
11
|
+
use serde_json::{json, Value};
|
|
12
|
+
|
|
13
|
+
struct Scenario {
|
|
14
|
+
resource: &'static str,
|
|
15
|
+
base: Value,
|
|
16
|
+
left: Value,
|
|
17
|
+
right: Value,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
21
|
+
struct Outcome {
|
|
22
|
+
relationship: Relationship,
|
|
23
|
+
has_lca: bool,
|
|
24
|
+
left_changes: usize,
|
|
25
|
+
right_changes: usize,
|
|
26
|
+
overall: ConflictClass,
|
|
27
|
+
path_conflicts: usize,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn run_scenario(scenario: Scenario) -> Outcome {
|
|
31
|
+
let store = StateStore::new_volatile();
|
|
32
|
+
let base = store
|
|
33
|
+
.create(scenario.resource, scenario.base.to_string(), "audit".to_string())
|
|
34
|
+
.expect("base");
|
|
35
|
+
let left = store
|
|
36
|
+
.commit(scenario.left.to_string(), &base, "audit".to_string())
|
|
37
|
+
.expect("left");
|
|
38
|
+
let right = store
|
|
39
|
+
.commit(scenario.right.to_string(), &base, "audit".to_string())
|
|
40
|
+
.expect("right");
|
|
41
|
+
|
|
42
|
+
let mut topology = StateTopology::new();
|
|
43
|
+
for revision in [&base, &left, &right] {
|
|
44
|
+
topology.add_revision(revision.clone());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let left_diff = SemanticDiff::compute(&scenario.base, &scenario.left);
|
|
48
|
+
let right_diff = SemanticDiff::compute(&scenario.base, &scenario.right);
|
|
49
|
+
let classification = ConflictClassification::classify(&base, &left, &right);
|
|
50
|
+
|
|
51
|
+
let plan = ReconciliationPlan::new(left.id.clone(), right.id.clone(), base.id.clone(), true);
|
|
52
|
+
let merged = reconcile(&base, &left, &right, &plan, "reconciler".to_string()).expect("merge");
|
|
53
|
+
assert_eq!(
|
|
54
|
+
merged.materialized_state.parent_id.as_ref(),
|
|
55
|
+
Some(&left.id),
|
|
56
|
+
"the selected parent is honored independent of representation"
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
Outcome {
|
|
60
|
+
relationship: topology.relationship(&left.id, &right.id),
|
|
61
|
+
has_lca: topology.common_ancestor(&left.id, &right.id).is_some(),
|
|
62
|
+
left_changes: left_diff.changes.len(),
|
|
63
|
+
right_changes: right_diff.changes.len(),
|
|
64
|
+
overall: classification.overall,
|
|
65
|
+
path_conflicts: classification.path_conflicts.len(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[test]
|
|
70
|
+
fn source_artifact_and_application_states_share_the_same_version_control_behavior() {
|
|
71
|
+
let source = run_scenario(Scenario {
|
|
72
|
+
resource: "source:src/main.rs",
|
|
73
|
+
base: json!({"body":"fn main() {}","digest":"h1"}),
|
|
74
|
+
left: json!({"body":"fn main() { println!(\"ok\"); }","digest":"h1"}),
|
|
75
|
+
right: json!({"body":"fn main() {}","digest":"h2"}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let artifact = run_scenario(Scenario {
|
|
79
|
+
resource: "artifact:dist/app.wasm",
|
|
80
|
+
base: json!({"payload":"AA==","digest":"h1"}),
|
|
81
|
+
left: json!({"payload":"AQ==","digest":"h1"}),
|
|
82
|
+
right: json!({"payload":"AA==","digest":"h2"}),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let application = run_scenario(Scenario {
|
|
86
|
+
resource: "application:tasks/1",
|
|
87
|
+
base: json!({"state":"open","digest":"h1"}),
|
|
88
|
+
left: json!({"state":"closed","digest":"h1"}),
|
|
89
|
+
right: json!({"state":"open","digest":"h2"}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let expected = Outcome {
|
|
93
|
+
relationship: Relationship::Diverged,
|
|
94
|
+
has_lca: true,
|
|
95
|
+
left_changes: 1,
|
|
96
|
+
right_changes: 1,
|
|
97
|
+
overall: ConflictClass::Independent,
|
|
98
|
+
path_conflicts: 2,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
assert_eq!(source, expected);
|
|
102
|
+
assert_eq!(artifact, expected);
|
|
103
|
+
assert_eq!(application, expected);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[test]
|
|
107
|
+
fn mutating_resource_identity_changes_ids_not_version_control_semantics() {
|
|
108
|
+
let source_namespace = run_scenario(Scenario {
|
|
109
|
+
resource: "source:module/alpha",
|
|
110
|
+
base: json!({"value":1,"tag":"stable"}),
|
|
111
|
+
left: json!({"value":2,"tag":"stable"}),
|
|
112
|
+
right: json!({"value":1,"tag":"candidate"}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
let artifact_namespace = run_scenario(Scenario {
|
|
116
|
+
resource: "artifact:module/alpha",
|
|
117
|
+
base: json!({"value":1,"tag":"stable"}),
|
|
118
|
+
left: json!({"value":2,"tag":"stable"}),
|
|
119
|
+
right: json!({"value":1,"tag":"candidate"}),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
assert_eq!(source_namespace, artifact_namespace);
|
|
123
|
+
}
|
package/dist/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
use feltdb::{
|
|
2
|
+
MaterializationError, MaterializationProjection, PathComponent, RefError, StateId, StateStore,
|
|
3
|
+
};
|
|
4
|
+
use feltdb::state_model::GarbageCollectionReport;
|
|
5
|
+
use serde_json::json;
|
|
6
|
+
use std::collections::BTreeMap;
|
|
7
|
+
use std::sync::Arc;
|
|
8
|
+
use tempfile::TempDir;
|
|
9
|
+
|
|
10
|
+
fn durable_store(name: &str) -> (TempDir, StateStore) {
|
|
11
|
+
let directory = TempDir::new().unwrap();
|
|
12
|
+
let db = Arc::new(feltdb::FeltDb::open(directory.path().join(name)).unwrap());
|
|
13
|
+
let store = StateStore::with_feltdb(db).unwrap();
|
|
14
|
+
(directory, store)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn projection(paths: Vec<Vec<PathComponent>>) -> MaterializationProjection {
|
|
18
|
+
MaterializationProjection::select(paths)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[test]
|
|
22
|
+
fn refs_cas_enforcement_rejects_stale_moves() {
|
|
23
|
+
let store = StateStore::new_volatile();
|
|
24
|
+
let a = store
|
|
25
|
+
.create("docs:1", json!({"n": 1}).to_string(), "auth".into())
|
|
26
|
+
.unwrap();
|
|
27
|
+
let b = store
|
|
28
|
+
.commit(json!({"n": 2}).to_string(), &a, "auth".into())
|
|
29
|
+
.unwrap();
|
|
30
|
+
store
|
|
31
|
+
.create_ref("refs", "main", a.id.clone(), "auth".into(), BTreeMap::new())
|
|
32
|
+
.unwrap();
|
|
33
|
+
|
|
34
|
+
store
|
|
35
|
+
.compare_and_swap_ref("refs", "main", 1, b.id.clone(), "auth".into(), None)
|
|
36
|
+
.unwrap();
|
|
37
|
+
let stale = store.compare_and_swap_ref("refs", "main", 1, a.id.clone(), "auth".into(), None);
|
|
38
|
+
assert!(matches!(
|
|
39
|
+
stale,
|
|
40
|
+
Err(RefError::VersionConflict {
|
|
41
|
+
expected: 1,
|
|
42
|
+
current: 2
|
|
43
|
+
})
|
|
44
|
+
));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[test]
|
|
48
|
+
fn refs_persist_target_and_versions_across_restart() {
|
|
49
|
+
let (directory, store) = durable_store("refs.log");
|
|
50
|
+
let a = store
|
|
51
|
+
.create("docs:1", json!({"n": 1}).to_string(), "auth".into())
|
|
52
|
+
.unwrap();
|
|
53
|
+
let b = store
|
|
54
|
+
.commit(json!({"n": 2}).to_string(), &a, "auth".into())
|
|
55
|
+
.unwrap();
|
|
56
|
+
|
|
57
|
+
store
|
|
58
|
+
.create_ref("refs", "main", a.id.clone(), "auth".into(), BTreeMap::new())
|
|
59
|
+
.unwrap();
|
|
60
|
+
let moved = store.move_ref("refs", "main", b.id.clone(), "auth2".into()).unwrap();
|
|
61
|
+
assert_eq!(moved.target, b.id);
|
|
62
|
+
assert_eq!(moved.version, 2);
|
|
63
|
+
|
|
64
|
+
let reopened = StateStore::with_feltdb(Arc::new(
|
|
65
|
+
feltdb::FeltDb::open(directory.path().join("refs.log")).unwrap(),
|
|
66
|
+
))
|
|
67
|
+
.unwrap();
|
|
68
|
+
let persisted = reopened.get_ref("refs", "main").unwrap();
|
|
69
|
+
assert_eq!(persisted.target, moved.target);
|
|
70
|
+
assert_eq!(persisted.version, moved.version);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#[test]
|
|
74
|
+
fn refs_namespace_isolation_deletion_and_enumeration() {
|
|
75
|
+
let store = StateStore::new_volatile();
|
|
76
|
+
let revision = store
|
|
77
|
+
.create("docs:1", json!({"n": 1}).to_string(), "auth".into())
|
|
78
|
+
.unwrap();
|
|
79
|
+
|
|
80
|
+
store
|
|
81
|
+
.create_ref("refs", "main", revision.id.clone(), "auth".into(), BTreeMap::new())
|
|
82
|
+
.unwrap();
|
|
83
|
+
store
|
|
84
|
+
.create_ref("tags", "main", revision.id.clone(), "auth".into(), BTreeMap::new())
|
|
85
|
+
.unwrap();
|
|
86
|
+
|
|
87
|
+
let refs_all = store.list_refs(None);
|
|
88
|
+
assert_eq!(refs_all.len(), 2);
|
|
89
|
+
assert_eq!(store.list_refs(Some("refs")).len(), 1);
|
|
90
|
+
assert_eq!(store.list_refs(Some("tags")).len(), 1);
|
|
91
|
+
|
|
92
|
+
store.delete_ref("tags", "main", Some(1)).unwrap();
|
|
93
|
+
assert!(store.get_ref("tags", "main").is_none());
|
|
94
|
+
assert!(store.get_ref("refs", "main").is_some());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[test]
|
|
98
|
+
fn materialization_projection_filters_revision_selection_and_nested_paths() {
|
|
99
|
+
let store = StateStore::new_volatile();
|
|
100
|
+
let base = store
|
|
101
|
+
.create(
|
|
102
|
+
"source:file",
|
|
103
|
+
json!({"tree":{"src":{"lib.rs":"v1","main.rs":"m1"}},"meta":{"lang":"rust"}})
|
|
104
|
+
.to_string(),
|
|
105
|
+
"auth".into(),
|
|
106
|
+
)
|
|
107
|
+
.unwrap();
|
|
108
|
+
let head = store
|
|
109
|
+
.commit(
|
|
110
|
+
json!({"tree":{"src":{"lib.rs":"v2","main.rs":"m1"}},"meta":{"lang":"rust"}})
|
|
111
|
+
.to_string(),
|
|
112
|
+
&base,
|
|
113
|
+
"auth".into(),
|
|
114
|
+
)
|
|
115
|
+
.unwrap();
|
|
116
|
+
|
|
117
|
+
let view = store
|
|
118
|
+
.materialize(
|
|
119
|
+
&head.id,
|
|
120
|
+
&projection(vec![
|
|
121
|
+
vec![PathComponent::Key("tree".into()), PathComponent::Key("src".into()), PathComponent::Key("lib.rs".into())],
|
|
122
|
+
]),
|
|
123
|
+
)
|
|
124
|
+
.unwrap();
|
|
125
|
+
assert_eq!(view, json!({"tree":{"src":{"lib.rs":"v2"}}}));
|
|
126
|
+
|
|
127
|
+
let base_view = store
|
|
128
|
+
.materialize(
|
|
129
|
+
&base.id,
|
|
130
|
+
&projection(vec![
|
|
131
|
+
vec![PathComponent::Key("tree".into()), PathComponent::Key("src".into()), PathComponent::Key("lib.rs".into())],
|
|
132
|
+
]),
|
|
133
|
+
)
|
|
134
|
+
.unwrap();
|
|
135
|
+
assert_eq!(base_view, json!({"tree":{"src":{"lib.rs":"v1"}}}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[test]
|
|
139
|
+
fn materialization_missing_paths_and_deterministic_output_are_enforced() {
|
|
140
|
+
let store = StateStore::new_volatile();
|
|
141
|
+
let revision = store
|
|
142
|
+
.create(
|
|
143
|
+
"artifact:item",
|
|
144
|
+
json!({"manifest":{"digest":"abc","size":7},"chunks":[{"id":1},{"id":2}]}).to_string(),
|
|
145
|
+
"auth".into(),
|
|
146
|
+
)
|
|
147
|
+
.unwrap();
|
|
148
|
+
|
|
149
|
+
let missing = store.materialize(
|
|
150
|
+
&revision.id,
|
|
151
|
+
&projection(vec![vec![
|
|
152
|
+
PathComponent::Key("manifest".into()),
|
|
153
|
+
PathComponent::Key("missing".into()),
|
|
154
|
+
]]),
|
|
155
|
+
);
|
|
156
|
+
assert!(matches!(missing, Err(MaterializationError::MissingPath(_))));
|
|
157
|
+
|
|
158
|
+
let first = store
|
|
159
|
+
.materialize(
|
|
160
|
+
&revision.id,
|
|
161
|
+
&projection(vec![
|
|
162
|
+
vec![PathComponent::Key("manifest".into()), PathComponent::Key("size".into())],
|
|
163
|
+
vec![PathComponent::Key("chunks".into()), PathComponent::Index(1)],
|
|
164
|
+
]),
|
|
165
|
+
)
|
|
166
|
+
.unwrap();
|
|
167
|
+
let second = store
|
|
168
|
+
.materialize(
|
|
169
|
+
&revision.id,
|
|
170
|
+
&projection(vec![
|
|
171
|
+
vec![PathComponent::Key("chunks".into()), PathComponent::Index(1)],
|
|
172
|
+
vec![PathComponent::Key("manifest".into()), PathComponent::Key("size".into())],
|
|
173
|
+
]),
|
|
174
|
+
)
|
|
175
|
+
.unwrap();
|
|
176
|
+
assert_eq!(first, second);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn setup_gc_graph(store: &StateStore) -> (StateId, StateId, StateId, StateId) {
|
|
180
|
+
let a = store
|
|
181
|
+
.create("graph:r", json!({"node":"A"}).to_string(), "auth".into())
|
|
182
|
+
.unwrap();
|
|
183
|
+
let b = store
|
|
184
|
+
.commit(json!({"node":"B"}).to_string(), &a, "auth".into())
|
|
185
|
+
.unwrap();
|
|
186
|
+
let c = store
|
|
187
|
+
.commit(json!({"node":"C"}).to_string(), &b, "auth".into())
|
|
188
|
+
.unwrap();
|
|
189
|
+
let d = store
|
|
190
|
+
.commit(json!({"node":"D"}).to_string(), &b, "auth".into())
|
|
191
|
+
.unwrap();
|
|
192
|
+
(a.id, b.id, c.id, d.id)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
fn collect(store: &StateStore) -> GarbageCollectionReport {
|
|
196
|
+
store.collect_unreachable().unwrap()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn gc_root_discovery_and_unreachable_collection() {
|
|
201
|
+
let store = StateStore::new_volatile();
|
|
202
|
+
let (_a, _b, _c, _d) = setup_gc_graph(&store);
|
|
203
|
+
let report = collect(&store);
|
|
204
|
+
assert_eq!(report.roots.len(), 0);
|
|
205
|
+
assert_eq!(report.reachable_revisions.len(), 0);
|
|
206
|
+
assert_eq!(report.reachable_objects.len(), 0);
|
|
207
|
+
assert_eq!(report.collected_revisions.len(), 4);
|
|
208
|
+
assert_eq!(report.collected_objects.len(), 4);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[test]
|
|
212
|
+
fn gc_parent_and_content_traversal_preserves_shared_history() {
|
|
213
|
+
let store = StateStore::new_volatile();
|
|
214
|
+
let (a, b, c, d) = setup_gc_graph(&store);
|
|
215
|
+
store
|
|
216
|
+
.create_ref("refs", "c", c.clone(), "auth".into(), BTreeMap::new())
|
|
217
|
+
.unwrap();
|
|
218
|
+
store
|
|
219
|
+
.create_ref("refs", "d", d.clone(), "auth".into(), BTreeMap::new())
|
|
220
|
+
.unwrap();
|
|
221
|
+
|
|
222
|
+
let keep_all = collect(&store);
|
|
223
|
+
assert!(keep_all.collected_revisions.is_empty());
|
|
224
|
+
|
|
225
|
+
store.delete_ref("refs", "d", Some(1)).unwrap();
|
|
226
|
+
let after_delete = collect(&store);
|
|
227
|
+
assert!(after_delete.collected_revisions.contains(&d));
|
|
228
|
+
assert!(!after_delete.collected_revisions.contains(&b));
|
|
229
|
+
assert!(!after_delete.collected_revisions.contains(&c));
|
|
230
|
+
assert!(!after_delete.collected_revisions.contains(&a));
|
|
231
|
+
assert_eq!(after_delete.collected_revisions.len(), 1);
|
|
232
|
+
assert_eq!(after_delete.collected_objects.len(), 1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#[test]
|
|
236
|
+
fn substrate_is_domain_neutral_for_refs_materialization_and_gc() {
|
|
237
|
+
let store = StateStore::new_volatile();
|
|
238
|
+
|
|
239
|
+
let scenarios = vec![
|
|
240
|
+
(
|
|
241
|
+
"source:module",
|
|
242
|
+
json!({"tree":{"src":{"lib.rs":"v1","main.rs":"v1"}}}),
|
|
243
|
+
vec![PathComponent::Key("tree".into()), PathComponent::Key("src".into()), PathComponent::Key("lib.rs".into())],
|
|
244
|
+
),
|
|
245
|
+
(
|
|
246
|
+
"artifact:bundle",
|
|
247
|
+
json!({"manifest":{"digest":"h1"},"payload":{"chunks":["a","b"]}}),
|
|
248
|
+
vec![PathComponent::Key("manifest".into()), PathComponent::Key("digest".into())],
|
|
249
|
+
),
|
|
250
|
+
(
|
|
251
|
+
"application:state",
|
|
252
|
+
json!({"entities":{"tasks":{"1":{"status":"open"}}},"meta":{"v":1}}),
|
|
253
|
+
vec![PathComponent::Key("entities".into()), PathComponent::Key("tasks".into()), PathComponent::Key("1".into()), PathComponent::Key("status".into())],
|
|
254
|
+
),
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (resource, content, path) in scenarios {
|
|
258
|
+
let rev = store
|
|
259
|
+
.create(resource, content.to_string(), "auth".into())
|
|
260
|
+
.unwrap();
|
|
261
|
+
store
|
|
262
|
+
.create_ref("heads", resource, rev.id.clone(), "auth".into(), BTreeMap::new())
|
|
263
|
+
.unwrap();
|
|
264
|
+
let view = store
|
|
265
|
+
.materialize(&rev.id, &projection(vec![path]))
|
|
266
|
+
.unwrap();
|
|
267
|
+
assert!(view.is_object());
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let keep = collect(&store);
|
|
271
|
+
assert_eq!(keep.collected_revisions.len(), 0);
|
|
272
|
+
|
|
273
|
+
for reference in store.list_refs(Some("heads")) {
|
|
274
|
+
store
|
|
275
|
+
.delete_ref("heads", &reference.name, Some(reference.version))
|
|
276
|
+
.unwrap();
|
|
277
|
+
}
|
|
278
|
+
let collected = collect(&store);
|
|
279
|
+
assert_eq!(collected.collected_revisions.len(), 3);
|
|
280
|
+
assert_eq!(collected.collected_objects.len(), 3);
|
|
281
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
use std::process::Command;
|
|
2
|
+
|
|
3
|
+
use feltdb::{AtomicMutation, FeltDb};
|
|
4
|
+
use serde_json::json;
|
|
5
|
+
use tempfile::TempDir;
|
|
6
|
+
|
|
7
|
+
const CHILD_ENV: &str = "FELTDB_OWNERSHIP_CHILD";
|
|
8
|
+
const PATH_ENV: &str = "FELTDB_OWNERSHIP_PATH";
|
|
9
|
+
const EXPECT_LOCKED_ENV: &str = "FELTDB_EXPECT_LOCKED";
|
|
10
|
+
|
|
11
|
+
#[test]
|
|
12
|
+
fn ownership_child() {
|
|
13
|
+
if std::env::var_os(CHILD_ENV).is_none() {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
let path = std::env::var(PATH_ENV).unwrap();
|
|
17
|
+
let expect_locked = std::env::var_os(EXPECT_LOCKED_ENV).is_some();
|
|
18
|
+
match FeltDb::open(path) {
|
|
19
|
+
Ok(database) => {
|
|
20
|
+
assert!(!expect_locked, "a second owner opened the database");
|
|
21
|
+
let mutation = AtomicMutation {
|
|
22
|
+
capability: "probe".into(),
|
|
23
|
+
key: "probe:child".into(),
|
|
24
|
+
value: Some(json!({ "owner": "child" })),
|
|
25
|
+
};
|
|
26
|
+
database
|
|
27
|
+
.apply_atomic_transaction("child", None, &[], &[mutation], None)
|
|
28
|
+
.unwrap();
|
|
29
|
+
}
|
|
30
|
+
Err(error) => {
|
|
31
|
+
assert!(expect_locked, "an unowned database failed to open: {error}");
|
|
32
|
+
assert!(error.to_string().contains("already owned"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn child(path: &std::path::Path, expect_locked: bool) -> std::process::ExitStatus {
|
|
38
|
+
let mut command = Command::new(std::env::current_exe().unwrap());
|
|
39
|
+
command
|
|
40
|
+
.arg("--exact")
|
|
41
|
+
.arg("ownership_child")
|
|
42
|
+
.arg("--nocapture")
|
|
43
|
+
.env(CHILD_ENV, "1")
|
|
44
|
+
.env(PATH_ENV, path);
|
|
45
|
+
if expect_locked {
|
|
46
|
+
command.env(EXPECT_LOCKED_ENV, "1");
|
|
47
|
+
}
|
|
48
|
+
command.status().unwrap()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[test]
|
|
52
|
+
fn another_process_cannot_read_write_or_transact_while_owned() {
|
|
53
|
+
let directory = TempDir::new().unwrap();
|
|
54
|
+
let path = directory.path().join("shared.db");
|
|
55
|
+
let _owner = FeltDb::open(&path).unwrap();
|
|
56
|
+
assert!(child(&path, true).success());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[test]
|
|
60
|
+
fn ownership_is_released_when_the_process_exits() {
|
|
61
|
+
let directory = TempDir::new().unwrap();
|
|
62
|
+
let path = directory.path().join("shared.db");
|
|
63
|
+
assert!(child(&path, false).success());
|
|
64
|
+
let database = FeltDb::open(path).unwrap();
|
|
65
|
+
assert_eq!(
|
|
66
|
+
database.get::<serde_json::Value>("probe:child").unwrap(),
|
|
67
|
+
Some(json!({ "owner": "child" }))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -1018,9 +1018,21 @@ fn retention_cost_grows_with_the_window_and_inflates_the_log() {
|
|
|
1018
1018
|
(start.elapsed(), std::fs::metadata(&path).unwrap().len())
|
|
1019
1019
|
}
|
|
1020
1020
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1021
|
+
// Compare the best of repeated independent runs. The workspace executes
|
|
1022
|
+
// other crash/soak processes concurrently, so a single wall-clock sample
|
|
1023
|
+
// can measure scheduler delay rather than retention work. Taking the
|
|
1024
|
+
// minimum preserves the envelope claim while filtering that one-sided
|
|
1025
|
+
// noise; every sample still performs the complete durable workload.
|
|
1026
|
+
fn sample(run_once: impl Fn() -> (std::time::Duration, u64)) -> (std::time::Duration, u64) {
|
|
1027
|
+
(0..3)
|
|
1028
|
+
.map(|_| run_once())
|
|
1029
|
+
.min_by_key(|(elapsed, _)| *elapsed)
|
|
1030
|
+
.unwrap()
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
let (none_time, none_bytes) = sample(|| run(None));
|
|
1034
|
+
let (small_time, small_bytes) = sample(|| run(Some(5)));
|
|
1035
|
+
let (large_time, _) = sample(|| run(Some(50)));
|
|
1024
1036
|
|
|
1025
1037
|
assert!(
|
|
1026
1038
|
large_time > small_time,
|
|
@@ -74,7 +74,17 @@ fn compacted_log_after_revisions(count: usize, pad: usize) -> String {
|
|
|
74
74
|
|
|
75
75
|
fn revision_lines(log: &str) -> usize {
|
|
76
76
|
log.lines()
|
|
77
|
-
.
|
|
77
|
+
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
|
78
|
+
.filter(|record| {
|
|
79
|
+
record
|
|
80
|
+
.get("capability")
|
|
81
|
+
.and_then(serde_json::Value::as_str)
|
|
82
|
+
== Some("state")
|
|
83
|
+
&& record
|
|
84
|
+
.get("key")
|
|
85
|
+
.and_then(serde_json::Value::as_str)
|
|
86
|
+
.is_some_and(|key| key.starts_with("state:revision:"))
|
|
87
|
+
})
|
|
78
88
|
.count()
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -188,13 +198,12 @@ fn each_compaction_rewrites_the_whole_revision_history() {
|
|
|
188
198
|
// Cost: what a revision is worth in bytes
|
|
189
199
|
// ---------------------------------------------------------------------------
|
|
190
200
|
|
|
191
|
-
/// **A revision
|
|
192
|
-
/// copy of its own state.**
|
|
201
|
+
/// **A revision and its canonical transaction attribution have linear cost.**
|
|
193
202
|
///
|
|
194
203
|
/// The compacted log is linear in both the revision count and the state size:
|
|
195
204
|
///
|
|
196
205
|
/// ```text
|
|
197
|
-
/// bytes
|
|
206
|
+
/// bytes ≈ header + count × (fixed_overhead + 2 × |content|)
|
|
198
207
|
/// ```
|
|
199
208
|
///
|
|
200
209
|
/// The fixed part is not incidental — it is a 64-character hex identity, a
|
|
@@ -203,43 +212,59 @@ fn each_compaction_rewrites_the_whole_revision_history() {
|
|
|
203
212
|
/// key and the fully-qualified Rust type name. It dominates until the state
|
|
204
213
|
/// being versioned is itself several hundred bytes.
|
|
205
214
|
///
|
|
206
|
-
/// The state is stored
|
|
207
|
-
///
|
|
208
|
-
///
|
|
215
|
+
/// The state is stored once in the revision and once in the canonical
|
|
216
|
+
/// transaction attribution retained for replay and provenance. The latter is
|
|
217
|
+
/// history, rather than a duplicate authority, and survives compaction by
|
|
218
|
+
/// design.
|
|
209
219
|
#[test]
|
|
210
220
|
fn a_revision_costs_a_fixed_overhead_plus_one_copy_of_its_state() {
|
|
211
|
-
let
|
|
221
|
+
let small_ten_log = compacted_log_after_revisions(10, 0);
|
|
222
|
+
let small_ten = small_ten_log.len();
|
|
212
223
|
let small_twenty = compacted_log_after_revisions(20, 0).len();
|
|
213
|
-
let
|
|
214
|
-
let
|
|
224
|
+
let padded_hundred_log = compacted_log_after_revisions(10, 100);
|
|
225
|
+
let padded_thousand_log = compacted_log_after_revisions(10, 1000);
|
|
215
226
|
|
|
216
227
|
// Ten more revisions of a 16-byte state cost ten more per-revision records.
|
|
217
228
|
// Indices 10..19 are one digit wider than 0..9, hence the ten-byte term.
|
|
218
229
|
let ten_more = small_twenty - small_ten - 10;
|
|
219
230
|
let per_revision = ten_more / 10;
|
|
220
231
|
// The shape is unchanged by the resource-scoped model; the constant is
|
|
221
|
-
// not. A revision now
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// measurement.
|
|
232
|
+
// not. A revision now carries its resource, identity and sequence, while
|
|
233
|
+
// the snapshot header also retains the canonical transaction attribution
|
|
234
|
+
// required for replay and provenance. This assertion catches a change in
|
|
235
|
+
// the combined linear shape rather than pretending that history is free.
|
|
226
236
|
assert!(
|
|
227
|
-
(
|
|
237
|
+
(1_800..=2_300).contains(&per_revision),
|
|
228
238
|
"a revision of a 16-byte state costs {per_revision} bytes; \
|
|
229
239
|
the overhead is the record, not the state"
|
|
230
240
|
);
|
|
231
241
|
|
|
232
|
-
// Content is
|
|
233
|
-
//
|
|
242
|
+
// Content is represented in both the revision and its canonical
|
|
243
|
+
// transaction attribution. Count those copies directly instead of
|
|
244
|
+
// requiring unrelated encoded metadata to have byte-for-byte identical
|
|
245
|
+
// size across platforms and runs.
|
|
234
246
|
assert_eq!(
|
|
235
|
-
|
|
236
|
-
10 *
|
|
237
|
-
"
|
|
247
|
+
padded_hundred_log.matches("x".repeat(100).as_str()).count(),
|
|
248
|
+
10 * 2,
|
|
249
|
+
"each state appears in its revision and canonical transaction attribution"
|
|
238
250
|
);
|
|
239
251
|
assert_eq!(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
252
|
+
padded_thousand_log
|
|
253
|
+
.matches("x".repeat(1000).as_str())
|
|
254
|
+
.count(),
|
|
255
|
+
10 * 2,
|
|
256
|
+
"the two-copy relationship remains direct at a kilobyte of state"
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
let hundred_growth = padded_hundred_log.len() - small_ten;
|
|
260
|
+
let thousand_growth = padded_thousand_log.len() - small_ten;
|
|
261
|
+
assert!(
|
|
262
|
+
hundred_growth.abs_diff(10 * 2 * 100) <= 100,
|
|
263
|
+
"the measured 100-byte content term was {hundred_growth} bytes"
|
|
264
|
+
);
|
|
265
|
+
assert!(
|
|
266
|
+
thousand_growth.abs_diff(10 * 2 * 1000) <= 1_000,
|
|
267
|
+
"the measured 1,000-byte content term was {thousand_growth} bytes"
|
|
243
268
|
);
|
|
244
269
|
}
|
|
245
270
|
|