diff --git a/backtrace-library/src/androidTest/java/backtraceio/library/database/BacktraceDatabaseContextMultithreadedTest.java b/backtrace-library/src/androidTest/java/backtraceio/library/database/BacktraceDatabaseContextMultithreadedTest.java new file mode 100644 index 00000000..05de2902 --- /dev/null +++ b/backtrace-library/src/androidTest/java/backtraceio/library/database/BacktraceDatabaseContextMultithreadedTest.java @@ -0,0 +1,174 @@ +package backtraceio.library.database; + +import static org.junit.Assert.assertEquals; + +import androidx.annotation.NonNull; + +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; + +import backtraceio.library.models.BacktraceData; +import backtraceio.library.models.database.BacktraceDatabaseRecord; +import backtraceio.library.models.database.BacktraceDatabaseSettings; +import backtraceio.library.models.json.BacktraceReport; +import backtraceio.library.services.BacktraceDatabaseContext; + +public class BacktraceDatabaseContextMultithreadedTest { + private static class TestConfig { + final int recordsState; + final int recordsToAdd; + final int recordsToDelete; + final int threadWaitTimeMs; + + TestConfig(int recordsState, int recordsToAdd, int recordsToDelete, int threadWaitTimeMs) { + this.recordsState = recordsState; + this.recordsToAdd = recordsToAdd; + this.recordsToDelete = recordsToDelete; + this.threadWaitTimeMs = threadWaitTimeMs; + } + } + + private static class ConcurrentTestState { + final List caughtExceptions = new ArrayList<>(); + final List deletedRecords = new ArrayList<>(); + final List addedRecords = new ArrayList<>(); + + synchronized void handleException(Exception e) { + caughtExceptions.add(e); + } + + void printExceptions() { + for (Exception e : caughtExceptions) { + e.printStackTrace(); + } + } + } + private BacktraceDatabaseContext databaseContext; + + @Before + public void setUp() { + BacktraceDatabaseSettings settings = new BacktraceDatabaseSettings("test-path"); + settings.setRetryLimit(3); + databaseContext = new BacktraceDatabaseContext(settings); + } + + @Test + public void testConcurrentModification() throws InterruptedException { + // GIVEN + final TestConfig config = new TestConfig(500, 250, 150, 30000); // 30s + final List initialRecords = generateMockRecords(config.recordsState); + + final CountDownLatch startLatch = new CountDownLatch(1); + final ConcurrentTestState testState = new ConcurrentTestState(); + + // Create and start test threads + Thread addThread = createAddThread(startLatch, config.recordsToAdd, testState); + Thread deleteThread = createDeleteThread(startLatch, initialRecords, config.recordsToDelete, testState); + Thread readThread = createReadThread(startLatch, testState); + + // WHEN + startThreads(deleteThread, addThread, readThread); + startLatch.countDown(); + waitForThreads(deleteThread, addThread, readThread, config.threadWaitTimeMs); + + // Print any exceptions that occurred + testState.printExceptions(); + + // THEN + assertTestResults(config, testState); + } + + private Thread createDeleteThread(CountDownLatch latch, List records, + int recordsToDelete, ConcurrentTestState state) { + return new Thread(() -> { + try { + latch.await(); + for (int i = 0; i < recordsToDelete; i++) { + databaseContext.delete(records.get(i)); + state.deletedRecords.add(1); + } + } catch (Exception e) { + state.handleException(e); + } + }); + } + + private Thread createAddThread(CountDownLatch latch, int recordsToAdd, ConcurrentTestState state) { + return new Thread(() -> { + try { + latch.await(); + for (int i = 0; i < recordsToAdd; i++) { + BacktraceData data = createMockBacktraceData(); + databaseContext.add(data); + state.addedRecords.add(1); + } + } catch (Exception e) { + state.handleException(e); + } + }); + } + + private Thread createReadThread(CountDownLatch latch, ConcurrentTestState state) { + return new Thread(() -> { + try { + latch.await(); + String result; + while (true) { + for (BacktraceDatabaseRecord record : databaseContext.get()) { + result = record.toString(); + } + } + } catch (Exception e) { + state.handleException(e); + } + }); + } + + private void startThreads(Thread... threads) { + for (Thread thread : threads) { + thread.start(); + } + } + + private void waitForThreads(Thread deleteThread, Thread addThread, Thread readThread, int waitTimeMs) + throws InterruptedException { + deleteThread.join(waitTimeMs); + addThread.join(waitTimeMs); + readThread.join(waitTimeMs); + } + + private void assertTestResults(TestConfig config, ConcurrentTestState state) { + assertEquals(0, state.caughtExceptions.size()); + assertEquals( + config.recordsState + config.recordsToAdd - config.recordsToDelete, + config.recordsState + state.addedRecords.size() - state.deletedRecords.size() + ); + } + + @NonNull + private List generateMockRecords(int recordCount) { + final List records = new ArrayList<>(); + for (int i = 0; i < recordCount; i++) { + BacktraceData data = createMockBacktraceData(); + BacktraceDatabaseRecord record = databaseContext.add(data); + records.add(record); + } + return records; + } + + private BacktraceData createMockBacktraceData() { + final Exception testException = new Exception("Test exception"); + + final Map attributes = new HashMap() {{ + put("test_attribute", "test_value"); + }}; + + return new BacktraceData.Builder(new BacktraceReport(testException, attributes)).build(); + } +} diff --git a/backtrace-library/src/main/java/backtraceio/library/services/BacktraceDatabaseContext.java b/backtrace-library/src/main/java/backtraceio/library/services/BacktraceDatabaseContext.java index 4424ab9c..499720bf 100644 --- a/backtrace-library/src/main/java/backtraceio/library/services/BacktraceDatabaseContext.java +++ b/backtrace-library/src/main/java/backtraceio/library/services/BacktraceDatabaseContext.java @@ -4,10 +4,14 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import backtraceio.library.enums.database.RetryOrder; import backtraceio.library.interfaces.DatabaseContext; @@ -30,21 +34,20 @@ public class BacktraceDatabaseContext implements DatabaseContext { */ private final int _retryNumber; - /** * Database cache */ - private final Map> batchRetry = new HashMap<>(); + private final Map> batchRetry = new ConcurrentHashMap<>(); /** * Total database size on hard drive */ - private long totalSize = 0; + private final AtomicLong totalSize = new AtomicLong(0); /** * Total records in BacktraceDatabase */ - private int totalRecords = 0; + private final AtomicInteger totalRecords = new AtomicInteger(0); /** * Record order @@ -98,7 +101,7 @@ private void setupBatch() { } for (int i = 0; i < _retryNumber; i++) { - this.batchRetry.put(i, new ArrayList<>()); + this.batchRetry.put(i, new ConcurrentLinkedQueue<>()); } } @@ -135,9 +138,9 @@ public BacktraceDatabaseRecord add(BacktraceDatabaseRecord backtraceDatabaseReco throw new NullPointerException("BacktraceDatabaseRecord"); } backtraceDatabaseRecord.locked = true; - this.totalSize += backtraceDatabaseRecord.getSize(); + this.totalSize.addAndGet(backtraceDatabaseRecord.getSize()); this.addToFirstBatch(backtraceDatabaseRecord); - this.totalRecords++; + this.totalRecords.incrementAndGet(); return backtraceDatabaseRecord; } @@ -169,12 +172,22 @@ public BacktraceDatabaseRecord last() { */ public Iterable get() { List allRecords = new ArrayList<>(); - for (Map.Entry> entry : batchRetry.entrySet()) { + for (Map.Entry> entry : batchRetry.entrySet()) { allRecords.addAll(entry.getValue()); } return allRecords; } + /** + * Get database size + * + * @return database size + */ + public long getDatabaseSize() { + return this.totalSize.get(); + } + + /** * Delete existing record from database * @@ -186,7 +199,7 @@ public boolean delete(BacktraceDatabaseRecord record) { } for (int key : batchRetry.keySet()) { - List records = batchRetry.get(key); + Queue records = batchRetry.get(key); if (records == null) { continue; @@ -202,12 +215,12 @@ public boolean delete(BacktraceDatabaseRecord record) { databaseRecord.delete(); try { iterator.remove(); - this.totalRecords--; - this.totalSize -= databaseRecord.getSize(); + this.totalRecords.decrementAndGet(); + this.totalSize.addAndGet(-databaseRecord.getSize()); return true; } catch (Exception e) { BacktraceLogger.d(LOG_TAG, "Exception on removing record " - + databaseRecord.id.toString() + "from db context: " + e.getMessage()); + + databaseRecord.id + " from db context: " + e.getMessage()); } } } @@ -224,8 +237,8 @@ public boolean contains(BacktraceDatabaseRecord record) { if (record == null) { throw new NullPointerException("BacktraceDatabaseRecord"); } - for (Map.Entry> entry : this.batchRetry.entrySet()) { - List records = entry.getValue(); + for (Map.Entry> entry : this.batchRetry.entrySet()) { + Queue records = entry.getValue(); for (BacktraceDatabaseRecord databaseRecord : records) { if (databaseRecord != null && databaseRecord.id == record.id) { @@ -242,7 +255,7 @@ public boolean contains(BacktraceDatabaseRecord record) { * @return is database empty */ public boolean isEmpty() { - return totalRecords == 0; + return totalRecords.get() == 0; } /** @@ -251,7 +264,7 @@ public boolean isEmpty() { * @return number of records in database */ public int count() { - return totalRecords; + return totalRecords.get(); } /** @@ -259,40 +272,23 @@ public int count() { */ public void clear() { BacktraceLogger.d(LOG_TAG, "Deleting all records from database context"); - for (Map.Entry> entry : this.batchRetry.entrySet()) { - List records = entry.getValue(); + for (Map.Entry> entry : this.batchRetry.entrySet()) { + Queue records = entry.getValue(); for (BacktraceDatabaseRecord databaseRecord : records) { databaseRecord.delete(); } } - this.totalRecords = 0; - this.totalSize = 0; + this.totalRecords.set(0); + this.totalSize.set(0); - for (Map.Entry> entry : this.batchRetry.entrySet()) { + for (Map.Entry> entry : this.batchRetry.entrySet()) { entry.getValue().clear(); } } - /** - * Increment retry time for current record - */ - public void incrementBatchRetry() { - removeMaxRetries(); - incrementBatches(); - } - - /** - * Get database size - * - * @return database size - */ - public long getDatabaseSize() { - return this.totalSize; - } - - /** + /** * Delete the oldest file * * @return is deletion was successful @@ -313,8 +309,8 @@ public boolean removeOldestRecord() { */ private void incrementBatches() { for (int i = this._retryNumber - 2; i >= 0; i--) { - List currentBatch = this.batchRetry.get(i); - batchRetry.put(i, new ArrayList<>()); + Queue currentBatch = this.batchRetry.get(i); + batchRetry.put(i, new ConcurrentLinkedQueue<>()); batchRetry.put(i + 1, currentBatch); } } @@ -323,18 +319,25 @@ private void incrementBatches() { * Remove last batch */ private void removeMaxRetries() { - List currentBatch = this.batchRetry.get(_retryNumber - 1); + Queue currentBatch = this.batchRetry.get(_retryNumber - 1); for (BacktraceDatabaseRecord record : currentBatch) { if (!record.valid()) { continue; } record.delete(); - this.totalRecords--; - totalSize -= record.getSize(); + this.totalRecords.decrementAndGet(); + totalSize.addAndGet(-record.getSize()); } } + /** + * Increment retry time for current record + */ + public void incrementBatchRetry() { + removeMaxRetries(); + incrementBatches(); + } /** * Get first record in in-cache BacktraceDatabase @@ -362,17 +365,20 @@ private BacktraceDatabaseRecord getLastRecord() { */ private BacktraceDatabaseRecord getRecordFromCache(boolean reverse) { for (int i = _retryNumber - 1; i >= 0; i--) { - List reverseRecords = batchRetry.get(i); + Queue batchRecords = batchRetry.get(i); - if (reverseRecords == null) { + if (batchRecords == null) { continue; } + Iterable records = batchRecords; if (reverse) { - Collections.reverse(reverseRecords); + List recordsList = new ArrayList<>(batchRecords); + Collections.reverse(recordsList); + records = recordsList; } - for (BacktraceDatabaseRecord record : reverseRecords) { + for (BacktraceDatabaseRecord record : records) { if (record != null && !record.locked) { record.locked = true; return record; @@ -386,13 +392,13 @@ private void addToFirstBatch(BacktraceDatabaseRecord backtraceDatabaseRecord) { final int firstBatch = 0; if (this.batchRetry.isEmpty()) { - this.batchRetry.put(firstBatch, new ArrayList<>()); + this.batchRetry.put(firstBatch, new ConcurrentLinkedQueue<>()); } - List batch = this.batchRetry.get(firstBatch); + Queue batch = this.batchRetry.get(firstBatch); if (batch == null) { - batch = new ArrayList<>(); + batch = new ConcurrentLinkedQueue<>(); this.batchRetry.put(firstBatch, batch); }