Current section

Files

Jump to
ecto_foundationdb lib ecto_foundationdb layer metadata.ex
Raw

lib/ecto_foundationdb/layer/metadata.ex

defmodule EctoFoundationDB.Layer.Metadata do
@moduledoc false
alias EctoFoundationDB.Future
alias EctoFoundationDB.Indexer
alias EctoFoundationDB.Layer.Metadata.Cache
alias EctoFoundationDB.Layer.MetadataVersion
alias EctoFoundationDB.Layer.Pack
alias EctoFoundationDB.Layer.Tx
alias EctoFoundationDB.Migration.SchemaMigration
alias EctoFoundationDB.MigrationsPJ
alias EctoFoundationDB.QueryPlan
@metadata_source "indexes"
@metadata_operation_failed {:erlfdb_error, 1020}
@app_metadata_version_idx [
id: "version",
type: :index,
indexer: Indexer.MDVAppVersion,
source: SchemaMigration.source(),
fields: [:version],
options: []
]
@builtin_metadata %{
SchemaMigration.source() => %{
partial_indexes: [],
other: [],
indexes: [@app_metadata_version_idx]
}
}
defstruct indexes: [], partial_indexes: [], other: []
@type t() :: %__MODULE__{}
def source(), do: @metadata_source
def new(metadata_kwl) do
new(%__MODULE__{}, metadata_kwl)
end
defp new(struct, []) do
%__MODULE__{indexes: indexes, other: other} = struct
%{struct | indexes: Enum.reverse(indexes), other: Enum.reverse(other)}
end
defp new(struct, [meta | metadata_kwl]) do
%__MODULE__{indexes: indexes, other: other} = struct
# Default type is index for historical reasons
case meta[:type] || :index do
:index ->
new(%{struct | indexes: [meta | indexes]}, metadata_kwl)
_ ->
new(%{struct | other: [meta | other]}, metadata_kwl)
end
end
def find_indexes_using_field(metadata, field) do
%__MODULE__{indexes: indexes, partial_indexes: partial_indexes} = metadata
indexes = indexes ++ for({idx, _} <- partial_indexes, do: idx)
Enum.filter(indexes, fn idx -> field in idx[:fields] end)
end
@doc """
Create an FDB kv to be stored in the schema_migrations source. This kv contains
the information necessary to manage data objects' associated indexes.
## Examples
iex> tenant = %EctoFoundationDB.Tenant{backend: EctoFoundationDB.Tenant.ManagedTenant}
iex> {key, obj} = EctoFoundationDB.Layer.Metadata.new_index(tenant, "users", "users_name_index", [:name], [])
iex> {EctoFoundationDB.Tenant.unpack(tenant, key), obj}
{{"\\xFE", "indexes", "users", "users_name_index"}, [id: "users_name_index", type: :index, indexer: EctoFoundationDB.Indexer.Default, source: "users", fields: [:name], options: []]}
"""
def new_index(tenant, source, index_name, index_fields, options) do
metadata_key = metadata_key(tenant, source, index_name)
idx = [
id: index_name,
type: :index,
indexer: get_indexer(options),
source: source,
fields: index_fields,
options: options
]
{metadata_key, idx}
end
def metadata_key(tenant, source, index_name) do
Pack.namespaced_pack(tenant, source(), source, ["#{index_name}"])
end
defp get_indexer(options) do
case Keyword.get(options, :indexer) do
nil -> Indexer.Default
module -> module
end
end
def select_index(metadata, constraints) do
%__MODULE__{indexes: indexes} = metadata
case Enum.min(indexes, &choose_a_over_b_or_throw(&1, &2, constraints), fn -> nil end) do
nil -> nil
idx -> if(sufficient?(idx, constraints), do: idx, else: nil)
end
catch
{:best, idx} -> idx
end
def arrange_constraints(constraints, idx) do
constraints = for op <- constraints, do: {op.field, op}
for(field <- idx[:fields], do: constraints[field])
|> Enum.filter(fn
nil -> false
_ -> true
end)
end
defp sufficient?(idx, constraints) when is_list(idx) and is_list(constraints) do
fields = idx[:fields] |> Enum.into(MapSet.new())
where_fields_list = for(op <- constraints, do: op.field)
where_fields = Enum.into(where_fields_list, MapSet.new())
sufficient?(fields, where_fields)
end
defp sufficient?(fields, where_fields) do
0 == MapSet.difference(where_fields, fields) |> MapSet.size()
end
defp choose_a_over_b_or_throw(idx_a, idx_b, constraints) do
fields_a = idx_a[:fields] |> Enum.into(MapSet.new())
fields_b = idx_b[:fields] |> Enum.into(MapSet.new())
where_fields_list = for(op <- constraints, do: op.field)
where_fields = Enum.into(where_fields_list, MapSet.new())
overlap_a = MapSet.intersection(where_fields, fields_a) |> MapSet.size()
overlap_b = MapSet.intersection(where_fields, fields_b) |> MapSet.size()
match_a? = overlap_a == MapSet.size(fields_a) and overlap_a == MapSet.size(where_fields)
match_b? = overlap_b == MapSet.size(fields_b) and overlap_b == MapSet.size(where_fields)
exact_match_short_circuit(match_a?, idx_a, match_b?, idx_b)
# Most indexes will be Default indexes, so we can optimize for that case. Default index allows 0 or 1
# Between ops, and it always must be the last field in the index.
between_fields = for %QueryPlan.Between{field: field} <- constraints, do: field
final_between_a? =
length(between_fields) <= 1 and
Enum.slice(idx_a[:fields], 0, length(where_fields_list)) ==
where_fields_list
final_between_b? =
length(between_fields) <= 1 and
Enum.slice(idx_b[:fields], 0, length(where_fields_list)) ==
where_fields_list
default_index_short_circuit(
match_a?,
final_between_a?,
idx_a,
match_b?,
final_between_b?,
idx_b
)
# index_sufficient is true when the where fields are a subset of the index fields
index_sufficient_a = sufficient?(fields_a, where_fields)
index_sufficient_b = sufficient?(fields_b, where_fields)
choose_a_over_b_partial(
index_sufficient_a,
final_between_a?,
overlap_a,
fields_a,
index_sufficient_b,
final_between_b?,
overlap_b,
fields_b
)
end
defp exact_match_short_circuit(match_a?, idx_a, match_b?, idx_b)
defp exact_match_short_circuit(true, idx_a, false, _idx_b), do: throw({:best, idx_a})
defp exact_match_short_circuit(false, _idx_a, true, idx_b), do: throw({:best, idx_b})
defp exact_match_short_circuit(_, _idx_a, _, _idx_b), do: nil
defp default_index_short_circuit(
match_a?,
final_between_a?,
indx_a,
match_b?,
final_between_b?,
idx_b
)
defp default_index_short_circuit(true, true, idx_a, true, false, _idx_b),
do: throw({:best, idx_a})
defp default_index_short_circuit(true, false, _idx_a, true, true, idx_b),
do: throw({:best, idx_b})
defp default_index_short_circuit(true, _, idx_a, true, _, _idx_b), do: throw({:best, idx_a})
defp default_index_short_circuit(_, _, _idx_a, _, _, _idx_b), do: nil
defp choose_a_over_b_partial(
index_sufficient_a,
final_between_a?,
overlap_a,
fields_a,
index_sufficient_b,
final_between_b?,
overlap_b,
fields_b
)
defp choose_a_over_b_partial(true, _, _, _, false, _, _, _), do: true
defp choose_a_over_b_partial(false, _, _, _, true, _, _, _), do: false
# Then, check to see if the final field is a between constraint. This
# optimizes for Default indexes
defp choose_a_over_b_partial(_, true, _, _, _, false, _, _), do: true
defp choose_a_over_b_partial(_, false, _, _, _, true, _, _), do: false
# Finally, check for the best partial matches
defp choose_a_over_b_partial(_, _, overlap_a, fields_a, _, _, overlap_b, fields_b) do
constraints_partially_determined_a = overlap_a / MapSet.size(fields_a)
constraints_partially_determined_b = overlap_b / MapSet.size(fields_b)
constraints_partially_determined_a > constraints_partially_determined_b
end
@doc """
Executes function within a transaction, while also supplying the indexes currently
existing for the schema.
This function uses the Ecto cache and clever use of FDB constructs to guarantee
that the cache is consistent with transactional semantics.
"""
def transactional(tenant, source, fun) do
transactional(tenant, %{cache: nil}, source, fun)
end
def transactional(tenant, %{cache: cache}, source, fun) do
cache? = :enabled == (tenant.options[:metadata_cache] || :enabled)
cache = if cache?, do: cache, else: nil
future = Future.before_transactional()
Tx.transactional(tenant, fn tx ->
transactional_(tenant, tx, cache, source, future, fun)
end)
end
def transactional_(tenant, tx, nil, source, future, fun) do
{_vsn, metadata, validator} = get_metadata(tenant, tx, source, nil, future)
apply_optimistically(tx, metadata, fun, validator, nil, nil)
end
def transactional_(tenant, tx, cache, source, future, fun) do
now = System.monotonic_time(:millisecond)
cache_key = Cache.key(tenant, source)
cache_item = Cache.lookup(cache, cache_key)
{mdv, metadata, validator} = get_metadata(tenant, tx, source, cache_item, future)
Cache.update(cache, Cache.CacheItem.new(cache_key, mdv, metadata, now))
apply_optimistically(tx, metadata, fun, validator, cache, cache_key)
end
defp apply_optimistically(tx, metadata, fun, validator, cache, cache_key) do
fun.(tx, metadata)
after
unless validator.() do
if cache, do: :ets.delete(cache, cache_key)
:erlang.error(@metadata_operation_failed)
end
end
defp get_metadata(tenant, tx, source, cache_item, future) do
valid = fn -> true end
case Map.get(@builtin_metadata, source, nil) do
nil ->
global_only_mdv = MetadataVersion.tx_get_new(tx, future) |> Future.result()
# We skip the global check if this is not our first time through the transaction.
# Otherwise, another tenant's index creation can cause us to retry more than is required.
if not Tx.repeated?(tx) and Cache.CacheItem.match_global?(cache_item, global_only_mdv) do
# Waiting on the global metadataVersion key is cheap (sent with every GRV). This buys
# us the privilege of having a trivial validator. If the global metadataVersion matches,
# the transaction will always succeed (wrt our Metadata checks)
mdv = Cache.CacheItem.get_metadata_version(cache_item)
metadata = Cache.CacheItem.get_metadata(cache_item)
{mdv, metadata, valid}
else
mdv_future =
MetadataVersion.tx_with_app(
global_only_mdv,
tenant,
tx,
@app_metadata_version_idx,
future
)
claim_future = MigrationsPJ.tx_get_claim_status(tenant, tx, source, future)
_get_metadata(tenant, tx, source, cache_item, mdv_future, claim_future, future)
end
metadata ->
{nil, struct(__MODULE__, metadata), valid}
end
end
defp _get_metadata(tenant, tx, source, nil, mdv_future, claim_future, future) do
read_metadata(tenant, tx, source, future, mdv_future, claim_future)
end
defp _get_metadata(_tenant, _tx, _source, cache_item, mdv_future, claim_future, _future) do
get_metadata_from_cache(cache_item, mdv_future, claim_future)
end
defp get_metadata_from_cache(cache_item, mdv_future, claim_future) do
mdv = Cache.CacheItem.get_metadata_version(cache_item)
vsn_validator = fn ->
[mdv2, claim_status] =
[mdv_future, claim_future]
|> Future.await_stream()
|> Enum.map(&Future.result/1)
{claim_active?, _} = claim_status
Cache.CacheItem.match_local?(cache_item, mdv2) and not claim_active?
end
{mdv, Cache.CacheItem.get_metadata(cache_item), vsn_validator}
end
defp read_metadata(tenant, tx, source, future, mdv_future, claim_future) do
metadata_future = tx_get_metadata(tenant, tx, source, future)
[mdv, claim_status, metadata] =
[mdv_future, claim_future, metadata_future]
|> Future.await_stream()
|> Enum.map(&Future.result/1)
case claim_status do
{claim_active?, []} ->
{mdv, metadata, fn -> not claim_active? end}
{_, partial_idxs} ->
# Adding a write conflict here allows writes to commit at a lower latency
# at the expense of migration taking longer, because we're not letting the
# migration job starve us out
MigrationsPJ.tx_add_claim_write_conflict(tenant, tx, source)
metadata = %{metadata | partial_indexes: partial_idxs}
{mdv, metadata, fn -> true end}
end
end
defp metadata_range(tenant, source) do
Pack.namespaced_range(tenant, source(), source, [])
end
def tx_get_metadata(tenant, tx, source, future) do
{start_key, end_key} = metadata_range(tenant, source)
Future.set(
future,
tx,
:erlfdb.get_range(tx, start_key, end_key, wait: false),
&decode_metadata/1
)
end
defp decode_metadata(result) do
result
|> Enum.map(fn {_key, fdb_value} -> Pack.from_fdb_value(fdb_value) end)
# high priority first
|> Enum.sort(&(Keyword.get(&1, :priority, 0) > Keyword.get(&2, :priority, 0)))
|> new()
end
end