Skip to content

Commit fb63ae5

Browse files
tmmampagent
andauthored
fix(zero-rust): keep every replica row findable after a B+tree merge (#177)
When a delete emptied an internal node's first child, the next child became the first with its own, higher lower bound. Inside the node that bound is never read, but once the node merged into its left neighbor it was, and lookups of keys between the two bounds went to the neighbor's last child: the replica held a row that get and has missed. A mutator or a poke then couldn't delete the row, and putting it again panicked in MemorySource ("Row already exists"). JS deletes and re-inserts the row. A merge now gives the right node's first child the bound the parent had for it. Tests: tests/replica_tree.rs reproduces it through mutators and through pokes; a KvMap fuzz of range deletes and refills, on nodes of 8 in unit tests, checks that every key is findable and within the bounds lookups use. Conformance unchanged: 63,453 cases, 0 failures. Amp-Thread-ID: https://ampcode.com/threads/T-319b90a3-6ed9-4da2-8727-8dc76a1cf67a Co-authored-by: Amp <amp@ampcode.com>
1 parent 6ae34f0 commit fb63ae5

3 files changed

Lines changed: 336 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'zero-rust': patch
3+
---
4+
5+
Fix the replica losing rows: after a delete emptied the first child of a node of its B+tree and the node later merged into its neighbor, the client couldn't find a row it still held. A mutator or a poke then couldn't delete the row, and putting it again panicked ("Row already exists").

‎rust/zero-client/src/client/replica_store.rs‎

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ use std::cmp::Ordering;
88
use std::collections::{BTreeMap, HashMap, HashSet};
99
use std::rc::Rc;
1010

11-
/// The most entries or children a node of a [`KvMap`] holds.
11+
/// The most entries or children a node of a [`KvMap`] holds. Unit tests
12+
/// use small nodes, so a few thousand keys make deep trees and many merges.
13+
#[cfg(not(test))]
1214
const MAX_NODE: usize = 64;
15+
#[cfg(test)]
16+
const MAX_NODE: usize = 8;
1317
/// A node with fewer after a delete merges with a neighbor that has room.
1418
const MIN_NODE: usize = MAX_NODE / 4;
1519

@@ -112,11 +116,17 @@ impl Node {
112116
} else if len < MIN_NODE && children.len() > 1 {
113117
let left = if i + 1 < children.len() { i } else { i - 1 };
114118
if children[left].1.len() + children[left + 1].1.len() <= MAX_NODE {
115-
let (_, right) = children.remove(left + 1);
119+
let (bound, right) = children.remove(left + 1);
116120
let right = Rc::unwrap_or_clone(right);
117121
match (Rc::make_mut(&mut children[left].1), right) {
118122
(Node::Leaf(a), Node::Leaf(b)) => a.extend(b),
119-
(Node::Internal(a), Node::Internal(b)) => a.extend(b),
123+
// The first child's bound goes unused until now,
124+
// and is stale once the child before it was
125+
// removed: it gets the bound the right node had.
126+
(Node::Internal(a), Node::Internal(mut b)) => {
127+
b[0].0 = bound;
128+
a.extend(b);
129+
}
120130
_ => unreachable!("the children of a node are at one depth"),
121131
}
122132
}
@@ -717,4 +727,89 @@ mod tests {
717727
assert!(copy.iter().eq(model.iter()));
718728
}
719729
}
730+
731+
/// Checks that each node's keys are within the bounds lookups use: at
732+
/// or above its bound (a first child's, its parent's), below the next.
733+
fn check_bounds(map: &KvMap) {
734+
fn walk(node: &Node, low: &str, high: Option<&str>) {
735+
match node {
736+
Node::Leaf(entries) => {
737+
for e in entries {
738+
assert!(e.0.as_str() >= low, "{} below the bound {low}", e.0);
739+
assert!(
740+
high.is_none_or(|h| e.0.as_str() < h),
741+
"{} at or above the next bound",
742+
e.0
743+
);
744+
}
745+
}
746+
Node::Internal(children) => {
747+
for (i, (bound, child)) in children.iter().enumerate() {
748+
// A first child's own bound is never read.
749+
let low = if i == 0 { low } else { bound.as_str() };
750+
let high = children.get(i + 1).map(|(b, _)| b.as_str()).or(high);
751+
walk(child, low, high);
752+
}
753+
}
754+
}
755+
}
756+
if let Some(root) = &map.root {
757+
walk(root, "", None);
758+
}
759+
}
760+
761+
/// Range deletes, which empty whole nodes, and refills of the gaps
762+
/// they leave, at three levels: every key the map iterates is found.
763+
/// A merged node once kept a stale bound and lost keys this way.
764+
#[test]
765+
fn kv_map_range_deletes_keep_every_key_findable() {
766+
const SEEDS: u64 = 20;
767+
const SPACE: u64 = 2000;
768+
const ROUNDS: usize = 200;
769+
const WIDTH: u64 = 200;
770+
for seed in 1..=SEEDS {
771+
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
772+
let mut random = |n: u64| {
773+
state ^= state << 13;
774+
state ^= state >> 7;
775+
state ^= state << 17;
776+
state % n
777+
};
778+
let key = |i: u64| format!("e/{i:06}");
779+
let mut map = KvMap::default();
780+
let mut model = BTreeMap::new();
781+
for i in 0..SPACE {
782+
if random(3) > 0 {
783+
map.set(key(i * 10), Value::Null);
784+
model.insert(key(i * 10), Value::Null);
785+
}
786+
}
787+
for round in 0..ROUNDS {
788+
if random(3) == 0 {
789+
let start = random(SPACE) * 10;
790+
let end = start + random(WIDTH) * 10;
791+
let keys: Vec<String> = model
792+
.range(key(start)..key(end))
793+
.map(|(k, _)| k.clone())
794+
.collect();
795+
for k in keys {
796+
assert!(map.delete(&k), "seed {seed} round {round}: delete {k}");
797+
model.remove(&k);
798+
}
799+
} else {
800+
let base = random(SPACE) * 10;
801+
for j in 0..random(40) {
802+
map.set(key(base + j), Value::Null);
803+
model.insert(key(base + j), Value::Null);
804+
}
805+
}
806+
assert_eq!(map.len, model.len(), "seed {seed} round {round}");
807+
assert!(map.iter().eq(model.iter()), "seed {seed} round {round}");
808+
check_bounds(&map);
809+
for k in model.keys() {
810+
assert!(map.has(k), "seed {seed} round {round}: lost {k}");
811+
}
812+
}
813+
}
814+
}
720815
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
//! The replica's B+tree (`KvMap` in `client/replica_store.rs`) once lost
2+
//! keys. When a delete emptied the first child of an internal node, the child
3+
//! was removed and the next one became the first, keeping its own lower
4+
//! bound, above the bound the parent had for the node. Inside the node the
5+
//! first bound is never read, so a key between the two went to that child.
6+
//! Once the node merged into its left neighbor, that bound was read: lookups
7+
//! of such a key went to the neighbor's last child, so `get` and `has` missed
8+
//! a row the map still held. A mutator or a poke then couldn't delete the
9+
//! row, and putting it again added a second entry and an add of a row the
10+
//! IVM source had, which panicked ("Row already exists").
11+
//!
12+
//! The steps are the shortest construction found: ascending inserts make
13+
//! leaves of 32 and a root over two internal nodes, P0 and P1. JS runs the
14+
//! same mutations (through the conformance harness's `harden.ops`) and
15+
//! deletes the row, then shows it again after the insert.
16+
17+
use std::cell::Cell;
18+
use std::rc::Rc;
19+
use std::sync::Arc;
20+
use zero_client::ast::System;
21+
use zero_client::client::{
22+
MaterializedView, Mutator, SocketFactory, Transaction, Zero, ZeroOptions,
23+
};
24+
use zero_client::json::{self, Object, Value};
25+
use zero_client::query::AnyQuery;
26+
use zero_client::schema::Schema;
27+
use zero_client::testing::{ManualScheduler, MockSocketFactory, RecordingLogSink};
28+
use zero_client::ttl::Ttl;
29+
30+
fn schema() -> Arc<Schema> {
31+
let schema = json::parse(
32+
r#"{"tables": {"issue": {"name": "issue", "columns": {
33+
"id": {"type": "string"}, "title": {"type": "string"}}, "primaryKey": ["id"]}}}"#,
34+
)
35+
.unwrap();
36+
Arc::new(Schema::from_json(&schema).unwrap())
37+
}
38+
39+
fn id(i: u32) -> String {
40+
format!("i{i:06}")
41+
}
42+
43+
/// `ids` as the mutator's arguments.
44+
fn args(ids: impl IntoIterator<Item = u32>) -> Option<Value> {
45+
Some(Value::Array(
46+
ids.into_iter().map(|i| Value::from(id(i))).collect(),
47+
))
48+
}
49+
50+
struct Client {
51+
zero: Zero,
52+
scheduler: Rc<ManualScheduler>,
53+
}
54+
55+
impl Client {
56+
/// A local client with a view of every issue.
57+
fn new() -> (Client, MaterializedView) {
58+
let scheduler = ManualScheduler::new(1_700_000_000_000.0);
59+
let insert = Mutator::new("insert", |tx: &Transaction, args, _: &()| {
60+
for v in args.unwrap().as_array().unwrap() {
61+
let mut row = Object::new();
62+
row.insert("id", v.clone());
63+
row.insert("title", Value::from("t"));
64+
tx.table("issue").insert(&row)?;
65+
}
66+
Ok(None)
67+
});
68+
let delete = Mutator::new("delete", |tx: &Transaction, args, _: &()| {
69+
for v in args.unwrap().as_array().unwrap() {
70+
let mut row = Object::new();
71+
row.insert("id", v.clone());
72+
tx.table("issue").delete(&row)?;
73+
}
74+
Ok(None)
75+
});
76+
let zero = Zero::new(
77+
schema(),
78+
(),
79+
vec![insert, delete],
80+
ZeroOptions {
81+
log_sink: Some(Rc::new(RecordingLogSink::default())),
82+
..ZeroOptions::default()
83+
},
84+
scheduler.clone(),
85+
None,
86+
)
87+
.unwrap();
88+
scheduler.run_checkpoints();
89+
let view = zero
90+
.materialize(
91+
&AnyQuery::new(schema(), "issue", System::Client),
92+
&Ttl::default(),
93+
)
94+
.unwrap();
95+
scheduler.run_checkpoints();
96+
(Client { zero, scheduler }, view)
97+
}
98+
99+
fn mutate(&self, name: &str, ids: impl IntoIterator<Item = u32>) {
100+
self.zero.mutate(name, args(ids));
101+
self.scheduler.run_checkpoints();
102+
}
103+
104+
/// Leaves `i010300` where the B+tree couldn't find it.
105+
fn lose_a_row(&self) {
106+
// Leaves of 32 keys; P1's first leaf has 10240..=10550, its second
107+
// 10560..=10870.
108+
self.mutate("insert", (0..2081).map(|i| i * 10));
109+
// The second leaf full (64), so the first can't merge into it.
110+
self.mutate("insert", (0..32).map(|i| 10561 + i * 10));
111+
// The first leaf emptied and removed: the second, bound 10560, is
112+
// P1's first child, and P1's bound is 10240.
113+
self.mutate("delete", (0..32).map(|i| 10240 + i * 10));
114+
self.mutate("insert", [10300]);
115+
// P1 shrunk until it merges into P0.
116+
self.mutate("delete", (0..563).map(|i| 20800 - i * 10));
117+
}
118+
}
119+
120+
fn has(view: &MaterializedView, i: u32) -> bool {
121+
view.data()
122+
.entries()
123+
.iter()
124+
.any(|e| e.row.get("id").and_then(Value::as_str) == Some(&id(i)))
125+
}
126+
127+
#[test]
128+
fn a_row_after_a_merge_of_the_replica_tree_can_be_deleted() {
129+
let (client, view) = Client::new();
130+
client.lose_a_row();
131+
assert!(has(&view, 10300));
132+
client.mutate("delete", [10300]);
133+
assert!(!has(&view, 10300), "the deleted row is still in the view");
134+
}
135+
136+
#[test]
137+
fn a_row_after_a_merge_of_the_replica_tree_can_be_inserted_again() {
138+
let (client, view) = Client::new();
139+
client.lose_a_row();
140+
client.mutate("delete", [10300]);
141+
client.mutate("insert", [10300]);
142+
assert!(has(&view, 10300));
143+
}
144+
145+
/// The steps of [`Client::lose_a_row`] as pokes: puts and dels of the
146+
/// server's rows, then a del and a put of the row.
147+
#[test]
148+
fn a_row_after_a_merge_of_the_replica_tree_follows_the_server() {
149+
let scheduler = ManualScheduler::new(1_700_000_000_000.0);
150+
let factory = MockSocketFactory::new();
151+
let zero = Zero::new(
152+
schema(),
153+
(),
154+
Vec::new(),
155+
ZeroOptions {
156+
cache_url: Some("http://zero.test".into()),
157+
user_id: Some("u1".into()),
158+
log_sink: Some(Rc::new(RecordingLogSink::default())),
159+
..ZeroOptions::default()
160+
},
161+
scheduler.clone(),
162+
Some(factory.clone() as Rc<dyn SocketFactory>),
163+
)
164+
.unwrap();
165+
let view = zero
166+
.materialize(
167+
&AnyQuery::new(schema(), "issue", System::Client),
168+
&Ttl::default(),
169+
)
170+
.unwrap();
171+
scheduler.advance(0.0);
172+
let socket = factory.sockets()[0].clone();
173+
socket.receive_text(r#"["connected",{"wsid":"w1"}]"#);
174+
let cookie = Cell::new(0);
175+
let poke = |patch: Vec<String>| {
176+
let n = cookie.get() + 1;
177+
let base = if n == 1 {
178+
"null".to_owned()
179+
} else {
180+
format!(r#""{:02}""#, n - 1)
181+
};
182+
cookie.set(n);
183+
socket.receive_text(&format!(
184+
r#"["pokeStart",{{"pokeID":"p{n}","baseCookie":{base}}}]"#
185+
));
186+
socket.receive_text(&format!(
187+
r#"["pokePart",{{"pokeID":"p{n}","rowsPatch":[{}]}}]"#,
188+
patch.join(",")
189+
));
190+
socket.receive_text(&format!(
191+
r#"["pokeEnd",{{"pokeID":"p{n}","cookie":"{n:02}"}}]"#
192+
));
193+
scheduler.advance(20.0);
194+
};
195+
let put = |ids: Vec<u32>| {
196+
poke(
197+
ids.into_iter()
198+
.map(|i| {
199+
format!(
200+
r#"{{"op":"put","tableName":"issue","value":{{"id":"{}","title":"t"}}}}"#,
201+
id(i)
202+
)
203+
})
204+
.collect(),
205+
)
206+
};
207+
let del = |ids: Vec<u32>| {
208+
poke(
209+
ids.into_iter()
210+
.map(|i| {
211+
format!(
212+
r#"{{"op":"del","tableName":"issue","id":{{"id":"{}"}}}}"#,
213+
id(i)
214+
)
215+
})
216+
.collect(),
217+
)
218+
};
219+
put((0..2081).map(|i| i * 10).collect());
220+
put((0..32).map(|i| 10561 + i * 10).collect());
221+
del((0..32).map(|i| 10240 + i * 10).collect());
222+
put(vec![10300]);
223+
del((0..563).map(|i| 20800 - i * 10).collect());
224+
assert!(has(&view, 10300));
225+
del(vec![10300]);
226+
assert!(
227+
!has(&view, 10300),
228+
"the row the server deleted is still in the view"
229+
);
230+
put(vec![10300]);
231+
assert!(has(&view, 10300));
232+
assert_eq!(view.data().entries().len(), 2081 + 32 - 32 + 1 - 563);
233+
}

0 commit comments

Comments
 (0)