Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 132 additions & 59 deletions dev/ets_demo_node.exs
Original file line number Diff line number Diff line change
@@ -1,92 +1,165 @@
# Seeds a node with ETS tables covering every case the Voyager ETS UI handles.
#
# Run:
# elixir --name demo@127.0.0.1 --cookie demo ets_demo_node.exs
# dev/mock_nodes/ets.sh
#
# Then in Voyager connect to demo@127.0.0.1 with cookie "demo".

# --- Truncated-binary-key repro -------------------------------------------
# In :truncated_key_bug, one key is an 8000-byte binary.
# 1. Open the table, set "Budget per record" to 100, Fetch records.
# 2. The long key renders as a silent ~99-byte prefix; Lookup stays enabled.
# 3. Click Lookup -> "No record with this key. It may have been deleted."
# ...but the record exists (this script prints proof on request, see below).
:ets.new(:truncated_key_bug, [:set, :named_table, :public])
:ets.insert(:truncated_key_bug, {:binary.copy("K", 8000), :value_a})
:ets.insert(:truncated_key_bug, {"short-key", :value_b})

# --- General zoo -----------------------------------------------------------
:ets.new(:kv_set, [:set, :named_table, :public])

for i <- 1..137 do
:ets.insert(:kv_set, {i, %{name: "item_#{i}", tags: [:a, :b], score: i * 1.5}})
# Then in Voyager connect to ets@127.0.0.1 with cookie "demo".

defmodule Demo do
# Protected and private tables only accept writes from their owner, so every call runs there.
def run(fun) do
Agent.get(__MODULE__, fn _ ->
try do
{:ok, fun.()}
rescue
e -> {:error, e}
end
end)
end

def new(name, opts \\ [:set, :named_table, :public]), do: run(fn -> :ets.new(name, opts) end)
def put(tab, records), do: run(fn -> :ets.insert(tab, records) end)
def delete(tab, key), do: run(fn -> :ets.delete(tab, key) end)
def clear(tab), do: run(fn -> :ets.delete_all_objects(tab) end)
def drop(tab), do: run(fn -> :ets.delete(tab) end)

def give_away(tab), do: run(fn -> :ets.give_away(tab, sleeper(), nil) end)
def heir(tab), do: run(fn -> :ets.setopts(tab, {:heir, sleeper(), nil}) end)
def heir(tab, :none), do: run(fn -> :ets.setopts(tab, {:heir, :none}) end)

# Type, protection, keypos, named_table, compressed and concurrency are fixed at :ets.new.
def recreate(tab, changes) do
run(fn ->
info = Keyword.merge(:ets.info(tab), changes)
rows = :ets.tab2list(tab)
options = opts(info)
registered = :ets.whereis(info[:name])

if info[:named_table] and registered not in [:undefined, :ets.info(tab, :id)],
do: raise(ArgumentError, "#{inspect(info[:name])} is already a named table")

temporary = :ets.new(info[:name], List.delete(options, :named_table))
Comment thread
srzeszut marked this conversation as resolved.

try do
:ets.insert(temporary, rows)
after
:ets.delete(temporary)
end

:ets.delete(tab)
new_tab = :ets.new(info[:name], options)
:ets.insert(new_tab, rows)
Comment thread
srzeszut marked this conversation as resolved.
new_tab
end)
end

defp opts(info) do
flags = for flag <- [:named_table, :compressed], info[flag], do: flag
heir = if is_pid(info[:heir]), do: [{:heir, info[:heir], nil}], else: []

[
info[:type],
info[:protection],
keypos: info[:keypos],
read_concurrency: info[:read_concurrency],
write_concurrency: info[:write_concurrency],
decentralized_counters: info[:decentralized_counters]
] ++ flags ++ heir
end

defp sleeper, do: spawn(fn -> Process.sleep(:infinity) end)
end

:ets.new(:users_ordered, [:ordered_set, :named_table, :public])
{:ok, _} = Agent.start(fn -> nil end, name: Demo)

