@feltdb/core 0.6.12 → 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.
- package/dist/agent-registry.js +1 -3
- package/dist/agent-runtime.js +8 -7
- package/dist/analytics-backend.js +3 -1
- package/dist/application-contract.js +1 -0
- package/dist/application-manifest.js +1 -0
- package/dist/artifact.js +2 -0
- package/dist/authorization.js +2 -0
- package/dist/bundle.js +2 -0
- package/dist/capability.js +1 -3
- package/dist/cell.js +9 -4
- package/dist/cli/application.js +1 -1
- package/dist/cli/browser-opening.js +35 -0
- package/dist/cli/commands.js +50 -17
- package/dist/cli/index.js +1 -1
- package/dist/collection.js +39 -31
- package/dist/create/cli.js +7 -5
- package/dist/create/create.js +9 -7
- package/dist/create/development-handoff.js +4 -0
- package/dist/create/docker-compose-generator.js +4 -4
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +497 -27
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +452 -0
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +208 -0
- package/dist/create/server-source/crates/feltdb/src/convergence.rs +16 -0
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +402 -0
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +765 -24
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +418 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +4 -0
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +661 -0
- package/dist/db.js +19 -13
- package/dist/development-runtime-bridge.js +1 -1
- package/dist/distributed-indexing.js +7 -5
- package/dist/file-db.js +8 -3
- package/dist/flowspec.js +2 -1
- package/dist/http-client.js +2 -0
- package/dist/http-db.js +16 -1
- package/dist/identity.js +1 -0
- package/dist/index-analytics.js +6 -7
- package/dist/index-backend.js +3 -3
- package/dist/index-dashboard.js +10 -13
- package/dist/index-manager.js +12 -11
- package/dist/index-monitoring.js +9 -4
- package/dist/index-store.js +2 -0
- package/dist/indexeddb-db.js +27 -23
- package/dist/memory-db.js +7 -4
- package/dist/observe.js +2 -0
- package/dist/provider.js +2 -0
- package/dist/query-planner.js +2 -4
- package/dist/reactive-graph.js +6 -8
- package/dist/release.js +2 -0
- package/dist/sharding.js +11 -6
- package/dist/state-contract.js +3 -3
- package/dist/studio-app/assets/{feltdb_wasm-CJv3wHzi.js → feltdb_wasm-C9xpYtna.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BsXHw7eX.wasm +0 -0
- package/dist/studio-app/assets/{index-DospFFYE.js → index-D4RZ44qs.js} +4 -4
- package/dist/studio-app/index.html +1 -1
- package/dist/sync-contract.js +9 -2
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +32 -12
- package/dist/transaction.js +4 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/worker.js +2 -0
- package/dist/workload.js +2 -0
- package/dist/workspace/development-node.js +11 -10
- package/dist/workspace/investigation-lifecycle-manager.js +2 -0
- package/dist/workspace/investigation-supervisor.js +5 -3
- package/dist/workspace/workspace-connection.js +14 -5
- package/package.json +1 -1
- 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::{
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
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(
|
|
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!({
|
|
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.
|