Skip to content

Commit 5357c5f

Browse files
LEGLINK-186: Address code review findings on the retry work
- Tolerate legacy and malformed attempts headers (the one real bug): currentAttempts() threw BufferUnderflowException on anything but a 4-byte value; a record carrying spring-kafka's legacy 1-byte encoding (mixed-version rollouts, old in-flight retry records) escaped the recoverer, skipped the ack, and stalled the partition. Now decodes both supported encodings and reads unsupported lengths as 0 - worst case one extra ladder pass instead of a wedged partition. Tests for both cases, watched red first. - Pin the awaited dead-letter publish: spring-kafka 3.1.4's DeadLetterPublishingRecoverer defaults failIfSendResultIsError=true (verified in the library bytecode), so accept() already awaits the bounded send result and a failed publish propagates - keeping the source offset uncommitted and the onDeadLetter hook unfired. No code change needed; a test now pins the invariant against future spring-kafka upgrades, with a comment at the delegate. - Correct the async-ack comments in AbstractAsyncConsumer: under MANUAL_IMMEDIATE + asyncAcks an executor-thread acknowledge() is queued for in-order commit, and a missing ack pauses the partition until restart/rebalance rather than triggering live redelivery. Comments only; behavior unchanged; nack() noted as unsupported. - Branch coverage: the KafkaConfig dead-letter hook's non-resource value path (skip cleanup, no throw) and ResourceCacheCleanup's ABS-with-null-service guard, which no longer had indirect coverage once cleanup became success-only. - Sanitize logging arguments (LogUtils.sanitize) in CacheBlobStorageConfig, AbsResourceService, AbstractResourceConsumer, and RedisResourceService. - Record shipped defaults in app-config.yaml: PT3S,PT3S and false for the spring.kafka.retry keys in both service sections. 151 measureeval-side tests green; validation compiles against the shared changes. Claude-Session: https://claude.ai/code/session_01KN565tFAuJUAEkK2DdRF1e
1 parent 9f87d82 commit 5357c5f

11 files changed

Lines changed: 147 additions & 13 deletions

