Skip to content

Commit 65e921e

Browse files
LEGLINK-186: Restrict MeasureEval cache cleanup to terminal outcomes
With the retry ladder in place, cleanup-on-any-failure became a bug: a transient failure deleted the {correlationId} cache entry in the finally block, so the redelivered record read an empty cache and the empty-bundle branch emitted a silent false not-reportable report. The consumer now cleans up only when processing succeeds (keeping the INITIAL+reportable entry for the SUPPLEMENTAL pass, as before). Failure- path cleanup moves to the one place that knows the routing decision: RetryTopicRecoverer gains an optional onDeadLetter hook, invoked solely on terminal decisions (poison / exhausted / retries disabled) and only after the dead letter is durably published. Hook failures are logged and swallowed - the recovery already succeeded, and propagating would redeliver the record into a duplicate dead letter. MeasureEval wires the hook through a new ResourceCacheCleanup component (Redis + optional ABS, best-effort, never throws) shared by the success path and both recoverers; a record whose value never deserialized is skipped and left to the cache expiration policy. Validation keeps the existing factory overload unchanged. Verified live in docker-compose across seven scenarios: poison, Mongo/ Redis/ABS outages run to exhaustion (cache kept through every retry, cleaned or gracefully leaked at the dead letter), and Mongo/Redis/ABS restored mid-ladder (redelivery completes a real NHSN evaluation and cleans up on success). Known follow-up: the Azure SDK's internal retry policy makes each ABS call block the single-threaded consumer executor for minutes during an ABS outage; tighten RequestRetryOptions so the retry-topic ladder is the single source of retry truth. Claude-Session: https://claude.ai/code/session_01KN565tFAuJUAEkK2DdRF1e
1 parent 55fc5b0 commit 65e921e

7 files changed

Lines changed: 363 additions & 50 deletions

File tree

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import com.fasterxml.jackson.databind.JavaType;
55
import com.fasterxml.jackson.databind.ObjectMapper;
66
import com.lantanagroup.link.measureeval.records.*;
7+
import com.lantanagroup.link.measureeval.services.AbsResourceService;
8+
import com.lantanagroup.link.measureeval.services.RedisResourceService;
9+
import com.lantanagroup.link.measureeval.services.ResourceCacheCleanup;
710
import com.lantanagroup.link.shared.kafka.RetryTopicRecoverer;
811
import com.lantanagroup.link.shared.kafka.RetryTopicRecovererFactory;
912
import com.lantanagroup.link.shared.config.KafkaRetryConfig;
@@ -285,35 +288,57 @@ private RetryTopicRecoverer createRetryTopicRecoverer(
285288
KafkaTemplate<?, ?> kafkaTemplate,
286289
String retryTopic,
287290
String errorTopic,
288-
KafkaRetryConfig retryConfig) {
289-
return RetryTopicRecovererFactory.create(kafkaTemplate, retryTopic, errorTopic, retryConfig, NON_RETRYABLE);
291+
KafkaRetryConfig retryConfig,
292+
ResourceCacheCleanup cacheCleanup) {
293+
// The consumer no longer cleans up the resource cache on failure — a record routed to -Retry
294+
// is redelivered and still needs its cached resources. This hook is the failure-path cleanup:
295+
// it runs only on terminal decisions (poison / exhausted / retries disabled), after the dead
296+
// letter is durably published. A record whose value never deserialized is skipped; the cache
297+
// expiration policy reclaims whatever it left behind.
298+
return RetryTopicRecovererFactory.create(kafkaTemplate, retryTopic, errorTopic, retryConfig, NON_RETRYABLE,
299+
(record, exception) -> {
300+
if (record.value() instanceof AbstractResourceRecord resourceRecord) {
301+
cacheCleanup.cleanup(resourceRecord.getCacheKey(), resourceRecord.getCacheType());
302+
}
303+
});
304+
}
305+
306+
@Bean
307+
public ResourceCacheCleanup resourceCacheCleanup(
308+
RedisResourceService redisResourceService,
309+
ObjectProvider<AbsResourceService> absResourceService) {
310+
return new ResourceCacheCleanup(redisResourceService, absResourceService.getIfAvailable());
290311
}
291312

292313
@Bean
293314
public ConsumerRecordRecoverer resourceNormalizedRecoverer(
294315
@Qualifier("compressedKafkaTemplate")
295316
KafkaTemplate<String, ResourcesNormalized> kafkaTemplate,
296-
KafkaRetryConfig retryConfig) {
317+
KafkaRetryConfig retryConfig,
318+
ResourceCacheCleanup resourceCacheCleanup) {
297319

298320
return createRetryTopicRecoverer(
299321
kafkaTemplate,
300322
"ResourcesNormalized-Retry",
301323
"ResourcesNormalized-Error",
302-
retryConfig
324+
retryConfig,
325+
resourceCacheCleanup
303326
);
304327
}
305328

