@@ -170,16 +170,6 @@ public void handleFailedEvent(EventPublisherException ex, boolean errorOnSub) {
170170 source .toString ());
171171 }
172172
173- private void recordSuccessfulChangeEvent (UUID eventSubscriptionId , ChangeEvent event ) {
174- Entity .getCollectionDAO ()
175- .eventSubscriptionDAO ()
176- .upsertSuccessfulChangeEvent (
177- event .getId ().toString (),
178- eventSubscriptionId .toString (),
179- JsonUtils .pojoToJson (event ),
180- System .currentTimeMillis ());
181- }
182-
183173 private EventSubscriptionOffset loadInitialOffset (JobExecutionContext context ) {
184174 Object offsetValue = jobDetail .getJobDataMap ().get (ALERT_OFFSET_KEY );
185175 if (offsetValue != null ) {
@@ -232,54 +222,68 @@ public void publishEvents(Map<ChangeEvent, Set<UUID>> events) {
232222 if (events .isEmpty ()) {
233223 return ;
234224 }
235-
236- // Filter events based on subscription configuration (entity type, conditions, etc.)
237225 Map <ChangeEvent , Set <UUID >> filteredEvents = getFilteredEvents (eventSubscription , events );
238226 RecipientResolver resolver = new RecipientResolver ();
227+ int successDeliveries = 0 ;
228+ int failedDeliveries = 0 ;
229+ for (Map .Entry <ChangeEvent , Set <UUID >> eventWithReceivers : filteredEvents .entrySet ()) {
230+ EventDeliveryResult result =
231+ publishEvent (eventWithReceivers .getKey (), eventWithReceivers .getValue (), resolver );
232+ // Record once per (event, subscription): the table has no destination dimension, so
233+ // recording per type would duplicate rows and break Postgres ON CONFLICT.
234+ if (result .delivered ()) {
235+ successfulEvents .add (eventWithReceivers .getKey ());
236+ }
237+ successDeliveries += result .successCount ();
238+ failedDeliveries += result .failedCount ();
239+ }
240+ alertMetrics .withSuccessEvents (alertMetrics .getSuccessEvents () + successDeliveries );
241+ alertMetrics .withFailedEvents (alertMetrics .getFailedEvents () + failedDeliveries );
242+ }
239243
240- for (var eventWithReceivers : filteredEvents .entrySet ()) {
241- ChangeEvent event = eventWithReceivers .getKey ();
242- Set <UUID > destinationIds = eventWithReceivers .getValue ();
243-
244- // Group destinations by type to enable cross-destination recipient deduplication
245- Map <SubscriptionType , List <Destination <ChangeEvent >>> destinationsByType =
246- groupDestinationsByType (destinationIds );
247-
248- for (var entry : destinationsByType .entrySet ()) {
249- List <Destination <ChangeEvent >> destinations = entry .getValue ();
250- Destination <ChangeEvent > publisher = destinations .getFirst ();
251-
252- // Resolve recipients from all destinations of this type for deduplication
253- Set <Recipient > recipients = Set .of ();
254- if (publisher .requiresRecipients ()) {
255- List <SubscriptionDestination > subDestinations =
256- destinations .stream ().map (Destination ::getSubscriptionDestination ).toList ();
257- recipients = resolver .resolveRecipients (event , subDestinations );
258- }
259-
260- // Send via primary destination only, with deduplicated recipients (one send per type)
261- boolean status = true ;
262- if (!publisher .requiresRecipients () || !recipients .isEmpty ()) {
263- try {
264- publisher .sendMessage (event , recipients );
265- } catch (EventPublisherException e ) {
266- LOG .error ("Failed to send alert: {}" , e .getMessage ());
267- handleFailedEvent (e , true );
268- status = false ;
269- }
270- }
244+ private EventDeliveryResult publishEvent (
245+ ChangeEvent event , Set <UUID > destinationIds , RecipientResolver resolver ) {
246+ // Group destinations by type to enable cross-destination recipient deduplication
247+ Map <SubscriptionType , List <Destination <ChangeEvent >>> destinationsByType =
248+ groupDestinationsByType (destinationIds );
249+ int successCount = 0 ;
250+ int failedCount = 0 ;
251+ for (Map .Entry <SubscriptionType , List <Destination <ChangeEvent >>> entry :
252+ destinationsByType .entrySet ()) {
253+ if (sendToDestinationType (event , entry .getValue (), resolver )) {
254+ successCount ++;
255+ } else {
256+ failedCount ++;
257+ }
258+ }
259+ return new EventDeliveryResult (successCount > 0 , successCount , failedCount );
260+ }
271261
272- if (status ) {
273- // Collect successful events instead of writing immediately
274- // Batch write happens in commit() to reduce connection pool contention
275- // Note: Empty recipients is treated as successful (no-op send)
276- successfulEvents .add (eventWithReceivers .getKey ());
277- alertMetrics .withSuccessEvents (alertMetrics .getSuccessEvents () + 1 );
278- } else {
279- alertMetrics .withFailedEvents (alertMetrics .getFailedEvents () + 1 );
280- }
262+ private record EventDeliveryResult (boolean delivered , int successCount , int failedCount ) {}
263+
264+ private boolean sendToDestinationType (
265+ ChangeEvent event , List <Destination <ChangeEvent >> destinations , RecipientResolver resolver ) {
266+ Destination <ChangeEvent > publisher = destinations .getFirst ();
267+ // Resolve recipients from all destinations of this type for deduplication
268+ Set <Recipient > recipients = Set .of ();
269+ if (publisher .requiresRecipients ()) {
270+ List <SubscriptionDestination > subDestinations =
271+ destinations .stream ().map (Destination ::getSubscriptionDestination ).toList ();
272+ recipients = resolver .resolveRecipients (event , subDestinations );
273+ }
274+ // Send via primary destination only, with deduplicated recipients (one send per type).
275+ // Empty recipients is treated as successful (no-op send).
276+ boolean status = true ;
277+ if (!publisher .requiresRecipients () || !recipients .isEmpty ()) {
278+ try {
279+ publisher .sendMessage (event , recipients );
280+ } catch (EventPublisherException e ) {
281+ LOG .error ("Failed to send alert: {}" , e .getMessage ());
282+ handleFailedEvent (e , true );
283+ status = false ;
281284 }
282285 }
286+ return status ;
283287 }
284288
285289 private Map <SubscriptionType , List <Destination <ChangeEvent >>> groupDestinationsByType (
0 commit comments