@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
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
//! The causal backlog bound, exercised through the production receive path.
|
|
2
|
+
//!
|
|
3
|
+
//! Every test here drives `DistributedTransactionExecutor::receive_replicated`
|
|
4
|
+
//! and `try_execute_local_transaction` -- the entry points the shipping node
|
|
5
|
+
//! binary calls. None of them constructs a `CausalDependencyBarrier` and fills
|
|
6
|
+
//! it directly.
|
|
7
|
+
//!
|
|
8
|
+
//! That restriction is the point, and it is written down because the previous
|
|
9
|
+
//! evidence for this property did exactly the opposite: Gate 14B built a
|
|
10
|
+
//! barrier, filled it, computed `backlog_depth * 1500 / 1024`, and reported the
|
|
11
|
+
//! result as a memory measurement. It found linear growth because linear growth
|
|
12
|
+
//! was the arithmetic it had been given. A new harness that also constructed a
|
|
13
|
+
//! barrier would repeat that defect in a fresh file, so these tests reach the
|
|
14
|
+
//! backlog the way an operation reaches it.
|
|
15
|
+
//!
|
|
16
|
+
//! What is asserted is behaviour at the bound, not a predicted footprint. Where
|
|
17
|
+
//! a number is reported it is one that was observed.
|
|
18
|
+
//!
|
|
19
|
+
//! The contract under test is `docs/architecture/causal-backlog-bound.md` §4.
|
|
20
|
+
|
|
21
|
+
#[cfg(test)]
|
|
22
|
+
mod tests {
|
|
23
|
+
use crate::convergence::VectorClock;
|
|
24
|
+
use crate::distributed_transactions::{
|
|
25
|
+
CausalCapacity, DistributedTransactionExecutor, ReceiveOutcome, ReplicationMessage,
|
|
26
|
+
SubmitOutcome, TransactionEnvelope,
|
|
27
|
+
};
|
|
28
|
+
use crate::state_hash::StateHash;
|
|
29
|
+
use crate::transactions::{
|
|
30
|
+
ConsistencyContract, Operation, OperationCommand, OperationId, StateVersion,
|
|
31
|
+
};
|
|
32
|
+
use std::collections::HashMap;
|
|
33
|
+
|
|
34
|
+
fn zero_version() -> StateVersion {
|
|
35
|
+
StateVersion::new(VectorClock::new(), "0".repeat(64))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// An envelope from `origin` at `sequence`, carrying the causal history
|
|
39
|
+
/// `{origin: sequence}` -- so its dependency is `{origin: sequence - 1}`,
|
|
40
|
+
/// exactly as the production stamping produces.
|
|
41
|
+
fn envelope_at(origin: &str, sequence: u64, payload: &str) -> TransactionEnvelope {
|
|
42
|
+
let mut fields = HashMap::new();
|
|
43
|
+
fields.insert("record".to_string(), serde_json::json!(payload));
|
|
44
|
+
let operation = Operation::new(
|
|
45
|
+
OperationId::new(origin.to_string(), sequence),
|
|
46
|
+
zero_version(),
|
|
47
|
+
format!("tx_{payload}"),
|
|
48
|
+
OperationCommand {
|
|
49
|
+
op_type: "set".to_string(),
|
|
50
|
+
collection: "items".to_string(),
|
|
51
|
+
record_id: payload.to_string(),
|
|
52
|
+
fields,
|
|
53
|
+
},
|
|
54
|
+
origin.to_string(),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
let mut envelope = TransactionEnvelope::new(
|
|
58
|
+
origin.to_string(),
|
|
59
|
+
sequence,
|
|
60
|
+
format!("tx_{payload}"),
|
|
61
|
+
vec![operation],
|
|
62
|
+
ConsistencyContract::local(),
|
|
63
|
+
);
|
|
64
|
+
let mut clock = VectorClock::new();
|
|
65
|
+
clock.clocks.insert(origin.to_string(), sequence);
|
|
66
|
+
envelope.vector_clock = clock;
|
|
67
|
+
envelope
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn message(envelope: TransactionEnvelope) -> ReplicationMessage {
|
|
71
|
+
let origin = envelope.originating_node.clone();
|
|
72
|
+
let sequence = envelope.envelope_id.sequence;
|
|
73
|
+
ReplicationMessage::new(envelope, origin, "receiver".to_string(), sequence)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// A receiver with a durable log, which every one of these tests needs:
|
|
77
|
+
/// a deferred or pending envelope is replayed from the log, not from RAM.
|
|
78
|
+
fn receiver(capacity: CausalCapacity) -> (DistributedTransactionExecutor, tempfile::TempDir) {
|
|
79
|
+
let dir = tempfile::tempdir().expect("tempdir");
|
|
80
|
+
let mut executor = DistributedTransactionExecutor::with_log(
|
|
81
|
+
"receiver".to_string(),
|
|
82
|
+
StateHash::from_hex("0".repeat(64)),
|
|
83
|
+
Some(dir.path().join("operations.jsonl")),
|
|
84
|
+
)
|
|
85
|
+
.expect("executor with log");
|
|
86
|
+
executor.set_causal_capacity(capacity);
|
|
87
|
+
(executor, dir)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Deliver `count` envelopes that can never become eligible, because the
|
|
91
|
+
/// dependency they name is never sent. This is how the backlog is filled
|
|
92
|
+
/// through the real path.
|
|
93
|
+
fn fill_with_blocked(
|
|
94
|
+
executor: &mut DistributedTransactionExecutor,
|
|
95
|
+
origin: &str,
|
|
96
|
+
count: usize,
|
|
97
|
+
) -> Vec<ReceiveOutcome> {
|
|
98
|
+
(0..count)
|
|
99
|
+
.map(|index| {
|
|
100
|
+
// Sequence starts at 2 so the dependency {origin: 1} is never
|
|
101
|
+
// satisfied: envelope 1 is deliberately never delivered.
|
|
102
|
+
let sequence = (index as u64) + 2;
|
|
103
|
+
executor
|
|
104
|
+
.receive_replicated(
|
|
105
|
+
message(envelope_at(origin, sequence, &format!("r{sequence}"))),
|
|
106
|
+
zero_version(),
|
|
107
|
+
)
|
|
108
|
+
.expect("receive")
|
|
109
|
+
})
|
|
110
|
+
.collect()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn causal_backlog_fills_to_just_under_the_entry_bound() {
|
|
115
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(8, usize::MAX));
|
|
116
|
+
let outcomes = fill_with_blocked(&mut executor, "A", 7);
|
|
117
|
+
|
|
118
|
+
assert!(
|
|
119
|
+
outcomes
|
|
120
|
+
.iter()
|
|
121
|
+
.all(|outcome| matches!(outcome, ReceiveOutcome::PendingDependencies { .. })),
|
|
122
|
+
"everything below the bound is buffered"
|
|
123
|
+
);
|
|
124
|
+
assert_eq!(executor.pending_causal_count(), 7);
|
|
125
|
+
assert_eq!(executor.deferred_causal_count(), 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[test]
|
|
129
|
+
fn causal_backlog_accepts_the_exact_entry_bound() {
|
|
130
|
+
// The bound is inclusive: `<=`, not `<`. Filling exactly to it is
|
|
131
|
+
// accepted, and the breach is the step past it.
|
|
132
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(8, usize::MAX));
|
|
133
|
+
let outcomes = fill_with_blocked(&mut executor, "A", 8);
|
|
134
|
+
|
|
135
|
+
assert!(matches!(
|
|
136
|
+
outcomes.last().expect("an outcome"),
|
|
137
|
+
ReceiveOutcome::PendingDependencies { .. }
|
|
138
|
+
));
|
|
139
|
+
assert_eq!(executor.pending_causal_count(), 8);
|
|
140
|
+
assert_eq!(executor.deferred_causal_count(), 0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn causal_backlog_defers_one_entry_past_the_bound() {
|
|
145
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(8, usize::MAX));
|
|
146
|
+
let outcomes = fill_with_blocked(&mut executor, "A", 9);
|
|
147
|
+
|
|
148
|
+
assert!(
|
|
149
|
+
matches!(outcomes[8], ReceiveOutcome::CapacityDeferred { .. }),
|
|
150
|
+
"the ninth entry breaches an eight-entry bound, got {:?}",
|
|
151
|
+
outcomes[8]
|
|
152
|
+
);
|
|
153
|
+
assert_eq!(executor.pending_causal_count(), 8, "the bound holds");
|
|
154
|
+
assert_eq!(executor.deferred_causal_count(), 1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn causal_backlog_defers_one_byte_past_the_byte_bound() {
|
|
159
|
+
// Size the byte bound from a real envelope rather than a guessed
|
|
160
|
+
// constant: measure what two envelopes actually occupy, then set the
|
|
161
|
+
// bound one byte below it.
|
|
162
|
+
let probe = envelope_at("A", 2, "r2");
|
|
163
|
+
let probe_bytes = serde_json::to_string(&probe).expect("serialize").len();
|
|
164
|
+
|
|
165
|
+
let (mut executor, _dir) =
|
|
166
|
+
receiver(CausalCapacity::new(usize::MAX, probe_bytes * 2 - 1));
|
|
167
|
+
let outcomes = fill_with_blocked(&mut executor, "A", 3);
|
|
168
|
+
|
|
169
|
+
assert!(matches!(outcomes[0], ReceiveOutcome::PendingDependencies { .. }));
|
|
170
|
+
assert!(
|
|
171
|
+
matches!(outcomes[1], ReceiveOutcome::CapacityDeferred { .. }),
|
|
172
|
+
"the second envelope breaches a bound one byte short of two, got {:?}",
|
|
173
|
+
outcomes[1]
|
|
174
|
+
);
|
|
175
|
+
assert_eq!(executor.pending_causal_count(), 1);
|
|
176
|
+
assert!(executor.pending_causal_payload_bytes() <= probe_bytes * 2 - 1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#[test]
|
|
180
|
+
fn causal_backlog_charges_a_large_envelope_its_actual_bytes() {
|
|
181
|
+
// A byte bound is only a bound if a big operation costs more than a
|
|
182
|
+
// small one. Two envelopes of very different sizes, against a bound
|
|
183
|
+
// that admits the small one and not the large one.
|
|
184
|
+
let small = envelope_at("A", 2, "s");
|
|
185
|
+
let large = envelope_at("A", 3, &"L".repeat(4096));
|
|
186
|
+
let small_bytes = serde_json::to_string(&small).expect("serialize").len();
|
|
187
|
+
let large_bytes = serde_json::to_string(&large).expect("serialize").len();
|
|
188
|
+
assert!(large_bytes > small_bytes * 2, "the fixture must actually differ in size");
|
|
189
|
+
|
|
190
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(usize::MAX, small_bytes + 16));
|
|
191
|
+
|
|
192
|
+
let first = executor
|
|
193
|
+
.receive_replicated(message(small), zero_version())
|
|
194
|
+
.expect("receive");
|
|
195
|
+
assert!(matches!(first, ReceiveOutcome::PendingDependencies { .. }));
|
|
196
|
+
|
|
197
|
+
let second = executor
|
|
198
|
+
.receive_replicated(message(large), zero_version())
|
|
199
|
+
.expect("receive");
|
|
200
|
+
assert!(
|
|
201
|
+
matches!(second, ReceiveOutcome::CapacityDeferred { .. }),
|
|
202
|
+
"a large envelope must consume its actual byte count, got {second:?}"
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[test]
|
|
207
|
+
fn causal_backlog_refuses_local_work_at_the_bound_without_admitting_it() {
|
|
208
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(4, usize::MAX));
|
|
209
|
+
fill_with_blocked(&mut executor, "A", 4);
|
|
210
|
+
assert_eq!(executor.pending_causal_count(), 4);
|
|
211
|
+
|
|
212
|
+
let applied_before = executor
|
|
213
|
+
.get_replica_state("receiver")
|
|
214
|
+
.map(|replica| replica.operations_applied)
|
|
215
|
+
.unwrap_or(0);
|
|
216
|
+
|
|
217
|
+
let mut fields = HashMap::new();
|
|
218
|
+
fields.insert("record".to_string(), serde_json::json!("local"));
|
|
219
|
+
let operation = Operation::new(
|
|
220
|
+
OperationId::new("receiver".to_string(), 1),
|
|
221
|
+
zero_version(),
|
|
222
|
+
"tx_local".to_string(),
|
|
223
|
+
OperationCommand {
|
|
224
|
+
op_type: "set".to_string(),
|
|
225
|
+
collection: "items".to_string(),
|
|
226
|
+
record_id: "local".to_string(),
|
|
227
|
+
fields,
|
|
228
|
+
},
|
|
229
|
+
"receiver".to_string(),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
let outcome = executor
|
|
233
|
+
.try_execute_local_transaction(
|
|
234
|
+
1,
|
|
235
|
+
"tx_local".to_string(),
|
|
236
|
+
zero_version(),
|
|
237
|
+
vec![operation],
|
|
238
|
+
ConsistencyContract::local(),
|
|
239
|
+
)
|
|
240
|
+
.expect("submit");
|
|
241
|
+
|
|
242
|
+
match outcome {
|
|
243
|
+
SubmitOutcome::CapacityExceeded { limit_entries, pending_entries, .. } => {
|
|
244
|
+
assert_eq!(limit_entries, 4);
|
|
245
|
+
assert_eq!(pending_entries, 4);
|
|
246
|
+
}
|
|
247
|
+
SubmitOutcome::Admitted(_) => panic!("local work must be refused at the bound"),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Refused work never appears as applied, and never reaches the log.
|
|
251
|
+
let applied_after = executor
|
|
252
|
+
.get_replica_state("receiver")
|
|
253
|
+
.map(|replica| replica.operations_applied)
|
|
254
|
+
.unwrap_or(0);
|
|
255
|
+
assert_eq!(applied_after, applied_before, "a refusal applies nothing");
|
|
256
|
+
assert!(
|
|
257
|
+
!executor
|
|
258
|
+
.causal_barrier
|
|
259
|
+
.pending_keys()
|
|
260
|
+
.iter()
|
|
261
|
+
.any(|key| key.starts_with("receiver:")),
|
|
262
|
+
"a refusal buffers nothing"
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#[test]
|
|
267
|
+
fn causal_backlog_never_evicts_and_admits_deferred_work_when_it_drains() {
|
|
268
|
+
// The whole point of deferring rather than refusing: nothing is lost.
|
|
269
|
+
// Once the dependency arrives and the backlog drains, the deferred
|
|
270
|
+
// envelope is admitted from the log and applied.
|
|
271
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(2, usize::MAX));
|
|
272
|
+
|
|
273
|
+
let outcomes = fill_with_blocked(&mut executor, "A", 3);
|
|
274
|
+
assert!(matches!(outcomes[2], ReceiveOutcome::CapacityDeferred { .. }));
|
|
275
|
+
assert_eq!(executor.deferred_causal_count(), 1);
|
|
276
|
+
|
|
277
|
+
// Deliver the missing dependency A:1, which releases the whole chain.
|
|
278
|
+
let released = executor
|
|
279
|
+
.receive_replicated(message(envelope_at("A", 1, "r1")), zero_version())
|
|
280
|
+
.expect("receive");
|
|
281
|
+
assert!(matches!(released, ReceiveOutcome::Applied { .. }));
|
|
282
|
+
|
|
283
|
+
assert_eq!(executor.pending_causal_count(), 0, "nothing is left waiting");
|
|
284
|
+
assert_eq!(executor.deferred_causal_count(), 0, "nothing is left deferred");
|
|
285
|
+
let applied = executor
|
|
286
|
+
.get_replica_state("receiver")
|
|
287
|
+
.map(|replica| replica.operations_applied)
|
|
288
|
+
.unwrap_or(0);
|
|
289
|
+
assert_eq!(applied, 4, "all four envelopes applied, none evicted");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#[test]
|
|
293
|
+
fn causal_backlog_holds_order_under_pressure() {
|
|
294
|
+
// Causal correctness is not traded away at capacity: nothing is applied
|
|
295
|
+
// before its dependencies, whatever the bound is doing.
|
|
296
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(2, usize::MAX));
|
|
297
|
+
fill_with_blocked(&mut executor, "A", 4);
|
|
298
|
+
|
|
299
|
+
let applied = executor
|
|
300
|
+
.get_replica_state("receiver")
|
|
301
|
+
.map(|replica| replica.operations_applied)
|
|
302
|
+
.unwrap_or(0);
|
|
303
|
+
assert_eq!(applied, 0, "nothing may be applied while its dependency is missing");
|
|
304
|
+
assert_eq!(executor.causal_frontier().get("A"), 0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn causal_backlog_deduplicates_a_redelivered_blocked_entry() {
|
|
309
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(8, usize::MAX));
|
|
310
|
+
|
|
311
|
+
let first = executor
|
|
312
|
+
.receive_replicated(message(envelope_at("A", 2, "r2")), zero_version())
|
|
313
|
+
.expect("receive");
|
|
314
|
+
assert!(matches!(first, ReceiveOutcome::PendingDependencies { .. }));
|
|
315
|
+
|
|
316
|
+
let again = executor
|
|
317
|
+
.receive_replicated(message(envelope_at("A", 2, "r2")), zero_version())
|
|
318
|
+
.expect("receive");
|
|
319
|
+
assert_eq!(again, ReceiveOutcome::AlreadyKnown);
|
|
320
|
+
assert_eq!(executor.pending_causal_count(), 1, "one logical operation");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[test]
|
|
324
|
+
fn causal_backlog_deduplicates_a_redelivered_deferred_entry() {
|
|
325
|
+
// A deferred envelope is durable but unbuffered, so it needs its own
|
|
326
|
+
// dedup answer: without one, a retransmission would append it to the
|
|
327
|
+
// log a second time.
|
|
328
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(1, usize::MAX));
|
|
329
|
+
let outcomes = fill_with_blocked(&mut executor, "A", 2);
|
|
330
|
+
assert!(matches!(outcomes[1], ReceiveOutcome::CapacityDeferred { .. }));
|
|
331
|
+
|
|
332
|
+
let again = executor
|
|
333
|
+
.receive_replicated(message(envelope_at("A", 3, "r3")), zero_version())
|
|
334
|
+
.expect("receive");
|
|
335
|
+
assert_eq!(again, ReceiveOutcome::AlreadyKnown);
|
|
336
|
+
assert_eq!(executor.deferred_causal_count(), 1, "still one deferred entry");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#[test]
|
|
340
|
+
fn causal_backlog_reports_current_and_maximum_occupancy() {
|
|
341
|
+
let (mut executor, _dir) = receiver(CausalCapacity::new(8, usize::MAX));
|
|
342
|
+
fill_with_blocked(&mut executor, "A", 5);
|
|
343
|
+
|
|
344
|
+
let (max_entries, max_bytes) = executor.max_causal_occupancy();
|
|
345
|
+
assert_eq!(executor.pending_causal_count(), 5);
|
|
346
|
+
assert_eq!(max_entries, 5);
|
|
347
|
+
assert!(max_bytes > 0, "payload bytes are reported, not assumed");
|
|
348
|
+
|
|
349
|
+
// Drain it, and the current gauge falls while the maximum does not --
|
|
350
|
+
// which is the reason the maximum is reported at all.
|
|
351
|
+
executor
|
|
352
|
+
.receive_replicated(message(envelope_at("A", 1, "r1")), zero_version())
|
|
353
|
+
.expect("receive");
|
|
354
|
+
assert_eq!(executor.pending_causal_count(), 0);
|
|
355
|
+
assert_eq!(
|
|
356
|
+
executor.max_causal_occupancy().0,
|
|
357
|
+
5,
|
|
358
|
+
"a backlog that filled and drained between two reads must still be visible"
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#[test]
|
|
363
|
+
fn causal_backlog_survives_restart_with_deferred_and_pending_entries() {
|
|
364
|
+
// Durability is the property the bound must not cost. A blocked
|
|
365
|
+
// envelope is fsynced before it is buffered, and a deferred one is
|
|
366
|
+
// fsynced before it is deferred, so a restart rebuilds both from the
|
|
367
|
+
// log rather than losing whichever the bound had excluded.
|
|
368
|
+
let dir = tempfile::tempdir().expect("tempdir");
|
|
369
|
+
let log_path = dir.path().join("operations.jsonl");
|
|
370
|
+
|
|
371
|
+
{
|
|
372
|
+
let mut executor = DistributedTransactionExecutor::with_log(
|
|
373
|
+
"receiver".to_string(),
|
|
374
|
+
StateHash::from_hex("0".repeat(64)),
|
|
375
|
+
Some(log_path.clone()),
|
|
376
|
+
)
|
|
377
|
+
.expect("executor");
|
|
378
|
+
executor.set_causal_capacity(CausalCapacity::new(2, usize::MAX));
|
|
379
|
+
let outcomes = fill_with_blocked(&mut executor, "A", 3);
|
|
380
|
+
assert!(matches!(outcomes[2], ReceiveOutcome::CapacityDeferred { .. }));
|
|
381
|
+
assert_eq!(executor.pending_causal_count(), 2);
|
|
382
|
+
assert_eq!(executor.deferred_causal_count(), 1);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Restart: a fresh executor over the same log.
|
|
386
|
+
let mut recovered = DistributedTransactionExecutor::with_log(
|
|
387
|
+
"receiver".to_string(),
|
|
388
|
+
StateHash::from_hex("0".repeat(64)),
|
|
389
|
+
Some(log_path.clone()),
|
|
390
|
+
)
|
|
391
|
+
.expect("executor");
|
|
392
|
+
recovered.set_causal_capacity(CausalCapacity::new(2, usize::MAX));
|
|
393
|
+
let replayed = recovered.load_from_disk(log_path).expect("recover");
|
|
394
|
+
|
|
395
|
+
assert_eq!(replayed, 3, "all three envelopes were durable before the restart");
|
|
396
|
+
assert_eq!(
|
|
397
|
+
recovered.pending_causal_count(),
|
|
398
|
+
2,
|
|
399
|
+
"recovery honours the same bound the live path does"
|
|
400
|
+
);
|
|
401
|
+
assert_eq!(recovered.deferred_causal_count(), 1, "the excess is deferred, not lost");
|
|
402
|
+
|
|
403
|
+
// And the dependency still releases everything after the restart.
|
|
404
|
+
recovered
|
|
405
|
+
.receive_replicated(message(envelope_at("A", 1, "r1")), zero_version())
|
|
406
|
+
.expect("receive");
|
|
407
|
+
assert_eq!(recovered.pending_causal_count(), 0);
|
|
408
|
+
assert_eq!(recovered.deferred_causal_count(), 0);
|
|
409
|
+
assert_eq!(
|
|
410
|
+
recovered
|
|
411
|
+
.get_replica_state("receiver")
|
|
412
|
+
.map(|replica| replica.operations_applied)
|
|
413
|
+
.unwrap_or(0),
|
|
414
|
+
4,
|
|
415
|
+
"every envelope that was durable before the crash is applied after it"
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[test]
|
|
420
|
+
fn causal_backlog_recovery_is_deterministic() {
|
|
421
|
+
// Behaviour at capacity must not depend on iteration order. The same
|
|
422
|
+
// log and the same bound must produce the same split twice.
|
|
423
|
+
let dir = tempfile::tempdir().expect("tempdir");
|
|
424
|
+
let log_path = dir.path().join("operations.jsonl");
|
|
425
|
+
|
|
426
|
+
{
|
|
427
|
+
let mut executor = DistributedTransactionExecutor::with_log(
|
|
428
|
+
"receiver".to_string(),
|
|
429
|
+
StateHash::from_hex("0".repeat(64)),
|
|
430
|
+
Some(log_path.clone()),
|
|
431
|
+
)
|
|
432
|
+
.expect("executor");
|
|
433
|
+
executor.set_causal_capacity(CausalCapacity::new(10, usize::MAX));
|
|
434
|
+
fill_with_blocked(&mut executor, "A", 6);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let split = |bound: usize| {
|
|
438
|
+
let mut executor = DistributedTransactionExecutor::with_log(
|
|
439
|
+
"receiver".to_string(),
|
|
440
|
+
StateHash::from_hex("0".repeat(64)),
|
|
441
|
+
Some(log_path.clone()),
|
|
442
|
+
)
|
|
443
|
+
.expect("executor");
|
|
444
|
+
executor.set_causal_capacity(CausalCapacity::new(bound, usize::MAX));
|
|
445
|
+
executor.load_from_disk(log_path.clone()).expect("recover");
|
|
446
|
+
executor.causal_barrier.pending_keys()
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
assert_eq!(split(3), split(3), "the same log and bound recover identically");
|
|
450
|
+
assert_eq!(split(3).len(), 3);
|
|
451
|
+
}
|
|
452
|
+
}
|