for i <- 1..23 do
:ets.insert(:users_ordered, {"user-#{String.pad_leading(to_string(i), 3, "0")}", i, :active})
end
{:ok, _} =
Demo.run(fn ->
# --- Truncated-binary-key repro -------------------------------------------
# In :truncated_key_bug, one key is an 8000-byte binary.
# 1. Open the table, set "Budget per record" to 100, Fetch records.
# 2. The long key renders as a silent ~99-byte prefix; Lookup stays enabled.
# 3. Click Lookup -> "No record with this key. It may have been deleted."
# ...but the record exists (this script prints proof on request, see below).
:ets.new(:truncated_key_bug, [:set, :named_table, :public])
:ets.insert(:truncated_key_bug, {:binary.copy("K", 8000), :value_a})
:ets.insert(:truncated_key_bug, {"short-key", :value_b})

# --- General zoo -----------------------------------------------------------
:ets.new(:kv_set, [:set, :named_table, :public])

for i <- 1..137 do
:ets.insert(:kv_set, {i, %{name: "item_#{i}", tags: [:a, :b], score: i * 1.5}})
end

:ets.new(:by_second_key, [:set, :named_table, :public, {:keypos, 2}])
:ets.insert(:by_second_key, {%{payload: 1}, :alpha, "extra"})
:ets.insert(:by_second_key, {%{payload: 2}, :beta, "extra2"})
:ets.new(:users_ordered, [:ordered_set, :named_table, :public])

:ets.new(:bag_events, [:bag, :named_table, :public])
:ets.insert(:bag_events, for(i <- 1..60, do: {:login, "user-#{i}", i}))
:ets.insert(:bag_events, {:logout, "alice", 61})
for i <- 1..23 do
:ets.insert(
:users_ordered,
{"user-#{String.pad_leading(to_string(i), 3, "0")}", i, :active}
)
end

:ets.new(:dup_bag, [:duplicate_bag, :named_table, :public])
:ets.insert(:dup_bag, List.duplicate({:x, 1}, 25))
:ets.insert(:dup_bag, {:y, 2})
:ets.new(:by_second_key, [:set, :named_table, :public, {:keypos, 2}])
:ets.insert(:by_second_key, {%{payload: 1}, :alpha, "extra"})
:ets.insert(:by_second_key, {%{payload: 2}, :beta, "extra2"})

:ets.new(:private_tab, [:set, :named_table, :private])
:ets.insert(:private_tab, {:secret, 42})
:ets.new(:bag_events, [:bag, :named_table, :public])
:ets.insert(:bag_events, for(i <- 1..60, do: {:login, "user-#{i}", i}))
:ets.insert(:bag_events, {:logout, "alice", 61})

unnamed = :ets.new(:anon_data, [:set, :public])
for i <- 1..12, do: :ets.insert(unnamed, {i, {:anon, i}})
:ets.new(:dup_bag, [:duplicate_bag, :named_table, :public])
:ets.insert(:dup_bag, List.duplicate({:x, 1}, 25))
:ets.insert(:dup_bag, {:y, 2})

:ets.new(:huge_records, [:set, :named_table, :public])
:ets.insert(:huge_records, {:big_binary, :crypto.strong_rand_bytes(5_000_000)})
:ets.new(:private_tab, [:set, :named_table, :private])
:ets.insert(:private_tab, {:secret, 42})

deep =
Enum.reduce(1..30, :leaf, fn i, acc -> %{level: i, child: acc, pad: List.duplicate(i, 50)} end)
unnamed = :ets.new(:anon_data, [:set, :public])
for i <- 1..12, do: :ets.insert(unnamed, {i, {:anon, i}})

:ets.insert(:huge_records, {:deep_term, deep})
:ets.insert(:huge_records, {:long_list, Enum.to_list(1..100_000)})
:ets.insert(:huge_records, {:small, :ok})
:ets.new(:huge_records, [:set, :named_table, :public])
:ets.insert(:huge_records, {:big_binary, :crypto.strong_rand_bytes(5_000_000)})

:ets.new(:charlist_keys, [:set, :named_table, :public])
:ets.insert(:charlist_keys, {~c"compiler", ~c"/lib/compiler", []})
deep =
Enum.reduce(1..30, :leaf, fn i, acc ->
%{level: i, child: acc, pad: List.duplicate(i, 50)}
end)

:ets.new(:tuple_keys, [:set, :named_table, :public])
:ets.insert(:tuple_keys, {{:user, 1}, "alice"})
:ets.insert(:tuple_keys, {{:user, 2}, "bob"})
:ets.insert(:huge_records, {:deep_term, deep})
:ets.insert(:huge_records, {:long_list, Enum.to_list(1..100_000)})
:ets.insert(:huge_records, {:small, :ok})

:ets.new(:binary_keys, [:set, :named_table, :public])
:ets.insert(:binary_keys, {"session-abc", %{ttl: 300}})
:ets.new(:charlist_keys, [:set, :named_table, :public])
:ets.insert(:charlist_keys, {~c"compiler", ~c"/lib/compiler", []})

:ets.new(:truncated_struct_key, [:set, :named_table, :public])
:ets.new(:tuple_keys, [:set, :named_table, :public])
:ets.insert(:tuple_keys, {{:user, 1}, "alice"})
:ets.insert(:tuple_keys, {{:user, 2}, "bob"})

:ets.insert(
:truncated_struct_key,
{%Version{major: 1, minor: 0, patch: 0, pre: [:binary.copy("P", 500)]}, :value_a}
)
:ets.new(:binary_keys, [:set, :named_table, :public])
:ets.insert(:binary_keys, {"session-abc", %{ttl: 300}})

:ets.insert(:truncated_struct_key, {%Version{major: 2, minor: 0, patch: 0}, :value_b})
:ets.new(:truncated_struct_key, [:set, :named_table, :public])

:ets.new(:empty_set, [:set, :named_table, :public])
:ets.insert(
:truncated_struct_key,
{%Version{major: 1, minor: 0, patch: 0, pre: [:binary.copy("P", 500)]}, :value_a}
)

:ets.insert(:truncated_struct_key, {%Version{major: 2, minor: 0, patch: 0}, :value_b})

:ets.new(:empty_set, [:set, :named_table, :public])
end)

