@feltdb/core 0.15.0 → 0.16.1
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/agent-state.d.ts +20 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +36 -0
- package/dist/build-identity.json +12 -4
- package/dist/cli/commands.js +187 -4
- package/dist/cli/publish-engine.js +116 -16
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +20 -2
- package/dist/create/build-identity.json +12 -4
- package/dist/create/managed-account.js +93 -35
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +6 -509
- package/dist/create/server-source/Cargo.toml +2 -13
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +356 -24
- package/dist/create/server-source/crates/feltdb-server/tests/rust_client.rs +10 -1
- package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +1 -1
- package/dist/db.d.ts +11 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +15 -2
- package/dist/error-codes.d.ts +5 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +9 -1
- package/dist/http-db.d.ts +16 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +36 -6
- package/dist/managed-recovery.d.ts +6 -0
- package/dist/managed-recovery.d.ts.map +1 -1
- package/dist/managed-recovery.js +9 -0
- package/dist/studio-app/assets/{feltdb_wasm-BjM1ylL3.js → feltdb_wasm-BUIxCuTR.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C3D6y6DK.wasm +0 -0
- package/dist/studio-app/assets/index-BhXYAhgS.js +37 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +55 -3
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +23 -0
- package/dist/workspace/workspace-connection.d.ts.map +1 -1
- package/dist/workspace/workspace-connection.js +14 -3
- package/package.json +1 -1
- package/dist/create/server-source/crates/feltdb/Cargo.lock +0 -175
- package/dist/create/server-source/crates/feltdb/Cargo.toml +0 -102
- package/dist/create/server-source/crates/feltdb/LICENSE +0 -21
- package/dist/create/server-source/crates/feltdb/README.md +0 -75
- package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +0 -370
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +0 -589
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +0 -247
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +0 -440
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +0 -275
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +0 -381
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +0 -467
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +0 -489
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +0 -281
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +0 -606
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +0 -531
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +0 -462
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +0 -505
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +0 -484
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +0 -758
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +0 -421
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +0 -329
- package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +0 -1705
- package/dist/create/server-source/crates/feltdb/src/acquisition.rs +0 -286
- package/dist/create/server-source/crates/feltdb/src/admission.rs +0 -185
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +0 -507
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +0 -539
- package/dist/create/server-source/crates/feltdb/src/agent_state.rs +0 -320
- package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +0 -1545
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +0 -521
- package/dist/create/server-source/crates/feltdb/src/application.rs +0 -2674
- package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +0 -1070
- package/dist/create/server-source/crates/feltdb/src/authority.rs +0 -479
- package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +0 -847
- package/dist/create/server-source/crates/feltdb/src/authorization.rs +0 -1030
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +0 -1122
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +0 -32
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +0 -19
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +0 -1474
- package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +0 -8
- package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +0 -418
- package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +0 -482
- package/dist/create/server-source/crates/feltdb/src/capability.rs +0 -903
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +0 -263
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -77
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +0 -496
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +0 -2574
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +0 -212
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +0 -422
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +0 -445
- package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +0 -465
- package/dist/create/server-source/crates/feltdb/src/convergence.rs +0 -634
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +0 -427
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +0 -376
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +0 -365
- package/dist/create/server-source/crates/feltdb/src/cron.rs +0 -294
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +0 -483
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +0 -477
- package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +0 -1003
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +0 -1334
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +0 -368
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +0 -231
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +0 -482
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +0 -203
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +0 -317
- package/dist/create/server-source/crates/feltdb/src/equality_index.rs +0 -595
- package/dist/create/server-source/crates/feltdb/src/execution.rs +0 -426
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +0 -214
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +0 -776
- package/dist/create/server-source/crates/feltdb/src/lib.rs +0 -7365
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +0 -234
- package/dist/create/server-source/crates/feltdb/src/materialization.rs +0 -184
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -266
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +0 -310
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +0 -736
- package/dist/create/server-source/crates/feltdb/src/observability.rs +0 -379
- package/dist/create/server-source/crates/feltdb/src/operation.rs +0 -290
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +0 -439
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +0 -349
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +0 -332
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +0 -718
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +0 -487
- package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +0 -166
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +0 -289
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +0 -566
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +0 -3391
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +0 -203
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +0 -261
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +0 -502
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +0 -216
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +0 -303
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +0 -323
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +0 -531
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +0 -406
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +0 -436
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +0 -387
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +0 -378
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +0 -359
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +0 -372
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +0 -404
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +0 -376
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +0 -461
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +0 -2125
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +0 -418
- package/dist/create/server-source/crates/feltdb/src/provenance.rs +0 -207
- package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +0 -126
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +0 -218
- package/dist/create/server-source/crates/feltdb/src/read_basis.rs +0 -257
- package/dist/create/server-source/crates/feltdb/src/references.rs +0 -404
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +0 -401
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +0 -501
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +0 -680
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +0 -163
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +0 -133
- package/dist/create/server-source/crates/feltdb/src/routing.rs +0 -409
- package/dist/create/server-source/crates/feltdb/src/semantic_decision.rs +0 -2029
- package/dist/create/server-source/crates/feltdb/src/semantic_query.rs +0 -599
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +0 -549
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +0 -516
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +0 -6003
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +0 -222
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +0 -320
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +0 -291
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +0 -4158
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +0 -379
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +0 -673
- package/dist/create/server-source/crates/feltdb/src/storage.rs +0 -273
- package/dist/create/server-source/crates/feltdb/src/submission.rs +0 -471
- package/dist/create/server-source/crates/feltdb/src/sync.rs +0 -495
- package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +0 -822
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +0 -433
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +0 -462
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +0 -965
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +0 -1120
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +0 -643
- package/dist/create/server-source/crates/feltdb/src/trigger.rs +0 -390
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +0 -749
- package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +0 -708
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +0 -1269
- package/dist/create/server-source/crates/feltdb/src/workflow.rs +0 -713
- package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +0 -510
- package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +0 -354
- package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +0 -594
- package/dist/create/server-source/crates/feltdb/src/workload.rs +0 -4222
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +0 -456
- package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +0 -1188
- package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +0 -2884
- package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +0 -297
- package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +0 -534
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +0 -132
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/common/mod.rs +0 -96
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +0 -272
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +0 -467
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +0 -309
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +0 -445
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +0 -518
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +0 -392
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +0 -850
- package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +0 -23340
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +0 -1916
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +0 -1878
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +0 -1862
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +0 -123
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/local_process_ownership.rs +0 -69
- package/dist/create/server-source/crates/feltdb/tests/managed_incident_regression.rs +0 -191
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +0 -278
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +0 -234
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +0 -947
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +0 -404
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +0 -916
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +0 -1045
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +0 -347
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +0 -361
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +0 -330
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +0 -93
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +0 -622
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +0 -517
- package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +0 -1153
- package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +0 -771
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +0 -1383
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +0 -868
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +0 -221
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +0 -417
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +0 -338
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +0 -1039
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +0 -739
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +0 -452
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +0 -81
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +0 -438
- package/dist/create/server-source/crates/feltdb/tests/semantic_decision.rs +0 -1165
- package/dist/create/server-source/crates/feltdb/tests/semantic_query.rs +0 -767
- package/dist/create/server-source/crates/feltdb/tests/semantic_query_contract.rs +0 -363
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +0 -1799
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +0 -1316
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +0 -358
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +0 -365
- package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +0 -918
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +0 -328
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +0 -255
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +0 -249
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +0 -994
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +0 -484
- package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
- package/dist/studio-app/assets/index-1wd0uKua.js +0 -30
|
@@ -1,1334 +0,0 @@
|
|
|
1
|
-
/// Distributed Transactions & Replication
|
|
2
|
-
///
|
|
3
|
-
/// Coordinates transaction execution across multiple nodes:
|
|
4
|
-
/// - TransactionEnvelope: Atomic unit for multi-replica delivery
|
|
5
|
-
/// - ReplicationMessage: Operation streaming between replicas
|
|
6
|
-
/// - ReplicaState: Per-node state tracking with vector clocks
|
|
7
|
-
/// - DistributedExecutor: Orchestrates consistency semantics
|
|
8
|
-
///
|
|
9
|
-
/// Invariant: All replicas converge to identical state_hash for same operations
|
|
10
|
-
use crate::causal_dependency_barrier::CausalDependencyBarrier;
|
|
11
|
-
use crate::convergence::VectorClock;
|
|
12
|
-
use crate::durable_operation_log::OperationLog;
|
|
13
|
-
use crate::state_hash::StateHash;
|
|
14
|
-
use crate::transactions::*;
|
|
15
|
-
use std::collections::HashMap;
|
|
16
|
-
use std::path::PathBuf;
|
|
17
|
-
|
|
18
|
-
/// Uniquely identifies a transaction envelope
|
|
19
|
-
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
20
|
-
pub struct EnvelopeId {
|
|
21
|
-
pub originating_node: String,
|
|
22
|
-
pub sequence: u64,
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
impl EnvelopeId {
|
|
26
|
-
pub fn new(originating_node: String, sequence: u64) -> Self {
|
|
27
|
-
Self {
|
|
28
|
-
originating_node,
|
|
29
|
-
sequence,
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/// Atomic unit for multi-replica delivery
|
|
35
|
-
/// Prevents partial delivery of multi-operation transactions
|
|
36
|
-
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
37
|
-
pub struct TransactionEnvelope {
|
|
38
|
-
pub envelope_id: EnvelopeId,
|
|
39
|
-
pub transaction_id: String,
|
|
40
|
-
pub operations: Vec<Operation>,
|
|
41
|
-
pub consistency_contract: ConsistencyContract,
|
|
42
|
-
pub originating_node: String,
|
|
43
|
-
pub vector_clock: VectorClock,
|
|
44
|
-
pub timestamp_ms: u64,
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
impl TransactionEnvelope {
|
|
48
|
-
pub fn new(
|
|
49
|
-
originating_node: String,
|
|
50
|
-
sequence: u64,
|
|
51
|
-
transaction_id: String,
|
|
52
|
-
operations: Vec<Operation>,
|
|
53
|
-
consistency_contract: ConsistencyContract,
|
|
54
|
-
) -> Self {
|
|
55
|
-
let mut vc = VectorClock::new();
|
|
56
|
-
vc.increment(&originating_node);
|
|
57
|
-
|
|
58
|
-
let timestamp_ms = std::time::SystemTime::now()
|
|
59
|
-
.duration_since(std::time::UNIX_EPOCH)
|
|
60
|
-
.unwrap()
|
|
61
|
-
.as_millis() as u64;
|
|
62
|
-
|
|
63
|
-
Self {
|
|
64
|
-
envelope_id: EnvelopeId::new(originating_node.clone(), sequence),
|
|
65
|
-
transaction_id,
|
|
66
|
-
operations,
|
|
67
|
-
consistency_contract,
|
|
68
|
-
originating_node,
|
|
69
|
-
vector_clock: vc,
|
|
70
|
-
timestamp_ms,
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/// Advance vector clock to account for replication delay
|
|
75
|
-
pub fn advance_clock(&mut self, replica_clock: &VectorClock) {
|
|
76
|
-
self.vector_clock.merge(replica_clock);
|
|
77
|
-
self.vector_clock.increment(&self.originating_node);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/// Verify envelope integrity (all operations present)
|
|
81
|
-
pub fn verify_integrity(&self) -> bool {
|
|
82
|
-
!self.operations.is_empty()
|
|
83
|
-
&& self.transaction_id.len() > 0
|
|
84
|
-
&& self.originating_node.len() > 0
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/// Message format for distributed operation streaming
|
|
89
|
-
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
90
|
-
pub struct ReplicationMessage {
|
|
91
|
-
pub envelope: TransactionEnvelope,
|
|
92
|
-
pub from_node: String,
|
|
93
|
-
pub to_node: String,
|
|
94
|
-
pub sequence_number: u64,
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
impl ReplicationMessage {
|
|
98
|
-
pub fn new(
|
|
99
|
-
envelope: TransactionEnvelope,
|
|
100
|
-
from_node: String,
|
|
101
|
-
to_node: String,
|
|
102
|
-
sequence_number: u64,
|
|
103
|
-
) -> Self {
|
|
104
|
-
Self {
|
|
105
|
-
envelope,
|
|
106
|
-
from_node,
|
|
107
|
-
to_node,
|
|
108
|
-
sequence_number,
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/// State of a replica
|
|
114
|
-
#[derive(Clone, Debug)]
|
|
115
|
-
pub struct ReplicaState {
|
|
116
|
-
pub node_id: String,
|
|
117
|
-
pub vector_clock: VectorClock,
|
|
118
|
-
pub state_hash: StateHash,
|
|
119
|
-
pub operations_applied: u64,
|
|
120
|
-
pub last_update_ms: u64,
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
impl ReplicaState {
|
|
124
|
-
pub fn new(node_id: String, initial_hash: StateHash) -> Self {
|
|
125
|
-
Self {
|
|
126
|
-
node_id,
|
|
127
|
-
vector_clock: VectorClock::new(),
|
|
128
|
-
state_hash: initial_hash,
|
|
129
|
-
operations_applied: 0,
|
|
130
|
-
last_update_ms: std::time::SystemTime::now()
|
|
131
|
-
.duration_since(std::time::UNIX_EPOCH)
|
|
132
|
-
.unwrap()
|
|
133
|
-
.as_millis() as u64,
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/// Update replica state after transaction
|
|
138
|
-
pub fn apply_transaction(&mut self, envelope: &TransactionEnvelope, new_state_hash: StateHash) {
|
|
139
|
-
self.vector_clock.merge(&envelope.vector_clock);
|
|
140
|
-
self.vector_clock.increment(&self.node_id);
|
|
141
|
-
self.state_hash = new_state_hash;
|
|
142
|
-
self.operations_applied += envelope.operations.len() as u64;
|
|
143
|
-
self.last_update_ms = std::time::SystemTime::now()
|
|
144
|
-
.duration_since(std::time::UNIX_EPOCH)
|
|
145
|
-
.unwrap()
|
|
146
|
-
.as_millis() as u64;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/// Causal read: wait for vector clock to advance
|
|
150
|
-
pub fn wait_for_clock_advance(&self, required_clock: &VectorClock) -> bool {
|
|
151
|
-
!self.vector_clock.happens_before(required_clock)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/// Distributed transaction executor with durable operation log support
|
|
156
|
-
///
|
|
157
|
-
/// Durability architecture:
|
|
158
|
-
/// - Operation log is the ONLY durable source of truth
|
|
159
|
-
/// - Dedup set is derived (reconstructed from operation log on startup)
|
|
160
|
-
/// - No separate dedup file (eliminates two-file atomicity problem)
|
|
161
|
-
pub struct DistributedTransactionExecutor {
|
|
162
|
-
pub local_node_id: String,
|
|
163
|
-
pub replicas: HashMap<String, ReplicaState>,
|
|
164
|
-
pub envelopes_seen: std::collections::HashSet<EnvelopeId>,
|
|
165
|
-
pub transaction_executor: TransactionExecutor,
|
|
166
|
-
pub operation_log: Option<OperationLog>,
|
|
167
|
-
/// Causal delivery gate for replicated envelopes.
|
|
168
|
-
///
|
|
169
|
-
/// Holds identifiers and dependency clocks only. A blocked envelope's
|
|
170
|
-
/// payload stays in the durable operation log and is replayed from there
|
|
171
|
-
/// when its dependencies land.
|
|
172
|
-
pub causal_barrier: CausalDependencyBarrier,
|
|
173
|
-
/// The bound on the causal backlog.
|
|
174
|
-
pub causal_capacity: CausalCapacity,
|
|
175
|
-
/// The next identity this node will issue for its own operations.
|
|
176
|
-
///
|
|
177
|
-
/// Recovered from the durable log rather than reset, because
|
|
178
|
-
/// `(origin, sequence)` is an identity and receivers deduplicate on it. A
|
|
179
|
-
/// counter that restarts re-issues identities that already exist, and the
|
|
180
|
-
/// receiver discards the new operation as a duplicate.
|
|
181
|
-
next_origin_sequence: u64,
|
|
182
|
-
/// Envelopes that are durable in the log but not buffered, because the
|
|
183
|
-
/// backlog was at its bound when they arrived.
|
|
184
|
-
///
|
|
185
|
-
/// Derived state, like `envelopes_seen`: it is rebuilt from the log on
|
|
186
|
-
/// recovery and exists so that a redelivery of a deferred envelope is
|
|
187
|
-
/// recognised without a log scan.
|
|
188
|
-
pub deferred: std::collections::HashSet<EnvelopeId>,
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/// What the receiver did with a replicated envelope.
|
|
192
|
-
///
|
|
193
|
-
/// This is a typed outcome rather than a string, because a caller has to be
|
|
194
|
-
/// able to tell "applied" from "held back" from "already known" without
|
|
195
|
-
/// matching on prose. The previous signature returned
|
|
196
|
-
/// `Result<StateHash, String>` and signalled a duplicate as
|
|
197
|
-
/// `Err("Duplicate envelope")`.
|
|
198
|
-
#[derive(Clone, Debug, PartialEq)]
|
|
199
|
-
pub enum ReceiveOutcome {
|
|
200
|
-
/// Dependencies were satisfied; the envelope was applied.
|
|
201
|
-
Applied { state_hash: StateHash },
|
|
202
|
-
/// Durable, but not yet applied: some dependency has not arrived.
|
|
203
|
-
/// The payload is in the operation log; the barrier holds its identity.
|
|
204
|
-
PendingDependencies { missing: Vec<String> },
|
|
205
|
-
/// This envelope has been seen before. Applying it again is a no-op.
|
|
206
|
-
AlreadyKnown,
|
|
207
|
-
/// Durable, but not buffered: the backlog is at its bound.
|
|
208
|
-
///
|
|
209
|
-
/// This is *not* a refusal and nothing is lost. The envelope was fsynced to
|
|
210
|
-
/// the operation log before this was decided, exactly as a buffered one is;
|
|
211
|
-
/// what is withheld is the in-memory metadata entry. The caller's obligation
|
|
212
|
-
/// is transport backpressure -- stop reading from that peer so TCP blocks
|
|
213
|
-
/// the sender -- because returning a refusal to a peer means either silent
|
|
214
|
-
/// loss or a retransmission protocol this repository does not have.
|
|
215
|
-
///
|
|
216
|
-
/// The envelope is admitted from the log once the backlog drains.
|
|
217
|
-
CapacityDeferred {
|
|
218
|
-
missing: Vec<String>,
|
|
219
|
-
pending_entries: usize,
|
|
220
|
-
pending_bytes: usize,
|
|
221
|
-
},
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/// What the executor did with a locally submitted transaction.
|
|
225
|
-
///
|
|
226
|
-
/// Capacity refusal is a distinct outcome rather than an `Err(String)`, because
|
|
227
|
-
/// a caller has to tell "this replica is saturated, retry" from "this
|
|
228
|
-
/// transaction is invalid". The two need opposite responses and the previous
|
|
229
|
-
/// signature made them the same value.
|
|
230
|
-
#[derive(Clone, Debug)]
|
|
231
|
-
pub enum SubmitOutcome {
|
|
232
|
-
Admitted(TransactionEnvelope),
|
|
233
|
-
/// The causal backlog is at its bound, so no new local work is admitted.
|
|
234
|
-
/// Nothing was executed, persisted, or applied: there is nothing to undo
|
|
235
|
-
/// and nothing to recover.
|
|
236
|
-
CapacityExceeded {
|
|
237
|
-
pending_entries: usize,
|
|
238
|
-
pending_bytes: usize,
|
|
239
|
-
limit_entries: usize,
|
|
240
|
-
limit_bytes: usize,
|
|
241
|
-
},
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/// The bound on the causal backlog, as decided in
|
|
245
|
-
/// `docs/architecture/causal-backlog-bound.md`.
|
|
246
|
-
///
|
|
247
|
-
/// Two limits, because they are driven by different variables. Bytes bound the
|
|
248
|
-
/// outstanding payload volume; entries bound the metadata, whose per-entry cost
|
|
249
|
-
/// scales with cluster size rather than with operation size, so a byte bound
|
|
250
|
-
/// alone would score ten thousand pending one-byte operations as nearly free.
|
|
251
|
-
///
|
|
252
|
-
/// Both are inclusive: the bound is `<=`, so filling exactly to it is accepted.
|
|
253
|
-
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
254
|
-
pub struct CausalCapacity {
|
|
255
|
-
pub max_pending_entries: usize,
|
|
256
|
-
pub max_pending_bytes: usize,
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
impl CausalCapacity {
|
|
260
|
-
/// The shipping default.
|
|
261
|
-
///
|
|
262
|
-
/// These are policy, not measurements, and are deliberately not derived
|
|
263
|
-
/// from a benchmark constant -- the figure that used to justify backlog
|
|
264
|
-
/// sizing here was `backlog_depth * 1500`, which was the benchmark's own
|
|
265
|
-
/// input rather than an observation. What the acceptance test establishes
|
|
266
|
-
/// is that the bound is *enforced through the production path*, and it
|
|
267
|
-
/// reports the occupancy it actually observed rather than asserting a
|
|
268
|
-
/// predicted footprint.
|
|
269
|
-
pub const DEFAULT_MAX_PENDING_ENTRIES: usize = 10_000;
|
|
270
|
-
pub const DEFAULT_MAX_PENDING_BYTES: usize = 64 * 1024 * 1024;
|
|
271
|
-
|
|
272
|
-
/// A bound small enough to be reached by a test through the real path.
|
|
273
|
-
pub fn new(max_pending_entries: usize, max_pending_bytes: usize) -> Self {
|
|
274
|
-
Self {
|
|
275
|
-
max_pending_entries,
|
|
276
|
-
max_pending_bytes,
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
impl Default for CausalCapacity {
|
|
282
|
-
fn default() -> Self {
|
|
283
|
-
Self {
|
|
284
|
-
max_pending_entries: Self::DEFAULT_MAX_PENDING_ENTRIES,
|
|
285
|
-
max_pending_bytes: Self::DEFAULT_MAX_PENDING_BYTES,
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
impl DistributedTransactionExecutor {
|
|
291
|
-
/// Create a new distributed executor without durable persistence
|
|
292
|
-
pub fn new(node_id: String, initial_state_hash: StateHash) -> Self {
|
|
293
|
-
let mut replicas = HashMap::new();
|
|
294
|
-
replicas.insert(
|
|
295
|
-
node_id.clone(),
|
|
296
|
-
ReplicaState::new(node_id.clone(), initial_state_hash),
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
Self {
|
|
300
|
-
local_node_id: node_id.clone(),
|
|
301
|
-
replicas,
|
|
302
|
-
envelopes_seen: std::collections::HashSet::new(),
|
|
303
|
-
transaction_executor: TransactionExecutor::new(node_id),
|
|
304
|
-
operation_log: None,
|
|
305
|
-
causal_barrier: CausalDependencyBarrier::new(),
|
|
306
|
-
causal_capacity: CausalCapacity::default(),
|
|
307
|
-
next_origin_sequence: 1,
|
|
308
|
-
deferred: std::collections::HashSet::new(),
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/// Create a new distributed executor with durable operation log
|
|
313
|
-
///
|
|
314
|
-
/// Note: dedup set is NOT persisted separately. It is reconstructed deterministically
|
|
315
|
-
/// from the operation log on startup, eliminating atomicity issues.
|
|
316
|
-
pub fn with_log(
|
|
317
|
-
node_id: String,
|
|
318
|
-
initial_state_hash: StateHash,
|
|
319
|
-
log_path: Option<PathBuf>,
|
|
320
|
-
) -> Result<Self, std::io::Error> {
|
|
321
|
-
let mut replicas = HashMap::new();
|
|
322
|
-
replicas.insert(
|
|
323
|
-
node_id.clone(),
|
|
324
|
-
ReplicaState::new(node_id.clone(), initial_state_hash),
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
let operation_log = if let Some(path) = &log_path {
|
|
328
|
-
Some(OperationLog::new(path)?)
|
|
329
|
-
} else {
|
|
330
|
-
None
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
// Identity is recovered here as well as in `load_from_disk`, because a
|
|
334
|
-
// caller that opens an existing log and issues an operation without
|
|
335
|
-
// replaying it must still not re-issue an identity that log already
|
|
336
|
-
// contains.
|
|
337
|
-
let next_origin_sequence = match &operation_log {
|
|
338
|
-
Some(log) => Self::next_sequence_after(log, &node_id)?,
|
|
339
|
-
None => 1,
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
Ok(Self {
|
|
343
|
-
local_node_id: node_id.clone(),
|
|
344
|
-
replicas,
|
|
345
|
-
envelopes_seen: std::collections::HashSet::new(),
|
|
346
|
-
transaction_executor: TransactionExecutor::new(node_id),
|
|
347
|
-
operation_log,
|
|
348
|
-
causal_barrier: CausalDependencyBarrier::new(),
|
|
349
|
-
causal_capacity: CausalCapacity::default(),
|
|
350
|
-
next_origin_sequence,
|
|
351
|
-
deferred: std::collections::HashSet::new(),
|
|
352
|
-
})
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/// Register a replica (peer node)
|
|
356
|
-
pub fn register_replica(&mut self, node_id: String, initial_state_hash: StateHash) {
|
|
357
|
-
self.replicas.insert(
|
|
358
|
-
node_id.clone(),
|
|
359
|
-
ReplicaState::new(node_id, initial_state_hash),
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/// Execute transaction locally and prepare for replication
|
|
364
|
-
/// Ordering: execute → persist → update state → record dedup
|
|
365
|
-
// ---------------------------------------------------------------------
|
|
366
|
-
// Durable operation identity
|
|
367
|
-
//
|
|
368
|
-
// `(origin, sequence)` is an identity, not a counter. Receivers
|
|
369
|
-
// deduplicate on it, so two operations must never be assigned the same
|
|
370
|
-
// one -- and "must never" has to hold across a process restart, which is
|
|
371
|
-
// where it previously did not: `feltdb_node` kept the counter in memory
|
|
372
|
-
// and started it at 1, so a restarted origin re-issued identities its own
|
|
373
|
-
// earlier operations already held and its peers discarded the new writes
|
|
374
|
-
// as duplicates. See `dedup_bound_investigation.rs`.
|
|
375
|
-
//
|
|
376
|
-
// The authority is the durable log, and specifically the log rather than
|
|
377
|
-
// the causal clock. Those are different identity domains: the clock
|
|
378
|
-
// component advances only on a successful apply, while the sequence is
|
|
379
|
-
// burned by an attempt, so the two disagree by exactly the number of
|
|
380
|
-
// refused transactions. `replication-convergence.md` fixes that the
|
|
381
|
-
// sequence is sparse and that making it dense is a separate deferred
|
|
382
|
-
// decision, so this derives identity from the sequence domain and leaves
|
|
383
|
-
// that decision alone.
|
|
384
|
-
// ---------------------------------------------------------------------
|
|
385
|
-
|
|
386
|
-
/// The highest identity committed to the durable log, per origin.
|
|
387
|
-
///
|
|
388
|
-
/// For this node it is its own identity high-water mark; for others it is
|
|
389
|
-
/// how far this replica's log carries their identities. A checkpoint that
|
|
390
|
-
/// discards log prefix must preserve these, because the log is also the
|
|
391
|
-
/// source `next_origin_sequence` is recovered from.
|
|
392
|
-
pub fn origin_sequence_high_water(&self) -> std::collections::BTreeMap<String, u64> {
|
|
393
|
-
let mut marks = std::collections::BTreeMap::new();
|
|
394
|
-
let Some(ref log) = self.operation_log else {
|
|
395
|
-
return marks;
|
|
396
|
-
};
|
|
397
|
-
let Ok(envelopes) = log.load_all() else {
|
|
398
|
-
return marks;
|
|
399
|
-
};
|
|
400
|
-
for envelope in envelopes {
|
|
401
|
-
let entry = marks
|
|
402
|
-
.entry(envelope.envelope_id.originating_node.clone())
|
|
403
|
-
.or_insert(0u64);
|
|
404
|
-
*entry = (*entry).max(envelope.envelope_id.sequence);
|
|
405
|
-
}
|
|
406
|
-
marks
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/// The next identity this node would issue.
|
|
410
|
-
pub fn next_origin_sequence(&self) -> u64 {
|
|
411
|
-
self.next_origin_sequence
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/// Claim the next identity for an operation this node originates.
|
|
415
|
-
///
|
|
416
|
-
/// Advancing on the attempt rather than on success is deliberate: it
|
|
417
|
-
/// preserves the sparse-sequence semantics the convergence contract froze,
|
|
418
|
-
/// where a refused transaction burns a number. Making the sequence dense is
|
|
419
|
-
/// a separate decision and is not made here as a side effect.
|
|
420
|
-
///
|
|
421
|
-
/// A number burned and then lost to a crash is safe to re-issue, because
|
|
422
|
-
/// nothing carrying it was ever persisted: recovery derives from what the
|
|
423
|
-
/// log holds, so an identity is only reserved forever once an envelope
|
|
424
|
-
/// bearing it is durable. That is the invariant -- no two *persisted*
|
|
425
|
-
/// envelopes from one origin share a sequence -- and it is the one
|
|
426
|
-
/// receivers depend on.
|
|
427
|
-
pub fn allocate_origin_sequence(&mut self) -> u64 {
|
|
428
|
-
let claimed = self.next_origin_sequence;
|
|
429
|
-
self.next_origin_sequence = claimed.saturating_add(1);
|
|
430
|
-
claimed
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/// Derive the next identity from a durable log: one past the highest this
|
|
434
|
-
/// node has already committed to it.
|
|
435
|
-
///
|
|
436
|
-
/// Filtered to locally-originated envelopes, because the log also holds
|
|
437
|
-
/// replicated ones and another node's sequence says nothing about this
|
|
438
|
-
/// node's identity space.
|
|
439
|
-
fn next_sequence_after(log: &OperationLog, node_id: &str) -> Result<u64, std::io::Error> {
|
|
440
|
-
let highest = log
|
|
441
|
-
.load_all()?
|
|
442
|
-
.iter()
|
|
443
|
-
.filter(|envelope| envelope.envelope_id.originating_node == node_id)
|
|
444
|
-
.map(|envelope| envelope.envelope_id.sequence)
|
|
445
|
-
.max()
|
|
446
|
-
.unwrap_or(0);
|
|
447
|
-
Ok(highest.saturating_add(1))
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/// Submit local work, allocating its identity from durable state.
|
|
451
|
-
///
|
|
452
|
-
/// This is the entry point a node should use: it owns the allocation, so
|
|
453
|
-
/// the identity cannot be assigned by one caller and persisted by another,
|
|
454
|
-
/// and there is no counter outside the executor to fall out of step with
|
|
455
|
-
/// the log.
|
|
456
|
-
pub fn submit_local_transaction(
|
|
457
|
-
&mut self,
|
|
458
|
-
transaction_id: String,
|
|
459
|
-
parent_version: StateVersion,
|
|
460
|
-
operations: Vec<Operation>,
|
|
461
|
-
consistency_contract: ConsistencyContract,
|
|
462
|
-
) -> Result<SubmitOutcome, String> {
|
|
463
|
-
// Allocation happens after the capacity check inside
|
|
464
|
-
// `try_execute_local_transaction`, so a refusal does not burn an
|
|
465
|
-
// identity: nothing was admitted, and there is nothing to account for.
|
|
466
|
-
if self.would_breach_bound(Self::operations_bytes(&operations)) {
|
|
467
|
-
return Ok(SubmitOutcome::CapacityExceeded {
|
|
468
|
-
pending_entries: self.causal_barrier.pending_count(),
|
|
469
|
-
pending_bytes: self.causal_barrier.pending_payload_bytes(),
|
|
470
|
-
limit_entries: self.causal_capacity.max_pending_entries,
|
|
471
|
-
limit_bytes: self.causal_capacity.max_pending_bytes,
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
let sequence = self.allocate_origin_sequence();
|
|
475
|
-
self.execute_local_transaction(
|
|
476
|
-
sequence,
|
|
477
|
-
transaction_id,
|
|
478
|
-
parent_version,
|
|
479
|
-
operations,
|
|
480
|
-
consistency_contract,
|
|
481
|
-
)
|
|
482
|
-
.map(SubmitOutcome::Admitted)
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/// Submit local work, subject to the causal backlog bound.
|
|
486
|
-
///
|
|
487
|
-
/// The bound is checked *before* anything is executed, persisted or
|
|
488
|
-
/// applied, so a refusal leaves nothing to undo and nothing to recover:
|
|
489
|
-
/// the operation was never admitted. That is what makes refusal the right
|
|
490
|
-
/// answer on this path and the wrong one for a replicated arrival, which
|
|
491
|
-
/// was already admitted and made durable somewhere else.
|
|
492
|
-
pub fn try_execute_local_transaction(
|
|
493
|
-
&mut self,
|
|
494
|
-
sequence: u64,
|
|
495
|
-
transaction_id: String,
|
|
496
|
-
parent_version: StateVersion,
|
|
497
|
-
operations: Vec<Operation>,
|
|
498
|
-
consistency_contract: ConsistencyContract,
|
|
499
|
-
) -> Result<SubmitOutcome, String> {
|
|
500
|
-
let projected_bytes = Self::operations_bytes(&operations);
|
|
501
|
-
if self.would_breach_bound(projected_bytes) {
|
|
502
|
-
return Ok(SubmitOutcome::CapacityExceeded {
|
|
503
|
-
pending_entries: self.causal_barrier.pending_count(),
|
|
504
|
-
pending_bytes: self.causal_barrier.pending_payload_bytes(),
|
|
505
|
-
limit_entries: self.causal_capacity.max_pending_entries,
|
|
506
|
-
limit_bytes: self.causal_capacity.max_pending_bytes,
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
self.execute_local_transaction(
|
|
510
|
-
sequence,
|
|
511
|
-
transaction_id,
|
|
512
|
-
parent_version,
|
|
513
|
-
operations,
|
|
514
|
-
consistency_contract,
|
|
515
|
-
)
|
|
516
|
-
.map(SubmitOutcome::Admitted)
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/// The size local work would contribute, measured before it is executed.
|
|
520
|
-
fn operations_bytes(operations: &[Operation]) -> usize {
|
|
521
|
-
serde_json::to_string(operations)
|
|
522
|
-
.map(|json| json.len())
|
|
523
|
-
.unwrap_or(0)
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/// Execute local work unconditionally.
|
|
527
|
-
///
|
|
528
|
-
/// This does not consult the backlog bound; `try_execute_local_transaction`
|
|
529
|
-
/// is the admitting entry point and the one the node binary uses. This
|
|
530
|
-
/// remains for callers that have already made the admission decision.
|
|
531
|
-
pub fn execute_local_transaction(
|
|
532
|
-
&mut self,
|
|
533
|
-
sequence: u64,
|
|
534
|
-
transaction_id: String,
|
|
535
|
-
parent_version: StateVersion,
|
|
536
|
-
operations: Vec<Operation>,
|
|
537
|
-
consistency_contract: ConsistencyContract,
|
|
538
|
-
) -> Result<TransactionEnvelope, String> {
|
|
539
|
-
// Step 1: Execute transaction through 14C
|
|
540
|
-
let transition = self.transaction_executor.execute_transaction(
|
|
541
|
-
transaction_id.clone(),
|
|
542
|
-
parent_version,
|
|
543
|
-
operations.clone(),
|
|
544
|
-
consistency_contract.clone(),
|
|
545
|
-
);
|
|
546
|
-
|
|
547
|
-
if !transition.is_successful() {
|
|
548
|
-
return Err("Transaction execution failed".to_string());
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// Step 2: Create envelope for replication
|
|
552
|
-
let mut envelope = TransactionEnvelope::new(
|
|
553
|
-
self.local_node_id.clone(),
|
|
554
|
-
sequence,
|
|
555
|
-
transaction_id,
|
|
556
|
-
operations,
|
|
557
|
-
consistency_contract,
|
|
558
|
-
);
|
|
559
|
-
|
|
560
|
-
// Step 2a: Stamp the envelope with this node's causal knowledge.
|
|
561
|
-
//
|
|
562
|
-
// `TransactionEnvelope::new` starts from an empty clock and increments
|
|
563
|
-
// only the origin, so every envelope it built carried {origin: 1}
|
|
564
|
-
// whatever the node had already seen. That is a position, not a
|
|
565
|
-
// history: a receiver given those clocks has no dependencies to
|
|
566
|
-
// enforce, which is why causal ordering could not have worked on this
|
|
567
|
-
// path even once the barrier was wired to it. The clock must carry the
|
|
568
|
-
// applied frontier this operation was produced on top of.
|
|
569
|
-
let mut stamped = self.causal_barrier.get_frontier().clone();
|
|
570
|
-
stamped.increment(&self.local_node_id);
|
|
571
|
-
envelope.vector_clock = stamped;
|
|
572
|
-
|
|
573
|
-
// Step 3: Durably persist operation to log (with fsync)
|
|
574
|
-
// This must succeed before we update in-memory state
|
|
575
|
-
self.persist_envelope(&envelope)?;
|
|
576
|
-
|
|
577
|
-
// Step 4: Update local replica state (in-memory)
|
|
578
|
-
if let Some(local_replica) = self.replicas.get_mut(&self.local_node_id) {
|
|
579
|
-
let state_hash = StateHash::from_hex(transition.to_version.state_hash.clone());
|
|
580
|
-
local_replica.apply_transaction(&envelope, state_hash);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// Step 5: Record envelope to prevent duplicates (in-memory, derived from log)
|
|
584
|
-
self.envelopes_seen.insert(envelope.envelope_id.clone());
|
|
585
|
-
|
|
586
|
-
// Step 6: A locally executed operation is applied, so it advances the
|
|
587
|
-
// frontier exactly as a replicated one does. Own writes are always
|
|
588
|
-
// causally eligible: they are built on the frontier they advance.
|
|
589
|
-
self.causal_barrier.record_applied(
|
|
590
|
-
&Self::barrier_key(&envelope.envelope_id),
|
|
591
|
-
&envelope.vector_clock,
|
|
592
|
-
);
|
|
593
|
-
|
|
594
|
-
Ok(envelope)
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/// Receive replicated transaction from peer
|
|
598
|
-
/// Ordering: dedup check → execute → persist → update state
|
|
599
|
-
pub fn receive_replicated_transaction(
|
|
600
|
-
&mut self,
|
|
601
|
-
message: ReplicationMessage,
|
|
602
|
-
parent_version: StateVersion,
|
|
603
|
-
) -> Result<StateHash, String> {
|
|
604
|
-
match self.receive_replicated(message, parent_version)? {
|
|
605
|
-
ReceiveOutcome::Applied { state_hash } => Ok(state_hash),
|
|
606
|
-
ReceiveOutcome::PendingDependencies { missing } => Err(format!(
|
|
607
|
-
"Envelope is durable but waiting on dependencies: {}",
|
|
608
|
-
missing.join(", ")
|
|
609
|
-
)),
|
|
610
|
-
ReceiveOutcome::AlreadyKnown => Err("Duplicate envelope".to_string()),
|
|
611
|
-
ReceiveOutcome::CapacityDeferred { missing, .. } => Err(format!(
|
|
612
|
-
"CAPACITY_DEFERRED: envelope is durable but the causal backlog is at its bound, waiting on: {}",
|
|
613
|
-
missing.join(", ")
|
|
614
|
-
)),
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
/// The causal receive path.
|
|
619
|
-
///
|
|
620
|
-
/// Ordering is deliberate and is the substance of this change:
|
|
621
|
-
///
|
|
622
|
-
/// dedup -> persist -> causal eligibility -> apply -> release
|
|
623
|
-
///
|
|
624
|
-
/// Persisting *before* deciding eligibility is what makes "held back" a
|
|
625
|
-
/// durable state rather than an in-memory one. An envelope that arrives
|
|
626
|
-
/// ahead of its dependencies is fsynced to the operation log and then
|
|
627
|
-
/// retained in the barrier by identity alone; if the process dies at that
|
|
628
|
-
/// moment, recovery replays the log and rediscovers it as pending. This is
|
|
629
|
-
/// also what catch-up will require, because a peer sending A3, A1, A2 in
|
|
630
|
-
/// transport order needs the receiver to be able to say "I have A3
|
|
631
|
-
/// durably, and I cannot apply it yet".
|
|
632
|
-
pub fn receive_replicated(
|
|
633
|
-
&mut self,
|
|
634
|
-
message: ReplicationMessage,
|
|
635
|
-
parent_version: StateVersion,
|
|
636
|
-
) -> Result<ReceiveOutcome, String> {
|
|
637
|
-
let envelope_id = message.envelope.envelope_id.clone();
|
|
638
|
-
let key = Self::barrier_key(&envelope_id);
|
|
639
|
-
|
|
640
|
-
// Step 1: Duplicate delivery is a no-op, whether the original was
|
|
641
|
-
// applied or is still waiting. Both states are recorded before this
|
|
642
|
-
// point is reached again, so one logical operation stays one.
|
|
643
|
-
if self.envelopes_seen.contains(&envelope_id)
|
|
644
|
-
|| self.causal_barrier.is_pending(&key)
|
|
645
|
-
|| self.deferred.contains(&envelope_id)
|
|
646
|
-
{
|
|
647
|
-
return Ok(ReceiveOutcome::AlreadyKnown);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// Step 2: Durability first. The log is the source of truth, so an
|
|
651
|
-
// envelope becomes durable on receipt and not on application.
|
|
652
|
-
self.persist_envelope(&message.envelope)?;
|
|
653
|
-
|
|
654
|
-
// Step 3: Eligibility. The dependencies are everything the origin had
|
|
655
|
-
// seen when it produced this operation, which is its clock with its own
|
|
656
|
-
// entry stepped back by one; the operation itself is not its own
|
|
657
|
-
// dependency.
|
|
658
|
-
let dependencies = Self::dependencies_of(&message.envelope);
|
|
659
|
-
if !self.causal_barrier.is_eligible(&dependencies) {
|
|
660
|
-
let missing = self.missing_dependencies(&dependencies);
|
|
661
|
-
|
|
662
|
-
// Step 3a: The bound. Buffering costs an entry and holds a payload
|
|
663
|
-
// outstanding in the log; if either would breach its limit, the
|
|
664
|
-
// metadata entry is withheld and the envelope stays durable and
|
|
665
|
-
// deferred. It is never refused and never evicted -- refusing a
|
|
666
|
-
// peer means silent loss or a retransmission protocol that does not
|
|
667
|
-
// exist here, and eviction is silent loss under another name.
|
|
668
|
-
let payload_bytes = Self::envelope_bytes(&message.envelope);
|
|
669
|
-
if self.would_breach_bound(payload_bytes) {
|
|
670
|
-
self.deferred.insert(envelope_id);
|
|
671
|
-
return Ok(ReceiveOutcome::CapacityDeferred {
|
|
672
|
-
missing,
|
|
673
|
-
pending_entries: self.causal_barrier.pending_count(),
|
|
674
|
-
pending_bytes: self.causal_barrier.pending_payload_bytes(),
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
self.causal_barrier
|
|
679
|
-
.retain_pending_sized(key, dependencies, payload_bytes);
|
|
680
|
-
return Ok(ReceiveOutcome::PendingDependencies { missing });
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// Step 4: Apply, which advances the frontier.
|
|
684
|
-
let state_hash = self.apply_envelope(&message.envelope, parent_version.clone())?;
|
|
685
|
-
|
|
686
|
-
// Step 5: This application may have unblocked others, and may have
|
|
687
|
-
// freed the capacity a deferred envelope was waiting for. Drain both.
|
|
688
|
-
self.drain(parent_version)?;
|
|
689
|
-
|
|
690
|
-
Ok(ReceiveOutcome::Applied { state_hash })
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/// Drive the backlog forward from outside the receive path.
|
|
694
|
-
///
|
|
695
|
-
/// A deferred envelope is durable but unbuffered, and the event that frees
|
|
696
|
-
/// capacity for it may arrive on a different connection than the one that
|
|
697
|
-
/// was deferred. A caller applying transport backpressure needs a way to
|
|
698
|
-
/// make progress without reading from the backpressured peer, and this is
|
|
699
|
-
/// it.
|
|
700
|
-
pub fn drain_backlog(&mut self, parent_version: StateVersion) -> Result<usize, String> {
|
|
701
|
-
self.drain(parent_version)
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/// Whether admitting one more entry of this size would breach either bound.
|
|
705
|
-
///
|
|
706
|
-
/// Inclusive, as the contract specifies: filling exactly to the bound is
|
|
707
|
-
/// accepted, and the breach is the step past it.
|
|
708
|
-
fn would_breach_bound(&self, payload_bytes: usize) -> bool {
|
|
709
|
-
let entries = self.causal_barrier.pending_count();
|
|
710
|
-
let bytes = self.causal_barrier.pending_payload_bytes();
|
|
711
|
-
entries + 1 > self.causal_capacity.max_pending_entries
|
|
712
|
-
|| bytes + payload_bytes > self.causal_capacity.max_pending_bytes
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/// The envelope's size as the durable log stores it.
|
|
716
|
-
///
|
|
717
|
-
/// Measured with the log's own serialisation rather than a nominal
|
|
718
|
-
/// per-operation constant, so a large envelope consumes its actual byte
|
|
719
|
-
/// count.
|
|
720
|
-
fn envelope_bytes(envelope: &TransactionEnvelope) -> usize {
|
|
721
|
-
serde_json::to_string(envelope)
|
|
722
|
-
.map(|json| json.len())
|
|
723
|
-
.unwrap_or(0)
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/// Apply one envelope and advance the applied frontier.
|
|
727
|
-
fn apply_envelope(
|
|
728
|
-
&mut self,
|
|
729
|
-
envelope: &TransactionEnvelope,
|
|
730
|
-
parent_version: StateVersion,
|
|
731
|
-
) -> Result<StateHash, String> {
|
|
732
|
-
let transition = self.transaction_executor.execute_transaction(
|
|
733
|
-
envelope.transaction_id.clone(),
|
|
734
|
-
parent_version,
|
|
735
|
-
envelope.operations.clone(),
|
|
736
|
-
envelope.consistency_contract.clone(),
|
|
737
|
-
);
|
|
738
|
-
|
|
739
|
-
if !transition.is_successful() {
|
|
740
|
-
return Err("Transaction execution failed".to_string());
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
self.envelopes_seen.insert(envelope.envelope_id.clone());
|
|
744
|
-
|
|
745
|
-
let state_hash = StateHash::from_hex(transition.to_version.state_hash.clone());
|
|
746
|
-
if let Some(replica) = self.replicas.get_mut(&self.local_node_id) {
|
|
747
|
-
replica.apply_transaction(envelope, state_hash.clone());
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// The frontier advances here and only here, so it describes what has
|
|
751
|
-
// been successfully applied rather than what was claimed or received.
|
|
752
|
-
self.causal_barrier.record_applied(
|
|
753
|
-
&Self::barrier_key(&envelope.envelope_id),
|
|
754
|
-
&envelope.vector_clock,
|
|
755
|
-
);
|
|
756
|
-
|
|
757
|
-
Ok(state_hash)
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/// Drive the backlog forward until it stops moving.
|
|
761
|
-
///
|
|
762
|
-
/// Two things can make progress and each can enable the other: applying a
|
|
763
|
-
/// pending envelope advances the frontier, which may make other pending
|
|
764
|
-
/// envelopes eligible; and applying anything frees capacity, which may let
|
|
765
|
-
/// a deferred envelope be admitted from the log. So this alternates until
|
|
766
|
-
/// neither step changes anything, which terminates because every iteration
|
|
767
|
-
/// that continues has strictly reduced the number of unapplied envelopes.
|
|
768
|
-
fn drain(&mut self, parent_version: StateVersion) -> Result<usize, String> {
|
|
769
|
-
let mut progressed_total = 0;
|
|
770
|
-
loop {
|
|
771
|
-
let released = self.release_eligible(parent_version.clone())?;
|
|
772
|
-
let admitted = self.admit_deferred(parent_version.clone())?;
|
|
773
|
-
progressed_total += released + admitted;
|
|
774
|
-
if released == 0 && admitted == 0 {
|
|
775
|
-
return Ok(progressed_total);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/// Apply every pending envelope the frontier now satisfies, repeatedly,
|
|
781
|
-
/// because releasing one can make the next eligible.
|
|
782
|
-
///
|
|
783
|
-
/// The log is read once per pass rather than once per key. It used to be
|
|
784
|
-
/// once per key: `replay_from_log` called `load_all()` for every eligible
|
|
785
|
-
/// entry, so a release cascade over a backlog of n entries against a log of
|
|
786
|
-
/// m records did n full scans. That is the exact shape of load this change
|
|
787
|
-
/// makes reachable -- a bounded backlog is one that is allowed to get
|
|
788
|
-
/// large -- so the quadratic scan had to go with it.
|
|
789
|
-
fn release_eligible(&mut self, parent_version: StateVersion) -> Result<usize, String> {
|
|
790
|
-
let mut released = 0;
|
|
791
|
-
loop {
|
|
792
|
-
let ready = self.causal_barrier.eligible_pending();
|
|
793
|
-
if ready.is_empty() {
|
|
794
|
-
return Ok(released);
|
|
795
|
-
}
|
|
796
|
-
let by_key = self.log_by_key()?;
|
|
797
|
-
let mut progressed = false;
|
|
798
|
-
for key in ready {
|
|
799
|
-
let Some(envelope) = by_key.get(&key) else {
|
|
800
|
-
// The barrier holds an identity whose payload is not in the
|
|
801
|
-
// log. Dropping the entry would be silent loss, so leave it
|
|
802
|
-
// pending and surface it rather than pretend it applied.
|
|
803
|
-
continue;
|
|
804
|
-
};
|
|
805
|
-
self.apply_envelope(&envelope.clone(), parent_version.clone())?;
|
|
806
|
-
released += 1;
|
|
807
|
-
progressed = true;
|
|
808
|
-
}
|
|
809
|
-
if !progressed {
|
|
810
|
-
return Ok(released);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
/// Move envelopes that are durable but unbuffered into the backlog, as far
|
|
816
|
-
/// as the bound now allows.
|
|
817
|
-
///
|
|
818
|
-
/// This is what makes `CapacityDeferred` safe: the envelope was fsynced
|
|
819
|
-
/// before it was deferred, so admitting it later needs no cooperation from
|
|
820
|
-
/// the sender and survives a restart in between. Entries are admitted in
|
|
821
|
-
/// key order so that two replicas draining the same set drain it
|
|
822
|
-
/// identically -- behaviour at capacity must not depend on iteration order
|
|
823
|
-
/// or timing.
|
|
824
|
-
fn admit_deferred(&mut self, parent_version: StateVersion) -> Result<usize, String> {
|
|
825
|
-
if self.deferred.is_empty() {
|
|
826
|
-
return Ok(0);
|
|
827
|
-
}
|
|
828
|
-
let by_key = self.log_by_key()?;
|
|
829
|
-
let mut keys: Vec<String> = self.deferred.iter().map(Self::barrier_key).collect();
|
|
830
|
-
keys.sort();
|
|
831
|
-
|
|
832
|
-
let mut admitted = 0;
|
|
833
|
-
for key in keys {
|
|
834
|
-
let Some(envelope) = by_key.get(&key).cloned() else {
|
|
835
|
-
continue;
|
|
836
|
-
};
|
|
837
|
-
let dependencies = Self::dependencies_of(&envelope);
|
|
838
|
-
let payload_bytes = Self::envelope_bytes(&envelope);
|
|
839
|
-
|
|
840
|
-
if self.causal_barrier.is_eligible(&dependencies) {
|
|
841
|
-
self.deferred.remove(&envelope.envelope_id);
|
|
842
|
-
self.apply_envelope(&envelope, parent_version.clone())?;
|
|
843
|
-
admitted += 1;
|
|
844
|
-
continue;
|
|
845
|
-
}
|
|
846
|
-
if self.would_breach_bound(payload_bytes) {
|
|
847
|
-
// Still no room. Leave it deferred; it is durable and will be
|
|
848
|
-
// reconsidered on the next drain or after a restart.
|
|
849
|
-
continue;
|
|
850
|
-
}
|
|
851
|
-
self.deferred.remove(&envelope.envelope_id);
|
|
852
|
-
self.causal_barrier
|
|
853
|
-
.retain_pending_sized(key, dependencies, payload_bytes);
|
|
854
|
-
admitted += 1;
|
|
855
|
-
}
|
|
856
|
-
Ok(admitted)
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
/// The durable log, indexed by barrier key.
|
|
860
|
-
///
|
|
861
|
-
/// This is why the barrier never needs the payload: it holds the identity,
|
|
862
|
-
/// and the bytes stay where they were already fsynced.
|
|
863
|
-
fn log_by_key(&self) -> Result<HashMap<String, TransactionEnvelope>, String> {
|
|
864
|
-
let Some(ref log) = self.operation_log else {
|
|
865
|
-
return Ok(HashMap::new());
|
|
866
|
-
};
|
|
867
|
-
let envelopes = log
|
|
868
|
-
.load_all()
|
|
869
|
-
.map_err(|e| format!("Failed to replay operation log: {}", e))?;
|
|
870
|
-
Ok(envelopes
|
|
871
|
-
.into_iter()
|
|
872
|
-
.map(|envelope| (Self::barrier_key(&envelope.envelope_id), envelope))
|
|
873
|
-
.collect())
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
/// The stable string identity the barrier is keyed by.
|
|
877
|
-
fn barrier_key(envelope_id: &EnvelopeId) -> String {
|
|
878
|
-
format!("{}:{}", envelope_id.originating_node, envelope_id.sequence)
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/// The causal dependencies of an envelope: what its origin had applied
|
|
882
|
-
/// before producing it.
|
|
883
|
-
fn dependencies_of(envelope: &TransactionEnvelope) -> VectorClock {
|
|
884
|
-
let mut dependencies = envelope.vector_clock.clone();
|
|
885
|
-
let own = dependencies.get(&envelope.originating_node);
|
|
886
|
-
dependencies
|
|
887
|
-
.clocks
|
|
888
|
-
.insert(envelope.originating_node.clone(), own.saturating_sub(1));
|
|
889
|
-
dependencies
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
/// Which dimensions of a dependency clock the frontier has not reached.
|
|
893
|
-
fn missing_dependencies(&self, dependencies: &VectorClock) -> Vec<String> {
|
|
894
|
-
let frontier = self.causal_barrier.get_frontier();
|
|
895
|
-
let mut missing: Vec<String> = dependencies
|
|
896
|
-
.clocks
|
|
897
|
-
.iter()
|
|
898
|
-
.filter(|(node, required)| frontier.get(node) < **required)
|
|
899
|
-
.map(|(node, required)| format!("{}@{} (have {})", node, required, frontier.get(node)))
|
|
900
|
-
.collect();
|
|
901
|
-
missing.sort();
|
|
902
|
-
missing
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/// How many envelopes are durable but waiting on dependencies.
|
|
906
|
-
///
|
|
907
|
-
/// Instrumentation only; no limit is enforced. See
|
|
908
|
-
/// docs/architecture/causal-backlog-bound.md.
|
|
909
|
-
pub fn pending_causal_count(&self) -> usize {
|
|
910
|
-
self.causal_barrier.pending_count()
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/// Approximate heap cost of the pending causal metadata, in bytes.
|
|
914
|
-
pub fn pending_causal_metadata_bytes(&self) -> usize {
|
|
915
|
-
self.causal_barrier.pending_metadata_bytes()
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/// Durable payload bytes the backlog is waiting on. The byte bound is
|
|
919
|
-
/// expressed in these.
|
|
920
|
-
pub fn pending_causal_payload_bytes(&self) -> usize {
|
|
921
|
-
self.causal_barrier.pending_payload_bytes()
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/// Envelopes that are durable but not buffered, because the bound was
|
|
925
|
-
/// reached when they arrived. They are not lost and not evicted.
|
|
926
|
-
pub fn deferred_causal_count(&self) -> usize {
|
|
927
|
-
self.deferred.len()
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
/// Keys of envelopes that are durable but not buffered, sorted.
|
|
931
|
-
pub fn deferred_causal_keys(&self) -> Vec<String> {
|
|
932
|
-
let mut keys: Vec<String> = self.deferred.iter().map(Self::barrier_key).collect();
|
|
933
|
-
keys.sort();
|
|
934
|
-
keys
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
/// Maximum occupancy ever reached, for entries and payload bytes.
|
|
938
|
-
///
|
|
939
|
-
/// Reported alongside the current values because a backlog that filled and
|
|
940
|
-
/// drained between two reads is invisible to a current-value gauge, and the
|
|
941
|
-
/// question an operator has is whether the bound was ever approached.
|
|
942
|
-
pub fn max_causal_occupancy(&self) -> (usize, usize) {
|
|
943
|
-
(
|
|
944
|
-
self.causal_barrier.max_pending_entries_seen(),
|
|
945
|
-
self.causal_barrier.max_pending_payload_bytes_seen(),
|
|
946
|
-
)
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
/// The bound in force on this executor.
|
|
950
|
-
pub fn causal_capacity(&self) -> CausalCapacity {
|
|
951
|
-
self.causal_capacity
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
/// Set the bound. Used by the node binary so an acceptance test can reach
|
|
955
|
-
/// the bound through the production path rather than by constructing a
|
|
956
|
-
/// barrier directly.
|
|
957
|
-
pub fn set_causal_capacity(&mut self, capacity: CausalCapacity) {
|
|
958
|
-
self.causal_capacity = capacity;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
/// The applied causal frontier: what this replica has successfully applied.
|
|
962
|
-
pub fn causal_frontier(&self) -> &VectorClock {
|
|
963
|
-
self.causal_barrier.get_frontier()
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/// Identities of envelopes durable but not yet applied.
|
|
967
|
-
pub fn pending_causal_keys(&self) -> Vec<String> {
|
|
968
|
-
self.causal_barrier.pending_keys()
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/// Materialize the applied records, order-independently.
|
|
972
|
-
///
|
|
973
|
-
/// This exists because the chained `H(parent, command)` state hash is a
|
|
974
|
-
/// missing-operation detector for an ordered history, not a convergence
|
|
975
|
-
/// identity: two replicas that applied the same set of concurrent
|
|
976
|
-
/// operations in different orders hold the same data and report different
|
|
977
|
-
/// chained hashes. `CanonicalState` is a BTreeMap keyed by
|
|
978
|
-
/// (collection, record_id), so its hash depends on the resulting records
|
|
979
|
-
/// and not on the path taken to them, which is what "the replicas agree"
|
|
980
|
-
/// should mean.
|
|
981
|
-
///
|
|
982
|
-
/// Built from the durable log, filtered to what the barrier records as
|
|
983
|
-
/// applied and taken in application order, so a pending envelope's
|
|
984
|
-
/// operations are excluded until it is actually released.
|
|
985
|
-
pub fn canonical_state(&self) -> Result<crate::state_hash::CanonicalState, String> {
|
|
986
|
-
let mut state = crate::state_hash::CanonicalState::new();
|
|
987
|
-
let Some(ref log) = self.operation_log else {
|
|
988
|
-
return Ok(state);
|
|
989
|
-
};
|
|
990
|
-
let envelopes = log
|
|
991
|
-
.load_all()
|
|
992
|
-
.map_err(|e| format!("Failed to read operation log: {}", e))?;
|
|
993
|
-
let by_key: HashMap<String, &TransactionEnvelope> = envelopes
|
|
994
|
-
.iter()
|
|
995
|
-
.map(|envelope| (Self::barrier_key(&envelope.envelope_id), envelope))
|
|
996
|
-
.collect();
|
|
997
|
-
|
|
998
|
-
for key in self.causal_barrier.applied_keys() {
|
|
999
|
-
let Some(envelope) = by_key.get(key) else {
|
|
1000
|
-
continue;
|
|
1001
|
-
};
|
|
1002
|
-
for operation in &envelope.operations {
|
|
1003
|
-
let command = &operation.command;
|
|
1004
|
-
let fields: serde_json::Map<String, serde_json::Value> = command
|
|
1005
|
-
.fields
|
|
1006
|
-
.iter()
|
|
1007
|
-
.map(|(name, value)| (name.clone(), value.clone()))
|
|
1008
|
-
.collect();
|
|
1009
|
-
state.set_record(
|
|
1010
|
-
command.collection.clone(),
|
|
1011
|
-
command.record_id.clone(),
|
|
1012
|
-
serde_json::Value::Object(fields),
|
|
1013
|
-
);
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
Ok(state)
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
/// Get current state of a replica
|
|
1020
|
-
pub fn get_replica_state(&self, node_id: &str) -> Option<ReplicaState> {
|
|
1021
|
-
self.replicas.get(node_id).cloned()
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
/// Check if replicas have converged to same state
|
|
1025
|
-
pub fn check_convergence(&self) -> bool {
|
|
1026
|
-
if self.replicas.is_empty() {
|
|
1027
|
-
return true;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
let first_hash = self.replicas.values().next().map(|r| r.state_hash.clone());
|
|
1031
|
-
|
|
1032
|
-
if let Some(first) = first_hash {
|
|
1033
|
-
self.replicas.values().all(|r| r.state_hash == first)
|
|
1034
|
-
} else {
|
|
1035
|
-
false
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
/// Get all replica states
|
|
1040
|
-
pub fn get_all_replicas(&self) -> Vec<ReplicaState> {
|
|
1041
|
-
self.replicas.values().cloned().collect()
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
/// Causal read: wait for specific version
|
|
1045
|
-
pub fn causal_read(&self, required_clock: &VectorClock) -> bool {
|
|
1046
|
-
if let Some(local) = self.replicas.get(&self.local_node_id) {
|
|
1047
|
-
local.wait_for_clock_advance(required_clock)
|
|
1048
|
-
} else {
|
|
1049
|
-
false
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
/// Get the local node ID
|
|
1054
|
-
pub fn get_local_node_id(&self) -> String {
|
|
1055
|
-
self.local_node_id.clone()
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/// Load persisted operations from disk and rebuild internal state
|
|
1059
|
-
///
|
|
1060
|
-
/// Atomicity guarantee:
|
|
1061
|
-
/// - Load operation log (single durable source of truth)
|
|
1062
|
-
/// - Reconstruct dedup set deterministically from envelopes
|
|
1063
|
-
/// - No separate dedup file (no two-file atomicity problem)
|
|
1064
|
-
pub fn load_from_disk(&mut self, log_path: PathBuf) -> Result<usize, std::io::Error> {
|
|
1065
|
-
let log = OperationLog::new(&log_path)?;
|
|
1066
|
-
let envelopes = log.load_all()?;
|
|
1067
|
-
|
|
1068
|
-
self.operation_log = Some(log);
|
|
1069
|
-
|
|
1070
|
-
// Rebuild the causal state, not just the dedup set.
|
|
1071
|
-
//
|
|
1072
|
-
// The log records what was *received*, in arrival order, and arrival
|
|
1073
|
-
// order is not causal order -- that is the whole reason the barrier
|
|
1074
|
-
// exists. So recovery cannot simply mark everything seen: it has to
|
|
1075
|
-
// replay eligibility, exactly as the live path does, and come out with
|
|
1076
|
-
// the same split between applied and still-pending. This is what makes
|
|
1077
|
-
// "deliver B, restart, deliver A" work: B is durable across the
|
|
1078
|
-
// restart, is rediscovered as pending rather than as applied, and is
|
|
1079
|
-
// released when A finally arrives.
|
|
1080
|
-
let mut undecided: Vec<TransactionEnvelope> = envelopes.clone();
|
|
1081
|
-
loop {
|
|
1082
|
-
let mut progressed = false;
|
|
1083
|
-
let mut still_undecided = Vec::new();
|
|
1084
|
-
for envelope in undecided.into_iter() {
|
|
1085
|
-
let dependencies = Self::dependencies_of(&envelope);
|
|
1086
|
-
if self.causal_barrier.is_eligible(&dependencies) {
|
|
1087
|
-
self.envelopes_seen.insert(envelope.envelope_id.clone());
|
|
1088
|
-
self.causal_barrier.record_applied(
|
|
1089
|
-
&Self::barrier_key(&envelope.envelope_id),
|
|
1090
|
-
&envelope.vector_clock,
|
|
1091
|
-
);
|
|
1092
|
-
progressed = true;
|
|
1093
|
-
} else {
|
|
1094
|
-
still_undecided.push(envelope);
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
undecided = still_undecided;
|
|
1098
|
-
if !progressed || undecided.is_empty() {
|
|
1099
|
-
break;
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
// Whatever could not be made eligible is durable and waiting, and is
|
|
1104
|
-
// restored to the barrier in that state -- as far as the bound allows.
|
|
1105
|
-
//
|
|
1106
|
-
// Recovery has to respect the same bound the live path does, or a node
|
|
1107
|
-
// could restart into an over-full backlog that it would never have
|
|
1108
|
-
// accepted while running, and the bound would hold only until the first
|
|
1109
|
-
// crash. Entries are restored in key order so that recovery is
|
|
1110
|
-
// deterministic: the same log and the same bound produce the same split
|
|
1111
|
-
// between pending and deferred every time.
|
|
1112
|
-
undecided.sort_by_key(|envelope| Self::barrier_key(&envelope.envelope_id));
|
|
1113
|
-
for envelope in undecided {
|
|
1114
|
-
let dependencies = Self::dependencies_of(&envelope);
|
|
1115
|
-
let payload_bytes = Self::envelope_bytes(&envelope);
|
|
1116
|
-
if self.would_breach_bound(payload_bytes) {
|
|
1117
|
-
self.deferred.insert(envelope.envelope_id.clone());
|
|
1118
|
-
continue;
|
|
1119
|
-
}
|
|
1120
|
-
self.causal_barrier.retain_pending_sized(
|
|
1121
|
-
Self::barrier_key(&envelope.envelope_id),
|
|
1122
|
-
dependencies,
|
|
1123
|
-
payload_bytes,
|
|
1124
|
-
);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// Restore the observable local replica state from the same applied
|
|
1128
|
-
// subset. Recovery previously rebuilt the barrier and canonical data
|
|
1129
|
-
// but left this counter and clock at their constructor values, making
|
|
1130
|
-
// a fully recovered node report that it had applied zero operations.
|
|
1131
|
-
let applied_keys = self.causal_barrier.applied_keys();
|
|
1132
|
-
let operations_applied = envelopes
|
|
1133
|
-
.iter()
|
|
1134
|
-
.filter(|envelope| applied_keys.contains(&Self::barrier_key(&envelope.envelope_id)))
|
|
1135
|
-
.map(|envelope| envelope.operations.len() as u64)
|
|
1136
|
-
.sum();
|
|
1137
|
-
let recovered_clock = self.causal_barrier.get_frontier().clone();
|
|
1138
|
-
let recovered_hash = self
|
|
1139
|
-
.canonical_state()
|
|
1140
|
-
.map(|state| state.hash())
|
|
1141
|
-
.unwrap_or_else(|_| StateHash::from_hex("0".repeat(64)));
|
|
1142
|
-
if let Some(local) = self.replicas.get_mut(&self.local_node_id) {
|
|
1143
|
-
local.vector_clock = recovered_clock;
|
|
1144
|
-
local.operations_applied = operations_applied;
|
|
1145
|
-
local.state_hash = recovered_hash;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// Identity is recovered from the same log, and this is the half that
|
|
1149
|
-
// was missing: the causal frontier was already rebuilt here, so a
|
|
1150
|
-
// restarted origin came back knowing its causal position and not its
|
|
1151
|
-
// identity position, and issued colliding identities on top of a
|
|
1152
|
-
// correct clock.
|
|
1153
|
-
self.next_origin_sequence = envelopes
|
|
1154
|
-
.iter()
|
|
1155
|
-
.filter(|envelope| envelope.envelope_id.originating_node == self.local_node_id)
|
|
1156
|
-
.map(|envelope| envelope.envelope_id.sequence)
|
|
1157
|
-
.max()
|
|
1158
|
-
.unwrap_or(0)
|
|
1159
|
-
.saturating_add(1);
|
|
1160
|
-
|
|
1161
|
-
Ok(envelopes.len())
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
/// Persist an envelope to the durable operation log
|
|
1165
|
-
/// Must be called after successful state application but before returning to caller
|
|
1166
|
-
///
|
|
1167
|
-
/// Note: dedup set is NOT persisted (it's derived from the operation log).
|
|
1168
|
-
fn persist_envelope(&mut self, envelope: &TransactionEnvelope) -> Result<(), String> {
|
|
1169
|
-
if let Some(ref mut log) = self.operation_log {
|
|
1170
|
-
log.append(envelope)
|
|
1171
|
-
.map_err(|e| format!("Failed to persist operation: {}", e))?;
|
|
1172
|
-
}
|
|
1173
|
-
Ok(())
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
#[cfg(test)]
|
|
1178
|
-
mod tests {
|
|
1179
|
-
use super::*;
|
|
1180
|
-
|
|
1181
|
-
#[test]
|
|
1182
|
-
fn test_envelope_creation() {
|
|
1183
|
-
let envelope = TransactionEnvelope::new(
|
|
1184
|
-
"node1".to_string(),
|
|
1185
|
-
1,
|
|
1186
|
-
"txn1".to_string(),
|
|
1187
|
-
Vec::new(),
|
|
1188
|
-
ConsistencyContract::causal(),
|
|
1189
|
-
);
|
|
1190
|
-
|
|
1191
|
-
assert_eq!(envelope.originating_node, "node1");
|
|
1192
|
-
assert_eq!(envelope.transaction_id, "txn1");
|
|
1193
|
-
assert_eq!(envelope.envelope_id.sequence, 1);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
#[test]
|
|
1197
|
-
fn test_envelope_integrity() {
|
|
1198
|
-
let envelope = TransactionEnvelope::new(
|
|
1199
|
-
"node1".to_string(),
|
|
1200
|
-
1,
|
|
1201
|
-
"txn1".to_string(),
|
|
1202
|
-
vec![], // Empty operations
|
|
1203
|
-
ConsistencyContract::causal(),
|
|
1204
|
-
);
|
|
1205
|
-
|
|
1206
|
-
assert!(!envelope.verify_integrity());
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
#[test]
|
|
1210
|
-
fn test_replication_message_creation() {
|
|
1211
|
-
let envelope = TransactionEnvelope::new(
|
|
1212
|
-
"node1".to_string(),
|
|
1213
|
-
1,
|
|
1214
|
-
"txn1".to_string(),
|
|
1215
|
-
Vec::new(),
|
|
1216
|
-
ConsistencyContract::causal(),
|
|
1217
|
-
);
|
|
1218
|
-
|
|
1219
|
-
let msg = ReplicationMessage::new(envelope, "node1".to_string(), "node2".to_string(), 1);
|
|
1220
|
-
|
|
1221
|
-
assert_eq!(msg.from_node, "node1");
|
|
1222
|
-
assert_eq!(msg.to_node, "node2");
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
#[test]
|
|
1226
|
-
fn test_replica_state_creation() {
|
|
1227
|
-
let state = ReplicaState::new(
|
|
1228
|
-
"node1".to_string(),
|
|
1229
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1230
|
-
);
|
|
1231
|
-
|
|
1232
|
-
assert_eq!(state.node_id, "node1");
|
|
1233
|
-
assert_eq!(state.operations_applied, 0);
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
#[test]
|
|
1237
|
-
fn test_distributed_executor_creation() {
|
|
1238
|
-
let executor = DistributedTransactionExecutor::new(
|
|
1239
|
-
"node1".to_string(),
|
|
1240
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1241
|
-
);
|
|
1242
|
-
|
|
1243
|
-
assert!(executor.replicas.contains_key("node1"));
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
#[test]
|
|
1247
|
-
fn test_register_replica() {
|
|
1248
|
-
let mut executor = DistributedTransactionExecutor::new(
|
|
1249
|
-
"node1".to_string(),
|
|
1250
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1251
|
-
);
|
|
1252
|
-
|
|
1253
|
-
executor.register_replica(
|
|
1254
|
-
"node2".to_string(),
|
|
1255
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1256
|
-
);
|
|
1257
|
-
|
|
1258
|
-
assert!(executor.replicas.contains_key("node2"));
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
#[test]
|
|
1262
|
-
fn test_convergence_check_empty() {
|
|
1263
|
-
let executor = DistributedTransactionExecutor::new(
|
|
1264
|
-
"node1".to_string(),
|
|
1265
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1266
|
-
);
|
|
1267
|
-
|
|
1268
|
-
// Single replica should be converged
|
|
1269
|
-
assert!(executor.check_convergence());
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
#[test]
|
|
1273
|
-
fn test_convergence_check_multiple_replicas() {
|
|
1274
|
-
let mut executor = DistributedTransactionExecutor::new(
|
|
1275
|
-
"node1".to_string(),
|
|
1276
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1277
|
-
);
|
|
1278
|
-
|
|
1279
|
-
executor.register_replica(
|
|
1280
|
-
"node2".to_string(),
|
|
1281
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1282
|
-
);
|
|
1283
|
-
executor.register_replica(
|
|
1284
|
-
"node3".to_string(),
|
|
1285
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1286
|
-
);
|
|
1287
|
-
|
|
1288
|
-
assert!(executor.check_convergence());
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
#[test]
|
|
1292
|
-
fn test_convergence_check_diverged() {
|
|
1293
|
-
let mut executor = DistributedTransactionExecutor::new(
|
|
1294
|
-
"node1".to_string(),
|
|
1295
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1296
|
-
);
|
|
1297
|
-
|
|
1298
|
-
executor.register_replica(
|
|
1299
|
-
"node2".to_string(),
|
|
1300
|
-
StateHash::from_hex("hash1".to_string()),
|
|
1301
|
-
);
|
|
1302
|
-
|
|
1303
|
-
assert!(!executor.check_convergence());
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
#[test]
|
|
1307
|
-
fn test_causal_read_initial_state() {
|
|
1308
|
-
let executor = DistributedTransactionExecutor::new(
|
|
1309
|
-
"node1".to_string(),
|
|
1310
|
-
StateHash::from_hex("hash0".to_string()),
|
|
1311
|
-
);
|
|
1312
|
-
|
|
1313
|
-
let vc = VectorClock::new();
|
|
1314
|
-
assert!(executor.causal_read(&vc));
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
#[test]
|
|
1318
|
-
fn test_envelope_clock_advance() {
|
|
1319
|
-
let mut envelope = TransactionEnvelope::new(
|
|
1320
|
-
"node1".to_string(),
|
|
1321
|
-
1,
|
|
1322
|
-
"txn1".to_string(),
|
|
1323
|
-
Vec::new(),
|
|
1324
|
-
ConsistencyContract::causal(),
|
|
1325
|
-
);
|
|
1326
|
-
|
|
1327
|
-
let mut other_clock = VectorClock::new();
|
|
1328
|
-
other_clock.increment("node2");
|
|
1329
|
-
|
|
1330
|
-
envelope.advance_clock(&other_clock);
|
|
1331
|
-
|
|
1332
|
-
assert!(envelope.vector_clock.happens_before(&other_clock) == false);
|
|
1333
|
-
}
|
|
1334
|
-
}
|