@@ -44,6 +44,7 @@ public class ResourcesAcquiredListener : BackgroundService
4444 private readonly CopyLocationAliasToTypeIterativelyOperationService _copyLocationAliasToTypeIterativelyOperationService ;
4545 private readonly RemoveExtensionsOperationService _removeExtensionsOperationService ;
4646 private readonly IResourceCache _resourceCache ;
47+ private readonly IResourceCachePurger _resourceCachePurger ;
4748
4849 public ResourcesAcquiredListener (
4950 ILogger < ResourcesAcquiredListener > logger ,
@@ -61,7 +62,8 @@ public ResourcesAcquiredListener(
6162 CopyLocationOperationService copyLocationOperationService ,
6263 CopyLocationAliasToTypeIterativelyOperationService copyLocationAliasToTypeIterativelyOperationService ,
6364 RemoveExtensionsOperationService removeExtensionsOperationService ,
64- IResourceCache resourceCache )
65+ IResourceCache resourceCache ,
66+ IResourceCachePurger resourceCachePurger )
6567 {
6668 this . _logger = logger ?? throw new ArgumentNullException ( nameof ( logger ) ) ;
6769 _consumerFactory = consumerFactory ?? throw new ArgumentNullException ( nameof ( consumerFactory ) ) ;
@@ -88,6 +90,7 @@ public ResourcesAcquiredListener(
8890 _copyLocationAliasToTypeIterativelyOperationService = copyLocationAliasToTypeIterativelyOperationService ?? throw new ArgumentNullException ( nameof ( copyLocationAliasToTypeIterativelyOperationService ) ) ;
8991 _removeExtensionsOperationService = removeExtensionsOperationService ?? throw new ArgumentNullException ( nameof ( removeExtensionsOperationService ) ) ;
9092 _resourceCache = resourceCache ?? throw new ArgumentNullException ( nameof ( resourceCache ) ) ;
93+ _resourceCachePurger = resourceCachePurger ?? throw new ArgumentNullException ( nameof ( resourceCachePurger ) ) ;
9194 }
9295
9396 protected override async Task ExecuteAsync ( CancellationToken cancellationToken )
@@ -113,25 +116,7 @@ await kafkaConsumer.ConsumeWithInstrumentation(async (result, consumeCancellatio
113116 {
114117 try
115118 {
116- await ProcessMessageAsync ( result , consumeCancellationToken ) ;
117- }
118- catch ( DeadLetterException ex )
119- {
120- _deadLetterExceptionHandler . HandleException ( result , ex , result . Message . Key ? . FacilityId ?? string . Empty ) ;
121- }
122- catch ( TransientException ex )
123- {
124- _transientExceptionHandler . HandleException ( result , ex , result . Message . Key ? . FacilityId ?? string . Empty ) ;
125- }
126- catch ( OperationCanceledException ) when ( consumeCancellationToken . IsCancellationRequested )
127- {
128- throw ;
129- }
130- catch ( Exception ex )
131- {
132- _logger . LogError ( ex , "Failed to process ResourceAcquired event for facility {FacilityId}." , result ? . Message . Key ? . FacilityId ? . SanitizeForLog ( ) ) ;
133-
134- _transientExceptionHandler . HandleException ( result , new TransientException ( "Normalization Exception thrown: " + ex . Message , ex ) , result . Message . Key ? . FacilityId ?? string . Empty ) ;
119+ await ConsumeMessageAsync ( result , consumeCancellationToken ) ;
135120 }
136121 finally
137122 {
@@ -182,6 +167,48 @@ await kafkaConsumer.ConsumeWithInstrumentation(async (result, consumeCancellatio
182167 }
183168 }
184169
170+ /// <summary>
171+ /// Processes a single consumed message and routes any failure to the dead letter or retry topic.
172+ /// </summary>
173+ /// <remarks>
174+ /// Separate from the consume loop (which owns only the offset commit) so that the failure routing —
175+ /// in particular which failures release the resource cache — is directly testable.
176+ /// </remarks>
177+ public async Task ConsumeMessageAsync ( ConsumeResult < ResourceKey , ResourcesAcquiredValue > result , CancellationToken consumeCancellationToken )
178+ {
179+ try
180+ {
181+ await ProcessMessageAsync ( result , consumeCancellationToken ) ;
182+ }
183+ catch ( DeadLetterException ex )
184+ {
185+ _deadLetterExceptionHandler . HandleException ( result , ex , result . Message . Key ? . FacilityId ?? string . Empty ) ;
186+
187+ // Terminal failure: the message is on ResourcesAcquired-Error and will never be normalized,
188+ // so release its acquisition keys. The retry paths below must NOT do this — a redelivered
189+ // message still needs its cache. The {correlationId} key is never deleted here: Measure
190+ // Eval owns its deletion, and the cache expiration policy reclaims it if unread.
191+ await _resourceCachePurger . PurgeAsync (
192+ result . Message . Value ,
193+ $ "{ nameof ( KafkaTopic . ResourcesAcquired ) } dead-lettered: { ex . Message } ",
194+ consumeCancellationToken ) ;
195+ }
196+ catch ( TransientException ex )
197+ {
198+ _transientExceptionHandler . HandleException ( result , ex , result . Message . Key ? . FacilityId ?? string . Empty ) ;
199+ }
200+ catch ( OperationCanceledException ) when ( consumeCancellationToken . IsCancellationRequested )
201+ {
202+ throw ;
203+ }
204+ catch ( Exception ex )
205+ {
206+ _logger . LogError ( ex , "Failed to process ResourceAcquired event for facility {FacilityId}." , result ? . Message . Key ? . FacilityId ? . SanitizeForLog ( ) ) ;
207+
208+ _transientExceptionHandler . HandleException ( result , new TransientException ( "Normalization Exception thrown: " + ex . Message , ex ) , result . Message . Key ? . FacilityId ?? string . Empty ) ;
209+ }
210+ }
211+
185212 public async Task ProcessMessageAsync ( ConsumeResult < ResourceKey , ResourcesAcquiredValue > result , CancellationToken cancellationToken )
186213 {
187214 ValidateResourcesAcquiredEvent ( result , out string correlationId ) ;
0 commit comments