@feltdb/core 0.6.13 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +32 -0
  2. package/dist/agent-registry.js +1 -3
  3. package/dist/agent-runtime.js +8 -7
  4. package/dist/analytics-backend.js +3 -1
  5. package/dist/application-contract.js +1 -0
  6. package/dist/application-manifest.js +1 -0
  7. package/dist/artifact.js +2 -0
  8. package/dist/authorization.js +2 -0
  9. package/dist/bundle.js +2 -0
  10. package/dist/capability.js +1 -3
  11. package/dist/cell.js +9 -4
  12. package/dist/cli/commands.js +26 -2
  13. package/dist/cli/index.js +1 -1
  14. package/dist/collection.js +39 -31
  15. package/dist/create/package-versions.js +1 -1
  16. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +613 -27
  17. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +452 -0
  18. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +208 -0
  19. package/dist/create/server-source/crates/feltdb/src/convergence.rs +16 -0
  20. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +402 -0
  21. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +784 -24
  22. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +418 -0
  23. package/dist/create/server-source/crates/feltdb/src/lib.rs +284 -0
  24. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +471 -0
  25. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +661 -0
  26. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +72 -3
  27. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +899 -0
  28. package/dist/create/server-source/crates/feltdb-server/src/main.rs +116 -4
  29. package/dist/db.d.ts +2 -2
  30. package/dist/db.d.ts.map +1 -1
  31. package/dist/db.js +48 -15
  32. package/dist/development-runtime-bridge.js +1 -1
  33. package/dist/distributed-indexing.js +7 -5
  34. package/dist/embedded-transaction.d.ts +9 -0
  35. package/dist/embedded-transaction.d.ts.map +1 -1
  36. package/dist/embedded-transaction.js +95 -3
  37. package/dist/feltdb.d.ts +16 -0
  38. package/dist/feltdb.d.ts.map +1 -1
  39. package/dist/file-db.d.ts.map +1 -1
  40. package/dist/file-db.js +9 -3
  41. package/dist/flowspec.js +2 -1
  42. package/dist/http-client.js +2 -0
  43. package/dist/http-db.d.ts +11 -0
  44. package/dist/http-db.d.ts.map +1 -1
  45. package/dist/http-db.js +32 -3
  46. package/dist/identity.js +1 -0
  47. package/dist/index-analytics.js +6 -7
  48. package/dist/index-backend.js +3 -3
  49. package/dist/index-dashboard.js +10 -13
  50. package/dist/index-manager.js +12 -11
  51. package/dist/index-monitoring.js +9 -4
  52. package/dist/index-store.js +2 -0
  53. package/dist/indexeddb-db.d.ts.map +1 -1
  54. package/dist/indexeddb-db.js +32 -23
  55. package/dist/memory-db.d.ts.map +1 -1
  56. package/dist/memory-db.js +8 -4
  57. package/dist/observe.js +2 -0
  58. package/dist/provider.js +2 -0
  59. package/dist/query-planner.js +2 -4
  60. package/dist/reactive-graph.js +6 -8
  61. package/dist/release.js +2 -0
  62. package/dist/sharding.js +11 -6
  63. package/dist/state-contract.js +3 -3
  64. package/dist/studio-app/assets/{feltdb_wasm-CJv3wHzi.js → feltdb_wasm-CD744e5D.js} +1 -1
  65. package/dist/studio-app/assets/feltdb_wasm_bg-CiIXhOLi.wasm +0 -0
  66. package/dist/studio-app/assets/index-DoROs8yx.js +28 -0
  67. package/dist/studio-app/index.html +1 -1
  68. package/dist/sync-contract.js +9 -2
  69. package/dist/telemetry.d.ts.map +1 -1
  70. package/dist/telemetry.js +32 -12
  71. package/dist/transaction.d.ts +127 -9
  72. package/dist/transaction.d.ts.map +1 -1
  73. package/dist/transaction.js +91 -5
  74. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  75. package/dist/worker.js +2 -0
  76. package/dist/workload.js +2 -0
  77. package/dist/workspace/development-node.js +11 -10
  78. package/dist/workspace/investigation-lifecycle-manager.js +2 -0
  79. package/dist/workspace/investigation-supervisor.js +5 -3
  80. package/dist/workspace/workspace-connection.js +14 -5
  81. package/package.json +1 -1
  82. package/dist/studio-app/assets/feltdb_wasm_bg-C8TG8r2n.wasm +0 -0
  83. package/dist/studio-app/assets/index-DospFFYE.js +0 -28
@@ -37,20 +37,29 @@
37
37
  //! resend-last put the identical envelope back on the wire, to test dedup
38
38
  //! query-state the local replica's state hash and applied-operation count
39
39
  //! metrics per-link byte and message counters, straight from the transport
