Skip to content

Commit 253dfb6

Browse files
authored
fix: preserve correlation ID in withdrawal events and guard uncompiled eligibility (#2040)
* fix: preserve correlation ID in withdrawal status events and guard uncompiled eligibility rules Pass the existing correlation ID from context into buildWithdrawalStatusEvent instead of generating a fresh UUID, maintaining distributed trace continuity across the withdrawal saga. Return an Internal error in validateProductTypeConstraints when EligibilityCEL is non-empty but EligibilityProgram is nil, preventing silent bypass of eligibility checks when the CEL compiler is not configured. * fix: fall back to new UUID when context has no correlation ID * fix: seed correlation ID at handler level in ExecuteWithdrawal Extract correlation ID from context/metadata at the top of the ExecuteWithdrawal handler and seed it into the context before passing it to the orchestrator and completePendingWithdrawal. This ensures a single trace ID flows through the entire request lifecycle instead of generating a new UUID in completeWithdrawalWithOutbox when the context value was not set. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 79e87f5 commit 253dfb6

3 files changed

Lines changed: 45 additions & 4 deletions

File tree

services/current-account/service/grpc_withdrawal_execute.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import (
1313
"github.com/meridianhub/meridian/services/current-account/adapters/persistence"
1414
"github.com/meridianhub/meridian/services/current-account/domain"
1515
caobservability "github.com/meridianhub/meridian/services/current-account/observability"
16+
sharedclients "github.com/meridianhub/meridian/shared/pkg/clients"
1617
"github.com/meridianhub/meridian/shared/pkg/idempotency"
1718
"github.com/meridianhub/meridian/shared/platform/events"
1819
"github.com/meridianhub/meridian/shared/platform/events/topics"
20+
"github.com/meridianhub/meridian/shared/platform/observability"
1921
"github.com/meridianhub/meridian/shared/platform/tenant"
2022
"google.golang.org/grpc/codes"
2123
"google.golang.org/grpc/status"
@@ -73,6 +75,13 @@ func (s *Service) ExecuteWithdrawal(ctx context.Context, req *pb.ExecuteWithdraw
7375
}()
7476
}
7577

78+
// Extract correlation ID from context/metadata and seed it so all downstream calls share the same trace
79+
correlationID := sharedclients.ExtractCorrelationID(ctx)
80+
if correlationID == "" {
81+
correlationID = uuid.New().String()
82+
}
83+
ctx = observability.WithCorrelationID(ctx, correlationID)
84+
7685
// Retrieve and prepare account for withdrawal
7786
account, amount, transactionID, opStatus, err := s.prepareAccountForWithdrawal(ctx, accountID, reqAmount)
7887
if err != nil {
@@ -314,8 +323,8 @@ func (s *Service) completeWithdrawalWithOutbox(ctx context.Context, withdrawal *
314323
return s.completeWithdrawalDirect(ctx, withdrawal)
315324
}
316325

317-
// Create and marshal event payload
318-
eventPayload, err := buildWithdrawalStatusEvent(withdrawal, accountID)
326+
// Create and marshal event payload (correlation ID is seeded at handler level)
327+
eventPayload, err := buildWithdrawalStatusEvent(withdrawal, accountID, observability.GetCorrelationID(ctx))
319328
if err != nil {
320329
return err
321330
}
@@ -365,14 +374,14 @@ func (s *Service) completeWithdrawalDirect(ctx context.Context, withdrawal *doma
365374
}
366375

367376
// buildWithdrawalStatusEvent creates and marshals a WithdrawalStatusUpdatedEvent.
368-
func buildWithdrawalStatusEvent(withdrawal *domain.Withdrawal, accountID uuid.UUID) ([]byte, error) {
377+
func buildWithdrawalStatusEvent(withdrawal *domain.Withdrawal, accountID uuid.UUID, correlationID string) ([]byte, error) {
369378
now := time.Now().UTC()
370379
event := &eventsv1.WithdrawalStatusUpdatedEvent{
371380
EventId: uuid.New().String(),
372381
WithdrawalId: withdrawal.Reference,
373382
AccountId: accountID.String(),
374383
Status: "COMPLETED",
375-
CorrelationId: uuid.New().String(),
384+
CorrelationId: correlationID,
376385
CausationId: withdrawal.Reference,
377386
Timestamp: timestamppb.New(now),
378387
Version: int64(withdrawal.Version),

services/current-account/service/validators.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ func (s *Service) validateProductTypeConstraints(
151151
attributes map[string]string,
152152
productTypeCode, accountID string,
153153
) (string, error) {
154+
if cachedType.Definition.EligibilityCEL != "" && cachedType.EligibilityProgram == nil {
155+
return "eligibility_not_compiled",
156+
status.Errorf(codes.Internal, "eligibility rule is configured but not compiled for product type %s", productTypeCode)
157+
}
154158
if cachedType.EligibilityProgram != nil {
155159
if opStatus, err := s.checkEligibility(ctx, cachedType, partyID, attributes, productTypeCode, accountID); err != nil {
156160
return opStatus, err

services/current-account/service/validators_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
partyv1 "github.com/meridianhub/meridian/api/proto/meridian/party/v1"
1313
quantitypb "github.com/meridianhub/meridian/api/proto/meridian/quantity/v1"
1414
"github.com/meridianhub/meridian/services/current-account/domain"
15+
"github.com/meridianhub/meridian/services/reference-data/accounttype"
1516
"github.com/meridianhub/meridian/services/reference-data/cache"
1617
"github.com/meridianhub/meridian/services/reference-data/registry"
1718
"github.com/stretchr/testify/assert"
@@ -495,3 +496,30 @@ func (m *getPartyErrorClient) GetParty(_ context.Context, _ string) (*partyv1.Pa
495496
return nil, m.err
496497
}
497498
func (m *getPartyErrorClient) Close() error { return nil }
499+
500+
// ---------------------------------------------------------------------------
501+
// validateProductTypeConstraints
502+
// ---------------------------------------------------------------------------
503+
504+
func TestValidateProductTypeConstraints_EligibilityCELWithNilProgram(t *testing.T) {
505+
svc := &Service{
506+
logger: validatorsTestLogger(),
507+
}
508+
509+
def := &accounttype.Definition{
510+
EligibilityCEL: `party.type == "PERSON"`,
511+
}
512+
cachedType := &CachedAccountType{
513+
Definition: def,
514+
EligibilityProgram: nil,
515+
}
516+
517+
opStatus, err := svc.validateProductTypeConstraints(context.Background(), cachedType, "party-1", nil, "SAVINGS", "test-account")
518+
require.Error(t, err)
519+
520+
st, ok := status.FromError(err)
521+
require.True(t, ok, "expected gRPC status error")
522+
assert.Equal(t, codes.Internal, st.Code())
523+
assert.Contains(t, st.Message(), "eligibility rule is configured but not compiled")
524+
assert.Equal(t, "eligibility_not_compiled", opStatus)
525+
}

0 commit comments

Comments
 (0)