@feltdb/core 0.6.13 → 0.6.14

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 (63) hide show
  1. package/dist/agent-registry.js +1 -3
  2. package/dist/agent-runtime.js +8 -7
  3. package/dist/analytics-backend.js +3 -1
  4. package/dist/application-contract.js +1 -0
  5. package/dist/application-manifest.js +1 -0
  6. package/dist/artifact.js +2 -0
  7. package/dist/authorization.js +2 -0
  8. package/dist/bundle.js +2 -0
  9. package/dist/capability.js +1 -3
  10. package/dist/cell.js +9 -4
  11. package/dist/cli/commands.js +26 -2
  12. package/dist/cli/index.js +1 -1
  13. package/dist/collection.js +39 -31
  14. package/dist/create/package-versions.js +1 -1
  15. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +497 -27
  16. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +452 -0
  17. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +208 -0
  18. package/dist/create/server-source/crates/feltdb/src/convergence.rs +16 -0
  19. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +402 -0
  20. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +765 -24
  21. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +418 -0
  22. package/dist/create/server-source/crates/feltdb/src/lib.rs +4 -0
  23. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +661 -0
  24. package/dist/db.js +19 -13
  25. package/dist/development-runtime-bridge.js +1 -1
  26. package/dist/distributed-indexing.js +7 -5
  27. package/dist/file-db.js +8 -3
  28. package/dist/flowspec.js +2 -1
  29. package/dist/http-client.js +2 -0
  30. package/dist/http-db.js +16 -1
  31. package/dist/identity.js +1 -0
  32. package/dist/index-analytics.js +6 -7
  33. package/dist/index-backend.js +3 -3
  34. package/dist/index-dashboard.js +10 -13
  35. package/dist/index-manager.js +12 -11
  36. package/dist/index-monitoring.js +9 -4
  37. package/dist/index-store.js +2 -0
  38. package/dist/indexeddb-db.js +27 -23
  39. package/dist/memory-db.js +7 -4
  40. package/dist/observe.js +2 -0
  41. package/dist/provider.js +2 -0
  42. package/dist/query-planner.js +2 -4
  43. package/dist/reactive-graph.js +6 -8
  44. package/dist/release.js +2 -0
  45. package/dist/sharding.js +11 -6
  46. package/dist/state-contract.js +3 -3
  47. package/dist/studio-app/assets/{feltdb_wasm-CJv3wHzi.js → feltdb_wasm-C9xpYtna.js} +1 -1
  48. package/dist/studio-app/assets/feltdb_wasm_bg-BsXHw7eX.wasm +0 -0
  49. package/dist/studio-app/assets/{index-DospFFYE.js → index-D4RZ44qs.js} +4 -4
  50. package/dist/studio-app/index.html +1 -1
  51. package/dist/sync-contract.js +9 -2
  52. package/dist/telemetry.d.ts.map +1 -1
  53. package/dist/telemetry.js +32 -12
  54. package/dist/transaction.js +4 -2
  55. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  56. package/dist/worker.js +2 -0
  57. package/dist/workload.js +2 -0
  58. package/dist/workspace/development-node.js +11 -10
  59. package/dist/workspace/investigation-lifecycle-manager.js +2 -0
  60. package/dist/workspace/investigation-supervisor.js +5 -3
  61. package/dist/workspace/workspace-connection.js +14 -5
  62. package/package.json +1 -1
  63. package/dist/studio-app/assets/feltdb_wasm_bg-C8TG8r2n.wasm +0 -0
@@ -37,20 +37,28 @@
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_membership::MembershipStore;
47
55
  use feltdb::state_hash::StateHash;
48
56
  use feltdb::tcp_transport::TcpTransport;
49
57
  use feltdb::transactions::{
50
58
  ConsistencyContract, Operation, OperationCommand, OperationId, StateVersion,
51
59
  };
52
60
  use serde_json::json;
53
- use std::collections::HashMap;
61
+ use std::collections::{HashMap, HashSet};
54
62
  use std::io::{self, BufRead, Write};
55
63
  use std::path::PathBuf;
56
64
  use std::sync::atomic::{AtomicBool, Ordering};
@@ -86,6 +94,53 @@ struct RecvLink {
86
94
  io: TcpTransport,
87
95
  }
88
96
 
