Skip to content

Equal PersistentOrderedSets are not equal #204 #217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 58 commits into from
Apr 28, 2025
Merged
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
adef358
198: Add simple equals test
DmitryNekrasov Apr 14, 2025
323f5e6
198: Add test from GitHub issue, it passes
DmitryNekrasov Apr 14, 2025
e91449c
198: Add some checks
DmitryNekrasov Apr 14, 2025
a6d39a5
204: PersistentOrderedSetTest.equalsTestFromGitHubIssue
DmitryNekrasov Apr 14, 2025
0502de2
204: Simplify equalsTestFromGitHubIssue
DmitryNekrasov Apr 14, 2025
3f6e472
204: Simplify equalsTestFromGitHubIssue
DmitryNekrasov Apr 14, 2025
0091e35
204: Simplify equalsTestFromGitHubIssue
DmitryNekrasov Apr 14, 2025
c31a5cb
204: Simplify equalsTestFromGitHubIssue
DmitryNekrasov Apr 14, 2025
7d7f44b
204: Add comment with strange behaviour
DmitryNekrasov Apr 14, 2025
fd8d6bf
204: Add equalsTestPersistentMap
DmitryNekrasov Apr 14, 2025
9b7d94f
204: Refactor equalsTestPersistentMap
DmitryNekrasov Apr 14, 2025
0bac539
204: Refactor equalsTestPersistentMap
DmitryNekrasov Apr 14, 2025
8d4cda7
204: equalsTestPersistentMap
DmitryNekrasov Apr 18, 2025
08d93ed
204: Add simpleTest
DmitryNekrasov Apr 18, 2025
5e175fb
204: Add simpleTest2
DmitryNekrasov Apr 18, 2025
5903400
204: Add Node value promouting
DmitryNekrasov Apr 18, 2025
6b3feba
204: Rollback return in makeNode
DmitryNekrasov Apr 18, 2025
217a0aa
204: Remove mutableUpdateNodeAtIndex
DmitryNekrasov Apr 18, 2025
06cd711
204: Add tests
DmitryNekrasov Apr 18, 2025
ba76ebb
204: Add persistentMapEqualsTest tests
DmitryNekrasov Apr 18, 2025
3510f7c
204: Refactoring
DmitryNekrasov Apr 18, 2025
b2a1e47
204: Rename tests
DmitryNekrasov Apr 18, 2025
400870d
204: Add testReproduceOverIterationIssue
DmitryNekrasov Apr 19, 2025
e58c4d6
204: Refactoring
DmitryNekrasov Apr 19, 2025
95c399e
204: Refactor testReproduceOverIterationIssue
DmitryNekrasov Apr 20, 2025
7a609f6
204: Add iteratorRemoveCalledTwiceThrowsIllegalStateExceptionTest
DmitryNekrasov Apr 20, 2025
7301ded
204: Add removing elements from different iterators throws Concurrent…
DmitryNekrasov Apr 20, 2025
810ebb7
204: Refactor testReproduceOverIterationIssue
DmitryNekrasov Apr 20, 2025
0612c43
204: Add promotion check to resetPath
DmitryNekrasov Apr 20, 2025
4efe7be
204: testReproduceOverIterationIssue passes
DmitryNekrasov Apr 20, 2025
dade1a3
204: Add testReproduceOverIterationIssue2
DmitryNekrasov Apr 20, 2025
df1f188
204: Refactor testReproduceOverIterationIssue2
DmitryNekrasov Apr 20, 2025
9e86a74
204: Simplify testReproduceOverIterationIssue2
DmitryNekrasov Apr 20, 2025
42cf312
204: testReproduceOverIterationIssue2 passes
DmitryNekrasov Apr 20, 2025
b93994c
204: Fix all iterator tests!
DmitryNekrasov Apr 20, 2025
4f9c54b
204: Remove ` char from test names
DmitryNekrasov Apr 20, 2025
5bcb156
204: Rollback default field in ObjectWrapper
DmitryNekrasov Apr 20, 2025
b1a5b24
204: Pass an owner in updateNodeAtIndex
DmitryNekrasov Apr 20, 2025
97256e5
204: Remove default 0 index
DmitryNekrasov Apr 20, 2025
fdaa0f7
204: Remove component1, component2 imports
DmitryNekrasov Apr 20, 2025
112cf9a
204: Add removingElementFromOneIteratorAndAccessingAnotherThrowsConcu…
DmitryNekrasov Apr 21, 2025
4971505
204: Rename tests
DmitryNekrasov Apr 21, 2025
94c33a3
204: Rename valuePromotionAfterMutableRemovingTest test
DmitryNekrasov Apr 21, 2025
f082a92
204: Rename tests with `` chars
DmitryNekrasov Apr 22, 2025
9735056
204: Remove PersistentOrderedMapTest
DmitryNekrasov Apr 25, 2025
21b6945
204: Avoid Int boxing
DmitryNekrasov Apr 25, 2025
84d7021
204: Add content check to iterationsAfterPromotionTest
DmitryNekrasov Apr 25, 2025
110cb36
204: Add validatePromotion call to iterationsAfterPromotionTest and i…
DmitryNekrasov Apr 25, 2025
4773226
204: Rename tests
DmitryNekrasov Apr 25, 2025
6e0ebed
204: Rename tests
DmitryNekrasov Apr 25, 2025
c4cd5f3
204: Move `if the collision is of size 2 and one of the keys is remov…
DmitryNekrasov Apr 25, 2025
5e6119c
204: Move PersistentHashMapBuilder tests
DmitryNekrasov Apr 25, 2025
16f015b
204: Move `persistentSet and their builder should be equal before and…
DmitryNekrasov Apr 25, 2025
95980bb
204: `Add persistentHashSet and their builder should be equal before …
DmitryNekrasov Apr 25, 2025
61085fa
204: Remove imports
DmitryNekrasov Apr 25, 2025
a339204
204: Add PersistentHashSetBuilderTest
DmitryNekrasov Apr 25, 2025
7a3ea2e
204: Remove comma from test name
DmitryNekrasov Apr 25, 2025
16bd081
204: Add comments to describe how removedKeyPositionMask check works
DmitryNekrasov Apr 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -57,7 +57,7 @@ internal open class PersistentHashMapBuilderBaseIterator<K, V, T>(
val currentKey = currentKey()

builder.remove(lastIteratedKey)
resetPath(currentKey.hashCode(), builder.node, currentKey, 0)
resetPath(currentKey.hashCode(), builder.node, currentKey, 0, lastIteratedKey.hashCode(), afterRemove = true)
} else {
builder.remove(lastIteratedKey)
}
@@ -82,7 +82,7 @@ internal open class PersistentHashMapBuilderBaseIterator<K, V, T>(
expectedModCount = builder.modCount
}

