Save state globally of __using__ macro

Hello,
I want to create a unique identifier for a module that will be passed to using as a string, example:

use Identifier, :unique_identifier

Now, in Identifier module I do the following:

defmacro __using__(name) when is_atom(name) do
    quote do
      def __reporter_unique_id__(), do: unquote(name)
    end
end

What I want to do before this is to ensure that there are no duplicated names like this one, if yes I want to throw a compile-time error. I was thinking about using a GenServer or Agent, however I cannot understand when should I start it. I’ve tried to start it inside of Identifier module body, however it doesn’t seem to work.

Any ideas on how to do this?

You can accumulate them in an attribute on your module that’s being used. It’s been a while but I think I did essentially what you are trying to do here: mr_badger/mr_badger.ex at master · tfwright/mr_badger · GitHub

This will not work, I have multiple modules that use Identifier module.

Since Identifier module is already compiled, I cannot add attributes to this module, and adding attribute to module that is using makes no sense.

Module names are unique - append UTC timestamp to it so that there wont be collision?

You want these reporter_ids to be same across the builds or change every time ?

I use a related approach in ex_cldr to cache locale data at compile time. It starts a GenServer hosting an ets table on demand. The relevant code is here in case any of it is useful to you.

4 Likes

I’d suggest you go with something more formal and polished as @kip’s.

In the meantime, here’s a hacky solution that gives you a compile-time error:

defmodule IdRegistryState do
  use Agent

  def start_link(_initial_value), do: Agent.start_link(fn -> %{} end, name: __MODULE__)

  def register(name) do
    Agent.get_and_update(__MODULE__, fn table ->
      case Map.fetch(table, name) do
        {:ok, _value} -> {{:id_already_registered, name}, table}
        :error -> {:ok, Map.put(table, name, 1)}
      end
    end)
  end
end

defmodule IdRegistry do
  defmacro __using__(name) when is_atom(name) do
    IdRegistryState.start_link(:ignored)

    quote bind_quoted: [name: name] do
      :ok = IdRegistryState.register(name)

      def __reporter_unique_id__(), do: unquote(name)
    end
  end
end

defmodule Z1 do
  use IdRegistry, :z1
end

defmodule Z2 do
  use IdRegistry, :z2
end

defmodule Z3 do
  use IdRegistry, :z1
end

You will get a compile-time error and stacktrace similar to this:

== Compilation error in file lib/play.ex ==
** (MatchError) no match of right hand side value: {:id_already_registered, :z1}
    lib/zyx.ex:37: (module)
    (stdlib 3.17) lists.erl:1267: :lists.foldl/3

That should be a good starting point for you.


Couple of notes:

  • Calling IdRegistryState.start_link(:ignored) every time you do use IdRegistry, :name is hacky but I was unable to make this Agent start in the app’s supervision tree before it’s needed so I just removed it from there and made the start_link call not assert on return value (and it will return {:already_started, pid} on the 2nd use and on; however that will not impact the already-running unique Agent in any way)
  • No real reason for separating both modules, try merging them into one if you like.
1 Like

Calling IdRegistryState.start_link(:ignored) every time you do use IdRegistry, :name is hacky but I was unable to make this Agent start in the app’s supervision tree before it’s needed so I just removed it from there and made the start_link call not assert on return value (and it will return {:already_started, pid} on the 2nd use and on; however that will not impact the already-running unique Agent in any way)

Looks like @kip is using the same approach, all his api functions start the genserver if it is not started. It seems it is not possible to start the agent before compilation process.

Great solution, thanks for sharing!

Okay there seems to be a problem.

Everything works ideally while you use the comandline. The problem appears when using elixir-ls, the compilation server never stops, so the genserver never gets stopped, creating duplicates and error messages inside of code editor.

Now that I think about this more, this method seems an anti-pattern, it will not work reliably if there are cached compiled files, since it won’t trigger compilation for them, the possible solution would be to look at (compiled modules?)

Looks like my problem is a duplicate of this and there is no reliable solution to do this.

Isn’t what you are describing only an issue on development machines? CI and production deployments always build from scratch so you won’t have issues with intermittent build artifacts.

I don’t think it’s necessary for what you’re trying to do, but since you referenced the approach where you add a compilation step that reads module attributes (like with protocol consolidation), I figure I’ll share an experiment I did trying to understand it more.

The idea was a library to generalize that solution, and while I wouldn’t use it in production, it works, and it could be helpful to reference.

1 Like

Actually this facility is for development mostly. We have modules that contain business-logic and are used by the application, currently we keep track of them by their module name inside of database, it works, however I wanted to decouple elixir-specific functionality, to facilitate easier migration to other projects.

The idea is to make it as dumb-proof as possible, since on these projects work mostly inexperienced people. Having the macro check for duplicates and throw compile-time error with good message makes them implement the functionality correctly.

Now that I think more about this, a approach similar to how phoenix routes work is well suited here and can solve the problem, at the cost of maintaining the mappings list.