40
+ //! causal-state applied frontier, and what is durable but held back
41
+ //! hold-inbound PEER park messages from PEER instead of delivering them
42
+ //! release-inbound PEER [reverse]
43
+ //! deliver what was parked, optionally newest-first
40
44
  //! peers per-link connection state
41
45
  //! shutdown exit cleanly
42
46
  //! ```
43
47
 
44
48
  use feltdb::convergence::VectorClock;
45
- use feltdb::distributed_transactions::{DistributedTransactionExecutor, ReplicationMessage};
49
+ use feltdb::distributed_transactions::{
50
+ CausalCapacity, DistributedTransactionExecutor, ReceiveOutcome, ReplicationMessage,
51
+ SubmitOutcome,
52
+ };
46
53
  use feltdb::replication_protocol::ProtocolTransport;
54
+ use feltdb::replica_acknowledgements::{AcknowledgementStore, ReclamationEvidence, ReplicaAcknowledgement};
55
+ use feltdb::replica_membership::MembershipStore;
47
56
  use feltdb::state_hash::StateHash;
48
57
  use feltdb::tcp_transport::TcpTransport;
49
58
  use feltdb::transactions::{
50
59
  ConsistencyContract, Operation, OperationCommand, OperationId, StateVersion,
51
60
  };
52
61
  use serde_json::json;
53
- use std::collections::HashMap;
62
+ use std::collections::{HashMap, HashSet};
54
63
  use std::io::{self, BufRead, Write};
55
64
  use std::path::PathBuf;
56
65
  use std::sync::atomic::{AtomicBool, Ordering};
@@ -86,6 +95,53 @@ struct RecvLink {
86
95
  io: TcpTransport,
87
96
  }
88
97
 
98
+ /// Hand one replicated message to the executor and record what it decided.
99
+ ///
100
+ /// The harness makes no ordering decision of its own here. It calls the same
101
+ /// `receive_replicated` the shipping receive path calls, and reports the typed
102
+ /// outcome back so a test can assert on "applied" versus "held back" rather
103
+ /// than inferring it from state.
104
+ fn deliver(
105
+ executor: &mut DistributedTransactionExecutor,
106
+ version: &mut StateVersion,
107
+ message: ReplicationMessage,
108
+ ) -> serde_json::Value {
109
+ let origin = message.envelope.originating_node.clone();
110
+ let key = format!(
111
+ "{}:{}",
112
+ message.envelope.envelope_id.originating_node, message.envelope.envelope_id.sequence
113
+ );
114
+ let parent = version.clone();
115
+ match executor.receive_replicated(message, parent) {
116
+ Ok(ReceiveOutcome::Applied { state_hash }) => {
117
+ // Only a real application advances the chained version, so a held
118
+ // or duplicated message cannot make the node look like it moved.
119
+ version.state_hash = state_hash.to_string();
120
+ version.vector_clock.increment(&origin);
121
+ json!({ "envelope": key, "outcome": "applied" })
122
+ }
123
+ Ok(ReceiveOutcome::PendingDependencies { missing }) => {
124
+ json!({ "envelope": key, "outcome": "pending", "missing": missing })
125
+ }
126
+ Ok(ReceiveOutcome::AlreadyKnown) => {
127
+ json!({ "envelope": key, "outcome": "already_known" })
128
+ }
129
+ Ok(ReceiveOutcome::CapacityDeferred { missing, pending_entries, pending_bytes }) => {
130
+ // Durable, but not buffered. The caller's obligation is to stop
131
+ // reading from this peer; the envelope is admitted from the log
132
+ // once the backlog drains.
133
+ json!({
134
+ "envelope": key,
135
+ "outcome": "capacity_deferred",
136
+ "missing": missing,
137
+ "pending_entries": pending_entries,
138
+ "pending_bytes": pending_bytes,
139
+ })
140
+ }
141
+ Err(error) => json!({ "envelope": key, "outcome": "error", "error": error }),
142
+ }
143
+ }
144
+
89
145
  fn parse_link(value: &str) -> Result<(String, String), String> {
90
146
  let (peer, addr) = value
91
147
  .split_once('=')
@@ -118,6 +174,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
118
174
  let mut data_dir: Option<PathBuf> = None;
119
175
  let mut send_specs: Vec<(String, String)> = Vec::new();
120
176
  let mut recv_specs: Vec<(String, String)> = Vec::new();
177
+ let mut max_pending_entries: Option<usize> = None;
178
+ let mut max_pending_bytes: Option<usize> = None;
121
179
 
122
180
  let mut index = 1;
123
181
  while index < args.len() {
@@ -143,6 +201,30 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
143
201
  index += 1;
144
202
  recv_specs.push(parse_link(args.get(index).ok_or("--recv requires PEER=ADDR")?)?);
145
203
  }
204
+ // The bound is settable so an acceptance test can reach it through
205
+ // the production path. Filling the shipping default would mean
206
+ // sending ten thousand blocked envelopes over TCP; a test that
207
+ // instead constructed a barrier and filled it directly would be
208
+ // measuring a component nothing runs, which is the defect this
209
+ // whole line of work exists to stop repeating.
210
+ "--max-pending-entries" => {
211
+ index += 1;
212
+ max_pending_entries = Some(
213
+ args.get(index)
214
+ .ok_or("--max-pending-entries requires a value")?
215
+ .parse::<usize>()
216
+ .map_err(|e| format!("--max-pending-entries: {e}"))?,
217
+ );
218
+ }
219
+ "--max-pending-bytes" => {
220
+ index += 1;
221
+ max_pending_bytes = Some(
222
+ args.get(index)
223
+ .ok_or("--max-pending-bytes requires a value")?
224
+ .parse::<usize>()
225
+ .map_err(|e| format!("--max-pending-bytes: {e}"))?,
226
+ );
227
+ }
146
228
  other => return Err(format!("unknown argument {other}").into()),
147
229
  }