File tree

Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/configs/CacheBlobStorageConfig.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.azure.storage.common.policy.RequestRetryOptions;
66
import com.azure.storage.common.policy.RetryPolicyType;
77
import com.lantanagroup.link.measureeval.services.AbsResourceService;
8+
import com.lantanagroup.link.shared.utils.LogUtils;
89
import lombok.Getter;
910
import lombok.Setter;
1011
import org.apache.commons.lang3.StringUtils;
@@ -47,7 +48,8 @@ public AbsResourceService absResourceService() {
4748
return null;
4849
}
4950
logger.info("Creating AbsResourceService: container={}, blobRoot={}, maxTries={}, tryTimeout={}s",
50-
blobContainerName, blobRoot, maxTries, tryTimeoutSeconds);
51+
LogUtils.sanitize(blobContainerName), LogUtils.sanitize(blobRoot),
52+
LogUtils.sanitize(maxTries), LogUtils.sanitize(tryTimeoutSeconds));
5153
BlobContainerClient client = new BlobServiceClientBuilder()
5254
.connectionString(connectionString)
5355
.retryOptions(buildRetryOptions())

Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/AbsResourceService.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.azure.storage.blob.models.BlobStorageException;
88
import com.azure.storage.blob.models.ListBlobsOptions;
99
import com.lantanagroup.link.measureeval.entities.Resource;
10+
import com.lantanagroup.link.shared.utils.LogUtils;
1011
import org.hl7.fhir.r4.model.ResourceType;
1112
import org.slf4j.Logger;
1213
import org.slf4j.LoggerFactory;
@@ -74,11 +75,12 @@ private List<Resource> readBlobResources(String blobName, String facilityId, Str
7475
// silently producing a not-reportable report and acking. Connectivity/timeout failures are
7576
// not BlobStorageException, so they are not caught here and propagate for the same reason.
7677
if (BlobErrorCode.BLOB_NOT_FOUND.equals(e.getErrorCode())) {
77-
logger.debug("Blob '{}' not found for correlationId='{}'; treating as empty cache", blobName, correlationId);
78+
logger.debug("Blob '{}' not found for correlationId='{}'; treating as empty cache",
79+
LogUtils.sanitize(blobName), LogUtils.sanitize(correlationId));
7880
return resources;
7981
}
8082
logger.error("Failed to download blob '{}' for correlationId='{}' (errorCode={}); propagating for retry.",
81-
blobName, correlationId, e.getErrorCode());
83+
LogUtils.sanitize(blobName), LogUtils.sanitize(correlationId), LogUtils.sanitize(e.getErrorCode()));
8284
throw e;
8385
}
8486

Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/AbstractResourceConsumer.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.lantanagroup.link.shared.kafka.Topics;
1414
import com.lantanagroup.link.shared.kafka.records.ResourceKey;
1515
import com.lantanagroup.link.shared.utils.DiagnosticNames;
16+
import com.lantanagroup.link.shared.utils.LogUtils;
1617
import io.opentelemetry.api.common.Attributes;
1718
import io.opentelemetry.api.trace.Span;
1819
import org.apache.commons.collections4.map.PassiveExpiringMap;
@@ -228,7 +229,7 @@ protected void process(ConsumerRecord<ResourceKey, T> record) {
228229
// its cached resources. Terminal (dead-letter) cleanup is the recoverer's job — it is the
229230
// only place the routing decision is known (see the terminal-failure hook in KafkaConfig).
230231
if (keepCacheForSupplemental) {
231-
logger.debug("Keeping cache for SUPPLEMENTAL pass, correlationId={}", correlationId);
232+
logger.debug("Keeping cache for SUPPLEMENTAL pass, correlationId={}", LogUtils.sanitize(correlationId));
232233
} else {
233234
if (perf) taskStopWatch.start("cleanupCache");
234235
try {

Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/RedisResourceService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.lantanagroup.link.measureeval.entities.Resource;
44
import com.lantanagroup.link.measureeval.exceptions.ResourceCacheUnavailableException;
5+
import com.lantanagroup.link.shared.utils.LogUtils;
56
import org.hl7.fhir.r4.model.ResourceType;
67
import org.slf4j.Logger;
78
import org.slf4j.LoggerFactory;
@@ -47,7 +48,7 @@ public List<Resource> readResources(String facilityId, String correlationId, Str
4748

4849
if (fields.isEmpty()) {
4950
// Reached only when the cache was reachable: the key genuinely has no fields.
50-
logger.debug("No Redis entries for correlationId='{}' (cache reachable, key absent)", correlationId);
51+
logger.debug("No Redis entries for correlationId='{}' (cache reachable, key absent)", LogUtils.sanitize(correlationId));
5152
return resources;
5253
}
5354

Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/configs/RetryTopicRecovererDecisionTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,21 @@ void kafkaConfigHook_cleansTheResourceCache_whenARecordIsDeadLettered() {
332332
verify(redis).cleanup("corr-1");
333333
}
334334

335+
@Test
336+
void kafkaConfigHook_skipsCleanup_whenADeadLetteredValueIsNotAResourceRecord() {
337+
// The other side of the hook's instanceof guard: a terminal record whose value never
338+
// deserialized into a resource record (e.g. container-thread poison republished as raw
339+
// content) has no cache key to read - the hook must skip, not throw, and leave whatever
340+
// the record left behind to the cache expiration policy.
341+
var redis = mock(com.lantanagroup.link.measureeval.services.RedisResourceService.class);
342+
343+
assertDoesNotThrow(() -> runThroughKafkaConfig(
344+
new ConsumerRecord<>("ResourcesNormalized", 0, 0L, "key", "raw non-record value"),
345+
new ValidationException("poison"), redis));
346+
347+
verifyNoInteractions(redis);
348+
}
349+
335350
@Test
336351
void kafkaConfigHook_leavesTheResourceCache_whenARecordIsRetried() {
337352
var redis = mock(com.lantanagroup.link.measureeval.services.RedisResourceService.class);
@@ -341,6 +356,30 @@ void kafkaConfigHook_leavesTheResourceCache_whenARecordIsRetried() {
341356
verify(redis, never()).cleanup(anyString());
342357
}
343358

359+
@Test
360+
void failedDeadLetterPublish_propagates_andTheHookDoesNotFire() {
361+
// The recoverer must await the send result. If accept() returned after merely enqueueing the
362+
// send, a broker failure would be invisible: the async consumer would ack the source record
363+
// (losing it - neither retried nor dead-lettered) and the hook would release resources for a
364+
// dead letter that never landed.
365+
KafkaTemplate<Object, Object> template = mock(KafkaTemplate.class);
366+
when(template.partitionsFor(anyString())).thenAnswer(invocation ->
367+
List.of(new PartitionInfo(invocation.getArgument(0), 0, null, new Node[0], new Node[0])));
368+
when(template.send(any(ProducerRecord.class)))
369+
.thenReturn(CompletableFuture.failedFuture(new RuntimeException("broker unavailable")));
370+
371+
List<String> events = new java.util.ArrayList<>();
372+
RetryTopicRecoverer recoverer = RetryTopicRecovererFactory.create(
373+
template, RETRY_TOPIC, ERROR_TOPIC, retryConfig(3, false), KafkaConfig.NON_RETRYABLE,
374+
(r, e) -> events.add("hook"));
375+
376+
assertThrows(Exception.class,
377+
() -> recoverer.accept(record(), new ValidationException("poison")),
378+
"a failed dead-letter publish must propagate so the source offset stays uncommitted");
379+
assertEquals(List.of(), events,
380+
"resources must not be released for a dead letter that was never published");
381+
}
382+
344383
@Test
345384
void terminalHook_throwing_doesNotFailTheRecovery() {
346385
List<String> events = new java.util.ArrayList<>();
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.lantanagroup.link.measureeval.services;
2+
3+
import com.lantanagroup.link.measureeval.entities.CacheType;
4+
import org.junit.jupiter.api.Test;
5+
6+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
7+
import static org.mockito.Mockito.mock;
8+
import static org.mockito.Mockito.verify;
9+
import static org.mockito.Mockito.verifyNoInteractions;
10+
11+
class ResourceCacheCleanupTest {
12+
13+
@Test
14+
void cleanup_absCacheTypeWithoutConfiguredService_doesNothing() {
15+
// The ABS branch's null-guard: cache-blob-storage may be unconfigured while a record still
16+
// arrives claiming CacheType.ABS. Cleanup must no-op (not NPE), and must not fall through
17+
// to Redis - the entry is left to the cache expiration policy.
18+
RedisResourceService redis = mock(RedisResourceService.class);
19+
ResourceCacheCleanup cleanup = new ResourceCacheCleanup(redis, null);
20+
21+
assertDoesNotThrow(() -> cleanup.cleanup("corr-1", CacheType.ABS));
22+
23+
verifyNoInteractions(redis);
24+
}
25+
26+
@Test
27+
void cleanup_absCacheTypeWithConfiguredService_cleansAbsOnly() {
28+
RedisResourceService redis = mock(RedisResourceService.class);
29+
AbsResourceService abs = mock(AbsResourceService.class);
30+
ResourceCacheCleanup cleanup = new ResourceCacheCleanup(redis, abs);
31+
32+
cleanup.cleanup("corr-1", CacheType.ABS);
33+
34+
verify(abs).cleanup("corr-1");
35+
verifyNoInteractions(redis);
36+
}
37+
}

Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/RetryTopicRecovererTest.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,31 @@ private static long decodeAsSpring(byte[] value) {
5050
return new BigInteger(value).longValue();
5151
}
5252

53+
private static RecordHeaders attemptsHeader(byte[] value) {
54+
RecordHeaders headers = new RecordHeaders();
55+
headers.add(RetryTopicHeaders.DEFAULT_HEADER_ATTEMPTS, value);
56+
return headers;
57+
}
58+
59+
// ---- Attempts-header tolerance. spring-kafka accepts both the legacy one-byte and the current
60+
// four-byte encoding, and records can arrive carrying either (mixed-version rollouts, old
61+
// in-flight retry records). A decode failure here would propagate out of the recoverer and
62+
// stall the partition (the async consumer skips the ack), so unsupported lengths must read
63+
// as 0 - "no attempts yet" - rather than throw.
64+
65+
@Test
66+
void currentAttempts_decodesLegacySingleByteHeader() {
67+
assertEquals(3, RetryTopicRecoverer.currentAttempts(attemptsHeader(new byte[]{3})));
68+
}
69+
70+
@Test
71+
void currentAttempts_returnsZeroForUnsupportedLengths() {
72+
assertEquals(0, RetryTopicRecoverer.currentAttempts(attemptsHeader(new byte[]{0, 1})),
73+
"a two-byte value is neither encoding; it must read as zero, not throw");
74+
assertEquals(0, RetryTopicRecoverer.currentAttempts(attemptsHeader(new byte[]{})),
75+
"an empty value must read as zero, not throw");
76+
}
77+
5378
@Test
5479
void backoffTimestamp_decodesAsFutureDueTime_viaSpringReader() {
5580
RetryTopicRecoverer recoverer = newRecoverer(mock(DeadLetterPublishingRecoverer.class));

Java/shared/src/main/java/com/lantanagroup/link/shared/kafka/AbstractAsyncConsumer.java

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,23 @@ protected void doConsume(ConsumerRecord<K, T> record, Acknowledgment ack) {
4747
record.topic(), record.partition(), record.offset(), processError);
4848
} else {
4949
// Route to -Retry/-Error. If this throws (e.g. the broker is unavailable), let it
50-
// propagate so the ack below is skipped — an uncommitted offset makes Kafka
51-
// redeliver the record instead of silently losing a failure that was neither
52-
// retried nor dead-lettered.
50+
// propagate so the ack below is skipped. Under MANUAL_IMMEDIATE + asyncAcks the
51+
// missing acknowledgment stalls this partition (no further records are committed
52+
// past the gap) rather than silently losing a failure that was neither retried nor
53+
// dead-lettered; the record is redelivered after a restart or rebalance.
5354
recoverer.accept(record, processError);
5455
}
5556
}
5657
// Reached only when process() succeeded, recovery published successfully, or we
57-
// intentionally dropped (no recoverer): the record is accounted for, so commit the offset.
58+
// intentionally dropped (no recoverer): the record is accounted for. With asyncAcks the
59+
// acknowledgment is queued from this executor thread and the container commits it in
60+
// offset order, not immediately. (Do not use nack() here — unsupported with asyncAcks.)
5861
ack.acknowledge();
5962
} catch (Exception unrecovered) {
60-
// process() failed AND recovery (or the ack itself) failed. Do NOT commit the offset:
61-
// leaving it uncommitted preserves at-least-once delivery so the record is redelivered.
63+
// process() failed AND recovery (or the ack itself) failed. Do NOT acknowledge: the
64+
// missing ack pauses this partition, preserving the record (at-least-once, no loss) at
65+
// the cost of a stalled partition until a restart or rebalance redelivers it — this log
66+
// line is the operational signal for that condition.
6267
logger.error("Failed to recover or acknowledge record from topic={} partition={} offset={}; leaving offset uncommitted for redelivery",
6368
record.topic(), record.partition(), record.offset(), unrecovered);
6469
} finally {

Java/shared/src/main/java/com/lantanagroup/link/shared/kafka/RetryTopicRecoverer.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,24 @@ public void accept(ConsumerRecord<?, ?> record, Exception exception) {
146146
/**
147147
* Retry attempts recorded in the attempts header, or 0 if absent. Shared with KafkaConfig's
148148
* destination resolver so routing and stamping use the same count.
149+
*
150+
* <p>Tolerates both encodings spring-kafka accepts — the current four-byte int and the legacy
151+
* single byte — since records can arrive carrying either (mixed-version rollouts, old in-flight
152+
* retry records). Any other length reads as 0 ("no attempts yet") rather than throwing: a decode
153+
* failure would propagate out of {@link #accept} and stall the partition, and the worst case of
154+
* reading 0 is one extra pass through the retry ladder before dead-lettering.</p>
149155
*/
150156
public static int currentAttempts(Headers headers) {
151157
var header = headers.lastHeader(RetryTopicHeaders.DEFAULT_HEADER_ATTEMPTS);
152-
if (header == null) return 0;
153-
return ByteBuffer.wrap(header.value()).getInt();
158+
if (header == null || header.value() == null) return 0;
159+
byte[] value = header.value();
160+
if (value.length == Integer.BYTES) {
161+
return ByteBuffer.wrap(value).getInt();
162+
}
163+
if (value.length == 1) {
164+
return value[0];
165+
}
166+
logger.warn("Unsupported attempts-header length {}; treating as 0 attempts.", value.length);
167+
return 0;
154168
}
155169
}

Java/shared/src/main/java/com/lantanagroup/link/shared/kafka/RetryTopicRecovererFactory.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ public static RetryTopicRecoverer create(
7777
return new TopicPartition(target, record.partition());
7878
};
7979

80+
// Relies on spring-kafka's failIfSendResultIsError=true default (since 2.7): accept() awaits
81+
// the send result (bounded by the producer's delivery.timeout.ms + buffer) and throws on
82+
// failure, so the source offset stays uncommitted and the onDeadLetter hook only runs after a
83+
// durable publish. Pinned by RetryTopicRecovererDecisionTest.failedDeadLetterPublish_*.
8084
DeadLetterPublishingRecoverer delegate = new DeadLetterPublishingRecoverer(kafkaTemplate, resolver);
8185

8286
return new RetryTopicRecoverer(

0 commit comments

Comments
 (0)