Skip to content

Commit 08f422c

Browse files
authored
fix: retry id collision for multi-destination events (#609)
* test: duplicate retry id bug * fix: retry id * test: improve manual retry assertion to compare task id
1 parent e975121 commit 08f422c

3 files changed

Lines changed: 150 additions & 22 deletions

File tree

internal/deliverymq/messagehandler_test.go

Lines changed: 118 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -811,9 +811,9 @@ func TestMessageHandler_PublishAndLogError(t *testing.T) {
811811

812812
func TestManualDelivery_Success(t *testing.T) {
813813
// Test scenario:
814-
// - Manual delivery succeeds
815-
// - Should cancel any pending retries
816-
// - Should be acked
814+
// - Automatic delivery fails and schedules a retry
815+
// - Manual retry succeeds and cancels the pending retry
816+
// - Verifies the canceled ID matches the scheduled ID
817817

818818
// Setup test data
819819
tenant := models.Tenant{ID: idgen.String()}
@@ -823,15 +823,20 @@ func TestManualDelivery_Success(t *testing.T) {
823823
event := testutil.EventFactory.Any(
824824
testutil.EventFactory.WithTenantID(tenant.ID),
825825
testutil.EventFactory.WithDestinationID(destination.ID),
826-
testutil.EventFactory.WithEligibleForRetry(true), // even with retry enabled
826+
testutil.EventFactory.WithEligibleForRetry(true),
827827
)
828828

829829
// Setup mocks
830830
destGetter := &mockDestinationGetter{dest: &destination}
831831
eventGetter := newMockEventGetter()
832832
eventGetter.registerEvent(&event)
833833
retryScheduler := newMockRetryScheduler()
834-
publisher := newMockPublisher([]error{nil}) // successful publish
834+
publishErr := &destregistry.ErrDestinationPublishAttempt{
835+
Err: errors.New("webhook returned 500"),
836+
Provider: "webhook",
837+
Data: map[string]interface{}{"error": "server_error"},
838+
}
839+
publisher := newMockPublisher([]error{publishErr, nil}) // first fails, second succeeds
835840
logPublisher := newMockLogPublisher(nil)
836841
alertMonitor := newMockAlertMonitor()
837842

@@ -850,28 +855,39 @@ func TestManualDelivery_Success(t *testing.T) {
850855
idempotence.New(testutil.CreateTestRedisClient(t), idempotence.WithSuccessfulTTL(24*time.Hour)),
851856
)
852857

853-
// Create and handle message
854-
deliveryEvent := models.DeliveryEvent{
858+
// Step 1: Automatic delivery fails and schedules retry
859+
autoDeliveryEvent := models.DeliveryEvent{
855860
ID: idgen.DeliveryEvent(),
856861
Event: event,
857862
DestinationID: destination.ID,
858-
Manual: true, // Manual delivery
863+
Manual: false,
859864
}
860-
mockMsg, msg := newDeliveryMockMessage(deliveryEvent)
861-
862-
// Handle message
863-
err := handler.Handle(context.Background(), msg)
865+
_, autoMsg := newDeliveryMockMessage(autoDeliveryEvent)
866+
_ = handler.Handle(context.Background(), autoMsg)
867+
868+
require.Len(t, retryScheduler.taskIDs, 1, "should schedule one retry")
869+
scheduledRetryID := retryScheduler.taskIDs[0]
870+
871+
// Step 2: Manual retry succeeds and cancels pending retry
872+
manualDeliveryEvent := models.DeliveryEvent{
873+
ID: idgen.DeliveryEvent(), // New delivery event ID
874+
Event: event, // Same event
875+
DestinationID: destination.ID, // Same destination
876+
Manual: true,
877+
}
878+
mockMsg, manualMsg := newDeliveryMockMessage(manualDeliveryEvent)
879+
err := handler.Handle(context.Background(), manualMsg)
864880
require.NoError(t, err)
865881

866882
// Assert behavior
867883
assert.True(t, mockMsg.acked, "message should be acked")
868884
assert.False(t, mockMsg.nacked, "message should not be nacked")
869-
assert.Equal(t, 1, publisher.current, "should publish once")
870-
assert.Len(t, retryScheduler.canceled, 1, "should cancel pending retries")
871-
assert.Equal(t, deliveryEvent.GetRetryID(), retryScheduler.canceled[0], "should cancel with correct retry ID")
872-
require.Len(t, logPublisher.deliveries, 1, "should have one delivery")
873-
assert.Equal(t, models.DeliveryStatusSuccess, logPublisher.deliveries[0].Delivery.Status, "delivery status should be OK")
874-
assertAlertMonitor(t, alertMonitor, true, &destination, nil)
885+
assert.Equal(t, 2, publisher.current, "should publish twice (auto + manual)")
886+
require.Len(t, retryScheduler.canceled, 1, "should cancel pending retry")
887+
888+
// Key assertion: the canceled ID must match the scheduled ID
889+
assert.Equal(t, scheduledRetryID, retryScheduler.canceled[0],
890+
"manual retry must cancel the same retry ID that was scheduled by automatic delivery")
875891
}
876892

877893
func TestManualDelivery_PublishError(t *testing.T) {
@@ -1243,3 +1259,87 @@ func assertAlertMonitor(t *testing.T, m *mockAlertMonitor, success bool, destina
12431259
assert.Nil(t, attempt.DeliveryResponse, "alert attempt should not have data")
12441260
}
12451261
}
1262+
1263+
func TestMessageHandler_RetryID_MultipleDestinations(t *testing.T) {
1264+
// Test scenario:
1265+
// - One event is delivered to TWO different destinations
1266+
// - Both deliveries fail with retryable errors
1267+
// - Each should schedule a retry with a UNIQUE task ID
1268+
//
1269+
// This verifies that retry scheduling works correctly when one event fans out to multiple
1270+
// destinations. Each destination must get its own retry with a unique task ID, otherwise
1271+
// retries overwrite each other in the scheduler and only the last destination gets retried.
1272+
1273+
// Setup test data
1274+
tenant := models.Tenant{ID: idgen.String()}
1275+
destination1 := testutil.DestinationFactory.Any(
1276+
testutil.DestinationFactory.WithType("webhook"),
1277+
testutil.DestinationFactory.WithTenantID(tenant.ID),
1278+
)
1279+
destination2 := testutil.DestinationFactory.Any(
1280+
testutil.DestinationFactory.WithType("webhook"),
1281+
testutil.DestinationFactory.WithTenantID(tenant.ID),
1282+
)
1283+
event := testutil.EventFactory.Any(
1284+
testutil.EventFactory.WithTenantID(tenant.ID),
1285+
testutil.EventFactory.WithEligibleForRetry(true),
1286+
)
1287+
1288+
// Setup mocks
1289+
destGetter := newMockMultiDestinationGetter()
1290+
destGetter.registerDestination(&destination1)
1291+
destGetter.registerDestination(&destination2)
1292+
eventGetter := newMockEventGetter()
1293+
eventGetter.registerEvent(&event)
1294+
retryScheduler := newMockRetryScheduler()
1295+
publishErr := &destregistry.ErrDestinationPublishAttempt{
1296+
Err: errors.New("webhook returned 500"),
1297+
Provider: "webhook",
1298+
Data: map[string]interface{}{"error": "server_error"},
1299+
}
1300+
publisher := newMockPublisher([]error{publishErr, publishErr}) // Both fail
1301+
logPublisher := newMockLogPublisher(nil)
1302+
alertMonitor := newMockAlertMonitor()
1303+
1304+
// Setup message handler
1305+
handler := deliverymq.NewMessageHandler(
1306+
testutil.CreateTestLogger(t),
1307+
logPublisher,
1308+
destGetter,
1309+
eventGetter,
1310+
publisher,
1311+
testutil.NewMockEventTracer(nil),
1312+
retryScheduler,
1313+
&backoff.ConstantBackoff{Interval: 1 * time.Second},
1314+
10,
1315+
alertMonitor,
1316+
idempotence.New(testutil.CreateTestRedisClient(t), idempotence.WithSuccessfulTTL(24*time.Hour)),
1317+
)
1318+
1319+
// Create delivery events for SAME event to DIFFERENT destinations
1320+
deliveryEvent1 := models.DeliveryEvent{
1321+
ID: idgen.DeliveryEvent(),
1322+
Event: event,
1323+
DestinationID: destination1.ID,
1324+
}
1325+
deliveryEvent2 := models.DeliveryEvent{
1326+
ID: idgen.DeliveryEvent(),
1327+
Event: event,
1328+
DestinationID: destination2.ID,
1329+
}
1330+
1331+
// Handle both messages
1332+
_, msg1 := newDeliveryMockMessage(deliveryEvent1)
1333+
_, msg2 := newDeliveryMockMessage(deliveryEvent2)
1334+
1335+
_ = handler.Handle(context.Background(), msg1)
1336+
_ = handler.Handle(context.Background(), msg2)
1337+
1338+
// Assert: Both retries should be scheduled
1339+
require.Len(t, retryScheduler.taskIDs, 2, "should schedule 2 retries")
1340+
1341+
// Assert: Task IDs should be UNIQUE (this is the bug - currently they are the same)
1342+
assert.NotEqual(t, retryScheduler.taskIDs[0], retryScheduler.taskIDs[1],
1343+
"BUG: retry task IDs should be unique per destination, but both are: %s",
1344+
retryScheduler.taskIDs[0])
1345+
}

internal/deliverymq/mock_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,29 @@ func (m *mockDestinationGetter) RetrieveDestination(ctx context.Context, tenantI
9191
return m.dest, m.err
9292
}
9393

94+
// mockMultiDestinationGetter supports multiple destinations keyed by destination ID
95+
type mockMultiDestinationGetter struct {
96+
destinations map[string]*models.Destination
97+
err error
98+
}
99+
100+
func newMockMultiDestinationGetter() *mockMultiDestinationGetter {
101+
return &mockMultiDestinationGetter{
102+
destinations: make(map[string]*models.Destination),
103+
}
104+
}
105+
106+
func (m *mockMultiDestinationGetter) registerDestination(dest *models.Destination) {
107+
m.destinations[dest.ID] = dest
108+
}
109+
110+
func (m *mockMultiDestinationGetter) RetrieveDestination(ctx context.Context, tenantID, destID string) (*models.Destination, error) {
111+
if m.err != nil {
112+
return nil, m.err
113+
}
114+
return m.destinations[destID], nil
115+
}
116+
94117
type mockEventGetter struct {
95118
events map[string]*models.Event
96119
err error

internal/models/event.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,16 @@ func (e *DeliveryEvent) ToMessage() (*mqs.Message, error) {
9797
}
9898

9999
// GetRetryID returns the ID used for scheduling retries.
100-
// We use Event.ID instead of DeliveryEvent.ID because:
101-
// 1. Each event should only have one scheduled retry at a time
102-
// 2. Event.ID is always accessible, while DeliveryEvent.ID may require additional queries in retry scenarios
100+
//
101+
// We use Event.ID + DestinationID (not DeliveryEvent.ID) because:
102+
// 1. Multiple destinations: The same event can be delivered to multiple destinations.
103+
// Each needs its own retry, so we include DestinationID to avoid collisions.
104+
// 2. Manual retry cancellation: When a manual retry succeeds, it must cancel any
105+
// pending automatic retry. Manual retries create a NEW DeliveryEvent with a NEW ID,
106+
// but share the same Event.ID + DestinationID. This allows Cancel() to find and
107+
// remove the pending automatic retry.
103108
func (e *DeliveryEvent) GetRetryID() string {
104-
return e.Event.ID
109+
return e.Event.ID + ":" + e.DestinationID
105110
}
106111

107112
func NewDeliveryEvent(event Event, destinationID string) DeliveryEvent {

0 commit comments

Comments
 (0)