Skip to content

Commit 65a2679

Browse files
SophieGuo410Sophie GuoCopilot
authored
Split internalServerErrorCount by wire-visibility (#3297)
* Split internalServerErrorCount by wire-visibility nettyMetrics.internalServerErrorCount previously incremented unconditionally whenever a generic (non-RestServiceException, non-client-termination) exception was handled in NettyResponseChannel#getErrorResponse, even when the constructed 500 response could never actually be written to the client because response metadata (e.g. a 200) had already been committed for a streamed response. This conflated two different failure modes under one counter/alert: - a 500 that actually reached the client on the wire - a post-commit failure where the client instead sees a force-closed connection after a partially delivered 200 Add internalServerErrorAfterResponseCommittedCount to track the second case separately, so internalServerErrorCount now reflects only errors that were actually written to the wire. The sum of the two new counters equals what internalServerErrorCount counted before this change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Revert metric split; track offline-service 500s after response commit instead Replace the previous internalServerErrorCount / internalServerErrorAfterResponseCommittedCount split with the original, simpler behavior: internalServerErrorCount is incremented unconditionally for generic internal errors again, exactly as before this PR, so no existing alerting/dashboards keyed on it need to change. A 500 that could not be delivered to the client because response metadata (e.g. a 200) was already committed for a streamed GET is still a case worth tracking - but only distinctly for known offline (e.g. composite router secondary/parity-check) callers, since those already-committed-response drops for that traffic are expected and otherwise indistinguishable from a genuine client-facing incident. Add a new netty.server.offline.service.ids config listing the x-ambry-service-id values (as configured for those callers, e.g. in the composite router config) that identify offline traffic. When a request from one of those service IDs hits the already-committed-500 case, increment a new, additive NettyMetrics#offlineInternalServerErrorOnlyCount counter (OfflineInternalServerErrorOnlyCount.Count.rrd) instead of introducing a second alerting metric. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix TOCTOU race in offline-metric detection Read responseMetadataWriteInitiated after the CAS attempt in maybeSendErrorResponse(), not before. A concurrent writer (e.g. a router content-write callback) can commit response metadata on another thread between the earlier snapshot and our own CAS attempt, which could cause the offline metric to be silently skipped even though a response was already committed to the client. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add offline-only 503 (ServiceUnavailable) tracking, mirroring the 500 case Adds offlineServiceUnavailableOnlyCount, incremented under the same conditions as offlineInternalServerErrorOnlyCount but for a genuine ServiceUnavailable (503) that never reached the wire because response metadata was already committed, for a request from a configured offline service ID. Host-level-throttled drops are excluded, matching how the existing serviceUnavailableErrorCount already excludes them from polluting SLO dashboards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add offlineHostLevelThrottledOnlyCount for offline host-level-throttled drops Mirrors offlineInternalServerErrorOnlyCount/offlineServiceUnavailableOnlyCount: tracks host-level-throttled 503 drops that never reached the wire because response metadata was already committed, for a request from a configured offline service ID. Kept as its own counter, matching how hostLevelThrottledCount is already tracked separately from serviceUnavailableErrorCount. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Trim and drop empty entries when parsing netty.server.offline.service.ids Previously an unconfigured (or trailing-comma) value produced a set containing a single empty string instead of a truly empty set, so the isEmpty() fast-path in isOfflineServiceRequest() was never hit by default. A stray space after a comma also produced an id that could never match via exact Set.contains(...). Trim each entry and drop empties so both cases are handled correctly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Sophie Guo <sopguo@sopguo-mn2.linkedin.biz> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent cbb372d commit 65a2679

5 files changed

Lines changed: 344 additions & 0 deletions

File tree

‎ambry-api/src/main/java/com/github/ambry/config/NettyConfig.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
package com.github.ambry.config;
1515

1616
import com.github.ambry.rest.RestRequestService;
17+
import com.github.ambry.rest.RestUtils;
1718
import java.util.Arrays;
1819
import java.util.HashSet;
1920
import java.util.Set;
21+
import java.util.stream.Collectors;
2022

2123

2224
/**
@@ -41,6 +43,7 @@ public class NettyConfig {
4143
public static final String NETTY_METRICS_STOP_WAIT_TIMEOUT_SECONDS = "netty.metrics.stop.wait.timeout.seconds";
4244
public static final String NETTY_SERVER_CLOSE_DELAY_TIMEOUT_MS = "netty.server.close.delay.timeout.ms";
4345
public static final String NETTY_ENABLE_ONE_HUNDRED_CONTINUE = "netty.enable.one.hundred.continue";
46+
public static final String NETTY_SERVER_OFFLINE_SERVICE_IDS = "netty.server.offline.service.ids";
4447

4548
/**
4649
* Number of netty boss threads.
@@ -174,6 +177,17 @@ public class NettyConfig {
174177
@Default("false")
175178
public final boolean nettyEnableOneHundredContinue;
176179

180+
/**
181+
* A comma separated list of {@link com.github.ambry.rest.RestUtils.Headers#SERVICE_ID} values that identify known
182+
* offline (e.g. composite router secondary/parity-check) callers, as configured for those callers elsewhere (e.g.
183+
* the composite router config). Used to attribute 500s that never reach the wire (because response metadata was
184+
* already committed) to a dedicated offline-only metric instead of dropping that visibility entirely. Ids are
185+
* trimmed of surrounding whitespace and empty entries are dropped, so surrounding spaces after a comma are fine.
186+
*/
187+
@Config(NETTY_SERVER_OFFLINE_SERVICE_IDS)
188+
@Default("")
189+
public final Set<String> nettyServerOfflineServiceIds;
190+
177191
public NettyConfig(VerifiableProperties verifiableProperties) {
178192
nettyServerBossThreadCount = verifiableProperties.getInt(NETTY_SERVER_BOSS_THREAD_COUNT, 1);
179193
nettyServerIdleTimeSeconds = verifiableProperties.getInt(NETTY_SERVER_IDLE_TIME_SECONDS, 60);
@@ -190,6 +204,14 @@ public NettyConfig(VerifiableProperties verifiableProperties) {
190204
Integer.MAX_VALUE);
191205
nettyServerDenyListedQueryParams = new HashSet<>(
192206
Arrays.asList(verifiableProperties.getString(NETTY_SERVER_DENY_LISTED_QUERY_PARAMS, "").split(",")));
207+
// trim whitespace around each id and drop empty entries so an unconfigured (or trailing-comma) value
208+
// yields a truly empty set rather than a set containing "" or " "-padded ids that can never match a
209+
// real service id via isOfflineServiceRequest()'s exact Set.contains(...) check.
210+
nettyServerOfflineServiceIds = Arrays.stream(verifiableProperties.getString(NETTY_SERVER_OFFLINE_SERVICE_IDS, "")
211+
.split(","))
212+
.map(String::trim)
213+
.filter(id -> !id.isEmpty())
214+
.collect(Collectors.toSet());
193215
nettyMultipartPostMaxSizeBytes =
194216
verifiableProperties.getLongInRange(NETTY_MULTIPART_POST_MAX_SIZE_BYTES, 20 * 1024 * 1024, 0, Long.MAX_VALUE);
195217
nettyServerSslFactory = verifiableProperties.getString(SSL_FACTORY_KEY, "");
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2026 LinkedIn Corp. All rights reserved.
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+
*/
14+
package com.github.ambry.config;
15+
16+
import java.util.Arrays;
17+
import java.util.Collections;
18+
import java.util.HashSet;
19+
import java.util.Properties;
20+
import org.junit.Assert;
21+
import org.junit.Test;
22+
23+
24+
/**
25+
* Tests for {@link NettyConfig}.
26+
*/
27+
public class NettyConfigTest {
28+
29+
@Test
30+
public void testOfflineServiceIdsDefaultIsEmpty() {
31+
NettyConfig config = new NettyConfig(new VerifiableProperties(new Properties()));
32+
Assert.assertEquals("Unconfigured netty.server.offline.service.ids should yield an empty set, not {\"\"}",
33+
Collections.emptySet(), config.nettyServerOfflineServiceIds);
34+
}
35+
36+
@Test
37+
public void testOfflineServiceIdsTrimsWhitespaceAndDropsEmptyEntries() {
38+
Properties properties = new Properties();
39+
properties.setProperty(NettyConfig.NETTY_SERVER_OFFLINE_SERVICE_IDS, " service-a, service-b ,,service-c");
40+
NettyConfig config = new NettyConfig(new VerifiableProperties(properties));
41+
Assert.assertEquals("Ids should be trimmed and empty entries dropped",
42+
new HashSet<>(Arrays.asList("service-a", "service-b", "service-c")), config.nettyServerOfflineServiceIds);
43+
}
44+
}

‎ambry-rest/src/main/java/com/github/ambry/rest/NettyMetrics.java‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,20 @@ public class NettyMetrics {
131131
public final Counter unauthorizedCount;
132132
public final Counter goneCount;
133133
public final Counter internalServerErrorCount;
134+
// incremented when a generic internal-error 500 could not be delivered to the client because response metadata
135+
// (e.g. a 200) had already been committed for a streamed GET, and the request's service ID identifies it as a
136+
// known offline (e.g. composite router secondary/parity-check) caller. internalServerErrorCount is still
137+
// incremented unconditionally for these, same as before; this is purely additive visibility.
138+
public final Counter offlineInternalServerErrorOnlyCount;
139+
// same as offlineInternalServerErrorOnlyCount above, but for a 503 that could not be delivered to the client.
140+
// excludes host-level-throttled drops, matching how serviceUnavailableErrorCount itself excludes them.
141+
public final Counter offlineServiceUnavailableOnlyCount;
134142
public final Counter serviceUnavailableErrorCount;
135143
public final Counter hostLevelThrottledCount;
144+
// same as offlineServiceUnavailableOnlyCount above, but specifically for host-level-throttled drops that could
145+
// not be delivered to the client, matching how hostLevelThrottledCount itself is tracked separately from
146+
// serviceUnavailableErrorCount.
147+
public final Counter offlineHostLevelThrottledOnlyCount;
136148
public final Counter insufficientCapacityErrorCount;
137149
public final Counter preconditionFailedErrorCount;
138150
public final Counter methodNotAllowedErrorCount;
@@ -325,10 +337,16 @@ public NettyMetrics(MetricRegistry metricRegistry) {
325337
goneCount = metricRegistry.counter(MetricRegistry.name(NettyResponseChannel.class, "GoneCount"));
326338
internalServerErrorCount =
327339
metricRegistry.counter(MetricRegistry.name(NettyResponseChannel.class, "InternalServerErrorCount"));
340+
offlineInternalServerErrorOnlyCount = metricRegistry.counter(
341+
MetricRegistry.name(NettyResponseChannel.class, "OfflineInternalServerErrorOnlyCount"));
342+
offlineServiceUnavailableOnlyCount = metricRegistry.counter(
343+
MetricRegistry.name(NettyResponseChannel.class, "OfflineServiceUnavailableOnlyCount"));
328344
serviceUnavailableErrorCount =
329345
metricRegistry.counter(MetricRegistry.name(NettyResponseChannel.class, "ServiceUnavailableErrorCount"));
330346
hostLevelThrottledCount =
331347
metricRegistry.counter(MetricRegistry.name(NettyResponseChannel.class, "HostLevelThrottledCount"));
348+
offlineHostLevelThrottledOnlyCount = metricRegistry.counter(
349+
MetricRegistry.name(NettyResponseChannel.class, "OfflineHostLevelThrottledOnlyCount"));
332350
insufficientCapacityErrorCount =
333351
metricRegistry.counter(MetricRegistry.name(NettyResponseChannel.class, "InsufficientCapacityErrorCount"));
334352
preconditionFailedErrorCount =

‎ambry-rest/src/main/java/com/github/ambry/rest/NettyResponseChannel.java‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ class NettyResponseChannel implements RestResponseChannel {
116116
// temp variable to hold the error response status which will be overwritten on responseStatus if the error response
117117
// was successfully sent
118118
private ResponseStatus errorResponseStatus = null;
119+
// set alongside errorResponseStatus in getErrorResponse() when the 503 being built is a host-level-throttled
120+
// drop rather than a genuine service-unavailable error, so callers can exclude it the same way
121+
// serviceUnavailableErrorCount already does.
122+
private boolean errorResponseIsHostLevelThrottled = false;
119123

120124
/**
121125
* A {@link ChannelFutureListener} that closes the {@link Channel} which is
@@ -593,10 +597,48 @@ private boolean maybeSendErrorResponse(Exception exception) {
593597
nettyMetrics.errorResponseProcessingTimeInMs.update(processingTime);
594598
} else {
595599
logger.debug("Could not send error response on channel {}", ctx.channel());
600+
// Check the flag *after* the write attempt above, not before: response metadata can be committed
601+
// concurrently by a writer on another thread (e.g. a router content-write callback), so reading the flag
602+
// before the CAS attempt above is racy and can miss an already-committed response that was in fact
603+
// committed by that other writer just before/around our own CAS attempt failed. Reading it here is safe
604+
// because by the time maybeWriteResponseMetadata() returns, the flag is guaranteed to be true if *any*
605+
// writer (this one or a concurrent one) has ever successfully committed metadata.
606+
if (responseMetadataWriteInitiated.get() && isOfflineServiceRequest()) {
607+
if (errorResponseStatus == ResponseStatus.InternalServerError) {
608+
// response metadata (e.g. a 200) was already committed to the client before this failure occurred, so the
609+
// 500 constructed above never reached the wire. internalServerErrorCount was still incremented above,
610+
// unconditionally, exactly as before this change. This is purely additive visibility into how often known
611+
// offline (e.g. composite router secondary/parity-check) callers hit this already-committed-response case.
612+
nettyMetrics.offlineInternalServerErrorOnlyCount.inc();
613+
} else if (errorResponseStatus == ResponseStatus.ServiceUnavailable) {
614+
if (errorResponseIsHostLevelThrottled) {
615+
// same as above, but the drop was host-level-throttled rather than a genuine service-unavailable
616+
// failure; tracked separately, matching how hostLevelThrottledCount is kept separate from
617+
// serviceUnavailableErrorCount.
618+
nettyMetrics.offlineHostLevelThrottledOnlyCount.inc();
619+
} else {
620+
// same as above, but for a genuine (non-throttler-driven) 503 that never reached the wire.
621+
nettyMetrics.offlineServiceUnavailableOnlyCount.inc();
622+
}
623+
}
624+
}
596625
}
597626
return responseSent;
598627
}
599628

629+
/**
630+
* @return {@code true} if the current request's {@link RestUtils.Headers#SERVICE_ID} matches one of the
631+
* configured offline (e.g. composite router secondary/parity-check) service IDs. {@code false} if there is no
632+
* current request, no service ID on it, or no offline service IDs are configured.
633+
*/
634+
private boolean isOfflineServiceRequest() {
635+
if (request == null || nettyConfig.nettyServerOfflineServiceIds.isEmpty()) {
636+
return false;
637+
}
638+
Object serviceId = request.getArgs().get(RestUtils.Headers.SERVICE_ID);
639+
return serviceId != null && nettyConfig.nettyServerOfflineServiceIds.contains(serviceId.toString());
640+
}
641+
600642
/**
601643
* Provided a cause, returns an error response with the right status and error message.
602644
* @param cause the cause of the error.
@@ -607,6 +649,7 @@ private FullHttpResponse getErrorResponse(Throwable cause) {
607649
RestServiceErrorCode restServiceErrorCode = null;
608650
String errReason = null;
609651
Map<String, String> errHeaders = null;
652+
errorResponseIsHostLevelThrottled = false;
610653
if (cause instanceof RestServiceException) {
611654
RestServiceException restServiceException = (RestServiceException) cause;
612655
restServiceErrorCode = restServiceException.getErrorCode();
@@ -618,6 +661,7 @@ private FullHttpResponse getErrorResponse(Throwable cause) {
618661
// here skips the ServiceUnavailable counter increment getHttpResponseStatus would do.
619662
nettyMetrics.hostLevelThrottledCount.inc();
620663
status = HttpResponseStatus.SERVICE_UNAVAILABLE;
664+
errorResponseIsHostLevelThrottled = true;
621665
} else {
622666
status = getHttpResponseStatus(errorResponseStatus);
623667
}

0 commit comments

Comments
 (0)