diff --git a/.mvn/maven.config b/.mvn/maven.config
index bc64d227cc1..26daffc4258 100644
--- a/.mvn/maven.config
+++ b/.mvn/maven.config
@@ -1,3 +1,6 @@
+-Dmaven.wagon.httpconnectionManager.ttlSeconds=120
+-Dmaven.wagon.http.retryHandler.requestSentEnabled=true
+-Dmaven.wagon.http.retryHandler.count=10
-ntp
# timefold-solver-service-parent configures maven plugins required for Timefold models, but failing the build
# of that module itself. This way, we disable the plugin execution, while it remains active by default for models.
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java
index c8fc185e1a2..5ddd3048a0f 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java
@@ -1,14 +1,11 @@
package ai.timefold.solver.core.impl.util;
-import java.lang.reflect.Array;
import java.util.AbstractList;
import java.util.Arrays;
-import java.util.ConcurrentModificationException;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.NoSuchElementException;
-import java.util.Objects;
import java.util.function.Consumer;
import org.jspecify.annotations.NullMarked;
@@ -23,7 +20,7 @@
* It uses internal state of the entry to track insertion position of the element.
* When an entry is removed, its slot in the underlying collection is replaced with {@code null} (a gap);
* therefore, the insertion position of later elements isn't changed.
- * Gaps are removed (the list is fully compacted) when {@link #forEach(Consumer)} or {@link #add(int, Object)} is called.
+ * Gaps are removed (the list is fully compacted) when {@link #listIterator()} is called.
* {@link #get(int)} and related index-based operations compact only the prefix up to the requested index.
* This keeps the overhead low while giving us most benefits of an array-backed list.
*
@@ -31,6 +28,7 @@
* All standard {@link List} methods are also available and may run in O(n) or worse.
*
* This class is so very not thread safe.
+ * {@code modCount} is intentionally not maintained; iteration is not fail-fast (matches {@link ElementAwareLinkedList}).
*
* @param
*/
@@ -38,12 +36,16 @@
public final class ElementAwareArrayList
extends AbstractList {
+ private static final Object[] EMPTY_ARRAY = new Object[0];
private static final int REMOVED_POSITION = -1;
- private static final int DEFAULT_CAPACITY = 16;
- private @Nullable Entry @Nullable [] entries;
+ private static final int DEFAULT_CAPACITY = 2;
+ private static final int RETAIN_THRESHOLD = DEFAULT_CAPACITY; // Retain backing array when length <= this.
+ private Object @Nullable [] entries = EMPTY_ARRAY;
private int lastElementPosition = -1;
private int gapCount = 0; // Always equals the total number of null slots in entryList.
+ private int firstGapPosition = 0; // Pessimistic lower bound: positions [0, firstGapPosition) are guaranteed gap-free and positionally compact (logical i == physical i).
+ private int size = 0;
/**
* Appends the specified element to the end of this list.
@@ -51,23 +53,19 @@ public final class ElementAwareArrayList
* @return the entry for later O(1) removal via {@link Entry#remove()}
*/
public Entry addEntry(T element) {
- modCount++;
- if (gapCount > 0 && entries[lastElementPosition] == null) { // Reuse a gap if it exists.
- var newEntry = new Entry(element, lastElementPosition);
- entries[lastElementPosition] = newEntry;
- gapCount--;
- return newEntry;
+ var newPosition = ++lastElementPosition;
+ if (newPosition == entries.length) { // Full (also covers EMPTY_ARRAY); grow on the cold path only.
+ resize(newPosition + 1);
}
- var newEntry = new Entry(element, ++lastElementPosition);
- resize(lastElementPosition + 1);
- entries[lastElementPosition] = newEntry;
+ var newEntry = new Entry(element, newPosition);
+ entries[newPosition] = newEntry;
+ size++;
return newEntry;
}
- @SuppressWarnings("unchecked")
private void resize(int minCapacity) {
- if (entries == null) {
- entries = (Entry[]) Array.newInstance(Entry.class, Math.max(DEFAULT_CAPACITY, minCapacity));
+ if (entries.length == 0) {
+ entries = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
return;
}
if (minCapacity <= entries.length) {
@@ -85,20 +83,36 @@ private Entry getEntry(int index) {
if (index < 0 || index >= size()) {
throw new IndexOutOfBoundsException(
"The index (%d) must be >= 0 and < size (%d).".formatted(index, size()));
- } else if (gapCount == 0) {
- return Objects.requireNonNull(entries[index]);
+ } else if (gapCount == 0 || index < firstGapPosition) {
+ return (Entry) entries[index];
+ }
+ partialCompact(index);
+ return (Entry) entries[index];
+ }
+
+ /**
+ * Removes all gaps from the list in O(n), preserving insertion order.
+ * After this call, {@code gapCount == 0} and every subsequent {@code get(int)} runs in O(1).
+ * No-op when the list is already compact or empty.
+ */
+ void compact() {
+ if (gapCount > 0 && !isEmpty()) {
+ partialCompact(size() - 1);
}
- return partialCompact(index);
}
/**
* Avoid calling this when {@code gapCount == 0}.
*/
- private Entry partialCompact(int rightBoundaryPosition) {
+ private void partialCompact(int rightBoundaryPosition) {
+ if (rightBoundaryPosition < firstGapPosition) {
+ // The entire target range is in the already-compacted prefix; no work needed.
+ return;
+ }
var encounteredGaps = 0;
- var lastNonNullPosition = -1;
- for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) {
- var entry = entries[currentPosition];
+ var lastNonNullPosition = firstGapPosition - 1; // firstGapPosition non-nulls are already in place before us.
+ for (var currentPosition = firstGapPosition; currentPosition <= lastElementPosition; currentPosition++) {
+ var entry = (Entry) entries[currentPosition];
if (entry == null) {
encounteredGaps++;
} else {
@@ -108,16 +122,17 @@ private Entry partialCompact(int rightBoundaryPosition) {
entry.moveTo(targetPosition);
entries[targetPosition] = entry;
entries[currentPosition] = null; // For consistency; the list is never in an invalid state.
- modCount++;
}
if (lastNonNullPosition == rightBoundaryPosition) {
- // Invariant: positions [0, index] are all non-null,
- // so all gapCount nulls lie in [index+1, lastElementPosition].
- // If that suffix is entirely nulls (equivalent to index == size()-1), trim it now.
+ // Invariant: positions [0, rightBoundaryPosition] are all non-null,
+ // so all gapCount nulls lie in [rightBoundaryPosition+1, lastElementPosition].
+ // If that suffix is entirely nulls (equivalent to rightBoundaryPosition == size()-1), trim it now.
if (gapCount == lastElementPosition - rightBoundaryPosition) {
truncateTo(rightBoundaryPosition);
+ } else {
+ firstGapPosition = rightBoundaryPosition + 1;
}
- return entry;
+ return;
}
}
}
@@ -133,7 +148,8 @@ private void truncateTo(int newLastPosition) {
Arrays.fill(entries, newLastPosition + 1, lastElementPosition + 1, null);
lastElementPosition = newLastPosition;
gapCount = 0;
- modCount++;
+ firstGapPosition = lastElementPosition + 1; // [0, lastElementPosition] are all non-null.
+ size = newLastPosition + 1;
}
@Override
@@ -143,14 +159,13 @@ public boolean add(T element) {
}
@Override
- @SuppressWarnings("DataFlowIssue")
public void add(int index, T element) {
- var size = size();
- if (index < 0 || index > size) {
+ var currentSize = size;
+ if (index < 0 || index > currentSize) {
throw new IndexOutOfBoundsException(
- "The index (%d) must be >= 0 and <= size (%d).".formatted(index, size));
+ "The index (%d) must be >= 0 and <= size (%d).".formatted(index, currentSize));
}
- if (index == size) {
+ if (index == currentSize) {
addEntry(element);
return;
}
@@ -160,12 +175,13 @@ public void add(int index, T element) {
}
// Compact prefix [0, index-1] so physical position k == logical position k for all k < index.
if (index > 0) {
- partialCompact(index - 1); // Increases modCount.
+ partialCompact(index - 1);
}
if (entries[index] == null) {
// Gap at the target position: fill it directly without shifting the array.
entries[index] = new Entry(element, index);
gapCount--;
+ size++;
} else {
// No gap at the target position: rotate entries rightward into the nearest gap in the suffix,
// consuming that gap rather than growing the backing list.
@@ -174,27 +190,27 @@ public void add(int index, T element) {
}
private void addWithoutGaps(int index, T element) {
- modCount++;
var newEntry = new Entry(element, index);
resize(lastElementPosition + 2);
for (var i = lastElementPosition; i >= index; i--) {
- var shifted = entries[i];
+ var shifted = (Entry) entries[i];
entries[i + 1] = shifted;
shifted.moveTo(i + 1);
}
entries[index] = newEntry;
lastElementPosition++;
+ size++;
}
private void addWithGaps(int index, Entry newEntry) {
- modCount++;
var displaced = newEntry;
for (var i = index; i <= lastElementPosition; i++) {
- var current = entries[i];
+ var current = (Entry) entries[i];
displaced.moveTo(i);
entries[i] = displaced;
if (current == null) {
gapCount--;
+ size++;
break;
}
displaced = current;
@@ -220,44 +236,51 @@ public T remove(int index) {
* @throws IllegalStateException if the entry was already removed
*/
private void remove(Entry entry) {
- if (entry.isRemoved()) {
+ var position = entry.position;
+ if (position == REMOVED_POSITION) {
throw new IllegalStateException("The entry (%s) was already removed."
.formatted(entry));
}
- var positionPreRemoval = entry.position;
- if (positionPreRemoval == lastElementPosition) { // Removing the last element; just trim the list.
- entries[lastElementPosition--] = null;
- } else {
- entries[positionPreRemoval] = null;
- gapCount++;
- }
entry.moveTo(REMOVED_POSITION); // Mark the entry as removed.
- modCount++;
- clearIfPossible();
- }
-
- private void clearIfPossible() {
- if (isEmpty()) {
- // All positions, if any, are gaps. Clear the list entirely.
- innerClear();
+ size--;
+ entries[position] = null;
+ if (position == lastElementPosition) { // Removing the last element; trim and retract trailing gaps.
+ lastElementPosition--;
+ while (lastElementPosition >= 0 && entries[lastElementPosition] == null) {
+ lastElementPosition--;
+ gapCount--;
+ }
+ if (lastElementPosition < 0) { // List now empty: retain a small backing array, free a large one.
+ gapCount = 0; // Already 0 after retraction; explicit for clarity.
+ firstGapPosition = 0;
+ if (entries.length > RETAIN_THRESHOLD) {
+ entries = EMPTY_ARRAY;
+ }
+ }
+ } else { // Interior removal; cannot empty the list, so no empty-handling needed.
+ gapCount++;
+ if (position < firstGapPosition) {
+ firstGapPosition = position;
+ }
}
}
@Override
public void clear() {
innerClear();
- modCount++;
}
private void innerClear() {
- entries = null;
+ entries = EMPTY_ARRAY;
gapCount = 0;
lastElementPosition = -1;
+ firstGapPosition = 0;
+ size = 0;
}
@Override
public int size() {
- return lastElementPosition - gapCount + 1;
+ return size;
}
/**
@@ -281,7 +304,7 @@ public void forEach(Consumer super T> action) {
@SuppressWarnings("DataFlowIssue")
private void forEachWithoutGaps(Consumer super T> elementConsumer) {
for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) {
- elementConsumer.accept(entries[currentPosition].element());
+ elementConsumer.accept(((Entry) entries[currentPosition]).element); // entries[i] is provably non-null (gapCount==0)
}
}
@@ -301,16 +324,15 @@ private void forEachCompacting(Consumer super T> elementConsumer) {
}
var compactPosition = 0;
for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) {
- var entry = entries[currentPosition];
+ var entry = (Entry) entries[currentPosition];
if (entry == null) {
continue;
}
- elementConsumer.accept(entry.element());
+ elementConsumer.accept(entry.element); // entry is provably live (post null-skip)
if (currentPosition != compactPosition) {
entry.moveTo(compactPosition);
entries[compactPosition] = entry;
entries[currentPosition] = null; // Prevent stale data.
- modCount++;
}
if (++compactPosition == liveCount) {
break;
@@ -324,8 +346,14 @@ public Iterator iterator() {
return listIterator(0);
}
+ @Override
+ public ListIterator listIterator() {
+ return this.listIterator(0);
+ }
+
@Override
public ListIterator listIterator(int index) {
+ compact(); // Ensure fast-path iteration; remove all gaps at once.
return new ElementAwareListIterator(index);
}
@@ -350,7 +378,6 @@ private final class ElementAwareListIterator implements ListIterator {
private int logicalPosition;
private @Nullable Entry lastEntry;
private boolean lastWasFwd;
- private int expectedModCount;
private ElementAwareListIterator(int startingPosition) {
var currentSize = size();
@@ -358,13 +385,9 @@ private ElementAwareListIterator(int startingPosition) {
throw new IndexOutOfBoundsException(
"The index (%d) must be >= 0 and <= size (%d).".formatted(startingPosition, currentSize));
}
- if (startingPosition > 0 && gapCount > 0) {
- currentPosition = partialCompact(startingPosition - 1).position + 1;
- } else {
- currentPosition = startingPosition;
- }
+ // listIterator() compacts before construction ⟹ gapless: logical position == physical position.
+ currentPosition = startingPosition;
logicalPosition = startingPosition;
- expectedModCount = modCount;
}
@Override
@@ -389,35 +412,35 @@ public int previousIndex() {
@Override
public T next() {
- checkModCount();
if (logicalPosition >= size()) {
throw new NoSuchElementException();
}
- var entry = entries[currentPosition];
+ var entry = (Entry) entries[currentPosition];
while (entry == null) {
- entry = entries[++currentPosition];
+ var position = ++currentPosition;
+ entry = (Entry) entries[position];
}
currentPosition++;
logicalPosition++;
lastEntry = entry;
lastWasFwd = true;
- return entry.element();
+ return entry.element; // provably live: entry is from a non-null slot after the null-skip loop
}
@Override
public T previous() {
- checkModCount();
if (logicalPosition <= 0) {
throw new NoSuchElementException();
}
Entry entry = null;
while (entry == null) {
- entry = entries[--currentPosition];
+ var position = --currentPosition;
+ entry = (Entry) entries[position];
}
logicalPosition--;
lastEntry = entry;
lastWasFwd = false;
- return entry.element();
+ return entry.element; // provably live: entry is from a non-null slot after the null-skip loop
}
@Override
@@ -426,12 +449,10 @@ public void remove() {
throw new IllegalStateException(
"remove() called without a preceding next() or previous().");
}
- checkModCount();
lastEntry.remove(); // Adjusts lastElementPosition.
if (lastWasFwd) {
logicalPosition--;
}
- expectedModCount = modCount;
lastEntry = null;
}
@@ -440,26 +461,20 @@ public void set(T element) {
if (lastEntry == null) {
throw new IllegalStateException("set() called without a preceding next() or previous().");
}
- checkModCount();
lastEntry.replaceElement(element);
}
@Override
public void add(T element) {
- checkModCount();
+ var appending = logicalPosition == size();
ElementAwareArrayList.this.add(logicalPosition, element);
logicalPosition++;
- currentPosition = logicalPosition;
- expectedModCount = modCount;
+ // Appended entries land at physical lastElementPosition (may exceed logicalPosition when gaps exist);
+ // interior inserts land at physical logicalPosition (prefix is compacted by add(int,T)).
+ currentPosition = appending ? lastElementPosition + 1 : logicalPosition;
lastEntry = null;
}
- private void checkModCount() {
- if (modCount != expectedModCount) {
- throw new ConcurrentModificationException();
- }
- }
-
}
public final class Entry implements ListEntry {
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java
index 4cdfbf15a71..b0d4879976b 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java
@@ -5,16 +5,22 @@
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import java.util.ArrayList;
-import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.NoSuchElementException;
+import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
@NullMarked
class ElementAwareArrayListTest {
@@ -102,22 +108,40 @@ void removeAllElements() {
}
@Test
- @DisplayName("addEntry reuses the null slot at lastElementPosition when it is a gap")
- void addEntryReusesGapAtTail() {
+ @DisplayName("addEntry after churn appends in insertion order")
+ void addEntryAfterChurnAppendsInOrder() {
var list = new ElementAwareArrayList();
list.addEntry("a");
var entryB = list.addEntry("b");
var entryC = list.addEntry("c");
- entryB.remove(); // gap at slot 1; physical [a@0, null, c@2]
- entryC.remove(); // c is last element → trim; physical [a@0, null], gapCount=1, lastElementPosition=1
+ entryB.remove(); // interior gap at slot 1
+ entryC.remove(); // last element: trim + retract trailing null → lastElementPosition=0, gapCount=0
- var entryX = list.addEntry("x"); // reuses slot 1 (null at lastElementPosition)
+ // addEntry appends at lastElementPosition+1 (==1); insertion order a, x preserved.
+ var entryX = list.addEntry("x");
assertThat(list).containsExactly("a", "x");
assertThat(entryX.toString()).contains("@1");
}
+ @Test
+ @DisplayName("add after free-path empty re-allocates and preserves insertion order")
+ void addAfterLargeArrayFreePathPreservesOrder() {
+ var list = new ElementAwareArrayList();
+ List.Entry> entryList = new ArrayList<>();
+ for (var i = 0; i < 30; i++) {
+ entryList.add(list.addEntry("e" + i));
+ }
+ for (var entry : entryList) {
+ entry.remove();
+ }
+ list.addEntry("x");
+ list.addEntry("y");
+ list.addEntry("z");
+ assertThat(list).containsExactly("x", "y", "z");
+ }
+
}
@Nested
@@ -214,77 +238,6 @@ void forEachCompactsWithTailGaps() {
}
}
- @Nested
- @DisplayName("Compaction tests")
- class CompactionTests {
-
- @Test
- @DisplayName("Access compacts the list when gaps exist")
- void accessCompactsWithGaps() {
- var list = new ElementAwareArrayList();
- list.addEntry("first");
- var entry2 = list.addEntry("second");
- list.addEntry("third");
-
- entry2.remove();
-
- assertThat(list).hasSize(2);
- assertThat(list.get(0)).isEqualTo("first");
- assertThat(list.get(1)).isEqualTo("third");
- }
-
- @Test
- @DisplayName("Access returns empty when all elements removed")
- void accessWhenAllRemoved() {
- var list = new ElementAwareArrayList();
- var entry1 = list.addEntry("first");
-
- entry1.remove();
-
- assertThat(list).isEmpty();
- }
-
- @Test
- @DisplayName("Access compacts with tail gaps")
- void accessCompactsWithTailGaps() {
- var list = new ElementAwareArrayList();
- list.add("first");
- list.add("second");
- var entry3 = list.addEntry("third");
- var entry4 = list.addEntry("fourth");
- var entry5 = list.addEntry("fifth");
-
- entry3.remove();
- entry4.remove();
- entry5.remove();
-
- assertThat(list).hasSize(2);
- assertThat(entry3.isRemoved()).isTrue();
- assertThat(entry4.isRemoved()).isTrue();
- assertThat(entry5.isRemoved()).isTrue();
- }
-
- @Test
- @DisplayName("addAt with trailing gaps only rotates entry into first suffix gap")
- void addAtAfterTrailingGapOnly() {
- var list = new ElementAwareArrayList();
- var e1 = list.addEntry("a");
- var e2 = list.addEntry("b");
- var e3 = list.addEntry("c");
- var e4 = list.addEntry("d");
-
- e3.remove();
- e4.remove();
- // Physical: [a, b, null], gapCount=1, size=2 (d trim reduced lastElementPosition)
-
- list.add(1, "x"); // partialCompact(0): no prefix gap; slot 1 non-null → rotate b into gap at slot 2
- assertThat(list).hasSize(3);
- assertThat(copyUsingForEach(list)).containsExactly("a", "x", "b");
- assertThat(e1.toString()).contains("@0");
- assertThat(e2.toString()).contains("@2");
- }
- }
-
@Nested
@DisplayName("Partial compaction tests")
class PartialCompactionTests {
@@ -651,14 +604,35 @@ void removeNullEntry() {
}
@Test
- @DisplayName("iterator visits null elements")
+ @DisplayName("listIterator visits null elements in forward and backward order")
void iterator() {
var list = new ElementAwareArrayList<@Nullable String>();
list.addEntry(null);
list.addEntry("a");
list.addEntry(null);
- var result = copyUsingForEach(list);
- assertThat(result).containsExactly(null, "a", null);
+
+ // Forward traversal returns null elements.
+ var it = list.listIterator();
+ assertThat(it.next()).isNull();
+ assertThat(it.next()).isEqualTo("a");
+ assertThat(it.next()).isNull();
+ assertThat(it.hasNext()).isFalse();
+
+ // Backward traversal returns null elements.
+ assertThat(it.previous()).isNull();
+ assertThat(it.previous()).isEqualTo("a");
+ assertThat(it.previous()).isNull();
+ assertThat(it.hasPrevious()).isFalse();
+
+ // remove() creates a gap (Entry == null) adjacent to a live null element (Entry != null, element == null);
+ // next() must skip the former without skipping the latter.
+ it = list.listIterator();
+ assertThat(it.next()).isNull(); // null element at logical 0
+ it.remove(); // slot 0 becomes a removed gap; live null element remains at slot 2
+ assertThat(it.next()).isEqualTo("a");
+ assertThat(it.next()).isNull(); // live null element still reachable
+ assertThat(it.hasNext()).isFalse();
+ assertThat(list).containsExactly("a", null);
}
@Test
@@ -1067,25 +1041,6 @@ void backwardIteration() {
assertThat(it.hasPrevious()).isFalse();
}
- @Test
- @DisplayName("forward iteration skips null slots")
- void forwardWithGaps() {
- var list = new ElementAwareArrayList();
- list.add("a");
- var entryB = list.addEntry("b");
- list.add("c");
- var entryD = list.addEntry("d");
- list.add("e");
- entryB.remove();
- entryD.remove();
-
- var it = list.listIterator();
- assertThat(it.next()).isEqualTo("a");
- assertThat(it.next()).isEqualTo("c");
- assertThat(it.next()).isEqualTo("e");
- assertThat(it.hasNext()).isFalse();
- }
-
@Test
@DisplayName("remove after next() removes correct element, adjusts cursor")
void removeFwd() {
@@ -1097,10 +1052,10 @@ void removeFwd() {
var it = list.listIterator();
assertThat(it.next()).isEqualTo("a");
it.remove();
- assertThat(list).containsExactly("b", "c");
assertThat(it.hasPrevious()).isFalse();
- assertThat(it.hasNext()).isTrue();
+ assertThat(it).hasNext();
assertThat(it.next()).isEqualTo("b");
+ assertThat(list).containsExactly("b", "c");
}
@Test
@@ -1172,29 +1127,38 @@ void addWithGaps() {
}
@Test
- @DisplayName("next() past end throws NoSuchElementException")
- void noSuchElement() {
+ @DisplayName("add() appending to list made gappy mid-iteration positions cursor for previous()")
+ void addAtEndAfterMidIterationRemoval() {
var list = new ElementAwareArrayList();
list.add("a");
+ list.add("b");
+ list.add("c");
+ list.add("d");
var it = list.listIterator();
- it.next();
- assertThatExceptionOfType(NoSuchElementException.class)
- .isThrownBy(it::next);
+ it.next(); // a
+ it.next(); // b
+ it.remove(); // gap at physical 1; logical [a, c, d]
+ it.next(); // c
+ it.next(); // d -> logical end, interior gap still present
+ it.add("e"); // append via addEntry while gap exists
+
+ assertThat(it.hasNext()).isFalse();
+ assertThat(it.previous()).isEqualTo("e");
+ assertThat(it.previous()).isEqualTo("d");
+ assertThat(it.previous()).isEqualTo("c");
+ assertThat(list).containsExactly("a", "c", "d", "e");
}
@Test
- @DisplayName("external remove(Entry) causes CME on subsequent iterator call")
- void cmeOnExternalModify() {
+ @DisplayName("next() past end throws NoSuchElementException")
+ void noSuchElement() {
var list = new ElementAwareArrayList();
- var entry = list.addEntry("a");
- list.add("b");
+ list.add("a");
var it = list.listIterator();
it.next();
- entry.remove();
-
- assertThatExceptionOfType(ConcurrentModificationException.class)
+ assertThatExceptionOfType(NoSuchElementException.class)
.isThrownBy(it::next);
}
@@ -1225,67 +1189,6 @@ void startAtIndex() {
assertThat(it.previous()).isEqualTo("b");
}
- @Test
- @DisplayName("listIterator(index) on gappy list compacts before starting")
- void startAtIndexWithGaps() {
- var list = new ElementAwareArrayList();
- list.add("a");
- var entry = list.addEntry("b");
- list.add("c");
- list.add("d");
- entry.remove();
-
- var it = list.listIterator(1);
- assertThat(it.next()).isEqualTo("c");
- assertThat(it.previous()).isEqualTo("c");
- assertThat(it.previous()).isEqualTo("a");
- }
-
- @Test
- @DisplayName("forward iteration through gaps then full backward traversal")
- void forwardWithGapsThenBackward() {
- var list = new ElementAwareArrayList();
- list.add("a");
- var entryB = list.addEntry("b");
- list.add("c");
- var entryD = list.addEntry("d");
- list.add("e");
- entryB.remove();
- entryD.remove();
- // Physical: [a, null, c, null, e]; gaps never compacted during forward scan.
-
- var it = list.listIterator();
- assertThat(it.next()).isEqualTo("a");
- assertThat(it.next()).isEqualTo("c");
- assertThat(it.next()).isEqualTo("e");
- assertThat(it.hasNext()).isFalse();
-
- // previous() must traverse back through the uncompacted gaps.
- assertThat(it.previous()).isEqualTo("e");
- assertThat(it.previous()).isEqualTo("c");
- assertThat(it.previous()).isEqualTo("a");
- assertThat(it.hasPrevious()).isFalse();
- }
-
- @Test
- @DisplayName("backward iteration from end of gappy list (gaps at head and middle)")
- void backwardFromEndWithGaps() {
- var list = new ElementAwareArrayList();
- var entryA = list.addEntry("a");
- list.add("b");
- var entryC = list.addEntry("c");
- list.add("d");
- entryA.remove();
- entryC.remove();
- // Physical: [null, b, null, d], logical: [b, d].
-
- var it = list.listIterator(list.size());
- assertThat(it.hasPrevious()).isTrue();
- assertThat(it.previous()).isEqualTo("d");
- assertThat(it.previous()).isEqualTo("b");
- assertThat(it.hasPrevious()).isFalse();
- }
-
@Test
@DisplayName("iterator remove on list that already has gaps")
void removeViaIteratorOnGappyList() {
@@ -1295,7 +1198,7 @@ void removeViaIteratorOnGappyList() {
list.add("c");
list.add("d");
entryB.remove();
- // Physical: [a, null, c, d], logical: [a, c, d].
+ // listIterator() compacts the b-gap away first; the gap under test is the one it.remove() creates mid-iteration.
var it = list.listIterator();
assertThat(it.next()).isEqualTo("a");
@@ -1319,7 +1222,7 @@ void nextRemoveNextPreviousCycle() {
list.add("b");
list.add("c");
entryX.remove();
- // Physical: [a, null, b, c], logical: [a, b, c].
+ // listIterator() compacts the x-gap away first; the gap under test is the one it.remove() creates mid-iteration.
var it = list.listIterator();
assertThat(it.next()).isEqualTo("a");
@@ -1363,52 +1266,152 @@ void setWithoutNextOrPrevious() {
}
@Nested
- @DisplayName("Fail-fast behavior tests (implementation-specific)")
- class FailFastBehaviorTests {
+ @DisplayName("compact() and firstGapPosition tests")
+ class CompactAndFirstGapPositionTests {
@Test
- @DisplayName("listIterator fail-fast on add (implementation-specific)")
- void cmeOnAdd() {
+ @DisplayName("compact() removes all gaps and preserves insertion order")
+ void compactRemovesAllGaps() {
var list = new ElementAwareArrayList();
- list.add("a");
- list.add("b");
+ list.addEntry("a");
+ var e1 = list.addEntry("b");
+ list.addEntry("c");
+ var e2 = list.addEntry("d");
+ list.addEntry("e");
+ e1.remove();
+ e2.remove();
- var it = list.listIterator();
- it.next();
- list.add("c");
+ list.compact();
- assertThatExceptionOfType(ConcurrentModificationException.class)
- .isThrownBy(it::next);
+ assertThat(list).containsExactly("a", "c", "e");
+ assertThat(list.get(0)).isEqualTo("a");
+ assertThat(list.get(1)).isEqualTo("c");
+ assertThat(list.get(2)).isEqualTo("e");
}
@Test
- @DisplayName("listIterator fail-fast on remove(int) (implementation-specific)")
- void cmeOnRemove() {
+ @DisplayName("compact() on already-compact list is a no-op")
+ void compactNoOpWhenAlreadyCompact() {
var list = new ElementAwareArrayList();
- list.add("a");
- list.add("b");
+ list.addEntry("a");
+ list.addEntry("b");
+ list.addEntry("c");
- var it = list.listIterator();
- it.next();
- list.removeFirst();
+ list.compact();
- assertThatExceptionOfType(ConcurrentModificationException.class)
- .isThrownBy(it::next);
+ assertThat(list).containsExactly("a", "b", "c");
}
@Test
- @DisplayName("listIterator fail-fast on remove(Entry) (implementation-specific)")
- void cmeOnEntryRemove() {
+ @DisplayName("compact() on empty list is a no-op")
+ void compactEmptyList() {
var list = new ElementAwareArrayList();
- var entry = list.addEntry("a");
- list.add("b");
- var it = list.listIterator();
- it.next();
- entry.remove();
+ assertThatCode(list::compact).doesNotThrowAnyException();
+ assertThat(list).isEmpty();
+ }
- assertThatExceptionOfType(ConcurrentModificationException.class)
- .isThrownBy(it::next);
+ @Test
+ @DisplayName("get() indices below the first gap return correct elements without compacting suffix")
+ void getBeforeFirstGapFastPath() {
+ var list = new ElementAwareArrayList();
+ list.addEntry("a");
+ list.addEntry("b");
+ var e2 = list.addEntry("c");
+ list.addEntry("d");
+ e2.remove(); // gap at physical 2; firstGapPosition ≤ 2
+
+ // get(0) and get(1) are below the boundary — return correct values
+ assertThat(list.get(0)).isEqualTo("a");
+ assertThat(list.get(1)).isEqualTo("b");
+ // get(2) is at/above the boundary — triggers compaction
+ assertThat(list.get(2)).isEqualTo("d");
+ }
+
+ @Test
+ @DisplayName("firstGapPosition advances after partialCompact; prefix stays fast-path-valid")
+ void firstGapAdvancesAfterPartialCompact() {
+ var list = new ElementAwareArrayList();
+ list.addEntry("a");
+ var e1 = list.addEntry("b");
+ list.addEntry("c");
+ var e2 = list.addEntry("d");
+ list.addEntry("e");
+ e1.remove();
+ e2.remove();
+
+ assertThat(list.get(1)).isEqualTo("c"); // triggers partialCompact(1), firstGapPosition advances
+ assertThat(list.get(0)).isEqualTo("a"); // still correct after boundary move
+ assertThat(list.get(1)).isEqualTo("c");
+ assertThat(list.get(2)).isEqualTo("e"); // next compaction round
+ assertThat(list).containsExactly("a", "c", "e");
+ }
+
+ @Test
+ @DisplayName("remove that lowers the boundary still compacts correctly on next get")
+ void removeLoweringBoundaryStillCompacts() {
+ var list = new ElementAwareArrayList();
+ var e0 = list.addEntry("a");
+ list.addEntry("b");
+ var e2 = list.addEntry("c");
+ list.addEntry("d");
+
+ e2.remove(); // gap at 2; firstGapPosition ≤ 2
+ list.get(2); // compact to index 2; firstGapPosition = 3
+ e0.remove(); // gap at 0; firstGapPosition must drop back to ≤ 0
+
+ // After removing a0, logical list = [b, d]; get(0) must still work
+ assertThat(list.get(0)).isEqualTo("b");
+ assertThat(list.get(1)).isEqualTo("d");
+ assertThat(list).containsExactly("b", "d");
+ }
+
+ @Execution(ExecutionMode.CONCURRENT)
+ @ParameterizedTest
+ @MethodSource("randomSeeds")
+ @DisplayName("randomized stress test: EAAL matches ArrayList reference under mixed add/remove/get/compact")
+ void stressTestAgainstReferenceArrayList(long seed) {
+ var random = new Random(seed);
+ var reference = new ArrayList();
+ var list = new ElementAwareArrayList();
+ var liveEntries = new ArrayList.Entry>();
+ var counter = new AtomicInteger(0);
+
+ for (var i = 0; i < 10_000; i++) {
+ var op = random.nextInt(reference.isEmpty() ? 2 : 5);
+ switch (op) {
+ case 0, 1 -> { // addEntry (weighted so the list doesn't starve)
+ var element = "e" + counter.getAndIncrement();
+ reference.add(element);
+ liveEntries.add(list.addEntry(element));
+ }
+ case 2 -> { // remove(Entry)
+ var idx = random.nextInt(liveEntries.size());
+ var entry = liveEntries.remove(idx);
+ reference.remove(entry.element());
+ entry.remove();
+ }
+ case 3 -> { // get(int) — exercises firstGapPosition fast-path + partialCompact
+ var idx = random.nextInt(reference.size());
+ assertThat(list.get(idx)).isEqualTo(reference.get(idx));
+ }
+ case 4 -> { // compact() then full equality check
+ list.compact();
+ assertThat(list).containsExactlyElementsOf(reference);
+ }
+ default -> {
+ throw new IllegalStateException("Unexpected operation code: %d".formatted(op));
+ }
+ }
+ assertThat(list).hasSameSizeAs(reference);
+ }
+ assertThat(list).containsExactlyElementsOf(reference);
+ }
+
+ static Stream randomSeeds() {
+ var random = new Random(0xCAFEBABEL);
+ return random.longs(10)
+ .mapToObj(Arguments::of);
}
}