Skip to content

fix(alerts): performance improvements for filtering alerts by informed entity values - #1100

Open
rudiejd wants to merge 7 commits into
masterfrom
fix/improve-alerts-filter-performance
Open

rudiejd wants to merge 7 commits into
masterfrom
fix/improve-alerts-filter-performance

Conversation

@rudiejd

@rudiejd rudiejd commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Summary of changes

Asana Tecket: 🍎 Figure out what went wrong with removing alerts hook

Problem:
Long, meandering Slack thread in which I crash out here: https://mbta.slack.com/archives/C06D663HRRN/p1790722806365799

The main performance problem boils down to the fact that nil gets repeated in the State.Alert.InformedEntity matchers for each element in a list.
For example, if you pass filter[stop_id]=1,2,3,4&filter[trip_id]=1,2,3,4 you end up with matchers for the collections [1, nil, 2, nil, 3, nil, 4, nil] * (cartesian product) 1,[nil, 2, nil, 3, nil, 4, nil]. So you get 8 * 8 = 64 matchers instead of what you want, which is [1, 2, 3, 4, nil] * [1, 2, 3, 4, nil] or 25 matchers.

Solution:
Make the matchers unique. This is only a linear improvement in runtime with respect to number of items in the list filters. I expect that you could get an exponential improvement (e.g. O(n^2) matchers -> O(1) matchers) if you used :ets.fun2ms or Ex2ms to generate one matcher that satisfies all conditions.

Also, I set the key for the InformedEntity ETS table to be the alert ID, which I think will result in more efficient in-memory representation of the table. The old key was the name of the struct (ETS defaults to using the first item in the tuple as the key), which obviously is not unique.

For future work, I expect that you could get the growth of matchers with respect to input list items from O(n^2) to O(1) if you used :ets.fun2ms/1 or Ex2ms to generated a compound match expression that covers all of the criteria instead of trying to create many matchers.

🤖 AI Disclosure: I was banging my head against the wall on this and used copilot to point me at the right line. I already knew basically what file the problem was in, and what category of problem it was. So I pointed Copilot at informed_entity.ex and asked for ideas on what could degrade ETS performance. Then I profiled some of the suggestions. The code written is all my own.

--

Stack created with GitHub Stacks CLI • Give Feedback 💬

@rudiejd
rudiejd requested a review from a team as a code owner October 1, 2026 20:16
@rudiejd
rudiejd added this pull request to stack #1101 October 1, 2026 20:16
Base automatically changed from chore/update-mint to master October 1, 2026 20:33
@rudiejd
rudiejd force-pushed the fix/improve-alerts-filter-performance branch 2 times, most recently from 44a3512 to f5791bb Compare October 1, 2026 20:33
end

describe "match/1" do
test "can return facility alerts" do

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the order of matchers shouldn't matter anyway here since they are ALL being applied (in other words, all matchers are logically OR'd together like trip_id == 1234 AND stop_id == nil OR trip_id == nil AND stop_id == 1234... ad. inf.) and the set isn't ordered

@rudiejd
rudiejd requested review from a team and jadrian and removed request for a team October 1, 2026 23:08
facility: facility
}
facility <- part_values(matcher, :facility),
reduce: MapSet.new() do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of reduce, I think you can use uniq: true. https://elixir.hexdocs.pm/Kernel.SpecialForms.html#for/1-the-into-and-uniq-options

Though see my next comment; I think you want to unique-ify things at a higher call level.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
facility <- part_values(matcher, :facility),
reduce: MapSet.new() do
# we create one nil selector for each parameter that's passed in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how the duplication you've described is happening here. IIUC, part_values() returns at worst a two-element list with nil as the second element. Since there are no duplicated nils in any one list, then there is no duplication in the cartesian product of the generators, and so no reason to unique-ify here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch on this! I originally was doing Enum.uniq\1 in the match function, but I decided to get cute and evidently ended up doing it in the wrong place. 5fb6ab0

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change the changes in the tests redundant

@rudiejd
rudiejd force-pushed the fix/improve-alerts-filter-performance branch 2 times, most recently from e269be6 to d65f2be Compare October 2, 2026 16:05
@rudiejd

rudiejd commented Oct 2, 2026 •

Copy link
Copy Markdown
Member Author

Load test results.

