Skip to content

Commit 7b23ace

Browse files
authored
Fix ResolvedKeySpacePath.equals and hashCode (#3591)
This fixes `ResolvedKeySpace.equals` and `hashCode` so that they follow the spec and are consistent. Notably there were a few issues: - They did not correctly handle `KeyType.BYTES` - They disagreed about what data to use (hashCode used to look at the `PathValue`, while equals looked at the value) - `PathValue` did not implement `equals`/`hashCode` - The `remainder` was not included. - KeySpacePathImpl redundantly checked the value from the directory - It did not check the parent of the ResolvedKeySpacePath. Fixes: #3594
1 parent 3e173c8 commit 7b23ace

File tree

7 files changed

+507
-27
lines changed

7 files changed

+507
-27
lines changed

fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectory.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,8 @@ public Object getValue() {
710710
return value;
711711
}
712712

713-
protected static boolean areEqual(Object o1, Object o2) {
713+
@SuppressWarnings("PMD.CompareObjectsWithEquals") // we use ref
714+
protected static boolean areEqual(@Nullable Object o1, @Nullable Object o2) {
714715
if (o1 == null) {
715716
return o2 == null;
716717
} else {
@@ -719,6 +720,12 @@ protected static boolean areEqual(Object o1, Object o2) {
719720
}
720721
}
721722

723+
// Handle ANY_VALUE specially - typeOf does not support ANY_VALUE
724+
boolean isAnyValue = (o1 == ANY_VALUE || o2 == ANY_VALUE);
725+
if (isAnyValue) {
726+
return Objects.equals(o1, o2);
727+
}
728+
722729
KeyType o1Type = KeyType.typeOf(o1);
723730
if (o1Type != KeyType.typeOf(o2)) {
724731
return false;
@@ -740,6 +747,31 @@ protected static boolean areEqual(Object o1, Object o2) {
740747
}
741748
}
742749

750+
protected static int valueHashCode(@Nullable Object value) {
751+
if (value == null) {
752+
return 0;
753+
}
754+
755+
// Handle ANY_VALUE specially
756+
if (value == ANY_VALUE) {
757+
return System.identityHashCode(value);
758+
}
759+
760+
switch (KeyType.typeOf(value)) {
761+
case BYTES:
762+
return Arrays.hashCode((byte[]) value);
763+
case LONG:
764+
case STRING:
765+
case FLOAT:
766+
case DOUBLE:
767+
case BOOLEAN:
768+
case UUID:
769+
return Objects.hashCode(value);
770+
default:
771+
throw new RecordCoreException("Unexpected key type " + KeyType.typeOf(value));
772+
}
773+
}
774+
743775
/**
744776
* Returns the path that leads up to this directory (including this directory), and returns it as a string
745777
* that looks something like a filesystem path.

fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpacePathImpl.java

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -276,27 +276,21 @@ public boolean equals(Object obj) {
276276
}
277277
KeySpacePath that = (KeySpacePath) obj;
278278

279-
// Check that the KeySpaceDirectories of the two paths are "equal enough".
280-
// Even this is probably overkill since the isCompatible check in KeySpaceDirectory
281-
// will keep us from doing anything too bad. We could move this check into KeySpaceDirectory
282-
// but comparing two directories by value would necessitate traversing the entire directory
283-
// tree, so instead we will use a narrower definition of equality here.
284-
boolean directoriesEqual = Objects.equals(this.getDirectory().getKeyType(), that.getDirectory().getKeyType()) &&
285-
Objects.equals(this.getDirectory().getName(), that.getDirectory().getName()) &&
286-
Objects.equals(this.getDirectory().getValue(), that.getDirectory().getValue());
279+
// Directories use reference equality, because the expected usage is that they go into a
280+
// singleton KeySpace.
281+
boolean directoriesEqual = this.getDirectory().equals(that.getDirectory());
287282

283+
// the values might be byte[]
288284
return directoriesEqual &&
289-
Objects.equals(this.getValue(), that.getValue()) &&
290-
Objects.equals(this.getParent(), that.getParent());
285+
KeySpaceDirectory.areEqual(this.getValue(), that.getValue()) &&
286+
Objects.equals(this.getParent(), that.getParent());
291287
}
292288

293289
@Override
294290
public int hashCode() {
295291
return Objects.hash(
296-
getDirectory().getKeyType(),
297-
getDirectory().getName(),
298-
getDirectory().getValue(),
299-
getValue(),
292+
getDirectory(),
293+
KeySpaceDirectory.valueHashCode(getValue()),
300294
parent);
301295
}
302296

fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/PathValue.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@
2424

2525
import javax.annotation.Nullable;
2626
import java.util.Arrays;
27+
import java.util.Objects;
2728

2829
/**
2930
* A class to represent the value stored at a particular element of a {@link KeySpacePath}. The <code>resolvedValue</code>
3031
* is the object that will appear in the {@link com.apple.foundationdb.tuple.Tuple} when
3132
* {@link KeySpacePath#toTuple(com.apple.foundationdb.record.provider.foundationdb.FDBRecordContext)} is invoked.
3233
* The <code>metadata</code> is left null by {@link KeySpaceDirectory} but other implementations may make use of
33-
* it (e.g. {@link DirectoryLayerDirectory}.
34+
* it (e.g. {@link DirectoryLayerDirectory}).
3435
*/
3536
@API(API.Status.UNSTABLE)
3637
public class PathValue {
@@ -69,4 +70,22 @@ public Object getResolvedValue() {
6970
public byte[] getMetadata() {
7071
return metadata == null ? null : Arrays.copyOf(metadata, metadata.length);
7172
}
73+
74+
@Override
75+
public boolean equals(Object other) {
76+
if (this == other) {
77+
return true;
78+
}
79+
if (!(other instanceof PathValue)) {
80+
return false;
81+
}
82+
PathValue that = (PathValue) other;
83+
return KeySpaceDirectory.areEqual(this.resolvedValue, that.resolvedValue) &&
84+
Arrays.equals(this.metadata, that.metadata);
85+
}
86+
87+
@Override
88+
public int hashCode() {
89+
return Objects.hash(KeySpaceDirectory.valueHashCode(resolvedValue), Arrays.hashCode(metadata));
90+
}
7291
}

fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/ResolvedKeySpacePath.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,13 +229,15 @@ public boolean equals(Object other) {
229229
}
230230

231231
ResolvedKeySpacePath otherPath = (ResolvedKeySpacePath) other;
232-
return this.inner.equals(otherPath.inner)
233-
&& Objects.equals(this.getResolvedValue(), otherPath.getResolvedValue());
232+
return Objects.equals(this.getResolvedPathValue(), otherPath.getResolvedPathValue()) &&
233+
Objects.equals(this.getParent(), otherPath.getParent()) &&
234+
this.inner.equals(otherPath.inner) &&
235+
Objects.equals(this.remainder, otherPath.remainder);
234236
}
235237

236238
@Override
237239
public int hashCode() {
238-
return Objects.hash(inner, getResolvedPathValue());
240+
return Objects.hash(inner, getResolvedPathValue(), remainder, getParent());
239241
}
240242

241243
@Override

fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectoryTest.java

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,16 @@ public KeyTypeValue(KeyType keyType, @Nullable Object value, @Nullable Object va
110110
assertTrue(keyType.isMatch(value));
111111
assertTrue(keyType.isMatch(generator.get()));
112112
}
113+
114+
@Override
115+
public String toString() {
116+
return "KeyTypeValue{" + keyType + '}';
117+
}
113118
}
114119

115-
private final Random random = new Random();
120+
private static final Random random = new Random();
116121

117-
private final List<KeyTypeValue> valueOfEveryType = new ImmutableList.Builder<KeyTypeValue>()
122+
private static final List<KeyTypeValue> valueOfEveryType = new ImmutableList.Builder<KeyTypeValue>()
118123
.add(new KeyTypeValue(KeyType.NULL, null, null, () -> null))
119124
.add(new KeyTypeValue(KeyType.BYTES, new byte[] { 0x01, 0x02 }, new byte[] { 0x03, 0x04 }, () -> {
120125
int size = random.nextInt(10) + 1;
@@ -1224,12 +1229,6 @@ public TestWrapper1(KeySpacePath inner) {
12241229
}
12251230
}
12261231

1227-
private static class TestWrapper2 extends KeySpacePathWrapper {
1228-
public TestWrapper2(KeySpacePath inner) {
1229-
super(inner);
1230-
}
1231-
}
1232-
12331232
@Test
12341233
public void testListConstantValue() {
12351234
// Create a root directory called "a" with subdirs of every type and a constant value
@@ -1493,6 +1492,65 @@ public void testPathCompareByValue() {
14931492
assertEquals(p1.hashCode(), sameAsP1.hashCode(), "they have the same hash code");
14941493
}
14951494

1495+
/**
1496+
* {@code KeySpaceDirectory}s are supposed to be inserted into a singleton {@link KeySpace}, thus we can use
1497+
* reference equality to do comparisons. This is particularly important for the efficiency of
1498+
* {@link KeySpacePathImpl#equals(Object)}, because we don't want it to have to re-compare all of the children of the
1499+
* directory as you go up through the parents. If using reference equality turns out to be problematic,
1500+
* we'll want to look at other solutions, such as ignoring the hierarchy, or something more tricky.
1501+
*/
1502+
@Test
1503+
void testKeySpaceDirectoryEqualsUsesReferenceEquality() {
1504+
// Create two directories with identical properties
1505+
KeySpaceDirectory dir1 = new KeySpaceDirectory("test", KeyType.STRING, "value");
1506+
KeySpaceDirectory dir2 = new KeySpaceDirectory("test", KeyType.STRING, "value");
1507+
1508+
// KeySpaceDirectory.equals should use reference equality
1509+
assertEquals(dir1, dir1, "Directory should equal itself");
1510+
assertNotEquals(dir1, dir2, "Directories with same properties should not be equal (reference equality)");
1511+
1512+
// Test with different properties
1513+
KeySpaceDirectory dir3 = new KeySpaceDirectory("different", KeyType.LONG, 42L);
1514+
assertNotEquals(dir1, dir3, "Directories with different properties should not be equal");
1515+
1516+
// Test with null
1517+
assertNotEquals(dir1, null, "Directory should not equal null, and calling with null shouldn't error");
1518+
1519+
// Test with different object type
1520+
assertNotEquals(dir1, "not a directory", "Directory should not equal a different type");
1521+
}
1522+
1523+
@Test
1524+
void testKeySpaceDirectoryHashCodeFollowsReferenceSemantics() {
1525+
// Create two directories with identical properties
1526+
KeySpaceDirectory dir1 = new KeySpaceDirectory("test", KeyType.STRING, "value");
1527+
KeySpaceDirectory dir2 = new KeySpaceDirectory("test", KeyType.STRING, "value");
1528+
1529+
// Since equals uses reference equality, hashCode should be consistent with that
1530+
// (i.e., objects that are equal should have the same hashCode, but since these
1531+
// objects are not equal by reference, their hashCodes may differ)
1532+
1533+
// The same object should always have the same hashCode
1534+
int hashCode1 = dir1.hashCode();
1535+
assertEquals(hashCode1, dir1.hashCode(), "Same object should produce same hashCode");
1536+
1537+
// Different instances (even with same properties) may have different hashCodes
1538+
// We can't assert they're different, but we can verify the hashCode is stable
1539+
int hashCode2 = dir2.hashCode();
1540+
assertEquals(hashCode2, dir2.hashCode(), "Same object should produce same hashCode");
1541+
1542+
// Test that hashCode is consistent across multiple calls
1543+
for (int i = 0; i < 10; i++) {
1544+
assertEquals(hashCode1, dir1.hashCode(), "hashCode should be stable across calls");
1545+
assertEquals(hashCode2, dir2.hashCode(), "hashCode should be stable across calls");
1546+
}
1547+
// two difference references may have the same hash code, but eventually we should find a different one, even
1548+
// though all properties are the same
1549+
for (int i = 0; i < 100; i++) {
1550+
assertNotEquals(hashCode1, new KeySpaceDirectory("test", KeyType.STRING, "value").hashCode());
1551+
}
1552+
}
1553+
14961554
private List<Long> resolveBatch(FDBRecordContext context, String... names) {
14971555
List<CompletableFuture<Long>> futures = new ArrayList<>();
14981556
for (String name : names) {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* PathValueTest.java
3+
*
4+
* This source file is part of the FoundationDB open source project
5+
*
6+
* Copyright 2015-2025 Apple Inc. and the FoundationDB project authors
7+
*
8+
* Licensed under the Apache License, Version 2.0 (the "License");
9+
* you may not use this file except in compliance with the License.
10+
* You may obtain a copy of the License at
11+
*
12+
* http://www.apache.org/licenses/LICENSE-2.0
13+
*
14+
* Unless required by applicable law or agreed to in writing, software
15+
* distributed under the License is distributed on an "AS IS" BASIS,
16+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
*/
20+
21+
package com.apple.foundationdb.record.provider.foundationdb.keyspace;
22+
23+
import org.junit.jupiter.api.Test;
24+
import org.junit.jupiter.params.ParameterizedTest;
25+
import org.junit.jupiter.params.provider.Arguments;
26+
import org.junit.jupiter.params.provider.MethodSource;
27+
28+
import java.util.stream.Stream;
29+
30+
import static org.junit.jupiter.api.Assertions.assertEquals;
31+
import static org.junit.jupiter.api.Assertions.assertNotEquals;
32+
33+
/**
34+
* Tests for {@link PathValue}.
35+
*/
36+
class PathValueTest {
37+
38+
/**
39+
* Test data for PathValue equality tests.
40+
*/
41+
static Stream<Arguments> equalPathValuePairs() {
42+
return Stream.of(
43+
Arguments.of("null values", null, null, null, null),
44+
Arguments.of("same string values", "test", null, "test", null),
45+
Arguments.of("same long values", 42L, null, 42L, null),
46+
Arguments.of("same boolean values", true, null, true, null),
47+
Arguments.of("same byte[] values", new byte[] {1, 2, 3}, null, new byte[] {1, 2, 3}, null),
48+
Arguments.of("same metadata", "test", new byte[]{1, 2, 3}, "test", new byte[]{1, 2, 3})
49+
);
50+
}
51+
52+
/**
53+
* Test data for PathValue inequality tests.
54+
*/
55+
static Stream<Arguments> unequalPathValuePairs() {
56+
return Stream.of(
57+
Arguments.of("different string values", "test1", null, "test2", null),
58+
Arguments.of("different long values", 42L, null, 100L, null),
59+
Arguments.of("different boolean values", true, null, false, null),
60+
Arguments.of("different types", "string", null, 42L, null),
61+
Arguments.of("different metadata", "test", new byte[]{1, 2, 3}, "test", new byte[]{4, 5, 6}),
62+
Arguments.of("one null metadata", "test", new byte[]{1, 2, 3}, "test", null),
63+
Arguments.of("one null value", null, null, "test", null),
64+
Arguments.of("different value with same metadata", "test1", new byte[]{1, 2, 3}, "test2", new byte[]{1, 2, 3})
65+
);
66+
}
67+
68+
@ParameterizedTest(name = "{0}")
69+
@MethodSource("equalPathValuePairs")
70+
void testEqualsAndHashCodeForEqualValues(String description, Object resolvedValue1, byte[] metadata1,
71+
Object resolvedValue2, byte[] metadata2) {
72+
PathValue value1 = new PathValue(resolvedValue1, metadata1);
73+
PathValue value2 = new PathValue(resolvedValue2, metadata2);
74+
75+
assertEquals(value1, value2, "PathValues should be equal: " + description);
76+
assertEquals(value1.hashCode(), value2.hashCode(), "Equal PathValues should have equal hash codes: " + description);
77+
}
78+
79+
@ParameterizedTest(name = "{0}")
80+
@MethodSource("unequalPathValuePairs")
81+
void testNotEqualsForUnequalValues(String description, Object resolvedValue1, byte[] metadata1,
82+
Object resolvedValue2, byte[] metadata2) {
83+
PathValue value1 = new PathValue(resolvedValue1, metadata1);
84+
PathValue value2 = new PathValue(resolvedValue2, metadata2);
85+
86+
assertNotEquals(value1, value2, "PathValues should not be equal: " + description);
87+
}
88+
89+
@Test
90+
void testTrivialEquality() {
91+
PathValue value1 = new PathValue("Foo", null);
92+
93+
assertEquals(value1, value1, "Cover reference equality shortcut");
94+
assertNotEquals("Foo", value1, "Check it doesn't fail with non-PathValue");
95+
}
96+
}

0 commit comments

Comments
 (0)