melpon/memoize

Disable memoize globally?

Closed this issue ยท 9 comments

Can you please suggest a way to temporarily disable memoize globally, for example for benchmarking purposes?

if Mix.env() == :benchmark do
  def foo() do
    do_foo()
  end
else
  defmemo foo() do
    do_foo()
  end
end
defmodule MyMemoize do
  defmacro __using__(_) do
    quote do
      if Mix.env() == :benchmark do
        use Memoize
      else
        import MyMemoize,
          only: [defmemo: 1, defmemo: 2, defmemo: 3, defmemop: 1, defmemop: 2, defmemop: 3]
    end
  end

  defmacro defmemo(call, expr_or_opts \\ nil) do
    {_opts, expr} = resolve_expr_or_opts(expr_or_opts)
    quote do
      def(unquote(call), unquote(expr))
    end
  end

  defmacro defmemop(call, expr_or_opts \\ nil) do
    {_opts, expr} = resolve_expr_or_opts(expr_or_opts)
    quote do
      defp(unquote(call), unquote(expr))
    end
  end

  defmacro defmemo(call, _opts, expr) do
    quote do
      def(unquote(call), unquote(expr))
    end
  end

  defmacro defmemop(call, _opts, expr) do
    quote do
      defp(unquote(call), unquote(expr))
    end
  end

  defp resolve_expr_or_opts(expr_or_opts) do
    cond do
      expr_or_opts == nil ->
        {[], nil}

      # expr_or_opts is expr
      Keyword.has_key?(expr_or_opts, :do) ->
        {[], expr_or_opts}

      # expr_or_opts is opts
      true ->
        {expr_or_opts, nil}
    end
  end
end

defmodule Foo do
  use MyMemoize
  # It will not be memoized if Mix.env() is :benchmark
  defmemo foo() do
    do_foo()
  end
end

Thank you very much! :)

I'll give it a whirl.

Just a slightly fixed version of @melpon solution:

defmodule MyApp.Memoize do
  defmacro __using__(_) do
    if Mix.env() == :test do
      quote do
        import MyApp.Memoize,
          only: [defmemo: 1, defmemo: 2, defmemo: 3, defmemop: 1, defmemop: 2, defmemop: 3]
      end
    else
      quote do
        use Memoize
      end
    end
  end

  defmacro defmemo(call, expr_or_opts \\ nil) do
    {_opts, expr} = resolve_expr_or_opts(expr_or_opts)

    quote do
      def(unquote(call), unquote(expr))
    end
  end

  defmacro defmemop(call, expr_or_opts \\ nil) do
    {_opts, expr} = resolve_expr_or_opts(expr_or_opts)

    quote do
      defp(unquote(call), unquote(expr))
    end
  end

  defmacro defmemo(call, _opts, expr) do
    quote do
      def(unquote(call), unquote(expr))
    end
  end

  defmacro defmemop(call, _opts, expr) do
    quote do
      defp(unquote(call), unquote(expr))
    end
  end

  defp resolve_expr_or_opts(expr_or_opts) do
    cond do
      expr_or_opts == nil -> {[], nil}
      Keyword.has_key?(expr_or_opts, :do) -> {[], expr_or_opts}
      true -> {expr_or_opts, nil}
    end
  end
end

Thank you @cblavier!

I think it would be really useful to add this into the library, perhaps as a config flag?

config :my_app, Memoize, caching_enabled: false

I don't think the particular mechanism matters, but in a standard web app scenario I imagine this would be a very common need.

By "standard web app scenario", I mean a web application that does CRUD and has integration tests. The Ecto Sandbox adapter prevents data from "bleeding over" between concurrently running tests, but when you use Memoize there is bleed-over. This cost me a few hours of debugging before I realized Memoize was the cause.

@melpon By the way, this is a reasoned suggestion, not a complaint. Thanks for sharing this useful library!

If the configuration route is undesirable, instructions about this scenario could perhaps be added to the README?

I implemented global :expires_in option since 1.4.0.

config :memoize,
  cache_strategy: Memoize.CacheStrategy.Default

config :memoize, Memoize.CacheStrategy.Default,
  expires_in: 0 # invalidate immediately

However, this is not exactly the same as disabling the cache. All concurrent calls by many processes are serialized.

I decided to implement a DummyCacheStrategy that I use in my test env, which in the read/3 function immediately invalidates everything in its tab before returning :ok. I think this is the equivalent of never storing the result in the cache. Seems to be working well so far.

I found that the expires_in: 0 option wasn't reliable enough for my needs, since it was only invalidating during a read after at least 1ms had passed since the value was cached. This would result in intermittent test failures in my test suite, which needs to completely disable the cache.

config :memoize, cache_strategy: MyApp.DummyCacheStrategy
defmodule MyApp.DummyCacheStrategy do
  @behaviour Memoize.CacheStrategy

  @ets_tab __MODULE__

  def init(opts) do
    :ets.new(@ets_tab, [:public, :set, :named_table, {:read_concurrency, true}])
    opts
  end

  def tab(_), do: @ets_tab
  def cache(_, _, _), do: nil

  def read(_, _, _) do
    invalidate()
    :ok
  end

  def invalidate() do
    :ets.select_delete(@ets_tab, [{{:_, {:completed, :_, :_}}, [], [true]}])
  end

  def invalidate(_), do: invalidate()
  def garbage_collect(), do: invalidate()
end

I think that such dummy strategy (as suggested by @tylerj) should be a part of the library and suggested as a way to go about testing. Most of the time that will be the behaviour the tester expects. To say more - it is confusing that the user can set "expires_in" to 0 and that is not exactly true (which is fine outside of the tests realm as requiring serialization would have performance implications).