Query: https://api-dev-green.mbtace.com/alerts?filter%5Bstop%5D=70079,70080,74611,74617,84611,NEC-2287,NEC-2287-01,NEC-2287-02,NEC-2287-03,NEC-2287-04,NEC-2287-05,NEC-2287-06,NEC-2287-07,NEC-2287-08,NEC-2287-09,NEC-2287-10,NEC-2287-11,NEC-2287-12,NEC-2287-13,32001,32002,32003,32004,32005,70101,70102,MM-0079-S&filter%5Btrip%5D=76734708,76734709,SouthBase-826039-1925,ADDED-1584585658,76734711,ADDED-1584585797,SouthBase-825905-1027,76734713,76734714,SouthBase-825930-1077

master/prod:


  █ TOTAL RESULTS

    HTTP
    http_req_duration..............: avg=4.51s min=1.42s med=2.95s max=17.29s p(90)=10.38s p(95)=11.33s
      { expected_response:true }...: avg=4.51s min=1.42s med=2.95s max=17.29s p(90)=10.38s p(95)=11.33s
    http_req_failed................: 0.00%  0 out of 201
    http_reqs......................: 201    4.019886/s

    EXECUTION
    iteration_duration.............: avg=5.02s min=1.98s med=3.45s max=17.79s p(90)=10.93s p(95)=11.89s
    iterations.....................: 197    3.939889/s
    vus............................: 1      min=1        max=49
    vus_max........................: 50     min=50       max=50

    NETWORK
    data_received..................: 3.2 MB 63 kB/s
    data_sent......................: 143 kB 2.9 kB/s

feature/dev-green:

  █ TOTAL RESULTS

    HTTP
    http_req_duration..............: avg=2.45s min=322.18ms med=2.21s max=7.82s p(90)=4.57s p(95)=5.01s
      { expected_response:true }...: avg=2.45s min=322.18ms med=2.21s max=7.82s p(90)=4.57s p(95)=5.01s
    http_req_failed................: 0.00%  0 out of 364
    http_reqs......................: 364    7.279849/s

    EXECUTION
    iteration_duration.............: avg=2.98s min=823.44ms med=2.75s max=8.33s p(90)=5.08s p(95)=5.53s
    iterations.....................: 356    7.119853/s
    vus............................: 1      min=1        max=49
    vus_max........................: 50     min=50       max=50

    NETWORK
    data_received..................: 13 MB  257 kB/s
    data_sent......................: 209 kB 4.2 kB/s

@rudiejd
rudiejd requested a review from jadrian October 2, 2026 17:40
# we create one nil selector for each parameter that's passed in
# so when a list is passed in, there is a nil selector for every item in the list
# meaning with a list length of N there are N - 1 redundant selectors)
# hence the use of a map set to remove duplicates here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update this comment; you're no longer removing duplicates. In fact I think you could revert the whole comment entirely, or perhaps adapt it to the comprehension on line 19.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -32,27 +32,34 @@ defmodule State.Alert.InformedEntityTest do

describe "match/1" do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way to create a test that would only pass with your change in place?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can easily do this without leaking some of the private details of the InformedEntiy module. I think the more important thing to test (performance) is non-functional here, and is validated by the load tests

@rudiejd
rudiejd requested a review from jadrian October 2, 2026 18:59

def new(table \\ @table) do
^table = :ets.new(table, [:named_table, :duplicate_bag, {:read_concurrency, true}])
# keypos 2 here sets the key for the ETS table to be the alert ID, which makes selecting by alert ID faster

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please wrap at 100 characters

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# we create one nil selector for each parameter that's passed in
# so when a list is passed in, there is a nil selector for every item in the list
# meaning with a list length of N there are N - 1 redundant selectors)
# hence the use of a map set to remove duplicates here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/a map set to remove duplicates/uniq/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uniq: true do
# we create one nil selector for each parameter that's passed in
# so when a list is passed in, there is a nil selector for every item in the list
# meaning with a list length of N there are N - 1 redundant selectors)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please fix dangling close-paren

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rudiejd
rudiejd force-pushed the fix/improve-alerts-filter-performance branch from 34754fb to 53c7907 Compare October 2, 2026 20:03
@rudiejd
rudiejd requested a review from jadrian October 2, 2026 20:12
@rudiejd
rudiejd force-pushed the fix/improve-alerts-filter-performance branch from 53c7907 to 6b92efa Compare October 2, 2026 20:23
# We create one nil selector for each combination in the Cartesian product of
# parameters that are passed in, with one `nil` for each element in each list. For example,
# with stops 1, 2 and trips 1, 2 we get all of these combinations including nil:
# [(1, nil), (1, nil), (2, nil), (2, nil), (nil, 1), (nil, 1), (nil, 2) (nil, 2)].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I understand this comment but I think if I hadn't been reviewing this PR, it would confuse me. A small change would clarify: "stops 1, 2 and trips A, B".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, wouldn't we get an additional four copies of a (nil, nil) combination?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants