diff --git a/engine/src/main/java/com/arcadedb/database/TransactionIndexContext.java b/engine/src/main/java/com/arcadedb/database/TransactionIndexContext.java index 279cebcc29..26419eac0a 100644 --- a/engine/src/main/java/com/arcadedb/database/TransactionIndexContext.java +++ b/engine/src/main/java/com/arcadedb/database/TransactionIndexContext.java @@ -42,6 +42,7 @@ public static class IndexKey { public final boolean unique; public final Object[] keyValues; public final RID rid; + public RID oldRid; // for REPLACE created from same-bucket REMOVE→ADD: the old RID being replaced public IndexKeyOperation operation; public enum IndexKeyOperation { @@ -174,6 +175,9 @@ public void commit() { for (final IndexKey key : values) { if (key.operation == IndexKey.IndexKeyOperation.REMOVE) index.remove(key.keyValues, key.rid); + else if (key.operation == IndexKey.IndexKeyOperation.REPLACE && key.oldRid != null) + // REMOVE THE OLD RID THAT WAS REPLACED BY A NEW ONE IN THE SAME BUCKET + index.remove(key.keyValues, key.oldRid); } } } @@ -290,6 +294,14 @@ public void addIndexKeyLock(final IndexInternal index, IndexKey.IndexKeyOperatio // REPLACE EXISTENT WITH THIS v.operation = IndexKey.IndexKeyOperation.REPLACE; + if (entry != null) { + if (entry.operation == IndexKey.IndexKeyOperation.REMOVE) + // SAVE THE OLD RID SO IT CAN BE PROPERLY REMOVED FROM THE PERSISTED INDEX AT COMMIT TIME + v.oldRid = entry.rid; + else if (entry.operation == IndexKey.IndexKeyOperation.REPLACE) + // PROPAGATE THE OLD RID FROM THE PREVIOUS REPLACE OPERATION (e.g. REMOVE → ADD → ADD) + v.oldRid = entry.oldRid; + } } } } @@ -418,9 +430,14 @@ private Map> getTxDeletedEntries() { final ComparableKey key = new ComparableKey(entry.getValue().keyValues); final RID existent = entries.get(key); - if (existent == null || entry.getValue().operation == IndexKey.IndexKeyOperation.REMOVE) - // MULTIPLE OPERATIONS ON THE SAME KEY (DIFFERENT BUCKETS), PREFER THE REMOVE ONE - entries.put(key, entry.getKey().rid); + if (existent == null || entry.getValue().operation == IndexKey.IndexKeyOperation.REMOVE) { + // MULTIPLE OPERATIONS ON THE SAME KEY (DIFFERENT BUCKETS), PREFER THE REMOVE ONE. + // For REPLACE entries that originated from a same-bucket REMOVE→ADD merge, use the oldRid (the actual deleted RID). + final RID deletedRid = (entry.getValue().operation == IndexKey.IndexKeyOperation.REPLACE && entry.getValue().oldRid != null) + ? entry.getValue().oldRid + : entry.getKey().rid; + entries.put(key, deletedRid); + } } } } diff --git a/engine/src/test/java/com/arcadedb/graph/EdgeIndexDuplicateKeyTest.java b/engine/src/test/java/com/arcadedb/graph/EdgeIndexDuplicateKeyTest.java index 8b8028a933..a383ce9830 100644 --- a/engine/src/test/java/com/arcadedb/graph/EdgeIndexDuplicateKeyTest.java +++ b/engine/src/test/java/com/arcadedb/graph/EdgeIndexDuplicateKeyTest.java @@ -19,8 +19,11 @@ package com.arcadedb.graph; import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.Result; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + /** * Test for issue #3097: Edge indexes become invalid in certain scenario #2 * Reproduces DuplicatedKeyException when deleting and recreating the same edge multiple times. @@ -33,52 +36,63 @@ class EdgeIndexDuplicateKeyTest extends TestHelper { void edgeDeleteAndRecreateMultipleTimes() { // Transaction #1: Create schema database.transaction(() -> { - database.command("sql", "CREATE VERTEX TYPE duct"); - database.command("sql", "CREATE VERTEX TYPE trs"); - database.command("sql", "CREATE PROPERTY duct.id STRING"); - database.command("sql", "CREATE INDEX ON duct (id) UNIQUE"); - database.command("sql", "CREATE PROPERTY trs.id STRING"); - database.command("sql", "CREATE INDEX ON trs (id) UNIQUE"); - database.command("sql", "CREATE EDGE TYPE trs_duct"); - database.command("sql", "CREATE PROPERTY trs_duct.from_id STRING"); - database.command("sql", "CREATE INDEX ON trs_duct (from_id) NOTUNIQUE"); - database.command("sql", "CREATE PROPERTY trs_duct.to_id STRING"); - database.command("sql", "CREATE INDEX ON trs_duct (to_id) NOTUNIQUE"); - database.command("sql", "CREATE PROPERTY trs_duct.swap STRING"); - database.command("sql", "CREATE PROPERTY trs_duct.order_number INTEGER"); - database.command("sql", "CREATE INDEX ON trs_duct (from_id,to_id,swap,order_number) UNIQUE"); + database.command("sqlscript", """ + CREATE VERTEX TYPE duct; + CREATE VERTEX TYPE trs; + CREATE PROPERTY duct.id STRING; + CREATE INDEX ON duct (id) UNIQUE; + CREATE PROPERTY trs.id STRING; + CREATE INDEX ON trs (id) UNIQUE; + CREATE EDGE TYPE trs_duct; + CREATE PROPERTY trs_duct.from_id STRING; + CREATE INDEX ON trs_duct (from_id) NOTUNIQUE; + CREATE PROPERTY trs_duct.to_id STRING; + CREATE INDEX ON trs_duct (to_id) NOTUNIQUE; + CREATE PROPERTY trs_duct.swap STRING; + CREATE PROPERTY trs_duct.order_number INTEGER; + CREATE INDEX ON trs_duct (from_id,to_id,swap,order_number) UNIQUE; + """); }); // Transaction #2: Insert vertices and create edge database.transaction(() -> { - database.command("sql", "INSERT INTO duct (id) VALUES ('duct_1')"); - database.command("sql", "INSERT INTO trs (id) VALUES ('trs_1')"); - database.command("sql", - """ - CREATE EDGE trs_duct from (SELECT FROM trs WHERE id='trs_1') to (SELECT FROM duct WHERE id='duct_1') \ + database.command("sqlscript", """ + INSERT INTO duct (id) VALUES ('duct_1'); + INSERT INTO trs (id) VALUES ('trs_1'); + + CREATE EDGE trs_duct + from (SELECT FROM trs WHERE id='trs_1') + to (SELECT FROM duct WHERE id='duct_1') SET from_id='trs_1', to_id='duct_1', swap='N', order_number=1"""); }); // Transaction #3: Delete and recreate edge (first time - should work) database.transaction(() -> { - database.command("sql", - "DELETE FROM trs_duct WHERE (from_id='trs_1') AND (to_id='duct_1') AND (swap='N') AND (order_number=1)"); - database.command("sql", - """ - CREATE EDGE trs_duct from (SELECT FROM trs WHERE id='trs_1') to (SELECT FROM duct WHERE id='duct_1') \ + database.command("sqlscript", """ + DELETE FROM trs_duct WHERE (from_id='trs_1') AND (to_id='duct_1') AND (swap='N') AND (order_number=1); + + CREATE EDGE trs_duct + from (SELECT FROM trs WHERE id='trs_1') + to (SELECT FROM duct WHERE id='duct_1') SET from_id='trs_1', to_id='duct_1', swap='N', order_number=1"""); }); // Transaction #4: Delete and recreate edge (second time - this should NOT throw DuplicatedKeyException) - database.transaction(() -> { - database.command("sql", - "DELETE FROM trs_duct WHERE (from_id='trs_1') AND (to_id='duct_1') AND (swap='N') AND (order_number=1)"); - database.command("sql", - """ - CREATE EDGE trs_duct from (SELECT FROM trs WHERE id='trs_1') to (SELECT FROM duct WHERE id='duct_1') \ - SET from_id='trs_1', to_id='duct_1', swap='N', order_number=1"""); - }); + for (int i = 0; i < 10; i++) { + database.transaction(() -> { + database.command("sqlscript", """ + DELETE FROM trs_duct WHERE (from_id='trs_1') AND (to_id='duct_1') AND (swap='N') AND (order_number=1); + + CREATE EDGE trs_duct + from (SELECT FROM trs WHERE id='trs_1') + to (SELECT FROM duct WHERE id='duct_1') + SET from_id='trs_1', to_id='duct_1', swap='N', order_number=1"""); + }); + } - // If we got here without exception, the test passes + Result result = database.query("sql", + "SELECT COUNT(*) AS edgeCount FROM trs_duct WHERE from_id='trs_1' AND to_id='duct_1' AND swap='N' AND order_number=1") + .next(); + assertThat(result.getProperty("edgeCount")).isEqualTo(1); } } diff --git a/server/src/main/java/com/arcadedb/server/ha/message/TxForwardRequest.java b/server/src/main/java/com/arcadedb/server/ha/message/TxForwardRequest.java index 2d639c2b20..ccbf56b6f8 100755 --- a/server/src/main/java/com/arcadedb/server/ha/message/TxForwardRequest.java +++ b/server/src/main/java/com/arcadedb/server/ha/message/TxForwardRequest.java @@ -160,6 +160,15 @@ protected void writeIndexKeysToBuffer(final DatabaseInternal database, uniqueKeysBuffer.putByte((byte) key.operation.ordinal()); uniqueKeysBuffer.putUnsignedNumber(key.rid.getBucketId()); uniqueKeysBuffer.putUnsignedNumber(key.rid.getPosition()); + if (key.operation == TransactionIndexContext.IndexKey.IndexKeyOperation.REPLACE) { + // Serialize oldRid for REPLACE entries (introduced to fix same-bucket REMOVE→ADD merge) + final boolean hasOldRid = key.oldRid != null; + uniqueKeysBuffer.putByte((byte) (hasOldRid ? 1 : 0)); + if (hasOldRid) { + uniqueKeysBuffer.putUnsignedNumber(key.oldRid.getBucketId()); + uniqueKeysBuffer.putUnsignedNumber(key.oldRid.getPosition()); + } + } } } } @@ -211,6 +220,11 @@ protected Map