IO.puts("""
node ready: #{node()} (cookie: demo)

repro check from another shell (proves the record the UI calls deleted exists):

elixir --name probe@127.0.0.1 --cookie demo -e '
n = :"demo@127.0.0.1"; true = Node.connect(n)
n = :"#{node()}"; true = Node.connect(n)
key = :binary.copy("K", 8000)
IO.inspect(full_key: :erpc.call(n, :ets, :lookup, [:truncated_key_bug, key]) != [],
prefix_99: :erpc.call(n, :ets, :lookup, [:truncated_key_bug, :binary.part(key, 0, 99)]))'
""")

Process.sleep(:infinity)
2 changes: 2 additions & 0 deletions dev/mock_nodes/ets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
iex --name ets@127.0.0.1 --cookie demo "$(dirname "$0")/../ets_demo_node.exs"
17 changes: 10 additions & 7 deletions lib/voyager_web/components/ets_peek_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ defmodule VoyagerWeb.Components.EtsPeekComponents do
</span>
</.info_item>
<.info_item id="ets-info-keypos" label="Key position" help={:keypos}>{@info.keypos}</.info_item>
<.info_item label="Objects" help={:size}>{Formatters.format_integer(@info.size)}</.info_item>
<.info_item id="ets-info-size" label="Objects" help={:size}>
{Formatters.format_integer(@info.size)}
</.info_item>
<.info_item label="Memory" help={:memory}>
<.bytes id="ets-info-memory" value={@info.memory} />
</.info_item>
Expand Down Expand Up @@ -166,10 +168,7 @@ defmodule VoyagerWeb.Components.EtsPeekComponents do

~H"""
<.form for={@form} id="ets-peek-controls" phx-change="validate" class="flex flex-col gap-1">
<fieldset
disabled={@loading? or not @readable?}
class={["contents", (@loading? or not @readable?) && "opacity-60"]}
>
<fieldset disabled={@loading?} class={["contents", @loading? && "opacity-60"]}>
<div class="grid-cols-[auto_auto_auto] grid-rows-[auto_auto_auto] grid w-max items-center gap-x-3">
<.field_label field={@form[:budget]} label="Record budget" help={@budget_help} />
<.field_label field={@form[:timeout]} label="Timeout (ms)" help={@records_timeout_help} />
Expand All @@ -186,11 +185,11 @@ defmodule VoyagerWeb.Components.EtsPeekComponents do
id="ets-peek-fetch"
type="button"
phx-click="fetch"
disabled={@loading? or not @readable?}
disabled={@loading?}
class="btn btn-primary btn-sm gap-2"
>
<span :if={@loading?} class="loading loading-spinner loading-xs" />
{if @fetched?, do: "Reload snapshot", else: "Fetch records"}
{fetch_label(@readable?, @fetched?)}
</button>

<.field_error field={@form[:budget]} />
Expand Down Expand Up @@ -518,6 +517,10 @@ defmodule VoyagerWeb.Components.EtsPeekComponents do
"""
end

