Reserve SQLite writer before reading subscription event receipts

This commit is contained in:
root
2026-10-21 10:11:57 +08:00
parent d6eb25a0f6
commit 788fd1e7e2
3 changed files with 29 additions and 0 deletions
@@ -7,6 +7,10 @@ import org.apache.ibatis.annotations.*;
@Mapper @Mapper
public interface SubscriptionEventMapper { public interface SubscriptionEventMapper {
/** Reserve SQLite's writer before the receipt read; a deferred read-to-write upgrade can fail immediately. */
@Update("update subscription_event_receipt set ingested_at=ingested_at where 0")
void reserveWriter();
@Select("select fingerprint from subscription_event_receipt where event_id=#{eventId}") @Select("select fingerprint from subscription_event_receipt where event_id=#{eventId}")
String fingerprint(String eventId); String fingerprint(String eventId);
@@ -123,6 +123,7 @@ public class SubscriptionEventService {
try { try {
// Return acknowledgements only after execute has returned and the entire transaction committed. // Return acknowledgements only after execute has returned and the entire transaction committed.
List<String> acknowledged = transactions.execute(status -> { List<String> acknowledged = transactions.execute(status -> {
events.reserveWriter();
List<String> ids = new ArrayList<>(); List<String> ids = new ArrayList<>();
for (SubscriptionEvent event : batch) if (persist(event, "standby", null)) ids.add(event.getEventId()); for (SubscriptionEvent event : batch) if (persist(event, "standby", null)) ids.add(event.getEventId());
return ids; return ids;
@@ -133,6 +134,7 @@ public class SubscriptionEventService {
public void recordPrimary(SubscriptionEvent event, SubBind binding) { public void recordPrimary(SubscriptionEvent event, SubBind binding) {
transactions.executeWithoutResult(status -> { transactions.executeWithoutResult(status -> {
events.reserveWriter();
if (!persist(event, "primary", binding)) throw new IllegalStateException("subscription user unavailable"); if (!persist(event, "primary", binding)) throw new IllegalStateException("subscription user unavailable");
}); });
} }
@@ -94,6 +94,29 @@ class SubscriptionEventIntegrationTest {
assertEquals(5, ((Number) row.get("user_id")).intValue()); assertEquals(5, ((Number) row.get("user_id")).intValue());
} }
@Test void concurrentPrimaryAndReplayTransactionsReserveTheWriterBeforeReading() throws Exception {
var start = new java.util.concurrent.CountDownLatch(1);
var executor = java.util.concurrent.Executors.newFixedThreadPool(8);
var binding = subscriptions.selectSubBind(KEY);
try {
List<java.util.concurrent.Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 32; i++) {
SubscriptionEvent event = event(NOW - i);
boolean primary = i % 2 == 0;
futures.add(executor.submit(() -> {
try { start.await(); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new RuntimeException(interrupted); }
if (primary) service.recordPrimary(event, binding);
else assertEquals(List.of(event.getEventId()), service.ingest(List.of(event)).get("acknowledged"));
}));
}
start.countDown();
for (var future : futures) future.get(30, java.util.concurrent.TimeUnit.SECONDS);
assertEquals(32, count("subscription_event_receipt"));
assertEquals(10, count("sub_update_record"));
} finally { executor.shutdownNow(); }
}
@Test void keyRotationAndRenameRetainStableIdentity() { @Test void keyRotationAndRenameRetainStableIdentity() {
service.rememberBindings(subscriptions.selectAllSubBind()); service.rememberBindings(subscriptions.selectAllSubBind());
db.update("update sub_bind set key='rotated-key'"); db.update("update sub_bind set key='rotated-key'");