Skip to content

Commit 709214d

Browse files
committed
feat(xds): Add filter state reference-counted resource management
- Introduce `SharedResourceManager` to manage reference-counted shared resources across xDS filters and resolvers. - Update `XdsNameResolver` to integrate with `SharedResourceManager` and support filter state retention. - Add `RefCountedRoute` and `RefCountedRouteInterceptor` to ensure resources are reliably retained during call interception and released across all RPC lifecycle termination paths (normal completion, cancellation, and exceptions). - Update `Filter` interfaces and existing implementations (`FaultFilter`, `GcpAuthenticationFilter`) to accommodate filter state sharing.
1 parent 71a10dc commit 709214d

10 files changed

Lines changed: 1675 additions & 61 deletions

xds/src/main/java/io/grpc/xds/ExternalProcessorFilter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ public ConfigOrError<ExternalProcessorFilterOverrideConfig> parseFilterConfigOve
128128
@Nullable
129129
@Override
130130
public ClientInterceptor buildClientInterceptor(FilterConfig filterConfig,
131-
@Nullable FilterConfig overrideConfig, ScheduledExecutorService scheduler) {
131+
@Nullable FilterConfig overrideConfig, ScheduledExecutorService scheduler,
132+
Filter.ResourceCleanupRegistry cleanupRegistry) {
132133
ExternalProcessorFilterConfig extProcFilterConfig =
133134
(ExternalProcessorFilterConfig) filterConfig;
134135
if (overrideConfig != null) {

xds/src/main/java/io/grpc/xds/FaultFilter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ private static FaultConfig.FractionalPercent parsePercent(FractionalPercent prot
199199
@Override
200200
public ClientInterceptor buildClientInterceptor(
201201
FilterConfig config, @Nullable FilterConfig overrideConfig,
202-
final ScheduledExecutorService scheduler) {
202+
final ScheduledExecutorService scheduler,
203+
Filter.ResourceCleanupRegistry cleanupRegistry) {
203204
checkNotNull(config, "config");
204205
if (overrideConfig != null) {
205206
config = overrideConfig;

xds/src/main/java/io/grpc/xds/Filter.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,21 @@ ConfigOrError<? extends FilterConfig> parseFilterConfigOverride(
109109
Message rawProtoMessage, FilterConfigParseContext context);
110110
}
111111

112-
/** Uses the FilterConfigs produced above to produce an HTTP filter interceptor for clients. */
112+
/**
113+
* Builds an HTTP filter interceptor for this route.
114+
*
115+
* <p>Filters that create stateful resources (e.g., shared channels) should register
116+
* cleanup tasks via {@code cleanupRegistry}. These tasks execute in the xDS
117+
* {@code SynchronizationContext} when the route's reference count reaches zero,
118+
* meaning no in-flight RPCs reference the route and the control plane has released it.
119+
*
120+
* @param cleanupRegistry registry for cleanup tasks; never null
121+
*/
113122
@Nullable
114123
default ClientInterceptor buildClientInterceptor(
115124
FilterConfig config, @Nullable FilterConfig overrideConfig,
116-
ScheduledExecutorService scheduler) {
125+
ScheduledExecutorService scheduler,
126+
ResourceCleanupRegistry cleanupRegistry) {
117127
return null;
118128
}
119129

@@ -206,4 +216,15 @@ public String toString() {
206216
.toString();
207217
}
208218
}
219+
220+
/**
221+
* Registry for cleanup tasks associated with a route's resource scope.
222+
*/
223+
@FunctionalInterface
224+
interface ResourceCleanupRegistry {
225+
/**
226+
* Registers a task to run when the route is no longer in use.
227+
*/
228+
void addCleanupTask(Runnable task);
229+
}
209230
}

xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ public ConfigOrError<GcpAuthenticationConfig> parseFilterConfigOverride(
132132
@Nullable
133133
@Override
134134
public ClientInterceptor buildClientInterceptor(FilterConfig config,
135-
@Nullable FilterConfig overrideConfig, ScheduledExecutorService scheduler) {
135+
@Nullable FilterConfig overrideConfig, ScheduledExecutorService scheduler,
136+
Filter.ResourceCleanupRegistry cleanupRegistry) {
136137

137138
ComputeEngineCredentials credentials = ComputeEngineCredentials.create();
138139
synchronized (callCredentialsCache) {
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
* Copyright 2026 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds;
18+
19+
import com.google.common.base.Preconditions;
20+
import io.grpc.Internal;
21+
import io.grpc.ManagedChannel;
22+
import java.util.concurrent.ConcurrentHashMap;
23+
import java.util.concurrent.ConcurrentMap;
24+
import java.util.concurrent.atomic.AtomicInteger;
25+
import java.util.function.Function;
26+
import javax.annotation.concurrent.ThreadSafe;
27+
28+
/**
29+
* Manages generic reference-counted shared resources for xDS filters.
30+
*
31+
* <p>Similar to {@code io.grpc.xds.internal.security.ReferenceCountingMap}, but provides
32+
* additional lifecycle management ({@link #close()}) and a simpler key-only
33+
* {@link #release(Object)} API designed for xDS filter state cleanup tasks.
34+
*
35+
* <h3>Requirements</h3>
36+
* <ul>
37+
* <li>{@link #acquire} and {@link #close} must be called from the same serialized context
38+
* (e.g., {@code SynchronizationContext}). They must never race each other.</li>
39+
* <li>{@link #release} may be called from any thread.</li>
40+
* <li>Managed resources ({@link ResourceCloseable#close}) must be thread-safe, as they may
41+
* be invoked from any thread when the last reference is released.</li>
42+
* <li>Keys are canonical resource identifiers: a given key always maps to the same logical
43+
* resource. The manager will never hold two distinct resources for the same key.</li>
44+
* </ul>
45+
*/
46+
@Internal
47+
public final class SharedResourceManager<K, V extends SharedResourceManager.ResourceCloseable> {
48+
49+
/**
50+
* An AutoCloseable resource that explicitly guarantees its close operation
51+
* will not throw checked exceptions.
52+
*/
53+
public interface ResourceCloseable extends AutoCloseable {
54+
@Override
55+
void close();
56+
}
57+
58+
/**
59+
* Adapts {@link ManagedChannel} to {@link ResourceCloseable} for management by
60+
* {@link SharedResourceManager}.
61+
*/
62+
public static final class ManagedChannelResource implements ResourceCloseable {
63+
private final ManagedChannel channel;
64+
65+
public ManagedChannelResource(ManagedChannel channel) {
66+
this.channel = Preconditions.checkNotNull(channel, "channel");
67+
}
68+
69+
@Override
70+
public void close() {
71+
channel.shutdown();
72+
}
73+
74+
public ManagedChannel getChannel() {
75+
return channel;
76+
}
77+
}
78+
79+
/**
80+
* An internal pure reference-counting container managing a stateful ResourceCloseable.
81+
*/
82+
@ThreadSafe
83+
static final class SharedResource<T extends ResourceCloseable> {
84+
private final T resource;
85+
private final AtomicInteger refCount = new AtomicInteger(1);
86+
87+
SharedResource(T resource) {
88+
this.resource = Preconditions.checkNotNull(resource, "resource");
89+
}
90+
91+
/**
92+
* Retains the resource. Returns false if the resource has hit 0 and is being closed.
93+
*/
94+
boolean retain() {
95+
int count;
96+
do {
97+
count = refCount.get();
98+
if (count == 0) {
99+
return false;
100+
}
101+
} while (!refCount.compareAndSet(count, count + 1));
102+
return true;
103+
}
104+
105+
/**
106+
* Decrements reference count. Closes underlying resource if count hits 0.
107+
* @return true if the count reached 0 and the resource was closed; false otherwise.
108+
*/
109+
boolean release() {
110+
int count;
111+
do {
112+
count = refCount.get();
113+
if (count <= 0) {
114+
throw new AssertionError("SharedResourceManager reference count is already 0");
115+
}
116+
} while (!refCount.compareAndSet(count, count - 1));
117+
if (count == 1) {
118+
resource.close();
119+
return true;
120+
}
121+
return false;
122+
}
123+
124+
T get() {
125+
return resource;
126+
}
127+
128+
int getRefCount() {
129+
return refCount.get();
130+
}
131+
}
132+
133+
private final ConcurrentMap<K, SharedResource<V>> resources = new ConcurrentHashMap<>();
134+
private final Function<K, V> resourceCreator;
135+
private boolean closed;
136+
137+
public SharedResourceManager(Function<K, V> resourceCreator) {
138+
this.resourceCreator = resourceCreator;
139+
}
140+
141+
/**
142+
* Acquires a resource for the given key, incrementing its reference count.
143+
*
144+
* <p>Must be called from the {@code SynchronizationContext}.
145+
*/
146+
public V acquire(K key) {
147+
Preconditions.checkState(!closed, "SharedResourceManager is closed");
148+
SharedResource<V> shared = resources.computeIfAbsent(key,
149+
k -> new SharedResource<>(resourceCreator.apply(k)));
150+
Preconditions.checkState(shared.retain(),
151+
"retain() failed; close() should not have run");
152+
return shared.get();
153+
}
154+
155+
/**
156+
* Releases a resource for the given key, decrementing its reference count.
157+
* Closes and evicts the resource if the reference count reaches 0.
158+
*
159+
* <p>Thread-safe: may be called from any thread. The underlying {@link SharedResource}
160+
* uses CAS-based reference counting, and eviction uses
161+
* {@link ConcurrentMap#remove(Object, Object)} for safe concurrent removal.
162+
*
163+
* <p>This API takes only a key (not the resource value) because keys are canonical
164+
* resource identifiers (see class-level requirements).
165+
*
166+
* @return true if the resource was closed; false otherwise.
167+
*/
168+
public boolean release(K key) {
169+
SharedResource<V> shared = resources.get(key);
170+
if (shared == null) {
171+
return false;
172+
}
173+
try {
174+
if (shared.release()) {
175+
return resources.remove(key, shared);
176+
}
177+
} catch (Throwable t) {
178+
resources.remove(key, shared);
179+
throw t;
180+
}
181+
return false;
182+
}
183+
184+
/**
185+
* Removes all entries from the cache and releases the manager's creation reference for each.
186+
*
187+
* <p>This performs a single {@code release()} per entry, decrementing the manager's own
188+
* reference count contribution (the initial refCount=1 from creation). If in-flight RPCs
189+
* still hold references, the underlying resource remains open until those references are
190+
* released. This avoids pulling resources out from under active operations.
191+
*
192+
* <p>Must be called from the {@code SynchronizationContext}.
193+
*/
194+
public void close() {
195+
closed = true;
196+
for (K key : resources.keySet()) {
197+
SharedResource<V> shared = resources.remove(key);
198+
if (shared != null) {
199+
try {
200+
shared.release();
201+
} catch (Throwable t) {
202+
// Ignore exceptions during final close-all to ensure we try to close other resources
203+
}
204+
}
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)