97
+ /// Hand one replicated message to the executor and record what it decided.
98
+ ///
99
+ /// The harness makes no ordering decision of its own here. It calls the same
100
+ /// `receive_replicated` the shipping receive path calls, and reports the typed
101
+ /// outcome back so a test can assert on "applied" versus "held back" rather
102
+ /// than inferring it from state.
103
+ fn deliver(
104
+ executor: &mut DistributedTransactionExecutor,
105
+ version: &mut StateVersion,
106
+ message: ReplicationMessage,
107
+ ) -> serde_json::Value {
108
+ let origin = message.envelope.originating_node.clone();
109
+ let key = format!(
110
+ "{}:{}",
111
+ message.envelope.envelope_id.originating_node, message.envelope.envelope_id.sequence
112
+ );
113
+ let parent = version.clone();
114
+ match executor.receive_replicated(message, parent) {
115
+ Ok(ReceiveOutcome::Applied { state_hash }) => {
116
+ // Only a real application advances the chained version, so a held
117
+ // or duplicated message cannot make the node look like it moved.
118
+ version.state_hash = state_hash.to_string();
119
+ version.vector_clock.increment(&origin);
120
+ json!({ "envelope": key, "outcome": "applied" })
121
+ }
122
+ Ok(ReceiveOutcome::PendingDependencies { missing }) => {
123
+ json!({ "envelope": key, "outcome": "pending", "missing": missing })
124
+ }
125
+ Ok(ReceiveOutcome::AlreadyKnown) => {
126
+ json!({ "envelope": key, "outcome": "already_known" })
127
+ }
128
+ Ok(ReceiveOutcome::CapacityDeferred { missing, pending_entries, pending_bytes }) => {
129
+ // Durable, but not buffered. The caller's obligation is to stop
130
+ // reading from this peer; the envelope is admitted from the log
131
+ // once the backlog drains.
132
+ json!({
133
+ "envelope": key,
134
+ "outcome": "capacity_deferred",
135
+ "missing": missing,
136
+ "pending_entries": pending_entries,
137
+ "pending_bytes": pending_bytes,
138
+ })
139
+ }
140
+ Err(error) => json!({ "envelope": key, "outcome": "error", "error": error }),
141
+ }
142
+ }
143
+
89
144
  fn parse_link(value: &str) -> Result<(String, String), String> {
90
145
  let (peer, addr) = value
91
146
  .split_once('=')
@@ -118,6 +173,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
118
173
  let mut data_dir: Option<PathBuf> = None;
119
174
  let mut send_specs: Vec<(String, String)> = Vec::new();
120
175
  let mut recv_specs: Vec<(String, String)> = Vec::new();
176
+ let mut max_pending_entries: Option<usize> = None;
177
+ let mut max_pending_bytes: Option<usize> = None;
121
178
 
122
179
  let mut index = 1;
123
180
  while index < args.len() {
@@ -143,6 +200,30 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
143
200
  index += 1;
144
201
  recv_specs.push(parse_link(args.get(index).ok_or("--recv requires PEER=ADDR")?)?);
145
202
  }
203
+ // The bound is settable so an acceptance test can reach it through
204
+ // the production path. Filling the shipping default would mean
205
+ // sending ten thousand blocked envelopes over TCP; a test that
206
+ // instead constructed a barrier and filled it directly would be
207
+ // measuring a component nothing runs, which is the defect this
208
+ // whole line of work exists to stop repeating.
209
+ "--max-pending-entries" => {
210
+ index += 1;
211
+ max_pending_entries = Some(
212
+ args.get(index)
213
+ .ok_or("--max-pending-entries requires a value")?
214
+ .parse::<usize>()
215
+ .map_err(|e| format!("--max-pending-entries: {e}"))?,
216
+ );
217
+ }
218
+ "--max-pending-bytes" => {
219
+ index += 1;
220
+ max_pending_bytes = Some(
221
+ args.get(index)
222
+ .ok_or("--max-pending-bytes requires a value")?
223
+ .parse::<usize>()
224
+ .map_err(|e| format!("--max-pending-bytes: {e}"))?,
225
+ );
226
+ }
146
227
  other => return Err(format!("unknown argument {other}").into()),
147
228
  }
148
229
  index += 1;
@@ -159,10 +240,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
159
240
  dir.join("operations.jsonl")
160
241
  });
161
242
 
