# SPDX-FileCopyrightText: 2021 Jon Carstens # SPDX-FileCopyrightText: 2022 Digit # SPDX-FileCopyrightText: 2022 Frank Hunleth # # SPDX-License-Identifier: Apache-2.0 # defmodule PropertyTable do @moduledoc File.read!("README.md") |> String.split("") |> Enum.fetch!(1) alias PropertyTable.Updater @typedoc """ A table_id identifies a group of properties """ @type table_id() :: atom() @typedoc """ A property Properties can be anything, but in order to be useful, they need to be compatible with the `PropertyTable.Matcher` implementation. In is common for this to be a string list (`[String.t()]`) since the default `PropertyTable.Matcher` works with those. """ @type property() :: any() @typedoc """ A match pattern Just like properties, these can be anything but they have to be compatible with the `PropertyTable.Matcher` implementation. The default is that patterns are string lists with the addition of wildcards like `:_`. """ @type pattern() :: any() @typedoc """ A properties value These can be whatever makes sense to the PropertyTable user. The only constraint is that if you're using PropertyTable's persistence feature, it needs to be possible to save and restore them. This means that pids and references, for example, can't be used. """ @type value() :: any() @typedoc """ A property/value tuple """ @type property_value() :: {property(), value()} @typedoc """ PropertyTable configuration options See `start_link/1` for usage. """ @type options() :: [ name: table_id(), properties: [property_value()], tuple_events: boolean(), matcher: module(), persist_data_path: String.t(), persist_interval: pos_integer(), persist_max_snapshots: pos_integer(), persist_compression: 0..9 ] @doc """ Start a PropertyTable's supervision tree To create a PropertyTable for your application or library, add the following `child_spec` to one of your supervision trees: ```elixir {PropertyTable, name: MyTableName} ``` The `:name` option is required. All calls to `PropertyTable` will need to know it and the process will be registered under than name so be sure it's unique. Options for properties and events: * `:properties` - a list of `{property, value}` tuples to initially populate the `PropertyTable` * `:matcher` - set the format for how properties and how they should be matched for triggering events. See `PropertyTable.Matcher`. * `:tuple_events` - set to `true` for change events to be in the old tuple format. This is not recommended for new code and hopefully will be removed in the future. Prefer `:event_transformer` * `:event_transformer` - set to a function that takes an `Event.t()` and returns the event that should be sent to subscribers. Options for persisting properties: * `:persist_data_path` - set to a directory where PropertyTable will persist the contents of the table to disk, snapshots will also be stored here. * `:persist_interval` - if set PropertyTable will persist the contents of tables to disk in intervals of the provided value (in milliseconds) automatically. * `:persist_max_snapshots` - Maximum number of manual snapshots to keep on disk before they are replaced - (oldest snapshots are replaced first.) Defaults to 25. * `:persist_compression` - `0..9` range to compress the terms when written to disk, see `:erlang.term_to_binary/2`. Defaults to 6. > #### Important {: .info} > > Setting `:persist_data_path` enables persistence. On initialization, if > PropertyTable is able to load a snapshot, the data in the snapshot is used > instead of the `:properties` option. """ @spec start_link(options()) :: Supervisor.on_start() def start_link(options) do name = case Keyword.fetch(options, :name) do {:ok, name} when is_atom(name) -> name {:ok, other} -> raise ArgumentError, "expected :name to be an atom, got: #{inspect(other)}" :error -> raise ArgumentError, "expected :name option to be present" end tuple_events = Keyword.get(options, :tuple_events, false) if not is_boolean(tuple_events) do raise ArgumentError, "expected :tuple_events to be boolean, got: #{inspect(tuple_events)}" end default_transformer = if tuple_events, do: &PropertyTable.Event.to_tuple/1, else: &Function.identity/1 event_transformer = Keyword.get(options, :event_transformer, default_transformer) if not is_function(event_transformer, 1) do raise ArgumentError, "expected :event_transformer to a function, got: #{inspect(event_transformer)}" end matcher = Keyword.get(options, :matcher, PropertyTable.Matcher.StringPath) if not is_atom(matcher) do raise ArgumentError, "expected :matcher to be module, got: #{inspect(matcher)}" end properties = Keyword.get(options, :properties, []) if !Enum.all?(properties, fn {k, _} -> matcher.check_property(k) == :ok end) do raise ArgumentError, "expected :properties to contain valid properties, got: #{inspect(properties)}" end persistence_options = maybe_get_persistence_options(options) Supervisor.start_link( __MODULE__.Supervisor, %{ table: name, properties: properties, event_transformer: event_transformer, matcher: matcher, persistence_options: persistence_options }, name: name ) end @doc """ Returns a specification to start a property_table under a supervisor. See `Supervisor`. """ @spec child_spec(options()) :: Supervisor.child_spec() def child_spec(options) do %{ id: Keyword.get(options, :name, PropertyTable), start: {PropertyTable, :start_link, [options]}, type: :supervisor } end @doc """ Subscribe to receive property change events Subscriptions may be for individual properties or contain wildcards as allowed by the `PropertyTable.Matcher` that's in use. See `PropertyTable.Matcher.StringPath` for the default. > #### Important {: .warning} > > A common pattern is to subscribe to a property and then to get its initial > value. The order matters. Subscription must be done first to prevent a race > condition. For example, if you get a property's value and _then_ subscribe, > it's possible for the value to change before the subscription takes effect. """ @spec subscribe(table_id(), pattern()) :: :ok def subscribe(table, pattern) do registry = PropertyTable.Supervisor.registry_name(table) {:ok, matcher} = Registry.meta(registry, :matcher) case matcher.check_pattern(pattern) do :ok -> {:ok, _} = Registry.register(registry, :subscriptions, pattern) :ok {:error, error} -> raise error end end @doc """ Stop subscribing to a property """ @spec unsubscribe(table_id(), pattern()) :: :ok def unsubscribe(table, pattern) do registry = PropertyTable.Supervisor.registry_name(table) Registry.unregister_match(registry, :subscriptions, pattern) end @doc """ Get the current value of a property """ @spec get(table_id(), property(), value()) :: value() def get(table, property, default \\ nil) do case :ets.lookup(table, property) do [{_property, value, _timestamp}] -> value [] -> default end end @doc """ Fetch a property with the time that it was set Timestamps come from `System.monotonic_time()`. Use `System.convert_time_unit/3` to convert from native time units. """ @spec fetch_with_timestamp(table_id(), property()) :: {:ok, value(), integer()} | :error def fetch_with_timestamp(table, property) do case :ets.lookup(table, property) do [{_property, value, timestamp}] -> {:ok, value, timestamp} [] -> :error end end @doc """ Get all properties This function might return a really long list so it's mainly intended for debug or convenience when you know that the table only contains a few properties. """ @spec get_all(table_id()) :: [{property(), value()}] def get_all(table) do :ets.foldl( fn {property, value, _timestamp}, acc -> [{property, value} | acc] end, [], table ) end @doc """ Get all properties with timestamps This function is similar to `get_all/1` but also returns the monotonic time that the property was set. Use `System.convert_time_unit/3` to convert from native time units. """ @spec get_all_with_timestamps(table_id()) :: [{property(), value(), integer()}] def get_all_with_timestamps(table) do :ets.tab2list(table) end @doc """ Get a list of all properties matching the specified property pattern """ @spec match(table_id(), pattern()) :: [{property(), value()}] def match(table, pattern) do registry = PropertyTable.Supervisor.registry_name(table) {:ok, matcher} = Registry.meta(registry, :matcher) with true <- function_exported?(matcher, :match_spec, 1), spec when is_list(spec) <- matcher.match_spec(pattern) do match_via_spec(table, spec) else _ -> match_via_foldl(table, matcher, pattern) end end defp match_via_spec(table, spec) do for {property, value, _meta} <- :ets.select(table, spec), do: {property, value} end defp match_via_foldl(table, matcher, pattern) do :ets.foldl( fn {property, value, _timestamp}, acc -> if matcher.matches?(pattern, property) do [{property, value} | acc] else acc end end, [], table ) end @doc """ Update a property and notify listeners """ @spec put(table_id(), property(), value()) :: :ok defdelegate put(table, property, value), to: Updater @doc """ Update many properties This is similar to calling `put/3` several times in a row, but atomically. It is also slightly more efficient when updating more than one property. """ @spec put_many(table_id(), [{property(), value()}]) :: :ok defdelegate put_many(table, properties), to: Updater @doc """ Delete the specified property """ @spec delete(table_id(), property()) :: :ok defdelegate delete(table, property), to: Updater @doc """ Delete all properties that match a pattern """ @spec delete_matches(table_id(), pattern()) :: :ok defdelegate delete_matches(table, pattern), to: Updater @doc """ Take a snapshot of the property table If persistence is enabled for this property table, save the current state and copy a snapshot of it into the `/snapshots` sub-directory of the set data directory. This returns an ID for the snapshot that can be passed `restore_snapshot/2`. """ @spec snapshot(table_id()) :: {:ok, String.t()} | {:error, Exception.t()} defdelegate snapshot(table), to: Updater @doc """ Same as `snapshot/1` but raises an exception on error """ @spec snapshot!(table_id()) :: {:ok, String.t()} def snapshot!(table) do case snapshot(table) do {:ok, id} -> {:ok, id} {:error, error} -> raise error end end @doc """ Write any changes to disk If persistence is enabled for this property table, save the current state to disk immediately. The table is already written every `:persist_interval`, but this is avoid waiting after important changes. """ @spec flush_to_disk(table_id()) :: :ok | {:error, Exception.t()} defdelegate flush_to_disk(table), to: Updater @doc """ Same as `flush_to_disk/1` but raises an exception on error """ @spec flush_to_disk!(table_id()) :: :ok def flush_to_disk!(table) do case flush_to_disk(table) do :ok -> :ok {:error, error} -> raise error end end @doc """ Return available snapshot IDs This scans the `snapshots` directory and returns a list of tuples containing snapshot IDs and their full name. """ @spec get_snapshots(table_id()) :: [{String.t(), String.t()}] defdelegate get_snapshots(table), to: Updater @doc """ Restart a previously saved snapshot If persistence is enabled for this property table, restore the current state of the PropertyTable to that of a past named snapshot """ @spec restore_snapshot(table_id(), String.t()) :: :ok | {:error, Exception.t()} defdelegate restore_snapshot(table, snapshot_name), to: Updater defp maybe_get_persistence_options(options) do if Keyword.has_key?(options, :persist_data_path) do table_name = Keyword.get(options, :name) |> Atom.to_string() # Set persistence options, and clean out any nil values # they will be filled with defaults in `PropertyTable.Persist` [ data_directory: Keyword.get(options, :persist_data_path), table_name: table_name, interval: Keyword.get(options, :persist_interval), max_snapshots: Keyword.get(options, :persist_max_snapshots), compression: Keyword.get(options, :persist_compression) ] |> Enum.filter(fn {_, v} -> v != nil end) else # :persist_data_path must be set for any of the other options to be respected # no persistence will be configured nil end end end