private fun resetPath(keyHash: Int, node: TrieNode<*, *>, key: K, pathIndex: Int) {
private fun resetPath(keyHash: Int, node: TrieNode<*, *>, key: K, pathIndex: Int, removedKeyHash: Int = 0, afterRemove: Boolean = false) {
val shift = pathIndex * LOG_MAX_BRANCHING_FACTOR

if (shift > MAX_SHIFT) { // collision
@@ -99,6 +99,21 @@ internal open class PersistentHashMapBuilderBaseIterator<K, V, T>(
if (node.hasEntryAt(keyPositionMask)) { // key is directly in buffer
val keyIndex = node.entryKeyIndex(keyPositionMask)

// After removing an element, we need to handle node promotion properly to maintain a correct iteration order.
// `removedKeyPositionMask` represents the bit position of the removed key's hash at the current level.
// This is needed to detect if the current key was potentially promoted from a deeper level.
val removedKeyPositionMask = if (afterRemove) 1 shl indexSegment(removedKeyHash, shift) else 0

// Check if the removed key is at the same position as the current key and was previously at a deeper level.
// This indicates a node promotion occurred during removal,
// and we need to handle it in a special way to prevent re-traversing already visited elements.
if (keyPositionMask == removedKeyPositionMask && pathIndex < pathLastIndex) {
// Instead of traversing the normal way, we create a special path entry at the previous depth
// that points directly to the promoted entry, maintaining the original iteration sequence.
path[pathLastIndex].reset(arrayOf(node.buffer[keyIndex], node.buffer[keyIndex + 1]), ENTRY_SIZE)
return
}

// assert(node.keyAtIndex(keyIndex) == key)

path[pathIndex].reset(node.buffer, ENTRY_SIZE * node.entryCount(), keyIndex)
@@ -111,7 +126,7 @@ internal open class PersistentHashMapBuilderBaseIterator<K, V, T>(
val nodeIndex = node.nodeIndex(keyPositionMask)
val targetNode = node.nodeAtIndex(nodeIndex)
path[pathIndex].reset(node.buffer, ENTRY_SIZE * node.entryCount(), nodeIndex)
resetPath(keyHash, targetNode, key, pathIndex + 1)
resetPath(keyHash, targetNode, key, pathIndex + 1, removedKeyHash, afterRemove)
}

private fun checkNextWasInvoked() {
28 changes: 6 additions & 22 deletions core/commonMain/src/implementations/immutableMap/TrieNode.kt
Original file line number Diff line number Diff line change
@@ -180,7 +180,7 @@ internal class TrieNode<K, V>(
}

/** The given [newNode] must not be a part of any persistent map instance. */
private fun updateNodeAtIndex(nodeIndex: Int, positionMask: Int, newNode: TrieNode<K, V>): TrieNode<K, V> {
private fun updateNodeAtIndex(nodeIndex: Int, positionMask: Int, newNode: TrieNode<K, V>, owner: MutabilityOwnership? = null): TrieNode<K, V> {
// assert(buffer[nodeIndex] !== newNode)
val newNodeBuffer = newNode.buffer
if (newNodeBuffer.size == 2 && newNode.nodeMap == 0) {
@@ -192,30 +192,14 @@ internal class TrieNode<K, V>(

val keyIndex = entryKeyIndex(positionMask)
val newBuffer = buffer.replaceNodeWithEntry(nodeIndex, keyIndex, newNodeBuffer[0], newNodeBuffer[1])
return TrieNode(dataMap xor positionMask, nodeMap xor positionMask, newBuffer)
return TrieNode(dataMap xor positionMask, nodeMap xor positionMask, newBuffer, owner)
}

val newBuffer = buffer.copyOf(buffer.size)
newBuffer[nodeIndex] = newNode
return TrieNode(dataMap, nodeMap, newBuffer)
}

/** The given [newNode] must not be a part of any persistent map instance. */
private fun mutableUpdateNodeAtIndex(nodeIndex: Int, newNode: TrieNode<K, V>, owner: MutabilityOwnership): TrieNode<K, V> {
assert(newNode.ownedBy === owner)
// assert(buffer[nodeIndex] !== newNode)

// nodes (including collision nodes) that have only one entry are upped if they have no siblings
if (buffer.size == 1 && newNode.buffer.size == ENTRY_SIZE && newNode.nodeMap == 0) {
// assert(dataMap == 0 && nodeMap xor positionMask == 0)
newNode.dataMap = nodeMap
return newNode
}

if (ownedBy === owner) {
if (owner != null && ownedBy === owner) {
buffer[nodeIndex] = newNode
return this
}

val newBuffer = buffer.copyOf()
newBuffer[nodeIndex] = newNode
return TrieNode(dataMap, nodeMap, newBuffer, owner)
@@ -716,7 +700,7 @@ internal class TrieNode<K, V>(
if (targetNode === newNode) {
return this
}
return mutableUpdateNodeAtIndex(nodeIndex, newNode, mutator.ownership)
return updateNodeAtIndex(nodeIndex, keyPositionMask, newNode, mutator.ownership)
}

// key is absent
@@ -791,7 +775,7 @@ internal class TrieNode<K, V>(
newNode == null ->
mutableRemoveNodeAtIndex(nodeIndex, positionMask, owner)
targetNode !== newNode ->
mutableUpdateNodeAtIndex(nodeIndex, newNode, owner)
updateNodeAtIndex(nodeIndex, positionMask, newNode, owner)
else -> this
}

121 changes: 121 additions & 0 deletions core/commonTest/src/contract/map/PersistentHashMapBuilderTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright 2016-2025 JetBrains s.r.o.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package tests.contract.map

import kotlinx.collections.immutable.implementations.immutableMap.PersistentHashMap
import kotlinx.collections.immutable.persistentHashMapOf
import tests.stress.IntWrapper
import kotlin.collections.iterator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class PersistentHashMapBuilderTest {

@Test
fun `should correctly iterate after removing integer key and promotion colliding key during iteration`() {
val removedKey = 0
val map: PersistentHashMap<Int, String> =
persistentHashMapOf(1 to "a", 2 to "b", 3 to "c", removedKey to "y", 32 to "z")
as PersistentHashMap<Int, String>

validatePromotion(map, removedKey)
}

@Test
fun `should correctly iterate after removing IntWrapper key and promotion colliding key during iteration`() {
val removedKey = IntWrapper(0, 0)
val map: PersistentHashMap<IntWrapper, String> = persistentHashMapOf(
removedKey to "a",
IntWrapper(1, 0) to "b",
IntWrapper(2, 32) to "c",
IntWrapper(3, 32) to "d"
) as PersistentHashMap<IntWrapper, String>

validatePromotion(map, removedKey)
}

private fun <K> validatePromotion(map: PersistentHashMap<K, *>, removedKey: K) {
val builder = map.builder()
val iterator = builder.entries.iterator()

val expectedCount = map.size
var actualCount = 0

while (iterator.hasNext()) {
val (key, _) = iterator.next()
if (key == removedKey) {
iterator.remove()
}
actualCount++
}

val resultMap = builder.build()
for ((key, value) in map) {
if (key != removedKey) {
assertTrue(key in resultMap)
assertEquals(resultMap[key], value)
} else {
assertFalse(key in resultMap)
}
}

assertEquals(expectedCount, actualCount)
}

@Test
fun `removing twice on iterators throws IllegalStateException`() {
val map: PersistentHashMap<Int, String> =
persistentHashMapOf(1 to "a", 2 to "b", 3 to "c", 0 to "y", 32 to "z") as PersistentHashMap<Int, String>
val builder = map.builder()
val iterator = builder.entries.iterator()

assertFailsWith<IllegalStateException> {
while (iterator.hasNext()) {
val (key, _) = iterator.next()
if (key == 0) iterator.remove()
if (key == 0) {
iterator.remove()
iterator.remove()
}
}
}
}

@Test
fun `removing elements from different iterators throws ConcurrentModificationException`() {
val map: PersistentHashMap<Int, String> =
persistentHashMapOf(1 to "a", 2 to "b", 3 to "c", 0 to "y", 32 to "z") as PersistentHashMap<Int, String>
val builder = map.builder()
val iterator1 = builder.entries.iterator()
val iterator2 = builder.entries.iterator()

assertFailsWith<ConcurrentModificationException> {
while (iterator1.hasNext()) {
val (key, _) = iterator1.next()
iterator2.next()
if (key == 0) iterator1.remove()
if (key == 2) iterator2.remove()
}
}
}

@Test
fun `removing element from one iterator and accessing another throws ConcurrentModificationException`() {
val map = persistentHashMapOf(1 to "a", 2 to "b", 3 to "c")
val builder = map.builder()
val iterator1 = builder.entries.iterator()
val iterator2 = builder.entries.iterator()

assertFailsWith<ConcurrentModificationException> {
iterator1.next()
iterator1.remove()
iterator2.next()
}
}
}
35 changes: 35 additions & 0 deletions core/commonTest/src/contract/map/PersistentHashMapTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2016-2025 JetBrains s.r.o.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package tests.contract.map

import kotlinx.collections.immutable.implementations.immutableMap.PersistentHashMap
import kotlinx.collections.immutable.persistentHashMapOf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class PersistentHashMapTest {

@Test
fun `if the collision is of size 2 and one of the keys is removed the remaining key must be promoted`() {
val map1: PersistentHashMap<Int, String> =
persistentHashMapOf(-1 to "a", 0 to "b", 32 to "c") as PersistentHashMap<Int, String>
val builder = map1.builder()
val map2 = builder.build()

assertTrue(map1.equals(builder))
assertEquals(map1, map2.toMap())
assertEquals(map1, map2)

val map3 = map1.remove(0)
builder.remove(0)
val map4 = builder.build()

assertTrue(map3.equals(builder))
assertEquals(map3, map4.toMap())
assertEquals(map3, map4)
}
}
119 changes: 119 additions & 0 deletions core/commonTest/src/contract/set/PersistentHashSetBuilderTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright 2016-2025 JetBrains s.r.o.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package tests.contract.set

import kotlinx.collections.immutable.implementations.immutableSet.PersistentHashSet
import kotlinx.collections.immutable.persistentHashSetOf
import tests.stress.IntWrapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class PersistentHashSetBuilderTest {

@Test
fun `should correctly iterate after removing integer element`() {
val removedElement = 0
val set: PersistentHashSet<Int> =
persistentHashSetOf(1, 2, 3, removedElement, 32)
as PersistentHashSet<Int>

validate(set, removedElement)
}

@Test
fun `should correctly iterate after removing IntWrapper element`() {
val removedElement = IntWrapper(0, 0)
val set: PersistentHashSet<IntWrapper> = persistentHashSetOf(
removedElement,
IntWrapper(1, 0),
IntWrapper(2, 32),
IntWrapper(3, 32)
) as PersistentHashSet<IntWrapper>

validate(set, removedElement)
}

private fun <E> validate(set: PersistentHashSet<E>, removedElement: E) {
val builder = set.builder()
val iterator = builder.iterator()

val expectedCount = set.size
var actualCount = 0

while (iterator.hasNext()) {
val element = iterator.next()
if (element == removedElement) {
iterator.remove()
}
actualCount++
}

val resultSet = builder.build()
for (element in set) {
if (element != removedElement) {
assertTrue(element in resultSet)
} else {
assertFalse(element in resultSet)
}
}

assertEquals(expectedCount, actualCount)
}

@Test
fun `removing twice on iterators throws IllegalStateException`() {
val set: PersistentHashSet<Int> =
persistentHashSetOf(1, 2, 3, 0, 32) as PersistentHashSet<Int>
val builder = set.builder()
val iterator = builder.iterator()

assertFailsWith<IllegalStateException> {
while (iterator.hasNext()) {
val element = iterator.next()
if (element == 0) iterator.remove()
if (element == 0) {
iterator.remove()
iterator.remove()
}
}
}
}

@Test
fun `removing elements from different iterators throws ConcurrentModificationException`() {
val set: PersistentHashSet<Int> =
persistentHashSetOf(1, 2, 3, 0, 32) as PersistentHashSet<Int>
val builder = set.builder()
val iterator1 = builder.iterator()
val iterator2 = builder.iterator()

assertFailsWith<ConcurrentModificationException> {
while (iterator1.hasNext()) {
val element1 = iterator1.next()
iterator2.next()
if (element1 == 0) iterator1.remove()
if (element1 == 2) iterator2.remove()
}
}
}

@Test
fun `removing element from one iterator and accessing another throws ConcurrentModificationException`() {
val set = persistentHashSetOf(1, 2, 3)
val builder = set.builder()
val iterator1 = builder.iterator()
val iterator2 = builder.iterator()

assertFailsWith<ConcurrentModificationException> {
iterator1.next()
iterator1.remove()
iterator2.next()
}
}
}
30 changes: 30 additions & 0 deletions core/commonTest/src/contract/set/PersistentHashSetTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2016-2025 JetBrains s.r.o.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package tests.contract.set

import kotlinx.collections.immutable.persistentHashSetOf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class PersistentHashSetTest {

@Test
fun `persistentHashSet and their builder should be equal before and after modification`() {
val set1 = persistentHashSetOf(-1, 0, 32)
val builder = set1.builder()

assertTrue(set1.equals(builder))
assertEquals(set1, builder.build())
assertEquals(set1, builder.build().toSet())

val set2 = set1.remove(0)
builder.remove(0)

assertEquals(set2, builder.build().toSet())
assertEquals(set2, builder.build())
}
}
33 changes: 33 additions & 0 deletions core/commonTest/src/contract/set/PersistentOrderedSetTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2016-2025 JetBrains s.r.o.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package tests.contract.set

import kotlinx.collections.immutable.persistentSetOf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class PersistentOrderedSetTest {

/**
* Test from issue: https://github.com/Kotlin/kotlinx.collections.immutable/issues/204
*/
@Test
fun `persistentOrderedSet and their builder should be equal before and after modification`() {
val set1 = persistentSetOf(-486539264, 16777216, 0, 67108864)
val builder = set1.builder()

assertTrue(set1.equals(builder))
assertEquals(set1, builder.build())
assertEquals(set1, builder.build().toSet())

val set2 = set1.remove(0)
builder.remove(0)

assertEquals(set2, builder.build().toSet())
assertEquals(set2, builder.build())
}
}