create-feltdb 0.6.8 → 0.6.10

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.
@@ -41,6 +41,7 @@ use feltdb::{
41
41
  Grant, GrantSigner, GrantStore, Subject as GrantSubject,
42
42
  },
43
43
  cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
44
+ AtomicMutation, AtomicPrecondition,
44
45
  policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
45
46
  state_contract::{
46
47
  begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
@@ -6298,6 +6299,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6298
6299
  "/api/connections/{connection_id}",
6299
6300
  axum::routing::delete(delete_connection_control),
6300
6301
  )
6302
+ .route("/transactions", axum::routing::post(commit_transaction))
6303
+ .route("/revision", get(get_revision))
6301
6304
  .route(
6302
6305
  "/collections/{collection}",
6303
6306
  get(list_records).post(create_record),
@@ -8974,6 +8977,30 @@ fn put_canonical(
8974
8977
  }))
8975
8978
  }
8976
8979
 
8980
+ /// The authority's current committed-state revision.
8981
+ ///
8982
+ /// This is the value `apply_atomic_transaction` advances and writes into each
8983
+ /// durable transaction record as `state_after`. It already survived restart and
8984
+ /// already refused to move backwards; the only thing missing was a way to read
8985
+ /// it without performing a write, which is what a cache needs.
8986
+ ///
8987
+ /// `scope` is the store's instance id. A revision is comparable only against
8988
+ /// another revision carrying the same scope, so a client that fails over to a
8989
+ /// different replica sees a scope change rather than a silently wrong
8990
+ /// comparison against a counter that means something else there.
8991
+ #[derive(Serialize)]
8992
+ struct RevisionResponse {
8993
+ revision: u64,
8994
+ scope: String,
8995
+ }
8996
+
8997
+ async fn get_revision(State(state): State<AppState>) -> Result<Json<RevisionResponse>, ApiError> {
8998
+ Ok(Json(RevisionResponse {
8999
+ revision: state.db.sequence()?,
9000
+ scope: state.db.instance_id()?,
9001
+ }))
9002
+ }
9003
+
8977
9004
  async fn list_records(
8978
9005
  State(state): State<AppState>,
8979
9006
  Path(collection): Path<String>,
@@ -9000,6 +9027,120 @@ async fn get_record(
9000
9027
  }))
9001
9028
  }
9002
9029
 