243
+ // Membership lives beside the log and is loaded from disk, never rebuilt
244
+ // from --send/--recv. A node's connection flags say who it talks to; they
245
+ // say nothing about who is owed history, and conflating the two is what
246
+ // this store exists to stop.
247
+ let mut membership = MembershipStore::open(
248
+ data_dir
249
+ .as_ref()
250
+ .map(|dir| dir.join("membership.json"))
251
+ .unwrap_or_else(|| PathBuf::from("membership.json")),
252
+ )
253
+ .map_err(|e| e.to_string())?;
254
+
162
255
  let initial_state = StateHash::from_hex("0".repeat(64));
163
256
  let mut executor =
164
257
  DistributedTransactionExecutor::with_log(node_id.clone(), initial_state.clone(), log_path.clone())?;
165
258
 
259
+ // The bound is set before recovery, not after, because recovery restores
260
+ // the backlog and has to respect the same limit the live path does.
261
+ // Otherwise a node could restart into a backlog it would never have
262
+ // accepted while running, and the bound would hold only until the first
263
+ // crash.
264
+ let default_capacity = CausalCapacity::default();
265
+ executor.set_causal_capacity(CausalCapacity::new(
266
+ max_pending_entries.unwrap_or(default_capacity.max_pending_entries),
267
+ max_pending_bytes.unwrap_or(default_capacity.max_pending_bytes),
268
+ ));
269
+
166
270
  // Replaying the log restores the deduplication set, so a restarted node
167
271
  // does not re-apply operations it already durably recorded.
168
272
  let mut replayed = 0usize;
@@ -194,7 +298,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
194
298
  // interleave and chain onto a parent that is already stale.
195
299
  let core = Arc::new(Mutex::new((executor, StateVersion::new(VectorClock::new(), "0".repeat(64)))));
196
300
  let should_exit = Arc::new(AtomicBool::new(false));
197
- let sequence = Arc::new(Mutex::new(1u64));
301
+ // Per-peer delivery hold.
302
+ //
303
+ // `Some(queue)` means messages arriving from that peer are received off the
304
+ // socket as normal and parked instead of being handed to the executor.
305
+ // This is how a test produces out-of-causal-order arrival without faking
306
+ // the network: the bytes really do cross TCP, and only the order in which
307
+ // the receiver is offered them is controlled. A real network reorders
308
+ // delivery this way; the harness just does it on demand.
309
+ let held: Arc<Mutex<HashMap<String, Vec<ReplicationMessage>>>> =
310
+ Arc::new(Mutex::new(HashMap::new()));
311
+ // Peers this node has stopped reading from because the causal backlog is
312
+ // at its bound.
313
+ //
314
+ // This is the backpressure the bounding decision commits to, and not
315
+ // reading is the whole mechanism: the socket's receive buffer fills, the
316
+ // TCP window closes, and the sender blocks. Nothing is dropped and nothing
317
+ // is refused, so no retransmission protocol is needed -- which is good,
318
+ // because there isn't one.
319
+ let backpressured: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
198
320
  // Kept so `resend-last` can put the identical envelope back on the wire.
199
321
  let last_envelope: Arc<Mutex<Option<feltdb::distributed_transactions::TransactionEnvelope>>> =
200
322
  Arc::new(Mutex::new(None));
@@ -230,6 +352,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
230
352
  let core = core.clone();
231
353
  let exit = should_exit.clone();
232
354
  let peer = link.peer.clone();