148
230
  index += 1;
@@ -159,10 +241,45 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
159
241
  dir.join("operations.jsonl")
160
242
  });
161
243
 
244
+ // Membership lives beside the log and is loaded from disk, never rebuilt
245
+ // from --send/--recv. A node's connection flags say who it talks to; they
246
+ // say nothing about who is owed history, and conflating the two is what
247
+ // this store exists to stop.
248
+ let mut membership = MembershipStore::open(
249
+ data_dir
250
+ .as_ref()
251
+ .map(|dir| dir.join("membership.json"))
252
+ .unwrap_or_else(|| PathBuf::from("membership.json")),
253
+ )
254
+ .map_err(|e| e.to_string())?;
255
+
256
+ // Acknowledgements are durable and shared with the receive tasks, because
257
+ // they arrive on the socket rather than through a command.
258
+ let acknowledgements = Arc::new(Mutex::new(
259
+ AcknowledgementStore::open(
260
+ data_dir
261
+ .as_ref()
262
+ .map(|dir| dir.join("acknowledgements.json"))
263
+ .unwrap_or_else(|| PathBuf::from("acknowledgements.json")),
264
+ )
265
+ .map_err(|e| e.to_string())?,
266
+ ));
267
+
162
268
  let initial_state = StateHash::from_hex("0".repeat(64));
163
269
  let mut executor =
164
270
  DistributedTransactionExecutor::with_log(node_id.clone(), initial_state.clone(), log_path.clone())?;
165
271
 
272
+ // The bound is set before recovery, not after, because recovery restores
273
+ // the backlog and has to respect the same limit the live path does.
274
+ // Otherwise a node could restart into a backlog it would never have
275
+ // accepted while running, and the bound would hold only until the first
276
+ // crash.
277
+ let default_capacity = CausalCapacity::default();
278
+ executor.set_causal_capacity(CausalCapacity::new(
279
+ max_pending_entries.unwrap_or(default_capacity.max_pending_entries),
280
+ max_pending_bytes.unwrap_or(default_capacity.max_pending_bytes),
281
+ ));
282
+
166
283
  // Replaying the log restores the deduplication set, so a restarted node
167
284
  // does not re-apply operations it already durably recorded.
168
285
  let mut replayed = 0usize;
@@ -194,7 +311,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
194
311
  // interleave and chain onto a parent that is already stale.
195
312
  let core = Arc::new(Mutex::new((executor, StateVersion::new(VectorClock::new(), "0".repeat(64)))));
196
313
  let should_exit = Arc::new(AtomicBool::new(false));
197
- let sequence = Arc::new(Mutex::new(1u64));
314
+ // Per-peer delivery hold.
315
+ //
316
+ // `Some(queue)` means messages arriving from that peer are received off the
317
+ // socket as normal and parked instead of being handed to the executor.
318
+ // This is how a test produces out-of-causal-order arrival without faking
319
+ // the network: the bytes really do cross TCP, and only the order in which
320
+ // the receiver is offered them is controlled. A real network reorders
321
+ // delivery this way; the harness just does it on demand.
322
+ let held: Arc<Mutex<HashMap<String, Vec<ReplicationMessage>>>> =
323
+ Arc::new(Mutex::new(HashMap::new()));
324
+ // Peers this node has stopped reading from because the causal backlog is
325
+ // at its bound.
326
+ //
327
+ // This is the backpressure the bounding decision commits to, and not
328
+ // reading is the whole mechanism: the socket's receive buffer fills, the
329
+ // TCP window closes, and the sender blocks. Nothing is dropped and nothing
330
+ // is refused, so no retransmission protocol is needed -- which is good,
331
+ // because there isn't one.
332
+ let backpressured: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
198
333
  // Kept so `resend-last` can put the identical envelope back on the wire.