306329
@Bean
307330
public ConsumerRecordRecoverer evaluationRequestedRecoverer(
308331
@Qualifier("compressedKafkaTemplate")
309332
KafkaTemplate<String, EvaluationRequested> kafkaTemplate,
310-
KafkaRetryConfig retryConfig) {
333+
KafkaRetryConfig retryConfig,
334+
ResourceCacheCleanup resourceCacheCleanup) {
311335

312336
return createRetryTopicRecoverer(
313337
kafkaTemplate,
314338
"EvaluationRequested-Retry",
315339
"EvaluationRequested-Error",
316-
retryConfig
340+
retryConfig,
341+
resourceCacheCleanup
317342
);
318343
}
319344

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

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
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;
1716
import io.opentelemetry.api.common.Attributes;
1817
import io.opentelemetry.api.trace.Span;
1918
import org.apache.commons.collections4.map.PassiveExpiringMap;
@@ -54,6 +53,7 @@ public abstract class AbstractResourceConsumer<T extends AbstractResourceRecord>
5453
private final MeasureReportGeneratedProducer measureReportGeneratedProducer;
5554
private final RedisResourceService redisResourceService;
5655
private final AbsResourceService absResourceService;
56+
private final ResourceCacheCleanup cacheCleanup;
5757
private final MongoOperations mongoOperations;
5858

