You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by ad...@apache.org on 2026/06/11 08:33:19 UTC
(polaris) branch main updated: Give the in-memory event buffer its own lock (#4572) (#4577)
This is an automated email from the ASF dual-hosted git repository.
adutra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new d9646354e9 Give the in-memory event buffer its own lock (#4572) (#4577)
d9646354e9 is described below
commit d9646354e9023630c7a238687087be05b7a3e5f0
Author: Rahul Joshi <vi...@gmail.com>
AuthorDate: Thu Jun 11 04:33:14 2026 -0400
Give the in-memory event buffer its own lock (#4572) (#4577)
Replace the borrowed-monitor approach from #4498 with an EventProcessor
that holds its own ReentrantLock and a completed flag, so onNext and
onComplete no longer depend on UnicastProcessor's internal synchronized.
processEvent retries on eviction; shutdown uses a read/write lock to
drain in-flight events. Removes the completeSynchronized helper.
---
.../inmemory/InMemoryBufferEventListener.java | 135 +++++++++++++++------
.../InMemoryBufferEventListenerBufferSizeTest.java | 6 +-
.../InMemoryBufferEventListenerTestBase.java | 10 +-
3 files changed, 109 insertions(+), 42 deletions(-)
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListener.java b/runtime/service/src/main/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListener.java
index 8a6aee707a..c2144c3f37 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListener.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListener.java
@@ -32,6 +32,8 @@ import jakarta.inject.Inject;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
import org.apache.polaris.core.PolarisCallContext;
import org.apache.polaris.core.context.RealmContext;
import org.apache.polaris.core.entity.PolarisEvent;
@@ -51,58 +53,117 @@ public class InMemoryBufferEventListener extends PolarisPersistenceEventListener
@Inject MetaStoreManagerFactory metaStoreManagerFactory;
@Inject InMemoryBufferEventListenerConfiguration configuration;
+ /**
+ * Thrown by {@link EventProcessor#onNext} when the wrapped processor has already completed (for
+ * example, it was evicted between the cache lookup and the {@code onNext} call). {@link
+ * #processEvent} catches it and retries with a freshly loaded processor.
+ */
+ private static final class CompletedException extends IllegalStateException {}
+
+ /**
+ * Wraps a {@link UnicastProcessor} together with its own lock, so that mutual exclusion between
+ * {@code onNext} (from {@link #processEvent}) and {@code onComplete} (from eviction or shutdown)
+ * does not depend on smallrye-mutiny's internal {@code synchronized} on {@code
+ * UnicastProcessor.onNext}.
+ */
+ protected final class EventProcessor {
+
+ @VisibleForTesting final UnicastProcessor<PolarisEvent> processor;
+ private final ReentrantLock lock = new ReentrantLock();
+ private boolean completed = false; // guarded by lock
+
+ EventProcessor(String realmId) {
+ processor = UnicastProcessor.create();
+ processor
+ .emitOn(Infrastructure.getDefaultWorkerPool())
+ .group()
+ .intoLists()
+ .of(configuration.maxBufferSize(), configuration.bufferTime())
+ .subscribe()
+ .with(events -> flush(realmId, events), error -> onProcessorError(realmId, error));
+ }
+
+ void onNext(PolarisEvent event) {
+ lock.lock();
+ try {
+ if (completed) {
+ throw new CompletedException();
+ }
+ processor.onNext(event);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ void onComplete() {
+ lock.lock();
+ try {
+ if (!completed) {
+ completed = true;
+ processor.onComplete();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+ }
+
@VisibleForTesting
- final LoadingCache<String, UnicastProcessor<PolarisEvent>> processors =
+ final LoadingCache<String, EventProcessor> processors =
Caffeine.newBuilder()
.expireAfterAccess(Duration.ofHours(1))
.evictionListener(
- (String realmId, UnicastProcessor<?> processor, RemovalCause cause) ->
- completeSynchronized(processor))
+ (String realmId, EventProcessor processor, RemovalCause cause) -> {
+ if (processor != null) {
+ processor.onComplete();
+ }
+ })
.build(this::createProcessor);
+ // Construct via a method (not EventProcessor::new) so that when the cache lives on a CDI client
+ // proxy, the call is delegated to the contextual bean instance, whose injected configuration is
+ // non-null. A direct inner-class instantiation would capture the proxy as the enclosing instance
+ // and read its uninjected (null) configuration.
+ protected EventProcessor createProcessor(String realmId) {
+ return new EventProcessor(realmId);
+ }
+
+ private final ReentrantReadWriteLock shutdownLock = new ReentrantReadWriteLock();
+ private boolean shutdown = false; // guarded by shutdownLock
+
@Override
protected void processEvent(String realmId, PolarisEvent event) {
- var processor = Objects.requireNonNull(processors.get(realmId));
- // UnicastProcessor.onNext() is internally synchronized (smallrye-mutiny
- // UnicastProcessor declares onNext as `public synchronized void`), so concurrent
- // processEvent() calls for the same realm serialize on the processor's intrinsic
- // lock without an external guard.
- processor.onNext(event);
+ shutdownLock.readLock().lock();
+ try {
+ if (shutdown) {
+ return;
+ }
+ while (true) {
+ var processor = Objects.requireNonNull(processors.get(realmId));
+ try {
+ processor.onNext(event);
+ return;
+ } catch (CompletedException ignored) {
+ // processor was evicted between the cache lookup and onNext; retry with a fresh one
+ }
+ }
+ } finally {
+ shutdownLock.readLock().unlock();
+ }
}
@PreDestroy
public void shutdown() {
- processors.asMap().values().forEach(InMemoryBufferEventListener::completeSynchronized);
- processors.invalidateAll(); // doesn't call the eviction listener
- }
-
- /**
- * Calls {@link UnicastProcessor#onComplete()} while holding the processor's intrinsic monitor.
- *
- * <p>smallrye-mutiny's {@code UnicastProcessor.onNext} is method-{@code synchronized} on the
- * processor instance; {@code onComplete} is not. Acquiring the same intrinsic monitor here
- * restores symmetric mutual exclusion between concurrent {@code onNext} (from {@code
- * processEvent}) and {@code onComplete} (from eviction or shutdown). Wrapping the pattern as an
- * invariant keeps the synchronization requirement structurally visible to future maintainers.
- */
- private static void completeSynchronized(UnicastProcessor<?> processor) {
- synchronized (processor) {
- processor.onComplete();
+ shutdownLock.writeLock().lock();
+ try {
+ shutdown = true;
+ processors.asMap().values().forEach(EventProcessor::onComplete);
+ processors.invalidateAll(); // doesn't call the eviction listener
+ } finally {
+ shutdownLock.writeLock().unlock();
}
}
- protected UnicastProcessor<PolarisEvent> createProcessor(String realmId) {
- UnicastProcessor<PolarisEvent> processor = UnicastProcessor.create();
- processor
- .emitOn(Infrastructure.getDefaultWorkerPool())
- .group()
- .intoLists()
- .of(configuration.maxBufferSize(), configuration.bufferTime())
- .subscribe()
- .with(events -> flush(realmId, events), error -> onProcessorError(realmId, error));
- return processor;
- }
-
@Retry(maxRetries = 5, delay = 1000, jitter = 100)
@Fallback(fallbackMethod = "onFlushError")
protected void flush(String realmId, List<PolarisEvent> events) {
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListenerBufferSizeTest.java b/runtime/service/src/test/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListenerBufferSizeTest.java
index 7ee12ebc02..aa80ad138a 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListenerBufferSizeTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListenerBufferSizeTest.java
@@ -28,10 +28,8 @@ import com.google.common.collect.ImmutableMap;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
-import io.smallrye.mutiny.operators.multi.processors.UnicastProcessor;
import io.smallrye.mutiny.subscription.BackPressureFailure;
import java.util.Map;
-import org.apache.polaris.core.entity.PolarisEvent;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@@ -89,10 +87,10 @@ class InMemoryBufferEventListenerBufferSizeTest extends InMemoryBufferEventListe
@Test
void testProcessorFailureRecovery() {
producer.processEvent("test1", event());
- UnicastProcessor<PolarisEvent> test1 = producer.processors.get("test1");
+ var test1 = producer.processors.get("test1");
assertThat(test1).isNotNull();
// emulate backpressure error; will drop the event and invalidate the processor
- test1.onError(new BackPressureFailure("error"));
+ test1.processor.onError(new BackPressureFailure("error"));
// will create a new processor and recover
sendAsync("test1", 10);
assertRows("test1", 10);
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListenerTestBase.java b/runtime/service/src/test/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListenerTestBase.java
index d86fb44e8a..a1d9248519 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListenerTestBase.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/events/listeners/inmemory/InMemoryBufferEventListenerTestBase.java
@@ -41,6 +41,7 @@ import javax.sql.DataSource;
import org.apache.polaris.core.entity.PolarisEvent;
import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
abstract class InMemoryBufferEventListenerTestBase {
@@ -64,6 +65,8 @@ abstract class InMemoryBufferEventListenerTestBase {
@Inject
@Identifier("persistence-in-memory-buffer")
+ Instance<InMemoryBufferEventListener> producerInstance;
+
InMemoryBufferEventListener producer;
@InjectSpy
@@ -78,10 +81,15 @@ abstract class InMemoryBufferEventListenerTestBase {
@Inject Instance<DataSource> dataSource;
+ @BeforeEach
+ void resolveProducer() {
+ producer = producerInstance.get();
+ }
+
@AfterEach
void clearEvents() throws Exception {
reset(metaStoreManagerFactory);
- producer.shutdown();
+ producerInstance.destroy(producer);
try (Connection connection = dataSource.get().getConnection();
Statement statement = connection.createStatement()) {
statement.execute("DELETE FROM polaris_schema.events");