Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restructure alerts engine to merge new results into cache instead of delete/write #864

Merged
merged 8 commits into from
Jan 6, 2025
7 changes: 3 additions & 4 deletions lib/engine/alerts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule Engine.Alerts do
use GenServer
require Logger
alias Engine.Alerts.Fetcher
alias Engine.ETS

@type ets_tables :: %{
stops_table: :ets.tab(),
Expand Down Expand Up @@ -101,10 +102,8 @@ defmodule Engine.Alerts do

case state.fetcher.get_statuses(state.all_route_ids) do
{:ok, %{:stop_statuses => stop_statuses, :route_statuses => route_statuses}} ->
:ets.delete_all_objects(state.tables.stops_table)
:ets.insert(state.tables.stops_table, Enum.into(stop_statuses, []))
:ets.delete_all_objects(state.tables.routes_table)
:ets.insert(state.tables.routes_table, Enum.into(route_statuses, []))
ETS.replace_contents(state.tables.routes_table, route_statuses)
ETS.replace_contents(state.tables.stops_table, stop_statuses)

Logger.info(
"Engine.Alerts Stop alert statuses: #{inspect(stop_statuses)} Route alert statuses #{inspect(route_statuses)}"
Expand Down
34 changes: 34 additions & 0 deletions lib/engine/ets.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule Engine.ETS do
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also thought about naming this module Engine.Cache since ETS is acting as our cache.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm yeah interesting thought - it's true that ETS essentially acts as our cache, although I'm not sure how I feel about abstracting it as such. It could help the cleanliness of some of the engine logic if we abstracted away the direct calls to :ets I suppose. Curious to see if @panentheos has thoughts

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree with Paul, I'm not sure there's much to be gained from adding new terminology here. Also, we usually use the name Engine to describe GenServer processes that are actively doing something, like polling data repeatedly. Since these are just functions, I'd probably file them under something with "Utilities" in the name, or just add them to one of the existing utility modules if they fit there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Gotcha, that distinction for engines makes sense to me! Given the other common use cases you pointed out, I think having this as an ETSUtilities module works well

# Safely replaces table contents.
#
# ETS doesn't support atomic bulk writes, so we can't just clear the whole table
# (:ets.delete_all_objects/1) and then insert all of the new entries (:ets.insert/2),
# because that would leave the table completely empty for a short period,
# causing any concurrent reads during that time to fail.
#
# Instead, we remove only the table entries that are absent from new_entries.
def replace_contents(table, new_entry) when is_tuple(new_entry) do
replace_contents(table, [new_entry])
end

def replace_contents(table, new_entries) do
new_keys = MapSet.new(new_entries, &elem(&1, 0))
current_table_keys = keys(table)

removed_keys = MapSet.difference(current_table_keys, new_keys)
Enum.each(removed_keys, &:ets.delete(table, &1))

:ets.insert(table, Enum.into(new_entries, []))
end

# Returns a MapSet of all keys in the table.
robbie-sundstrom marked this conversation as resolved.
Show resolved Hide resolved
defp keys(table) do
keys(table, :ets.first(table), [])
end

defp keys(_table, :"$end_of_table", acc), do: MapSet.new(acc)

defp keys(table, key, acc) do
keys(table, :ets.next(table, key), [key | acc])
end
end
68 changes: 68 additions & 0 deletions test/engine/ets_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
defmodule ETSTest do
use ExUnit.Case
alias Engine.ETS

# Helper to create a temporary ETS table
defp create_table do
:ets.new(:test_table, [:named_table, :set, :public, {:keypos, 1}])
end

setup do
# Create a fresh table before each test
table = create_table()
{:ok, table: table}
end

test "inserts single tuple into an empty table", %{table: table} do
# Act
ETS.replace_contents(table, {:key1, "value1"})

# Assert
assert {:key1, "value1"} in :ets.tab2list(table)
end

test "inserts a single entry when new entry is a tuple", %{table: table} do
# Act
ETS.replace_contents(table, {:key1, "value1"})

# Assert
assert {:key1, "value1"} in :ets.tab2list(table)
end

test "inserts multiple entries when new entries is a list of tuples", %{table: table} do
# Act
ETS.replace_contents(table, [{:key1, "value1"}, {:key2, "value2"}, {:key3, "value3"}])

# Assert
assert {:key1, "value1"} in :ets.tab2list(table)
assert {:key2, "value2"} in :ets.tab2list(table)
assert {:key3, "value3"} in :ets.tab2list(table)
end

test "replaces existing entries with new ones", %{table: table} do
# Arrange
:ets.insert(table, {:key1, "old_value1"})
:ets.insert(table, {:key2, "old_value2"})

# Act
ETS.replace_contents(table, [{:key1, "new_value1"}, {:key3, "new_value3"}])

# Assert
assert {:key1, "new_value1"} in :ets.tab2list(table)
assert {:key3, "new_value3"} in :ets.tab2list(table)
refute {:key2, "old_value2"} in :ets.tab2list(table)
end

test "removes keys that are not in the new entries", %{table: table} do
# Arrange
:ets.insert(table, {:key1, "old_value1"})
:ets.insert(table, {:key2, "old_value2"})

# Act
ETS.replace_contents(table, [{:key1, "new_value1"}])

# Assert
assert {:key1, "new_value1"} in :ets.tab2list(table)
refute {:key2, "old_value2"} in :ets.tab2list(table)
end
end
Loading