Skip to content

Commit eb218db

Browse files
committed
fix: race condition on page updates
Fixed issue ArcadeData#3714
1 parent d0155a0 commit eb218db

3 files changed

Lines changed: 200 additions & 3 deletions

File tree

engine/src/main/java/com/arcadedb/database/Binary.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,14 +392,16 @@ public long getNumber() {
392392

393393
@Override
394394
public long getUnsignedNumber() {
395+
final int startPos = buffer.position();
395396
long value = 0L;
396397
int i = 0;
397398
long b;
398399
while (((b = getByte()) & 0x80L) != 0) {
399400
value |= (b & 0x7F) << i;
400401
i += 7;
401402
if (i > 63)
402-
throw new IllegalArgumentException("Variable length quantity is too long (must be <= 63)");
403+
throw new IllegalArgumentException(
404+
"Variable length quantity is too long (must be <= 63) at position " + startPos + " in buffer of size " + size);
403405
}
404406
return value | (b << i);
405407
}
@@ -411,6 +413,7 @@ public long getUnsignedNumber() {
411413
*/
412414
@Override
413415
public long[] getUnsignedNumberAndSize() {
416+
final int startPos = buffer.position();
414417
long value = 0L;
415418
int i = 0;
416419
long b;
@@ -419,7 +422,8 @@ public long[] getUnsignedNumberAndSize() {
419422
value |= (b & 0x7F) << i;
420423
i += 7;
421424
if (i > 63)
422-
throw new IllegalArgumentException("Variable length (" + i + ") quantity is too long (must be <= 63)");
425+
throw new IllegalArgumentException(
426+
"Variable length quantity is too long (must be <= 63) at position " + startPos + " in buffer of size " + size);
423427
++byteRead;
424428
}
425429
return new long[] { value | (b << i), byteRead };

engine/src/main/java/com/arcadedb/engine/CachedPage.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,17 @@ public class CachedPage {
3737

3838
public CachedPage(final MutablePage page, final boolean copyBuffer) {
3939
this.pageId = page.pageId;
40-
this.content = copyBuffer ? page.content.copy() : page.content;
40+
if (copyBuffer) {
41+
// Deep copy: duplicate the full backing array so the cached copy is completely independent
42+
// from the original MutablePage. Binary.copy() only creates a new ByteBuffer view over the
43+
// SAME array, which is not safe when the original page is still reachable (e.g. from the
44+
// async flush thread's queue).
45+
final byte[] srcArray = page.content.getContent();
46+
final byte[] copied = java.util.Arrays.copyOf(srcArray, srcArray.length);
47+
this.content = new Binary(copied, page.content.size());
48+
} else {
49+
this.content = page.content;
50+
}
4151
this.size = page.size;
4252
this.version = page.version;
4353
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
* Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
17+
* SPDX-License-Identifier: Apache-2.0
18+
*/
19+
package com.arcadedb.index;
20+
21+
import com.arcadedb.database.Database;
22+
import com.arcadedb.database.DatabaseFactory;
23+
import com.arcadedb.schema.Schema;
24+
import com.arcadedb.utility.FileUtils;
25+
import org.junit.jupiter.api.AfterEach;
26+
import org.junit.jupiter.api.BeforeEach;
27+
import org.junit.jupiter.api.Test;
28+
29+
import java.io.*;
30+
import java.util.concurrent.*;
31+
import java.util.concurrent.atomic.*;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
35+
/**
36+
* Regression test for #3714: "Variable length quantity is too long (must be <= 63)"
37+
* error during batch UPSERTs after many records. The issue manifests as page data
38+
* corruption during concurrent batch UPSERT operations with indexed fields.
39+
*
40+
* @author Luca Garulli (l.garulli@arcadedata.com)
41+
*/
42+
class Issue3714BatchUpsertVLQTest {
43+
private static final String DB_PATH = "target/databases/Issue3714BatchUpsertVLQTest";
44+
45+
private Database database;
46+
47+
@BeforeEach
48+
void setUp() {
49+
FileUtils.deleteRecursively(new File(DB_PATH));
50+
database = new DatabaseFactory(DB_PATH).create();
51+
database.transaction(() -> {
52+
final var type = database.getSchema().buildDocumentType().withName("Metadata").withTotalBuckets(4).create();
53+
type.createProperty("recordId", String.class);
54+
type.createProperty("data", String.class);
55+
database.getSchema().buildTypeIndex("Metadata", new String[] { "recordId" })
56+
.withType(Schema.INDEX_TYPE.LSM_TREE).withUnique(true).withPageSize(64 * 1024).create();
57+
});
58+
}
59+
60+
@AfterEach
61+
void tearDown() {
62+
if (database != null && database.isOpen())
63+
database.drop();
64+
}
65+
66+
/**
67+
* Reproduce #3714: concurrent batch UPSERTs with many records trigger
68+
* "Variable length quantity is too long" or "arraycopy: length is negative"
69+
* during transaction commit. Each thread uses its own key namespace to minimize
70+
* ConcurrentModificationException retries.
71+
*/
72+
@Test
73+
void testConcurrentBatchUpsertsDoNotCorruptIndex() throws Exception {
74+
// Use 2 threads to reduce contention while still testing concurrency
75+
final int threads = 2;
76+
final int batchesPerThread = 500;
77+
final int recordsPerBatch = 20;
78+
final AtomicReference<Throwable> error = new AtomicReference<>();
79+
final AtomicInteger successfulBatches = new AtomicInteger();
80+
final CountDownLatch startLatch = new CountDownLatch(1);
81+
final CountDownLatch doneLatch = new CountDownLatch(threads);
82+
83+
for (int t = 0; t < threads; t++) {
84+
final int threadId = t;
85+
new Thread(() -> {
86+
try {
87+
startLatch.await();
88+
for (int batch = 0; batch < batchesPerThread; batch++) {
89+
final StringBuilder sql = new StringBuilder("BEGIN;");
90+
for (int r = 0; r < recordsPerBatch; r++) {
91+
final String id = "t" + threadId + "_b" + batch + "_r" + r;
92+
final String data = "{\"recordId\":\"" + id + "\",\"value\":\"data_" + id + "\",\"batch\":" + batch + "}";
93+
sql.append("UPDATE Metadata CONTENT ").append(data)
94+
.append(" UPSERT WHERE recordId = '").append(id).append("';");
95+
}
96+
sql.append("COMMIT;");
97+
98+
boolean success = false;
99+
for (int retry = 0; retry < 200 && !success; retry++) {
100+
try {
101+
database.command("sqlscript", sql.toString());
102+
success = true;
103+
successfulBatches.incrementAndGet();
104+
} catch (final Exception e) {
105+
final String msg = fullErrorMessage(e);
106+
if (msg.contains("ConcurrentModification") || msg.contains("Please retry"))
107+
continue;
108+
// Unexpected error — likely the VLQ or arraycopy bug
109+
error.compareAndSet(null, e);
110+
return;
111+
}
112+
}
113+
// Skip if retries exhausted due to contention — not the bug we're testing for
114+
}
115+
} catch (final Throwable e) {
116+
error.compareAndSet(null, e);
117+
} finally {
118+
doneLatch.countDown();
119+
}
120+
}, "UpsertThread-" + t).start();
121+
}
122+
123+
startLatch.countDown();
124+
assertThat(doneLatch.await(180, TimeUnit.SECONDS)).as("Threads should finish within timeout").isTrue();
125+
126+
if (error.get() != null)
127+
error.get().printStackTrace();
128+
129+
assertThat(error.get()).as("Batch UPSERTs should not throw: %s",
130+
error.get() != null ? error.get().getMessage() : "").isNull();
131+
132+
// Verify data integrity: at least the successful batches should be counted
133+
database.transaction(() -> {
134+
final long count = database.countType("Metadata", false);
135+
assertThat(count).isEqualTo((long) successfulBatches.get() * recordsPerBatch);
136+
});
137+
}
138+
139+
/**
140+
* Test single-thread batch UPSERTs that re-update the same keys repeatedly.
141+
* This stresses the index with many modifications to the same page.
142+
*/
143+
@Test
144+
void testRepeatedBatchUpsertsOnSameKeys() {
145+
final int totalKeys = 1000;
146+
final int iterations = 100;
147+
148+
// First insert all keys
149+
database.transaction(() -> {
150+
for (int i = 0; i < totalKeys; i++)
151+
database.command("sql", "INSERT INTO Metadata SET recordId = 'key_" + i + "', data = 'initial'");
152+
});
153+
154+
// Repeatedly update in batches
155+
for (int iter = 0; iter < iterations; iter++) {
156+
final StringBuilder sql = new StringBuilder("BEGIN;");
157+
for (int i = 0; i < 20; i++) {
158+
final int keyIdx = (iter * 20 + i) % totalKeys;
159+
sql.append("UPDATE Metadata CONTENT {\"recordId\":\"key_").append(keyIdx)
160+
.append("\",\"data\":\"updated_iter").append(iter).append("_").append(i)
161+
.append("\"} UPSERT WHERE recordId = 'key_").append(keyIdx).append("';");
162+
}
163+
sql.append("COMMIT;");
164+
database.command("sqlscript", sql.toString());
165+
}
166+
167+
// Verify integrity
168+
database.transaction(() -> {
169+
assertThat(database.countType("Metadata", false)).isEqualTo(totalKeys);
170+
});
171+
}
172+
173+
private static String fullErrorMessage(final Throwable e) {
174+
final StringBuilder sb = new StringBuilder();
175+
Throwable current = e;
176+
while (current != null) {
177+
if (current.getMessage() != null)
178+
sb.append(current.getMessage()).append(" ");
179+
current = current.getCause();
180+
}
181+
return sb.toString();
182+
}
183+
}

0 commit comments

Comments
 (0)