This is an automated email from the ASF dual-hosted git repository.

wuchong pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git


The following commit(s) were added to refs/heads/main by this push:
     new 8b7cd096b [server] Add negative cache for non-existent partition IDs 
in metadata requests (#3511)
8b7cd096b is described below

commit 8b7cd096b98400cddfd290d3f098d9c0c5f8b369
Author: yunhong <[email protected]>
AuthorDate: Wed Jul 8 15:56:16 2026 +0800

    [server] Add negative cache for non-existent partition IDs in metadata 
requests (#3511)
---
 .../org/apache/fluss/server/RpcServiceBase.java    |  37 ++++
 .../server/metadata/PartitionNegativeCache.java    | 128 ++++++++++++
 .../metadata/PartitionNegativeCacheITCase.java     | 144 ++++++++++++++
 .../metadata/PartitionNegativeCacheTest.java       | 220 +++++++++++++++++++++
 4 files changed, 529 insertions(+)

diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java 
b/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java
index f7b6de9eb..a82541510 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java
@@ -17,6 +17,7 @@
 
 package org.apache.fluss.server;
 
+import org.apache.fluss.annotation.VisibleForTesting;
 import org.apache.fluss.cluster.ServerNode;
 import org.apache.fluss.cluster.ServerType;
 import org.apache.fluss.config.cluster.ConfigEntry;
@@ -86,6 +87,7 @@ import org.apache.fluss.server.coordinator.MetadataManager;
 import org.apache.fluss.server.kv.snapshot.CompletedSnapshot;
 import org.apache.fluss.server.metadata.MetadataProvider;
 import org.apache.fluss.server.metadata.PartitionMetadata;
+import org.apache.fluss.server.metadata.PartitionNegativeCache;
 import org.apache.fluss.server.metadata.ServerMetadataCache;
 import org.apache.fluss.server.metadata.TableMetadata;
 import org.apache.fluss.server.tablet.TabletService;
@@ -143,6 +145,7 @@ public abstract class RpcServiceBase extends 
RpcGatewayService implements AdminR
     protected final MetadataManager metadataManager;
     protected final @Nullable Authorizer authorizer;
     protected final DynamicConfigManager dynamicConfigManager;
+    protected final PartitionNegativeCache partitionNegativeCache;
 
     private long tokenLastUpdateTimeMs = 0;
     private ObtainedSecurityToken securityToken = null;
@@ -164,9 +167,15 @@ public abstract class RpcServiceBase extends 
RpcGatewayService implements AdminR
         this.metadataManager = metadataManager;
         this.authorizer = authorizer;
         this.dynamicConfigManager = dynamicConfigManager;
+        this.partitionNegativeCache = new PartitionNegativeCache();
         this.ioExecutor = ioExecutor;
     }
 
+    @VisibleForTesting
+    public PartitionNegativeCache getPartitionNegativeCache() {
+        return partitionNegativeCache;
+    }
+
     @Override
     public ServerType providerType() {
         return provider;
@@ -617,7 +626,16 @@ public abstract class RpcServiceBase extends 
RpcGatewayService implements AdminR
             Optional<PhysicalTablePath> physicalTablePath =
                     
metadataProvider.getPhysicalTablePathFromCache(partitionId);
             if (physicalTablePath.isPresent()) {
+                partitionNegativeCache.markExistent(partitionId);
                 partitionPaths.add(physicalTablePath.get());
+            } else if (partitionNegativeCache.isKnownNonExistent(partitionId)) 
{
+                // Fast-path only after the positive metadata cache misses. A 
stale negative-cache
+                // entry must not hide a partition that has already been 
synced into metadata cache.
+                throw new PartitionNotExistException(
+                        String.format(
+                                "The partition id '%d' does not exist or you 
don't have"
+                                        + " permission to access it.",
+                                partitionId));
             } else {
                 partitionIdsNotExistsInCache.add(partitionId);
             }
@@ -632,8 +650,15 @@ public abstract class RpcServiceBase extends 
RpcGatewayService implements AdminR
             }
             for (long partitionId : partitionIdsNotExistsInCache) {
                 if (partitionIdAndPaths.containsKey(partitionId)) {
+                    partitionNegativeCache.markExistent(partitionId);
                     partitionPaths.add(partitionIdAndPaths.get(partitionId));
                 } else {
+                    // Only cache when the authoritative partition assignment 
is also gone. A miss
+                    // from the scoped table-path lookup may simply mean that 
the request omitted
+                    // the owning table or the session is not authorized for 
it.
+                    if (isPartitionAssignmentMissingFromZk(partitionId)) {
+                        partitionNegativeCache.markNonExistent(partitionId);
+                    }
                     throw new PartitionNotExistException(
                             String.format(
                                     "The partition id '%d' does not exist or 
you don't have permission to access it.",
@@ -674,4 +699,16 @@ public abstract class RpcServiceBase extends 
RpcGatewayService implements AdminR
         return buildMetadataResponse(
                 coordinatorServer, aliveTabletServers, tablesMetadata, 
partitionsMetadata);
     }
+
+    private boolean isPartitionAssignmentMissingFromZk(long partitionId) {
+        try {
+            return !zkClient.getPartitionAssignment(partitionId).isPresent();
+        } catch (Exception e) {
+            LOG.warn(
+                    "Failed to check partition assignment for partition {}. 
Skip negative cache update.",
+                    partitionId,
+                    e);
+            return false;
+        }
+    }
 }
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionNegativeCache.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionNegativeCache.java
new file mode 100644
index 000000000..44e22ed0a
--- /dev/null
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionNegativeCache.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.server.metadata;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.utils.clock.Clock;
+import org.apache.fluss.utils.clock.SystemClock;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Ticker;
+
+import javax.annotation.concurrent.ThreadSafe;
+
+import java.time.Duration;
+
+/**
+ * A thread-safe negative cache for partition IDs that are known to not exist 
in ZooKeeper.
+ *
+ * <p>This cache helps reduce ZooKeeper pressure when clients repeatedly 
request metadata for
+ * partitions that have been deleted (e.g., during hourly partition rotation). 
Instead of querying
+ * ZK every time, we cache the "not exist" result and return it directly.
+ *
+ * <p>The cache uses access-time-based TTL: entries are evicted after a 
configurable duration of no
+ * access. As long as clients keep asking for the same non-existent partition, 
the cache entry stays
+ * alive and protects ZK.
+ */
+@ThreadSafe
+public class PartitionNegativeCache {
+
+    /** Default TTL for negative cache entries: 10 minutes of no access. */
+    private static final Duration DEFAULT_TTL = Duration.ofMinutes(10);
+
+    /** Default maximum number of negative cache entries. */
+    private static final long DEFAULT_MAXIMUM_SIZE = 10_000L;
+
+    private final Cache<Long, Boolean> cache;
+
+    public PartitionNegativeCache() {
+        this(DEFAULT_TTL, DEFAULT_MAXIMUM_SIZE);
+    }
+
+    public PartitionNegativeCache(Duration ttl, long maximumSize) {
+        this(ttl, maximumSize, SystemClock.getInstance());
+    }
+
+    @VisibleForTesting
+    PartitionNegativeCache(Duration ttl, long maximumSize, Clock clock) {
+        if (ttl.isZero() || ttl.isNegative()) {
+            throw new IllegalArgumentException("TTL must be positive.");
+        }
+        if (maximumSize <= 0) {
+            throw new IllegalArgumentException("Maximum size must be 
positive.");
+        }
+        this.cache =
+                Caffeine.newBuilder()
+                        .maximumSize(maximumSize)
+                        .expireAfterAccess(ttl)
+                        .ticker(
+                                new Ticker() {
+                                    @Override
+                                    public long read() {
+                                        return clock.nanoseconds();
+                                    }
+                                })
+                        .build();
+    }
+
+    /**
+     * Checks if the given partition ID is known to not exist.
+     *
+     * <p>If the entry exists and is not expired, Caffeine refreshes its 
access time and this method
+     * returns {@code true}. If the entry is expired or doesn't exist, returns 
{@code false}.
+     *
+     * @param partitionId the partition ID to check
+     * @return {@code true} if the partition is known to not exist, {@code 
false} otherwise
+     */
+    public boolean isKnownNonExistent(long partitionId) {
+        return cache.getIfPresent(partitionId) != null;
+    }
+
+    /**
+     * Marks the given partition ID as known to not exist. The entry will 
remain in the cache until
+     * it hasn't been accessed for the configured TTL duration or the bounded 
cache evicts it.
+     *
+     * @param partitionId the partition ID to mark as non-existent
+     */
+    public void markNonExistent(long partitionId) {
+        cache.put(partitionId, Boolean.TRUE);
+    }
+
+    /**
+     * Marks the given partition ID as existing by removing any stale 
negative-cache entry.
+     *
+     * @param partitionId the partition ID to mark as existent
+     */
+    public void markExistent(long partitionId) {
+        cache.invalidate(partitionId);
+    }
+
+    /** Returns the current number of entries in the cache after triggering 
cleanup. */
+    @VisibleForTesting
+    public long size() {
+        cache.cleanUp();
+        return cache.estimatedSize();
+    }
+
+    /** Removes all entries from the cache. */
+    @VisibleForTesting
+    public void clear() {
+        cache.invalidateAll();
+    }
+}
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/metadata/PartitionNegativeCacheITCase.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/metadata/PartitionNegativeCacheITCase.java
new file mode 100644
index 000000000..9aa2d379b
--- /dev/null
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/metadata/PartitionNegativeCacheITCase.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.server.metadata;
+
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.exception.PartitionNotExistException;
+import org.apache.fluss.metadata.PartitionSpec;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.rpc.gateway.CoordinatorGateway;
+import org.apache.fluss.rpc.messages.MetadataRequest;
+import org.apache.fluss.server.testutils.FlussClusterExtension;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.time.Duration;
+import java.util.Collections;
+
+import static org.apache.fluss.record.TestData.DATA1_SCHEMA;
+import static 
org.apache.fluss.server.testutils.RpcMessageTestUtils.createPartition;
+import static 
org.apache.fluss.server.testutils.RpcMessageTestUtils.createTable;
+import static 
org.apache.fluss.server.testutils.RpcMessageTestUtils.newDropPartitionRequest;
+import static org.apache.fluss.testutils.common.CommonTestUtils.retry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** ITCase for {@link PartitionNegativeCache} integration with metadata 
request processing. */
+class PartitionNegativeCacheITCase {
+
+    @RegisterExtension
+    public static final FlussClusterExtension FLUSS_CLUSTER_EXTENSION =
+            FlussClusterExtension.builder().setNumOfTabletServers(1).build();
+
+    private static CoordinatorGateway coordinatorGateway;
+
+    @BeforeAll
+    static void setup() {
+        coordinatorGateway = FLUSS_CLUSTER_EXTENSION.newCoordinatorClient();
+    }
+
+    @Test
+    void testNegativeCacheForDeletedPartition() throws Exception {
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+
+        // Create a partitioned table with auto-partition disabled.
+        TablePath tablePath = TablePath.of("test_db_neg_cache", 
"partitioned_table");
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder()
+                        .schema(DATA1_SCHEMA)
+                        .distributedBy(1)
+                        .partitionedBy("b")
+                        .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, 
false)
+                        .build();
+        createTable(FLUSS_CLUSTER_EXTENSION, tablePath, tableDescriptor);
+
+        // Create a partition and get its ID.
+        String partitionName = "p_to_delete";
+        long partitionId =
+                createPartition(
+                        FLUSS_CLUSTER_EXTENSION,
+                        tablePath,
+                        new PartitionSpec(Collections.singletonMap("b", 
partitionName)),
+                        false);
+
+        PartitionNegativeCache negativeCache =
+                FLUSS_CLUSTER_EXTENSION
+                        .getCoordinatorServer()
+                        .getCoordinatorService()
+                        .getPartitionNegativeCache();
+
+        // A stale negative-cache entry must not hide a partition that exists 
in metadata cache.
+        MetadataRequest existingPartitionRequest = new MetadataRequest();
+        existingPartitionRequest.addPartitionsId(partitionId);
+        existingPartitionRequest
+                .addTablePath()
+                .setDatabaseName(tablePath.getDatabaseName())
+                .setTableName(tablePath.getTableName());
+        negativeCache.markNonExistent(partitionId);
+        retry(
+                Duration.ofMinutes(1),
+                () -> 
coordinatorGateway.metadata(existingPartitionRequest).get());
+        assertThat(negativeCache.isKnownNonExistent(partitionId)).isFalse();
+
+        // Drop the partition.
+        coordinatorGateway
+                .dropPartition(
+                        newDropPartitionRequest(
+                                tablePath,
+                                new 
PartitionSpec(Collections.singletonMap("b", partitionName)),
+                                false))
+                .get();
+
+        // Wait until the partition is actually deleted from ZK (metadata 
cache update).
+        retry(
+                Duration.ofMinutes(1),
+                () -> {
+                    // Request metadata with the deleted partition ID - should 
throw.
+                    MetadataRequest request = new MetadataRequest();
+                    request.addPartitionsId(partitionId);
+                    request.addTablePath()
+                            .setDatabaseName(tablePath.getDatabaseName())
+                            .setTableName(tablePath.getTableName());
+                    assertThatThrownBy(() -> 
coordinatorGateway.metadata(request).get())
+                            .cause()
+                            .isInstanceOf(PartitionNotExistException.class);
+                    
assertThat(negativeCache.isKnownNonExistent(partitionId)).isTrue();
+                });
+
+        // At this point, the partition ID should be in the negative cache.
+        // Verify the negative cache is populated on the coordinator.
+        assertThat(negativeCache.isKnownNonExistent(partitionId)).isTrue();
+
+        // Second request should also fail (served from negative cache, no ZK 
query).
+        MetadataRequest secondRequest = new MetadataRequest();
+        secondRequest.addPartitionsId(partitionId);
+        secondRequest
+                .addTablePath()
+                .setDatabaseName(tablePath.getDatabaseName())
+                .setTableName(tablePath.getTableName());
+        assertThatThrownBy(() -> 
coordinatorGateway.metadata(secondRequest).get())
+                .cause()
+                .isInstanceOf(PartitionNotExistException.class);
+
+        // Negative cache should still have the entry.
+        assertThat(negativeCache.isKnownNonExistent(partitionId)).isTrue();
+    }
+}
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/metadata/PartitionNegativeCacheTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/metadata/PartitionNegativeCacheTest.java
new file mode 100644
index 000000000..dacee8cac
--- /dev/null
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/metadata/PartitionNegativeCacheTest.java
@@ -0,0 +1,220 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.server.metadata;
+
+import org.apache.fluss.utils.clock.ManualClock;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link PartitionNegativeCache}. */
+class PartitionNegativeCacheTest {
+
+    private static final Duration TTL = Duration.ofMinutes(10);
+    private static final long TTL_MS = TTL.toMillis();
+    private static final long MAXIMUM_SIZE = 100L;
+
+    @Test
+    void testMarkAndQueryNonExistent() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        // Initially, partition is not known as non-existent
+        assertThat(cache.isKnownNonExistent(100L)).isFalse();
+
+        // Mark it as non-existent
+        cache.markNonExistent(100L);
+
+        // Now it should be known as non-existent
+        assertThat(cache.isKnownNonExistent(100L)).isTrue();
+        assertThat(cache.size()).isEqualTo(1);
+    }
+
+    @Test
+    void testUnknownPartitionReturnsFalse() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        // Never marked partition should return false
+        assertThat(cache.isKnownNonExistent(999L)).isFalse();
+        assertThat(cache.isKnownNonExistent(0L)).isFalse();
+        assertThat(cache.isKnownNonExistent(-1L)).isFalse();
+    }
+
+    @Test
+    void testMultiplePartitions() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        cache.markNonExistent(1L);
+        cache.markNonExistent(2L);
+        cache.markNonExistent(3L);
+
+        assertThat(cache.isKnownNonExistent(1L)).isTrue();
+        assertThat(cache.isKnownNonExistent(2L)).isTrue();
+        assertThat(cache.isKnownNonExistent(3L)).isTrue();
+        assertThat(cache.isKnownNonExistent(4L)).isFalse();
+        assertThat(cache.size()).isEqualTo(3);
+    }
+
+    @Test
+    void testTtlExpiration() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        cache.markNonExistent(100L);
+        assertThat(cache.isKnownNonExistent(100L)).isTrue();
+
+        // Advance time past TTL from last access time (which is 0)
+        clock.advanceTime(TTL_MS + 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isFalse();
+
+        // Entry should be removed from cache
+        assertThat(cache.size()).isEqualTo(0);
+    }
+
+    @Test
+    void testExpiresAtTtlBoundary() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        cache.markNonExistent(100L);
+
+        clock.advanceTime(TTL_MS - 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isTrue();
+
+        // Guava expireAfterAccess expires the entry once the TTL boundary is 
reached.
+        cache.markNonExistent(100L);
+        clock.advanceTime(TTL_MS, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isFalse();
+    }
+
+    @Test
+    void testAccessTimeRefreshKeepsEntryAlive() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        cache.markNonExistent(100L);
+
+        // Access at t = TTL - 1 (within TTL window), refreshes access time to 
TTL - 1
+        clock.advanceTime(TTL_MS - 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isTrue();
+
+        // Now TTL is measured from last access (TTL_MS - 1).
+        // At t = 2*TTL - 2, elapsed = TTL - 1, NOT > TTL, still valid.
+        clock.advanceTime(TTL_MS - 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isTrue(); // refreshes to 
2*TTL - 2
+
+        // Access again at t = 3*TTL - 3 to prove indefinite survival
+        clock.advanceTime(TTL_MS - 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isTrue(); // refreshes to 
3*TTL - 3
+
+        // Now stop accessing. Advance past TTL from last access (3*TTL - 3).
+        clock.advanceTime(TTL_MS + 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isFalse();
+    }
+
+    @Test
+    void testClear() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        cache.markNonExistent(1L);
+        cache.markNonExistent(2L);
+        cache.markNonExistent(3L);
+        assertThat(cache.size()).isEqualTo(3);
+
+        cache.clear();
+        assertThat(cache.size()).isEqualTo(0);
+        assertThat(cache.isKnownNonExistent(1L)).isFalse();
+        assertThat(cache.isKnownNonExistent(2L)).isFalse();
+        assertThat(cache.isKnownNonExistent(3L)).isFalse();
+    }
+
+    @Test
+    void testMarkExistentRemovesStaleNegativeEntry() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        cache.markNonExistent(100L);
+        assertThat(cache.isKnownNonExistent(100L)).isTrue();
+
+        cache.markExistent(100L);
+        assertThat(cache.isKnownNonExistent(100L)).isFalse();
+    }
+
+    @Test
+    void testRemarkAfterExpiration() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        cache.markNonExistent(100L);
+        assertThat(cache.isKnownNonExistent(100L)).isTrue();
+
+        // Expire the entry
+        clock.advanceTime(TTL_MS + 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isFalse();
+
+        // Re-mark at new time should work
+        cache.markNonExistent(100L);
+        assertThat(cache.isKnownNonExistent(100L)).isTrue();
+
+        // The new entry expires TTL after re-mark time (TTL_MS + 1)
+        clock.advanceTime(TTL_MS + 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(100L)).isFalse();
+    }
+
+    @Test
+    void testIndependentExpiration() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 
MAXIMUM_SIZE, clock);
+
+        // Mark partition 1 at t=0
+        cache.markNonExistent(1L);
+
+        // Mark partition 2 at t=5min
+        clock.advanceTime(TTL_MS / 2, TimeUnit.MILLISECONDS);
+        cache.markNonExistent(2L);
+
+        // At t=TTL+1: partition 1 should expire, partition 2 should still be 
alive
+        clock.advanceTime(TTL_MS / 2 + 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(1L)).isFalse();
+        assertThat(cache.isKnownNonExistent(2L)).isTrue(); // refreshes access 
time to TTL_MS+1
+
+        // Partition 2's access time was refreshed to TTL_MS+1 above.
+        // To expire it, advance past TTL from that refreshed time.
+        clock.advanceTime(TTL_MS + 1, TimeUnit.MILLISECONDS);
+        assertThat(cache.isKnownNonExistent(2L)).isFalse();
+    }
+
+    @Test
+    void testMaximumSizeBoundsCache() {
+        ManualClock clock = new ManualClock();
+        PartitionNegativeCache cache = new PartitionNegativeCache(TTL, 2L, 
clock);
+
+        cache.markNonExistent(1L);
+        cache.markNonExistent(2L);
+        cache.markNonExistent(3L);
+
+        assertThat(cache.size()).isLessThanOrEqualTo(2);
+    }
+}

Reply via email to