Skip to content

Commit 0b56915

Browse files
authored
[ING-543] fix(events-processor): Select the same charge filter as Rails (#774)
1 parent 530a0f3 commit 0b56915

3 files changed

Lines changed: 196 additions & 40 deletions

File tree

events-processor/models/flat_filters.go

Lines changed: 57 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -177,51 +177,68 @@ func (ff *FlatFilter) ToDefaultFilter() *FlatFilter {
177177
return defaultFilter
178178
}
179179

180+
// MatchingFilter returns the filter the event belongs to, the charge's default bucket when none
181+
// matches.
182+
//
183+
// NOTE: This must return the same filter as the Ruby ChargeFilters::EventMatchingService, which
184+
// does the same job for organizations on the Postgres events store. Both the usage cache key and
185+
// the enriched event are built from it, so picking another one leaves the usage cache expiring a
186+
// key the usage reader never wrote to.
180187
func MatchingFilter(filters []FlatFilter, event *EnrichedEvent) *FlatFilter {
181-
// Multiple filters are present, identify the best match
182-
if len(filters) > 1 {
183-
// First select all matching filters
184-
matchingFilters := make([]FlatFilter, 0)
185-
for _, filter := range filters {
186-
if filter.HasFilters() && filter.IsMatchingEvent(event).Value() {
187-
matchingFilters = append(matchingFilters, filter)
188-
}
188+
var bestFilter *FlatFilter
189+
190+
for i := range filters {
191+
filter := &filters[i]
192+
if !filter.HasFilters() || !filter.IsMatchingEvent(event).Value() {
193+
continue
189194
}
190195

191-
// No filters matches the event
192-
if len(matchingFilters) == 0 {
193-
// Return the charge's default bucket
194-
return filters[0].ToDefaultFilter()
195-
196-
} else {
197-
// NOTE: Multiple filters match the event (parent/child filters),
198-
// We must take only the one matching the most properties
199-
var bestFilter *FlatFilter
200-
for _, filter := range matchingFilters {
201-
if bestFilter == nil {
202-
bestFilter = &filter
203-
continue
204-
}
205-
206-
if len(filter.Filters.Keys()) > len(bestFilter.Filters.Keys()) {
207-
bestFilter = &filter
208-
}
209-
}
210-
211-
// Return the best match
212-
return bestFilter
196+
if bestFilter == nil || filter.isBetterMatchThan(bestFilter) {
197+
bestFilter = filter
213198
}
199+
}
214200

215-
} else {
216-
filter := filters[0]
201+
// No filter matches the event, it falls into the charge's default bucket
202+
if bestFilter == nil {
203+
return filters[0].ToDefaultFilter()
204+
}
217205

218-
// Check if the only filter is matching the event
219-
if filter.HasFilters() && filter.IsMatchingEvent(event).Value() {
220-
// Return the only matching filter
221-
return &filter
222-
} else {
223-
// Otherwise, return the charge's default bucket
224-
return filter.ToDefaultFilter()
225-
}
206+
return bestFilter
207+
}
208+
209+
// isBetterMatchThan reports whether ff must be preferred over other: it matches more of the event
210+
// properties, or as many but is older.
211+
//
212+
// NOTE: Ruby takes the first filter matching the most properties out of charge.filters, ordered by
213+
// the ChargeFilter default scope (order(updated_at: :asc)), hence the tie-break on
214+
// ChargeFilterUpdatedAt. The one on ChargeFilterID has no Ruby counterpart (Postgres resolves an
215+
// ORDER BY tie arbitrarily), it only keeps us deterministic when two filters share a timestamp,
216+
// rather than falling back to the order the filters were read in.
217+
func (ff *FlatFilter) isBetterMatchThan(other *FlatFilter) bool {
218+
if keys, otherKeys := len(ff.Filters.Keys()), len(other.Filters.Keys()); keys != otherKeys {
219+
return keys > otherKeys
220+
}
221+
222+
updatedAt, otherUpdatedAt := ff.chargeFilterUpdatedAt(), other.chargeFilterUpdatedAt()
223+
if !updatedAt.Equal(otherUpdatedAt) {
224+
return updatedAt.Before(otherUpdatedAt)
226225
}
226+
227+
return ff.chargeFilterID() < other.chargeFilterID()
228+
}
229+
230+
func (ff *FlatFilter) chargeFilterUpdatedAt() time.Time {
231+
if ff.ChargeFilterUpdatedAt == nil {
232+
return time.Time{}
233+
}
234+
235+
return *ff.ChargeFilterUpdatedAt
236+
}
237+
238+
func (ff *FlatFilter) chargeFilterID() string {
239+
if ff.ChargeFilterID == nil {
240+
return ""
241+
}
242+
243+
return *ff.ChargeFilterID
227244
}

events-processor/models/flat_filters_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,66 @@ func TestMatchingFilter(t *testing.T) {
457457
assert.Equal(t, result, flatFilter2)
458458
})
459459
}
460+
461+
func TestMatchingFilterTieBreak(t *testing.T) {
462+
older := time.Now().Add(-time.Hour)
463+
newer := time.Now()
464+
465+
buildFilter := func(id string, updatedAt time.Time, values FlatFilterValues) FlatFilter {
466+
return FlatFilter{
467+
OrganizationID: "org_id",
468+
BillableMetricCode: "api_call",
469+
PlanID: "plan_id",
470+
ChargeID: "charge_id",
471+
ChargeUpdatedAt: updatedAt,
472+
ChargeFilterID: &id,
473+
ChargeFilterUpdatedAt: &updatedAt,
474+
Filters: &values,
475+
}
476+
}
477+
478+
// NOTE: the flat_filters view expands a __ALL_FILTER_VALUES__ filter to the billable metric
479+
// filter values, so it becomes indistinguishable from an explicit filter when the metric
480+
// declares that single value. Ruby buckets the usage under the oldest of the two.
481+
t.Run("it should return the oldest of two value-equivalent filters", func(t *testing.T) {
482+
event := EnrichedEvent{
483+
Properties: map[string]any{"model": "m1"},
484+
}
485+
486+
allValues := buildFilter("all_values_filter_id", older, FlatFilterValues{"model": []string{"m1"}})
487+
explicit := buildFilter("explicit_filter_id", newer, FlatFilterValues{"model": []string{"m1"}})
488+
489+
// The filters must not be picked out of the order they are read in
490+
result := MatchingFilter([]FlatFilter{allValues, explicit}, &event)
491+
assert.Equal(t, "all_values_filter_id", *result.ChargeFilterID)
492+
493+
result = MatchingFilter([]FlatFilter{explicit, allValues}, &event)
494+
assert.Equal(t, "all_values_filter_id", *result.ChargeFilterID)
495+
})
496+
497+
t.Run("it should return the smallest filter id when they share a timestamp", func(t *testing.T) {
498+
event := EnrichedEvent{
499+
Properties: map[string]any{"model": "m1"},
500+
}
501+
502+
filter1 := buildFilter("filter_id1", older, FlatFilterValues{"model": []string{"m1"}})
503+
filter2 := buildFilter("filter_id2", older, FlatFilterValues{"model": []string{"m1"}})
504+
505+
result := MatchingFilter([]FlatFilter{filter2, filter1}, &event)
506+
507+
assert.Equal(t, "filter_id1", *result.ChargeFilterID)
508+
})
509+
510+
t.Run("it should return the most specific filter even when it is the newest", func(t *testing.T) {
511+
event := EnrichedEvent{
512+
Properties: map[string]any{"scheme": "visa", "method": "debit"},
513+
}
514+
515+
parent := buildFilter("parent_filter_id", older, FlatFilterValues{"scheme": []string{"visa"}})
516+
child := buildFilter("child_filter_id", newer, FlatFilterValues{"scheme": []string{"visa"}, "method": []string{"debit"}})
517+
518+
result := MatchingFilter([]FlatFilter{parent, child}, &event)
519+
520+
assert.Equal(t, "child_filter_id", *result.ChargeFilterID)
521+
})
522+
}

events-processor/processors/events_processor/enrichment_service_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,3 +1056,79 @@ func TestEvaluateExpression(t *testing.T) {
10561056
assert.Equal(t, "36", event.Properties["total_value"])
10571057
})
10581058
}
1059+
1060+
func TestEnrichEventWithOverlappingFilters(t *testing.T) {
1061+
// NOTE: the flat_filters view expands a __ALL_FILTER_VALUES__ filter to the billable metric
1062+
// filter values, so it becomes indistinguishable from an explicit filter when the metric
1063+
// declares that single value. The event must be enriched with the same filter the Ruby
1064+
// usage reader buckets the usage under, the oldest one, otherwise the usage cache is
1065+
// expired on a key that is never written to.
1066+
t.Run("With two value-equivalent filters on the same charge", func(t *testing.T) {
1067+
testEnv := setupEnrichmentTestEnv(t, false)
1068+
defer testEnv.Cleanup()
1069+
1070+
event := models.Event{
1071+
OrganizationID: "1a901a90-1a90-1a90-1a90-1a901a901a90",
1072+
ExternalSubscriptionID: "sub_id",
1073+
Code: "api_calls",
1074+
Timestamp: 1741007009.0,
1075+
Properties: map[string]any{"value": "12.12", "model": "m1"},
1076+
Source: "SQS",
1077+
}
1078+
1079+
bm := &models.BillableMetric{
1080+
ID: "bm123",
1081+
OrganizationID: event.OrganizationID,
1082+
Code: event.Code,
1083+
AggregationType: models.AggregationTypeSum,
1084+
FieldName: "value",
1085+
CreatedAt: utils.NowNullTime(),
1086+
UpdatedAt: utils.NowNullTime(),
1087+
}
1088+
testEnv.DataStore.SetBillableMetric(bm)
1089+
1090+
sub := &models.Subscription{
1091+
ID: "sub123",
1092+
OrganizationID: &event.OrganizationID,
1093+
ExternalID: event.ExternalSubscriptionID,
1094+
PlanID: "plan_id",
1095+
StartedAt: utils.NewNullTime(time.Unix(1700000000, 0)),
1096+
}
1097+
testEnv.DataStore.SetSubscription(sub)
1098+
1099+
older := time.Now().Add(-time.Hour)
1100+
newer := time.Now()
1101+
1102+
// The newest filter comes first, so reading the filters in order picks the wrong one
1103+
testEnv.DataStore.SetFlatFilters([]*models.FlatFilter{
1104+
{
1105+
OrganizationID: event.OrganizationID,
1106+
BillableMetricCode: event.Code,
1107+
PlanID: "plan_id",
1108+
ChargeID: "charge1",
1109+
ChargeUpdatedAt: newer,
1110+
ChargeFilterID: utils.StringPtr("explicit_filter_id"),
1111+
ChargeFilterUpdatedAt: &newer,
1112+
Filters: &models.FlatFilterValues{"model": []string{"m1"}},
1113+
},
1114+
{
1115+
OrganizationID: event.OrganizationID,
1116+
BillableMetricCode: event.Code,
1117+
PlanID: "plan_id",
1118+
ChargeID: "charge1",
1119+
ChargeUpdatedAt: newer,
1120+
ChargeFilterID: utils.StringPtr("all_values_filter_id"),
1121+
ChargeFilterUpdatedAt: &older,
1122+
Filters: &models.FlatFilterValues{"model": []string{"m1"}},
1123+
},
1124+
})
1125+
1126+
enrichResult := testEnv.EventProcessor.EnrichEvent(&event)
1127+
assert.True(t, enrichResult.Success())
1128+
assert.Equal(t, 1, len(enrichResult.Value()))
1129+
1130+
eventResult := enrichResult.Value()[0]
1131+
assert.Equal(t, "all_values_filter_id", *eventResult.ChargeFilterID)
1132+
assert.Equal(t, older.UTC(), eventResult.ChargeFilterUpdatedAt.UTC())
1133+
})
1134+
}

0 commit comments

Comments
 (0)