Skip to content

Commit 2bbf2b6

Browse files
committed
test(federation): add proptest coverage for SelectionMap overwrite semantics
Property: SelectionMap::insert should overwrite an existing equal-key selection in place (last-write-wins), matching its documented contract and the pre-#6074 IndexMap-backed implementation. Oracle: a linear Vec<(OwnedSelectionKey, Selection)> model with a straightforward find-and-replace-or-push insert. Finding: commands = [Extend(["b { x }"]), Insert("b { x }")] left two entries for key `b` in the map instead of one. Fix: insert() now looks up the existing bucket for the key and overwrites the selection in place instead of always appending. Adds proptest as a workspace dependency and apollo-federation dev-dependency.
1 parent 68b7cbc commit 2bbf2b6

5 files changed

Lines changed: 274 additions & 2 deletions

File tree

‎Cargo.lock‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ dependencies = [
240240
"percent-encoding",
241241
"petgraph",
242242
"pretty_assertions",
243+
"proptest",
243244
"regex",
244245
"ron",
245246
"rstest",
@@ -5560,12 +5561,16 @@ version = "1.10.0"
55605561
source = "registry+https://github.com/rust-lang/crates.io-index"
55615562
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
55625563
dependencies = [
5564+
"bit-set",
5565+
"bit-vec",
55635566
"bitflags 2.11.0",
55645567
"num-traits",
55655568
"rand 0.9.2",
55665569
"rand_chacha 0.9.0",
55675570
"rand_xorshift",
55685571
"regex-syntax",
5572+
"rusty-fork",
5573+
"tempfile",
55695574
"unarray",
55705575
]
55715576

@@ -5684,6 +5689,12 @@ dependencies = [
56845689
"pulldown-cmark",
56855690
]
56865691

5692+
[[package]]
5693+
name = "quick-error"
5694+
version = "1.2.3"
5695+
source = "registry+https://github.com/rust-lang/crates.io-index"
5696+
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
5697+
56875698
[[package]]
56885699
name = "quinn"
56895700
version = "0.11.8"
@@ -6453,6 +6464,18 @@ version = "1.0.22"
64536464
source = "registry+https://github.com/rust-lang/crates.io-index"
64546465
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
64556466

6467+
[[package]]
6468+
name = "rusty-fork"
6469+
version = "0.3.1"
6470+
source = "registry+https://github.com/rust-lang/crates.io-index"
6471+
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
6472+
dependencies = [
6473+
"fnv",
6474+
"quick-error",
6475+
"tempfile",
6476+
"wait-timeout",
6477+
]
6478+
64566479
[[package]]
64576480
name = "ruzstd"
64586481
version = "0.9.0"
@@ -8149,6 +8172,15 @@ version = "0.8.0"
81498172
source = "registry+https://github.com/rust-lang/crates.io-index"
81508173
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
81518174

8175+
[[package]]
8176+
name = "wait-timeout"
8177+
version = "0.2.1"
8178+
source = "registry+https://github.com/rust-lang/crates.io-index"
8179+
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
8180+
dependencies = [
8181+
"libc",
8182+
]
8183+
81528184
[[package]]
81538185
name = "walkdir"
81548186
version = "2.5.0"

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ insta = { version = "1.38.0", features = [
6363
"glob",
6464
] }
6565
once_cell = "1.19.0"
66+
proptest = "1.10.0"
6667
reqwest = { version = "0.12.0", default-features = false }
6768

6869
schemars = { version = "1.0.0", features = ["url2"] }

‎apollo-federation/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ sha1.workspace = true
7272
similar.workspace = true
7373
tempfile.workspace = true
7474
pretty_assertions = "1.4.0"
75+
proptest.workspace = true
7576
rstest = "0.26.0"
7677
dhat = "0.3.3"
7778
test-log = { version = "0.2.16", default-features = false, features = [
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 384f08e5a24b20da17575cebb1c7adae246f7281ed6bf7e7ec6e21e0c63e92e6 # shrinks to commands = [Extend(["b { x }"]), Insert("b { x }")]

‎apollo-federation/src/operation/selection_map.rs‎

Lines changed: 233 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,17 @@ impl SelectionMap {
318318
}
319319
}
320320

321+
/// Insert a selection into the map. If a selection with an equal key is already present, it
322+
/// is overwritten in place (keeping its original position); otherwise the new selection is
323+
/// appended.
321324
pub(crate) fn insert(&mut self, value: Selection) {
322-
let hash = self.hash_key(value.key());
323-
self.raw_insert(hash, value);
325+
let key = value.key();
326+
let hash = self.hash_key(key);
327+
if let Some(bucket) = self.table.find_mut(hash, key_eq(&self.selections, key)) {
328+
self.selections[bucket.index] = value;
329+
} else {
330+
self.raw_insert(hash, value);
331+
}
324332
}
325333

326334
/// Remove a selection from the map. Returns the selection and its numeric index.
@@ -619,3 +627,226 @@ impl<'a> VacantEntry<'a> {
619627
Ok(SelectionValue::new(self.map.raw_insert(self.hash, value)))
620628
}
621629
}
630+
631+
#[cfg(test)]
632+
mod tests {
633+
use std::collections::HashSet;
634+
635+
use proptest::prelude::*;
636+
637+
use super::*;
638+
use crate::operation::tests::parse_operation;
639+
use crate::operation::tests::parse_schema;
640+
641+
const SCHEMA: &str = r#"
642+
type Query {
643+
a: T
644+
b: T
645+
c: T
646+
}
647+
648+
type T {
649+
x: Int
650+
y: Int
651+
}
652+
"#;
653+
654+
fn selection_from_source(
655+
schema: &crate::schema::ValidFederationSchema,
656+
source: &str,
657+
) -> Selection {
658+
let query = format!("{{ {source} }}");
659+
let operation = parse_operation(schema, &query);
660+
operation
661+
.selection_set
662+
.selections
663+
.values()
664+
.next()
665+
.expect("selection source must produce exactly one top-level selection")
666+
.clone()
667+
}
668+
669+
/// Minimized regression for the bug found by `selection_map_matches_last_write_wins_model`:
670+
/// inserting a selection under a key that's already present used to append a second entry
671+
/// instead of overwriting the first, in violation of the documented last-write-wins contract.
672+
#[test]
673+
fn insert_overwrites_equal_key_without_changing_position() {
674+
let schema = parse_schema(SCHEMA);
675+
let mut map = SelectionMap::new();
676+
677+
map.insert(selection_from_source(&schema, "a { x }"));
678+
map.insert(selection_from_source(&schema, "b { x }"));
679+
map.insert(selection_from_source(&schema, "b { y }"));
680+
681+
assert_eq!(
682+
map.len(),
683+
2,
684+
"the second `b` insertion should overwrite the first"
685+
);
686+
let values: Vec<&Selection> = map.values().collect();
687+
assert_eq!(values[0], &selection_from_source(&schema, "a { x }"));
688+
assert_eq!(
689+
values[1],
690+
&selection_from_source(&schema, "b { y }"),
691+
"the surviving `b` entry should hold the last-written value, in its original position"
692+
);
693+
}
694+
695+
/// A slow, obviously-correct model of `SelectionMap`'s documented last-write-wins overwrite
696+
/// semantics: a key that already exists keeps its position but gets the new value.
697+
fn model_insert(model: &mut Vec<(OwnedSelectionKey, Selection)>, value: Selection) {
698+
let key = value.key().to_owned_key();
699+
if let Some(existing) = model.iter_mut().find(|(k, _)| *k == key) {
700+
existing.1 = value;
701+
} else {
702+
model.push((key, value));
703+
}
704+
}
705+
706+
fn response_name() -> impl Strategy<Value = &'static str> {
707+
prop_oneof![Just("a"), Just("b"), Just("c")]
708+
}
709+
710+
fn key_name() -> impl Strategy<Value = &'static str> {
711+
prop_oneof![Just("a"), Just("b"), Just("c"), Just("z")]
712+
}
713+
714+
fn sub_selection() -> impl Strategy<Value = &'static str> {
715+
prop_oneof![Just("x"), Just("y"), Just("x y")]
716+
}
717+
718+
fn selection_source() -> impl Strategy<Value = String> {
719+
(response_name(), sub_selection()).prop_map(|(name, sub)| format!("{name} {{ {sub} }}"))
720+
}
721+
722+
#[derive(Debug, Clone)]
723+
enum Command {
724+
Insert(String),
725+
Get(String),
726+
Remove(String),
727+
Retain(Vec<String>),
728+
Extend(Vec<String>),
729+
}
730+
731+
fn command() -> impl Strategy<Value = Command> {
732+
prop_oneof![
733+
3 => selection_source().prop_map(Command::Insert),
734+
2 => key_name().prop_map(|n| Command::Get(n.to_string())),
735+
2 => key_name().prop_map(|n| Command::Remove(n.to_string())),
736+
1 => prop::collection::vec(key_name(), 0..3)
737+
.prop_map(|names| Command::Retain(names.into_iter().map(String::from).collect())),
738+
2 => prop::collection::vec(selection_source(), 0..3).prop_map(Command::Extend),
739+
]
740+
}
741+
742+
/// Compares production state against the model: length, iteration order and content,
743+
/// structural (order-independent) equality against a freshly reconstructed map, and
744+
/// lookups for every name in the small generated key universe.
745+
fn assert_map_matches_model(map: &SelectionMap, model: &[(OwnedSelectionKey, Selection)]) {
746+
let actual: Vec<&Selection> = map.values().collect();
747+
let expected: Vec<&Selection> = model.iter().map(|(_, value)| value).collect();
748+
assert_eq!(actual, expected, "iteration order/content mismatch");
749+
assert_eq!(map.len(), model.len());
750+
assert_eq!(map.is_empty(), model.is_empty());
751+
752+
let rebuilt: SelectionMap = model.iter().map(|(_, value)| value.clone()).collect();
753+
assert_eq!(
754+
map, &rebuilt,
755+
"map should equal a freshly reconstructed map"
756+
);
757+
758+
for name in ["a", "b", "c", "z"] {
759+
let name = Name::new(name).unwrap();
760+
let key = SelectionKey::field_name(&name);
761+
let owned_key = key.to_owned_key();
762+
let expected = model
763+
.iter()
764+
.find(|(k, _)| *k == owned_key)
765+
.map(|(_, value)| value);
766+
assert_eq!(map.get(key), expected, "lookup mismatch for {name}");
767+
assert_eq!(map.contains_key(key), expected.is_some());
768+
}
769+
}
770+
771+
proptest! {
772+
#![proptest_config(ProptestConfig::with_cases(256))]
773+
774+
#[test]
775+
fn selection_map_matches_last_write_wins_model(commands in prop::collection::vec(command(), 0..40)) {
776+
let schema = parse_schema(SCHEMA);
777+
let mut map = SelectionMap::new();
778+
let mut model: Vec<(OwnedSelectionKey, Selection)> = Vec::new();
779+
780+
for command in commands {
781+
match command {
782+
Command::Insert(source) => {
783+
let selection = selection_from_source(&schema, &source);
784+
map.insert(selection.clone());
785+
model_insert(&mut model, selection);
786+
}
787+
Command::Get(name) => {
788+
let name = Name::new(&name).unwrap();
789+
let key = SelectionKey::field_name(&name);
790+
let owned_key = key.to_owned_key();
791+
let expected = model
792+
.iter()
793+
.find(|(k, _)| *k == owned_key)
794+
.map(|(_, value)| value.clone());
795+
prop_assert_eq!(map.get(key).cloned(), expected);
796+
}
797+
Command::Remove(name) => {
798+
let name = Name::new(&name).unwrap();
799+
let key = SelectionKey::field_name(&name);
800+
let owned_key = key.to_owned_key();
801+
let model_index = model.iter().position(|(k, _)| *k == owned_key);
802+
let removed = map.remove(key);
803+
match (removed, model_index) {
804+
(Some((index, selection)), Some(model_index)) => {
805+
prop_assert_eq!(index, model_index);
806+
let (_, model_selection) = model.remove(model_index);
807+
prop_assert_eq!(selection, model_selection);
808+
}
809+
(None, None) => {}
810+
(actual, expected_index) => {
811+
prop_assert!(
812+
false,
813+
"remove presence mismatch: actual={:?} expected_index={:?}",
814+
actual,
815+
expected_index
816+
);
817+
}
818+
}
819+
}
820+
Command::Retain(names) => {
821+
let keep: HashSet<String> = names.into_iter().collect();
822+
map.retain(|key, _| match key {
823+
SelectionKey::Field { response_name, .. } => {
824+
keep.contains(response_name.as_str())
825+
}
826+
_ => false,
827+
});
828+
model.retain(|(key, _)| match key {
829+
OwnedSelectionKey::Field { response_name, .. } => {
830+
keep.contains(response_name.as_str())
831+
}
832+
_ => false,
833+
});
834+
}
835+
Command::Extend(sources) => {
836+
let selections: Vec<Selection> = sources
837+
.iter()
838+
.map(|source| selection_from_source(&schema, source))
839+
.collect();
840+
let other: SelectionMap = selections.iter().cloned().collect();
841+
map.extend(other);
842+
for selection in selections {
843+
model_insert(&mut model, selection);
844+
}
845+
}
846+
}
847+
848+
assert_map_matches_model(&map, &model);
849+
}
850+
}
851+
}
852+
}

0 commit comments

Comments
 (0)