Skip to content

Commit

Permalink
ModuleToModuleTracer
Browse files Browse the repository at this point in the history
  • Loading branch information
jessestimpson committed Dec 8, 2024
1 parent 7d51e2c commit cb5a297
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 55 deletions.
20 changes: 20 additions & 0 deletions lib/ecto_foundationdb/tenant.ex
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,26 @@ defmodule EctoFoundationDB.Tenant do
@spec delete(Ecto.Repo.t(), id()) :: :ok
def delete(repo, id) when byte_size(id) > 0, do: Backend.delete(FDB.db(repo), id, repo.config())

@doc """
Packs an Elixir tuple into an FDB-encoded Tuple.
## Keyspace Design
We always pack into a **non-prefixed tuple key**. In other words, EctoFDB
DirectoryTenant keys use the subspace prefix as the first tuple element
instead of binary key prefix. As such, our keys are not compliant with
other Directory Layer implementations. However, we've made this choice so that
we can continue to use GetMappedRange functionality. (Indeed, the GetMappedRange mapper spec
has reserved syntax for stripping out non-tuple prefixes, but it's not yet implemented.)
Note: ManagedTenant keys do not use directories/subspaces. The underlying binary
prefix that a ManagedTenant uses internally still allows use of GetMappedRange.
ManagedTenants and GetMappedRange are both experimental features. The safest choice is
to use neither, but that would forfeit the GetMappedRange optimization. We choose
to accept the risk of GetMappedRange, which can easily be replaced with a different
client implementation, and suggest against using ManagedTenant because it puts
your data at risk with its unsupported\[[0](https://github.com/apple/foundationdb/issues/11292)\]\[[1](https://github.com/apple/foundationdb/issues/11382)\]
keyspace.
"""
def pack(tenant, tuple) when is_tuple(tuple) do
tuple
|> tenant.backend.extend_tuple(tenant.meta)
Expand Down
78 changes: 23 additions & 55 deletions test/ecto/integration/fdb_api_counting_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Ecto.Integration.FdbApiCountingTest do
alias Ecto.Adapters.FoundationDB
alias Ecto.Integration.TestRepo

alias EctoFoundationDB.ModuleToModuleTracer
alias EctoFoundationDB.Schemas.User

# This module tracks all calls from modules that begin with EctoFoundationDB.* to the following
Expand All @@ -28,65 +29,32 @@ defmodule Ecto.Integration.FdbApiCountingTest do
{:erlfdb, :wait_for_all_interleaving, 2}
]

def start_trace(target) do
tracer = spawn(fn -> trace_listener([]) end)
trace_flags = [:call, :arity]
match_spec = [{:_, [], [{:message, {{:cp, {:caller}}}}]}]
:erlang.trace_pattern(:on_load, match_spec, [:local])
:erlang.trace_pattern({:erlfdb, :_, :_}, match_spec, [:local])
:erlang.trace(target, true, [{:tracer, tracer} | trace_flags])
tracer
end

def stop_trace(tracer, target) do
:erlang.trace(target, false, [:all])
:erlang.send(tracer, {:dump, self()})

ret =
receive do
{:acc, acc} ->
{:ok, Enum.reverse(acc)}
after
5000 -> {:error, :timeout}
def with_erlfdb_calls(fun) do
caller_spec = fn caller_module ->
try do
case Module.split(caller_module) do
["EctoFoundationDB" | _] ->
true

_ ->
false
end
rescue
_e in ArgumentError ->
false
end
end

Process.exit(tracer, :normal)
ret
end

defp trace_listener(acc) do
receive do
{:dump, pid} ->
:erlang.send(pid, {:acc, acc})

{:trace, _pid, :call, {:erlfdb, fun, arity}, {:cp, {caller, _, _}}} ->
try do
case Module.split(caller) do
["EctoFoundationDB" | _] ->
if Enum.member?(@traced_calls, {:erlfdb, fun, arity}) do
trace_listener([{caller, fun} | acc])
else
trace_listener(acc)
end

_ ->
trace_listener(acc)
end
rescue
_e in ArgumentError ->
trace_listener(acc)
end
{traced_calls, res} =
ModuleToModuleTracer.with_traced_calls([caller_spec], @traced_calls, fun)

_term ->
trace_listener(acc)
end
end
# clean up the traces for more concise assertions
traced_calls =
for {caller, {:erlfdb, call_fun, _arity}} <- traced_calls do
{caller, call_fun}
end

def with_erlfdb_calls(fun) do
tracer = start_trace(self())
res = fun.()
{:ok, calls} = stop_trace(tracer, self())
{calls, res}
{traced_calls, res}
end

test "counting", context do
Expand Down
112 changes: 112 additions & 0 deletions test/support/module_to_module_tracer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
defmodule EctoFoundationDB.ModuleToModuleTracer do
@moduledoc false

use GenServer

# module_name
@type caller() :: atom()

# {module_name, function_name, arity}
@type call() :: {atom(), atom(), integer()}

@type caller_spec() :: function() | caller()
@type call_spec() :: function() | call()

@type traced_calls() :: {caller(), call()}

defstruct caller_specs: [], call_specs: [], traced_calls: []

@doc """
The given function is executed and for any function call made within,
when both a caller_spec and a call_spec are found, the function call is
recorded in the trace.
"""
@spec with_traced_calls(list(caller_spec()), list(call_spec()), function()) ::
{list(traced_calls()), any()}
def with_traced_calls(caller_specs, call_specs, fun) do
tracer = start_trace(self(), caller_specs, call_specs)
res = fun.()
calls = stop_trace(tracer, self())
{calls, res}
end

def start_trace(target, caller_specs, call_specs) do
{:ok, tracer} = start_link(caller_specs, call_specs)
trace_flags = [:call, :arity]
match_spec = [{:_, [], [{:message, {{:cp, {:caller}}}}]}]
:erlang.trace_pattern(:on_load, match_spec, [:local])
:erlang.trace_pattern({:erlfdb, :_, :_}, match_spec, [:local])
:erlang.trace(target, true, [{:tracer, tracer} | trace_flags])
tracer
end

def stop_trace(tracer, target) do
:erlang.trace(target, false, [:all])

ret = get_traced_calls(tracer)

GenServer.stop(tracer)

ret
end

def start_link(caller_specs, call_specs) do
GenServer.start_link(__MODULE__, {caller_specs, call_specs}, [])
end

def get_traced_calls(pid) do
GenServer.call(pid, :get_traced_calls)
end

@impl true
def init({caller_specs, call_specs}) do
{:ok, %__MODULE__{caller_specs: caller_specs, call_specs: call_specs}}
end

@impl true
def handle_call(:get_traced_calls, _from, state) do
{:reply, Enum.reverse(state.traced_calls), state}
end

@impl true
def handle_info(
{:trace, _pid, :call, call = {_module, _fun, _arity}, {:cp, {caller, _, _}}},
state
) do
if is_match?(caller, call, state) do
{:noreply, %__MODULE__{state | traced_calls: [{caller, call} | state.traced_calls]}}
else
{:noreply, state}
end
end

def handle_info(_info, state) do
# other traces will end up here
{:noreply, state}
end

defp match?(caller, call, state) do
matching_origin?(caller, state) and matching_call?(call, state)
end

defp matching_origin?(caller, state) do
any_match?(state.caller_specs, caller)
end

defp matching_call?(call, state) do
any_match?(state.call_specs, call)
end

defp any_match?(specs, item) do
Enum.any?(
specs,
fn
spec when is_function(spec) ->
spec.(item)

spec ->
item == spec
end
)
end
end

0 comments on commit cb5a297

Please sign in to comment.