@feltdb/core 0.8.3 → 0.8.5
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/cli/commands.js +4 -1
- package/dist/cli/provisioning-neutrality.js +79 -0
- package/dist/collection.d.ts +43 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +192 -22
- package/dist/create/create.js +25 -21
- package/dist/create/managed-account.js +11 -0
- 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/equality_index.rs +595 -0
- 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 +1050 -117
- 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_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 +90 -72
- 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_execution_diagnostics.rs +126 -0
- 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 +305 -6
- 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 +1514 -537
- 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/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
- 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 +442 -0
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -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/pr34_query_collection.rs +234 -0
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +947 -0
- 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 +1032 -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/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-server/src/app_state.rs +2 -1
- package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +800 -34
- package/dist/db.d.ts +33 -34
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +74 -20
- package/dist/deployment.d.ts +30 -0
- package/dist/deployment.d.ts.map +1 -0
- package/dist/deployment.js +130 -0
- package/dist/embedded-transaction.d.ts +22 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +51 -5
- package/dist/feltdb.d.ts +14 -2
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.js +1 -1
- package/dist/http-client.d.ts +14 -0
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +23 -5
- package/dist/http-db.d.ts +119 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +346 -31
- package/dist/index-core.d.ts +2 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +35 -21
- package/dist/managed-recovery.d.ts +192 -0
- package/dist/managed-recovery.d.ts.map +1 -0
- package/dist/managed-recovery.js +242 -0
- package/dist/memory-db.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-DaNwCLRX.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
- package/dist/studio-app/assets/index-j8IlhNqJ.js +29 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +30 -0
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +41 -0
- 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-ClhDHp0S.wasm +0 -0
- package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
|
@@ -0,0 +1,1032 @@
|
|
|
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
|
+
// Atomicity and concurrency
|
|
72
|
+
(
|
|
73
|
+
"A1 a stale fenced write is rejected and mints nothing",
|
|
74
|
+
Status::Proven,
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
"A2 concurrent writes to distinct resources keep distinct chains",
|
|
78
|
+
Status::Proven,
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"A3 concurrent writes to one resource form one correct chain",
|
|
82
|
+
Status::Proven,
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"A4 a duplicate remote operation is idempotent",
|
|
86
|
+
Status::Proven,
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
"A5 a remote operation with a sequence gap is rejected",
|
|
90
|
+
Status::Proven,
|
|
91
|
+
),
|
|
92
|
+
(
|
|
93
|
+
"A6 a crash part-way through a mutation leaves consistent state",
|
|
94
|
+
Status::Unproven,
|
|
95
|
+
),
|
|
96
|
+
// Recovery
|
|
97
|
+
("R1 a truncated log is detected", Status::Blocked),
|
|
98
|
+
("R2 a malformed record is detected", Status::Blocked),
|
|
99
|
+
(
|
|
100
|
+
"R3 damaged ancestry is distinguishable from expired ancestry",
|
|
101
|
+
Status::Proven,
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
"R4 damage is surfaced without being asked for",
|
|
105
|
+
Status::Unproven,
|
|
106
|
+
),
|
|
107
|
+
// History and retention
|
|
108
|
+
(
|
|
109
|
+
"H1 history is bounded under a configured policy",
|
|
110
|
+
Status::Proven,
|
|
111
|
+
),
|
|
112
|
+
(
|
|
113
|
+
"H2 history is unbounded without one, by design",
|
|
114
|
+
Status::Proven,
|
|
115
|
+
),
|
|
116
|
+
(
|
|
117
|
+
"H3 a policy change takes effect in both directions",
|
|
118
|
+
Status::Proven,
|
|
119
|
+
),
|
|
120
|
+
("H4 maintenance never rewrites history", Status::Proven),
|
|
121
|
+
("H5 forked history is preserved", Status::Proven),
|
|
122
|
+
// Backup and restore
|
|
123
|
+
(
|
|
124
|
+
"B1 an in-process snapshot round-trips state and history",
|
|
125
|
+
Status::PartiallyProven,
|
|
126
|
+
),
|
|
127
|
+
(
|
|
128
|
+
"B2 an operator-facing backup, restore and verify workflow exists",
|
|
129
|
+
Status::Unproven,
|
|
130
|
+
),
|
|
131
|
+
// Replication
|
|
132
|
+
("S1 current state replicates to a peer", Status::Proven),
|
|
133
|
+
("S2 revision history replicates to a peer", Status::Blocked),
|
|
134
|
+
// Workload envelope
|
|
135
|
+
(
|
|
136
|
+
"E1 retention cost per write grows with the retention window",
|
|
137
|
+
Status::Proven,
|
|
138
|
+
),
|
|
139
|
+
(
|
|
140
|
+
"E2 retention increases log size before compaction",
|
|
141
|
+
Status::Proven,
|
|
142
|
+
),
|
|
143
|
+
// Upgrade and migration
|
|
144
|
+
(
|
|
145
|
+
"U1 an incompatible durable format is detected before any record is interpreted",
|
|
146
|
+
Status::Proven,
|
|
147
|
+
),
|
|
148
|
+
(
|
|
149
|
+
"U2 a pre-model database's revision history is losslessly migratable",
|
|
150
|
+
Status::NotApplicable,
|
|
151
|
+
),
|
|
152
|
+
(
|
|
153
|
+
"U3 a persisted durable format version exists and is checked on open",
|
|
154
|
+
Status::Proven,
|
|
155
|
+
),
|
|
156
|
+
(
|
|
157
|
+
"U4 a refused open leaves the database byte-identical",
|
|
158
|
+
Status::Proven,
|
|
159
|
+
),
|
|
160
|
+
(
|
|
161
|
+
"U5 a durable format from a newer build is refused",
|
|
162
|
+
Status::Proven,
|
|
163
|
+
),
|
|
164
|
+
(
|
|
165
|
+
"U6 a snapshot from another format is refused",
|
|
166
|
+
Status::Proven,
|
|
167
|
+
),
|
|
168
|
+
// Observability
|
|
169
|
+
(
|
|
170
|
+
"O1 an operator can inspect history and retention state",
|
|
171
|
+
Status::PartiallyProven,
|
|
172
|
+
),
|
|
173
|
+
(
|
|
174
|
+
"O2 reported health reflects actual storage state",
|
|
175
|
+
Status::Blocked,
|
|
176
|
+
),
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
fn status(claim: &str) -> Status {
|
|
180
|
+
CLAIMS
|
|
181
|
+
.iter()
|
|
182
|
+
.find(|(name, _)| *name == claim)
|
|
183
|
+
.unwrap_or_else(|| panic!("{claim} is not in the claim matrix"))
|
|
184
|
+
.1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// Assert a claim's recorded status, so a test cannot quietly widen its claim.
|
|
188
|
+
fn claiming(claim: &str, expected: Status) {
|
|
189
|
+
assert_eq!(
|
|
190
|
+
status(claim),
|
|
191
|
+
expected,
|
|
192
|
+
"{claim}: this test exercises a {expected:?} claim, but the matrix records \
|
|
193
|
+
something else. Update docs/architecture/production-readiness.md deliberately."
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn durable(name: &str) -> (TempDir, Arc<FeltDb>, StateStore) {
|
|
198
|
+
let directory = TempDir::new().unwrap();
|
|
199
|
+
let db = Arc::new(FeltDb::open(directory.path().join(name)).unwrap());
|
|
200
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
201
|
+
(directory, db, store)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ===========================================================================
|
|
205
|
+
// Durability
|
|
206
|
+
// ===========================================================================
|
|
207
|
+
|
|
208
|
+
/// **Claim D1–D4.** A clean restart preserves state, history, retention and
|
|
209
|
+
/// position.
|
|
210
|
+
///
|
|
211
|
+
/// *Scenario*: write, mint under a policy, close, reopen, write again.
|
|
212
|
+
/// *Expected*: everything survives and history continues.
|
|
213
|
+
/// *Observed*: it does.
|
|
214
|
+
/// *Production implication*: a planned restart is safe. This says nothing about
|
|
215
|
+
/// an unplanned one — see D5.
|
|
216
|
+
#[test]
|
|
217
|
+
fn a_clean_restart_preserves_state_history_and_retention() {
|
|
218
|
+
claiming(
|
|
219
|
+
"D1 committed mutations survive a clean restart",
|
|
220
|
+
Status::Proven,
|
|
221
|
+
);
|
|
222
|
+
claiming(
|
|
223
|
+
"D2 revision history survives a clean restart",
|
|
224
|
+
Status::Proven,
|
|
225
|
+
);
|
|
226
|
+
claiming(
|
|
227
|
+
"D3 retention policy and horizon survive a restart",
|
|
228
|
+
Status::Proven,
|
|
229
|
+
);
|
|
230
|
+
claiming(
|
|
231
|
+
"D4 a reopened database continues history rather than restarting it",
|
|
232
|
+
Status::Proven,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
let directory = TempDir::new().unwrap();
|
|
236
|
+
let path = directory.path().join("restart.log");
|
|
237
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
238
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
239
|
+
store
|
|
240
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(3))
|
|
241
|
+
.unwrap();
|
|
242
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
243
|
+
for n in 1..10 {
|
|
244
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
245
|
+
}
|
|
246
|
+
let horizon_before = store.retention_horizon("tasks:1");
|
|
247
|
+
drop(store);
|
|
248
|
+
drop(db);
|
|
249
|
+
|
|
250
|
+
let reopened = Arc::new(FeltDb::open(&path).unwrap());
|
|
251
|
+
let store = StateStore::with_feltdb(reopened.clone()).unwrap();
|
|
252
|
+
|
|
253
|
+
let current: serde_json::Value = reopened.get("tasks:1").unwrap().unwrap();
|
|
254
|
+
assert_eq!(current["n"], json!(9), "D1");
|
|
255
|
+
assert_eq!(store.history_of("tasks:1").len(), 3, "D2");
|
|
256
|
+
assert_eq!(
|
|
257
|
+
store.retention_policy("tasks:1"),
|
|
258
|
+
RetentionPolicy::keep_last(3),
|
|
259
|
+
"D3 policy"
|
|
260
|
+
);
|
|
261
|
+
assert_eq!(
|
|
262
|
+
store.retention_horizon("tasks:1"),
|
|
263
|
+
horizon_before,
|
|
264
|
+
"D3 horizon"
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
reopened.update("tasks:1", json!({"n": 10})).unwrap();
|
|
268
|
+
let history = store.history_of("tasks:1");
|
|
269
|
+
assert_eq!(history.last().unwrap().sequence, 10, "D4");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// **Claim D5 is Unproven, and the reason is in the code rather than a guess.**
|
|
273
|
+
///
|
|
274
|
+
/// `append_event` writes a record and flushes it to the operating system. It
|
|
275
|
+
/// does **not** fsync; only `append_transaction` does. So a single write
|
|
276
|
+
/// survives the process dying, and its survival across an operating-system or
|
|
277
|
+
/// power failure is not established by anything here.
|
|
278
|
+
///
|
|
279
|
+
/// Automatic revision minting uses the same path, so a revision inherits
|
|
280
|
+
/// exactly this durability and no more.
|
|
281
|
+
///
|
|
282
|
+
/// This test asserts only what it can: that the process-death case holds. It
|
|
283
|
+
/// deliberately does not assert the power-loss case, and the matrix says so.
|
|
284
|
+
#[test]
|
|
285
|
+
fn a_single_write_is_flushed_but_its_power_loss_behaviour_is_unproven() {
|
|
286
|
+
claiming(
|
|
287
|
+
"D5 a single write survives operating-system or power loss",
|
|
288
|
+
Status::Unproven,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
let directory = TempDir::new().unwrap();
|
|
292
|
+
let path = directory.path().join("flush.log");
|
|
293
|
+
let db = FeltDb::open(&path).unwrap();
|
|
294
|
+
db.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
295
|
+
// Read the file behind the database's back: the record is already there.
|
|
296
|
+
let log = std::fs::read_to_string(&path).unwrap();
|
|
297
|
+
assert!(
|
|
298
|
+
log.contains("tasks:1"),
|
|
299
|
+
"the record reached the file before any close"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ===========================================================================
|
|
304
|
+
// Atomicity and concurrency
|
|
305
|
+
// ===========================================================================
|
|
306
|
+
|
|
307
|
+
/// **Claim A1.** A refused fence changes nothing — not the row, not the history.
|
|
308
|
+
#[test]
|
|
309
|
+
fn a_stale_fenced_write_is_rejected_and_mints_nothing() {
|
|
310
|
+
claiming(
|
|
311
|
+
"A1 a stale fenced write is rejected and mints nothing",
|
|
312
|
+
Status::Proven,
|
|
313
|
+
);
|
|
314
|
+
let (_directory, db, store) = durable("cas.log");
|
|
315
|
+
db.insert("tasks:1", json!({"title": "A", "__version": 1}))
|
|
316
|
+
.unwrap();
|
|
317
|
+
let before = store.history_of("tasks:1").len();
|
|
318
|
+
|
|
319
|
+
let refused = db
|
|
320
|
+
.compare_and_set_json("tasks:1", 99, None, None, false, json!({"title": "Z"}))
|
|
321
|
+
.unwrap();
|
|
322
|
+
assert!(matches!(refused, JsonCasResult::VersionConflict { .. }));
|
|
323
|
+
|
|
324
|
+
assert_eq!(
|
|
325
|
+
store.history_of("tasks:1").len(),
|
|
326
|
+
before,
|
|
327
|
+
"no revision minted"
|
|
328
|
+
);
|
|
329
|
+
let current: serde_json::Value = db.get("tasks:1").unwrap().unwrap();
|
|
330
|
+
assert_eq!(current["title"], json!("A"), "the row is unchanged");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// **Claims A2 and A3.** Concurrency produces correct chains, on separate
|
|
334
|
+
/// resources and on one shared resource.
|
|
335
|
+
///
|
|
336
|
+
/// *Production implication*: the write path serializes minting correctly. It
|
|
337
|
+
/// says nothing about throughput under contention — see E1.
|
|
338
|
+
#[test]
|
|
339
|
+
fn concurrent_writes_produce_correct_chains() {
|
|
340
|
+
claiming(
|
|
341
|
+
"A2 concurrent writes to distinct resources keep distinct chains",
|
|
342
|
+
Status::Proven,
|
|
343
|
+
);
|
|
344
|
+
claiming(
|
|
345
|
+
"A3 concurrent writes to one resource form one correct chain",
|
|
346
|
+
Status::Proven,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
let directory = TempDir::new().unwrap();
|
|
350
|
+
let db = Arc::new(FeltDb::open(directory.path().join("concurrent.log")).unwrap());
|
|
351
|
+
|
|
352
|
+
let mut handles = Vec::new();
|
|
353
|
+
for thread_index in 0..4 {
|
|
354
|
+
let db = db.clone();
|
|
355
|
+
handles.push(std::thread::spawn(move || {
|
|
356
|
+
for n in 0..25 {
|
|
357
|
+
db.update(&format!("tasks:{thread_index}"), json!({ "n": n }))
|
|
358
|
+
.unwrap();
|
|
359
|
+
db.update("shared:1", json!({ "t": thread_index, "n": n }))
|
|
360
|
+
.unwrap();
|
|
361
|
+
}
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
for handle in handles {
|
|
365
|
+
handle.join().unwrap();
|
|
366
|
+
}
|
|
367
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
368
|
+
|
|
369
|
+
for thread_index in 0..4 {
|
|
370
|
+
let history = store.history_of(&format!("tasks:{thread_index}"));
|
|
371
|
+
assert_eq!(history.len(), 25, "A2 length");
|
|
372
|
+
assert!(
|
|
373
|
+
history
|
|
374
|
+
.windows(2)
|
|
375
|
+
.all(|pair| pair[1].parent_id.as_ref() == Some(&pair[0].id)),
|
|
376
|
+
"A2 chained"
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let shared = store.history_of("shared:1");
|
|
381
|
+
assert_eq!(shared.len(), 100, "A3 every write minted exactly once");
|
|
382
|
+
assert!(
|
|
383
|
+
shared
|
|
384
|
+
.windows(2)
|
|
385
|
+
.all(|pair| pair[1].parent_id.as_ref() == Some(&pair[0].id)),
|
|
386
|
+
"A3 one chain, not four"
|
|
387
|
+
);
|
|
388
|
+
let mut sequences: Vec<u64> = shared.iter().map(|revision| revision.sequence).collect();
|
|
389
|
+
sequences.dedup();
|
|
390
|
+
assert_eq!(sequences.len(), 100, "A3 sequences are unique");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/// **Claims A4 and A5.** Replayed and out-of-order remote operations.
|
|
394
|
+
#[test]
|
|
395
|
+
fn replayed_and_gapped_remote_operations_are_handled_explicitly() {
|
|
396
|
+
claiming(
|
|
397
|
+
"A4 a duplicate remote operation is idempotent",
|
|
398
|
+
Status::Proven,
|
|
399
|
+
);
|
|
400
|
+
claiming(
|
|
401
|
+
"A5 a remote operation with a sequence gap is rejected",
|
|
402
|
+
Status::Proven,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
let directory = TempDir::new().unwrap();
|
|
406
|
+
let primary = Arc::new(FeltDb::open(directory.path().join("p.log")).unwrap());
|
|
407
|
+
let replica = Arc::new(FeltDb::open(directory.path().join("r.log")).unwrap());
|
|
408
|
+
|
|
409
|
+
primary.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
410
|
+
primary.update("tasks:1", json!({"n": 2})).unwrap();
|
|
411
|
+
let operations = primary.operations_since(&HashMap::new()).unwrap();
|
|
412
|
+
assert!(operations.len() >= 2);
|
|
413
|
+
|
|
414
|
+
// Applying out of order is refused rather than silently accepted.
|
|
415
|
+
assert!(
|
|
416
|
+
replica
|
|
417
|
+
.apply_remote_operation(operations[1].clone())
|
|
418
|
+
.is_err(),
|
|
419
|
+
"A5"
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
for operation in &operations {
|
|
423
|
+
replica.apply_remote_operation(operation.clone()).unwrap();
|
|
424
|
+
}
|
|
425
|
+
let after_first_pass: serde_json::Value = replica.get("tasks:1").unwrap().unwrap();
|
|
426
|
+
|
|
427
|
+
// Replaying the whole stream is a no-op.
|
|
428
|
+
for operation in &operations {
|
|
429
|
+
replica.apply_remote_operation(operation.clone()).unwrap();
|
|
430
|
+
}
|
|
431
|
+
let after_replay: serde_json::Value = replica.get("tasks:1").unwrap().unwrap();
|
|
432
|
+
assert_eq!(after_first_pass, after_replay, "A4");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ===========================================================================
|
|
436
|
+
// Recovery and corruption
|
|
437
|
+
// ===========================================================================
|
|
438
|
+
|
|
439
|
+
/// **Claims R1 and R2 are Blocked, and this test records the defect.**
|
|
440
|
+
///
|
|
441
|
+
/// *Scenario*: truncate the log mid-record; append a line that is not JSON.
|
|
442
|
+
/// *Expected of a database*: the damage is reported.
|
|
443
|
+
/// *Observed*: both open successfully. The truncated tail record is dropped and
|
|
444
|
+
/// the malformed line is skipped, with no error, no warning and no way for a
|
|
445
|
+
/// caller to learn it happened.
|
|
446
|
+
/// *Production implication*: **an operator cannot distinguish a healthy
|
|
447
|
+
/// database from one that has silently lost its most recent writes.** This is
|
|
448
|
+
/// the single largest recovery gap.
|
|
449
|
+
///
|
|
450
|
+
/// The assertions below encode the current behaviour, so closing the gap fails
|
|
451
|
+
/// this test and forces the matrix to be updated.
|
|
452
|
+
#[test]
|
|
453
|
+
fn a_damaged_log_is_accepted_silently() {
|
|
454
|
+
claiming("R1 a truncated log is detected", Status::Blocked);
|
|
455
|
+
claiming("R2 a malformed record is detected", Status::Blocked);
|
|
456
|
+
|
|
457
|
+
// Truncation.
|
|
458
|
+
let directory = TempDir::new().unwrap();
|
|
459
|
+
let path = directory.path().join("truncated.log");
|
|
460
|
+
let db = FeltDb::open(&path).unwrap();
|
|
461
|
+
for n in 0..5 {
|
|
462
|
+
db.insert(&format!("tasks:{n}"), json!({ "n": n })).unwrap();
|
|
463
|
+
}
|
|
464
|
+
drop(db);
|
|
465
|
+
let bytes = std::fs::read(&path).unwrap();
|
|
466
|
+
std::fs::write(&path, &bytes[..bytes.len() - 600]).unwrap();
|
|
467
|
+
|
|
468
|
+
let reopened = FeltDb::open(&path).expect("truncation does not fail the open");
|
|
469
|
+
let surviving = (0..5)
|
|
470
|
+
.filter(|n| {
|
|
471
|
+
reopened
|
|
472
|
+
.get::<serde_json::Value>(&format!("tasks:{n}"))
|
|
473
|
+
.unwrap()
|
|
474
|
+
.is_some()
|
|
475
|
+
})
|
|
476
|
+
.count();
|
|
477
|
+
assert!(
|
|
478
|
+
surviving < 5,
|
|
479
|
+
"records were lost — and nothing said so ({surviving}/5 present)"
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// A line that is not JSON at all.
|
|
483
|
+
let directory = TempDir::new().unwrap();
|
|
484
|
+
let path = directory.path().join("garbage.log");
|
|
485
|
+
let db = FeltDb::open(&path).unwrap();
|
|
486
|
+
db.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
487
|
+
drop(db);
|
|
488
|
+
let mut file = std::fs::OpenOptions::new()
|
|
489
|
+
.append(true)
|
|
490
|
+
.open(&path)
|
|
491
|
+
.unwrap();
|
|
492
|
+
writeln!(file, "this is not json at all").unwrap();
|
|
493
|
+
drop(file);
|
|
494
|
+
|
|
495
|
+
let reopened = FeltDb::open(&path).expect("a malformed record does not fail the open");
|
|
496
|
+
assert!(
|
|
497
|
+
reopened
|
|
498
|
+
.get::<serde_json::Value>("tasks:1")
|
|
499
|
+
.unwrap()
|
|
500
|
+
.is_some(),
|
|
501
|
+
"earlier records are fine; the corrupt one simply vanished"
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/// **Claim R3.** Damaged ancestry and expired ancestry are different facts, and
|
|
506
|
+
/// the store says which.
|
|
507
|
+
///
|
|
508
|
+
/// This is the one corruption case FeltDB genuinely detects, and it exists
|
|
509
|
+
/// because retention needed the distinction. A parent absent at or below the
|
|
510
|
+
/// horizon was expired on purpose; absent above it is damage.
|
|
511
|
+
///
|
|
512
|
+
/// *Production implication*: ancestry damage is detectable — **on inspection**.
|
|
513
|
+
/// Nothing surfaces it unprompted, which is claim R4.
|
|
514
|
+
#[test]
|
|
515
|
+
fn damaged_ancestry_is_distinguishable_from_expired_ancestry() {
|
|
516
|
+
claiming(
|
|
517
|
+
"R3 damaged ancestry is distinguishable from expired ancestry",
|
|
518
|
+
Status::Proven,
|
|
519
|
+
);
|
|
520
|
+
claiming(
|
|
521
|
+
"R4 damage is surfaced without being asked for",
|
|
522
|
+
Status::Unproven,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
// Damage: a mid-history revision removed with no retention involved.
|
|
526
|
+
let (_directory, db, store) = durable("damaged.log");
|
|
527
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
528
|
+
db.update("tasks:1", json!({"n": 1})).unwrap();
|
|
529
|
+
db.update("tasks:1", json!({"n": 2})).unwrap();
|
|
530
|
+
let history = store.history_of("tasks:1");
|
|
531
|
+
let middle = history[1].id.clone();
|
|
532
|
+
let child = history[2].id.clone();
|
|
533
|
+
db.delete(&format!("state:revision:{}", middle.as_hex()))
|
|
534
|
+
.unwrap();
|
|
535
|
+
|
|
536
|
+
assert!(
|
|
537
|
+
matches!(store.parent_of(&child), Some(ParentLookup::Missing(_))),
|
|
538
|
+
"R3: reported as damage"
|
|
539
|
+
);
|
|
540
|
+
assert_eq!(
|
|
541
|
+
store.retention_horizon("tasks:1"),
|
|
542
|
+
0,
|
|
543
|
+
"no retention happened"
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// Expiry: the same shape, but a decision.
|
|
547
|
+
let (_directory, db, store) = durable("expired.log");
|
|
548
|
+
store
|
|
549
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(2))
|
|
550
|
+
.unwrap();
|
|
551
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
552
|
+
for n in 1..6 {
|
|
553
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
554
|
+
}
|
|
555
|
+
let oldest = store.history_of("tasks:1")[0].id.clone();
|
|
556
|
+
assert!(
|
|
557
|
+
matches!(store.parent_of(&oldest), Some(ParentLookup::Expired(_))),
|
|
558
|
+
"R3: reported as a decision"
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ===========================================================================
|
|
563
|
+
// History and retention
|
|
564
|
+
// ===========================================================================
|
|
565
|
+
|
|
566
|
+
/// **Claims H1, H2, H3.** The retention envelope, in both directions.
|
|
567
|
+
#[test]
|
|
568
|
+
fn the_retention_envelope_behaves_in_both_directions() {
|
|
569
|
+
claiming(
|
|
570
|
+
"H1 history is bounded under a configured policy",
|
|
571
|
+
Status::Proven,
|
|
572
|
+
);
|
|
573
|
+
claiming(
|
|
574
|
+
"H2 history is unbounded without one, by design",
|
|
575
|
+
Status::Proven,
|
|
576
|
+
);
|
|
577
|
+
claiming(
|
|
578
|
+
"H3 a policy change takes effect in both directions",
|
|
579
|
+
Status::Proven,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
// Unbounded by default.
|
|
583
|
+
let (_directory, db, store) = durable("unbounded.log");
|
|
584
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
585
|
+
for n in 1..50 {
|
|
586
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
587
|
+
}
|
|
588
|
+
assert_eq!(store.history_of("tasks:1").len(), 50, "H2");
|
|
589
|
+
|
|
590
|
+
// Tightening expires immediately.
|
|
591
|
+
store
|
|
592
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(3))
|
|
593
|
+
.unwrap();
|
|
594
|
+
assert_eq!(store.history_of("tasks:1").len(), 3, "H3 tighten");
|
|
595
|
+
|
|
596
|
+
// And stays bounded as writes continue.
|
|
597
|
+
for n in 50..100 {
|
|
598
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
599
|
+
}
|
|
600
|
+
assert_eq!(store.history_of("tasks:1").len(), 3, "H1");
|
|
601
|
+
|
|
602
|
+
// Loosening lets it grow again — it does not restore what was expired.
|
|
603
|
+
store
|
|
604
|
+
.set_retention_policy("tasks:1", RetentionPolicy::unbounded())
|
|
605
|
+
.unwrap();
|
|
606
|
+
for n in 100..110 {
|
|
607
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
608
|
+
}
|
|
609
|
+
assert_eq!(store.history_of("tasks:1").len(), 13, "H3 loosen");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/// **Claims H4 and H5.** Maintenance never rewrites history, and forks survive.
|
|
613
|
+
#[test]
|
|
614
|
+
fn maintenance_never_rewrites_history_and_forks_survive() {
|
|
615
|
+
claiming("H4 maintenance never rewrites history", Status::Proven);
|
|
616
|
+
claiming("H5 forked history is preserved", Status::Proven);
|
|
617
|
+
|
|
618
|
+
let directory = TempDir::new().unwrap();
|
|
619
|
+
let path = directory.path().join("forks.log");
|
|
620
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
621
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
622
|
+
|
|
623
|
+
let base = store
|
|
624
|
+
.create("docs:1", r#"{"v":0}"#.into(), "a".into())
|
|
625
|
+
.unwrap();
|
|
626
|
+
let left = store
|
|
627
|
+
.commit(r#"{"v":1}"#.into(), &base, "a".into())
|
|
628
|
+
.unwrap();
|
|
629
|
+
let right = store
|
|
630
|
+
.commit(r#"{"v":2}"#.into(), &base, "b".into())
|
|
631
|
+
.unwrap();
|
|
632
|
+
assert_ne!(left.id, right.id, "H5: two heads from one base");
|
|
633
|
+
assert_eq!(left.parent_id, right.parent_id);
|
|
634
|
+
|
|
635
|
+
// Compaction, an unrelated write, and a retention pass on another resource.
|
|
636
|
+
db.insert("tasks:1", json!({"unrelated": true})).unwrap();
|
|
637
|
+
let versions = db.operation_versions().unwrap();
|
|
638
|
+
db.acknowledge_peer_versions("peer-1".to_string(), versions)
|
|
639
|
+
.unwrap();
|
|
640
|
+
db.compact_operation_log(&["peer-1".to_string()]).unwrap();
|
|
641
|
+
store
|
|
642
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(1))
|
|
643
|
+
.unwrap();
|
|
644
|
+
|
|
645
|
+
for revision in [&base, &left, &right] {
|
|
646
|
+
let stored = store.get(&revision.id).expect("H4: still there");
|
|
647
|
+
assert_eq!(stored.parent_id, revision.parent_id, "H4: ancestry intact");
|
|
648
|
+
assert_eq!(stored.content_id, revision.content_id);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ===========================================================================
|
|
653
|
+
// Backup and restore
|
|
654
|
+
// ===========================================================================
|
|
655
|
+
|
|
656
|
+
/// **Claim B1 is Partially Proven; B2 is Unproven.**
|
|
657
|
+
///
|
|
658
|
+
/// *Observed*: `export_snapshot` and `install_snapshot` do round-trip current
|
|
659
|
+
/// state **and** revision history into an empty database.
|
|
660
|
+
/// *What that is not*: a backup product. There is no operator command, no
|
|
661
|
+
/// on-disk backup format, no integrity verification step a human can run, no
|
|
662
|
+
/// incremental or scheduled backup, and no documented restore procedure. The
|
|
663
|
+
/// snapshot is an in-process value used by replication.
|
|
664
|
+
/// *Production implication*: **an operator has no supported way to back up or
|
|
665
|
+
/// restore a FeltDB database today.** The mechanism a backup could be built on
|
|
666
|
+
/// exists; the workflow does not.
|
|
667
|
+
#[test]
|
|
668
|
+
fn a_snapshot_round_trips_but_is_not_a_backup_workflow() {
|
|
669
|
+
claiming(
|
|
670
|
+
"B1 an in-process snapshot round-trips state and history",
|
|
671
|
+
Status::PartiallyProven,
|
|
672
|
+
);
|
|
673
|
+
claiming(
|
|
674
|
+
"B2 an operator-facing backup, restore and verify workflow exists",
|
|
675
|
+
Status::Unproven,
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
let (_source_dir, source, source_store) = durable("source.log");
|
|
679
|
+
source.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
680
|
+
source.update("tasks:1", json!({"n": 1})).unwrap();
|
|
681
|
+
let history_before = source_store.history_of("tasks:1").len();
|
|
682
|
+
let snapshot = source.export_snapshot().unwrap();
|
|
683
|
+
|
|
684
|
+
let (_target_dir, target, target_store) = durable("target.log");
|
|
685
|
+
assert!(target.can_install_snapshot().unwrap());
|
|
686
|
+
target.install_snapshot(snapshot).unwrap();
|
|
687
|
+
|
|
688
|
+
let current: serde_json::Value = target.get("tasks:1").unwrap().unwrap();
|
|
689
|
+
assert_eq!(current["n"], json!(1), "B1 current state");
|
|
690
|
+
assert_eq!(
|
|
691
|
+
target_store.history_of("tasks:1").len(),
|
|
692
|
+
history_before,
|
|
693
|
+
"B1 revision history"
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ===========================================================================
|
|
698
|
+
// Replication
|
|
699
|
+
// ===========================================================================
|
|
700
|
+
|
|
701
|
+
/// **Claim S2 is Blocked, and this is the open question #306 left.**
|
|
702
|
+
///
|
|
703
|
+
/// *Scenario*: a primary takes a write; the replica applies the operation.
|
|
704
|
+
/// *Expected of the production claim*: the replica has the same history.
|
|
705
|
+
/// *Observed*: the replica has the same **current state** and **no revision
|
|
706
|
+
/// history at all**. `apply_remote_operation` writes rows directly and never
|
|
707
|
+
/// reaches the minting path.
|
|
708
|
+
/// *Production implication*: **revision history is a local artifact.** A
|
|
709
|
+
/// failover to a replica loses all history; reconciliation on a replica has no
|
|
710
|
+
/// `base`; and the two nodes disagree about what happened even though they
|
|
711
|
+
/// agree about what is.
|
|
712
|
+
///
|
|
713
|
+
/// The revision model makes this closable — identity is a function of resource,
|
|
714
|
+
/// content, parent and sequence, all of which the replica has — but nothing
|
|
715
|
+
/// closes it today.
|
|
716
|
+
#[test]
|
|
717
|
+
fn a_replica_receives_state_but_no_history() {
|
|
718
|
+
claiming("S1 current state replicates to a peer", Status::Proven);
|
|
719
|
+
claiming("S2 revision history replicates to a peer", Status::Blocked);
|
|
720
|
+
|
|
721
|
+
let directory = TempDir::new().unwrap();
|
|
722
|
+
let primary = Arc::new(FeltDb::open(directory.path().join("primary.log")).unwrap());
|
|
723
|
+
let replica = Arc::new(FeltDb::open(directory.path().join("replica.log")).unwrap());
|
|
724
|
+
|
|
725
|
+
primary.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
726
|
+
primary.update("tasks:1", json!({"n": 2})).unwrap();
|
|
727
|
+
for operation in primary.operations_since(&HashMap::new()).unwrap() {
|
|
728
|
+
replica.apply_remote_operation(operation).unwrap();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
let current: serde_json::Value = replica.get("tasks:1").unwrap().unwrap();
|
|
732
|
+
assert_eq!(current["n"], json!(2), "S1: state arrived");
|
|
733
|
+
|
|
734
|
+
let primary_history = StateStore::with_feltdb(primary.clone())
|
|
735
|
+
.unwrap()
|
|
736
|
+
.history_of("tasks:1")
|
|
737
|
+
.len();
|
|
738
|
+
let replica_history = StateStore::with_feltdb(replica.clone())
|
|
739
|
+
.unwrap()
|
|
740
|
+
.history_of("tasks:1")
|
|
741
|
+
.len();
|
|
742
|
+
assert_eq!(primary_history, 2, "the primary recorded two revisions");
|
|
743
|
+
assert_eq!(replica_history, 0, "S2: the replica recorded none");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ===========================================================================
|
|
747
|
+
// Workload envelope
|
|
748
|
+
// ===========================================================================
|
|
749
|
+
|
|
750
|
+
/// **Claims E1 and E2.** Retention has a measurable cost, in two directions
|
|
751
|
+
/// that pull against each other.
|
|
752
|
+
///
|
|
753
|
+
/// *Observed*: the per-write cost of retention grows with the retention window,
|
|
754
|
+
/// because expiry walks the resource's retained history on every mint. And
|
|
755
|
+
/// retention makes the log **larger** before compaction, because each expiry
|
|
756
|
+
/// appends a tombstone.
|
|
757
|
+
/// *Production implication*: a large retention window costs write throughput,
|
|
758
|
+
/// and a small one costs log volume until the next compaction. Neither is a
|
|
759
|
+
/// defect; both are envelope facts an operator has to size for.
|
|
760
|
+
///
|
|
761
|
+
/// This measures. It does not optimise, and the thresholds are loose on purpose
|
|
762
|
+
/// — the claim is the *shape*, not a benchmark number.
|
|
763
|
+
#[test]
|
|
764
|
+
fn retention_cost_grows_with_the_window_and_inflates_the_log() {
|
|
765
|
+
claiming(
|
|
766
|
+
"E1 retention cost per write grows with the retention window",
|
|
767
|
+
Status::Proven,
|
|
768
|
+
);
|
|
769
|
+
claiming(
|
|
770
|
+
"E2 retention increases log size before compaction",
|
|
771
|
+
Status::Proven,
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
fn run(keep: Option<usize>) -> (std::time::Duration, u64) {
|
|
775
|
+
let directory = TempDir::new().unwrap();
|
|
776
|
+
let path = directory.path().join("envelope.log");
|
|
777
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
778
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
779
|
+
if let Some(keep) = keep {
|
|
780
|
+
store
|
|
781
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(keep))
|
|
782
|
+
.unwrap();
|
|
783
|
+
}
|
|
784
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
785
|
+
let start = std::time::Instant::now();
|
|
786
|
+
for n in 1..400 {
|
|
787
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
788
|
+
}
|
|
789
|
+
(start.elapsed(), std::fs::metadata(&path).unwrap().len())
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
let (none_time, none_bytes) = run(None);
|
|
793
|
+
let (small_time, small_bytes) = run(Some(5));
|
|
794
|
+
let (large_time, _) = run(Some(50));
|
|
795
|
+
|
|
796
|
+
assert!(
|
|
797
|
+
large_time > small_time,
|
|
798
|
+
"E1: a wider window costs more per write ({large_time:?} vs {small_time:?})"
|
|
799
|
+
);
|
|
800
|
+
assert!(
|
|
801
|
+
small_time > none_time,
|
|
802
|
+
"E1: retention costs more than no retention ({small_time:?} vs {none_time:?})"
|
|
803
|
+
);
|
|
804
|
+
assert!(
|
|
805
|
+
small_bytes > none_bytes,
|
|
806
|
+
"E2: expiry tombstones inflate the log ({small_bytes} vs {none_bytes})"
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ===========================================================================
|
|
811
|
+
// Upgrade and migration
|
|
812
|
+
// ===========================================================================
|
|
813
|
+
|
|
814
|
+
/// **The U-block, closed.** What was the most dangerous finding in this audit.
|
|
815
|
+
///
|
|
816
|
+
/// *Was*: a database whose `state:revision:` records predate the resource-scoped
|
|
817
|
+
/// model opened cleanly and reported no history at all. The records were still
|
|
818
|
+
/// on disk and every read path discarded what failed to deserialize. It failed
|
|
819
|
+
/// **open**.
|
|
820
|
+
/// *Now*: the durable format carries a version, the version is checked before
|
|
821
|
+
/// any record is interpreted, and an unrecognised format is refused with an
|
|
822
|
+
/// actionable error and without touching a byte.
|
|
823
|
+
/// *Production implication*: FeltDB fails closed when its durable meaning is
|
|
824
|
+
/// unknown — in both directions, so a rolled-back deployment cannot
|
|
825
|
+
/// reinterpret newer data either.
|
|
826
|
+
///
|
|
827
|
+
/// U2 is `NotApplicable` rather than `Proven`, and the distinction is the
|
|
828
|
+
/// point: lossless migration is impossible, because a legacy revision never
|
|
829
|
+
/// recorded which resource it belonged to. Inventing that would produce a
|
|
830
|
+
/// database that looks migrated and is wrong. Refusing is the correct
|
|
831
|
+
/// behaviour, not a deferral.
|
|
832
|
+
///
|
|
833
|
+
/// Full evidence in `crates/feltdb/tests/durable_format_compatibility.rs`.
|
|
834
|
+
#[test]
|
|
835
|
+
fn an_incompatible_durable_format_fails_closed() {
|
|
836
|
+
claiming(
|
|
837
|
+
"U1 an incompatible durable format is detected before any record is interpreted",
|
|
838
|
+
Status::Proven,
|
|
839
|
+
);
|
|
840
|
+
claiming(
|
|
841
|
+
"U2 a pre-model database's revision history is losslessly migratable",
|
|
842
|
+
Status::NotApplicable,
|
|
843
|
+
);
|
|
844
|
+
claiming(
|
|
845
|
+
"U3 a persisted durable format version exists and is checked on open",
|
|
846
|
+
Status::Proven,
|
|
847
|
+
);
|
|
848
|
+
claiming(
|
|
849
|
+
"U4 a refused open leaves the database byte-identical",
|
|
850
|
+
Status::Proven,
|
|
851
|
+
);
|
|
852
|
+
claiming(
|
|
853
|
+
"U5 a durable format from a newer build is refused",
|
|
854
|
+
Status::Proven,
|
|
855
|
+
);
|
|
856
|
+
claiming(
|
|
857
|
+
"U6 a snapshot from another format is refused",
|
|
858
|
+
Status::Proven,
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
let directory = TempDir::new().unwrap();
|
|
862
|
+
let path = directory.path().join("legacy.log");
|
|
863
|
+
let legacy_revision = json!({
|
|
864
|
+
"capability": "state",
|
|
865
|
+
"key": "state:revision:aabbcc",
|
|
866
|
+
"rust_type": "feltdb::state_model::StateRevision",
|
|
867
|
+
"value": {
|
|
868
|
+
"id": "aabbcc",
|
|
869
|
+
"content": "{\"title\":\"A\"}",
|
|
870
|
+
"parent_id": null,
|
|
871
|
+
"authority": "before-the-model",
|
|
872
|
+
"timestamp_ms": 0,
|
|
873
|
+
"metadata": {}
|
|
874
|
+
},
|
|
875
|
+
"unix_ms": 1,
|
|
876
|
+
"content_hash": null,
|
|
877
|
+
"flow_ref": null,
|
|
878
|
+
"deleted": false,
|
|
879
|
+
"operation": null
|
|
880
|
+
});
|
|
881
|
+
let mut file = std::fs::File::create(&path).unwrap();
|
|
882
|
+
writeln!(file, "{}", serde_json::to_string(&legacy_revision).unwrap()).unwrap();
|
|
883
|
+
drop(file);
|
|
884
|
+
let before = std::fs::read(&path).unwrap();
|
|
885
|
+
|
|
886
|
+
// U1/U3: detected before interpretation, by a read-only examination.
|
|
887
|
+
assert!(!feltdb::inspect_durable_format(&path)
|
|
888
|
+
.unwrap()
|
|
889
|
+
.is_compatible());
|
|
890
|
+
assert!(
|
|
891
|
+
matches!(FeltDb::open(&path), Err(FlowError::IncompatibleFormat(_))),
|
|
892
|
+
"U3: refused, not opened empty"
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
// U4: nothing was modified.
|
|
896
|
+
assert_eq!(std::fs::read(&path).unwrap(), before);
|
|
897
|
+
|
|
898
|
+
// U5: the same check in the rollback direction.
|
|
899
|
+
let newer = directory.path().join("newer.log");
|
|
900
|
+
std::fs::write(
|
|
901
|
+
&newer,
|
|
902
|
+
format!(
|
|
903
|
+
"{}\n",
|
|
904
|
+
json!({
|
|
905
|
+
"record_type": "feltdb.format.v1",
|
|
906
|
+
"format_version": feltdb::DURABLE_FORMAT_VERSION + 1
|
|
907
|
+
})
|
|
908
|
+
),
|
|
909
|
+
)
|
|
910
|
+
.unwrap();
|
|
911
|
+
assert!(matches!(
|
|
912
|
+
FeltDb::open(&newer),
|
|
913
|
+
Err(FlowError::IncompatibleFormat(_))
|
|
914
|
+
));
|
|
915
|
+
|
|
916
|
+
// U6: and on the snapshot path, which also carries whole revision rows.
|
|
917
|
+
let (_source_dir, source, _) = durable("snap-source.log");
|
|
918
|
+
source.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
919
|
+
let mut snapshot = source.export_snapshot().unwrap();
|
|
920
|
+
snapshot.format_version = 0;
|
|
921
|
+
let (_target_dir, target, _) = durable("snap-target.log");
|
|
922
|
+
assert!(matches!(
|
|
923
|
+
target.install_snapshot(snapshot),
|
|
924
|
+
Err(FlowError::IncompatibleFormat(_))
|
|
925
|
+
));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ===========================================================================
|
|
929
|
+
// Observability
|
|
930
|
+
// ===========================================================================
|
|
931
|
+
|
|
932
|
+
/// **Claim O1 is Partially Proven; O2 is Blocked.**
|
|
933
|
+
///
|
|
934
|
+
/// *Observed*: an operator with the library API can inspect a resource's
|
|
935
|
+
/// history, its retention policy and horizon, and resolve any parent to
|
|
936
|
+
/// `Root` / `Revision` / `Expired` / `Missing`. That is a real inspection
|
|
937
|
+
/// surface.
|
|
938
|
+
/// *What is missing*: nothing is aggregated. There is no count of resources
|
|
939
|
+
/// under retention, no record of retention activity, no counter for rejected
|
|
940
|
+
/// writes or conflicts, and no way to ask "is any ancestry damaged?" short of
|
|
941
|
+
/// walking every revision.
|
|
942
|
+
/// *And O2*: the server's `/health` reports `storage: "durable"` as a **string
|
|
943
|
+
/// constant**. It does not inspect storage. A database with a truncated log and
|
|
944
|
+
/// silently missing records reports itself healthy.
|
|
945
|
+
/// *Production implication*: an operator can investigate a resource they already
|
|
946
|
+
/// suspect, and cannot discover a problem they do not.
|
|
947
|
+
#[test]
|
|
948
|
+
fn an_operator_can_inspect_a_resource_but_not_the_database() {
|
|
949
|
+
claiming(
|
|
950
|
+
"O1 an operator can inspect history and retention state",
|
|
951
|
+
Status::PartiallyProven,
|
|
952
|
+
);
|
|
953
|
+
claiming(
|
|
954
|
+
"O2 reported health reflects actual storage state",
|
|
955
|
+
Status::Blocked,
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
let (_directory, db, store) = durable("observe.log");
|
|
959
|
+
store
|
|
960
|
+
.set_retention_policy("tasks:1", RetentionPolicy::keep_last(2))
|
|
961
|
+
.unwrap();
|
|
962
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
963
|
+
for n in 1..6 {
|
|
964
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// What an operator *can* see, per resource.
|
|
968
|
+
assert_eq!(store.resources(), vec!["tasks:1".to_string()]);
|
|
969
|
+
assert_eq!(store.history_of("tasks:1").len(), 2);
|
|
970
|
+
assert_eq!(
|
|
971
|
+
store.retention_policy("tasks:1"),
|
|
972
|
+
RetentionPolicy::keep_last(2)
|
|
973
|
+
);
|
|
974
|
+
assert!(store.retention_horizon("tasks:1") > 0);
|
|
975
|
+
let oldest = store.history_of("tasks:1")[0].id.clone();
|
|
976
|
+
assert!(store.parent_of(&oldest).is_some());
|
|
977
|
+
|
|
978
|
+
// What no API reports: how many revisions were expired, when, or whether
|
|
979
|
+
// any resource anywhere has damaged ancestry. Establishing either means
|
|
980
|
+
// walking every resource by hand, which is why O1 is only partly proven.
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ===========================================================================
|
|
984
|
+
// The matrix itself
|
|
985
|
+
// ===========================================================================
|
|
986
|
+
|
|
987
|
+
/// Every claim is exercised by a test, or is explicitly `Unproven`.
|
|
988
|
+
///
|
|
989
|
+
/// This is the discipline the audit turns on: a claim may not sit in the matrix
|
|
990
|
+
/// with a status better than `Unproven` unless a test asserts it.
|
|
991
|
+
#[test]
|
|
992
|
+
fn every_provable_claim_has_a_test() {
|
|
993
|
+
let source = include_str!("production_readiness_contract.rs");
|
|
994
|
+
let mut unexercised = Vec::new();
|
|
995
|
+
for (claim, recorded) in CLAIMS {
|
|
996
|
+
if matches!(recorded, Status::Unproven | Status::NotApplicable) {
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
// `claiming(...)` names the claim verbatim at the top of its test.
|
|
1000
|
+
let quoted = format!("\"{claim}\"");
|
|
1001
|
+
if source.matches("ed).count() < 2 {
|
|
1002
|
+
unexercised.push(*claim);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
assert!(
|
|
1006
|
+
unexercised.is_empty(),
|
|
1007
|
+
"these claims are recorded better than Unproven but no test asserts them:\n {}",
|
|
1008
|
+
unexercised.join("\n ")
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/// The matrix is the size the document says it is.
|
|
1013
|
+
#[test]
|
|
1014
|
+
fn the_claim_matrix_is_complete() {
|
|
1015
|
+
assert_eq!(
|
|
1016
|
+
CLAIMS.len(),
|
|
1017
|
+
34,
|
|
1018
|
+
"claims changed without updating the count"
|
|
1019
|
+
);
|
|
1020
|
+
let blocked = CLAIMS.iter().filter(|(_, s)| *s == Status::Blocked).count();
|
|
1021
|
+
let unproven = CLAIMS
|
|
1022
|
+
.iter()
|
|
1023
|
+
.filter(|(_, s)| *s == Status::Unproven)
|
|
1024
|
+
.count();
|
|
1025
|
+
let not_applicable = CLAIMS
|
|
1026
|
+
.iter()
|
|
1027
|
+
.filter(|(_, s)| *s == Status::NotApplicable)
|
|
1028
|
+
.count();
|
|
1029
|
+
assert_eq!(blocked, 4, "blocked claims changed");
|
|
1030
|
+
assert_eq!(unproven, 4, "unproven claims changed");
|
|
1031
|
+
assert_eq!(not_applicable, 1, "not-applicable claims changed");
|
|
1032
|
+
}
|