org.junit.jupiter
junit-jupiter
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheFactory.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheFactory.java
deleted file mode 100644
index 03e4126e9911be..00000000000000
--- a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheFactory.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// 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.doris.connector.cache;
-
-import com.github.benmanes.caffeine.cache.AsyncCacheLoader;
-import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
-import com.github.benmanes.caffeine.cache.CacheLoader;
-import com.github.benmanes.caffeine.cache.Caffeine;
-import com.github.benmanes.caffeine.cache.LoadingCache;
-import com.github.benmanes.caffeine.cache.RemovalListener;
-import com.github.benmanes.caffeine.cache.Ticker;
-
-import java.time.Duration;
-import java.util.OptionalLong;
-import java.util.concurrent.ExecutorService;
-
-/**
- * Factory to create Caffeine cache.
- *
- * Connector-side copy of fe-core {@code org.apache.doris.common.CacheFactory} (independent-copy meta-cache
- * migration): connector plugins cannot import fe-core, so the framework is duplicated under
- * {@code org.apache.doris.connector.cache}. This type is framework-internal — its public methods RETURN
- * Caffeine types, which must never cross to connector (child-first) code; connectors only touch the
- * Caffeine-free {@link MetaCacheEntry} API. Keep behaviourally in sync with the fe-core original.
- *
- *
This class is used to create Caffeine cache with specified parameters.
- * It is used to create both sync and async cache.
- * The cache is created with the following parameters:
- * - expireAfterAccessSec: The duration after which the cache entries will expire.
- * - refreshAfterWriteSec: The duration after which the cache entries will be refreshed.
- * - maxSize: The maximum size of the cache.
- * - enableStats: Whether to enable stats for the cache.
- * - ticker: The ticker to use for the cache.
- */
-public class CacheFactory {
-
- private OptionalLong expireAfterAccessSec;
- private OptionalLong refreshAfterWriteSec;
- private long maxSize;
- private boolean enableStats;
- // Ticker is used to provide a time source for the cache.
- // Only used for test, to provide a fake time source.
- // If not provided, the system time is used.
- private Ticker ticker;
-
- public CacheFactory(
- OptionalLong expireAfterAccessSec,
- OptionalLong refreshAfterWriteSec,
- long maxSize,
- boolean enableStats,
- Ticker ticker) {
- this.expireAfterAccessSec = expireAfterAccessSec;
- this.refreshAfterWriteSec = refreshAfterWriteSec;
- this.maxSize = maxSize;
- this.enableStats = enableStats;
- this.ticker = ticker;
- }
-
- // Build a loading cache, without executor, it will use fork-join pool for refresh
- public LoadingCache buildCache(CacheLoader cacheLoader) {
- Caffeine builder = buildWithParams();
- return builder.build(cacheLoader);
- }
-
- // Build a loading cache, with executor, it will use given executor for refresh
- public LoadingCache buildCache(CacheLoader cacheLoader,
- ExecutorService executor) {
- Caffeine builder = buildWithParams();
- builder.executor(executor);
- return builder.build(cacheLoader);
- }
-
- // Build cache with sync removal listener to prevent deadlock when listener calls invalidateAll()
- public LoadingCache buildCacheWithSyncRemovalListener(CacheLoader cacheLoader,
- RemovalListener removalListener) {
- Caffeine builder = buildWithParams();
- if (removalListener != null) {
- builder.removalListener(removalListener);
- }
- builder.executor(Runnable::run); // Sync execution to avoid thread pool deadlock
- return builder.build(cacheLoader);
- }
-
- // Build an async loading cache
- public AsyncLoadingCache buildAsyncCache(AsyncCacheLoader cacheLoader,
- ExecutorService executor) {
- Caffeine builder = buildWithParams();
- builder.executor(executor);
- return builder.buildAsync(cacheLoader);
- }
-
- private Caffeine buildWithParams() {
- Caffeine builder = Caffeine.newBuilder();
- builder.maximumSize(maxSize);
-
- if (expireAfterAccessSec.isPresent()) {
- builder.expireAfterAccess(Duration.ofSeconds(expireAfterAccessSec.getAsLong()));
- }
- if (refreshAfterWriteSec.isPresent()) {
- builder.refreshAfterWrite(Duration.ofSeconds(refreshAfterWriteSec.getAsLong()));
- }
-
- if (enableStats) {
- builder.recordStats();
- }
-
- if (ticker != null) {
- builder.ticker(ticker);
- }
- return builder;
- }
-}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java
index 0524b31d402f48..01ee88a1fc905b 100644
--- a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java
@@ -25,13 +25,8 @@
/**
* Common cache specification for external metadata caches.
*
- * Connector-side copy of the meta-cache property model (independent-copy meta-cache migration). fe-core is
- * NOT changed: it keeps its own {@code org.apache.doris.datasource.metacache.CacheSpec}; this is a separate
- * class under {@code org.apache.doris.connector.*} used only by the connector plugins. Although that prefix is
- * parent-first, fe-core does not depend on this module, so the class resolves parent → miss → CHILD and is
- * child-loaded per plugin — fe-core and the plugins do NOT share one {@code Class} identity. It carries no
- * third-party dependency (JDK only) and never crosses the fe-core↔connector boundary as an object (only its
- * {@code IllegalArgumentException}, a JDK type, crosses), so it is safe on both classpaths.
+ *
This is the single property model shared by fe-core and connector plugins. It carries no fe-core dependency,
+ * so cache configuration and validation remain available to independently loaded connectors.
*
*
The {@code check*Property} validators throw {@link IllegalArgumentException} (fe-core's
* {@code PluginDrivenExternalCatalog.checkProperties} re-wraps it into a {@code DdlException} verbatim; the
@@ -204,7 +199,7 @@ public static boolean isMetaCacheKeyForEngine(String key, String engine) {
}
/**
- * Convert ttlSecond to OptionalLong for CacheFactory.
+ * Convert ttlSecond to the optional expiry used by the cache runtime.
* ttlSecond=-1 means no expiration; ttlSecond=0 disables cache.
*/
public static OptionalLong toExpireAfterAccess(long ttlSecond) {
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CatalogMetaCache.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CatalogMetaCache.java
new file mode 100644
index 00000000000000..199f516582ebf5
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CatalogMetaCache.java
@@ -0,0 +1,116 @@
+// 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.doris.connector.cache;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Catalog-local owner of all framework metadata caches.
+ *
+ *
Connectors invalidate a semantic scope once. Every physical cache created through this owner participates
+ * automatically because its values are registered in the shared {@link ScopedMetaCacheRegistry}.
+ */
+public final class CatalogMetaCache implements AutoCloseable {
+ private final ScopedMetaCacheRegistry registry;
+ private final Set names = ConcurrentHashMap.newKeySet();
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ public CatalogMetaCache() {
+ this(new ScopedMetaCacheRegistry());
+ }
+
+ CatalogMetaCache(ScopedMetaCacheRegistry registry) {
+ this.registry = Objects.requireNonNull(registry, "registry can not be null");
+ }
+
+ public MetaCache create(MetaCacheDefinition definition) {
+ MetaCacheDefinition nonNullDefinition =
+ Objects.requireNonNull(definition, "definition can not be null");
+ checkOpen();
+ if (!names.add(nonNullDefinition.name())) {
+ throw new IllegalArgumentException("Duplicate meta cache name: " + nonNullDefinition.name());
+ }
+ try {
+ return new MetaCache<>(nonNullDefinition,
+ registry.createCacheWithMetaRemovalListener(nonNullDefinition.name(),
+ nonNullDefinition.cacheSpec(), nonNullDefinition.removalListener(),
+ nonNullDefinition.discardListener(),
+ nonNullDefinition.refreshAfterWrite(), nonNullDefinition.refreshExecutor()));
+ } catch (RuntimeException | Error throwable) {
+ names.remove(nonNullDefinition.name());
+ throw throwable;
+ }
+ }
+
+ public void invalidateCatalog() {
+ registry.invalidate(ScopePath.catalog());
+ }
+
+ public void invalidateDatabase(String database) {
+ registry.invalidate(ScopePath.database(database));
+ }
+
+ public void invalidateTable(String database, String table) {
+ registry.invalidate(ScopePath.table(database, table));
+ }
+
+ public void invalidatePartition(String database, String table, Object partition) {
+ registry.invalidate(ScopePath.partition(database, table, partition));
+ }
+
+ public void invalidatePartitionCollection(String database, String table) {
+ registry.invalidate(ScopePath.partitionCollection(database, table));
+ }
+
+ /**
+ * Atomically invalidates the partition collection and the specified partition identities for one table.
+ * Logical invalidation has one publication linearization point; physical cleanup runs after publication is
+ * released so unrelated cache operations do not wait for Caffeine removal callbacks.
+ */
+ public void invalidatePartitions(String database, String table, Collection> partitions) {
+ Objects.requireNonNull(partitions, "partitions can not be null");
+ List paths = new ArrayList<>(partitions.size() + 1);
+ paths.add(ScopePath.partitionCollection(database, table));
+ partitions.forEach(partition -> paths.add(ScopePath.partition(database, table, partition)));
+ registry.invalidate(paths);
+ }
+
+ public ScopedMetaCacheRegistry.ScopeMetrics metrics() {
+ return registry.metrics();
+ }
+
+ @Override
+ public void close() {
+ if (closed.compareAndSet(false, true)) {
+ registry.close();
+ names.clear();
+ }
+ }
+
+ private void checkOpen() {
+ if (closed.get()) {
+ throw new IllegalStateException("Catalog meta cache is closed");
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorMetadataCache.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorMetadataCache.java
index fde68573e73c50..da47360f862225 100644
--- a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorMetadataCache.java
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorMetadataCache.java
@@ -20,14 +20,14 @@
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
-import java.util.concurrent.ForkJoinPool;
+import java.util.function.Function;
import java.util.function.Supplier;
/**
* GENERIC (engine-agnostic) cross-query cache of a connector's derived metadata, keyed by a table identity plus
* an optional MVCC coordinate ({@link ConnectorTableKey}) and holding an opaque value {@code V}. It is the generic
* form of the hand-rolled iceberg caches (e.g. {@code IcebergPartitionCache}): same construction pattern (a
- * contextual-only, manual-miss-load {@link MetaCacheEntry}), same {@link CacheSpec} wiring, same invalidation style
+ * contextual-only, manual-miss-load {@link MetaCache}), same {@link CacheSpec} wiring, same invalidation style
* — but keyed by the engine-agnostic {@link ConnectorTableKey} and holding an opaque {@code V} instead of an
* engine-specific value, so it has no engine-specific imports and is shared by every connector. Consumers today:
* the hive/iceberg/paimon derived partition-view caches (entry {@code "partition_view"}); a connector may hold
@@ -50,7 +50,8 @@ public final class ConnectorMetadataCache {
/** Default capacity, matching {@code IcebergPartitionCache}'s {@code DEFAULT_TABLE_CACHE_CAPACITY}. */
static final long DEFAULT_CAPACITY = 1000L;
- private final MetaCacheEntry entry;
+ private final CatalogMetaCache owner;
+ private final MetaCache entry;
/**
* @param engine engine token for the {@code meta.cache...*} property namespace, e.g.
@@ -61,20 +62,31 @@ public final class ConnectorMetadataCache {
* {@code null}, treated as empty (defaults apply).
*/
public ConnectorMetadataCache(String engine, String entryName, Map props) {
+ this(new CatalogMetaCache(), engine + "." + entryName, engine, entryName, props);
+ }
+
+ public ConnectorMetadataCache(
+ CatalogMetaCache owner, String cacheName, String engine, String entryName, Map props) {
+ this(owner, cacheName, engine, entryName, props,
+ key -> ScopePath.table(key.getDb(), key.getTable()));
+ }
+
+ public ConnectorMetadataCache(CatalogMetaCache owner, String cacheName, String engine, String entryName,
+ Map props, Function scopeResolver) {
+ this.owner = Objects.requireNonNull(owner, "owner can not be null");
Objects.requireNonNull(engine, "engine can not be null");
Objects.requireNonNull(entryName, "entryName can not be null");
Map properties = props == null ? Collections.emptyMap() : props;
CacheSpec spec = CacheSpec.fromProperties(properties, engine, entryName,
CacheSpec.of(true, DEFAULT_TTL_SECOND, DEFAULT_CAPACITY));
- // contextual-only (loader == null, supplied per-call by get()) + manual-miss-load, no auto-refresh --
- // identical shape to IcebergPartitionCache / MaxComputePartitionCache's entry construction.
- this.entry = new MetaCacheEntry<>(engine + "." + entryName, null, spec,
- ForkJoinPool.commonPool(), false, true, 0L, true);
+ this.entry = owner.create(MetaCacheDefinition
+ .builder(cacheName, spec, scopeResolver)
+ .build());
}
/** Caching is on only when the resolved {@link CacheSpec} is effectively enabled (see {@link #entry}'s spec). */
public boolean isEnabled() {
- return entry.stats().isEffectiveEnabled();
+ return entry.isEnabled();
}
/**
@@ -89,23 +101,21 @@ public V get(ConnectorTableKey key, Supplier loader) {
/** Evicts every cached snapshot/schema entry of one (db, table). Backs REFRESH TABLE / table-level invalidation. */
public void invalidateTable(String db, String table) {
- entry.invalidateIf(key -> key.matches(db, table));
+ owner.invalidateTable(db, table);
}
/** Evicts every cached entry of one db (all its tables). Backs REFRESH DATABASE / db-level invalidation. */
public void invalidateDb(String db) {
- entry.invalidateIf(key -> key.matchesDb(db));
+ owner.invalidateDatabase(db);
}
/** Evicts every cached entry. Backs REFRESH CATALOG. */
public void invalidateAll() {
- entry.invalidateAll();
+ owner.invalidateCatalog();
}
/** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */
long size() {
- int[] count = {0};
- entry.forEach((key, value) -> count[0]++);
- return count[0];
+ return entry.size();
}
}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCache.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCache.java
new file mode 100644
index 00000000000000..3b57fef2364dc0
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCache.java
@@ -0,0 +1,152 @@
+// 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.doris.connector.cache;
+
+import java.util.Objects;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/** One typed physical cache owned by a {@link CatalogMetaCache}. */
+public final class MetaCache {
+ private final MetaCacheDefinition definition;
+ private final ScopedMetaCache delegate;
+
+ MetaCache(MetaCacheDefinition definition, ScopedMetaCache delegate) {
+ this.definition = Objects.requireNonNull(definition, "definition can not be null");
+ this.delegate = Objects.requireNonNull(delegate, "delegate can not be null");
+ }
+
+ public String name() {
+ return definition.name();
+ }
+
+ public V get(K key) {
+ Function loader = definition.loader();
+ if (loader == null) {
+ throw new IllegalStateException("Meta cache '" + name() + "' has no default loader");
+ }
+ return get(key, loader);
+ }
+
+ public V get(K key, Function loader) {
+ K nonNullKey = Objects.requireNonNull(key, "key can not be null");
+ return delegate.get(nonNullKey, definition.scope(nonNullKey), loader);
+ }
+
+ /**
+ * Loads a missing value while allowing an adapter to publish an auxiliary side effect immediately before the
+ * cache value at the same guarded commit point. The coordinator must invoke the supplied commit callback once;
+ * the callback argument is run only if the load is still current and is fenced with cache publication.
+ */
+ public V getWithPublicationAction(K key, Function loader,
+ BiConsumer> publicationCoordinator) {
+ K nonNullKey = Objects.requireNonNull(key, "key can not be null");
+ return delegate.getWithPublicationAction(nonNullKey, definition.scope(nonNullKey), loader,
+ publicationCoordinator);
+ }
+
+ public V getIfPresent(K key) {
+ K nonNullKey = Objects.requireNonNull(key, "key can not be null");
+ return delegate.getIfPresent(nonNullKey, definition.scope(nonNullKey));
+ }
+
+ public void put(K key, V value) {
+ K nonNullKey = Objects.requireNonNull(key, "key can not be null");
+ delegate.put(nonNullKey, definition.scope(nonNullKey), value);
+ }
+
+ public boolean compareAndSet(K key, V expectedValue, V updatedValue) {
+ return compareAndSet(key, expectedValue, updatedValue, () -> {
+ });
+ }
+
+ /**
+ * Updates a value only if it is still current, and runs an auxiliary action inside the same guarded commit.
+ * Concurrent cache loads can not publish between the action and the value update.
+ * The action runs while the publication guards and key lock are held, so it must be short, non-blocking, and
+ * must not re-enter this cache. If it throws, the value is not updated, but completed external side effects are
+ * not rolled back.
+ */
+ public boolean compareAndSet(K key, V expectedValue, V updatedValue, Runnable commitAction) {
+ K nonNullKey = Objects.requireNonNull(key, "key can not be null");
+ return delegate.compareAndSet(nonNullKey, definition.scope(nonNullKey), expectedValue, updatedValue,
+ Objects.requireNonNull(commitAction, "commitAction can not be null"));
+ }
+
+ public void invalidateKey(K key) {
+ delegate.invalidateKey(Objects.requireNonNull(key, "key can not be null"));
+ }
+
+ /**
+ * Starts a bulk remote load whose results all belong below {@code parentScope}. Publishing through the
+ * returned handle is fenced against concurrent scope and exact-key invalidation.
+ */
+ public BulkLoad beginBulkLoad(ScopePath parentScope) {
+ return new BulkLoad<>(this, delegate.beginBulkLoad(parentScope));
+ }
+
+ public ScopedMetaCache.CacheMetrics metrics() {
+ return delegate.metrics();
+ }
+
+ public boolean isEnabled() {
+ return metrics().isEffectiveEnabled();
+ }
+
+ public long size() {
+ return metrics().getPhysicalEntryCount();
+ }
+
+ public long loadSuccessCount() {
+ return metrics().getLoadSuccessCount();
+ }
+
+ public CacheSpec cacheSpec() {
+ return definition.cacheSpec();
+ }
+
+ public boolean isAutoRefresh() {
+ return definition.refreshAfterWrite() != null;
+ }
+
+ public void forEach(BiConsumer consumer) {
+ delegate.forEach(consumer);
+ }
+
+ public static final class BulkLoad implements AutoCloseable {
+ private final MetaCache owner;
+ private final ScopedMetaCache.BulkLoadHandle delegate;
+
+ private BulkLoad(MetaCache owner, ScopedMetaCache.BulkLoadHandle delegate) {
+ this.owner = owner;
+ this.delegate = delegate;
+ }
+
+ /** Returns false when an invalidation raced the remote load and the value was intentionally not cached. */
+ public boolean publish(K key, V value) {
+ K nonNullKey = Objects.requireNonNull(key, "key can not be null");
+ return owner.delegate.publish(delegate, nonNullKey, owner.definition.scope(nonNullKey), value);
+ }
+
+ @Override
+ public void close() {
+ delegate.close();
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheDefinition.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheDefinition.java
new file mode 100644
index 00000000000000..ab8c0e3615e749
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheDefinition.java
@@ -0,0 +1,153 @@
+// 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.doris.connector.cache;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+/**
+ * Declarative definition of one physical metadata cache in a catalog.
+ *
+ * The scope resolver is mandatory. It is the only invalidation dependency a framework-owned cache declares:
+ * every value published by the cache is registered at that scope, so catalog/database/table/partition invalidation
+ * automatically covers it without a connector-maintained list of sibling caches.
+ */
+public final class MetaCacheDefinition {
+ private final String name;
+ private final CacheSpec cacheSpec;
+ private final Function scopeResolver;
+ private final Function loader;
+ private final MetaCacheRemovalListener removalListener;
+ private final BiConsumer discardListener;
+ private final Duration refreshAfterWrite;
+ private final Executor refreshExecutor;
+
+ private MetaCacheDefinition(Builder builder) {
+ name = requireName(builder.name);
+ cacheSpec = Objects.requireNonNull(builder.cacheSpec, "cacheSpec can not be null");
+ scopeResolver = Objects.requireNonNull(builder.scopeResolver, "scopeResolver can not be null");
+ loader = builder.loader;
+ removalListener = builder.removalListener;
+ discardListener = builder.discardListener;
+ refreshAfterWrite = builder.refreshAfterWrite;
+ refreshExecutor = builder.refreshExecutor;
+ if (refreshAfterWrite != null && loader == null) {
+ throw new IllegalArgumentException("refresh-after-write requires a default loader");
+ }
+ }
+
+ public static Builder builder(
+ String name, CacheSpec cacheSpec, Function scopeResolver) {
+ return new Builder<>(name, cacheSpec, scopeResolver);
+ }
+
+ public String name() {
+ return name;
+ }
+
+ CacheSpec cacheSpec() {
+ return cacheSpec;
+ }
+
+ ScopePath scope(K key) {
+ return Objects.requireNonNull(
+ scopeResolver.apply(Objects.requireNonNull(key, "key can not be null")),
+ "scopeResolver can not return null");
+ }
+
+ Function loader() {
+ return loader;
+ }
+
+ MetaCacheRemovalListener removalListener() {
+ return removalListener;
+ }
+
+ BiConsumer discardListener() {
+ return discardListener;
+ }
+
+ Duration refreshAfterWrite() {
+ return refreshAfterWrite;
+ }
+
+ Executor refreshExecutor() {
+ return refreshExecutor;
+ }
+
+ private static String requireName(String name) {
+ String nonNullName = Objects.requireNonNull(name, "name can not be null");
+ if (nonNullName.isEmpty()) {
+ throw new IllegalArgumentException("name can not be empty");
+ }
+ return nonNullName;
+ }
+
+ public static final class Builder {
+ private final String name;
+ private final CacheSpec cacheSpec;
+ private final Function scopeResolver;
+ private Function loader;
+ private MetaCacheRemovalListener removalListener;
+ private BiConsumer discardListener;
+ private Duration refreshAfterWrite;
+ private Executor refreshExecutor;
+
+ private Builder(String name, CacheSpec cacheSpec, Function scopeResolver) {
+ this.name = name;
+ this.cacheSpec = cacheSpec;
+ this.scopeResolver = scopeResolver;
+ }
+
+ public Builder loader(Function loader) {
+ this.loader = Objects.requireNonNull(loader, "loader can not be null");
+ return this;
+ }
+
+ public Builder removalListener(MetaCacheRemovalListener removalListener) {
+ this.removalListener = Objects.requireNonNull(removalListener, "removalListener can not be null");
+ return this;
+ }
+
+ /**
+ * Registers cleanup for a loaded value that never becomes cache-owned, for example because caching is
+ * disabled or invalidation rejects publication. The caller still owns and may return the value.
+ */
+ public Builder discardListener(BiConsumer discardListener) {
+ this.discardListener = Objects.requireNonNull(discardListener, "discardListener can not be null");
+ return this;
+ }
+
+ public Builder refreshAfterWrite(Duration duration, Executor executor) {
+ Duration nonNullDuration = Objects.requireNonNull(duration, "duration can not be null");
+ if (nonNullDuration.isZero() || nonNullDuration.isNegative()) {
+ throw new IllegalArgumentException("refresh-after-write duration must be positive");
+ }
+ this.refreshAfterWrite = nonNullDuration;
+ this.refreshExecutor = Objects.requireNonNull(executor, "refreshExecutor can not be null");
+ return this;
+ }
+
+ public MetaCacheDefinition build() {
+ return new MetaCacheDefinition<>(this);
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntry.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntry.java
deleted file mode 100644
index 97a64ecc159a4a..00000000000000
--- a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntry.java
+++ /dev/null
@@ -1,377 +0,0 @@
-// 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.doris.connector.cache;
-
-import com.github.benmanes.caffeine.cache.Cache;
-import com.github.benmanes.caffeine.cache.LoadingCache;
-import com.github.benmanes.caffeine.cache.stats.CacheStats;
-
-import java.util.Objects;
-import java.util.OptionalLong;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.BiConsumer;
-import java.util.function.Function;
-import java.util.function.Predicate;
-
-/**
- * Unified cache entry abstraction.
- * It stores one logical cache dataset and provides optional lazy loading,
- * key/predicate/full invalidation, and lightweight runtime stats.
- *
- * Connector-side copy of fe-core {@code org.apache.doris.datasource.metacache.MetaCacheEntry}
- * (independent-copy meta-cache migration): connector plugins cannot import fe-core, so the framework is
- * duplicated under {@code org.apache.doris.connector.cache}. The public API is Caffeine-free (Caffeine is
- * encapsulated), so instances are safe to hold in connector (child-first) code. Two knobs that fe-core reads
- * from static {@code Config} are here supplied by the connector via the constructor
- * ({@code refreshAfterWriteSeconds}, {@code manualMissLoadEnabled}); otherwise keep in sync with fe-core.
- */
-public class MetaCacheEntry {
- // Use striped locks to deduplicate slow external loads without managing per-key lock lifecycle.
- private static final int LOAD_LOCK_STRIPES = 128;
-
- private final String name;
- private final Function loader;
- private final CacheSpec cacheSpec;
- private final boolean effectiveEnabled;
- private final boolean autoRefresh;
- // fe-core reads these two from Config; the connector copy has no fe-core Config, so they are injected.
- // refreshAfterWriteSeconds is already in seconds (fe-core computes Config.*_minutes * 60 at its call site).
- private final long refreshAfterWriteSeconds;
- private final boolean manualMissLoadEnabled;
- // Connector-owned cleanup hook. Keep the public API free of Caffeine types.
- private final BiConsumer retirementListener;
- // Keep the loading cache for refreshAfterWrite and the legacy sync-load path when the feature is disabled.
- private final LoadingCache loadingData;
- // Use the plain cache view for manual miss load so slow I/O does not happen in Caffeine's sync load path.
- private final Cache data;
- // Protect one key stripe at a time to deduplicate concurrent miss loads with bounded lock count.
- private final Object[] loadLocks = new Object[LOAD_LOCK_STRIPES];
- private final AtomicLong invalidateCount = new AtomicLong(0);
- // Bump generation before invalidation so in-flight manual loads do not repopulate stale values.
- private final AtomicLong invalidateGeneration = new AtomicLong(0);
- // Track load statistics outside Caffeine because manual miss loads bypass the built-in load counters.
- private final AtomicLong loadSuccessCount = new AtomicLong(0);
- private final AtomicLong loadFailureCount = new AtomicLong(0);
- private final AtomicLong totalLoadTimeNanos = new AtomicLong(0);
- private final AtomicLong lastLoadSuccessTimeMs = new AtomicLong(-1L);
- private final AtomicLong lastLoadFailureTimeMs = new AtomicLong(-1L);
- private final AtomicReference lastError = new AtomicReference<>("");
-
- /**
- * Convenience constructor for the common connector case: a loader-backed entry with no auto-refresh and no
- * manual miss load (Caffeine's sync load path). Use the full constructor for contextual-only entries,
- * auto-refresh, or manual miss load.
- */
- public MetaCacheEntry(String name, Function loader, CacheSpec cacheSpec, ExecutorService refreshExecutor) {
- this(name, loader, cacheSpec, refreshExecutor, false, false, 0L, false);
- }
-
- public MetaCacheEntry(String name, Function loader, CacheSpec cacheSpec,
- ExecutorService refreshExecutor, boolean autoRefresh, boolean contextualOnly,
- long refreshAfterWriteSeconds, boolean manualMissLoadEnabled) {
- this(name, loader, cacheSpec, refreshExecutor, autoRefresh, contextualOnly,
- refreshAfterWriteSeconds, manualMissLoadEnabled, null);
- }
-
- /**
- * Creates an entry with a synchronous retirement callback. The callback is invoked whenever a loaded value
- * stops being owned by this cache: eviction/invalidation, a disabled cache, or an invalidation racing a
- * manual miss load before publication. The callback deliberately uses only JDK types; Caffeine remains an
- * implementation detail of this module.
- */
- public MetaCacheEntry(String name, Function loader, CacheSpec cacheSpec,
- ExecutorService refreshExecutor, boolean autoRefresh, boolean contextualOnly,
- long refreshAfterWriteSeconds, boolean manualMissLoadEnabled,
- BiConsumer retirementListener) {
- this.name = name;
- if (contextualOnly) {
- if (loader != null) {
- throw new IllegalArgumentException("contextual-only entry loader must be null");
- }
- if (autoRefresh) {
- throw new IllegalArgumentException("contextual-only entry can not enable auto refresh");
- }
- } else {
- Objects.requireNonNull(loader, "loader can not be null");
- }
- this.loader = loader;
- this.cacheSpec = Objects.requireNonNull(cacheSpec, "cacheSpec can not be null");
- this.autoRefresh = autoRefresh;
- this.refreshAfterWriteSeconds = refreshAfterWriteSeconds;
- this.manualMissLoadEnabled = manualMissLoadEnabled;
- this.retirementListener = retirementListener;
- Objects.requireNonNull(refreshExecutor, "refreshExecutor can not be null");
- this.effectiveEnabled = CacheSpec.isCacheEnabled(
- this.cacheSpec.isEnable(), this.cacheSpec.getTtlSecond(), this.cacheSpec.getCapacity());
- OptionalLong expireAfterAccessSec =
- effectiveEnabled ? CacheSpec.toExpireAfterAccess(this.cacheSpec.getTtlSecond()) : OptionalLong.empty();
- OptionalLong refreshAfterWriteSec =
- effectiveEnabled && autoRefresh
- ? OptionalLong.of(refreshAfterWriteSeconds)
- : OptionalLong.empty();
- long maxSize = effectiveEnabled ? this.cacheSpec.getCapacity() : 0L;
- CacheFactory cacheFactory = new CacheFactory(
- expireAfterAccessSec,
- refreshAfterWriteSec,
- maxSize,
- true,
- null);
- if (retirementListener != null) {
- this.loadingData = cacheFactory.buildCacheWithSyncRemovalListener(
- this::loadFromDefaultLoader,
- (key, value, cause) -> retirementListener.accept(key, value));
- } else {
- this.loadingData = cacheFactory.buildCache(this::loadFromDefaultLoader, refreshExecutor);
- }
- this.data = loadingData;
- // Initialize striped locks eagerly to keep the hot path allocation-free.
- for (int i = 0; i < loadLocks.length; i++) {
- loadLocks[i] = new Object();
- }
- }
-
- public String name() {
- return name;
- }
-
- public V get(K key) {
- if (!isManualMissLoadEnabled()) {
- return loadingData.get(key);
- }
- return getWithManualLoad(key, this::applyDefaultLoader);
- }
-
- public V get(K key, Function missLoader) {
- Function loadFunction = Objects.requireNonNull(missLoader, "missLoader can not be null");
- if (!isManualMissLoadEnabled()) {
- return loadingData.get(key, typedKey -> loadAndTrack(typedKey, loadFunction));
- }
- return getWithManualLoad(key, loadFunction);
- }
-
- public V getIfPresent(K key) {
- if (!effectiveEnabled) {
- return null;
- }
- return data.getIfPresent(key);
- }
-
- public void put(K key, V value) {
- if (!effectiveEnabled) {
- return;
- }
- data.put(key, value);
- }
-
- /**
- * The current invalidation generation. Capture this BEFORE a slow external load, then hand it to
- * {@link #putIfNotInvalidatedSince} so a {@code flush}/{@code invalidate*} that raced the load does not
- * get its clear silently undone by a stale write-back. Mirrors the guard the manual-miss-load path
- * ({@link #getWithManualLoad}) applies around its own put; exposed so a caller that does its OWN bulk
- * external read (e.g. a decorator batching a multi-key RPC and putting each result under its own key)
- * can reuse the same generation guard instead of an unguarded {@link #put}.
- */
- public long invalidationGeneration() {
- return invalidateGeneration.get();
- }
-
- /**
- * Generation-guarded put: caches {@code (key, value)} only if no invalidation has happened since
- * {@code generation} was captured (before the caller's external load). If a {@code flush}/{@code
- * invalidate*} raced the load — bumping the generation either before the put (skip) or between the put
- * and the recheck (drop only the value we wrote, via {@link #removeLoadedValue}) — the stale value is
- * NOT left cached, exactly as {@link #getWithManualLoad} does for its single-key load. Additive: the
- * existing {@link #put} is unchanged; a disabled entry is a no-op.
- */
- public void putIfNotInvalidatedSince(long generation, K key, V value) {
- if (!effectiveEnabled) {
- return;
- }
- synchronized (loadLock(key)) {
- // A racing flush already bumped the generation before we could put: skip so a stale pre-flush
- // value is not re-cached (mirrors getWithManualLoad's pre-put guard).
- if (generation != invalidateGeneration.get()) {
- return;
- }
- data.put(key, value);
- // A flush landing between the check and the put: drop only the value we just wrote, keeping any
- // newer replacement intact (mirrors getWithManualLoad's post-put guard).
- if (generation != invalidateGeneration.get()) {
- removeLoadedValue(key, value);
- }
- }
- }
-
- public void invalidateKey(K key) {
- invalidateGeneration.incrementAndGet();
- if (data.asMap().remove(key) != null) {
- invalidateCount.incrementAndGet();
- }
- }
-
- public void invalidateIf(Predicate predicate) {
- invalidateGeneration.incrementAndGet();
- data.asMap().keySet().removeIf(key -> {
- if (predicate.test(key)) {
- invalidateCount.incrementAndGet();
- return true;
- }
- return false;
- });
- }
-
- public void invalidateAll() {
- invalidateGeneration.incrementAndGet();
- long size = data.estimatedSize();
- data.invalidateAll();
- invalidateCount.addAndGet(size);
- }
-
- public void forEach(BiConsumer consumer) {
- data.asMap().forEach(consumer);
- }
-
- public MetaCacheEntryStats stats() {
- CacheStats cacheStats = loadingData.stats();
- long successCount = loadSuccessCount.get();
- long failureCount = loadFailureCount.get();
- long totalLoadTime = totalLoadTimeNanos.get();
- long totalLoadCount = successCount + failureCount;
- return new MetaCacheEntryStats(
- cacheSpec.isEnable(),
- effectiveEnabled,
- autoRefresh,
- cacheSpec.getTtlSecond(),
- cacheSpec.getCapacity(),
- data.estimatedSize(),
- cacheStats.requestCount(),
- cacheStats.hitCount(),
- cacheStats.missCount(),
- cacheStats.hitRate(),
- successCount,
- failureCount,
- totalLoadTime,
- totalLoadCount == 0 ? 0D : (double) totalLoadTime / totalLoadCount,
- cacheStats.evictionCount(),
- invalidateCount.get(),
- lastLoadSuccessTimeMs.get(),
- lastLoadFailureTimeMs.get(),
- lastError.get());
- }
-
- // Injected at construction (fe-core reads Config.enable_external_meta_cache_manual_miss_load dynamically).
- private boolean isManualMissLoadEnabled() {
- return manualMissLoadEnabled;
- }
-
- // Execute slow miss loads outside Caffeine's sync load path and suppress stale write-back after invalidation.
- private V getWithManualLoad(K key, Function loadFunction) {
- if (!effectiveEnabled) {
- // Bypass cache entirely when the entry is disabled so manual miss load does not relax disable semantics.
- V loaded = loadAndTrack(key, loadFunction);
- notifyRetirement(key, loaded);
- return loaded;
- }
-
- V value = data.getIfPresent(key);
- if (value != null) {
- return value;
- }
-
- synchronized (loadLock(key)) {
- value = data.asMap().get(key);
- if (value != null) {
- return value;
- }
-
- long generation = invalidateGeneration.get();
- V loaded = loadAndTrack(key, loadFunction);
- if (generation != invalidateGeneration.get()) {
- notifyRetirement(key, loaded);
- return loaded;
- }
-
- // Keep null results uncached so manual miss load matches LoadingCache null-return behavior.
- if (loaded == null) {
- return null;
- }
-
- // Leave a narrow hook for tests to pause exactly before the cache put race window.
- beforeManualCachePutForTest(key, loaded);
- data.put(key, loaded);
- if (generation != invalidateGeneration.get()) {
- removeLoadedValue(key, loaded);
- }
- return loaded;
- }
- }
-
- // Remove only the value loaded by the current request and keep newer replacements intact.
- private void removeLoadedValue(K key, V loaded) {
- data.asMap().computeIfPresent(key, (ignored, currentValue) -> currentValue == loaded ? null : currentValue);
- }
-
- private void notifyRetirement(K key, V value) {
- if (retirementListener != null && value != null) {
- retirementListener.accept(key, value);
- }
- }
-
- // Map keys to a fixed lock stripe set to bound memory usage while keeping same-key deduplication.
- private Object loadLock(K key) {
- int hash = key == null ? 0 : key.hashCode();
- return loadLocks[(hash & Integer.MAX_VALUE) % loadLocks.length];
- }
-
- // Let tests pause between the first generation check and data.put without affecting production behavior.
- void beforeManualCachePutForTest(K key, V loaded) {
- }
-
- private V loadFromDefaultLoader(K key) {
- return loadAndTrack(key, this::applyDefaultLoader);
- }
-
- // Resolve the default loader separately so the manual path can share tracking without double counting.
- private V applyDefaultLoader(K key) {
- if (loader == null) {
- throw new UnsupportedOperationException(
- String.format("Entry '%s' requires a contextual miss loader.", name));
- }
- return loader.apply(key);
- }
-
- // Track load outcomes locally because manual miss loads do not contribute to Caffeine load statistics.
- private V loadAndTrack(K key, Function loadFunction) {
- long startNanos = System.nanoTime();
- try {
- V value = loadFunction.apply(key);
- loadSuccessCount.incrementAndGet();
- totalLoadTimeNanos.addAndGet(System.nanoTime() - startNanos);
- lastLoadSuccessTimeMs.set(System.currentTimeMillis());
- return value;
- } catch (RuntimeException | Error e) {
- loadFailureCount.incrementAndGet();
- totalLoadTimeNanos.addAndGet(System.nanoTime() - startNanos);
- lastLoadFailureTimeMs.set(System.currentTimeMillis());
- lastError.set(e.toString());
- throw e;
- }
- }
-}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntryStats.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntryStats.java
deleted file mode 100644
index 41c8b89192cd1b..00000000000000
--- a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntryStats.java
+++ /dev/null
@@ -1,201 +0,0 @@
-// 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.doris.connector.cache;
-
-import java.util.Objects;
-
-/**
- * Immutable stats snapshot of one {@link MetaCacheEntry}.
- *
- * Connector-side copy of fe-core {@code org.apache.doris.datasource.metacache.MetaCacheEntryStats}
- * (independent-copy migration): the connector plugins cannot import fe-core, so the meta-cache framework is
- * duplicated under {@code org.apache.doris.connector.cache}. Keep behaviourally in sync with the fe-core
- * original until the fe-core copy is retired.
- *
- *
Time fields use the following units:
- *
- * {@code totalLoadTimeNanos}/{@code averageLoadPenaltyNanos}: nanoseconds
- * {@code lastLoadSuccessTimeMs}/{@code lastLoadFailureTimeMs}: epoch milliseconds
- *
- *
- * For last-load timestamps, {@code -1} means no corresponding event happened yet.
- * {@code lastError} keeps the latest load failure message; empty string means no failure recorded.
- */
-public final class MetaCacheEntryStats {
- private final boolean configEnabled;
- private final boolean effectiveEnabled;
- private final boolean autoRefresh;
- private final long ttlSecond;
- private final long capacity;
- private final long estimatedSize;
- private final long requestCount;
- private final long hitCount;
- private final long missCount;
- private final double hitRate;
- private final long loadSuccessCount;
- private final long loadFailureCount;
- private final long totalLoadTimeNanos;
- private final double averageLoadPenaltyNanos;
- private final long evictionCount;
- private final long invalidateCount;
- private final long lastLoadSuccessTimeMs;
- private final long lastLoadFailureTimeMs;
- private final String lastError;
-
- /**
- * Build an immutable stats snapshot.
- */
- public MetaCacheEntryStats(
- boolean configEnabled,
- boolean effectiveEnabled,
- boolean autoRefresh,
- long ttlSecond,
- long capacity,
- long estimatedSize,
- long requestCount,
- long hitCount,
- long missCount,
- double hitRate,
- long loadSuccessCount,
- long loadFailureCount,
- long totalLoadTimeNanos,
- double averageLoadPenaltyNanos,
- long evictionCount,
- long invalidateCount,
- long lastLoadSuccessTimeMs,
- long lastLoadFailureTimeMs,
- String lastError) {
- this.configEnabled = configEnabled;
- this.effectiveEnabled = effectiveEnabled;
- this.autoRefresh = autoRefresh;
- this.ttlSecond = ttlSecond;
- this.capacity = capacity;
- this.estimatedSize = estimatedSize;
- this.requestCount = requestCount;
- this.hitCount = hitCount;
- this.missCount = missCount;
- this.hitRate = hitRate;
- this.loadSuccessCount = loadSuccessCount;
- this.loadFailureCount = loadFailureCount;
- this.totalLoadTimeNanos = totalLoadTimeNanos;
- this.averageLoadPenaltyNanos = averageLoadPenaltyNanos;
- this.evictionCount = evictionCount;
- this.invalidateCount = invalidateCount;
- this.lastLoadSuccessTimeMs = lastLoadSuccessTimeMs;
- this.lastLoadFailureTimeMs = lastLoadFailureTimeMs;
- this.lastError = Objects.requireNonNull(lastError, "lastError");
- }
-
- public boolean isConfigEnabled() {
- return configEnabled;
- }
-
- /**
- * Effective cache enable state evaluated by {@link CacheSpec#isCacheEnabled(boolean, long, long)}.
- */
- public boolean isEffectiveEnabled() {
- return effectiveEnabled;
- }
-
- public boolean isAutoRefresh() {
- return autoRefresh;
- }
-
- public long getTtlSecond() {
- return ttlSecond;
- }
-
- public long getCapacity() {
- return capacity;
- }
-
- public long getEstimatedSize() {
- return estimatedSize;
- }
-
- public long getRequestCount() {
- return requestCount;
- }
-
- public long getHitCount() {
- return hitCount;
- }
-
- public long getMissCount() {
- return missCount;
- }
-
- public double getHitRate() {
- return hitRate;
- }
-
- public long getLoadSuccessCount() {
- return loadSuccessCount;
- }
-
- public long getLoadFailureCount() {
- return loadFailureCount;
- }
-
- public long getTotalLoadTimeNanos() {
- return totalLoadTimeNanos;
- }
-
- /**
- * Average load penalty in nanoseconds.
- */
- public double getAverageLoadPenaltyNanos() {
- return averageLoadPenaltyNanos;
- }
-
- public long getEvictionCount() {
- return evictionCount;
- }
-
- public double getEvictionRate() {
- if (requestCount == 0) {
- return 0D;
- }
- return (double) evictionCount / requestCount;
- }
-
- public long getInvalidateCount() {
- return invalidateCount;
- }
-
- /**
- * Last successful load timestamp in epoch milliseconds, or {@code -1} if absent.
- */
- public long getLastLoadSuccessTimeMs() {
- return lastLoadSuccessTimeMs;
- }
-
- /**
- * Last failed load timestamp in epoch milliseconds, or {@code -1} if absent.
- */
- public long getLastLoadFailureTimeMs() {
- return lastLoadFailureTimeMs;
- }
-
- /**
- * Latest load failure message, or empty string if no failure is recorded.
- */
- public String getLastError() {
- return lastError;
- }
-}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheRemovalListener.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheRemovalListener.java
new file mode 100644
index 00000000000000..816bd98691a382
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheRemovalListener.java
@@ -0,0 +1,24 @@
+// 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.doris.connector.cache;
+
+/** Caffeine-free callback invoked when one published value stops being owned by the metadata cache. */
+@FunctionalInterface
+public interface MetaCacheRemovalListener {
+ void onRemoval(K key, V value, MetaCacheRemovalReason reason);
+}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheRemovalReason.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheRemovalReason.java
new file mode 100644
index 00000000000000..ac2c2769dfc5a4
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheRemovalReason.java
@@ -0,0 +1,27 @@
+// 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.doris.connector.cache;
+
+/** Framework-owned reason for a metadata-cache removal. */
+public enum MetaCacheRemovalReason {
+ EXPLICIT,
+ REPLACED,
+ COLLECTED,
+ EXPIRED,
+ SIZE
+}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopePath.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopePath.java
new file mode 100644
index 00000000000000..98d9a7abdfed3f
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopePath.java
@@ -0,0 +1,166 @@
+// 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.doris.connector.cache;
+
+import java.util.Objects;
+
+/**
+ * Hierarchical invalidation path within one catalog-owned scoped cache registry.
+ *
+ * The catalog identity is implicit in the owning {@link ScopedMetaCacheRegistry}. Database and table
+ * identities are strings because FE and connector callers already invalidate by their respective local or remote
+ * names. A partition identity is deliberately opaque: Hive partition values, Iceberg transforms, and other
+ * connector-specific partition identities do not share one representation. Like a cache key, it must keep stable
+ * {@link Object#equals(Object)} and {@link Object#hashCode()} semantics while registered.
+ */
+public final class ScopePath {
+ private enum SyntheticPartition {
+ COLLECTION
+ }
+
+ public enum Level {
+ CATALOG,
+ DATABASE,
+ TABLE,
+ PARTITION
+ }
+
+ private static final ScopePath CATALOG = new ScopePath(Level.CATALOG, null, null, null);
+
+ private final Level level;
+ private final String database;
+ private final String table;
+ private final Object partition;
+
+ private ScopePath(Level level, String database, String table, Object partition) {
+ this.level = level;
+ this.database = database;
+ this.table = table;
+ this.partition = partition;
+ }
+
+ public static ScopePath catalog() {
+ return CATALOG;
+ }
+
+ public static ScopePath database(String database) {
+ return new ScopePath(Level.DATABASE, Objects.requireNonNull(database, "database can not be null"),
+ null, null);
+ }
+
+ public static ScopePath table(String database, String table) {
+ return new ScopePath(
+ Level.TABLE,
+ Objects.requireNonNull(database, "database can not be null"),
+ Objects.requireNonNull(table, "table can not be null"),
+ null);
+ }
+
+ public static ScopePath partition(String database, String table, Object partition) {
+ return new ScopePath(
+ Level.PARTITION,
+ Objects.requireNonNull(database, "database can not be null"),
+ Objects.requireNonNull(table, "table can not be null"),
+ Objects.requireNonNull(partition, "partition can not be null"));
+ }
+
+ /**
+ * Scope for a value describing the table's partition collection as a whole, such as a partition-name list.
+ * It is a child of the table but distinct from every connector partition identity, allowing a partition
+ * change to invalidate membership views without also dropping unrelated table metadata.
+ */
+ public static ScopePath partitionCollection(String database, String table) {
+ return partition(database, table, SyntheticPartition.COLLECTION);
+ }
+
+ public Level level() {
+ return level;
+ }
+
+ public String database() {
+ return database;
+ }
+
+ public String table() {
+ return table;
+ }
+
+ public Object partition() {
+ return partition;
+ }
+
+ public boolean contains(ScopePath other) {
+ Objects.requireNonNull(other, "other can not be null");
+ if (level.ordinal() > other.level.ordinal()) {
+ return false;
+ }
+ if (level == Level.CATALOG) {
+ return true;
+ }
+ if (!database.equals(other.database)) {
+ return false;
+ }
+ if (level == Level.DATABASE) {
+ return true;
+ }
+ if (!table.equals(other.table)) {
+ return false;
+ }
+ return level == Level.TABLE || partition.equals(other.partition);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof ScopePath)) {
+ return false;
+ }
+ ScopePath other = (ScopePath) obj;
+ return level == other.level
+ && Objects.equals(database, other.database)
+ && Objects.equals(table, other.table)
+ && Objects.equals(partition, other.partition);
+ }
+
+ @Override
+ public int hashCode() {
+ int pathHashCode = 1;
+ pathHashCode = 31 * pathHashCode + Objects.hashCode(level);
+ pathHashCode = 31 * pathHashCode + Objects.hashCode(database);
+ pathHashCode = 31 * pathHashCode + Objects.hashCode(table);
+ return 31 * pathHashCode + Objects.hashCode(partition);
+ }
+
+ @Override
+ public String toString() {
+ switch (level) {
+ case CATALOG:
+ return "catalog";
+ case DATABASE:
+ return "database[" + database + "]";
+ case TABLE:
+ return "table[" + database + "." + table + "]";
+ case PARTITION:
+ return "partition[" + database + "." + table + ":" + partition + "]";
+ default:
+ throw new IllegalStateException("Unknown scope level: " + level);
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopedMetaCache.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopedMetaCache.java
new file mode 100644
index 00000000000000..4f8ac0924e79c9
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopedMetaCache.java
@@ -0,0 +1,1197 @@
+// 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.doris.connector.cache;
+
+import org.apache.doris.connector.cache.ScopedMetaCacheRegistry.CacheAddress;
+import org.apache.doris.connector.cache.ScopedMetaCacheRegistry.PublicationState;
+import org.apache.doris.connector.cache.ScopedMetaCacheRegistry.ScopeLease;
+import org.apache.doris.connector.cache.ScopedMetaCacheRegistry.ScopeSnapshot;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.github.benmanes.caffeine.cache.RemovalListener;
+import com.github.benmanes.caffeine.cache.Ticker;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Objects;
+import java.util.OptionalLong;
+import java.util.TreeMap;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.atomic.LongAdder;
+import java.util.function.BiConsumer;
+import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/**
+ * One physical Caffeine cache participating in a {@link ScopedMetaCacheRegistry}.
+ *
+ *
Every value is wrapped with both its hierarchical scope-state identities and an exact-key state. Hierarchical
+ * invalidation can therefore detach a whole catalog/database/table/partition subtree, while exact-key invalidation
+ * fences only one physical cache key. Removal listeners conditionally remove the exact wrapper from its original
+ * scope bucket and key node, so delayed callbacks cannot delete a replacement.
+ */
+public final class ScopedMetaCache implements AutoCloseable {
+ private static final Logger LOG = LogManager.getLogger(ScopedMetaCache.class);
+ private static final Runnable NO_OP = () -> {
+ };
+
+ private final ScopedMetaCacheRegistry registry;
+ private final String name;
+ private final boolean effectiveEnabled;
+ private final Cache> data;
+ private final ConcurrentMap> keyNodes = new ConcurrentHashMap<>();
+ private final ConcurrentMap, CompletableFuture> inFlightLoads = new ConcurrentHashMap<>();
+ private final ConcurrentMap> refreshing = new ConcurrentHashMap<>();
+ private final StripedPhaseGate bulkInvalidationGate = new StripedPhaseGate();
+ private final Map exactInvalidations = new HashMap<>();
+ private final NavigableMap activeBulkStarts = new TreeMap<>();
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+ private final LongAdder requestCount = new LongAdder();
+ private final LongAdder hitCount = new LongAdder();
+ private final LongAdder missCount = new LongAdder();
+ private final LongAdder loadSuccessCount = new LongAdder();
+ private final LongAdder loadFailureCount = new LongAdder();
+ private final LongAdder totalLoadTimeNanos = new LongAdder();
+ private final LongAdder evictionCount = new LongAdder();
+ private final LongAdder invalidateCount = new LongAdder();
+ private final AtomicReference lastLoadSuccessTimeMs = new AtomicReference<>(-1L);
+ private final AtomicReference lastLoadFailureTimeMs = new AtomicReference<>(-1L);
+ private final AtomicReference lastError = new AtomicReference<>("");
+ private final RemovalListener beforeRemoval;
+ private final BiConsumer discardListener;
+ private final Ticker ticker;
+ private final long refreshAfterWriteNanos;
+ private final Executor refreshExecutor;
+ private final Runnable afterLoadElection;
+ private final Runnable afterBulkStage;
+ private final Runnable afterRefreshRegistration;
+ private final ThreadLocal> removalDeferrals =
+ ThreadLocal.withInitial(RemovalDeferral::new);
+ private BigInteger exactInvalidationSequence = BigInteger.ZERO;
+
+ ScopedMetaCache(
+ ScopedMetaCacheRegistry registry,
+ String name,
+ CacheSpec cacheSpec,
+ Ticker ticker,
+ RemovalListener beforeRemoval,
+ BiConsumer discardListener,
+ Duration refreshAfterWrite,
+ Executor refreshExecutor,
+ Runnable afterLoadElection,
+ Runnable afterBulkStage,
+ Runnable afterRefreshRegistration) {
+ this.registry = Objects.requireNonNull(registry, "registry can not be null");
+ this.name = Objects.requireNonNull(name, "name can not be null");
+ Objects.requireNonNull(cacheSpec, "cacheSpec can not be null");
+ this.beforeRemoval = beforeRemoval;
+ this.discardListener = discardListener;
+ this.ticker = ticker == null ? Ticker.systemTicker() : ticker;
+ this.refreshAfterWriteNanos = refreshAfterWrite == null ? 0L : refreshAfterWrite.toNanos();
+ this.refreshExecutor = refreshExecutor;
+ this.afterLoadElection =
+ Objects.requireNonNull(afterLoadElection, "afterLoadElection can not be null");
+ this.afterBulkStage = Objects.requireNonNull(afterBulkStage, "afterBulkStage can not be null");
+ this.afterRefreshRegistration = Objects.requireNonNull(
+ afterRefreshRegistration, "afterRefreshRegistration can not be null");
+ this.effectiveEnabled = CacheSpec.isCacheEnabled(
+ cacheSpec.isEnable(), cacheSpec.getTtlSecond(), cacheSpec.getCapacity());
+
+ Caffeine builder = Caffeine.newBuilder()
+ .maximumSize(effectiveEnabled ? cacheSpec.getCapacity() : 0L)
+ .executor(Runnable::run)
+ .removalListener(this::onRemoval);
+ OptionalLong expiry = effectiveEnabled
+ ? CacheSpec.toExpireAfterAccess(cacheSpec.getTtlSecond())
+ : OptionalLong.empty();
+ if (expiry.isPresent()) {
+ builder.expireAfterAccess(Duration.ofSeconds(expiry.getAsLong()));
+ }
+ if (ticker != null) {
+ builder.ticker(this.ticker);
+ }
+ this.data = builder.build();
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public V get(K key, ScopePath path, Function loader) {
+ return getWithPublicationAction(key, path, loader,
+ (loaded, commit) -> commit.accept(NO_OP));
+ }
+
+ public V getWithPublicationAction(K key, ScopePath path, Function loader,
+ BiConsumer> publicationCoordinator) {
+ Objects.requireNonNull(key, "key can not be null");
+ Objects.requireNonNull(path, "path can not be null");
+ Function loadFunction = Objects.requireNonNull(loader, "loader can not be null");
+ BiConsumer> coordinator = Objects.requireNonNull(
+ publicationCoordinator, "publicationCoordinator can not be null");
+ checkOpen();
+ if (!effectiveEnabled) {
+ recordAccess(false);
+ V loaded = loadAndRecord(key, loadFunction);
+ if (loaded != null) {
+ AtomicBoolean commitInvoked = new AtomicBoolean(false);
+ try {
+ coordinator.accept(loaded, beforePublication -> {
+ if (!commitInvoked.compareAndSet(false, true)) {
+ throw new IllegalStateException("Metadata cache publication callback was invoked twice");
+ }
+ Objects.requireNonNull(beforePublication, "beforePublication can not be null").run();
+ });
+ if (!commitInvoked.get()) {
+ throw new IllegalStateException("Metadata cache publication callback was not invoked");
+ }
+ } finally {
+ notifyDiscarded(key, loaded);
+ }
+ }
+ return loaded;
+ }
+
+ VersionedValue presentVersioned = currentVersionedValue(key, path);
+ if (presentVersioned != null) {
+ recordAccess(true);
+ scheduleRefresh(key, path, loader, presentVersioned);
+ return presentVersioned.value;
+ }
+ recordAccess(false);
+ try (PublicationLease lease = acquirePublicationLease(key, path, true)) {
+ LoadAddress loadAddress = new LoadAddress<>(key, path, lease);
+ CompletableFuture ownLoad = new CompletableFuture<>();
+ CompletableFuture existingLoad = inFlightLoads.putIfAbsent(loadAddress, ownLoad);
+ if (existingLoad != null) {
+ return awaitLoad(existingLoad);
+ }
+ try {
+ afterLoadElection.run();
+ AtomicReference> electedValue = new AtomicReference<>();
+ boolean electedValuePresent = deferRemovals(() -> {
+ VersionedValue present;
+ synchronized (lease.keyNode) {
+ present = currentVersionedValue(key, path);
+ }
+ electedValue.set(present);
+ return present != null;
+ });
+ if (electedValuePresent) {
+ VersionedValue present = electedValue.get();
+ ownLoad.complete(present.value);
+ return present.value;
+ }
+ V loaded = loadAndRecord(key, loadFunction);
+ if (loaded != null) {
+ AtomicBoolean commitInvoked = new AtomicBoolean(false);
+ AtomicBoolean retained = new AtomicBoolean(false);
+ try {
+ coordinator.accept(loaded, beforePublication -> {
+ if (!commitInvoked.compareAndSet(false, true)) {
+ throw new IllegalStateException(
+ "Metadata cache publication callback was invoked twice");
+ }
+ retained.set(commitLoaded(lease, key, loaded, beforePublication));
+ });
+ if (!commitInvoked.get()) {
+ throw new IllegalStateException("Metadata cache publication callback was not invoked");
+ }
+ } finally {
+ if (!retained.get()) {
+ notifyDiscarded(key, loaded);
+ }
+ }
+ }
+ ownLoad.complete(loaded);
+ return loaded;
+ } catch (RuntimeException | Error throwable) {
+ ownLoad.completeExceptionally(throwable);
+ throw throwable;
+ } finally {
+ inFlightLoads.remove(loadAddress, ownLoad);
+ }
+ }
+ }
+
+ public V getIfPresent(K key, ScopePath path) {
+ Objects.requireNonNull(key, "key can not be null");
+ Objects.requireNonNull(path, "path can not be null");
+ checkOpen();
+ if (!effectiveEnabled) {
+ recordAccess(false);
+ return null;
+ }
+ VersionedValue versioned = currentVersionedValue(key, path);
+ recordAccess(versioned != null);
+ return versioned == null ? null : versioned.value;
+ }
+
+ private VersionedValue currentVersionedValue(K key, ScopePath path) {
+ VersionedValue versioned = data.getIfPresent(key);
+ if (versioned == null) {
+ return null;
+ }
+ if (!versioned.scopeSnapshot.path().equals(path)) {
+ return null;
+ }
+ if (!versioned.isCurrent(registry, keyNodes)) {
+ data.asMap().remove(key, versioned);
+ return null;
+ }
+ return versioned;
+ }
+
+ public void put(K key, ScopePath path, V value) {
+ Objects.requireNonNull(key, "key can not be null");
+ Objects.requireNonNull(path, "path can not be null");
+ Objects.requireNonNull(value, "value can not be null");
+ checkOpen();
+ if (!effectiveEnabled) {
+ return;
+ }
+ try (PublicationLease lease = acquirePublicationLease(key, path, false)) {
+ guardedCommit(lease, () -> {
+ lease.keyNode.loadPublicationState.set(new Object());
+ return publishCommitted(lease, key, value) != null;
+ });
+ }
+ }
+
+ public boolean compareAndSet(K key, ScopePath path, V expectedValue, V updatedValue) {
+ return compareAndSet(key, path, expectedValue, updatedValue, NO_OP);
+ }
+
+ public boolean compareAndSet(
+ K key, ScopePath path, V expectedValue, V updatedValue, Runnable commitAction) {
+ Objects.requireNonNull(key, "key can not be null");
+ Objects.requireNonNull(path, "path can not be null");
+ Runnable action = Objects.requireNonNull(commitAction, "commitAction can not be null");
+ checkOpen();
+ if (!effectiveEnabled) {
+ action.run();
+ return true;
+ }
+ try (PublicationLease lease = acquirePublicationLease(key, path, false)) {
+ return guardedCommit(lease, () -> {
+ VersionedValue current = currentVersionedValue(key, path);
+ V currentValue = current == null ? null : current.value;
+ if (currentValue != expectedValue) {
+ return false;
+ }
+ lease.keyNode.loadPublicationState.set(new Object());
+ action.run();
+ if (updatedValue == currentValue) {
+ lease.keyNode.loadPublicationState.set(new Object());
+ return true;
+ }
+ if (updatedValue == null) {
+ if (current != null) {
+ data.asMap().remove(key, current);
+ lease.keyNode.registration.compareAndSet(current, null);
+ }
+ } else {
+ publishCommitted(lease, key, updatedValue);
+ }
+ lease.keyNode.loadPublicationState.set(new Object());
+ return true;
+ });
+ }
+ }
+
+ public void invalidateKey(K key) {
+ invalidateKey(key, NO_OP, NO_OP);
+ }
+
+ void invalidateKey(K key, Runnable afterStateReplacement) {
+ invalidateKey(key, NO_OP, afterStateReplacement);
+ }
+
+ void invalidateKey(
+ K key, Runnable beforeInvalidationLock, Runnable afterStateReplacement) {
+ Objects.requireNonNull(key, "key can not be null");
+ Objects.requireNonNull(beforeInvalidationLock, "beforeInvalidationLock can not be null");
+ Objects.requireNonNull(afterStateReplacement, "afterStateReplacement can not be null");
+ checkOpen();
+ beforeInvalidationLock.run();
+ InvalidatedKey invalidated = bulkInvalidationGate.write(() -> {
+ if (closed.get()) {
+ return null;
+ }
+ exactInvalidationSequence = exactInvalidationSequence.add(BigInteger.ONE);
+ if (!activeBulkStarts.isEmpty()) {
+ exactInvalidations.put(key, exactInvalidationSequence);
+ }
+ KeyNode node = keyNodes.get(key);
+ KeyState invalidatedState = null;
+ if (node != null) {
+ invalidatedState = replaceKeyState(node);
+ }
+ return new InvalidatedKey<>(node, invalidatedState);
+ });
+ if (invalidated == null || invalidated.node == null) {
+ return;
+ }
+ afterStateReplacement.run();
+ VersionedValue registered = invalidated.node.registration.get();
+ if (registered != null && registered.keyState == invalidated.keyState) {
+ data.asMap().remove(key, registered);
+ invalidated.node.registration.compareAndSet(registered, null);
+ }
+ tryPruneKey(key, invalidated.node);
+ }
+
+ public BulkLoadHandle beginBulkLoad(ScopePath parentScope) {
+ Objects.requireNonNull(parentScope, "parentScope can not be null");
+ checkOpen();
+ if (!effectiveEnabled) {
+ return BulkLoadHandle.disabled(this, parentScope);
+ }
+ ScopeLease scopeLease = registry.acquire(parentScope);
+ BigInteger exactSequence = bulkInvalidationGate.write(() -> {
+ if (closed.get()) {
+ scopeLease.close();
+ throw new IllegalStateException("Scoped meta cache '" + name + "' is closed");
+ }
+ BigInteger sequence = exactInvalidationSequence;
+ activeBulkStarts.merge(sequence, 1, Integer::sum);
+ return sequence;
+ });
+ return new BulkLoadHandle(
+ this,
+ parentScope,
+ scopeLease,
+ scopeLease.publicationState(),
+ exactSequence);
+ }
+
+ public boolean publish(
+ BulkLoadHandle handle, K key, ScopePath actualScope, V value) {
+ Objects.requireNonNull(handle, "handle can not be null");
+ Objects.requireNonNull(key, "key can not be null");
+ Objects.requireNonNull(actualScope, "actualScope can not be null");
+ Objects.requireNonNull(value, "value can not be null");
+ checkOpen();
+ handle.checkOwner(this);
+ if (!handle.parentScope.contains(actualScope)) {
+ throw new IllegalArgumentException(
+ "Actual scope " + actualScope + " is outside bulk-load parent " + handle.parentScope);
+ }
+ if (!effectiveEnabled) {
+ return false;
+ }
+ try (PublicationLease lease = acquirePublicationLease(key, actualScope, false)) {
+ VersionedValue staged = newVersionedValue(lease, key, value);
+ afterBulkStage.run();
+ return handle.tryCommit(key, lease, staged);
+ }
+ }
+
+ public CacheMetrics metrics() {
+ return bulkInvalidationGate.read(() -> new CacheMetrics(
+ data.estimatedSize(),
+ keyNodes.size(),
+ inFlightLoads.size(),
+ activeBulkStarts.values().stream().mapToInt(Integer::intValue).sum(),
+ exactInvalidations.size(),
+ effectiveEnabled,
+ requestCount.sum(),
+ hitCount.sum(),
+ missCount.sum(),
+ loadSuccessCount.sum(),
+ loadFailureCount.sum(),
+ totalLoadTimeNanos.sum(),
+ evictionCount.sum(),
+ invalidateCount.sum(),
+ lastLoadSuccessTimeMs.get(),
+ lastLoadFailureTimeMs.get(),
+ lastError.get()));
+ }
+
+ int refreshingCountForTest() {
+ return refreshing.size();
+ }
+
+ public void forEach(BiConsumer consumer) {
+ Objects.requireNonNull(consumer, "consumer can not be null");
+ data.asMap().forEach((key, versioned) -> {
+ if (versioned.isCurrent(registry, keyNodes)) {
+ consumer.accept(key, versioned.value);
+ }
+ });
+ }
+
+ private V loadAndRecord(K key, Function loader) {
+ long startNanos = System.nanoTime();
+ try {
+ V loaded = loader.apply(key);
+ loadSuccessCount.increment();
+ lastLoadSuccessTimeMs.set(System.currentTimeMillis());
+ return loaded;
+ } catch (RuntimeException | Error throwable) {
+ loadFailureCount.increment();
+ lastLoadFailureTimeMs.set(System.currentTimeMillis());
+ lastError.set(throwable.toString());
+ throw throwable;
+ } finally {
+ totalLoadTimeNanos.add(System.nanoTime() - startNanos);
+ }
+ }
+
+ private void recordAccess(boolean hit) {
+ requestCount.increment();
+ if (hit) {
+ hitCount.increment();
+ } else {
+ missCount.increment();
+ }
+ }
+
+ public void cleanUp() {
+ data.cleanUp();
+ }
+
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ registry.removeCache(this);
+ refreshing.clear();
+ closePhysicalState();
+ }
+
+ void closeFromRegistry() {
+ if (closed.compareAndSet(false, true)) {
+ refreshing.clear();
+ closePhysicalState();
+ }
+ }
+
+ void removeExpectedRaw(Object rawKey, Object expectedValue) {
+ @SuppressWarnings("unchecked")
+ K key = (K) rawKey;
+ @SuppressWarnings("unchecked")
+ VersionedValue versionedValue = (VersionedValue) expectedValue;
+ data.asMap().remove(key, versionedValue);
+ }
+
+ private PublicationLease acquirePublicationLease(
+ K key, ScopePath path, boolean fenceAgainstDirectPublication) {
+ while (true) {
+ ScopeLease scopeLease = registry.acquire(path);
+ KeyNode keyNode = keyNodes.computeIfAbsent(key, ignored -> new KeyNode<>());
+ keyNode.activeLoads.incrementAndGet();
+ KeyState keyState = keyNode.current.get();
+ Object loadPublicationState =
+ fenceAgainstDirectPublication ? keyNode.loadPublicationState.get() : null;
+ if (keyNodes.get(key) == keyNode && scopeLease.isCurrent()) {
+ return new PublicationLease<>(
+ this, key, scopeLease, keyNode, keyState, loadPublicationState);
+ }
+ releaseKey(key, keyNode);
+ scopeLease.close();
+ }
+ }
+
+ private VersionedValue publish(PublicationLease lease, K key, V value) {
+ if (!lease.isCurrent()) {
+ return null;
+ }
+ VersionedValue versioned = newVersionedValue(lease, key, value);
+ install(versioned, lease);
+ if (!lease.isCurrent()) {
+ data.asMap().remove(key, versioned);
+ return null;
+ }
+ return versioned;
+ }
+
+ private VersionedValue publishCommitted(PublicationLease lease, K key, V value) {
+ return publish(lease, key, value);
+ }
+
+ private boolean commitLoaded(PublicationLease lease, K key, V value, Runnable beforePublication) {
+ Runnable action = Objects.requireNonNull(beforePublication, "beforePublication can not be null");
+ return guardedCommit(lease, () -> {
+ action.run();
+ return publishCommitted(lease, key, value) != null;
+ });
+ }
+
+ private boolean guardedCommit(PublicationLease lease, BooleanSupplier commitAction) {
+ return deferRemovals(() -> bulkInvalidationGate.readBoolean(
+ () -> lease.scopeLease.commitIfPublicationCurrent(
+ lease.scopePublicationState, () -> {
+ synchronized (lease.keyNode) {
+ return lease.isCurrent() && commitAction.getAsBoolean();
+ }
+ })));
+ }
+
+ private VersionedValue newVersionedValue(
+ PublicationLease lease, K key, V value) {
+ CacheAddress address = new CacheAddress(this, key);
+ return new VersionedValue<>(
+ key, value, address, lease.scopeLease.snapshot(), lease.keyNode, lease.keyState, ticker.read());
+ }
+
+ private void scheduleRefresh(K key, ScopePath path, Function loader, VersionedValue current) {
+ if (refreshAfterWriteNanos == 0L || ticker.read() - current.writeTimeNanos < refreshAfterWriteNanos
+ || refreshing.putIfAbsent(key, current) != null) {
+ return;
+ }
+ try {
+ afterRefreshRegistration.run();
+ checkOpen();
+ } catch (RuntimeException | Error throwable) {
+ refreshing.remove(key, current);
+ throw throwable;
+ }
+ if (data.getIfPresent(key) != current || !current.isCurrent(registry, keyNodes)) {
+ refreshing.remove(key, current);
+ return;
+ }
+ try {
+ refreshExecutor.execute(() -> {
+ try {
+ if (closed.get()) {
+ return;
+ }
+ try (PublicationLease lease = acquirePublicationLease(key, path, true)) {
+ if (data.getIfPresent(key) != current || !current.isCurrent(registry, keyNodes)) {
+ return;
+ }
+ V refreshed = loadAndRecord(key, loader);
+ if (refreshed != null) {
+ boolean retained = false;
+ try {
+ retained = replaceRefreshExpected(lease, key, current, refreshed);
+ } finally {
+ if (!retained && refreshed != current.value) {
+ notifyDiscarded(key, refreshed);
+ }
+ }
+ }
+ }
+ } catch (RuntimeException | Error throwable) {
+ LOG.warn("Scoped metadata cache refresh failed", throwable);
+ } finally {
+ refreshing.remove(key, current);
+ }
+ });
+ } catch (RejectedExecutionException exception) {
+ refreshing.remove(key, current);
+ }
+ }
+
+ private void install(
+ VersionedValue versioned, PublicationLease lease) {
+ registry.register(versioned.address, versioned, versioned.scopeSnapshot);
+ lease.keyNode.registration.set(versioned);
+ data.asMap().put(versioned.key, versioned);
+ }
+
+ private boolean replaceRefreshExpected(PublicationLease lease, K key,
+ VersionedValue expected, V refreshed) {
+ return guardedCommit(lease, () -> {
+ if (data.getIfPresent(key) != expected || !expected.isCurrent(registry, keyNodes)) {
+ return false;
+ }
+ if (refreshed == expected.value) {
+ expected.writeTimeNanos = ticker.read();
+ return true;
+ }
+ VersionedValue replacement = newVersionedValue(lease, key, refreshed);
+ registry.register(replacement.address, replacement, replacement.scopeSnapshot);
+ if (!lease.keyNode.registration.compareAndSet(expected, replacement)) {
+ registry.unregister(replacement.address, replacement, replacement.scopeSnapshot);
+ return false;
+ }
+ if (!data.asMap().replace(key, expected, replacement)) {
+ lease.keyNode.registration.compareAndSet(replacement, null);
+ registry.unregister(replacement.address, replacement, replacement.scopeSnapshot);
+ return false;
+ }
+ return true;
+ });
+ }
+
+ private boolean tryCommitBulk(
+ BulkLoadHandle handle,
+ Object rawKey,
+ PublicationLease, ?> rawLease,
+ VersionedValue, ?> rawStaged) {
+ @SuppressWarnings("unchecked")
+ K key = (K) rawKey;
+ @SuppressWarnings("unchecked")
+ PublicationLease lease = (PublicationLease) rawLease;
+ @SuppressWarnings("unchecked")
+ VersionedValue staged = (VersionedValue) rawStaged;
+ return deferRemovals(() -> bulkInvalidationGate.readBoolean(() -> {
+ if (!isBulkKeyCurrent(handle, key)) {
+ return false;
+ }
+ return handle.scopeLease.commitIfPublicationCurrent(
+ handle.scopePublicationState, () -> {
+ synchronized (lease.keyNode) {
+ if (!lease.isCurrent()) {
+ return false;
+ }
+ lease.keyNode.loadPublicationState.set(new Object());
+ install(staged, lease);
+ return true;
+ }
+ });
+ }));
+ }
+
+ private boolean deferRemovals(BooleanSupplier action) {
+ RemovalDeferral removalDeferral = removalDeferrals.get();
+ removalDeferral.depth++;
+ try {
+ return action.getAsBoolean();
+ } finally {
+ removalDeferral.depth--;
+ if (removalDeferral.depth == 0 && !removalDeferral.draining) {
+ drainDeferredRemovals(removalDeferral);
+ }
+ }
+ }
+
+ private boolean isBulkKeyCurrent(BulkLoadHandle handle, Object rawKey) {
+ BigInteger invalidation = exactInvalidations.get(rawKey);
+ return !closed.get()
+ && !handle.closed.get()
+ && (invalidation == null || invalidation.compareTo(handle.exactInvalidationSequence) <= 0);
+ }
+
+ private void closeBulkHandle(BulkLoadHandle handle) {
+ ScopeLease leaseToClose = bulkInvalidationGate.write(() -> {
+ if (!handle.closed.compareAndSet(false, true)) {
+ return null;
+ }
+ if (handle.scopeLease != null) {
+ Integer count = activeBulkStarts.get(handle.exactInvalidationSequence);
+ if (count == null) {
+ throw new IllegalStateException("Bulk-load handle start sequence is not registered");
+ }
+ if (count == 1) {
+ activeBulkStarts.remove(handle.exactInvalidationSequence);
+ } else {
+ activeBulkStarts.put(handle.exactInvalidationSequence, count - 1);
+ }
+ pruneExactInvalidations();
+ return handle.scopeLease;
+ }
+ return null;
+ });
+ if (leaseToClose != null) {
+ leaseToClose.close();
+ }
+ }
+
+ private void pruneExactInvalidations() {
+ if (activeBulkStarts.isEmpty()) {
+ exactInvalidations.clear();
+ return;
+ }
+ BigInteger oldestStart = activeBulkStarts.firstKey();
+ exactInvalidations.entrySet().removeIf(entry -> entry.getValue().compareTo(oldestStart) <= 0);
+ }
+
+ private V awaitLoad(CompletableFuture load) {
+ try {
+ return load.join();
+ } catch (CompletionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ if (cause instanceof Error) {
+ throw (Error) cause;
+ }
+ throw new IllegalStateException("Unexpected checked exception from metadata loader", cause);
+ }
+ }
+
+ private void closePhysicalState() {
+ bulkInvalidationGate.write(exactInvalidations::clear);
+ keyNodes.forEach((key, node) -> {
+ replaceKeyState(node);
+ VersionedValue versioned = node.registration.get();
+ if (versioned != null) {
+ data.asMap().remove(key, versioned);
+ node.registration.compareAndSet(versioned, null);
+ }
+ tryPruneKey(key, node);
+ });
+ data.invalidateAll();
+ data.cleanUp();
+ }
+
+ private void onRemoval(
+ Object rawKey, Object rawValue, RemovalCause cause) {
+ Objects.requireNonNull(rawKey, "removed cache key can not be null");
+ Objects.requireNonNull(rawValue, "removed cache value can not be null");
+ @SuppressWarnings("unchecked")
+ VersionedValue versioned = (VersionedValue) rawValue;
+ RemovalDeferral removalDeferral = removalDeferrals.get();
+ removalDeferral.removals.addLast(new DeferredRemoval<>(versioned, cause));
+ if (removalDeferral.depth > 0 || removalDeferral.draining) {
+ return;
+ }
+ drainDeferredRemovals(removalDeferral);
+ }
+
+ private void drainDeferredRemovals(RemovalDeferral removalDeferral) {
+ removalDeferral.draining = true;
+ Throwable failure = null;
+ try {
+ DeferredRemoval removal;
+ while ((removal = removalDeferral.removals.pollFirst()) != null) {
+ try {
+ completeRemoval(removal.versioned, removal.cause);
+ } catch (RuntimeException | Error e) {
+ if (failure == null) {
+ failure = e;
+ } else {
+ failure.addSuppressed(e);
+ }
+ }
+ }
+ } finally {
+ removalDeferral.draining = false;
+ removalDeferral.removals.clear();
+ removalDeferrals.remove();
+ }
+ if (failure instanceof RuntimeException) {
+ throw (RuntimeException) failure;
+ }
+ if (failure != null) {
+ throw (Error) failure;
+ }
+ }
+
+ private void completeRemoval(VersionedValue versioned, RemovalCause cause) {
+ if (cause.wasEvicted()) {
+ evictionCount.increment();
+ } else if (cause == RemovalCause.EXPLICIT) {
+ invalidateCount.increment();
+ }
+ K key = versioned.key;
+ if (beforeRemoval != null) {
+ try {
+ beforeRemoval.onRemoval(key, versioned.value, cause);
+ } catch (Throwable t) {
+ LOG.warn("Scoped metadata cache removal callback failed", t);
+ }
+ }
+ registry.unregister(versioned.address, versioned, versioned.scopeSnapshot);
+ versioned.keyNode.registration.compareAndSet(versioned, null);
+ tryPruneKey(key, versioned.keyNode);
+ }
+
+ private void notifyDiscarded(K key, V value) {
+ if (discardListener == null) {
+ return;
+ }
+ try {
+ discardListener.accept(key, value);
+ } catch (Throwable t) {
+ LOG.warn("Scoped metadata cache discard callback failed", t);
+ }
+ }
+
+ private static final class RemovalDeferral {
+ private final Deque> removals = new ArrayDeque<>();
+ private int depth;
+ private boolean draining;
+ }
+
+ private static final class DeferredRemoval {
+ private final VersionedValue versioned;
+ private final RemovalCause cause;
+
+ private DeferredRemoval(VersionedValue versioned, RemovalCause cause) {
+ this.versioned = versioned;
+ this.cause = cause;
+ }
+ }
+
+ private static final class InvalidatedKey {
+ private final KeyNode node;
+ private final KeyState keyState;
+
+ private InvalidatedKey(KeyNode node, KeyState keyState) {
+ this.node = node;
+ this.keyState = keyState;
+ }
+ }
+
+ private KeyState replaceKeyState(KeyNode node) {
+ while (true) {
+ KeyState oldState = node.current.get();
+ if (node.current.compareAndSet(oldState, new KeyState())) {
+ return oldState;
+ }
+ }
+ }
+
+ private void releaseKey(K key, KeyNode node) {
+ int remaining = node.activeLoads.decrementAndGet();
+ if (remaining < 0) {
+ throw new IllegalStateException("Cache key active-load count became negative");
+ }
+ tryPruneKey(key, node);
+ }
+
+ private void tryPruneKey(K key, KeyNode node) {
+ if (node.activeLoads.get() == 0 && node.registration.get() == null) {
+ keyNodes.remove(key, node);
+ }
+ }
+
+ private void checkOpen() {
+ if (closed.get()) {
+ throw new IllegalStateException("Scoped meta cache '" + name + "' is closed");
+ }
+ registry.checkOpen();
+ }
+
+ public static final class BulkLoadHandle implements AutoCloseable {
+ private final ScopedMetaCache, ?> owner;
+ private final ScopePath parentScope;
+ private final ScopeLease scopeLease;
+ private final PublicationState scopePublicationState;
+ private final BigInteger exactInvalidationSequence;
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private BulkLoadHandle(
+ ScopedMetaCache, ?> owner,
+ ScopePath parentScope,
+ ScopeLease scopeLease,
+ PublicationState scopePublicationState,
+ BigInteger exactInvalidationSequence) {
+ this.owner = owner;
+ this.parentScope = parentScope;
+ this.scopeLease = scopeLease;
+ this.scopePublicationState = scopePublicationState;
+ this.exactInvalidationSequence = exactInvalidationSequence;
+ }
+
+ private static BulkLoadHandle disabled(
+ ScopedMetaCache, ?> owner, ScopePath parentScope) {
+ return new BulkLoadHandle(owner, parentScope, null, null, BigInteger.ZERO);
+ }
+
+ private void checkOwner(ScopedMetaCache, ?> expectedOwner) {
+ if (owner != expectedOwner) {
+ throw new IllegalArgumentException("Bulk-load handle belongs to another cache");
+ }
+ if (closed.get()) {
+ throw new IllegalStateException("Bulk-load handle is closed");
+ }
+ }
+
+ private boolean tryCommit(
+ Object key, PublicationLease, ?> lease, VersionedValue, ?> staged) {
+ return owner.tryCommitBulk(this, key, lease, staged);
+ }
+
+ @Override
+ public void close() {
+ owner.closeBulkHandle(this);
+ }
+ }
+
+ public static final class CacheMetrics {
+ private final long physicalEntryCount;
+ private final int keyNodeCount;
+ private final int inFlightLoadCount;
+ private final int activeBulkHandleCount;
+ private final int exactInvalidationTombstoneCount;
+ private final boolean effectiveEnabled;
+ private final long requestCount;
+ private final long hitCount;
+ private final long missCount;
+ private final long loadSuccessCount;
+ private final long loadFailureCount;
+ private final long totalLoadTimeNanos;
+ private final long evictionCount;
+ private final long invalidateCount;
+ private final long lastLoadSuccessTimeMs;
+ private final long lastLoadFailureTimeMs;
+ private final String lastError;
+
+ private CacheMetrics(
+ long physicalEntryCount,
+ int keyNodeCount,
+ int inFlightLoadCount,
+ int activeBulkHandleCount,
+ int exactInvalidationTombstoneCount,
+ boolean effectiveEnabled,
+ long requestCount,
+ long hitCount,
+ long missCount,
+ long loadSuccessCount,
+ long loadFailureCount,
+ long totalLoadTimeNanos,
+ long evictionCount,
+ long invalidateCount,
+ long lastLoadSuccessTimeMs,
+ long lastLoadFailureTimeMs,
+ String lastError) {
+ this.physicalEntryCount = physicalEntryCount;
+ this.keyNodeCount = keyNodeCount;
+ this.inFlightLoadCount = inFlightLoadCount;
+ this.activeBulkHandleCount = activeBulkHandleCount;
+ this.exactInvalidationTombstoneCount = exactInvalidationTombstoneCount;
+ this.effectiveEnabled = effectiveEnabled;
+ this.requestCount = requestCount;
+ this.hitCount = hitCount;
+ this.missCount = missCount;
+ this.loadSuccessCount = loadSuccessCount;
+ this.loadFailureCount = loadFailureCount;
+ this.totalLoadTimeNanos = totalLoadTimeNanos;
+ this.evictionCount = evictionCount;
+ this.invalidateCount = invalidateCount;
+ this.lastLoadSuccessTimeMs = lastLoadSuccessTimeMs;
+ this.lastLoadFailureTimeMs = lastLoadFailureTimeMs;
+ this.lastError = lastError;
+ }
+
+ public long getPhysicalEntryCount() {
+ return physicalEntryCount;
+ }
+
+ public int getKeyNodeCount() {
+ return keyNodeCount;
+ }
+
+ public int getInFlightLoadCount() {
+ return inFlightLoadCount;
+ }
+
+ public int getActiveBulkHandleCount() {
+ return activeBulkHandleCount;
+ }
+
+ public int getExactInvalidationTombstoneCount() {
+ return exactInvalidationTombstoneCount;
+ }
+
+ public boolean isEffectiveEnabled() {
+ return effectiveEnabled;
+ }
+
+ public long getLoadSuccessCount() {
+ return loadSuccessCount;
+ }
+
+ public long getRequestCount() {
+ return requestCount;
+ }
+
+ public long getHitCount() {
+ return hitCount;
+ }
+
+ public long getMissCount() {
+ return missCount;
+ }
+
+ public long getLoadFailureCount() {
+ return loadFailureCount;
+ }
+
+ public long getTotalLoadTimeNanos() {
+ return totalLoadTimeNanos;
+ }
+
+ public long getEvictionCount() {
+ return evictionCount;
+ }
+
+ public long getInvalidateCount() {
+ return invalidateCount;
+ }
+
+ public long getLastLoadSuccessTimeMs() {
+ return lastLoadSuccessTimeMs;
+ }
+
+ public long getLastLoadFailureTimeMs() {
+ return lastLoadFailureTimeMs;
+ }
+
+ public String getLastError() {
+ return lastError;
+ }
+ }
+
+ private static final class PublicationLease implements AutoCloseable {
+ private final ScopedMetaCache owner;
+ private final K key;
+ private final ScopeLease scopeLease;
+ private final KeyNode keyNode;
+ private final KeyState keyState;
+ private final Object loadPublicationState;
+ private final PublicationState scopePublicationState;
+ private final AtomicBoolean released = new AtomicBoolean(false);
+
+ private PublicationLease(
+ ScopedMetaCache owner,
+ K key,
+ ScopeLease scopeLease,
+ KeyNode keyNode,
+ KeyState keyState,
+ Object loadPublicationState) {
+ this.owner = owner;
+ this.key = key;
+ this.scopeLease = scopeLease;
+ this.keyNode = keyNode;
+ this.keyState = keyState;
+ this.loadPublicationState = loadPublicationState;
+ this.scopePublicationState = scopeLease.publicationState();
+ }
+
+ private boolean isCurrent() {
+ return !owner.closed.get()
+ && scopeLease.isCurrent()
+ && owner.keyNodes.get(key) == keyNode
+ && keyNode.current.get() == keyState
+ && (loadPublicationState == null
+ || keyNode.loadPublicationState.get() == loadPublicationState);
+ }
+
+ @Override
+ public void close() {
+ if (released.compareAndSet(false, true)) {
+ owner.releaseKey(key, keyNode);
+ scopeLease.close();
+ }
+ }
+ }
+
+ private static final class VersionedValue {
+ private final K key;
+ private final V value;
+ private final CacheAddress address;
+ private final ScopeSnapshot scopeSnapshot;
+ private final KeyNode keyNode;
+ private final KeyState keyState;
+ private volatile long writeTimeNanos;
+
+ private VersionedValue(
+ K key,
+ V value,
+ CacheAddress address,
+ ScopeSnapshot scopeSnapshot,
+ KeyNode keyNode,
+ KeyState keyState,
+ long writeTimeNanos) {
+ this.key = key;
+ this.value = value;
+ this.address = address;
+ this.scopeSnapshot = scopeSnapshot;
+ this.keyNode = keyNode;
+ this.keyState = keyState;
+ this.writeTimeNanos = writeTimeNanos;
+ }
+
+ private boolean isCurrent(
+ ScopedMetaCacheRegistry registry,
+ Map> currentKeyNodes) {
+ return scopeSnapshot.isCurrent(registry)
+ && currentKeyNodes.get(key) == keyNode
+ && keyNode.current.get() == keyState
+ && keyNode.registration.get() == this;
+ }
+ }
+
+ private static final class KeyNode {
+ private final AtomicReference current = new AtomicReference<>(new KeyState());
+ private final AtomicReference loadPublicationState = new AtomicReference<>(new Object());
+ private final AtomicReference> registration = new AtomicReference<>();
+ private final AtomicInteger activeLoads = new AtomicInteger();
+ }
+
+ private static final class KeyState {
+ }
+
+ private static final class LoadAddress {
+ private final K key;
+ private final ScopePath path;
+ private final ScopeSnapshot scopeSnapshot;
+ private final KeyNode, ?> keyNode;
+ private final KeyState keyState;
+ private final Object loadPublicationState;
+ private final int hashCode;
+
+ private LoadAddress(K key, ScopePath path, PublicationLease lease) {
+ this.key = key;
+ this.path = path;
+ this.scopeSnapshot = lease.scopeLease.snapshot();
+ this.keyNode = lease.keyNode;
+ this.keyState = lease.keyState;
+ this.loadPublicationState = lease.loadPublicationState;
+ this.hashCode = 31 * key.hashCode() + scopeSnapshot.pathHashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof LoadAddress)) {
+ return false;
+ }
+ LoadAddress> other = (LoadAddress>) obj;
+ return key.equals(other.key)
+ && path.equals(other.path)
+ && scopeSnapshot.sameGeneration(other.scopeSnapshot)
+ && keyNode == other.keyNode
+ && keyState == other.keyState
+ && loadPublicationState == other.loadPublicationState;
+ }
+
+ @Override
+ public int hashCode() {
+ return hashCode;
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopedMetaCacheRegistry.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopedMetaCacheRegistry.java
new file mode 100644
index 00000000000000..d08fcdb97202ea
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ScopedMetaCacheRegistry.java
@@ -0,0 +1,958 @@
+// 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.doris.connector.cache;
+
+import com.github.benmanes.caffeine.cache.RemovalListener;
+import com.github.benmanes.caffeine.cache.Ticker;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.atomic.LongAdder;
+import java.util.function.BiConsumer;
+import java.util.function.BooleanSupplier;
+
+/**
+ * Catalog-local hierarchy shared by independent physical metadata caches.
+ *
+ * Logical invalidation replaces one state object. Values capture state identities and are rejected after a
+ * replacement even when physical Caffeine cleanup is delayed. Each state also owns the exact registrations that
+ * must be physically removed. Empty child nodes are pruned bottom-up so scope-directory memory remains bounded by
+ * live values and in-flight loads rather than by every name ever observed.
+ */
+public final class ScopedMetaCacheRegistry implements AutoCloseable {
+ private static final Runnable NO_OP = () -> {
+ };
+ private static final BiConsumer NO_OP_SCOPE = (level, key) -> {
+ };
+
+ private final ScopeNode root = new ScopeNode(null, null, ScopePath.Level.CATALOG);
+ private final Set> caches = ConcurrentHashMap.newKeySet();
+ private final StripedPhaseGate publicationGate = new StripedPhaseGate();
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+ private final LongAdder[] retainedActiveLoads = newCounters(ScopePath.Level.values().length);
+ private final BiConsumer afterParentPin;
+ private final BiConsumer beforePruneMark;
+ private final BiConsumer afterPruneMark;
+
+ public ScopedMetaCacheRegistry() {
+ this(NO_OP_SCOPE, NO_OP_SCOPE, NO_OP_SCOPE);
+ }
+
+ ScopedMetaCacheRegistry(
+ BiConsumer afterParentPin,
+ BiConsumer beforePruneMark,
+ BiConsumer afterPruneMark) {
+ this.afterParentPin = Objects.requireNonNull(afterParentPin, "afterParentPin can not be null");
+ this.beforePruneMark = Objects.requireNonNull(beforePruneMark, "beforePruneMark can not be null");
+ this.afterPruneMark = Objects.requireNonNull(afterPruneMark, "afterPruneMark can not be null");
+ }
+
+ public ScopedMetaCache createCache(String name, CacheSpec cacheSpec) {
+ return createCache(name, cacheSpec, null, null, NO_OP, NO_OP);
+ }
+
+ ScopedMetaCache createCacheWithRemovalListener(
+ String name, CacheSpec cacheSpec, RemovalListener removalListener,
+ Duration refreshAfterWrite, Executor refreshExecutor) {
+ return createCacheWithRemovalListener(name, cacheSpec, null, removalListener,
+ null, refreshAfterWrite, refreshExecutor, NO_OP, NO_OP, NO_OP);
+ }
+
+ ScopedMetaCache createCacheWithMetaRemovalListener(
+ String name, CacheSpec cacheSpec, MetaCacheRemovalListener removalListener,
+ BiConsumer discardListener,
+ Duration refreshAfterWrite, Executor refreshExecutor) {
+ RemovalListener caffeineListener = removalListener == null ? null
+ : (key, value, cause) -> removalListener.onRemoval(
+ key, value, MetaCacheRemovalReason.valueOf(cause.name()));
+ return createCacheWithRemovalListener(
+ name, cacheSpec, null, caffeineListener, discardListener,
+ refreshAfterWrite, refreshExecutor, NO_OP, NO_OP, NO_OP);
+ }
+
+ ScopedMetaCache createCache(
+ String name,
+ CacheSpec cacheSpec,
+ Ticker ticker,
+ BiConsumer beforeRemoval) {
+ return createCache(name, cacheSpec, ticker, beforeRemoval, NO_OP, NO_OP);
+ }
+
+ ScopedMetaCache createCache(
+ String name,
+ CacheSpec cacheSpec,
+ Ticker ticker,
+ BiConsumer beforeRemoval,
+ Runnable afterBulkStage) {
+ return createCache(name, cacheSpec, ticker, beforeRemoval, NO_OP, afterBulkStage);
+ }
+
+ ScopedMetaCache createCache(
+ String name,
+ CacheSpec cacheSpec,
+ Ticker ticker,
+ BiConsumer beforeRemoval,
+ Runnable afterLoadElection,
+ Runnable afterBulkStage) {
+ RemovalListener listener = beforeRemoval == null
+ ? null
+ : (key, value, cause) -> beforeRemoval.accept(key, value);
+ return createCacheWithRemovalListener(
+ name, cacheSpec, ticker, listener, null, null, null,
+ afterLoadElection, afterBulkStage, NO_OP);
+ }
+
+ ScopedMetaCache createCacheWithRefresh(
+ String name,
+ CacheSpec cacheSpec,
+ Duration refreshAfterWrite,
+ Executor refreshExecutor,
+ Runnable afterRefreshRegistration) {
+ return createCacheWithRemovalListener(
+ name, cacheSpec, null, null, null, refreshAfterWrite, refreshExecutor,
+ NO_OP, NO_OP, afterRefreshRegistration);
+ }
+
+ private ScopedMetaCache createCacheWithRemovalListener(
+ String name,
+ CacheSpec cacheSpec,
+ Ticker ticker,
+ RemovalListener removalListener,
+ BiConsumer discardListener,
+ Duration refreshAfterWrite,
+ Executor refreshExecutor,
+ Runnable afterLoadElection,
+ Runnable afterBulkStage,
+ Runnable afterRefreshRegistration) {
+ checkOpen();
+ ScopedMetaCache cache = new ScopedMetaCache<>(
+ this,
+ name,
+ cacheSpec,
+ ticker,
+ removalListener,
+ discardListener,
+ refreshAfterWrite,
+ refreshExecutor,
+ afterLoadElection,
+ afterBulkStage,
+ afterRefreshRegistration);
+ caches.add(cache);
+ if (closed.get()) {
+ caches.remove(cache);
+ cache.closeFromRegistry();
+ throw new IllegalStateException("Scoped meta cache registry is closed");
+ }
+ return cache;
+ }
+
+ public void invalidate(ScopePath path) {
+ invalidate(path, NO_OP);
+ }
+
+ void invalidate(List paths) {
+ invalidate(paths, NO_OP);
+ }
+
+ void invalidate(ScopePath path, Runnable afterStateReplacement) {
+ Objects.requireNonNull(path, "path can not be null");
+ Objects.requireNonNull(afterStateReplacement, "afterStateReplacement can not be null");
+ checkOpen();
+ InvalidatedScope invalidated = publicationGate.write(() -> {
+ List existing = resolveExisting(path);
+ bumpPublicationStates(existing);
+ if (existing.size() != path.level().ordinal() + 1) {
+ return null;
+ }
+ ScopeNode target = existing.get(existing.size() - 1);
+ return new InvalidatedScope(existing, replaceState(target));
+ });
+ if (invalidated == null) {
+ return;
+ }
+ afterStateReplacement.run();
+ cleanDetachedState(invalidated.oldState);
+ tryPrune(invalidated.existing);
+ }
+
+ void invalidate(List paths, Runnable afterStateReplacement) {
+ Objects.requireNonNull(paths, "paths can not be null");
+ Objects.requireNonNull(afterStateReplacement, "afterStateReplacement can not be null");
+ checkOpen();
+ List invalidated = publicationGate.write(() -> {
+ List> resolvedScopes = new ArrayList<>(paths.size());
+ Set targets = Collections.newSetFromMap(new IdentityHashMap<>());
+ Set publicationNodes = Collections.newSetFromMap(new IdentityHashMap<>());
+ for (ScopePath path : paths) {
+ Objects.requireNonNull(path, "path can not be null");
+ List existing = resolveExisting(path);
+ publicationNodes.addAll(existing);
+ if (existing.size() == path.level().ordinal() + 1) {
+ ScopeNode target = existing.get(existing.size() - 1);
+ if (targets.add(target)) {
+ resolvedScopes.add(existing);
+ }
+ }
+ }
+ bumpPublicationStates(new ArrayList<>(publicationNodes));
+ List result = new ArrayList<>(resolvedScopes.size());
+ resolvedScopes.forEach(existing -> result.add(new InvalidatedScope(
+ existing, replaceState(existing.get(existing.size() - 1)))));
+ return result;
+ });
+ afterStateReplacement.run();
+ invalidated.forEach(scope -> cleanDetachedState(scope.oldState));
+ invalidated.forEach(scope -> tryPrune(scope.existing));
+ }
+
+ public ScopeMetrics metrics() {
+ ScopeMetricsAccumulator accumulator = new ScopeMetricsAccumulator();
+ collectMetrics(root, accumulator);
+ for (ScopePath.Level level : ScopePath.Level.values()) {
+ accumulator.retainedActiveLoads[level.ordinal()] = retainedActiveLoads[level.ordinal()].intValue();
+ }
+ return accumulator.snapshot();
+ }
+
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ ScopeState oldState = publicationGate.write(() -> {
+ bumpPublicationStates(Collections.singletonList(root));
+ return replaceState(root);
+ });
+ cleanDetachedState(oldState);
+ List> snapshot = new ArrayList<>(caches);
+ caches.clear();
+ snapshot.forEach(ScopedMetaCache::closeFromRegistry);
+ }
+
+ ScopeLease acquire(ScopePath path) {
+ Objects.requireNonNull(path, "path can not be null");
+ while (true) {
+ checkOpen();
+ ScopeSnapshot snapshot = resolveOrCreateSnapshot(path);
+ if (snapshot == null || !retain(snapshot)) {
+ continue;
+ }
+ if (snapshot.isCurrent(this)) {
+ return new ScopeLease(this, snapshot);
+ }
+ release(snapshot);
+ }
+ }
+
+ void checkOpen() {
+ if (closed.get()) {
+ throw new IllegalStateException("Scoped meta cache registry is closed");
+ }
+ }
+
+ void removeCache(ScopedMetaCache, ?> cache) {
+ caches.remove(cache);
+ }
+
+ void register(CacheAddress address, Object versionedValue, ScopeSnapshot snapshot) {
+ snapshot.leafState().entries.put(address, versionedValue);
+ }
+
+ void unregister(CacheAddress address, Object versionedValue, ScopeSnapshot snapshot) {
+ snapshot.leafState().entries.remove(address, versionedValue);
+ tryPrune(snapshot);
+ }
+
+ private ScopeState replaceState(ScopeNode node) {
+ while (true) {
+ ScopeState oldState = node.current.get();
+ ScopeState newState = new ScopeState(oldState.generation + 1L);
+ if (node.current.compareAndSet(oldState, newState)) {
+ return oldState;
+ }
+ }
+ }
+
+ private ScopeSnapshot resolveOrCreateSnapshot(ScopePath path) {
+ ScopeNode catalogNode = root;
+ if (path.level() == ScopePath.Level.CATALOG) {
+ return ScopeSnapshot.capture(path, catalogNode, null, null, null);
+ }
+ ScopeState catalogState = catalogNode.current.get();
+ ScopeNode dbNode = child(catalogNode, catalogState, path.database(), ScopePath.Level.DATABASE);
+ if (dbNode == null) {
+ return null;
+ }
+ if (path.level() == ScopePath.Level.DATABASE) {
+ return ScopeSnapshot.capture(path, catalogNode, dbNode, null, null);
+ }
+ ScopeState dbState = dbNode.current.get();
+ ScopeNode tableNode = child(dbNode, dbState, path.table(), ScopePath.Level.TABLE);
+ if (tableNode == null) {
+ return null;
+ }
+ if (path.level() == ScopePath.Level.TABLE) {
+ return ScopeSnapshot.capture(path, catalogNode, dbNode, tableNode, null);
+ }
+ ScopeState tableState = tableNode.current.get();
+ ScopeNode partitionNode = child(
+ tableNode, tableState, path.partition(), ScopePath.Level.PARTITION);
+ if (partitionNode == null) {
+ return null;
+ }
+ return ScopeSnapshot.capture(path, catalogNode, dbNode, tableNode, partitionNode);
+ }
+
+ private List resolveExisting(ScopePath path) {
+ List nodes = new ArrayList<>(path.level().ordinal() + 1);
+ ScopeNode catalogNode = root;
+ nodes.add(catalogNode);
+ if (path.level() == ScopePath.Level.CATALOG) {
+ return nodes;
+ }
+ ScopeNode dbNode = catalogNode.current.get().children.get(path.database());
+ if (dbNode == null) {
+ return nodes;
+ }
+ nodes.add(dbNode);
+ if (path.level() == ScopePath.Level.DATABASE) {
+ return nodes;
+ }
+ ScopeNode tableNode = dbNode.current.get().children.get(path.table());
+ if (tableNode == null) {
+ return nodes;
+ }
+ nodes.add(tableNode);
+ if (path.level() == ScopePath.Level.TABLE) {
+ return nodes;
+ }
+ ScopeNode partitionNode = tableNode.current.get().children.get(path.partition());
+ if (partitionNode != null) {
+ nodes.add(partitionNode);
+ }
+ return nodes;
+ }
+
+ private ScopeNode child(
+ ScopeNode parent, ScopeState parentState, Object childKey, ScopePath.Level childLevel) {
+ ScopeNode existing = parentState.children.get(childKey);
+ if (existing != null) {
+ return existing;
+ }
+ if (!parent.tryRetain()) {
+ return null;
+ }
+ try {
+ if (parent.current.get() != parentState) {
+ return null;
+ }
+ afterParentPin.accept(childLevel, childKey);
+ existing = parentState.children.get(childKey);
+ if (existing != null) {
+ return existing;
+ }
+ ScopeNode created = new ScopeNode(parent, childKey, childLevel);
+ ScopeNode raced = parentState.children.putIfAbsent(childKey, created);
+ ScopeNode child = raced == null ? created : raced;
+ if (parent.current.get() != parentState) {
+ parentState.children.remove(childKey, child);
+ return null;
+ }
+ return child;
+ } finally {
+ parent.releaseRetain();
+ }
+ }
+
+ private void bumpPublicationStates(List nodes) {
+ nodes.forEach(node -> {
+ while (true) {
+ PublicationState oldState = node.descendantPublication.get();
+ synchronized (oldState) {
+ if (node.descendantPublication.compareAndSet(oldState, new PublicationState())) {
+ break;
+ }
+ }
+ }
+ });
+ }
+
+ private void cleanDetachedState(ScopeState state) {
+ state.entries.forEach((address, value) -> {
+ if (state.entries.remove(address, value)) {
+ address.removeExpected(value);
+ }
+ });
+ state.children.values().forEach(child -> cleanDetachedState(child.current.get()));
+ state.children.clear();
+ }
+
+ private boolean retain(ScopeSnapshot snapshot) {
+ if (!snapshot.leafNode().tryRetain()) {
+ return false;
+ }
+ retainedActiveLoads[ScopePath.Level.CATALOG.ordinal()].increment();
+ if (snapshot.databaseNode != null) {
+ retainedActiveLoads[ScopePath.Level.DATABASE.ordinal()].increment();
+ }
+ if (snapshot.tableNode != null) {
+ retainedActiveLoads[ScopePath.Level.TABLE.ordinal()].increment();
+ }
+ if (snapshot.partitionNode != null) {
+ retainedActiveLoads[ScopePath.Level.PARTITION.ordinal()].increment();
+ }
+ return true;
+ }
+
+ private void release(ScopeSnapshot snapshot) {
+ snapshot.leafNode().releaseRetain();
+ retainedActiveLoads[ScopePath.Level.CATALOG.ordinal()].decrement();
+ if (snapshot.databaseNode != null) {
+ retainedActiveLoads[ScopePath.Level.DATABASE.ordinal()].decrement();
+ }
+ if (snapshot.tableNode != null) {
+ retainedActiveLoads[ScopePath.Level.TABLE.ordinal()].decrement();
+ }
+ if (snapshot.partitionNode != null) {
+ retainedActiveLoads[ScopePath.Level.PARTITION.ordinal()].decrement();
+ }
+ tryPrune(snapshot);
+ }
+
+ private void tryPrune(List