subIndex = this.subIndices[i];
+ if(oldEmpty)
+ {
+ // Old key didn't exist at this position: ADD
+ subIndex.internalAddToEntry(entityId, newKeys);
+ }
+ else if(newEmpty)
+ {
+ // New key no longer relevant: REMOVE
+ subIndex.internalRemove(entityId, oldKeys);
+ }
+ else
+ {
+ // Both exist: HANDLE CHANGE
+ subIndex.internalHandleChanged(oldKeys, entityId, newKeys);
+ }
}
this.markStateChangeChildren();
}
diff --git a/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java b/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
new file mode 100644
index 00000000..6fa10a0b
--- /dev/null
+++ b/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
@@ -0,0 +1,206 @@
+package org.eclipse.store.gigamap.indexer;
+
+/*-
+ * #%L
+ * EclipseStore GigaMap
+ * %%
+ * Copyright (C) 2023 - 2025 MicroStream Software
+ * %%
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ * #L%
+ */
+
+import org.eclipse.store.gigamap.types.BitmapIndices;
+import org.eclipse.store.gigamap.types.GigaMap;
+import org.eclipse.store.gigamap.types.IndexerInstant;
+import org.eclipse.store.storage.embedded.types.EmbeddedStorage;
+import org.eclipse.store.storage.embedded.types.EmbeddedStorageManager;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.time.Instant;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Test for GitHub issue: ArrayIndexOutOfBoundsException in SubBitmapIndexHashing
+ * when updating entity from null to non-null composite key value.
+ *
+ * Root cause: {@code AbstractCompositeBitmapIndex.internalHandleChanged()} called
+ * {@code ensureSubIndices(newKeys)} which grew the sub-index array (e.g. from 1 to 6),
+ * then iterated all sub-indices calling
+ * {@code subIndex.internalHandleChanged(oldKeys, ...)}.
+ * Each sub-index called {@code internalLookupEntry(oldKeys)} → {@code indexEntity(oldKeys)} →
+ * {@code index(oldKeys)} → {@code oldKeys[this.position]}.
+ * When {@code oldKeys} was the {@code NULL()} sentinel (length 1) but
+ * {@code this.position >= 1}, it threw {@code ArrayIndexOutOfBoundsException}.
+ *
+ * Fix: {@code internalHandleChanged} now checks {@code isEmpty(oldKeys, i)} and
+ * {@code isEmpty(newKeys, i)} per sub-index, dispatching to {@code internalAddToEntry}
+ * or {@code internalRemove} instead of unconditionally calling {@code internalHandleChanged}.
+ */
+public class CompositeIndexNullHandlingTest
+{
+
+ @TempDir
+ Path tempDir;
+
+ /**
+ * Entity with an optional {@link Instant} field — mirrors real-world scenario
+ * like Booking with nullable {@code expiresAt}, {@code billingReportedAt}, etc.
+ */
+ static final class Event
+ {
+ final long id;
+ Instant timestamp; // initially null, set on update
+
+ Event(final long id)
+ {
+ this.id = id;
+ this.timestamp = null;
+ }
+ }
+
+ static final class EventIndexer extends IndexerInstant.Abstract
+ {
+ @Override
+ protected Instant getInstant(final Event entity)
+ {
+ return entity.timestamp;
+ }
+ }
+
+ @Test
+ void nullToNonNullCompositeKeyUpdateShouldWork()
+ {
+ final GigaMap map = GigaMap.New();
+ final EventIndexer indexer = new EventIndexer();
+ map.index().bitmap().ensure(indexer);
+
+ // Add entity with null timestamp → composite key = NULL() = Object[1]
+ final Event event = new Event(1L);
+ map.add(event);
+ assertEquals(1, map.size());
+
+ // Update to set non-null timestamp → composite key = Object[6]
+ // Before fix: ArrayIndexOutOfBoundsException (Index 1 out of bounds for length 1)
+ assertDoesNotThrow(() ->
+ map.update(event, e -> e.timestamp = Instant.parse("2025-06-15T10:30:00Z"))
+ );
+
+ assertEquals(1, map.size());
+ }
+
+ @Test
+ void nonNullToNullCompositeKeyUpdateShouldWork()
+ {
+ final GigaMap map = GigaMap.New();
+ final EventIndexer indexer = new EventIndexer();
+ map.index().bitmap().ensure(indexer);
+
+ // Add entity with non-null timestamp → composite key = Object[6]
+ final Event event = new Event(1L);
+ event.timestamp = Instant.parse("2025-06-15T10:30:00Z");
+ map.add(event);
+
+ // Update to null timestamp → composite key = NULL() = Object[1]
+ assertDoesNotThrow(() ->
+ map.update(event, e -> e.timestamp = null)
+ );
+
+ assertEquals(1, map.size());
+ }
+
+ @Test
+ void nullToNonNullCompositeKeyUpdateWithPersistenceShouldWork()
+ {
+ final GigaMap map = GigaMap.New();
+ final EventIndexer indexer = new EventIndexer();
+ map.index().bitmap().ensure(indexer);
+
+ // Add entity with null timestamp
+ final Event event = new Event(1L);
+ map.add(event);
+
+ try (EmbeddedStorageManager manager = EmbeddedStorage.start(map, this.tempDir))
+ {
+ // Update to set non-null timestamp
+ assertDoesNotThrow(() ->
+ map.update(event, e -> e.timestamp = Instant.parse("2025-06-15T10:30:00Z"))
+ );
+
+ assertEquals(1, map.size());
+ }
+
+ // Verify persistence
+ try (EmbeddedStorageManager manager = EmbeddedStorage.start(this.tempDir))
+ {
+ final GigaMap loadedMap = (GigaMap) manager.root();
+ assertEquals(1, loadedMap.size());
+
+ final Event loadedEvent = loadedMap.query().findFirst().get();
+ assertNotNull(loadedEvent.timestamp);
+ assertEquals(Instant.parse("2025-06-15T10:30:00Z"), loadedEvent.timestamp);
+ }
+ }
+
+ @Test
+ void multipleNullToNonNullUpdatesShouldWork()
+ {
+ final GigaMap map = GigaMap.New();
+ final EventIndexer indexer = new EventIndexer();
+ map.index().bitmap().ensure(indexer);
+
+ // Add multiple entities with null timestamps
+ final Event event1 = new Event(1L);
+ final Event event2 = new Event(2L);
+ final Event event3 = new Event(3L);
+ map.addAll(event1, event2, event3);
+ assertEquals(3, map.size());
+
+ // Update all to non-null timestamps
+ assertDoesNotThrow(() ->
+ {
+ event1.timestamp = Instant.parse("2025-06-15T10:30:00Z");
+ event2.timestamp = Instant.parse("2025-06-15T11:30:00Z");
+ event3.timestamp = Instant.parse("2025-06-15T12:30:00Z");
+ map.update(event1, e -> {});
+ map.update(event2, e -> {});
+ map.update(event3, e -> {});
+ });
+
+ assertEquals(3, map.size());
+ }
+
+ @Test
+ void nullToNonNullQueryShouldWork()
+ {
+ final GigaMap map = GigaMap.New();
+ final EventIndexer indexer = new EventIndexer();
+ map.index().bitmap().ensure(indexer);
+
+ // Add entities with mixed null/non-null timestamps
+ final Event event1 = new Event(1L);
+ final Event event2 = new Event(2L);
+ event2.timestamp = Instant.parse("2025-06-15T10:30:00Z");
+ map.addAll(event1, event2);
+
+ // Query for null timestamps
+ assertEquals(1, map.query(indexer.is((Instant) null)).count());
+
+ // Query for non-null timestamps
+ assertEquals(1, map.query(indexer.is(Instant.parse("2025-06-15T10:30:00Z"))).count());
+
+ // Update null to non-null
+ map.update(event1, e -> e.timestamp = Instant.parse("2025-06-15T11:30:00Z"));
+
+ // Verify queries after update
+ assertEquals(0, map.query(indexer.is((Instant) null)).count());
+ assertEquals(2, map.query(indexer.is((Instant) null).or(indexer.is(Instant.parse("2025-06-15T10:30:00Z")))).count());
+ }
+}
From 46fa7f9591403289b4d4ba72e99b6902535cae40 Mon Sep 17 00:00:00 2001
From: Hristo I Stoyanov
Date: Mon, 30 Mar 2026 15:26:51 -0700
Subject: [PATCH 2/4] Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../store/gigamap/indexer/CompositeIndexNullHandlingTest.java | 1 +
1 file changed, 1 insertion(+)
diff --git a/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java b/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
index 6fa10a0b..44079eb7 100644
--- a/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
+++ b/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
@@ -135,6 +135,7 @@ void nullToNonNullCompositeKeyUpdateWithPersistenceShouldWork()
);
assertEquals(1, map.size());
+ map.store();
}
// Verify persistence
From de4c5fc658cab1c9c4ea308f36dc349facbce9ba Mon Sep 17 00:00:00 2001
From: Hristo I Stoyanov
Date: Mon, 30 Mar 2026 15:27:08 -0700
Subject: [PATCH 3/4] Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../gigamap/indexer/CompositeIndexNullHandlingTest.java | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java b/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
index 44079eb7..dfcfa925 100644
--- a/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
+++ b/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
@@ -167,12 +167,9 @@ void multipleNullToNonNullUpdatesShouldWork()
// Update all to non-null timestamps
assertDoesNotThrow(() ->
{
- event1.timestamp = Instant.parse("2025-06-15T10:30:00Z");
- event2.timestamp = Instant.parse("2025-06-15T11:30:00Z");
- event3.timestamp = Instant.parse("2025-06-15T12:30:00Z");
- map.update(event1, e -> {});
- map.update(event2, e -> {});
- map.update(event3, e -> {});
+ map.update(event1, e -> e.timestamp = Instant.parse("2025-06-15T10:30:00Z"));
+ map.update(event2, e -> e.timestamp = Instant.parse("2025-06-15T11:30:00Z"));
+ map.update(event3, e -> e.timestamp = Instant.parse("2025-06-15T12:30:00Z"));
});
assertEquals(3, map.size());
From c8b352b6d8a751735d85c87e26112d674c212a53 Mon Sep 17 00:00:00 2001
From: "hr.stoyanov"
Date: Tue, 31 Mar 2026 08:27:31 -0700
Subject: [PATCH 4/4] Addressing Copilot fixes
---
.../gigamap/indexer/CompositeIndexNullHandlingTest.java | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java b/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
index dfcfa925..7904c058 100644
--- a/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
+++ b/gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/CompositeIndexNullHandlingTest.java
@@ -4,7 +4,7 @@
* #%L
* EclipseStore GigaMap
* %%
- * Copyright (C) 2023 - 2025 MicroStream Software
+ * Copyright (C) 2023 - 2026 MicroStream Software
* %%
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
@@ -14,7 +14,7 @@
* #L%
*/
-import org.eclipse.store.gigamap.types.BitmapIndices;
+
import org.eclipse.store.gigamap.types.GigaMap;
import org.eclipse.store.gigamap.types.IndexerInstant;
import org.eclipse.store.storage.embedded.types.EmbeddedStorage;
@@ -170,6 +170,9 @@ void multipleNullToNonNullUpdatesShouldWork()
map.update(event1, e -> e.timestamp = Instant.parse("2025-06-15T10:30:00Z"));
map.update(event2, e -> e.timestamp = Instant.parse("2025-06-15T11:30:00Z"));
map.update(event3, e -> e.timestamp = Instant.parse("2025-06-15T12:30:00Z"));
+ map.update(event1, e -> {});
+ map.update(event2, e -> {});
+ map.update(event3, e -> {});
});
assertEquals(3, map.size());
@@ -199,6 +202,6 @@ void nullToNonNullQueryShouldWork()
// Verify queries after update
assertEquals(0, map.query(indexer.is((Instant) null)).count());
- assertEquals(2, map.query(indexer.is((Instant) null).or(indexer.is(Instant.parse("2025-06-15T10:30:00Z")))).count());
+ assertEquals(1, map.query(indexer.is((Instant) null).or(indexer.is(Instant.parse("2025-06-15T10:30:00Z")))).count());
}
}