9030
+ /// One operation inside an application transaction.
9031
+ #[derive(Deserialize)]
9032
+ #[serde(rename_all = "camelCase")]
9033
+ struct AtomicTxOperationRequest {
9034
+ collection: String,
9035
+ id: String,
9036
+ /// Absent value means delete.
9037
+ #[serde(default)]
9038
+ value: Option<Map<String, Value>>,
9039
+ /// When set, the record must not already exist for the transaction to commit.
9040
+ #[serde(default)]
9041
+ require_absent: bool,
9042
+ }
9043
+
9044
+ #[derive(Deserialize)]
9045
+ #[serde(rename_all = "camelCase")]
9046
+ struct AtomicTxRequest {
9047
+ /// Caller-supplied identity. Reusing one makes the commit idempotent.
9048
+ transaction_id: String,
9049
+ operations: Vec<AtomicTxOperationRequest>,
9050
+ }
9051
+
9052
+ #[derive(Serialize)]
9053
+ #[serde(rename_all = "camelCase")]
9054
+ struct AtomicTxResponse {
9055
+ transaction_id: String,
9056
+ /// True when this id had already been committed; nothing was reapplied.
9057
+ duplicate: bool,
9058
+ operations: usize,
9059
+ state_before: u64,
9060
+ state_after: u64,
9061
+ }
9062
+
9063
+ /// Commit several operations as one atomic, durable transaction.
9064
+ ///
9065
+ /// This is the application-facing entry point to the same atomic commit path
9066
+ /// the substrate uses: preconditions are evaluated first, then one transaction
9067
+ /// record is written and fsynced, and only then is derived state updated.
9068
+ /// There is no second transaction mechanism — the request is translated into
9069
+ /// the store's own atomic transaction and nothing else.
9070
+ async fn commit_transaction(
9071
+ State(state): State<AppState>,
9072
+ Json(request): Json<AtomicTxRequest>,
9073
+ ) -> Result<(StatusCode, Json<AtomicTxResponse>), ApiError> {
9074
+ if request.transaction_id.trim().is_empty() {
9075
+ return Err(ApiError(
9076
+ StatusCode::BAD_REQUEST,
9077
+ "transactionId is required".to_string(),
9078
+ ));
9079
+ }
9080
+ if request.operations.is_empty() {
9081
+ return Err(ApiError(
9082
+ StatusCode::BAD_REQUEST,
9083
+ "a transaction requires at least one operation".to_string(),
9084
+ ));
9085
+ }
9086
+
9087
+ // Validate every operation before any of them is turned into a mutation, so
9088
+ // a malformed request cannot commit a prefix of the transaction.
9089
+ let mut mutations = Vec::with_capacity(request.operations.len());
9090
+ let mut preconditions = Vec::new();
9091
+ let mut seen = std::collections::HashSet::new();
9092
+ for operation in request.operations {
9093
+ let key = record_key(&operation.collection, &operation.id)?;
9094
+ if !seen.insert(key.clone()) {
9095
+ return Err(ApiError(
9096
+ StatusCode::BAD_REQUEST,
9097
+ format!("transaction writes {key} more than once"),
9098
+ ));
9099
+ }
9100
+ if operation.require_absent {
9101
+ // The store reads a precondition with no expected version as
9102
+ // "this record must not exist", and evaluates it inside the same
9103
+ // lock as the commit, so there is no window between the check and
9104
+ // the write.
9105
+ preconditions.push(AtomicPrecondition {
9106
+ capability: operation.collection.clone(),
9107
+ key: key.clone(),
9108
+ expected_version: None,
9109
+ });
9110
+ }
9111
+ let value = operation.value.map(|mut fields| {
9112
+ fields.insert("id".to_string(), Value::String(operation.id.clone()));
9113
+ Value::Object(fields)
9114
+ });
9115
+ mutations.push(AtomicMutation {
9116
+ capability: operation.collection,
9117
+ key,
9118
+ value,
9119
+ });
9120
+ }
9121
+
9122
+ let operations = mutations.len();
9123
+ let commit = state
9124
+ .db
9125
+ .apply_atomic_transaction(&request.transaction_id, None, &preconditions, &mutations, None)
9126
+ .map_err(|error| {
9127
+ // A refused transaction applied nothing, so this is a conflict, not
9128
+ // a partially completed request.
9129
+ ApiError(StatusCode::CONFLICT, error.to_string())
9130
+ })?;
9131
+
9132
+ Ok((
9133
+ if commit.duplicate { StatusCode::OK } else { StatusCode::CREATED },
9134
+ Json(AtomicTxResponse {
9135
+ transaction_id: commit.transaction_id,
9136
+ duplicate: commit.duplicate,
9137
+ operations,
9138
+ state_before: commit.state_before,
9139
+ state_after: commit.state_after,
9140
+ }),
9141
+ ))
9142
+ }
9143
+
9003
9144
  async fn create_record(
9004
9145
  State(state): State<AppState>,
9005
9146
  Path(collection): Path<String>,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "create-feltdb",
3
3
  "private": false,
4
4
  "description": "Create a new FeltDB application with one command",
5
- "version": "0.6.8",
5
+ "version": "0.6.10",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"
@@ -1,523 +0,0 @@
1
- /// Phase 1c: Three-Node TCP Scaling
2
- ///
3
- /// Validates that FeltDB replication converges correctly with 3 nodes over real TCP.
4
- ///
5
- /// Success Criteria:
6
- /// - 3-node cluster established over TCP (1 leader + 2 followers)
7
- /// - 1000 operations replicated to all 3 nodes
8
- /// - All 3 nodes converge to identical state_hash
9
- /// - Replication latency <1s (p99) on LAN
10
- /// - Zero message loss or corruption
11
- /// - Test passes 10 consecutive runs
12
- /// - No deadlocks or stalls
13
-
14
- #[cfg(test)]
15
- mod tests {
16
- use crate::convergence::VectorClock;
17
- use crate::distributed_transactions::{
18
- DistributedTransactionExecutor, ReplicationMessage, TransactionEnvelope,
19
- };
20
- use crate::in_process_transport::InProcessTransport;
21
- use crate::replication_protocol::ProtocolTransport;
22
- use crate::state_hash::StateHash;
23
- use crate::tcp_transport::TcpTransport;
24
- use crate::transactions::{ConsistencyContract, Operation, OperationCommand, OperationId, StateVersion};
25
- use serde_json::json;
26
- use std::collections::HashMap;
27
- use std::sync::Arc;
28
- use std::time::{Duration, Instant};
29
- use tokio::sync::Mutex;
30
- use tokio::task::{self, JoinSet};
31
-
32
- /// Test node that combines FeltDb state with TCP transport
33
- struct TestNode {
34
- node_id: String,
35
- port: u16,
36
- executor: Arc<Mutex<DistributedTransactionExecutor>>,
37
- transport: Arc<Mutex<TcpTransport>>,
38
- sequence: Arc<Mutex<u64>>,
39
- }
40
-
41
- impl TestNode {
42
- fn new(node_id: String, port: u16) -> Self {
43
- let initial_hash = StateHash::from_hex("0".repeat(64));
44
- let executor = DistributedTransactionExecutor::new(node_id.clone(), initial_hash);
45
-
46
- let addr = format!("127.0.0.1:{}", port);
47
- let transport = TcpTransport::new(addr);
48
-
49
- Self {
50
- node_id,
51
- port,
52
- executor: Arc::new(Mutex::new(executor)),
53
- transport: Arc::new(Mutex::new(transport)),
54
- sequence: Arc::new(Mutex::new(0)),
55
- }
56
- }
57
-
58
- /// Get the bind address for this node
59
- fn addr(&self) -> String {
60
- format!("127.0.0.1:{}", self.port)
61
- }
62
-
63
- /// Get current state hash
64
- async fn state_hash(&self) -> StateHash {
65
- let executor = self.executor.lock().await;
66
- executor
67
- .get_replica_state(&self.node_id)
68
- .map(|s| s.state_hash)
69
- .unwrap_or_else(|| StateHash::from_hex("0".repeat(64)))
70
- }
71
-
72
- /// Check if this node has received all expected envelopes
73
- async fn operations_applied(&self) -> u64 {
74
- let executor = self.executor.lock().await;
75
- executor
76
- .get_replica_state(&self.node_id)
77
- .map(|s| s.operations_applied)
78
- .unwrap_or(0)
79
- }
80
-
81
- /// Submit a single operation (leader only)
82
- async fn submit_operation(&self, op_idx: u64) -> Result<(), String> {
83
- let mut executor = self.executor.lock().await;
84
- let mut seq = self.sequence.lock().await;
85
- *seq += 1;
86
-
87
- let op_id = OperationId::new(self.node_id.clone(), *seq);
88
- let parent_vc = VectorClock::new();
89
- let parent_version = StateVersion::new(parent_vc, "0".repeat(64));
90
-
91
- let mut fields = HashMap::new();
92
- fields.insert("value".to_string(), json!(op_idx));
93
-
94
- let operation = Operation::new(
95
- op_id,
96
- parent_version.clone(),
97
- format!("op_{}", op_idx),
98
- OperationCommand {
99
- op_type: "set".to_string(),
100
- collection: "items".to_string(),
101
- record_id: format!("item_{}", op_idx),
102
- fields,
103
- },
104
- self.node_id.clone(),
105
- );
106
-
107
- let envelope = executor.execute_local_transaction(
108
- *seq,
109
- format!("txn_{}", op_idx),
110
- parent_version,
111
- vec![operation],
112
- ConsistencyContract::causal(),
113
- )?;
114
-
115
- Ok(())
116
- }
117
- }
118
-
119
- /// Test coordinator for managing 3-node cluster
120
- struct TestCluster {
121
- nodes: Vec<TestNode>,
122
- }
123
-
124
- impl TestCluster {
125
- fn new(node_count: usize) -> Self {
126
- let mut nodes = Vec::new();
127
- for i in 0..node_count {
128
- let node_id = format!("node-{}", i);
129
- let port = 10000 + i as u16;
130
- nodes.push(TestNode::new(node_id, port));
131
- }
132
- Self { nodes }
133
- }
134
-
135
- /// Get node by index
136
- fn node(&self, idx: usize) -> &TestNode {
137
- &self.nodes[idx]
138
- }
139
-
140
- /// Start listening on all nodes
141
- async fn start_listeners(&self) {
142
- for node in &self.nodes {
143
- let transport = node.transport.clone();
144
- let _handle = task::spawn(async move {
145
- let mut t = transport.lock().await;
146
- let _ = t.listen().await;
147
- });
148
- }
149
- // Give listeners time to start
150
- tokio::time::sleep(Duration::from_millis(100)).await;
151
- }
152
-
153
- /// Connect followers to leader
154
- async fn establish_replication_topology(&self) {
155
- let leader_addr = self.nodes[0].addr();
156
-
157
- for node in self.nodes.iter().skip(1) {
158
- let mut t = node.transport.lock().await;
159
- let _ = (*t).connect(&leader_addr).await;
160
- }
161
- }
162
-
163
- /// Simulate replication: broadcast leader messages to all followers
164
- async fn replicate_from_leader(&self, operations_count: u64) {
165
- let leader = &self.nodes[0];
166
- let followers: Vec<_> = (1..self.nodes.len()).collect();
167
-
168
- // Wait for operations to be applied on followers
169
- for op_idx in 0..operations_count {
170
- // Small delay between operations to allow replication
171
- tokio::time::sleep(Duration::from_millis(1)).await;
172
- }
173
- }
174
-
175
- /// Verify all nodes have converged to same state
176
- async fn verify_convergence(&self) -> Result<(), String> {
177
- let mut hashes = Vec::new();
178
-
179
- for node in &self.nodes {
180
- let executor = node.executor.lock().await;
181
- let state = executor.get_replica_state(&node.node_id);
182
- if let Some(s) = state {
183
- hashes.push(s.state_hash);
184
- } else {
185
- return Err(format!("Node {} has no state", node.node_id));
186
- }
187
- }
188
-
189
- if hashes.is_empty() {
190
- return Err("No nodes".to_string());
191
- }
192
-
193
- let first = &hashes[0];
194
- for (idx, hash) in hashes.iter().enumerate().skip(1) {
195
- if hash != first {
196
- return Err(format!(
197
- "Convergence failed: node-0 has {}, node-{} has {}",
198
- first, idx, hash
199
- ));
200
- }
201
- }
202
-
203
- Ok(())
204
- }
205
-
206
- /// Get statistics about replication
207
- async fn get_stats(&self) -> ClusterStats {
208
- let mut total_ops_applied = 0;
209
- let mut node_states = Vec::new();
210
-
211
- for node in &self.nodes {
212
- let ops = node.operations_applied().await;
213
- let hash = node.state_hash().await;
214
- total_ops_applied += ops;
215
- node_states.push((node.node_id.clone(), ops, hash));
216
- }
217
-
218
- ClusterStats {
219
- node_states,
220
- total_ops_applied,
221
- }
222
- }
223
- }
224
-
225
- struct ClusterStats {
226
- node_states: Vec<(String, u64, StateHash)>,
227
- total_ops_applied: u64,
228
- }
229
-
230
- /// Phase 1c Test 1: Cluster initialization (no networking yet)
231
- #[tokio::test]
232
- async fn three_node_cluster_initializes() {
233
- let cluster = TestCluster::new(3);
234
-
235
- // Verify all nodes are created with initialized state
236
- for node in &cluster.nodes {
237
- let hash = node.state_hash().await;
238
- // Hash should be deterministic initial value
239
- assert_ne!(hash, StateHash::from_hex("".to_string()), "Node {} should have state", node.node_id);
240
- }
241
- }
242
-
243
- /// Phase 1c Test 2: Submit single operation to leader
244
- #[tokio::test]
245
- async fn leader_submit_single_operation() {
246
- let cluster = TestCluster::new(3);
247
- let leader = cluster.node(0);
248
-
249
- // Submit first operation
250
- leader
251
- .submit_operation(0)
252
- .await
253
- .expect("submit should succeed");
254
-
255
- // Verify leader state changed
256
- let ops_applied = leader.operations_applied().await;
257
- assert_eq!(ops_applied, 1, "Leader should have 1 operation applied");
258
- }
259
-
260
- /// Phase 1c Test 3: Submit 100 operations to leader
261
- #[tokio::test]
262
- async fn leader_submit_100_operations() {
263
- let cluster = TestCluster::new(3);
264
- let leader = cluster.node(0);
265
- let start = Instant::now();
266
-
267
- // Submit 100 operations through leader
268
- for i in 0..100 {
269
- leader
270
- .submit_operation(i)
271
- .await
272
- .expect("submit should succeed");
273
- }
274
-
275
- // Check leader has all operations
276
- let leader_ops = leader.operations_applied().await;
277
- assert_eq!(leader_ops, 100, "Leader should have 100 operations applied");
278
-
279
- println!(
280
- "Submitted 100 operations in {:?}",
281
- start.elapsed()
282
- );
283
- }
284
-
285
- /// Phase 1c Test 4: Leader can track multiple operations
286
- #[tokio::test]
287
- async fn leader_tracks_operation_sequence() {
288
- let cluster = TestCluster::new(3);
289
- let leader = cluster.node(0);
290
-
291
- // Submit multiple operations
292
- for i in 0..50 {
293
- leader
294
- .submit_operation(i)
295
- .await
296
- .expect("submit should succeed");
297
- }
298
-
299
- let ops_applied = leader.operations_applied().await;
300
- assert_eq!(ops_applied, 50, "Leader should track all 50 operations");
301
- }
302
-
303
- /// Phase 1c Test 5: Submit 1000 operations to leader
304
- #[tokio::test]
305
- async fn leader_submit_1000_operations() {
306
- let cluster = TestCluster::new(3);
307
- let leader = cluster.node(0);
308
- let start = Instant::now();
309
-
310
- // Submit 1000 operations
311
- for i in 0..1000 {
312
- leader
313
- .submit_operation(i)
314
- .await
315
- .expect("submit should succeed");
316
-
317
- // Add small delay to avoid overwhelming the system
318
- if i % 100 == 0 {
319
- tokio::time::sleep(Duration::from_millis(1)).await;
320
- }
321
- }
322
-
323
- let elapsed = start.elapsed();
324
- let leader_ops = leader.operations_applied().await;
325
-
326
- println!("Submitted 1000 operations in {:?}", elapsed);
327
- println!("Leader has {} operations applied", leader_ops);
328
-
329
- assert_eq!(leader_ops, 1000, "Leader should have all 1000 operations applied");
330
- }
331
-
332
- /// Phase 1c Test 6: Multiple nodes can submit operations independently
333
- #[tokio::test]
334
- async fn multiple_nodes_submit_independently() {
335
- let cluster = TestCluster::new(3);
336
- let node0 = cluster.node(0);
337
- let node1 = cluster.node(1);
338
- let node2 = cluster.node(2);
339
-
340
- // Each node submits operations independently
341
- for i in 0..10 {
342
- node0.submit_operation(i).await.expect("node0 submit");
343
- node1.submit_operation(i + 1000).await.expect("node1 submit");
344
- node2.submit_operation(i + 2000).await.expect("node2 submit");
345
- }
346
-
347
- // Verify each node tracks its operations
348
- assert_eq!(node0.operations_applied().await, 10, "Node0 ops");
349
- assert_eq!(node1.operations_applied().await, 10, "Node1 ops");
350
- assert_eq!(node2.operations_applied().await, 10, "Node2 ops");
351
-
352
- println!("All 3 nodes submitted 10 operations each independently");
353
- }
354
-
355
- /// Replication Test 1: Single operation replication between two nodes
356
- #[tokio::test]
357
- async fn replicate_single_operation_to_follower() {
358
- let leader_id = "leader".to_string();
359
- let follower_id = "follower".to_string();
360
-
361
- let initial_hash = StateHash::from_hex("0".repeat(64));
362
- let mut leader_executor = DistributedTransactionExecutor::new(leader_id.clone(), initial_hash.clone());
363
- let mut follower_executor = DistributedTransactionExecutor::new(follower_id.clone(), initial_hash.clone());
364
-
365
- // Register replicas
366
- leader_executor.register_replica(follower_id.clone(), initial_hash.clone());
367
- follower_executor.register_replica(leader_id.clone(), initial_hash.clone());
368
-
369
- // Leader submits operation
370
- let op_id = OperationId::new(leader_id.clone(), 1);
371
- let parent_vc = VectorClock::new();
372
- let parent_version = StateVersion::new(parent_vc, "0".repeat(64));
373
-
374
- let mut fields = HashMap::new();
375
- fields.insert("value".to_string(), json!(42));
376
-
377
- let operation = Operation::new(
378
- op_id,
379
- parent_version.clone(),
380
- "op_1".to_string(),
381
- OperationCommand {
382
- op_type: "set".to_string(),
383
- collection: "items".to_string(),
384
- record_id: "item_1".to_string(),
385
- fields,
386
- },
387
- leader_id.clone(),
388
- );
389
-
390
- let envelope = leader_executor
391
- .execute_local_transaction(
392
- 1,
393
- "txn_1".to_string(),
394
- parent_version.clone(),
395
- vec![operation],
396
- ConsistencyContract::causal(),
397
- )
398
- .expect("leader should execute");
399
-
400
- // Create replication message
401
- let message = ReplicationMessage::new(
402
- envelope,
403
- leader_id.clone(),
404
- follower_id.clone(),
405
- 1,
406
- );
407
-
408
- // Follower applies replicated operation
409
- let follower_result = follower_executor.receive_replicated_transaction(message, parent_version);
410
- assert!(follower_result.is_ok(), "Follower should apply operation");
411
-
412
- // Verify leader and follower have same state
413
- let leader_state = leader_executor.get_replica_state(&leader_id).unwrap();
414
- let follower_state = follower_executor.get_replica_state(&follower_id).unwrap();
415
-
416
- println!(
417
- "Leader hash: {}, Follower hash: {}",
418
- leader_state.state_hash, follower_state.state_hash
419
- );
420
- println!(
421
- "Leader ops: {}, Follower ops: {}",
422
- leader_state.operations_applied, follower_state.operations_applied
423
- );
424
- }
425
-
426
- /// Replication Test 2: Multiple operations converge across 3 nodes
427
- #[tokio::test]
428
- async fn three_node_convergence_with_replication() {
429
- let node_ids = ["leader", "follower1", "follower2"];
430
- let mut executors: Vec<_> = node_ids
431
- .iter()
432
- .map(|id| {
433
- let initial_hash = StateHash::from_hex("0".repeat(64));
434
- DistributedTransactionExecutor::new(id.to_string(), initial_hash)
435
- })
436
- .collect();
437
-
438
- let initial_hash = StateHash::from_hex("0".repeat(64));
439
-
440
- // Register replicas on all executors
441
- for executor in &mut executors {
442
- for node_id in &node_ids {
443
- if node_id != &executor.local_node_id.as_str() {
444
- executor.register_replica(node_id.to_string(), initial_hash.clone());
445
- }
446
- }
447
- }
448
-
449
- // Leader submits 10 operations
450
- let mut envelopes = Vec::new();
451
- for i in 0..10 {
452
- let op_id = OperationId::new("leader".to_string(), i + 1);
453
- let parent_vc = VectorClock::new();
454
- let parent_version = StateVersion::new(parent_vc, "0".repeat(64));
455
-
456
- let mut fields = HashMap::new();
457
- fields.insert("value".to_string(), json!(i));
458
-
459
- let operation = Operation::new(
460
- op_id,
461
- parent_version.clone(),
462
- format!("op_{}", i),
463
- OperationCommand {
464
- op_type: "set".to_string(),
465
- collection: "items".to_string(),
466
- record_id: format!("item_{}", i),
467
- fields,
468
- },
469
- "leader".to_string(),
470
- );
471
-
472
- let envelope = executors[0]
473
- .execute_local_transaction(
474
- i + 1,
475
- format!("txn_{}", i),
476
- parent_version,
477
- vec![operation],
478
- ConsistencyContract::causal(),
479
- )
480
- .expect("leader execute");
481
-
482
- envelopes.push(envelope);
483
- }
484
-
485
- // Followers apply operations
486
- for (follower_idx, follower_id) in node_ids.iter().enumerate().skip(1) {
487
- for envelope in &envelopes {
488
- let message = ReplicationMessage::new(
489
- envelope.clone(),
490
- "leader".to_string(),
491
- follower_id.to_string(),
492
- 0,
493
- );
494
-
495
- let parent_version = StateVersion::new(VectorClock::new(), "0".repeat(64));
496
- let _ = executors[follower_idx].receive_replicated_transaction(message, parent_version);
497
- }
498
- }
499
-
500
- // Verify convergence
501
- let leader_state = executors[0].get_replica_state("leader").unwrap();
502
- let follower1_state = executors[1].get_replica_state("follower1").unwrap();
503
- let follower2_state = executors[2].get_replica_state("follower2").unwrap();
504
-
505
- println!(
506
- "Leader: {} ops, hash {}",
507
- leader_state.operations_applied, leader_state.state_hash
508
- );
509
- println!(
510
- "Follower1: {} ops, hash {}",
511
- follower1_state.operations_applied, follower1_state.state_hash
512
- );
513
- println!(
514
- "Follower2: {} ops, hash {}",
515
- follower2_state.operations_applied, follower2_state.state_hash
516
- );
517
-
518
- // All nodes should have applied the same number of operations on their respective views
519
- assert_eq!(leader_state.operations_applied, 10, "Leader ops");
520
- assert_eq!(follower1_state.operations_applied, 10, "Follower1 ops");
521
- assert_eq!(follower2_state.operations_applied, 10, "Follower2 ops");
522
- }
523
- }