5959
public AbstractResourceConsumer (
@@ -80,6 +80,7 @@ public AbstractResourceConsumer (
8080
this.blobStorageService = blobStorageService;
8181
this.redisResourceService = redisResourceService;
8282
this.absResourceService = absResourceService;
83+
this.cacheCleanup = new ResourceCacheCleanup(redisResourceService, absResourceService);
8384
this.mongoOperations = mongoOperations;
8485
}
8586

@@ -119,9 +120,6 @@ protected void process(ConsumerRecord<ResourceKey, T> record) {
119120
if (value.getCacheType() == null) {
120121
throw new ValidationException("Cache Type is null.");
121122
}
122-
// Captured here, next to its validation, rather than after the metrics/logging below: the
123-
// finally-block cleanup is guarded on both correlationId and cacheType, so anything that
124-
// throws between the two assignments would silently strand a valid cache entry.
125123
cacheType = value.getCacheType();
126124
correlationId = value.getCacheKey();
127125
if (correlationId == null || correlationId.isEmpty()) {
@@ -211,7 +209,7 @@ protected void process(ConsumerRecord<ResourceKey, T> record) {
211209
value.getQueryType() == QueryType.INITIAL && reportablePatient;
212210

213211
// INITIAL + reportable keeps the cache for the SUPPLEMENTAL pass to reuse; every other
214-
// outcome, including failure, is cleaned up by the finally block below.
212+
// successful outcome cleans it up below.
215213
keepCacheForSupplemental = initialReportable;
216214

217215
if (initialReportable) {
@@ -225,39 +223,28 @@ protected void process(ConsumerRecord<ResourceKey, T> record) {
225223
resources.size(), correlationId);
226224
}
227225

228-
} finally {
229-
// A task may still be running if we got here by way of an exception; stop it so that its
230-
// elapsed time is reported and so that starting the cleanup task below cannot throw.
231-
if (perf && taskStopWatch.isRunning()) {
232-
taskStopWatch.stop();
233-
}
234-
235-
// Clean up cache after SUPPLEMENTAL, after INITIAL if patient is not reportable, and after
236-
// any failure. INITIAL + reportable keeps the cache for the SUPPLEMENTAL pass to reuse.
226+
// Cleanup runs ONLY when processing succeeded. A thrown exception must leave the cache
227+
// intact: the recoverer may route the record to -Retry, and the redelivered record needs
228+
// its cached resources. Terminal (dead-letter) cleanup is the recoverer's job — it is the
229+
// only place the routing decision is known (see the terminal-failure hook in KafkaConfig).
237230
if (keepCacheForSupplemental) {
238231
logger.debug("Keeping cache for SUPPLEMENTAL pass, correlationId={}", correlationId);
239-
} else if (correlationId != null && cacheType != null) {
232+
} else {
240233
if (perf) taskStopWatch.start("cleanupCache");
241234
try {
242-
switch (cacheType) {
243-
case REDIS -> redisResourceService.cleanup(correlationId);
244-
case ABS -> {
245-
if (absResourceService != null) {
246-
absResourceService.cleanup(correlationId);
247-
}
248-
}
249-
}
250-
logger.debug("Cache cleanup complete for correlationId={}, cacheType={}",
251-
LogUtils.sanitize(correlationId), LogUtils.sanitize(cacheType));
252-
} catch (Exception e) {
253-
// Never mask the exception that brought us here, if any.
254-
logger.error("Cache cleanup failed for correlationId={}, cacheType={}",
255-
LogUtils.sanitize(correlationId), LogUtils.sanitize(cacheType), e);
235+
cacheCleanup.cleanup(correlationId, cacheType);
256236
} finally {
257237
if (perf) taskStopWatch.stop();
258238
}
259239
}
260240

241+
} finally {
242+
// A task may still be running if we got here by way of an exception; stop it so that its
243+
// elapsed time is reported.
244+
if (perf && taskStopWatch.isRunning()) {
245+
taskStopWatch.stop();
246+
}
247+
261248
if (perf) {
262249
totalStopWatch.stop();
263250
for (StopWatch.TaskInfo task : taskStopWatch.getTaskInfo()) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.lantanagroup.link.measureeval.services;
2+
3+
import com.lantanagroup.link.measureeval.entities.CacheType;
4+
import com.lantanagroup.link.shared.utils.LogUtils;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
8+
/**
9+
* Releases the resource cache entry for a correlation id. Shared by the two places allowed to
10+
* delete it: the consumer's success path (the record was fully evaluated) and the recoverer's
11+
* terminal-failure hook (the record was durably dead-lettered and will never be redelivered).
12+
*
13+
* <p>Never call this on a failure that may still be retried — the redelivered record needs its
14+
* cached resources. Best-effort: failures are logged, never thrown, so cleanup can never turn a
15+
* handled record back into a failure; the cache expiration policy is the backstop.</p>
16+
*/
17+
public class ResourceCacheCleanup {
18+
19+
private static final Logger logger = LoggerFactory.getLogger(ResourceCacheCleanup.class);
20+
21+
private final RedisResourceService redisResourceService;
22+
private final AbsResourceService absResourceService;
23+
24+
public ResourceCacheCleanup(RedisResourceService redisResourceService, AbsResourceService absResourceService) {
25+
this.redisResourceService = redisResourceService;
26+
this.absResourceService = absResourceService;
27+
}
28+
29+
public void cleanup(String correlationId, CacheType cacheType) {
30+
if (correlationId == null || correlationId.isEmpty() || cacheType == null) {
31+
return;
32+
}
33+
try {
34+
switch (cacheType) {
35+
case REDIS -> redisResourceService.cleanup(correlationId);
36+
case ABS -> {
37+
if (absResourceService != null) {
38+
absResourceService.cleanup(correlationId);
39+
}
40+
}
41+
}
42+
logger.debug("Cache cleanup complete for correlationId={}, cacheType={}",
43+
LogUtils.sanitize(correlationId), LogUtils.sanitize(cacheType));
44+
} catch (Exception e) {
45+
logger.error("Cache cleanup failed for correlationId={}, cacheType={}",
46+
LogUtils.sanitize(correlationId), LogUtils.sanitize(cacheType), e);
47+
}
48+
}
49+
}

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,119 @@ void deadLetteredRecord_stillRecordsHowManyAttemptsItGot() {
238238
assertEquals(1, ByteBuffer.wrap(attempts.value()).getInt(),
239239
"poison failed once and was never retried, so it must record exactly one attempt");
240240
}
241+
242+
// ---- Terminal-failure hook. A record routed to -Error is finished — it will never be redelivered —
243+
// so this is the one moment its resources (e.g. the cached FHIR bundle) may be released. The
244+
// hook must fire exactly on dead-letter decisions, only after the dead letter is durably
245+
// published, and must never turn a completed recovery back into a failure.
246+
247+
/** Drives the record through the 6-arg factory wiring, recording publish/hook ordering. */
248+
@SuppressWarnings("unchecked")
249+
private void runWithHook(KafkaRetryConfig config, ConsumerRecord<String, String> record,
250+
Exception exception,
251+
java.util.function.BiConsumer<ConsumerRecord<?, ?>, Exception> onDeadLetter,
252+
List<String> events) {
253+
KafkaTemplate<Object, Object> template = mock(KafkaTemplate.class);
254+
when(template.partitionsFor(anyString())).thenAnswer(invocation ->
255+
List.of(new PartitionInfo(invocation.getArgument(0), 0, null, new Node[0], new Node[0])));
256+
when(template.send(any(ProducerRecord.class))).thenAnswer(invocation -> {
257+
events.add("published");
258+
return CompletableFuture.completedFuture(null);
259+
});
260+
261+
RetryTopicRecoverer recoverer = RetryTopicRecovererFactory.create(
262+
template, RETRY_TOPIC, ERROR_TOPIC, config, KafkaConfig.NON_RETRYABLE, onDeadLetter);
263+
recoverer.accept(record, exception);
264+
}
265+
266+
@Test
267+
void terminalHook_firesForPoison_afterTheDeadLetterIsPublished() {
268+
List<String> events = new java.util.ArrayList<>();
269+
270+
runWithHook(retryConfig(3, false), record(), new ValidationException("Query Type is null."),
271+
(r, e) -> events.add("hook"), events);
272+
273+
assertEquals(List.of("published", "hook"), events,
274+
"the hook releases resources the record still needs until it is durably dead-lettered, "
275+
+ "so it must run exactly once, after the publish");
276+
}
277+
278+
@Test
279+
void terminalHook_firesForExhaustedAttempts() {
280+
ConsumerRecord<String, String> record = record();
281+
stampAttempts(record, 2);
282+
List<String> events = new java.util.ArrayList<>();
283+
284+
runWithHook(retryConfig(3, false), record, new RuntimeException("still failing"),
285+
(r, e) -> events.add("hook"), events);
286+
287+
assertEquals(List.of("published", "hook"), events);
288+
}
289+
290+
@Test
291+
void terminalHook_doesNotFireForARetriedRecord() {
292+
List<String> events = new java.util.ArrayList<>();
293+
294+
runWithHook(retryConfig(3, false), record(), new RuntimeException("transient boom"),
295+
(r, e) -> events.add("hook"), events);
296+
297+
assertEquals(List.of("published"), events,
298+
"a record routed to -Retry will be redelivered and still needs its resources — "
299+
+ "the hook must not fire");
300+
}
301+
302+
/** Drives a record through the REAL KafkaConfig recoverer bean, so the wiring itself is pinned. */
303+
@SuppressWarnings("unchecked")
304+
private void runThroughKafkaConfig(ConsumerRecord<String, Object> record, Exception exception,
305+
com.lantanagroup.link.measureeval.services.RedisResourceService redis) {
306+
KafkaTemplate<String, com.lantanagroup.link.measureeval.records.ResourcesNormalized> template =
307+
mock(KafkaTemplate.class);
308+
when(template.partitionsFor(anyString())).thenAnswer(invocation ->
309+
List.of(new PartitionInfo(invocation.getArgument(0), 0, null, new Node[0], new Node[0])));
310+
when(template.send(any(ProducerRecord.class)))
311+
.thenReturn(CompletableFuture.completedFuture(null));
312+
313+
var recoverer = new KafkaConfig().resourceNormalizedRecoverer(
314+
template, retryConfig(3, false),
315+
new com.lantanagroup.link.measureeval.services.ResourceCacheCleanup(redis, null));
316+
recoverer.accept(record, exception);
317+
}
318+
319+
private static ConsumerRecord<String, Object> resourceRecord(String cacheKey) {
320+
var value = new com.lantanagroup.link.measureeval.records.ResourcesNormalized();
321+
value.setCacheKey(cacheKey);
322+
value.setCacheType(com.lantanagroup.link.measureeval.entities.CacheType.REDIS);
323+
return new ConsumerRecord<>("ResourcesNormalized", 0, 0L, "key", value);
324+
}
325+
326+
@Test
327+
void kafkaConfigHook_cleansTheResourceCache_whenARecordIsDeadLettered() {
328+
var redis = mock(com.lantanagroup.link.measureeval.services.RedisResourceService.class);
329+
330+
runThroughKafkaConfig(resourceRecord("corr-1"), new ValidationException("poison"), redis);
331+
332+
verify(redis).cleanup("corr-1");
333+
}
334+
335+
@Test
336+
void kafkaConfigHook_leavesTheResourceCache_whenARecordIsRetried() {
337+
var redis = mock(com.lantanagroup.link.measureeval.services.RedisResourceService.class);
338+
339+
runThroughKafkaConfig(resourceRecord("corr-1"), new RuntimeException("transient boom"), redis);
340+
341+
verify(redis, never()).cleanup(anyString());
342+
}
343+
344+
@Test
345+
void terminalHook_throwing_doesNotFailTheRecovery() {
346+
List<String> events = new java.util.ArrayList<>();
347+
348+
// The dead letter is already published when the hook runs; a hook failure must not propagate,
349+
// or the offset stays uncommitted and the record is redelivered into a duplicate dead letter.
350+
assertDoesNotThrow(() -> runWithHook(
351+
retryConfig(3, false), record(), new ValidationException("Query Type is null."),
352+
(r, e) -> { throw new RuntimeException("hook failed"); }, events));
353+
354+
assertEquals(List.of("published"), events);
355+
}
241356
}

0 commit comments

Comments
 (0)