355
+ let held = held.clone();
356
+ let backpressured = backpressured.clone();
233
357
  tokio::spawn(async move {
234
358
  while !exit.load(Ordering::Relaxed) {
235
359
  {
@@ -247,22 +371,48 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
247
371
  if exit.load(Ordering::Relaxed) {
248
372
  return;
249
373
  }
374
+
375
+ // Do not read from a backpressured peer. While paused, keep
376
+ // trying to drain: the work that frees capacity may arrive
377
+ // on another connection, and a deferred envelope is already
378
+ // durable, so draining needs nothing from this socket.
379
+ while backpressured.lock().await.contains(&peer) {
380
+ if exit.load(Ordering::Relaxed) {
381
+ return;
382
+ }
383
+ {
384
+ let mut guard = core.lock().await;
385
+ let (executor, version) = &mut *guard;
386
+ let parent = version.clone();
387
+ let _ = executor.drain_backlog(parent);
388
+ if executor.pending_causal_count()
389
+ < executor.causal_capacity().max_pending_entries
390
+ {
391
+ backpressured.lock().await.remove(&peer);
392
+ }
393
+ }
394
+ sleep(Duration::from_millis(25)).await;
395
+ }
396
+
250
397
  match transport.receive().await {
251
398
  Ok(messages) => {
252
399
  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
400
  {
264
- version.state_hash = applied.to_string();
265
- version.vector_clock.increment(&origin);
401
+ let mut hold = held.lock().await;
402
+ if let Some(queue) = hold.get_mut(&peer) {
403
+ queue.push(message);
404
+ continue;
405
+ }
406
+ }
407
+ let outcome = {
408
+ let mut guard = core.lock().await;
409
+ let (executor, version) = &mut *guard;
410
+ deliver(executor, version, message)
411
+ };
412
+ if outcome.get("outcome").and_then(|value| value.as_str())
413
+ == Some("capacity_deferred")
414
+ {
415
+ backpressured.lock().await.insert(peer.clone());
266
416
  }
267
417
  }
268
418
  }
@@ -309,17 +459,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
309
459
  match verb {
310
460
  "execute-tx" => {
311
461
  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
462
 
319
463
  let mut fields = HashMap::new();
320
464
  fields.insert("record".to_string(), json!(record.clone()));
321
465
 
322
- let parent = { core.lock().await.1.clone() };
466
+ // The identity comes from the executor, which derives it from the
467
+ // durable log. This binary used to keep its own counter,
468
+ // starting at 1 on every launch, so a restarted node re-issued
469
+ // identities its own earlier operations already held.
470
+ let (parent, seq) = {
471
+ let guard = core.lock().await;
472
+ (guard.1.clone(), guard.0.next_origin_sequence())
473
+ };
323
474
  let operation = Operation::new(
324
475
  OperationId::new(node_id.clone(), seq),
325
476
  parent.clone(),
@@ -336,14 +487,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
336
487
  let envelope = {
337
488
  let mut guard = core.lock().await;
338
489
  let (executor, version) = &mut *guard;
339
- let result = executor.execute_local_transaction(
340
- seq,
490
+ // The admitting entry point: local work is refused when the
491
+ // causal backlog is at its bound, before anything is
492
+ // executed or persisted, so a refusal leaves nothing to
493
+ // undo.
494
+ let result = executor.submit_local_transaction(
341
495
  format!("tx_{record}"),
342
496
  version.clone(),
343
497
  vec![operation],
344
498
  ConsistencyContract::local(),
345
499
  );
346
- if result.is_ok() {
500
+ if matches!(result, Ok(SubmitOutcome::Admitted(_))) {
347
501
  if let Some(replica) = executor.get_replica_state(&node_id) {
348
502
  version.state_hash = replica.state_hash.to_string();
349
503
  version.vector_clock.increment(&node_id);
@@ -353,7 +507,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
353
507
  };
354
508
 
355
509
  match envelope {
356
- Ok(envelope) => {
510
+ Ok(SubmitOutcome::CapacityExceeded {
511
+ pending_entries,
512
+ pending_bytes,
513
+ limit_entries,
514
+ limit_bytes,
515
+ }) => {
516
+ // A distinct outcome, not an error: the caller should
517
+ // retry, and nothing was admitted or lost.
518
+ println!(
519
+ "TX_REFUSED {}",
520
+ json!({
521
+ "record": record,
522
+ "reason": "CAPACITY_EXCEEDED",
523
+ "pending_entries": pending_entries,
524
+ "pending_bytes": pending_bytes,
525
+ "limit_entries": limit_entries,
526
+ "limit_bytes": limit_bytes,
527
+ })
528
+ );
529
+ }
530
+ Ok(SubmitOutcome::Admitted(envelope)) => {
357
531
  *last_envelope.lock().await = Some(envelope.clone());
358
532
  let mut delivered = Vec::new();
359
533
  let mut failed = Vec::new();
@@ -383,7 +557,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
383
557
  }
384
558
  println!(
385
559
  "TX_EXECUTED {}",
386
- json!({ "record": record, "sequence": seq, "delivered": delivered, "failed": failed })
560
+ json!({
561
+ "record": record,
562
+ // The identity the executor actually issued.
563
+ "sequence": envelope.envelope_id.sequence,
564
+ "delivered": delivered,
565
+ "failed": failed,
566
+ })
387
567
  );
388
568
  }
389
569
  Err(error) => println!("ERROR {}", json!({ "reason": error })),
@@ -397,6 +577,127 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
397
577
  // only interesting if the duplicate actually crosses the wire.
398
578
  // Issuing another `execute-tx` would not test it: that produces a
399
579
  // new envelope id and is a different transaction.
580
+ // `resend SEQUENCE` — put one specific envelope from this node's
581
+ // durable log back on the wire.
582
+ //
583
+ // A peer re-sending an operation another node is missing is the
584
+ // primitive catch-up is built from. This is not catch-up: nothing
585
+ // decides *what* to send, a test does. It exists so the receiver's
586
+ // behaviour can be exercised on a gap that a restart left behind.
587
+ // `forward ORIGIN SEQUENCE` -- put an envelope this node holds, but
588
+ // did not originate, back on the wire.
589
+ //
590
+ // `resend` deliberately only replays this node's own envelopes. A
591
+ // peer relaying an operation that a *third* node is missing is a
592
+ // different primitive, and it is the one a backpressured receiver
593
+ // needs: when a node stops reading from the peer whose envelopes it
594
+ // deferred, the dependency it is waiting for cannot arrive on that
595
+ // link. It has to come from somewhere else, which is precisely the
596
+ // case the read loop's keep-draining-while-paused behaviour exists
597
+ // to serve.
598
+ "forward" => {
599
+ let mut parts = argument.split_whitespace();
600
+ let origin = parts.next().unwrap_or_default().to_string();
601
+ let wanted: Option<u64> = parts.next().and_then(|value| value.parse().ok());
602
+ let envelope = {
603
+ let guard = core.lock().await;
604
+ let (executor, _) = &*guard;
605
+ executor.operation_log.as_ref().and_then(|log| {
606
+ log.load_all().ok().and_then(|envelopes| {
607
+ envelopes.into_iter().find(|candidate| {
608
+ candidate.envelope_id.originating_node == origin
609
+ && Some(candidate.envelope_id.sequence) == wanted
610
+ })
611
+ })
612
+ })
613
+ };
614
+ match envelope {
615
+ Some(envelope) => {
616
+ let mut delivered = Vec::new();
617
+ for link in &send_links {
618
+ let message = ReplicationMessage::new(
619
+ envelope.clone(),
620
+ node_id.clone(),
621
+ link.peer.clone(),
622
+ envelope.envelope_id.sequence,
623
+ );
624
+ if link.io.send(message.clone()).await.is_ok() {
625
+ delivered.push(link.peer.clone());
626
+ } else if connect_with_retry(link, 3).await
627
+ && link.io.send(message).await.is_ok()
628
+ {
629
+ delivered.push(link.peer.clone());
630
+ }
631
+ }
632
+ println!(
633
+ "FORWARDED {}",
634
+ json!({
635
+ "origin": origin,
636
+ "sequence": wanted,
637
+ "delivered": delivered,
638
+ })
639
+ );
640
+ }
641
+ None => println!(
642
+ "FORWARDED {}",
643
+ json!({ "origin": origin, "sequence": wanted, "delivered": [], "found": false })
644
+ ),
645
+ }
646
+ io::stdout().flush().ok();
647
+ }
648
+
649
+ "resend" => {
650
+ let wanted: Option<u64> = argument.trim().parse().ok();
651
+ let envelope = {
652
+ let guard = core.lock().await;
653
+ let (executor, _) = &*guard;
654
+ executor.operation_log.as_ref().and_then(|log| {
655
+ log.load_all().ok().and_then(|envelopes| {
656
+ envelopes.into_iter().find(|candidate| {
657
+ candidate.envelope_id.originating_node == node_id
658
+ && Some(candidate.envelope_id.sequence) == wanted
659
+ })
660
+ })
661
+ })
662
+ };
663
+ match envelope {
664
+ Some(envelope) => {
665
+ let mut delivered = Vec::new();
666
+ for link in &send_links {
667
+ let message = ReplicationMessage::new(
668
+ envelope.clone(),
669
+ node_id.clone(),
670
+ link.peer.clone(),
671
+ envelope.envelope_id.sequence,
672
+ );
673
+ // The peer this is aimed at may have restarted
674
+ // since the link was established, which is the
675
+ // whole point of re-sending. Reconnect and retry,
676
+ // as `execute-tx` does.
677
+ match link.io.send(message.clone()).await {
678
+ Ok(()) => delivered.push(link.peer.clone()),
679
+ Err(_) => {
680
+ if connect_with_retry(link, 3).await
681
+ && link.io.send(message).await.is_ok()
682
+ {
683
+ delivered.push(link.peer.clone());
684
+ }
685
+ }
686
+ }
687
+ }
688
+ println!(
689
+ "RESENT {}",
690
+ json!({ "envelope": envelope.envelope_id, "delivered": delivered })
691
+ );
692
+ }
693
+ None => println!(
694
+ "ERROR {}",
695
+ json!({ "reason": format!("no envelope {argument} in this node's log") })
696
+ ),
697
+ }
698
+ io::stdout().flush().ok();
699
+ }
700
+
400
701
  "resend-last" => {
401
702
  let envelope = last_envelope.lock().await.clone();
402
703
  match envelope {
@@ -426,12 +727,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
426
727
  "query-state" => {
427
728
  let guard = core.lock().await;
428
729
  let (executor, _) = &*guard;
730
+ let canonical = executor
731
+ .canonical_state()
732
+ .map(|state| state.hash().to_string())
733
+ .unwrap_or_else(|error| format!("error: {error}"));
429
734
  match executor.get_replica_state(&node_id) {
430
735
  Some(replica) => println!(
431
736
  "STATE {}",
432
737
  json!({
433
738
  "hash": replica.state_hash.to_string(),
434
739
  "operations_applied": replica.operations_applied,
740
+ // Order-independent, and therefore the one to
741
+ // compare across replicas. The chained hash above
742
+ // detects a missing operation in an ordered
743
+ // history; it calls two nodes that applied the same
744
+ // concurrent operations in different orders
745
+ // divergent, which they are not.
746
+ "canonical_hash": canonical,
435
747
  })
436
748
  ),
437
749
  None => println!("ERROR {}", json!({ "reason": "no local replica" })),
@@ -439,6 +751,164 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
439
751
  io::stdout().flush().ok();
440
752
  }
441
753
 
754
+ // What the causal receiver is holding, and why.
755
+ // Membership commands. Deliberately no compaction frontier and no
756
+ // acknowledgement tracking: this establishes who exists and who is
757
+ // required, and reclaims nothing.
758
+ "cluster-create" => {
759
+ let result = membership.create_cluster(argument.trim());
760
+ println!(
761
+ "CLUSTER {}",
762
+ match result {
763
+ Ok(()) => json!({ "cluster_id": argument.trim(), "created": true }),
764
+ Err(error) => json!({ "created": false, "error": error.to_string() }),
765
+ }
766
+ );
767
+ io::stdout().flush().ok();
768
+ }
769
+
770
+ "replica-join" | "replica-approve" | "replica-suspect" | "replica-seen"
771
+ | "replica-remove" => {
772
+ let mut parts = argument.split_whitespace();
773
+ let replica = parts.next().unwrap_or_default().to_string();
774
+ let result = match verb {
775
+ "replica-join" => {
776
+ let node = parts.next().unwrap_or(&replica).to_string();
777
+ membership.join_replica(&replica, &node)
778
+ }
779
+ "replica-approve" => membership.approve_replica(&replica),
780
+ "replica-suspect" => membership.suspect_replica(&replica),
781
+ "replica-seen" => membership.mark_seen(&replica),
782
+ _ => membership.remove_replica(&replica),
783
+ };
784
+ println!(
785
+ "REPLICA {}",
786
+ match result {
787
+ Ok(()) => json!({ "replica_id": replica, "action": verb, "ok": true }),
788
+ Err(error) =>
789
+ json!({ "replica_id": replica, "action": verb, "ok": false, "error": error.to_string() }),
790
+ }
791
+ );
792
+ io::stdout().flush().ok();
793
+ }
794
+
795
+ "members" => {
796
+ println!(
797
+ "MEMBERS {}",
798
+ json!({
799
+ "cluster_id": membership.cluster().map(|c| c.cluster_id.clone()),
800
+ "members": membership.get_members(),
801
+ "all": membership.all_replicas(),
802
+ })
803
+ );
804
+ io::stdout().flush().ok();
805
+ }
806
+
807
+ "required-replicas" => {
808
+ println!(
809
+ "REQUIRED {}",
810
+ json!({ "required": membership.get_required_replicas() })
811
+ );
812
+ io::stdout().flush().ok();
813
+ }
814
+
815
+ "causal-state" => {
816
+ let guard = core.lock().await;
817
+ let (executor, _) = &*guard;
818
+ let paused = {
819
+ let mut peers: Vec<String> =
820
+ backpressured.lock().await.iter().cloned().collect();
821
+ peers.sort();
822
+ peers
823
+ };
824
+ println!(
825
+ "CAUSAL {}",
826
+ json!({
827
+ "frontier": executor.causal_frontier().clocks,
828
+ "pending": executor.pending_causal_keys(),
829
+ "pending_count": executor.pending_causal_count(),
830
+ "pending_metadata_bytes": executor.pending_causal_metadata_bytes(),
831
+ // Occupancy against the bound that is now enforced.
832
+ // Current and maximum, for both dimensions, because a
833
+ // backlog that filled and drained between two reads is
834
+ // invisible to a current-value gauge.
835
+ "pending_payload_bytes": executor.pending_causal_payload_bytes(),
836
+ "deferred_count": executor.deferred_causal_count(),
837
+ "deferred": executor.deferred_causal_keys(),
838
+ "max_pending_entries_seen": executor.max_causal_occupancy().0,
839
+ "max_pending_payload_bytes_seen": executor.max_causal_occupancy().1,
840
+ "limit_entries": executor.causal_capacity().max_pending_entries,
841
+ "limit_bytes": executor.causal_capacity().max_pending_bytes,
842
+ // Peers this node has stopped reading from. This is the
843
+ // observable for backpressure: the pause lives in the
844
+ // socket read loop, so without it a test can only infer
845
+ // the behaviour from timing.
846
+ //
847
+ // Locked after `core`, matching the order the read loop
848
+ // takes them in.
849
+ "backpressured": paused,
850
+ })
851
+ );
852
+ io::stdout().flush().ok();
853
+ }
854
+
855
+ // Park messages from a peer instead of delivering them, so a test
856
+ // can choose the order the receiver is offered them in.
857
+ "hold-inbound" => {
858
+ let mut hold = held.lock().await;
859
+ hold.entry(argument.clone()).or_default();
860
+ println!("HELD {}", json!({ "peer": argument }));
861
+ io::stdout().flush().ok();
862
+ }
863
+
864
+ // Deliver what was parked, optionally newest-first, then resume
865
+ // normal delivery. Each message goes through the same production
866
+ // receiver as an unheld one.
867
+ // `release-inbound PEER [reverse] [COUNT]`
868
+ //
869
+ // COUNT releases only that many and keeps holding the rest, which
870
+ // is what lets a test offer an operation while deliberately
871
+ // withholding the dependency it needs.
872
+ "release-inbound" => {
873
+ let mut parts = argument.split_whitespace();
874
+ let peer = parts.next().unwrap_or_default().to_string();
875
+ let mut reverse = false;
876
+ let mut limit: Option<usize> = None;
877
+ for token in parts {
878
+ if token == "reverse" {
879
+ reverse = true;
880
+ } else if let Ok(value) = token.parse::<usize>() {
881
+ limit = Some(value);
882
+ }
883
+ }
884
+
885
+ let mut queued = { held.lock().await.remove(&peer).unwrap_or_default() };
886
+ if reverse {
887
+ queued.reverse();
888
+ }
889
+ let take = limit.unwrap_or(queued.len()).min(queued.len());
890
+ let remainder = queued.split_off(take);
891
+
892
+ let mut outcomes = Vec::new();
893
+ for message in queued {
894
+ let mut guard = core.lock().await;
895
+ let (executor, version) = &mut *guard;
896
+ outcomes.push(deliver(executor, version, message));
897
+ }
898
+
899
+ // Anything not released stays held, so the hold is still in
900
+ // force for messages that arrive afterwards.
901
+ if !remainder.is_empty() || limit.is_some() {
902
+ held.lock().await.insert(peer.clone(), remainder);
903
+ }
904
+
905
+ println!(
906
+ "RELEASED {}",
907
+ json!({ "peer": peer, "reverse": reverse, "outcomes": outcomes })
908
+ );
909
+ io::stdout().flush().ok();
910
+ }
911
+
442
912
  // The proof that the network carried the work. Counters come from
443
913
  // the transport itself, so a test can assert bytes actually moved
444
914
  // rather than inferring it from converged state.