defp fetch_label(false = _readable?, _fetched?), do: "Refetch info"
defp fetch_label(_readable?, true = _fetched?), do: "Refetch records"
defp fetch_label(_readable?, _fetched?), do: "Fetch records"

defp row_open?(open_rows, index), do: MapSet.member?(open_rows, index)

defp truncated_record?(@truncated), do: true
Expand Down
53 changes: 46 additions & 7 deletions lib/voyager_web/live/ets_table_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ defmodule VoyagerWeb.EtsTableLive do

<EtsPeekComponents.controls
form={@form}
loading?={loading?(@chunk)}
loading?={loading?(@chunk) or loading?(@info)}
readable?={readable?(@info)}
fetched?={@fetched?}
/>
Expand Down Expand Up @@ -220,11 +220,17 @@ defmodule VoyagerWeb.EtsTableLive do
# A new snapshot starts a fresh select: the old continuations belong to a
# walk that is no longer on screen.
def handle_event("fetch", _params, socket) do
socket
|> assign(:conts, [nil])
|> assign(:page_size, socket.assigns.controls.chunk_size)
|> fetch_page(0)
|> noreply()
if readable?(socket.assigns.info) do
socket
|> assign(:conts, [nil])
|> assign(:page_size, socket.assigns.controls.chunk_size)
|> fetch_page(0)
|> noreply()
else
socket
|> refresh_info()
|> noreply()
end
end

# The pager is 1-based; a page is reachable only while its continuation is
Expand Down Expand Up @@ -355,17 +361,20 @@ defmodule VoyagerWeb.EtsTableLive do
socket
|> assign(:table_id, info.id)
|> assign(:info, AsyncResult.ok(socket.assigns.info, info))
|> drop_unreadable_records()
|> noreply()
end

def handle_async(:info, {:ok, {:error, reason}}, socket) do
socket
|> flash_info_failure(reason)
|> assign(:info, AsyncResult.failed(socket.assigns.info, reason))
|> noreply()
end

def handle_async(:info, {:exit, reason}, socket) do
socket
|> flash_info_failure(reason)
|> assign(:info, AsyncResult.failed(socket.assigns.info, reason))
|> noreply()
end
Expand All @@ -384,12 +393,14 @@ defmodule VoyagerWeb.EtsTableLive do
|> assign(:last_updated, DateTime.utc_now())
|> assign(:round_trip_ms, round_trip_ms)
|> put_record_terms(chunk.records)
|> drop_unreadable_records()
|> noreply()
end

def handle_async(:chunk, {:ok, {:error, reason}}, socket) do
socket
|> assign(:chunk, AsyncResult.failed(socket.assigns.chunk, reason))
|> drop_unreadable_records()
|> noreply()
end

Expand Down Expand Up @@ -444,6 +455,34 @@ defmodule VoyagerWeb.EtsTableLive do
end)
end

defp refresh_info(socket) do
node = socket.assigns.session.node
table = socket.assigns.table_id
timeout = socket.assigns.controls.timeout

socket
|> assign(:info, AsyncResult.loading(socket.assigns.info))
|> start_async(:info, fn -> Remote.info(node, table, timeout) end)
end

defp flash_info_failure(%{assigns: %{info: %AsyncResult{ok?: true}}} = socket, reason),
do: put_flash(socket, :error, format_error(reason))

defp flash_info_failure(socket, _reason), do: socket

defp drop_unreadable_records(socket) do
if readable?(socket.assigns.info) do
socket
else
socket
|> cancel_async(:chunk, {:shutdown, :cancel})
|> assign(:chunk, %AsyncResult{})
|> assign(:records, [])
|> assign(:truncated?, false)
|> assign(:fetched?, false)
Comment thread
srzeszut marked this conversation as resolved.
end
end

# A reference cannot be reconstructed from its inspect string, so it is
# matched against the node's live table handles instead.
defp table_ids(node, "#Ref" <> _param, timeout) do
Expand All @@ -464,7 +503,7 @@ defmodule VoyagerWeb.EtsTableLive do
continuation = Enum.at(socket.assigns.conts, page)

socket
|> start_async(:info, fn -> Remote.info(node, table, timeout) end)
|> refresh_info()
|> cancel_async(:chunk, {:shutdown, :cancel})
|> assign(:pending_page, page)
|> assign(:chunk, AsyncResult.loading(socket.assigns.chunk))
Expand Down
Loading