Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 c95e1403c5 test: make RobustnessTest.testConcurrentUsage deterministic
CircleCI job 22 failed with turnCount 19 instead of 20. A writer thread
died with ConcurrentModificationException: QQQ's MemoryRecordStore.query
iterates a table's synchronized map without holding its lock, so one
writer's INSTANCE-row update (INSTANCE usage lock) raced another
writer's insert of a user row (a different usage lock). The exception
was swallowed by the virtual thread, so the test only saw a lost count.

The race is in the in-memory test backend, not in AssistantStore's
per-key locking. The writers now use a store whose individual backend
calls are atomic, as on a real database, so the per-key locks are still
the only thing protecting the read-modify-write under test. A start
latch releases all writers together, and an executor's futures surface
any writer exception as a test failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:13:08 -05:00
james.maes c04950efc4 Merge pull request #8 from QRun-IO/feature/ga-only-release-tags
ci: publish only GA tags (vX.Y.Z), never RC tags
2026-10-12 16:01:34 -05:00
james.maesandClaude Opus 5.5 f031f9f50a ci: publish only GA tags (vX.Y.Z), never RC tags
An RC tag matched /v.*/ and the orb republished it as the next RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:58:47 -05:00
james.maes 61a2ba643f Merge pull request #3 from QRun-IO/feature/snake-case-table-names
feat!: snake_case assistant_ table and key names (0.1.0-RC.2)
2026-10-12 15:17:10 -05:00
2 changed files with 99 additions and 23 deletions

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ workflows:
branches:
ignore: /.*/
tags:
only: [/v.*/]
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
@@ -26,6 +26,10 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
@@ -46,6 +50,7 @@ import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
@@ -158,31 +163,43 @@ class RobustnessTest extends BaseAssistantTest
@Test
void testConcurrentUsage() throws Exception
{
LocalDate today = LocalDate.now(ZoneOffset.UTC);
List<Thread> threads = new ArrayList<>();
for(int i = 0; i < 20; i++)
LocalDate today = LocalDate.now(ZoneOffset.UTC);
int writers = 20;
CountDownLatch ready = new CountDownLatch(writers);
CountDownLatch go = new CountDownLatch(1);
List<Future<?>> results = new ArrayList<>();
try(ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor())
{
String user = i % 2 == 0 ? EDITOR : AssistantTestFixture.OTHER_EDITOR;
threads.add(Thread.ofVirtual().start(() ->
for(int i = 0; i < writers; i++)
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
try
String user = i % 2 == 0 ? EDITOR : AssistantTestFixture.OTHER_EDITOR;
results.add(executor.submit(() ->
{
new AssistantStore().addUsage(user, today, new ModelUsage().withInputTokens(10L).withOutputTokens(1L), 7, true);
}
catch(QException e)
{
throw (new RuntimeException(e));
}
finally
{
QContext.clear();
}
}));
}
for(Thread thread : threads)
{
thread.join();
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
try
{
ready.countDown();
go.await();
new AtomicCallsStore().addUsage(user, today, new ModelUsage().withInputTokens(10L).withOutputTokens(1L), 7, true);
}
finally
{
QContext.clear();
}
return (null);
}));
}
///////////////////////////////////////////////////////////////////
// release every writer at once, then surface any writer failure //
// (a worker exception must fail the test, not vanish) //
///////////////////////////////////////////////////////////////////
ready.await();
go.countDown();
for(Future<?> result : results)
{
result.get();
}
}
QContext.init(qInstance, AssistantTestFixture.sessionFor(ADMIN));
@@ -205,6 +222,65 @@ class RobustnessTest extends BaseAssistantTest
/*******************************************************************************
** AssistantStore whose individual backend calls are atomic, as they are on a
** real database. QQQ's in-memory backend is not safe for concurrent callers:
** MemoryRecordStore.query iterates a table's synchronized map without holding
** its lock, so inserting a different usage row (another key, so another
** AssistantStore usage lock) can throw ConcurrentModificationException. Each
** call is made atomic here; the read-modify-write across calls is still only
** protected by AssistantStore's per-key usage locks, which is what the test
** checks.
*******************************************************************************/
private static class AtomicCallsStore extends AssistantStore
{
private static final Object BACKEND_LOCK = new Object();
/*******************************************************************************
**
*******************************************************************************/
@Override
public <T extends QRecordEntity> List<T> query(String tableName, QQueryFilter filter, Class<T> entityClass) throws QException
{
synchronized(BACKEND_LOCK)
{
return (super.query(tableName, filter, entityClass));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public <T extends QRecordEntity> T insert(T entity) throws QException
{
synchronized(BACKEND_LOCK)
{
return (super.insert(entity));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void update(QRecordEntity entity) throws QException
{
synchronized(BACKEND_LOCK)
{
super.update(entity);
}
}
}
/*******************************************************************************
** Usage of a failed model call (partial) is still counted.
*******************************************************************************/