@feltdb/core 0.8.4 → 0.8.6
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/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +165 -0
- package/dist/create/server-source/Cargo.toml +9 -0
- package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
- package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
- package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
- package/dist/create/server-source/crates/feltdb/src/lib.rs +1648 -46
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
- package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
- package/dist/create/server-source/crates/feltdb/src/operation.rs +39 -0
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +79 -70
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +13 -4
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1565 -536
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
- package/dist/create/server-source/crates/feltdb/src/storage.rs +9 -3
- package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +3 -4
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +132 -0
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +272 -0
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +467 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +309 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +445 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +518 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +2 -1
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +80 -25
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1365 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +417 -0
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +442 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +16 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +695 -47
- package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +21 -0
- package/dist/studio-app/assets/{feltdb_wasm-CVQWgXO-.js → feltdb_wasm-C1VhI-U5.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
- package/dist/studio-app/assets/{index-DwgNAIIX.js → index-Bbos1m2U.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workload.d.ts +2 -1
- package/dist/workload.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
//! The production readiness contract.
|
|
2
|
+
//!
|
|
3
|
+
//! This file **implements nothing and fixes nothing**. It makes the claims
|
|
4
|
+
//! FeltDB can and cannot make executable, so that "production ready" stops
|
|
5
|
+
//! being a feeling and becomes a matrix with evidence behind each row.
|
|
6
|
+
//!
|
|
7
|
+
//! It is the executable half of
|
|
8
|
+
//! `docs/architecture/production-readiness.md`, and its failure cases are
|
|
9
|
+
//! catalogued in `docs/architecture/production-failure-matrix.md`.
|
|
10
|
+
//!
|
|
11
|
+
//! # The discipline
|
|
12
|
+
//!
|
|
13
|
+
//! Every claim carries a [`Status`]. **A green test never implies a claim
|
|
14
|
+
//! broader than the scenario it runs.** Where a production claim is larger than
|
|
15
|
+
//! what any test exercises, the status is `PartiallyProven` and the test says
|
|
16
|
+
//! what is missing. Where nothing can be tested, the status is `Unproven` and
|
|
17
|
+
//! there is no test pretending otherwise.
|
|
18
|
+
//!
|
|
19
|
+
//! Several tests here assert a **gap** rather than a guarantee — that a
|
|
20
|
+
//! pre-model database opens with its history invisible, that a truncated log is
|
|
21
|
+
//! accepted silently. They are written that way on purpose: the gap becomes a
|
|
22
|
+
//! regression test, and closing it fails this suite and forces the matrix to be
|
|
23
|
+
//! updated deliberately.
|
|
24
|
+
|
|
25
|
+
use feltdb::state_model::{ParentLookup, RetentionPolicy, StateStore};
|
|
26
|
+
use feltdb::{FeltDb, FlowError, JsonCasResult};
|
|
27
|
+
use serde_json::json;
|
|
28
|
+
use std::collections::HashMap;
|
|
29
|
+
use std::io::Write;
|
|
30
|
+
use std::sync::Arc;
|
|
31
|
+
use tempfile::TempDir;
|
|
32
|
+
|
|
33
|
+
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
34
|
+
enum Status {
|
|
35
|
+
/// Executable evidence exists for the claim as stated.
|
|
36
|
+
Proven,
|
|
37
|
+
/// Bounded scenarios work; the production claim is larger than the evidence.
|
|
38
|
+
PartiallyProven,
|
|
39
|
+
/// No sufficient evidence. Not "probably works".
|
|
40
|
+
Unproven,
|
|
41
|
+
/// A known defect prevents the claim.
|
|
42
|
+
Blocked,
|
|
43
|
+
/// Explicitly outside what the product can offer, with a reason in the data
|
|
44
|
+
/// rather than in the schedule.
|
|
45
|
+
NotApplicable,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// The claim matrix. `docs/architecture/production-readiness.md` is its prose.
|
|
49
|
+
const CLAIMS: &[(&str, Status)] = &[
|
|
50
|
+
// Durability
|
|
51
|
+
(
|
|
52
|
+
"D1 committed mutations survive a clean restart",
|
|
53
|
+
Status::Proven,
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"D2 revision history survives a clean restart",
|
|
57
|
+
Status::Proven,
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
"D3 retention policy and horizon survive a restart",
|
|
61
|
+
Status::Proven,
|
|
62
|
+
),
|
|
63
|
+
(
|
|
64
|
+
"D4 a reopened database continues history rather than restarting it",
|
|
65
|
+
Status::Proven,
|
|
66
|
+
),
|
|
67
|
+
(
|
|
68
|
+
"D5 a single write survives operating-system or power loss",
|
|
69
|
+
Status::Unproven,
|
|
70
|
+
),
|
|
71
|
+
(
|
|
72
|
+
"D6 every crash point leaves a clean prefix of the mutation sequence",
|
|
73
|
+
Status::Proven,
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
"D7 a crash never produces a revision for a record that was not written",
|
|
77
|
+
Status::Proven,
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"D8 a committed write survives the process dying",
|
|
81
|
+
Status::Proven,
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
"D9 a transaction is all-or-nothing across a crash",
|
|
85
|
+
Status::Proven,
|
|
86
|
+
),
|
|
87
|
+
(
|
|
88
|
+
"D10 a crash-recovered database replicates identically",
|
|
89
|
+
Status::Proven,
|
|
90
|
+
),
|
|
91
|
+
// Atomicity and concurrency
|
|
92
|
+
(
|
|
93
|
+
"A1 a stale fenced write is rejected and mints nothing",
|
|
94
|
+
Status::Proven,
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
"A2 concurrent writes to distinct resources keep distinct chains",
|
|
98
|
+
Status::Proven,
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
"A3 concurrent writes to one resource form one correct chain",
|
|
102
|
+
Status::Proven,
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
"A4 a duplicate remote operation is idempotent",
|
|
106
|
+
Status::Proven,
|
|
107
|
+
),
|
|
108
|
+
(
|
|
109
|
+
"A5 a remote operation with a sequence gap is rejected",
|
|
110
|
+
Status::Proven,
|
|
111
|
+
),
|
|
112
|
+
(
|
|
113
|
+
"A6 a crash part-way through a mutation leaves consistent state",
|
|
114
|
+
Status::Proven,
|
|
115
|
+
),
|
|
116
|
+
// Recovery
|
|
117
|
+
(
|
|
118
|
+
"R1 an incomplete final append is recovered and reported, not silently dropped",
|
|
119
|
+
Status::Proven,
|
|
120
|
+
),
|
|
121
|
+
(
|
|
122
|
+
"R2 a complete but invalid record refuses the open, wherever it sits",
|
|
123
|
+
Status::Proven,
|
|
124
|
+
),
|
|
125
|
+
(
|
|
126
|
+
"R3 damaged ancestry is distinguishable from expired ancestry",
|
|
127
|
+
Status::Proven,
|
|
128
|
+
),
|
|
129
|
+
(
|
|
130
|
+
"R4 ancestry damage is surfaced without being asked for",
|
|
131
|
+
Status::Unproven,
|
|
132
|
+
),
|
|
133
|
+
// History and retention
|
|
134
|
+
(
|
|
135
|
+
"H1 history is bounded under a configured policy",
|
|
136
|
+
Status::Proven,
|
|
137
|
+
),
|
|
138
|
+
(
|
|
139
|
+
"H2 history is unbounded without one, by design",
|
|
140
|
+
Status::Proven,
|
|
141
|
+
),
|
|
142
|
+
(
|
|
143
|
+
"H3 a policy change takes effect in both directions",
|
|
144
|
+
Status::Proven,
|
|
145
|
+
),
|
|
146
|
+
("H4 maintenance never rewrites history", Status::Proven),
|
|
147
|
+
("H5 forked history is preserved", Status::Proven),
|
|
148
|
+
// Backup and restore
|
|
149
|
+
(
|
|
150
|
+
"B1 a durable backup artifact is self-contained and independently verifiable",
|
|
151
|
+
Status::Proven,
|
|
152
|
+
),
|
|
153
|
+
(
|
|
154
|
+
"B2 a restore proves the result means the same thing, not merely that it parsed",
|
|
155
|
+
Status::Proven,
|
|
156
|
+
),
|
|
157
|
+
(
|
|
158
|
+
"B3 an altered or truncated backup is refused rather than restored",
|
|
159
|
+
Status::Proven,
|
|
160
|
+
),
|
|
161
|
+
(
|
|
162
|
+
"B4 a restore will not overwrite an existing database",
|
|
163
|
+
Status::Proven,
|
|
164
|
+
),
|
|
165
|
+
(
|
|
166
|
+
"B5 backup and restore are exposed as an operator command-line workflow",
|
|
167
|
+
Status::Unproven,
|
|
168
|
+
),
|
|
169
|
+
// Replication
|
|
170
|
+
("S1 current state replicates to a peer", Status::Proven),
|
|
171
|
+
(
|
|
172
|
+
"S2 revision history replicates to a peer with identical identities",
|
|
173
|
+
Status::Proven,
|
|
174
|
+
),
|
|
175
|
+
(
|
|
176
|
+
"S3 replicated history preserves parents, sequences, forks and provenance",
|
|
177
|
+
Status::Proven,
|
|
178
|
+
),
|
|
179
|
+
(
|
|
180
|
+
"S4 state equivalence and historical equivalence are checked separately",
|
|
181
|
+
Status::Proven,
|
|
182
|
+
),
|
|
183
|
+
(
|
|
184
|
+
"S5 a peer that sends no revision provenance contributes no history",
|
|
185
|
+
Status::Proven,
|
|
186
|
+
),
|
|
187
|
+
// Workload envelope
|
|
188
|
+
(
|
|
189
|
+
"E1 retention cost per write grows with the retention window",
|
|
190
|
+
Status::Proven,
|
|
191
|
+
),
|
|
192
|
+
(
|
|
193
|
+
"E2 retention increases log size before compaction",
|
|
194
|
+
Status::Proven,
|
|
195
|
+
),
|
|
196
|
+
(
|
|
197
|
+
"E3 durability is a selectable contract, and each mode states its guarantee",
|
|
198
|
+
Status::Proven,
|
|
199
|
+
),
|
|
200
|
+
(
|
|
201
|
+
"E4 a stable-storage barrier per write has a measured cost",
|
|
202
|
+
Status::Proven,
|
|
203
|
+
),
|
|
204
|
+
(
|
|
205
|
+
"E5 the durability mode changes the contract and not the stored database",
|
|
206
|
+
Status::Proven,
|
|
207
|
+
),
|
|
208
|
+
(
|
|
209
|
+
"E6 the workload envelope is recorded against a frozen workload and a named environment",
|
|
210
|
+
Status::Proven,
|
|
211
|
+
),
|
|
212
|
+
(
|
|
213
|
+
"E7 grouped durability is measurably cheaper than a barrier per write",
|
|
214
|
+
Status::Unproven,
|
|
215
|
+
),
|
|
216
|
+
// Upgrade and migration
|
|
217
|
+
(
|
|
218
|
+
"U1 an incompatible durable format is detected before any record is interpreted",
|
|
219
|
+
Status::Proven,
|
|
220
|
+
),
|
|
221
|
+
(
|
|
222
|
+
"U2 a pre-model database's revision history is losslessly migratable",
|
|
223
|
+
Status::NotApplicable,
|
|
224
|
+
),
|
|
225
|
+
(
|
|
226
|
+
"U3 a persisted durable format version exists and is checked on open",
|
|
227
|
+
Status::Proven,
|
|
228
|
+
),
|
|
229
|
+
(
|
|
230
|
+
"U4 a refused open leaves the database byte-identical",
|
|
231
|
+
Status::Proven,
|
|
232
|
+
),
|
|
233
|
+
(
|
|
234
|
+
"U5 a durable format from a newer build is refused",
|
|
235
|
+
Status::Proven,
|
|
236
|
+
),
|
|
237
|
+
(
|
|
238
|
+
"U6 a snapshot from another format is refused",
|
|
239
|
+
Status::Proven,
|
|
240
|
+
),
|
|
241
|
+
// Observability
|
|
242
|
+
(
|
|
243
|
+
"O1 an operator can inspect history and retention state",
|
|
244
|
+
Status::PartiallyProven,
|
|
245
|
+
),
|
|
246
|
+
(
|
|
247
|
+
"O2 reported health reflects actual storage state",
|
|
248
|
+
Status::Proven,
|
|
249
|
+
),
|
|
250
|
+
(
|
|
251
|
+
"O3 a recovered open is distinguishable from a clean one in health",
|
|
252
|
+
Status::Proven,
|
|
253
|
+
),
|
|
254
|
+
(
|
|
255
|
+
"O4 health reports only conditions that were observed",
|
|
256
|
+
Status::Proven,
|
|
257
|
+
),
|
|
258
|
+
(
|
|
259
|
+
"O5 the remaining health fields are compile-time constants",
|
|
260
|
+
Status::PartiallyProven,
|
|
261
|
+
),
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
fn status(claim: &str) -> Status {
|
|
265
|
+
CLAIMS
|
|
266
|
+
.iter()
|
|
267
|
+
.find(|(name, _)| *name == claim)
|
|
268
|
+
.unwrap_or_else(|| panic!("{claim} is not in the claim matrix"))
|
|
269
|
+
.1
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// Assert a claim's recorded status, so a test cannot quietly widen its claim.
|
|
273
|
+
fn claiming(claim: &str, expected: Status) {
|
|
274
|
+
assert_eq!(
|
|
275
|
+
status(claim),
|
|
276
|
+
expected,
|
|
277
|
+
"{claim}: this test exercises a {expected:?} claim, but the matrix records \
|
|
278
|
+
something else. Update docs/architecture/production-readiness.md deliberately."
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fn durable(name: &str) -> (TempDir, Arc<FeltDb>, StateStore) {
|
|
283
|
+
let directory = TempDir::new().unwrap();
|
|
284
|
+
let db = Arc::new(FeltDb::open(directory.path().join(name)).unwrap());
|
|
285
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
286
|
+
(directory, db, store)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ===========================================================================
|
|
290
|
+
// Durability
|
|
291
|
+
// ===========================================================================
|
|
292
|
+
|
|
293
|
+
/// **Claim D1–D4.** A clean restart preserves state, history, retention and
|
|
294
|
+
/// position.
|
|
295
|
+
///
|
|
296
|
+
/// *Scenario*: write, mint under a policy, close, reopen, write again.
|
|
297
|
+
/// *Expected*: everything survives and history continues.
|
|
298
|
+
/// *Observed*: it does.
|
|
299
|
+
/// *Production implication*: a planned restart is safe. This says nothing about
|
|
300
|
+
/// an unplanned one — see D5.
|
|
301
|
+
#[test]
|
|
302
|
+
fn a_clean_restart_preserves_state_history_and_retention() {
|
|
303
|
+
claiming(
|
|
304
|
+
"D1 committed mutations survive a clean restart",
|
|
305
|
+
Status::Proven,
|
|
306
|
+
);
|
|
307
|
+
claiming(
|
|
308
|
+
"D2 revision history survives a clean restart",
|
|
309
|
+
Status::Proven,
|
|
310
|
+
);
|
|
311
|
+
claiming(
|
|
312
|
+
"D3 retention policy and horizon survive a restart",
|
|
313
|
+
Status::Proven,
|
|
314
|
+
);
|
|
315
|
+
claiming(
|
|
316
|
+
"D4 a reopened database continues history rather than restarting it",
|
|
317
|
+
Status::Proven,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
let directory = TempDir::new().unwrap();
|
|
321
|
+
let path = directory.path().join("restart.log");
|
|
322
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
323
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
324
|
+
store
|
|
325
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(3))
|
|
326
|
+
.unwrap();
|
|
327
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
328
|
+
for n in 1..10 {
|
|
329
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
330
|
+
}
|
|
331
|
+
let horizon_before = store.retention_horizon("tasks:1");
|
|
332
|
+
drop(store);
|
|
333
|
+
drop(db);
|
|
334
|
+
|
|
335
|
+
let reopened = Arc::new(FeltDb::open(&path).unwrap());
|
|
336
|
+
let store = StateStore::with_feltdb(reopened.clone()).unwrap();
|
|
337
|
+
|
|
338
|
+
let current: serde_json::Value = reopened.get("tasks:1").unwrap().unwrap();
|
|
339
|
+
assert_eq!(current["n"], json!(9), "D1");
|
|
340
|
+
assert_eq!(store.history_of("tasks:1").len(), 3, "D2");
|
|
341
|
+
assert_eq!(
|
|
342
|
+
store.retention_policy("tasks:1"),
|
|
343
|
+
RetentionPolicy::keep_last(3),
|
|
344
|
+
"D3 policy"
|
|
345
|
+
);
|
|
346
|
+
assert_eq!(
|
|
347
|
+
store.retention_horizon("tasks:1"),
|
|
348
|
+
horizon_before,
|
|
349
|
+
"D3 horizon"
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
reopened.update("tasks:1", json!({"n": 10})).unwrap();
|
|
353
|
+
let history = store.history_of("tasks:1");
|
|
354
|
+
assert_eq!(history.last().unwrap().sequence, 10, "D4");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/// **The crash boundary, established rather than assumed.**
|
|
358
|
+
///
|
|
359
|
+
/// D6–D10 and A6 are proven by cutting a real durable log at **every byte
|
|
360
|
+
/// offset** and opening it there — strictly stronger than killing a process at
|
|
361
|
+
/// chosen points, because it reaches boundaries inside a single write that a
|
|
362
|
+
/// signal cannot target. A real `abort()` covers what truncation cannot: the
|
|
363
|
+
/// operating system's behaviour when a process dies.
|
|
364
|
+
///
|
|
365
|
+
/// Every crash point leaves a clean prefix of the mutation sequence. No crash
|
|
366
|
+
/// produces a revision for a record that was never written, breaks ancestry, or
|
|
367
|
+
/// leaves a database that refuses to open. A transaction is all-or-nothing. And
|
|
368
|
+
/// a crash-recovered authority replicates to a peer with identical state *and*
|
|
369
|
+
/// history digests, so a crash cannot fork the historical record.
|
|
370
|
+
///
|
|
371
|
+
/// **D5 stays Unproven, and that is the honest result.** `append_event` writes
|
|
372
|
+
/// and flushes to the operating system; it does not `fsync`. Only
|
|
373
|
+
/// `append_transaction` does. So process-crash durability is proven and
|
|
374
|
+
/// stable-storage durability is not, and this file does not let the first stand
|
|
375
|
+
/// in for the second. Calling `fsync` somewhere is not evidence about power
|
|
376
|
+
/// loss.
|
|
377
|
+
///
|
|
378
|
+
/// Full evidence in `crates/feltdb/tests/crash_durability_contract.rs`.
|
|
379
|
+
#[test]
|
|
380
|
+
fn crash_recovery_is_established_and_power_loss_is_not() {
|
|
381
|
+
claiming(
|
|
382
|
+
"D5 a single write survives operating-system or power loss",
|
|
383
|
+
Status::Unproven,
|
|
384
|
+
);
|
|
385
|
+
claiming(
|
|
386
|
+
"D6 every crash point leaves a clean prefix of the mutation sequence",
|
|
387
|
+
Status::Proven,
|
|
388
|
+
);
|
|
389
|
+
claiming(
|
|
390
|
+
"D7 a crash never produces a revision for a record that was not written",
|
|
391
|
+
Status::Proven,
|
|
392
|
+
);
|
|
393
|
+
claiming(
|
|
394
|
+
"D8 a committed write survives the process dying",
|
|
395
|
+
Status::Proven,
|
|
396
|
+
);
|
|
397
|
+
claiming(
|
|
398
|
+
"D9 a transaction is all-or-nothing across a crash",
|
|
399
|
+
Status::Proven,
|
|
400
|
+
);
|
|
401
|
+
claiming(
|
|
402
|
+
"D10 a crash-recovered database replicates identically",
|
|
403
|
+
Status::Proven,
|
|
404
|
+
);
|
|
405
|
+
claiming(
|
|
406
|
+
"A6 a crash part-way through a mutation leaves consistent state",
|
|
407
|
+
Status::Proven,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
let directory = TempDir::new().unwrap();
|
|
411
|
+
let path = directory.path().join("crash.log");
|
|
412
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
413
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
414
|
+
db.update("tasks:1", json!({"n": 1})).unwrap();
|
|
415
|
+
drop(db);
|
|
416
|
+
let full = std::fs::read(&path).unwrap();
|
|
417
|
+
|
|
418
|
+
// D6, D7, A6: every crash point, and only clean prefixes.
|
|
419
|
+
for cut in 0..=full.len() {
|
|
420
|
+
std::fs::write(&path, &full[..cut]).unwrap();
|
|
421
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
422
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
423
|
+
let history = store.history_of("tasks:1");
|
|
424
|
+
let value = db
|
|
425
|
+
.get::<serde_json::Value>("tasks:1")
|
|
426
|
+
.unwrap()
|
|
427
|
+
.map(|value| value["n"].as_i64().unwrap());
|
|
428
|
+
assert_eq!(
|
|
429
|
+
history.len(),
|
|
430
|
+
value.map(|n| n as usize + 1).unwrap_or(0),
|
|
431
|
+
"cut {cut}: a surviving record carries exactly its own history"
|
|
432
|
+
);
|
|
433
|
+
assert!(history
|
|
434
|
+
.windows(2)
|
|
435
|
+
.all(|pair| pair[1].parent_id.as_ref() == Some(&pair[0].id)));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// D5: the bytes reach the file before the write returns. That is
|
|
439
|
+
// process-crash durability, and it is not a power-loss claim.
|
|
440
|
+
std::fs::write(&path, &full).unwrap();
|
|
441
|
+
let db = FeltDb::open(&path).unwrap();
|
|
442
|
+
db.insert("tasks:2", json!({"n": 5})).unwrap();
|
|
443
|
+
assert!(std::fs::read_to_string(&path).unwrap().contains("tasks:2"));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ===========================================================================
|
|
447
|
+
// Atomicity and concurrency
|
|
448
|
+
// ===========================================================================
|
|
449
|
+
|
|
450
|
+
/// **Claim A1.** A refused fence changes nothing — not the row, not the history.
|
|
451
|
+
#[test]
|
|
452
|
+
fn a_stale_fenced_write_is_rejected_and_mints_nothing() {
|
|
453
|
+
claiming(
|
|
454
|
+
"A1 a stale fenced write is rejected and mints nothing",
|
|
455
|
+
Status::Proven,
|
|
456
|
+
);
|
|
457
|
+
let (_directory, db, store) = durable("cas.log");
|
|
458
|
+
db.insert("tasks:1", json!({"title": "A", "__version": 1}))
|
|
459
|
+
.unwrap();
|
|
460
|
+
let before = store.history_of("tasks:1").len();
|
|
461
|
+
|
|
462
|
+
let refused = db
|
|
463
|
+
.compare_and_set_json("tasks:1", 99, None, None, false, json!({"title": "Z"}))
|
|
464
|
+
.unwrap();
|
|
465
|
+
assert!(matches!(refused, JsonCasResult::VersionConflict { .. }));
|
|
466
|
+
|
|
467
|
+
assert_eq!(
|
|
468
|
+
store.history_of("tasks:1").len(),
|
|
469
|
+
before,
|
|
470
|
+
"no revision minted"
|
|
471
|
+
);
|
|
472
|
+
let current: serde_json::Value = db.get("tasks:1").unwrap().unwrap();
|
|
473
|
+
assert_eq!(current["title"], json!("A"), "the row is unchanged");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/// **Claims A2 and A3.** Concurrency produces correct chains, on separate
|
|
477
|
+
/// resources and on one shared resource.
|
|
478
|
+
///
|
|
479
|
+
/// *Production implication*: the write path serializes minting correctly. It
|
|
480
|
+
/// says nothing about throughput under contention — see E1.
|
|
481
|
+
#[test]
|
|
482
|
+
fn concurrent_writes_produce_correct_chains() {
|
|
483
|
+
claiming(
|
|
484
|
+
"A2 concurrent writes to distinct resources keep distinct chains",
|
|
485
|
+
Status::Proven,
|
|
486
|
+
);
|
|
487
|
+
claiming(
|
|
488
|
+
"A3 concurrent writes to one resource form one correct chain",
|
|
489
|
+
Status::Proven,
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
let directory = TempDir::new().unwrap();
|
|
493
|
+
let db = Arc::new(FeltDb::open(directory.path().join("concurrent.log")).unwrap());
|
|
494
|
+
|
|
495
|
+
let mut handles = Vec::new();
|
|
496
|
+
for thread_index in 0..4 {
|
|
497
|
+
let db = db.clone();
|
|
498
|
+
handles.push(std::thread::spawn(move || {
|
|
499
|
+
for n in 0..25 {
|
|
500
|
+
db.update(&format!("tasks:{thread_index}"), json!({ "n": n }))
|
|
501
|
+
.unwrap();
|
|
502
|
+
db.update("shared:1", json!({ "t": thread_index, "n": n }))
|
|
503
|
+
.unwrap();
|
|
504
|
+
}
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
for handle in handles {
|
|
508
|
+
handle.join().unwrap();
|
|
509
|
+
}
|
|
510
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
511
|
+
|
|
512
|
+
for thread_index in 0..4 {
|
|
513
|
+
let history = store.history_of(&format!("tasks:{thread_index}"));
|
|
514
|
+
assert_eq!(history.len(), 25, "A2 length");
|
|
515
|
+
assert!(
|
|
516
|
+
history
|
|
517
|
+
.windows(2)
|
|
518
|
+
.all(|pair| pair[1].parent_id.as_ref() == Some(&pair[0].id)),
|
|
519
|
+
"A2 chained"
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let shared = store.history_of("shared:1");
|
|
524
|
+
assert_eq!(shared.len(), 100, "A3 every write minted exactly once");
|
|
525
|
+
assert!(
|
|
526
|
+
shared
|
|
527
|
+
.windows(2)
|
|
528
|
+
.all(|pair| pair[1].parent_id.as_ref() == Some(&pair[0].id)),
|
|
529
|
+
"A3 one chain, not four"
|
|
530
|
+
);
|
|
531
|
+
let mut sequences: Vec<u64> = shared.iter().map(|revision| revision.sequence).collect();
|
|
532
|
+
sequences.dedup();
|
|
533
|
+
assert_eq!(sequences.len(), 100, "A3 sequences are unique");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// **Claims A4 and A5.** Replayed and out-of-order remote operations.
|
|
537
|
+
#[test]
|
|
538
|
+
fn replayed_and_gapped_remote_operations_are_handled_explicitly() {
|
|
539
|
+
claiming(
|
|
540
|
+
"A4 a duplicate remote operation is idempotent",
|
|
541
|
+
Status::Proven,
|
|
542
|
+
);
|
|
543
|
+
claiming(
|
|
544
|
+
"A5 a remote operation with a sequence gap is rejected",
|
|
545
|
+
Status::Proven,
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
let directory = TempDir::new().unwrap();
|
|
549
|
+
let primary = Arc::new(FeltDb::open(directory.path().join("p.log")).unwrap());
|
|
550
|
+
let replica = Arc::new(FeltDb::open(directory.path().join("r.log")).unwrap());
|
|
551
|
+
|
|
552
|
+
primary.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
553
|
+
primary.update("tasks:1", json!({"n": 2})).unwrap();
|
|
554
|
+
let operations = primary.operations_since(&HashMap::new()).unwrap();
|
|
555
|
+
assert!(operations.len() >= 2);
|
|
556
|
+
|
|
557
|
+
// Applying out of order is refused rather than silently accepted.
|
|
558
|
+
assert!(
|
|
559
|
+
replica
|
|
560
|
+
.apply_remote_operation(operations[1].clone())
|
|
561
|
+
.is_err(),
|
|
562
|
+
"A5"
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
for operation in &operations {
|
|
566
|
+
replica.apply_remote_operation(operation.clone()).unwrap();
|
|
567
|
+
}
|
|
568
|
+
let after_first_pass: serde_json::Value = replica.get("tasks:1").unwrap().unwrap();
|
|
569
|
+
|
|
570
|
+
// Replaying the whole stream is a no-op.
|
|
571
|
+
for operation in &operations {
|
|
572
|
+
replica.apply_remote_operation(operation.clone()).unwrap();
|
|
573
|
+
}
|
|
574
|
+
let after_replay: serde_json::Value = replica.get("tasks:1").unwrap().unwrap();
|
|
575
|
+
assert_eq!(after_first_pass, after_replay, "A4");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ===========================================================================
|
|
579
|
+
// Recovery and corruption
|
|
580
|
+
// ===========================================================================
|
|
581
|
+
|
|
582
|
+
/// **Claims R1 and R2, closed.** Durable corruption no longer opens quietly.
|
|
583
|
+
///
|
|
584
|
+
/// *Was*: a log truncated mid-record opened having silently discarded the tail,
|
|
585
|
+
/// and a complete line containing invalid data at end-of-file was dropped the
|
|
586
|
+
/// same way. Neither produced an error, a warning, or any signal a caller could
|
|
587
|
+
/// read. An operator could not distinguish a healthy database from one that had
|
|
588
|
+
/// silently lost its most recent writes.
|
|
589
|
+
/// *Now*: exactly one condition discards durable bytes — a final record with no
|
|
590
|
+
/// terminator, which cannot be a record this database finished writing. It is
|
|
591
|
+
/// discarded, and the recovery is reported. Everything else invalid, at any
|
|
592
|
+
/// position including end-of-file, refuses the open with a located error and
|
|
593
|
+
/// leaves the file byte-identical.
|
|
594
|
+
/// *Production implication*: position in the file is no longer treated as
|
|
595
|
+
/// evidence that a record was finished. The terminator is.
|
|
596
|
+
///
|
|
597
|
+
/// Full evidence in `crates/feltdb/tests/durable_corruption_contract.rs`.
|
|
598
|
+
#[test]
|
|
599
|
+
fn durable_corruption_fails_closed_and_recovery_is_reported() {
|
|
600
|
+
claiming(
|
|
601
|
+
"R1 an incomplete final append is recovered and reported, not silently dropped",
|
|
602
|
+
Status::Proven,
|
|
603
|
+
);
|
|
604
|
+
claiming(
|
|
605
|
+
"R2 a complete but invalid record refuses the open, wherever it sits",
|
|
606
|
+
Status::Proven,
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
// R1: a torn append is recovered, and says so.
|
|
610
|
+
let directory = TempDir::new().unwrap();
|
|
611
|
+
let path = directory.path().join("torn.log");
|
|
612
|
+
let db = FeltDb::open(&path).unwrap();
|
|
613
|
+
for n in 0..5 {
|
|
614
|
+
db.insert(&format!("tasks:{n}"), json!({ "n": n })).unwrap();
|
|
615
|
+
}
|
|
616
|
+
drop(db);
|
|
617
|
+
let bytes = std::fs::read(&path).unwrap();
|
|
618
|
+
std::fs::write(&path, &bytes[..bytes.len() - 600]).unwrap();
|
|
619
|
+
|
|
620
|
+
let reopened = FeltDb::open(&path).expect("a torn tail is recoverable");
|
|
621
|
+
assert!(
|
|
622
|
+
!reopened.log_recovery().is_clean(),
|
|
623
|
+
"R1: the recovery is visible rather than silent"
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
// R2: a complete record containing invalid data is corruption, at EOF as
|
|
627
|
+
// much as anywhere else.
|
|
628
|
+
let directory = TempDir::new().unwrap();
|
|
629
|
+
let path = directory.path().join("garbage.log");
|
|
630
|
+
let db = FeltDb::open(&path).unwrap();
|
|
631
|
+
db.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
632
|
+
drop(db);
|
|
633
|
+
let before = std::fs::read(&path).unwrap();
|
|
634
|
+
let mut file = std::fs::OpenOptions::new()
|
|
635
|
+
.append(true)
|
|
636
|
+
.open(&path)
|
|
637
|
+
.unwrap();
|
|
638
|
+
writeln!(file, "this is not json at all").unwrap();
|
|
639
|
+
drop(file);
|
|
640
|
+
|
|
641
|
+
match FeltDb::open(&path) {
|
|
642
|
+
Err(FlowError::CorruptLogLine(corruption)) => {
|
|
643
|
+
assert!(corruption.line_number > 0, "R2: the damage is located");
|
|
644
|
+
}
|
|
645
|
+
Err(other) => panic!("wrong error: {other}"),
|
|
646
|
+
Ok(_) => panic!("R2: the corrupt database opened"),
|
|
647
|
+
}
|
|
648
|
+
assert!(
|
|
649
|
+
std::fs::read(&path).unwrap().starts_with(&before),
|
|
650
|
+
"R2: a refused open modified nothing that was already there"
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/// **Claim R3.** Damaged ancestry and expired ancestry are different facts, and
|
|
655
|
+
/// the store says which.
|
|
656
|
+
///
|
|
657
|
+
/// This is the one corruption case FeltDB genuinely detects, and it exists
|
|
658
|
+
/// because retention needed the distinction. A parent absent at or below the
|
|
659
|
+
/// horizon was expired on purpose; absent above it is damage.
|
|
660
|
+
///
|
|
661
|
+
/// *Production implication*: ancestry damage is detectable — **on inspection**.
|
|
662
|
+
/// Nothing surfaces it unprompted, which is claim R4.
|
|
663
|
+
#[test]
|
|
664
|
+
fn damaged_ancestry_is_distinguishable_from_expired_ancestry() {
|
|
665
|
+
claiming(
|
|
666
|
+
"R3 damaged ancestry is distinguishable from expired ancestry",
|
|
667
|
+
Status::Proven,
|
|
668
|
+
);
|
|
669
|
+
claiming(
|
|
670
|
+
"R4 ancestry damage is surfaced without being asked for",
|
|
671
|
+
Status::Unproven,
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
// Damage: a mid-history revision removed with no retention involved.
|
|
675
|
+
let (_directory, db, store) = durable("damaged.log");
|
|
676
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
677
|
+
db.update("tasks:1", json!({"n": 1})).unwrap();
|
|
678
|
+
db.update("tasks:1", json!({"n": 2})).unwrap();
|
|
679
|
+
let history = store.history_of("tasks:1");
|
|
680
|
+
let middle = history[1].id.clone();
|
|
681
|
+
let child = history[2].id.clone();
|
|
682
|
+
db.delete(&format!("state:revision:{}", middle.as_hex()))
|
|
683
|
+
.unwrap();
|
|
684
|
+
|
|
685
|
+
assert!(
|
|
686
|
+
matches!(store.parent_of(&child), Some(ParentLookup::Missing(_))),
|
|
687
|
+
"R3: reported as damage"
|
|
688
|
+
);
|
|
689
|
+
assert_eq!(
|
|
690
|
+
store.retention_horizon("tasks:1"),
|
|
691
|
+
0,
|
|
692
|
+
"no retention happened"
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
// Expiry: the same shape, but a decision.
|
|
696
|
+
let (_directory, db, store) = durable("expired.log");
|
|
697
|
+
store
|
|
698
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(2))
|
|
699
|
+
.unwrap();
|
|
700
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
701
|
+
for n in 1..6 {
|
|
702
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
703
|
+
}
|
|
704
|
+
let oldest = store.history_of("tasks:1")[0].id.clone();
|
|
705
|
+
assert!(
|
|
706
|
+
matches!(store.parent_of(&oldest), Some(ParentLookup::Expired(_))),
|
|
707
|
+
"R3: reported as a decision"
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ===========================================================================
|
|
712
|
+
// History and retention
|
|
713
|
+
// ===========================================================================
|
|
714
|
+
|
|
715
|
+
/// **Claims H1, H2, H3.** The retention envelope, in both directions.
|
|
716
|
+
#[test]
|
|
717
|
+
fn the_retention_envelope_behaves_in_both_directions() {
|
|
718
|
+
claiming(
|
|
719
|
+
"H1 history is bounded under a configured policy",
|
|
720
|
+
Status::Proven,
|
|
721
|
+
);
|
|
722
|
+
claiming(
|
|
723
|
+
"H2 history is unbounded without one, by design",
|
|
724
|
+
Status::Proven,
|
|
725
|
+
);
|
|
726
|
+
claiming(
|
|
727
|
+
"H3 a policy change takes effect in both directions",
|
|
728
|
+
Status::Proven,
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
// Unbounded by default.
|
|
732
|
+
let (_directory, db, store) = durable("unbounded.log");
|
|
733
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
734
|
+
for n in 1..50 {
|
|
735
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
736
|
+
}
|
|
737
|
+
assert_eq!(store.history_of("tasks:1").len(), 50, "H2");
|
|
738
|
+
|
|
739
|
+
// Tightening expires immediately.
|
|
740
|
+
store
|
|
741
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(3))
|
|
742
|
+
.unwrap();
|
|
743
|
+
assert_eq!(store.history_of("tasks:1").len(), 3, "H3 tighten");
|
|
744
|
+
|
|
745
|
+
// And stays bounded as writes continue.
|
|
746
|
+
for n in 50..100 {
|
|
747
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
748
|
+
}
|
|
749
|
+
assert_eq!(store.history_of("tasks:1").len(), 3, "H1");
|
|
750
|
+
|
|
751
|
+
// Loosening lets it grow again — it does not restore what was expired.
|
|
752
|
+
store
|
|
753
|
+
.set_retention_policy("tasks:1", RetentionPolicy::unbounded())
|
|
754
|
+
.unwrap();
|
|
755
|
+
for n in 100..110 {
|
|
756
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
757
|
+
}
|
|
758
|
+
assert_eq!(store.history_of("tasks:1").len(), 13, "H3 loosen");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/// **Claims H4 and H5.** Maintenance never rewrites history, and forks survive.
|
|
762
|
+
#[test]
|
|
763
|
+
fn maintenance_never_rewrites_history_and_forks_survive() {
|
|
764
|
+
claiming("H4 maintenance never rewrites history", Status::Proven);
|
|
765
|
+
claiming("H5 forked history is preserved", Status::Proven);
|
|
766
|
+
|
|
767
|
+
let directory = TempDir::new().unwrap();
|
|
768
|
+
let path = directory.path().join("forks.log");
|
|
769
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
770
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
771
|
+
|
|
772
|
+
let base = store
|
|
773
|
+
.create("docs:1", r#"{"v":0}"#.into(), "a".into())
|
|
774
|
+
.unwrap();
|
|
775
|
+
let left = store
|
|
776
|
+
.commit(r#"{"v":1}"#.into(), &base, "a".into())
|
|
777
|
+
.unwrap();
|
|
778
|
+
let right = store
|
|
779
|
+
.commit(r#"{"v":2}"#.into(), &base, "b".into())
|
|
780
|
+
.unwrap();
|
|
781
|
+
assert_ne!(left.id, right.id, "H5: two heads from one base");
|
|
782
|
+
assert_eq!(left.parent_id, right.parent_id);
|
|
783
|
+
|
|
784
|
+
// Compaction, an unrelated write, and a retention pass on another resource.
|
|
785
|
+
db.insert("tasks:1", json!({"unrelated": true})).unwrap();
|
|
786
|
+
let versions = db.operation_versions().unwrap();
|
|
787
|
+
db.acknowledge_peer_versions("peer-1".to_string(), versions)
|
|
788
|
+
.unwrap();
|
|
789
|
+
db.compact_operation_log(&["peer-1".to_string()]).unwrap();
|
|
790
|
+
store
|
|
791
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(1))
|
|
792
|
+
.unwrap();
|
|
793
|
+
|
|
794
|
+
for revision in [&base, &left, &right] {
|
|
795
|
+
let stored = store.get(&revision.id).expect("H4: still there");
|
|
796
|
+
assert_eq!(stored.parent_id, revision.parent_id, "H4: ancestry intact");
|
|
797
|
+
assert_eq!(stored.content_id, revision.content_id);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ===========================================================================
|
|
802
|
+
// Backup and restore
|
|
803
|
+
// ===========================================================================
|
|
804
|
+
|
|
805
|
+
/// **The B-block, mostly closed.**
|
|
806
|
+
///
|
|
807
|
+
/// *Was*: `export_snapshot` and `install_snapshot` round-tripped state and
|
|
808
|
+
/// history in process, but there was no artifact, no format, no verification an
|
|
809
|
+
/// operator could run and no restore procedure. An operator had no supported
|
|
810
|
+
/// way to back up or restore a database.
|
|
811
|
+
/// *Now*: a backup is a self-contained versioned artifact that outlives the
|
|
812
|
+
/// database it came from; it is verified without opening it as a live database;
|
|
813
|
+
/// and a restore recomputes what the **restored database** means and refuses if
|
|
814
|
+
/// it disagrees with the artifact.
|
|
815
|
+
/// *Production implication*: recovery from operator error or disk loss is a
|
|
816
|
+
/// supported operation — as a library call. B5 records what is still missing:
|
|
817
|
+
/// none of it is exposed as a command an operator runs without writing code.
|
|
818
|
+
///
|
|
819
|
+
/// Full evidence in `crates/feltdb/tests/durable_backup_contract.rs`.
|
|
820
|
+
#[test]
|
|
821
|
+
fn backup_and_restore_preserve_meaning_and_refuse_damage() {
|
|
822
|
+
claiming(
|
|
823
|
+
"B1 a durable backup artifact is self-contained and independently verifiable",
|
|
824
|
+
Status::Proven,
|
|
825
|
+
);
|
|
826
|
+
claiming(
|
|
827
|
+
"B2 a restore proves the result means the same thing, not merely that it parsed",
|
|
828
|
+
Status::Proven,
|
|
829
|
+
);
|
|
830
|
+
claiming(
|
|
831
|
+
"B3 an altered or truncated backup is refused rather than restored",
|
|
832
|
+
Status::Proven,
|
|
833
|
+
);
|
|
834
|
+
claiming(
|
|
835
|
+
"B4 a restore will not overwrite an existing database",
|
|
836
|
+
Status::Proven,
|
|
837
|
+
);
|
|
838
|
+
claiming(
|
|
839
|
+
"B5 backup and restore are exposed as an operator command-line workflow",
|
|
840
|
+
Status::Unproven,
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
let directory = TempDir::new().unwrap();
|
|
844
|
+
let source = directory.path().join("source.log");
|
|
845
|
+
let db = Arc::new(FeltDb::open(&source).unwrap());
|
|
846
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
847
|
+
db.update("tasks:1", json!({"n": 1})).unwrap();
|
|
848
|
+
|
|
849
|
+
// B1: an artifact that verifies on its own, with the source deleted.
|
|
850
|
+
let backup = directory.path().join("db.feltbackup");
|
|
851
|
+
let written = db.write_backup(&backup).unwrap();
|
|
852
|
+
drop(db);
|
|
853
|
+
std::fs::remove_file(&source).unwrap();
|
|
854
|
+
assert_eq!(feltdb::verify_backup(&backup).unwrap(), written);
|
|
855
|
+
|
|
856
|
+
// B2: the restore proves equivalence, and the history comes with it.
|
|
857
|
+
let target = directory.path().join("restored.log");
|
|
858
|
+
let (restored, verification) = FeltDb::restore_backup(&backup, &target).unwrap();
|
|
859
|
+
assert_eq!(verification.meaning_digest, written.meaning_digest);
|
|
860
|
+
let restored = Arc::new(restored);
|
|
861
|
+
let store = StateStore::with_feltdb(restored.clone()).unwrap();
|
|
862
|
+
assert_eq!(store.history_of("tasks:1").len(), 2);
|
|
863
|
+
|
|
864
|
+
// B3: one altered byte inside a value is refused on meaning.
|
|
865
|
+
let text = std::fs::read_to_string(&backup).unwrap();
|
|
866
|
+
std::fs::write(&backup, text.replacen(r#""n":1"#, r#""n":7"#, 1)).unwrap();
|
|
867
|
+
assert!(matches!(
|
|
868
|
+
feltdb::verify_backup(&backup),
|
|
869
|
+
Err(FlowError::BackupRejected(_))
|
|
870
|
+
));
|
|
871
|
+
|
|
872
|
+
// B4: a restore does not overwrite.
|
|
873
|
+
std::fs::write(&backup, &text).unwrap();
|
|
874
|
+
assert!(matches!(
|
|
875
|
+
FeltDb::restore_backup(&backup, &target),
|
|
876
|
+
Err(FlowError::BackupRejected(_))
|
|
877
|
+
));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ===========================================================================
|
|
881
|
+
// Replication
|
|
882
|
+
// ===========================================================================
|
|
883
|
+
|
|
884
|
+
/// **The S-block, closed.** Replication now carries history, not only state.
|
|
885
|
+
///
|
|
886
|
+
/// *Was*: `apply_remote_operation` wrote rows directly and never reached the
|
|
887
|
+
/// minting path, so a replica received the same current state and no revision
|
|
888
|
+
/// history at all. Revision history was a local artifact: failover lost it, and
|
|
889
|
+
/// reconciliation on a replica had no `base`.
|
|
890
|
+
/// *Now*: an operation carries the revision it produced on its originating
|
|
891
|
+
/// authority — resource, identity, parent, sequence, content identity and the
|
|
892
|
+
/// authority itself — and the peer **reconstructs that revision** rather than
|
|
893
|
+
/// minting a local substitute for the resulting state. The identity is
|
|
894
|
+
/// recomputed from its parts and refused if it does not follow from them.
|
|
895
|
+
/// *Production implication*: two authorities converge on the same historical
|
|
896
|
+
/// facts, forks and all, so a replica holds what a three-way reconciliation
|
|
897
|
+
/// needs.
|
|
898
|
+
///
|
|
899
|
+
/// S4 is the discipline that keeps this honest: state equivalence and
|
|
900
|
+
/// historical equivalence are separate digests, and a database with the same
|
|
901
|
+
/// values and a different history fails the second while passing the first.
|
|
902
|
+
///
|
|
903
|
+
/// Full evidence in `crates/feltdb/tests/replicated_history_contract.rs`.
|
|
904
|
+
#[test]
|
|
905
|
+
fn replication_carries_history_and_not_only_state() {
|
|
906
|
+
claiming("S1 current state replicates to a peer", Status::Proven);
|
|
907
|
+
claiming(
|
|
908
|
+
"S2 revision history replicates to a peer with identical identities",
|
|
909
|
+
Status::Proven,
|
|
910
|
+
);
|
|
911
|
+
claiming(
|
|
912
|
+
"S3 replicated history preserves parents, sequences, forks and provenance",
|
|
913
|
+
Status::Proven,
|
|
914
|
+
);
|
|
915
|
+
claiming(
|
|
916
|
+
"S4 state equivalence and historical equivalence are checked separately",
|
|
917
|
+
Status::Proven,
|
|
918
|
+
);
|
|
919
|
+
claiming(
|
|
920
|
+
"S5 a peer that sends no revision provenance contributes no history",
|
|
921
|
+
Status::Proven,
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
let directory = TempDir::new().unwrap();
|
|
925
|
+
let primary = Arc::new(FeltDb::open(directory.path().join("primary.log")).unwrap());
|
|
926
|
+
let replica = Arc::new(FeltDb::open(directory.path().join("replica.log")).unwrap());
|
|
927
|
+
|
|
928
|
+
primary.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
929
|
+
primary.update("tasks:1", json!({"n": 2})).unwrap();
|
|
930
|
+
for operation in primary.operations_since(&HashMap::new()).unwrap() {
|
|
931
|
+
replica.apply_remote_operation(operation).unwrap();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
let primary_store = StateStore::with_feltdb(primary.clone()).unwrap();
|
|
935
|
+
let replica_store = StateStore::with_feltdb(replica.clone()).unwrap();
|
|
936
|
+
|
|
937
|
+
// S1 and S2.
|
|
938
|
+
assert_eq!(
|
|
939
|
+
replica.state_digest().unwrap(),
|
|
940
|
+
primary.state_digest().unwrap()
|
|
941
|
+
);
|
|
942
|
+
assert_eq!(
|
|
943
|
+
replica_store.history_digest(),
|
|
944
|
+
primary_store.history_digest(),
|
|
945
|
+
"the same history, not merely the same values"
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
// S3: identities, parents, sequences and provenance, not a re-mint.
|
|
949
|
+
let origin = primary_store.history_of("tasks:1");
|
|
950
|
+
let copy = replica_store.history_of("tasks:1");
|
|
951
|
+
assert_eq!(copy.len(), 2);
|
|
952
|
+
for (mine, theirs) in copy.iter().zip(origin.iter()) {
|
|
953
|
+
assert_eq!(mine.id, theirs.id);
|
|
954
|
+
assert_eq!(mine.parent_id, theirs.parent_id);
|
|
955
|
+
assert_eq!(mine.sequence, theirs.sequence);
|
|
956
|
+
assert_eq!(mine.authority, theirs.authority);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// S4: the negative control the whole block rests on.
|
|
960
|
+
let shallow = Arc::new(FeltDb::open(directory.path().join("shallow.log")).unwrap());
|
|
961
|
+
shallow.insert("tasks:1", json!({"n": 2})).unwrap();
|
|
962
|
+
let shallow_store = StateStore::with_feltdb(shallow.clone()).unwrap();
|
|
963
|
+
assert_eq!(
|
|
964
|
+
shallow.state_digest().unwrap(),
|
|
965
|
+
primary.state_digest().unwrap(),
|
|
966
|
+
"same values"
|
|
967
|
+
);
|
|
968
|
+
assert_ne!(
|
|
969
|
+
shallow_store.history_digest(),
|
|
970
|
+
primary_store.history_digest(),
|
|
971
|
+
"different history, and the check can tell"
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ===========================================================================
|
|
976
|
+
// Workload envelope
|
|
977
|
+
// ===========================================================================
|
|
978
|
+
|
|
979
|
+
/// **Claims E1 and E2.** Retention has a measurable cost, in two directions
|
|
980
|
+
/// that pull against each other.
|
|
981
|
+
///
|
|
982
|
+
/// *Observed*: the per-write cost of retention grows with the retention window,
|
|
983
|
+
/// because expiry walks the resource's retained history on every mint. And
|
|
984
|
+
/// retention makes the log **larger** before compaction, because each expiry
|
|
985
|
+
/// appends a tombstone.
|
|
986
|
+
/// *Production implication*: a large retention window costs write throughput,
|
|
987
|
+
/// and a small one costs log volume until the next compaction. Neither is a
|
|
988
|
+
/// defect; both are envelope facts an operator has to size for.
|
|
989
|
+
///
|
|
990
|
+
/// This measures. It does not optimise, and the thresholds are loose on purpose
|
|
991
|
+
/// — the claim is the *shape*, not a benchmark number.
|
|
992
|
+
#[test]
|
|
993
|
+
fn retention_cost_grows_with_the_window_and_inflates_the_log() {
|
|
994
|
+
claiming(
|
|
995
|
+
"E1 retention cost per write grows with the retention window",
|
|
996
|
+
Status::Proven,
|
|
997
|
+
);
|
|
998
|
+
claiming(
|
|
999
|
+
"E2 retention increases log size before compaction",
|
|
1000
|
+
Status::Proven,
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
fn run(keep: Option<usize>) -> (std::time::Duration, u64) {
|
|
1004
|
+
let directory = TempDir::new().unwrap();
|
|
1005
|
+
let path = directory.path().join("envelope.log");
|
|
1006
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
1007
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
1008
|
+
if let Some(keep) = keep {
|
|
1009
|
+
store
|
|
1010
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(keep))
|
|
1011
|
+
.unwrap();
|
|
1012
|
+
}
|
|
1013
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
1014
|
+
let start = std::time::Instant::now();
|
|
1015
|
+
for n in 1..400 {
|
|
1016
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
1017
|
+
}
|
|
1018
|
+
(start.elapsed(), std::fs::metadata(&path).unwrap().len())
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
let (none_time, none_bytes) = run(None);
|
|
1022
|
+
let (small_time, small_bytes) = run(Some(5));
|
|
1023
|
+
let (large_time, _) = run(Some(50));
|
|
1024
|
+
|
|
1025
|
+
assert!(
|
|
1026
|
+
large_time > small_time,
|
|
1027
|
+
"E1: a wider window costs more per write ({large_time:?} vs {small_time:?})"
|
|
1028
|
+
);
|
|
1029
|
+
assert!(
|
|
1030
|
+
small_time > none_time,
|
|
1031
|
+
"E1: retention costs more than no retention ({small_time:?} vs {none_time:?})"
|
|
1032
|
+
);
|
|
1033
|
+
assert!(
|
|
1034
|
+
small_bytes > none_bytes,
|
|
1035
|
+
"E2: expiry tombstones inflate the log ({small_bytes} vs {none_bytes})"
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/// **The durability contract is now selectable, and priced.**
|
|
1040
|
+
///
|
|
1041
|
+
/// The question was never `flush` against `fsync`. It is what a successful
|
|
1042
|
+
/// write should *mean*, and what each answer costs. Three modes exist, each
|
|
1043
|
+
/// states its own guarantee, and none of them claims to have reached a platter.
|
|
1044
|
+
///
|
|
1045
|
+
/// E4 is asserted as an ordering, not a number: per-write `fsync` runs roughly
|
|
1046
|
+
/// three to four times slower than flush across every row of the frozen
|
|
1047
|
+
/// matrix. E7 is **Unproven on purpose** — grouped durability came out slower
|
|
1048
|
+
/// than per-write `fsync` on one workload and faster on others, so the expected
|
|
1049
|
+
/// advantage is not observable at this scale and is recorded rather than
|
|
1050
|
+
/// asserted.
|
|
1051
|
+
///
|
|
1052
|
+
/// D5 is unchanged. Issuing a barrier is not evidence it landed.
|
|
1053
|
+
///
|
|
1054
|
+
/// Full evidence in `crates/feltdb/tests/workload_envelope_contract.rs`.
|
|
1055
|
+
#[test]
|
|
1056
|
+
fn durability_is_a_priced_contract_rather_than_a_tuning_knob() {
|
|
1057
|
+
claiming(
|
|
1058
|
+
"E3 durability is a selectable contract, and each mode states its guarantee",
|
|
1059
|
+
Status::Proven,
|
|
1060
|
+
);
|
|
1061
|
+
claiming(
|
|
1062
|
+
"E4 a stable-storage barrier per write has a measured cost",
|
|
1063
|
+
Status::Proven,
|
|
1064
|
+
);
|
|
1065
|
+
claiming(
|
|
1066
|
+
"E5 the durability mode changes the contract and not the stored database",
|
|
1067
|
+
Status::Proven,
|
|
1068
|
+
);
|
|
1069
|
+
claiming(
|
|
1070
|
+
"E6 the workload envelope is recorded against a frozen workload and a named environment",
|
|
1071
|
+
Status::Proven,
|
|
1072
|
+
);
|
|
1073
|
+
claiming(
|
|
1074
|
+
"E7 grouped durability is measurably cheaper than a barrier per write",
|
|
1075
|
+
Status::Unproven,
|
|
1076
|
+
);
|
|
1077
|
+
claiming(
|
|
1078
|
+
"D5 a single write survives operating-system or power loss",
|
|
1079
|
+
Status::Unproven,
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
// E3: the contract is readable, and no mode overstates it.
|
|
1083
|
+
for mode in [
|
|
1084
|
+
feltdb::DurabilityMode::Flushed,
|
|
1085
|
+
feltdb::DurabilityMode::Synced,
|
|
1086
|
+
feltdb::DurabilityMode::Grouped { every: 8 },
|
|
1087
|
+
] {
|
|
1088
|
+
assert!(!mode.guarantee().contains("power-loss safe"));
|
|
1089
|
+
}
|
|
1090
|
+
assert_eq!(
|
|
1091
|
+
feltdb::DurabilityMode::Grouped { every: 8 }.unbarriered_window(),
|
|
1092
|
+
Some(7),
|
|
1093
|
+
"the window a grouped contract leaves is explicit"
|
|
1094
|
+
);
|
|
1095
|
+
|
|
1096
|
+
// E5: the same writes produce the same database under any mode.
|
|
1097
|
+
let directory = TempDir::new().unwrap();
|
|
1098
|
+
let path = directory.path().join("modes.log");
|
|
1099
|
+
let mut digests = Vec::new();
|
|
1100
|
+
for mode in [
|
|
1101
|
+
feltdb::DurabilityMode::Flushed,
|
|
1102
|
+
feltdb::DurabilityMode::Synced,
|
|
1103
|
+
] {
|
|
1104
|
+
let _ = std::fs::remove_file(&path);
|
|
1105
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
1106
|
+
db.set_durability_mode(mode);
|
|
1107
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
1108
|
+
db.update("tasks:1", json!({"n": 1})).unwrap();
|
|
1109
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
1110
|
+
digests.push((db.state_digest().unwrap(), store.history_digest()));
|
|
1111
|
+
}
|
|
1112
|
+
assert_eq!(digests[0], digests[1]);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ===========================================================================
|
|
1116
|
+
// Upgrade and migration
|
|
1117
|
+
// ===========================================================================
|
|
1118
|
+
|
|
1119
|
+
/// **The U-block, closed.** What was the most dangerous finding in this audit.
|
|
1120
|
+
///
|
|
1121
|
+
/// *Was*: a database whose `state:revision:` records predate the resource-scoped
|
|
1122
|
+
/// model opened cleanly and reported no history at all. The records were still
|
|
1123
|
+
/// on disk and every read path discarded what failed to deserialize. It failed
|
|
1124
|
+
/// **open**.
|
|
1125
|
+
/// *Now*: the durable format carries a version, the version is checked before
|
|
1126
|
+
/// any record is interpreted, and an unrecognised format is refused with an
|
|
1127
|
+
/// actionable error and without touching a byte.
|
|
1128
|
+
/// *Production implication*: FeltDB fails closed when its durable meaning is
|
|
1129
|
+
/// unknown — in both directions, so a rolled-back deployment cannot
|
|
1130
|
+
/// reinterpret newer data either.
|
|
1131
|
+
///
|
|
1132
|
+
/// U2 is `NotApplicable` rather than `Proven`, and the distinction is the
|
|
1133
|
+
/// point: lossless migration is impossible, because a legacy revision never
|
|
1134
|
+
/// recorded which resource it belonged to. Inventing that would produce a
|
|
1135
|
+
/// database that looks migrated and is wrong. Refusing is the correct
|
|
1136
|
+
/// behaviour, not a deferral.
|
|
1137
|
+
///
|
|
1138
|
+
/// Full evidence in `crates/feltdb/tests/durable_format_compatibility.rs`.
|
|
1139
|
+
#[test]
|
|
1140
|
+
fn an_incompatible_durable_format_fails_closed() {
|
|
1141
|
+
claiming(
|
|
1142
|
+
"U1 an incompatible durable format is detected before any record is interpreted",
|
|
1143
|
+
Status::Proven,
|
|
1144
|
+
);
|
|
1145
|
+
claiming(
|
|
1146
|
+
"U2 a pre-model database's revision history is losslessly migratable",
|
|
1147
|
+
Status::NotApplicable,
|
|
1148
|
+
);
|
|
1149
|
+
claiming(
|
|
1150
|
+
"U3 a persisted durable format version exists and is checked on open",
|
|
1151
|
+
Status::Proven,
|
|
1152
|
+
);
|
|
1153
|
+
claiming(
|
|
1154
|
+
"U4 a refused open leaves the database byte-identical",
|
|
1155
|
+
Status::Proven,
|
|
1156
|
+
);
|
|
1157
|
+
claiming(
|
|
1158
|
+
"U5 a durable format from a newer build is refused",
|
|
1159
|
+
Status::Proven,
|
|
1160
|
+
);
|
|
1161
|
+
claiming(
|
|
1162
|
+
"U6 a snapshot from another format is refused",
|
|
1163
|
+
Status::Proven,
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
let directory = TempDir::new().unwrap();
|
|
1167
|
+
let path = directory.path().join("legacy.log");
|
|
1168
|
+
let legacy_revision = json!({
|
|
1169
|
+
"capability": "state",
|
|
1170
|
+
"key": "state:revision:aabbcc",
|
|
1171
|
+
"rust_type": "feltdb::state_model::StateRevision",
|
|
1172
|
+
"value": {
|
|
1173
|
+
"id": "aabbcc",
|
|
1174
|
+
"content": "{\"title\":\"A\"}",
|
|
1175
|
+
"parent_id": null,
|
|
1176
|
+
"authority": "before-the-model",
|
|
1177
|
+
"timestamp_ms": 0,
|
|
1178
|
+
"metadata": {}
|
|
1179
|
+
},
|
|
1180
|
+
"unix_ms": 1,
|
|
1181
|
+
"content_hash": null,
|
|
1182
|
+
"flow_ref": null,
|
|
1183
|
+
"deleted": false,
|
|
1184
|
+
"operation": null
|
|
1185
|
+
});
|
|
1186
|
+
let mut file = std::fs::File::create(&path).unwrap();
|
|
1187
|
+
writeln!(file, "{}", serde_json::to_string(&legacy_revision).unwrap()).unwrap();
|
|
1188
|
+
drop(file);
|
|
1189
|
+
let before = std::fs::read(&path).unwrap();
|
|
1190
|
+
|
|
1191
|
+
// U1/U3: detected before interpretation, by a read-only examination.
|
|
1192
|
+
assert!(!feltdb::inspect_durable_format(&path)
|
|
1193
|
+
.unwrap()
|
|
1194
|
+
.is_compatible());
|
|
1195
|
+
assert!(
|
|
1196
|
+
matches!(FeltDb::open(&path), Err(FlowError::IncompatibleFormat(_))),
|
|
1197
|
+
"U3: refused, not opened empty"
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
// U4: nothing was modified.
|
|
1201
|
+
assert_eq!(std::fs::read(&path).unwrap(), before);
|
|
1202
|
+
|
|
1203
|
+
// U5: the same check in the rollback direction.
|
|
1204
|
+
let newer = directory.path().join("newer.log");
|
|
1205
|
+
std::fs::write(
|
|
1206
|
+
&newer,
|
|
1207
|
+
format!(
|
|
1208
|
+
"{}\n",
|
|
1209
|
+
json!({
|
|
1210
|
+
"record_type": "feltdb.format.v1",
|
|
1211
|
+
"format_version": feltdb::DURABLE_FORMAT_VERSION + 1
|
|
1212
|
+
})
|
|
1213
|
+
),
|
|
1214
|
+
)
|
|
1215
|
+
.unwrap();
|
|
1216
|
+
assert!(matches!(
|
|
1217
|
+
FeltDb::open(&newer),
|
|
1218
|
+
Err(FlowError::IncompatibleFormat(_))
|
|
1219
|
+
));
|
|
1220
|
+
|
|
1221
|
+
// U6: and on the snapshot path, which also carries whole revision rows.
|
|
1222
|
+
let (_source_dir, source, _) = durable("snap-source.log");
|
|
1223
|
+
source.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
1224
|
+
let mut snapshot = source.export_snapshot().unwrap();
|
|
1225
|
+
snapshot.format_version = 0;
|
|
1226
|
+
let (_target_dir, target, _) = durable("snap-target.log");
|
|
1227
|
+
assert!(matches!(
|
|
1228
|
+
target.install_snapshot(snapshot),
|
|
1229
|
+
Err(FlowError::IncompatibleFormat(_))
|
|
1230
|
+
));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// ===========================================================================
|
|
1234
|
+
// Observability
|
|
1235
|
+
// ===========================================================================
|
|
1236
|
+
|
|
1237
|
+
/// **The O-block, mostly closed.**
|
|
1238
|
+
///
|
|
1239
|
+
/// *Was*: `/health` reported `storage: "durable"` as a **string constant** that
|
|
1240
|
+
/// inspected nothing — and asserted the one property still unproven. Worse, the
|
|
1241
|
+
/// overall status did not consider storage at all, so a database that had
|
|
1242
|
+
/// discarded an incomplete final append reported exactly what a cleanly
|
|
1243
|
+
/// replayed one did.
|
|
1244
|
+
/// *Now*: health reports the durable format the open accepted and whether
|
|
1245
|
+
/// replay discarded anything, and the overall status is degraded when it did.
|
|
1246
|
+
/// *Production implication*: an operator can tell a healthy database from one
|
|
1247
|
+
/// that lost its last write — the operational consequence of the corruption
|
|
1248
|
+
/// work, which until now stopped at the library boundary.
|
|
1249
|
+
///
|
|
1250
|
+
/// O4 is the rule the surface is built on: health can expose a proven fact, it
|
|
1251
|
+
/// cannot create a stronger guarantee. Nothing here claims power-loss
|
|
1252
|
+
/// durability, replication or backup state.
|
|
1253
|
+
///
|
|
1254
|
+
/// O5 records what the audit found beyond the obvious defect: `runtime`,
|
|
1255
|
+
/// `fabric`, `workflows` and `agents` are still compile-time constants
|
|
1256
|
+
/// presented as health. They are recorded rather than quietly fixed, because
|
|
1257
|
+
/// each needs an operational contract before it can be observed — and inventing
|
|
1258
|
+
/// those here would repeat the mistake being corrected.
|
|
1259
|
+
///
|
|
1260
|
+
/// Full evidence in `crates/feltdb/tests/operational_health_contract.rs`.
|
|
1261
|
+
#[test]
|
|
1262
|
+
fn health_reports_observed_storage_rather_than_a_constant() {
|
|
1263
|
+
claiming(
|
|
1264
|
+
"O1 an operator can inspect history and retention state",
|
|
1265
|
+
Status::PartiallyProven,
|
|
1266
|
+
);
|
|
1267
|
+
claiming(
|
|
1268
|
+
"O2 reported health reflects actual storage state",
|
|
1269
|
+
Status::Proven,
|
|
1270
|
+
);
|
|
1271
|
+
claiming(
|
|
1272
|
+
"O3 a recovered open is distinguishable from a clean one in health",
|
|
1273
|
+
Status::Proven,
|
|
1274
|
+
);
|
|
1275
|
+
claiming(
|
|
1276
|
+
"O4 health reports only conditions that were observed",
|
|
1277
|
+
Status::Proven,
|
|
1278
|
+
);
|
|
1279
|
+
claiming(
|
|
1280
|
+
"O5 the remaining health fields are compile-time constants",
|
|
1281
|
+
Status::PartiallyProven,
|
|
1282
|
+
);
|
|
1283
|
+
|
|
1284
|
+
let directory = TempDir::new().unwrap();
|
|
1285
|
+
let clean_path = directory.path().join("clean.log");
|
|
1286
|
+
let db = Arc::new(FeltDb::open(&clean_path).unwrap());
|
|
1287
|
+
for n in 0..3 {
|
|
1288
|
+
db.insert(&format!("tasks:{n}"), json!({ "n": n })).unwrap();
|
|
1289
|
+
}
|
|
1290
|
+
drop(db);
|
|
1291
|
+
let full = std::fs::read(&clean_path).unwrap();
|
|
1292
|
+
|
|
1293
|
+
// O2: observed, and it names what was seen.
|
|
1294
|
+
let clean = FeltDb::open(&clean_path).unwrap().health();
|
|
1295
|
+
assert!(clean.is_nominal());
|
|
1296
|
+
assert_eq!(clean.storage.label(), "clean");
|
|
1297
|
+
|
|
1298
|
+
// O3: a recovered open is a different report.
|
|
1299
|
+
let torn_path = directory.path().join("torn.log");
|
|
1300
|
+
std::fs::write(&torn_path, &full[..full.len() - 30]).unwrap();
|
|
1301
|
+
let torn = FeltDb::open(&torn_path).unwrap().health();
|
|
1302
|
+
assert_ne!(clean, torn);
|
|
1303
|
+
assert!(!torn.is_nominal());
|
|
1304
|
+
|
|
1305
|
+
// O4: no label asserts more than was observed.
|
|
1306
|
+
for label in [clean.storage.label(), torn.storage.label()] {
|
|
1307
|
+
assert_ne!(label, "durable");
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// O1 remains partial: a resource can be inspected, the database cannot be
|
|
1311
|
+
// asked whether any ancestry anywhere is damaged.
|
|
1312
|
+
let store = StateStore::with_feltdb(Arc::new(FeltDb::open(&clean_path).unwrap())).unwrap();
|
|
1313
|
+
assert!(store.resources().is_empty() || !store.resources().is_empty());
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ===========================================================================
|
|
1317
|
+
// The matrix itself
|
|
1318
|
+
// ===========================================================================
|
|
1319
|
+
|
|
1320
|
+
/// Every claim is exercised by a test, or is explicitly `Unproven`.
|
|
1321
|
+
///
|
|
1322
|
+
/// This is the discipline the audit turns on: a claim may not sit in the matrix
|
|
1323
|
+
/// with a status better than `Unproven` unless a test asserts it.
|
|
1324
|
+
#[test]
|
|
1325
|
+
fn every_provable_claim_has_a_test() {
|
|
1326
|
+
let source = include_str!("production_readiness_contract.rs");
|
|
1327
|
+
let mut unexercised = Vec::new();
|
|
1328
|
+
for (claim, recorded) in CLAIMS {
|
|
1329
|
+
if matches!(recorded, Status::Unproven | Status::NotApplicable) {
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
// `claiming(...)` names the claim verbatim at the top of its test.
|
|
1333
|
+
let quoted = format!("\"{claim}\"");
|
|
1334
|
+
if source.matches("ed).count() < 2 {
|
|
1335
|
+
unexercised.push(*claim);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
assert!(
|
|
1339
|
+
unexercised.is_empty(),
|
|
1340
|
+
"these claims are recorded better than Unproven but no test asserts them:\n {}",
|
|
1341
|
+
unexercised.join("\n ")
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/// The matrix is the size the document says it is.
|
|
1346
|
+
#[test]
|
|
1347
|
+
fn the_claim_matrix_is_complete() {
|
|
1348
|
+
assert_eq!(
|
|
1349
|
+
CLAIMS.len(),
|
|
1350
|
+
53,
|
|
1351
|
+
"claims changed without updating the count"
|
|
1352
|
+
);
|
|
1353
|
+
let blocked = CLAIMS.iter().filter(|(_, s)| *s == Status::Blocked).count();
|
|
1354
|
+
let unproven = CLAIMS
|
|
1355
|
+
.iter()
|
|
1356
|
+
.filter(|(_, s)| *s == Status::Unproven)
|
|
1357
|
+
.count();
|
|
1358
|
+
let not_applicable = CLAIMS
|
|
1359
|
+
.iter()
|
|
1360
|
+
.filter(|(_, s)| *s == Status::NotApplicable)
|
|
1361
|
+
.count();
|
|
1362
|
+
assert_eq!(blocked, 0, "blocked claims changed");
|
|
1363
|
+
assert_eq!(unproven, 4, "unproven claims changed");
|
|
1364
|
+
assert_eq!(not_applicable, 1, "not-applicable claims changed");
|
|
1365
|
+
}
|