199
334
  let last_envelope: Arc<Mutex<Option<feltdb::distributed_transactions::TransactionEnvelope>>> =
200
335
  Arc::new(Mutex::new(None));
@@ -230,6 +365,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
230
365
  let core = core.clone();
231
366
  let exit = should_exit.clone();
232
367
  let peer = link.peer.clone();
368
+ let held = held.clone();
369
+ let backpressured = backpressured.clone();
370
+ let acknowledgements = acknowledgements.clone();
233
371
  tokio::spawn(async move {
234
372
  while !exit.load(Ordering::Relaxed) {
235
373
  {
@@ -247,22 +385,57 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
247
385
  if exit.load(Ordering::Relaxed) {
248
386
  return;
249
387
  }
388
+
389
+ // Do not read from a backpressured peer. While paused, keep
390
+ // trying to drain: the work that frees capacity may arrive
391
+ // on another connection, and a deferred envelope is already
392
+ // durable, so draining needs nothing from this socket.
393
+ while backpressured.lock().await.contains(&peer) {
394
+ if exit.load(Ordering::Relaxed) {
395
+ return;
396
+ }
397
+ {
398
+ let mut guard = core.lock().await;
399
+ let (executor, version) = &mut *guard;
400
+ let parent = version.clone();
401
+ let _ = executor.drain_backlog(parent);
402
+ if executor.pending_causal_count()
403
+ < executor.causal_capacity().max_pending_entries
404
+ {
405
+ backpressured.lock().await.remove(&peer);
406
+ }
407
+ }
408
+ sleep(Duration::from_millis(25)).await;
409
+ }
410
+
250
411
  match transport.receive().await {
251
412
  Ok(messages) => {
413
+ // Acknowledgements arrive interleaved with
414
+ // envelopes on the same connection. They are
415
+ // durable before they count as evidence.
416
+ for ack in transport.take_acknowledgements().await {
417
+ let mut store = acknowledgements.lock().await;
418
+ if let Err(error) = store.record(ack) {
419
+ eprintln!("acknowledgement not recorded: {error}");
420
+ }
421
+ }
252
422
  for message in messages {
253
- let mut guard = core.lock().await;
254
- let (executor, version) = &mut *guard;
255
- let origin = message.envelope.originating_node.clone();
256
- let parent = version.clone();
257
- // A duplicate is refused by the executor, which
258
- // is the behaviour under test, not an error
259
- // here — and a refused message must not advance
260
- // the version.
261
- if let Ok(applied) =
262
- executor.receive_replicated_transaction(message, parent)
263
423
  {
264
- version.state_hash = applied.to_string();
265
- version.vector_clock.increment(&origin);
424
+ let mut hold = held.lock().await;
425
+ if let Some(queue) = hold.get_mut(&peer) {
426
+ queue.push(message);
427
+ continue;
428
+ }
429
+ }
430
+ let outcome = {
431
+ let mut guard = core.lock().await;
432
+ let (executor, version) = &mut *guard;
433
+ deliver(executor, version, message)
434
+ };
435
+ if outcome.get("outcome").and_then(|value| value.as_str())
436
+ == Some("capacity_deferred")
437
+ {
438
+ backpressured.lock().await.insert(peer.clone());
266
439
  }
267
440
  }
268
441
  }
@@ -309,17 +482,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
309
482
  match verb {
310
483
  "execute-tx" => {
311
484
  let record = if argument.is_empty() { "item".to_string() } else { argument };
312
- let seq = {
313
- let mut guard = sequence.lock().await;
314
- let current = *guard;
315
- *guard += 1;
316
- current
317
- };
318
485
 
319
486
  let mut fields = HashMap::new();
320
487
  fields.insert("record".to_string(), json!(record.clone()));
321
488
 
322
- let parent = { core.lock().await.1.clone() };
489
+ // The identity comes from the executor, which derives it from the
490
+ // durable log. This binary used to keep its own counter,
491
+ // starting at 1 on every launch, so a restarted node re-issued
492
+ // identities its own earlier operations already held.
493
+ let (parent, seq) = {
494
+ let guard = core.lock().await;
495
+ (guard.1.clone(), guard.0.next_origin_sequence())
496
+ };
323
497
  let operation = Operation::new(
324
498
  OperationId::new(node_id.clone(), seq),
325
499
  parent.clone(),
@@ -336,14 +510,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
336
510
  let envelope = {
337
511
  let mut guard = core.lock().await;
338
512
  let (executor, version) = &mut *guard;
339
- let result = executor.execute_local_transaction(
340
- seq,
513
+ // The admitting entry point: local work is refused when the
514
+ // causal backlog is at its bound, before anything is
515
+ // executed or persisted, so a refusal leaves nothing to
516
+ // undo.
517
+ let result = executor.submit_local_transaction(
341
518
  format!("tx_{record}"),
342
519
  version.clone(),
343
520
  vec![operation],
344
521
  ConsistencyContract::local(),
345
522
  );
346
- if result.is_ok() {
523
+ if matches!(result, Ok(SubmitOutcome::Admitted(_))) {
347
524
  if let Some(replica) = executor.get_replica_state(&node_id) {
348
525
  version.state_hash = replica.state_hash.to_string();
349
526
  version.vector_clock.increment(&node_id);
@@ -353,7 +530,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
353
530
  };
354
531
 
355
532
  match envelope {
356
- Ok(envelope) => {
533
+ Ok(SubmitOutcome::CapacityExceeded {
534
+ pending_entries,
535
+ pending_bytes,
536
+ limit_entries,
537
+ limit_bytes,
538
+ }) => {
539
+ // A distinct outcome, not an error: the caller should
540
+ // retry, and nothing was admitted or lost.
541
+ println!(
542
+ "TX_REFUSED {}",
543
+ json!({
544
+ "record": record,
545
+ "reason": "CAPACITY_EXCEEDED",
546
+ "pending_entries": pending_entries,
547
+ "pending_bytes": pending_bytes,
548
+ "limit_entries": limit_entries,
549
+ "limit_bytes": limit_bytes,
550
+ })
551
+ );
552
+ }
553
+ Ok(SubmitOutcome::Admitted(envelope)) => {
357
554
  *last_envelope.lock().await = Some(envelope.clone());
358
555
  let mut delivered = Vec::new();
359
556
  let mut failed = Vec::new();
@@ -383,7 +580,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
383
580
  }
384
581
  println!(
385
582
  "TX_EXECUTED {}",
386
- json!({ "record": record, "sequence": seq, "delivered": delivered, "failed": failed })
583
+ json!({
584
+ "record": record,
585
+ // The identity the executor actually issued.
586
+ "sequence": envelope.envelope_id.sequence,
587
+ "delivered": delivered,
588
+ "failed": failed,
589
+ })
387
590
  );
388
591
  }
389
592
  Err(error) => println!("ERROR {}", json!({ "reason": error })),
@@ -397,6 +600,127 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
397
600
  // only interesting if the duplicate actually crosses the wire.
398
601
  // Issuing another `execute-tx` would not test it: that produces a
399
602
  // new envelope id and is a different transaction.
603
+ // `resend SEQUENCE` — put one specific envelope from this node's
604
+ // durable log back on the wire.
605
+ //
606
+ // A peer re-sending an operation another node is missing is the
607
+ // primitive catch-up is built from. This is not catch-up: nothing
608
+ // decides *what* to send, a test does. It exists so the receiver's
609
+ // behaviour can be exercised on a gap that a restart left behind.
610
+ // `forward ORIGIN SEQUENCE` -- put an envelope this node holds, but
611
+ // did not originate, back on the wire.
612
+ //
613
+ // `resend` deliberately only replays this node's own envelopes. A
614
+ // peer relaying an operation that a *third* node is missing is a
615
+ // different primitive, and it is the one a backpressured receiver
616
+ // needs: when a node stops reading from the peer whose envelopes it
617
+ // deferred, the dependency it is waiting for cannot arrive on that
618
+ // link. It has to come from somewhere else, which is precisely the
619
+ // case the read loop's keep-draining-while-paused behaviour exists
620
+ // to serve.
621
+ "forward" => {
622
+ let mut parts = argument.split_whitespace();
623
+ let origin = parts.next().unwrap_or_default().to_string();
624
+ let wanted: Option<u64> = parts.next().and_then(|value| value.parse().ok());
625
+ let envelope = {
626
+ let guard = core.lock().await;
627
+ let (executor, _) = &*guard;
628
+ executor.operation_log.as_ref().and_then(|log| {
629
+ log.load_all().ok().and_then(|envelopes| {
630
+ envelopes.into_iter().find(|candidate| {
631
+ candidate.envelope_id.originating_node == origin
632
+ && Some(candidate.envelope_id.sequence) == wanted
633
+ })
634
+ })
635
+ })
636
+ };
637
+ match envelope {
638
+ Some(envelope) => {
639
+ let mut delivered = Vec::new();
640
+ for link in &send_links {
641
+ let message = ReplicationMessage::new(
642
+ envelope.clone(),
643
+ node_id.clone(),
644
+ link.peer.clone(),
645
+ envelope.envelope_id.sequence,
646
+ );
647
+ if link.io.send(message.clone()).await.is_ok() {
648
+ delivered.push(link.peer.clone());
649
+ } else if connect_with_retry(link, 3).await
650
+ && link.io.send(message).await.is_ok()
651
+ {
652
+ delivered.push(link.peer.clone());
653
+ }
654
+ }
655
+ println!(
656
+ "FORWARDED {}",
657
+ json!({
658
+ "origin": origin,
659
+ "sequence": wanted,
660
+ "delivered": delivered,
661
+ })
662
+ );
663
+ }
664
+ None => println!(
665
+ "FORWARDED {}",
666
+ json!({ "origin": origin, "sequence": wanted, "delivered": [], "found": false })
667
+ ),
668
+ }
669
+ io::stdout().flush().ok();
670
+ }
671
+
672
+ "resend" => {
673
+ let wanted: Option<u64> = argument.trim().parse().ok();
674
+ let envelope = {
675
+ let guard = core.lock().await;
676
+ let (executor, _) = &*guard;
677
+ executor.operation_log.as_ref().and_then(|log| {
678
+ log.load_all().ok().and_then(|envelopes| {
679
+ envelopes.into_iter().find(|candidate| {
680
+ candidate.envelope_id.originating_node == node_id
681
+ && Some(candidate.envelope_id.sequence) == wanted
682
+ })
683
+ })
684
+ })
685
+ };
686
+ match envelope {
687
+ Some(envelope) => {
688
+ let mut delivered = Vec::new();
689
+ for link in &send_links {
690
+ let message = ReplicationMessage::new(
691
+ envelope.clone(),
692
+ node_id.clone(),
693
+ link.peer.clone(),
694
+ envelope.envelope_id.sequence,
695
+ );
696
+ // The peer this is aimed at may have restarted
697
+ // since the link was established, which is the
698
+ // whole point of re-sending. Reconnect and retry,
699
+ // as `execute-tx` does.
700
+ match link.io.send(message.clone()).await {
701
+ Ok(()) => delivered.push(link.peer.clone()),
702
+ Err(_) => {
703
+ if connect_with_retry(link, 3).await
704
+ && link.io.send(message).await.is_ok()
705
+ {
706
+ delivered.push(link.peer.clone());
707
+ }
708
+ }
709
+ }
710
+ }
711
+ println!(
712
+ "RESENT {}",
713
+ json!({ "envelope": envelope.envelope_id, "delivered": delivered })
714
+ );
715
+ }
716
+ None => println!(
717
+ "ERROR {}",
718
+ json!({ "reason": format!("no envelope {argument} in this node's log") })
719
+ ),
720
+ }
721
+ io::stdout().flush().ok();
722
+ }
723
+
400
724
  "resend-last" => {
401
725
  let envelope = last_envelope.lock().await.clone();
402
726
  match envelope {
@@ -426,12 +750,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
426
750
  "query-state" => {
427
751
  let guard = core.lock().await;
428
752
  let (executor, _) = &*guard;
753
+ let canonical = executor
754
+ .canonical_state()
755
+ .map(|state| state.hash().to_string())
756
+ .unwrap_or_else(|error| format!("error: {error}"));
429
757
  match executor.get_replica_state(&node_id) {
430
758
  Some(replica) => println!(
431
759
  "STATE {}",
432
760
  json!({
433
761
  "hash": replica.state_hash.to_string(),
434
762
  "operations_applied": replica.operations_applied,
763
+ // Order-independent, and therefore the one to
764
+ // compare across replicas. The chained hash above
765
+ // detects a missing operation in an ordered
766
+ // history; it calls two nodes that applied the same
767
+ // concurrent operations in different orders
768
+ // divergent, which they are not.
769
+ "canonical_hash": canonical,
435
770
  })
436
771
  ),
437
772
  None => println!("ERROR {}", json!({ "reason": "no local replica" })),
@@ -439,6 +774,257 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
439
774
  io::stdout().flush().ok();
440
775
  }
441
776
 
777
+ // What the causal receiver is holding, and why.
778
+ // Membership commands. Deliberately no compaction frontier and no
779
+ // acknowledgement tracking: this establishes who exists and who is
780
+ // required, and reclaims nothing.
781
+ "cluster-create" => {
782
+ let result = membership.create_cluster(argument.trim());
783
+ println!(
784
+ "CLUSTER {}",
785
+ match result {
786
+ Ok(()) => json!({ "cluster_id": argument.trim(), "created": true }),
787
+ Err(error) => json!({ "created": false, "error": error.to_string() }),
788
+ }
789
+ );
790
+ io::stdout().flush().ok();
791
+ }
792
+
793
+ "replica-join" | "replica-approve" | "replica-suspect" | "replica-seen"
794
+ | "replica-remove" => {
795
+ let mut parts = argument.split_whitespace();
796
+ let replica = parts.next().unwrap_or_default().to_string();
797
+ let result = match verb {
798
+ "replica-join" => {
799
+ let node = parts.next().unwrap_or(&replica).to_string();
800
+ membership.join_replica(&replica, &node)
801
+ }
802
+ "replica-approve" => membership.approve_replica(&replica),
803
+ "replica-suspect" => membership.suspect_replica(&replica),
804
+ "replica-seen" => membership.mark_seen(&replica),
805
+ _ => membership.remove_replica(&replica),
806
+ };
807
+ println!(
808
+ "REPLICA {}",
809
+ match result {
810
+ Ok(()) => json!({ "replica_id": replica, "action": verb, "ok": true }),
811
+ Err(error) =>
812
+ json!({ "replica_id": replica, "action": verb, "ok": false, "error": error.to_string() }),
813
+ }
814
+ );
815
+ io::stdout().flush().ok();
816
+ }
817
+
818
+ // Report what this node has durably applied, to every peer.
819
+ //
820
+ // The frontier is read from the executor, where it advances only in
821
+ // record_applied -- after an fsynced envelope is applied. So the
822
+ // statement "durably applied through this frontier" is true by
823
+ // construction rather than by assertion.
824
+ "ack-broadcast" => {
825
+ let (frontier, marks) = {
826
+ let guard = core.lock().await;
827
+ let (executor, _) = &*guard;
828
+ (executor.causal_frontier().clone(), executor.origin_sequence_high_water())
829
+ };
830
+ let view: Vec<String> = membership
831
+ .get_members()
832
+ .into_iter()
833
+ .map(|replica| replica.replica_id)
834
+ .collect();
835
+ let replica_id = membership
836
+ .get_members()
837
+ .into_iter()
838
+ .find(|replica| replica.node_id == node_id)
839
+ .map(|replica| replica.replica_id)
840
+ .unwrap_or_else(|| node_id.clone());
841
+
842
+ let ack = ReplicaAcknowledgement::new(
843
+ replica_id.clone(),
844
+ node_id.clone(),
845
+ frontier,
846
+ marks,
847
+ view,
848
+ );
849
+
850
+ // A node's own acknowledgement is evidence it holds first-hand:
851
+ // it knows its own durable frontier without being told. Without
852
+ // this a node would be permanently blocked on itself, since
853
+ // nothing else can report on its behalf.
854
+ {
855
+ let mut store = acknowledgements.lock().await;
856
+ let _ = store.record(ack.clone());
857
+ }
858
+
859
+ let mut delivered = Vec::new();
860
+ for link in &send_links {
861
+ if link.io.send_acknowledgement(ack.clone()).await.is_ok() {
862
+ delivered.push(link.peer.clone());
863
+ } else if connect_with_retry(link, 3).await
864
+ && link.io.send_acknowledgement(ack.clone()).await.is_ok()
865
+ {
866
+ delivered.push(link.peer.clone());
867
+ }
868
+ }
869
+ println!(
870
+ "ACK_SENT {}",
871
+ json!({
872
+ "replica_id": replica_id,
873
+ "frontier": ack.applied_frontier.clocks,
874
+ "delivered": delivered,
875
+ })
876
+ );
877
+ io::stdout().flush().ok();
878
+ }
879
+
880
+ "acknowledgements" => {
881
+ let store = acknowledgements.lock().await;
882
+ println!("ACKS {}", json!({ "acknowledgements": store.all() }));
883
+ io::stdout().flush().ok();
884
+ }
885
+
886
+ // What this node's evidence permits. Computes; reclaims nothing.
887
+ "reclamation-evidence" => {
888
+ let local: Vec<String> = membership
889
+ .get_required_replicas()
890
+ .into_iter()
891
+ .map(|replica| replica.replica_id)
892
+ .collect();
893
+ let store = acknowledgements.lock().await;
894
+ let report = match store.evidence(&local) {
895
+ ReclamationEvidence::Blocked { waiting_on } => json!({
896
+ "state": "BLOCKED",
897
+ "waiting_on": waiting_on,
898
+ "required_union": store.required_union(&local),
899
+ }),
900
+ ReclamationEvidence::SafeThrough { frontier, covered } => json!({
901
+ "state": "SAFE_THROUGH",
902
+ "frontier": frontier.clocks,
903
+ "covered": covered,
904
+ "required_union": store.required_union(&local),
905
+ }),
906
+ };
907
+ println!("EVIDENCE {}", report);
908
+ io::stdout().flush().ok();
909
+ }
910
+
911
+ "members" => {
912
+ println!(
913
+ "MEMBERS {}",
914
+ json!({
915
+ "cluster_id": membership.cluster().map(|c| c.cluster_id.clone()),
916
+ "members": membership.get_members(),
917
+ "all": membership.all_replicas(),
918
+ })
919
+ );
920
+ io::stdout().flush().ok();
921
+ }
922
+
923
+ "required-replicas" => {
924
+ println!(
925
+ "REQUIRED {}",
926
+ json!({ "required": membership.get_required_replicas() })
927
+ );
928
+ io::stdout().flush().ok();
929
+ }
930
+
931
+ "causal-state" => {
932
+ let guard = core.lock().await;
933
+ let (executor, _) = &*guard;
934
+ let paused = {
935
+ let mut peers: Vec<String> =
936
+ backpressured.lock().await.iter().cloned().collect();
937
+ peers.sort();
938
+ peers
939
+ };
940
+ println!(
941
+ "CAUSAL {}",
942
+ json!({
943
+ "frontier": executor.causal_frontier().clocks,
944
+ "pending": executor.pending_causal_keys(),
945
+ "pending_count": executor.pending_causal_count(),
946
+ "pending_metadata_bytes": executor.pending_causal_metadata_bytes(),
947
+ // Occupancy against the bound that is now enforced.
948
+ // Current and maximum, for both dimensions, because a
949
+ // backlog that filled and drained between two reads is
950
+ // invisible to a current-value gauge.
951
+ "pending_payload_bytes": executor.pending_causal_payload_bytes(),
952
+ "deferred_count": executor.deferred_causal_count(),
953
+ "deferred": executor.deferred_causal_keys(),
954
+ "max_pending_entries_seen": executor.max_causal_occupancy().0,
955
+ "max_pending_payload_bytes_seen": executor.max_causal_occupancy().1,
956
+ "limit_entries": executor.causal_capacity().max_pending_entries,
957
+ "limit_bytes": executor.causal_capacity().max_pending_bytes,
958
+ // Peers this node has stopped reading from. This is the
959
+ // observable for backpressure: the pause lives in the
960
+ // socket read loop, so without it a test can only infer
961
+ // the behaviour from timing.
962
+ //
963
+ // Locked after `core`, matching the order the read loop
964
+ // takes them in.
965
+ "backpressured": paused,
966
+ })
967
+ );
968
+ io::stdout().flush().ok();
969
+ }
970
+
971
+ // Park messages from a peer instead of delivering them, so a test
972
+ // can choose the order the receiver is offered them in.
973
+ "hold-inbound" => {
974
+ let mut hold = held.lock().await;
975
+ hold.entry(argument.clone()).or_default();
976
+ println!("HELD {}", json!({ "peer": argument }));
977
+ io::stdout().flush().ok();
978
+ }
979
+
980
+ // Deliver what was parked, optionally newest-first, then resume
981
+ // normal delivery. Each message goes through the same production
982
+ // receiver as an unheld one.
983
+ // `release-inbound PEER [reverse] [COUNT]`
984
+ //
985
+ // COUNT releases only that many and keeps holding the rest, which
986
+ // is what lets a test offer an operation while deliberately
987
+ // withholding the dependency it needs.
988
+ "release-inbound" => {
989
+ let mut parts = argument.split_whitespace();
990
+ let peer = parts.next().unwrap_or_default().to_string();
991
+ let mut reverse = false;
992
+ let mut limit: Option<usize> = None;
993
+ for token in parts {
994
+ if token == "reverse" {
995
+ reverse = true;
996
+ } else if let Ok(value) = token.parse::<usize>() {
997
+ limit = Some(value);
998
+ }
999
+ }
1000
+
1001
+ let mut queued = { held.lock().await.remove(&peer).unwrap_or_default() };
1002
+ if reverse {
1003
+ queued.reverse();
1004
+ }
1005
+ let take = limit.unwrap_or(queued.len()).min(queued.len());
1006
+ let remainder = queued.split_off(take);
1007
+
1008
+ let mut outcomes = Vec::new();
1009
+ for message in queued {
1010
+ let mut guard = core.lock().await;
1011
+ let (executor, version) = &mut *guard;
1012
+ outcomes.push(deliver(executor, version, message));
1013
+ }
1014
+
1015
+ // Anything not released stays held, so the hold is still in
1016
+ // force for messages that arrive afterwards.
1017
+ if !remainder.is_empty() || limit.is_some() {
1018
+ held.lock().await.insert(peer.clone(), remainder);
1019
+ }
1020
+
1021
+ println!(
1022
+ "RELEASED {}",
1023
+ json!({ "peer": peer, "reverse": reverse, "outcomes": outcomes })
1024
+ );
1025
+ io::stdout().flush().ok();
1026
+ }
1027
+
442
1028
  // The proof that the network carried the work. Counters come from
443
1029
  // the transport itself, so a test can assert bytes actually moved
444
1030
  // rather than inferring it from converged state.