Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b50ca84
core,api,xds: Implement load balancing policy delay plumbing
AgraVator May 13, 2026
c38ce1d
fix: tests
AgraVator May 14, 2026
a992bdf
fix: minor changes
AgraVator May 19, 2026
6a55ff2
add missing endDelay()
AgraVator Jun 8, 2026
389b96f
core,api,rls,util,xds: Implement dual Load Balancer delay APIs and ca…
AgraVator Jun 19, 2026
5e56f38
core: Add 100% test coverage for dual LB delay APIs and cadence rules
AgraVator Jun 19, 2026
6a3572b
opentelemetry: Implement dual Load Balancer delay spans and metrics
AgraVator Jun 22, 2026
9a4f21f
Merge remote-tracking branch 'upstream/master' into lb-policy-delay
AgraVator Jun 22, 2026
d25d064
Implement Name Resolution and unified RPC Delay Observability specifi…
AgraVator Jun 23, 2026
ffb485e
Ensure thread-safety and unit test coverage for Call-Level Delay APIs…
AgraVator Jul 6, 2026
42352f2
Add comprehensive End-to-End tests for Call-Level Name Resolution Del…
AgraVator Jul 6, 2026
a76996c
Ensure Call-Level delay recording only triggers when RPCs are queued …
AgraVator Jul 6, 2026
7c94743
Fix CdsLoadBalancer2Test atLeastOnce static import and assertions on …
AgraVator Jul 6, 2026
a7830ff
Fix PriorityLoadBalancerTest handleNameResolutionError assertion on n…
AgraVator Jul 6, 2026
b482c43
Fix checkstyle import ordering in OpenTelemetryTracingModuleTest
AgraVator Jul 6, 2026
fbbc1b9
Merge remote-tracking branch 'upstream/master' into name-resolution-d…
AgraVator Jul 27, 2026
df48bee
core, opentelemetry: Harden Name Resolution & LB delay state machines…
AgraVator Jul 27, 2026
fc9721d
api: update @since 1.82.0 to 1.84.0
AgraVator Jul 27, 2026
162caef
test: remove temporary stress test files prior to PR submission
AgraVator Jul 27, 2026
f3bdcd9
core: align PendingStream synchronized (this) blocks with ManagedChan…
AgraVator Jul 27, 2026
9cc310b
opentelemetry: add targeted unit tests to expand branch coverage for …
AgraVator Jul 27, 2026
0a7eb26
core: harden PendingStream synchronization and add multithreaded race…
AgraVator Jul 28, 2026
0bc1634
opentelemetry: add end-to-end Client/Server simulation tests for dela…
AgraVator Jul 29, 2026
20fc8d0
Fix PR #12893 CI failures: revert PickResult.withError default delay,…
AgraVator Jul 29, 2026
a7398d7
cleanup: remove extra stress and unit tests, keeping only essential C…
AgraVator Jul 29, 2026
a9ea9e7
test: restore unit tests from master and add test coverage for delay …
AgraVator Jul 29, 2026
35547a8
test: restore pickResult_withSubchannelReplacement and pickResult_wit…
AgraVator Jul 29, 2026
6571c2d
test: remove multi-threaded stress tests from DelayedClientTransportT…
AgraVator Jul 29, 2026
b7227a7
test: remove unused imports from ManagedChannelImplTest and DelayedCl…
AgraVator Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions api/src/main/java/io/grpc/ClientStreamTracer.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public void createPendingStream() {
*
* @param delayType canonical low-cardinality label categorizing the delay (e.g., "connecting")
* @param delayReason high-cardinality diagnostic string describing granular runtime conditions
* @since 1.82.0
* @since 1.84.0
*/
public void recordAttemptDelayStart(String delayType, String delayReason) {
}
Expand All @@ -80,7 +80,7 @@ public void recordAttemptDelayStart(String delayType, String delayReason) {
* on the active delay span without recreating the span or resetting cumulative timers.
*
* @param delayReason updated high-cardinality diagnostic string describing new conditions
* @since 1.82.0
* @since 1.84.0
*/
public void recordAttemptDelayReasonChanged(String delayReason) {
}
Expand All @@ -91,7 +91,7 @@ public void recordAttemptDelayReasonChanged(String delayReason) {
* <p>Implementations should simultaneously close active child tracing spans and record elapsed
* duration to the {@code grpc.client.attempt.delay.duration} histogram.
*
* @since 1.82.0
* @since 1.84.0
*/
public void recordAttemptDelayEnd() {
}
Expand Down Expand Up @@ -156,6 +156,44 @@ public abstract static class Factory {
public ClientStreamTracer newClientStreamTracer(StreamInfo info, Metadata headers) {
throw new UnsupportedOperationException("Not implemented");
}

/**
* Called when a call-level delay segment (such as waiting for name resolution or service
* configuration parsing) starts before any individual RPC attempt is created.
*
* <p>Implementations should start logical timers and create child tracing spans (named strictly
* {@code "Call Delay"}) carrying the canonical {@code grpc.delay_type} attribute.
*
* @param delayType canonical low-cardinality label categorizing the delay (e.g., "resolving")
* @param delayReason high-cardinality diagnostic string describing granular runtime conditions
* @since 1.84.0
*/
public void recordCallDelayStart(String delayType, String delayReason) {
}

/**
* Called when a call-level delay reason changes while the active delay segment continues.
*
* <p>Implementations should emit structured events (such as {@code "Delay state transition"})
* on the active call delay span without recreating the span or resetting timers.
*
* @param delayReason updated high-cardinality diagnostic string describing new conditions
* @since 1.84.0
*/
public void recordCallDelayReasonChanged(String delayReason) {
}

/**
* Called when a call-level delay segment ends upon successful name resolution or when an RPC
* is cancelled before resolution completes.
*
* <p>Implementations should close active call delay spans and record elapsed duration to the
* {@code grpc.client.call.delay.duration} histogram.
*
* @since 1.84.0
*/
public void recordCallDelayEnd() {
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion api/src/main/java/io/grpc/LoadBalancer.java
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ public static PickResult withNoResult() {
*
* @param delayType low-cardinality root cause label (e.g., "connecting")
* @param delayReason high-cardinality diagnostic string for trace events
* @since 1.82.0
* @since 1.84.0
*/
public static PickResult withNoResult(String delayType, String delayReason) {
Preconditions.checkNotNull(delayType, "delayType");
Expand Down
13 changes: 13 additions & 0 deletions api/src/test/java/io/grpc/ClientStreamTracerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,17 @@ public void streamInfo_toBuilder() {
StreamInfo info2 = info1.toBuilder().build();
assertThat(info2.getCallOptions()).isSameInstanceAs(callOptions);
}

@Test
public void defaultDelayMethodsNoOp() {
ClientStreamTracer tracer = new ClientStreamTracer() {};
tracer.recordAttemptDelayStart("connecting", "test");
tracer.recordAttemptDelayReasonChanged("test2");
tracer.recordAttemptDelayEnd();

ClientStreamTracer.Factory factory = new ClientStreamTracer.Factory() {};
factory.recordCallDelayStart("resolving", "test");
factory.recordCallDelayReasonChanged("test2");
factory.recordCallDelayEnd();
}
}
72 changes: 50 additions & 22 deletions core/src/main/java/io/grpc/internal/DelayedClientTransport.java
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ private class PendingStream extends DelayedStream {
@Nullable private String activeDelayType;
@GuardedBy("this")
@Nullable private String activeDelayReason;
@GuardedBy("this")
private boolean delayEnded;

private PendingStream(PickSubchannelArgs args, ClientStreamTracer[] tracers,
@Nullable String initialType, @Nullable String initialReason) {
Expand All @@ -427,50 +429,75 @@ private PendingStream(PickSubchannelArgs args, ClientStreamTracer[] tracers,
* spans are ended and a new segment is initiated. If only {@code newReason} changes, a
* structured transition event is appended to the active span without span re-creation.
*/
synchronized void updateDelay(@Nullable String newType, @Nullable String newReason) {
if (getRealStream() != null) {
return;
}
if (!Objects.equals(activeDelayType, newType)) {
// Delay type changed (e.g., from RLS lookup to connecting). End the previous delay.
if (activeDelayType != null) {
void updateDelay(@Nullable String newType, @Nullable String newReason) {
synchronized (this) {
if (getRealStream() != null || delayEnded) {
return;
}
String prevTypeToClose = null;
String newTypeToStart = null;
String newReasonToStart = null;
String newReasonToNotify = null;

if (!Objects.equals(activeDelayType, newType)) {
if (activeDelayType != null) {
prevTypeToClose = activeDelayType;
}
activeDelayType = newType;
activeDelayReason = null;
if (newType != null) {
newTypeToStart = newType;
newReasonToStart = newReason != null ? newReason : "";
}
}
if (newType != null && newReason != null
&& !Objects.equals(activeDelayReason, newReason)) {
activeDelayReason = newReason;
newReasonToNotify = newReason;
}

if (prevTypeToClose != null) {
for (ClientStreamTracer tracer : tracers) {
tracer.recordAttemptDelayEnd();
}
}
activeDelayType = newType;
activeDelayReason = null;
if (newType != null) {
if (newTypeToStart != null) {
for (ClientStreamTracer tracer : tracers) {
tracer.recordAttemptDelayStart(newType, newReason != null ? newReason : "");
tracer.recordAttemptDelayStart(newTypeToStart, newReasonToStart);
}
}
}
if (newType != null && newReason != null && !Objects.equals(activeDelayReason, newReason)) {
// Delay type is unchanged, but the reason changed (e.g., priority failover).
activeDelayReason = newReason;
for (ClientStreamTracer tracer : tracers) {
tracer.recordAttemptDelayReasonChanged(newReason);
if (newReasonToNotify != null) {
for (ClientStreamTracer tracer : tracers) {
tracer.recordAttemptDelayReasonChanged(newReasonToNotify);
}
}
}
}

/**
* Ends active attempt delay segment telemetry upon stream creation or stream cancellation.
*/
synchronized void endDelay() {
if (activeDelayType != null) {
for (ClientStreamTracer tracer : tracers) {
tracer.recordAttemptDelayEnd();
void endDelay() {
synchronized (this) {
if (delayEnded) {
return;
}
delayEnded = true;
boolean shouldEnd = activeDelayType != null;
activeDelayType = null;
activeDelayReason = null;
if (shouldEnd) {
for (ClientStreamTracer tracer : tracers) {
tracer.recordAttemptDelayEnd();
}
}
}
}

Runnable setStreamAndEndDelay(ClientStream stream) {
Runnable runnable = setStream(stream);
endDelay();
return setStream(stream);
return runnable;
}

/** Runnable may be null. */
Expand All @@ -496,6 +523,7 @@ private Runnable createRealStream(ClientTransport transport, String authorityOve

@Override
public void cancel(Status reason) {
endDelay();
super.cancel(reason);
synchronized (lock) {
if (reportTransportTerminated != null) {
Expand Down
70 changes: 67 additions & 3 deletions core/src/main/java/io/grpc/internal/ManagedChannelImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,7 @@ public void run() {
inUseStateAggregator.updateObjectInUse(pendingCallsInUseObject, true);
}
pendingCalls.add(pendingCall);
pendingCall.notifyQueuedForNameResolution();
} else {
pendingCall.reprocess();
}
Expand Down Expand Up @@ -997,6 +998,10 @@ private final class PendingCall<ReqT, RespT> extends DelayedClientCall<ReqT, Res
final MethodDescriptor<ReqT, RespT> method;
final CallOptions callOptions;
private final long callCreationTime;
@GuardedBy("this") private boolean queuedForResolution;
@GuardedBy("this") private boolean callCancelled;
@GuardedBy("this") private boolean delayEnded;
@GuardedBy("this") private boolean callDelayStarted;

PendingCall(Context context, MethodDescriptor<ReqT, RespT> method, CallOptions callOptions) {
super(
Expand All @@ -1010,14 +1015,69 @@ private final class PendingCall<ReqT, RespT> extends DelayedClientCall<ReqT, Res
this.callCreationTime = ticker.nanoTime();
}

void notifyQueuedForNameResolution() {
boolean shouldStart = false;
synchronized (this) {
if (!callCancelled && !delayEnded && !queuedForResolution) {
queuedForResolution = true;
shouldStart = true;
}
}
if (shouldStart) {
for (ClientStreamTracer.Factory factory : callOptions.getStreamTracerFactories()) {
factory.recordCallDelayStart(
"resolving", "waiting for name resolution or service config");
}
boolean shouldEndNow = false;
synchronized (this) {
callDelayStarted = true;
if (!delayEnded && callCancelled) {
delayEnded = true;
shouldEndNow = true;
}
}
if (shouldEndNow) {
for (ClientStreamTracer.Factory factory : callOptions.getStreamTracerFactories()) {
factory.recordCallDelayEnd();
}
}
}
}

private void endDelayIfNeeded() {
boolean shouldEnd = false;
synchronized (this) {
if (!queuedForResolution || delayEnded) {
return;
}
if (callDelayStarted) {
delayEnded = true;
shouldEnd = true;
}
}
if (shouldEnd) {
for (ClientStreamTracer.Factory factory : callOptions.getStreamTracerFactories()) {
factory.recordCallDelayEnd();
}
}
}

/** Called when it's ready to create a real call and reprocess the pending call. */
void reprocess() {
endDelayIfNeeded();
ClientCall<ReqT, RespT> realCall;
Context previous = context.attach();
try {
CallOptions delayResolutionOption = callOptions.withOption(NAME_RESOLUTION_DELAYED,
ticker.nanoTime() - callCreationTime);
realCall = newClientCall(method, delayResolutionOption);
CallOptions effectiveOptions = callOptions;
boolean wasQueued;
synchronized (this) {
wasQueued = queuedForResolution;
}
if (wasQueued) {
effectiveOptions = callOptions.withOption(NAME_RESOLUTION_DELAYED,
ticker.nanoTime() - callCreationTime);
}
realCall = newClientCall(method, effectiveOptions);
} finally {
context.detach(previous);
}
Expand All @@ -1037,6 +1097,10 @@ public void run() {

@Override
protected void callCancelled() {
synchronized (this) {
callCancelled = true;
}
endDelayIfNeeded();
super.callCancelled();
syncContext.execute(new PendingCallRemoval());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ public void allMethodsForwarded() throws Exception {
Collections.<Method>emptyList());
}

@Test
public void attemptDelayMethodsForwarded() {
TestClientStreamTracer tracer = new TestClientStreamTracer();
tracer.recordAttemptDelayStart("connecting", "test");
org.mockito.Mockito.verify(mockDelegate).recordAttemptDelayStart("connecting", "test");

tracer.recordAttemptDelayReasonChanged("test2");
org.mockito.Mockito.verify(mockDelegate).recordAttemptDelayReasonChanged("test2");

tracer.recordAttemptDelayEnd();
org.mockito.Mockito.verify(mockDelegate).recordAttemptDelayEnd();
}

private final class TestClientStreamTracer extends ForwardingClientStreamTracer {
@Override
protected ClientStreamTracer delegate() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ && isMetricEnabled("grpc.client.attempt.delay.duration", enableMetrics, disableD
.build());
}

if (isDelayObservabilityEnabled()
&& isMetricEnabled("grpc.client.call.delay.duration", enableMetrics, disableDefault)) {
builder.clientCallDelayCounter(
meter.histogramBuilder(
"grpc.client.call.delay.duration")
.setUnit("s")
.setDescription("Time taken before a client call starts")
.setExplicitBucketBoundariesAdvice(LATENCY_BUCKETS)
.build());
}
if (isMetricEnabled("grpc.client.attempt.sent_total_compressed_message_size", enableMetrics,
disableDefault)) {
builder.clientTotalSentCompressedMessageSizeCounter(
Expand Down Expand Up @@ -364,7 +374,7 @@ && isMetricEnabled("grpc.client.attempt.delay.duration", enableMetrics, disableD
* Checks whether experimental client attempt and call delay observability is globally enabled.
*
* <p>Guarded strictly by the {@code GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY} environment
* variable (defaults to {@code false}). When disabled, delay spans and
* variable or JVM system property (defaults to {@code false}). When disabled, delay spans and
* duration histograms are suppressed to avoid runtime overhead.
*/
static boolean isDelayObservabilityEnabled() {
Expand Down
Loading
Loading