diff --git a/lib/sanbase_web/graphql/cache/cache.ex b/lib/sanbase_web/graphql/cache/cache.ex index e0de5b92d5..38736d78da 100644 --- a/lib/sanbase_web/graphql/cache/cache.ex +++ b/lib/sanbase_web/graphql/cache/cache.ex @@ -7,6 +7,7 @@ defmodule SanbaseWeb.Graphql.Cache do alias __MODULE__, as: CacheMod # alias SanbaseWeb.Graphql.ConCacheProvider, as: CacheProvider + # CachexProvider uses Cachex.fetch (fallback runs in worker → breaks Dataloader) alias SanbaseWeb.Graphql.CachexProvider, as: CacheProvider @ttl 300 diff --git a/lib/sanbase_web/graphql/cache/cachex_provider.ex b/lib/sanbase_web/graphql/cache/cachex_provider.ex index 4dd823a5fd..d59246804d 100644 --- a/lib/sanbase_web/graphql/cache/cachex_provider.ex +++ b/lib/sanbase_web/graphql/cache/cachex_provider.ex @@ -1,16 +1,8 @@ defmodule SanbaseWeb.Graphql.CachexProvider do @behaviour SanbaseWeb.Graphql.CacheProvider - @default_ttl_seconds 300 - - @max_lock_acquired_time_ms 60_000 import Cachex.Spec - - @compile inline: [ - execute_cache_miss_function: 4, - handle_execute_cache_miss_function: 4, - obtain_lock: 3 - ] + require Logger @impl SanbaseWeb.Graphql.CacheProvider def start_link(opts) do @@ -22,21 +14,43 @@ defmodule SanbaseWeb.Graphql.CachexProvider do Supervisor.child_spec({Cachex, opts(opts)}, id: Keyword.fetch!(opts, :id)) end + @default_max_entries 2_000_000 + @default_reclaim_ratio 0.3 + @default_limit_check_interval_ms 5000 + @default_ttl_seconds 300 + @default_expiration_interval_seconds 10 + defp opts(opts) do + max_entries = Keyword.get(opts, :max_entries, @default_max_entries) + reclaim = Keyword.get(opts, :reclaim, @default_reclaim_ratio) + + limit_interval_ms = + Keyword.get(opts, :limit_check_interval_ms, @default_limit_check_interval_ms) + + default_ttl = Keyword.get(opts, :default_ttl_seconds, @default_ttl_seconds) + + expiration_interval = + Keyword.get(opts, :expiration_interval_seconds, @default_expiration_interval_seconds) + + ensure_opts_ets() + :ets.insert(:sanbase_graphql_cachex_opts, {Keyword.fetch!(opts, :name), default_ttl}) + [ name: Keyword.fetch!(opts, :name), - # When the keys reach 2 million, remove 30% of the - # least recently written keys - limit: 2_000_000, - policy: Cachex.Policy.LRW, - reclaim: 0.3, - # How often the Janitor process runs to clean the cache - interval: 5000, - # The default TTL of keys in the cache + hooks: [ + hook( + module: Cachex.Limit.Scheduled, + args: { + max_entries, + [reclaim: reclaim], + [frequency: limit_interval_ms] + } + ) + ], expiration: expiration( - default: :timer.seconds(@default_ttl_seconds), - interval: :timer.seconds(10), + default: :timer.seconds(default_ttl), + interval: :timer.seconds(expiration_interval), lazy: true ) ] @@ -78,8 +92,7 @@ defmodule SanbaseWeb.Graphql.CachexProvider do :ok {:nocache, _} -> - Process.put(:has_nocache_field, true) - + Process.put(:do_not_cache_query, true) :ok _ -> @@ -90,105 +103,97 @@ defmodule SanbaseWeb.Graphql.CachexProvider do @impl SanbaseWeb.Graphql.CacheProvider def get_or_store(cache, key, func, cache_modify_middleware) do true_key = true_key(key) + ttl = ttl_ms(cache, key) - case Cachex.get(cache, true_key) do - {:ok, compressed_value} when is_binary(compressed_value) -> - decompress_value(compressed_value) + result = + Cachex.fetch(cache, true_key, fn -> + case func.() do + {:ok, _} = ok_tuple -> + {:commit, compress_value(ok_tuple), [expire: ttl]} - _ -> - execute_cache_miss_function(cache, key, func, cache_modify_middleware) - end - end + {:error, _} = error -> + {:ignore, error} - defp execute_cache_miss_function(cache, key, func, cache_modify_middleware) do - # This is the only place where we need to have the transactional get_or_store - # mechanism. Cachex.fetch! is running in multiple processes, which causes issues - # when testing. Cachex.transaction has a non-configurable timeout. We actually - # can achieve the required behavior by manually getting and realeasing the lock. - # The transactional guarantees are not needed. - cache_record = Cachex.Services.Overseer.ensure(cache) - - # Start a process that will handle the unlock in case this process terminates - # without releasing the lock. The process is not linked to the current one so - # it can continue to live and do its job even if this process terminates. - {:ok, unlocker_pid} = - __MODULE__.Unlocker.start(max_lock_acquired_time_ms: @max_lock_acquired_time_ms) - - unlock_fun = fn -> Cachex.Services.Locksmith.unlock(cache_record, [true_key(key)]) end - - try do - true = obtain_lock(cache_record, [true_key(key)]) - _ = GenServer.cast(unlocker_pid, {:unlock_after, unlock_fun}) - - case Cachex.get(cache, true_key(key)) do - {:ok, compressed_value} when is_binary(compressed_value) -> - # First check if the result has not been stored while waiting for the lock. - decompress_value(compressed_value) - - _ -> - handle_execute_cache_miss_function( - cache, - key, - _result = func.(), - cache_modify_middleware - ) - end - after - true = unlock_fun.() - # We expect the process to unlock only in case we don't reach here for some reason. - # If we're here we can kill the process. If the process has already unlocked - _ = GenServer.cast(unlocker_pid, :stop) - end - end + {:nocache, value} -> + # Do not put the :do_not_cache_query flag here as is + # is executed inside a Courier process. Set it afterwards + # when handling the result + {:ignore, {:nocache, value}} - defp obtain_lock(cache_record, keys, attempt \\ 0) + {:middleware, _middleware_module, _args} = tuple -> + {:ignore, cache_modify_middleware.(cache, key, tuple)} + end + end) - defp obtain_lock(_cache_record, _keys, 30) do - raise("Obtaining cache lock failed because of timeout") - end + case result do + {:commit, compressed} when is_binary(compressed) -> + decompress_value(compressed) - defp obtain_lock(cache_record, keys, attempt) do - case Cachex.Services.Locksmith.lock(cache_record, keys) do - false -> - # In case the lock cannot be obtained, try again after some time - # In the beginning the next attempt is scheduled in an exponential - # backoff fashion - 10, 130, 375, 709, etc. milliseconds - # The backoff is capped at 2 seconds - sleep_ms = (:math.pow(attempt * 20, 1.6) + 10) |> trunc() - sleep_ms = Enum.min([sleep_ms, 2000]) - - Process.sleep(sleep_ms) - obtain_lock(cache_record, keys, attempt + 1) - - true -> - true - end - end + {:ok, compressed} when is_binary(compressed) -> + decompress_value(compressed) - defp handle_execute_cache_miss_function(cache, key, result, cache_modify_middleware) do - case result do - {:middleware, _, _} = tuple -> - cache_modify_middleware.(cache, key, tuple) + {:error, error} -> + # Transforms like :no_cache -> "Specified cache not running" + error_msg = if is_atom(error), do: Cachex.Error.long_form(error), else: error + {:error, error_msg} - {:nocache, value} -> - Process.put(:has_nocache_field, true) - value + {:ignore, {:error, error}} -> + # Transforms like :no_cache -> "Specified cache not running" + error_msg = if is_atom(error), do: Cachex.Error.long_form(error), else: error + {:error, error_msg} - {:error, _} = error -> - error + {:ignore, {:nocache, value}} -> + Process.put(:do_not_cache_query, true) + value - {:ok, _value} = ok_tuple -> - cache_item(cache, key, ok_tuple) - ok_tuple + {:ignore, value} -> + value end end + defp ttl_ms(_cache, {_key, ttl}) when is_integer(ttl), do: :timer.seconds(ttl) + defp ttl_ms(cache, _key), do: :timer.seconds(default_ttl_seconds(cache)) + defp cache_item(cache, {key, ttl}, value) when is_integer(ttl) do - Cachex.put(cache, key, compress_value(value), ttl: :timer.seconds(ttl)) + Cachex.put(cache, key, compress_value(value), expire: :timer.seconds(ttl)) end defp cache_item(cache, key, value) do - Cachex.put(cache, key, compress_value(value), ttl: :timer.seconds(@default_ttl_seconds)) + Cachex.put(cache, key, compress_value(value), + expire: :timer.seconds(default_ttl_seconds(cache)) + ) + end + + defp default_ttl_seconds(cache) do + case :ets.lookup(:sanbase_graphql_cachex_opts, cache) do + [{^cache, ttl}] -> ttl + [] -> @default_ttl_seconds + end + rescue + _ -> + Logger.error( + "CachexProvider: Could not get default TTL from ETS for cache #{cache}, using default #{@default_ttl_seconds}" + ) + + @default_ttl_seconds + end + + defp ensure_opts_ets() do + case :ets.whereis(:sanbase_graphql_cachex_opts) do + :undefined -> + try do + :ets.new(:sanbase_graphql_cachex_opts, [:named_table, :public, :set]) + catch + :error, {:badarg, _} -> :ok + :error, :badarg -> :ok + :error, %ArgumentError{} -> :ok + end + + _ -> + :ok + end + + :ok end defp true_key({key, ttl}) when is_integer(ttl), do: key diff --git a/lib/sanbase_web/graphql/cache/cachex_provider_global.ex b/lib/sanbase_web/graphql/cache/cachex_provider_global.ex new file mode 100644 index 0000000000..20416b561d --- /dev/null +++ b/lib/sanbase_web/graphql/cache/cachex_provider_global.ex @@ -0,0 +1,152 @@ +defmodule SanbaseWeb.Graphql.CachexProviderGlobal do + @behaviour SanbaseWeb.Graphql.CacheProvider + @default_ttl_seconds 300 + + import Cachex.Spec + + @impl SanbaseWeb.Graphql.CacheProvider + def start_link(opts) do + Cachex.start_link(opts(opts)) + end + + @impl SanbaseWeb.Graphql.CacheProvider + def child_spec(opts) do + Supervisor.child_spec({Cachex, opts(opts)}, id: Keyword.fetch!(opts, :id)) + end + + defp opts(opts) do + [ + name: Keyword.fetch!(opts, :name), + expiration: + expiration( + default: :timer.seconds(@default_ttl_seconds), + interval: :timer.seconds(10), + lazy: true + ) + ] + end + + @impl SanbaseWeb.Graphql.CacheProvider + def size(cache) do + {:ok, bytes_size} = Cachex.inspect(cache, {:memory, :bytes}) + (bytes_size / (1024 * 1024)) |> Float.round(2) + end + + @impl SanbaseWeb.Graphql.CacheProvider + def count(cache) do + {:ok, count} = Cachex.size(cache) + count + end + + @impl SanbaseWeb.Graphql.CacheProvider + def clear_all(cache) do + {:ok, _} = Cachex.clear(cache) + :ok + end + + @impl SanbaseWeb.Graphql.CacheProvider + def get(cache, key) do + case Cachex.get(cache, true_key(key)) do + {:ok, compressed_value} when is_binary(compressed_value) -> + decompress_value(compressed_value) + + _ -> + nil + end + end + + @impl SanbaseWeb.Graphql.CacheProvider + def store(cache, key, value) do + case value do + {:error, _} -> + :ok + + {:nocache, _} -> + Process.put(:do_not_cache_query, true) + :ok + + _ -> + cache_item(cache, key, value) + end + end + + @impl SanbaseWeb.Graphql.CacheProvider + def get_or_store(cache, key, func, cache_modify_middleware) do + true_key = true_key(key) + # The self() is the LockRequesterId, the resource is uniquely + # identified by the first element of the tuple. Using the pid + # here DOES NOT make it so different callers execute the function + # at the same time. + lock_key = {{cache, true_key}, self()} + + case Cachex.get(cache, true_key) do + {:ok, compressed_value} when is_binary(compressed_value) -> + decompress_value(compressed_value) + + _ -> + :global.trans( + lock_key, + fn -> + case Cachex.get(cache, true_key) do + {:ok, compressed_value} when is_binary(compressed_value) -> + decompress_value(compressed_value) + + _ -> + execute_cache_miss(cache, key, func, cache_modify_middleware) + end + end, + [node()] + ) + end + end + + defp execute_cache_miss(cache, key, func, cache_modify_middleware) do + result = + try do + func.() + rescue + e -> {:error, Exception.message(e)} + catch + kind, reason -> {:error, "#{kind}: #{inspect(reason)}"} + end + + case result do + {:ok, _} = ok_tuple -> + cache_item(cache, key, ok_tuple) + ok_tuple + + {:error, _} = error -> + error + + {:nocache, value} -> + Process.put(:do_not_cache_query, true) + value + + {:middleware, _middleware_module, _args} = tuple -> + cache_modify_middleware.(cache, key, tuple) + end + end + + defp cache_item(cache, {key, ttl}, value) when is_integer(ttl) do + Cachex.put(cache, key, compress_value(value), expire: :timer.seconds(ttl)) + end + + defp cache_item(cache, key, value) do + Cachex.put(cache, key, compress_value(value), expire: :timer.seconds(@default_ttl_seconds)) + end + + defp true_key({key, ttl}) when is_integer(ttl), do: key + defp true_key(key), do: key + + defp compress_value(value) do + value + |> :erlang.term_to_binary() + |> :zlib.gzip() + end + + defp decompress_value(value) do + value + |> :zlib.gunzip() + |> :erlang.binary_to_term() + end +end diff --git a/lib/sanbase_web/graphql/cache/cachex_unlocker.ex b/lib/sanbase_web/graphql/cache/cachex_unlocker.ex deleted file mode 100644 index ef5898bd3b..0000000000 --- a/lib/sanbase_web/graphql/cache/cachex_unlocker.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule SanbaseWeb.Graphql.CachexProvider.Unlocker do - @moduledoc ~s""" - Module that makes sure that locks acquired during get_or_store locking in - the Cachex provider. - - When locks are acquired, a process is spawned that unlocks the lock in case - something wrong does with the process that obtained it. If the process finishes - fast without issues it will kill this process. - """ - - use GenServer - - def start(opts) do - GenServer.start(__MODULE__, opts) - end - - def init(opts) do - max_lock_acquired_time_ms = Keyword.fetch!(opts, :max_lock_acquired_time_ms) - # If the process that started the Unlocker terminates before sending the unlock_after - # message this process will live forever. Because of this schedule a termination after - # at least 2 `max_lock_acquired_time_ms` epochs has passed. Two are needed so it waits - # both the lock obtaining time (up to ~55-60 seconds) and the actual lock holding time - Process.send_after(self(), :self_terminate, 2 * max_lock_acquired_time_ms + 1000) - {:ok, %{max_lock_acquired_time_ms: max_lock_acquired_time_ms}} - end - - def handle_cast({:unlock_after, unlock_fun}, state) do - Process.send_after(self(), {:unlock_lock, unlock_fun}, state[:max_lock_acquired_time_ms]) - {:noreply, state} - end - - def handle_cast(:stop, state) do - {:stop, :normal, state} - end - - def handle_info({:unlock_lock, unlock_fun}, state) do - unlock_fun.() - {:noreply, state} - end - - def handle_info(:self_terminate, state) do - {:stop, :normal, state} - end - - def terminate(_reason, _state) do - :normal - end -end diff --git a/lib/sanbase_web/graphql/dataloader/postgres_dataloader.ex b/lib/sanbase_web/graphql/dataloader/postgres_dataloader.ex index 4297f8319b..716b7b9832 100644 --- a/lib/sanbase_web/graphql/dataloader/postgres_dataloader.ex +++ b/lib/sanbase_web/graphql/dataloader/postgres_dataloader.ex @@ -4,6 +4,9 @@ defmodule SanbaseWeb.Graphql.PostgresDataloader do alias Sanbase.Repo alias Sanbase.Comment alias Sanbase.Model.{MarketSegment, Infrastructure} + alias Sanbase.Project.{ContractAddress, ProjectMarketSegment, SourceSlugMapping} + alias Sanbase.Project.SocialVolumeQuery + alias Sanbase.ProjectEthAddress def data() do Dataloader.KV.new(&query/2) @@ -80,6 +83,51 @@ defmodule SanbaseWeb.Graphql.PostgresDataloader do |> Map.new(fn %Infrastructure{id: id, code: code} -> {id, code} end) end + def query(:contract_addresses, project_ids) do + project_ids = Enum.to_list(project_ids) + + from(ca in ContractAddress, where: ca.project_id in ^project_ids) + |> Repo.all() + |> Enum.group_by(& &1.project_id, & &1) + end + + def query(:eth_addresses, project_ids) do + project_ids = Enum.to_list(project_ids) + + from(pea in ProjectEthAddress, where: pea.project_id in ^project_ids, order_by: [asc: pea.id]) + |> Repo.all() + |> Enum.group_by(& &1.project_id, & &1) + end + + def query(:social_volume_query, project_ids) do + project_ids = Enum.to_list(project_ids) + + from(svq in SocialVolumeQuery, where: svq.project_id in ^project_ids) + |> Repo.all() + |> Map.new(&{&1.project_id, &1}) + end + + def query(:source_slug_mappings, project_ids) do + project_ids = Enum.to_list(project_ids) + + from(ssm in SourceSlugMapping, where: ssm.project_id in ^project_ids) + |> Repo.all() + |> Enum.group_by(& &1.project_id, & &1) + end + + def query(:market_segments, project_ids) do + project_ids = Enum.to_list(project_ids) + + from(pms in ProjectMarketSegment, + join: ms in MarketSegment, + on: ms.id == pms.market_segment_id, + where: pms.project_id in ^project_ids, + select: {pms.project_id, ms} + ) + |> Repo.all() + |> Enum.group_by(fn {project_id, _} -> project_id end, fn {_, ms} -> ms end) + end + def query(:traded_on_exchanges, slugs_mapset) do slugs = Enum.to_list(slugs_mapset) diff --git a/lib/sanbase_web/graphql/dataloader/sanbase_dataloader.ex b/lib/sanbase_web/graphql/dataloader/sanbase_dataloader.ex index 1c80a84f65..c4c37538b7 100644 --- a/lib/sanbase_web/graphql/dataloader/sanbase_dataloader.ex +++ b/lib/sanbase_web/graphql/dataloader/sanbase_dataloader.ex @@ -74,11 +74,16 @@ defmodule SanbaseWeb.Graphql.SanbaseDataloader do ] @postgres_dataloader [ + :contract_addresses, :current_user_address_details, + :eth_addresses, :infrastructure, :insights_count_per_user, :market_segment, + :market_segments, :project_by_slug, + :social_volume_query, + :source_slug_mappings, :traded_on_exchanges_count, :traded_on_exchanges, # Users diff --git a/lib/sanbase_web/graphql/resolvers/project/project_resolver.ex b/lib/sanbase_web/graphql/resolvers/project/project_resolver.ex index 27979e960a..de8425973f 100644 --- a/lib/sanbase_web/graphql/resolvers/project/project_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/project/project_resolver.ex @@ -110,6 +110,109 @@ defmodule SanbaseWeb.Graphql.Resolvers.ProjectResolver do end) end + def project_main_contract_address(%Project{id: id}, _args, %{ + context: %{loader: loader} + }) do + loader + |> Dataloader.load(SanbaseDataloader, :contract_addresses, id) + |> on_load(fn loader -> + contract_addresses = Dataloader.get(loader, SanbaseDataloader, :contract_addresses, id) + + result = + case contract_addresses do + [_ | _] -> + main = Project.ContractAddress.list_to_main_contract_address(contract_addresses) + if main, do: main.address, else: nil + + _ -> + nil + end + + {:ok, result} + end) + end + + def project_contract_addresses(%Project{id: id}, _args, %{context: %{loader: loader}}) do + loader + |> Dataloader.load(SanbaseDataloader, :contract_addresses, id) + |> on_load(fn loader -> + contract_addresses = + Dataloader.get(loader, SanbaseDataloader, :contract_addresses, id) || [] + + {:ok, contract_addresses |> Enum.reject(&is_nil(&1.decimals))} + end) + end + + def eth_addresses_resolver_fun(%Project{id: id}, _args, %{context: %{loader: loader}}) do + loader + |> Dataloader.load(SanbaseDataloader, :eth_addresses, id) + |> on_load(fn loader -> + {:ok, Dataloader.get(loader, SanbaseDataloader, :eth_addresses, id)} + end) + end + + def social_volume_query(%Project{id: id} = project, _args, %{context: %{loader: loader}}) do + loader + |> Dataloader.load(SanbaseDataloader, :social_volume_query, id) + |> on_load(fn loader -> + svq = Dataloader.get(loader, SanbaseDataloader, :social_volume_query, id) + + result = + case svq do + nil -> + Project.SocialVolumeQuery.default_query(project) + + %{query: query} when query in [nil, ""] -> + svq.autogenerated_query + + %{query: query} -> + query + end + + {:ok, result} + end) + end + + def source_slug_mappings(%Project{id: id}, _args, %{context: %{loader: loader}}) do + loader + |> Dataloader.load(SanbaseDataloader, :source_slug_mappings, id) + |> on_load(fn loader -> + source_slug_mappings = + Dataloader.get(loader, SanbaseDataloader, :source_slug_mappings, id) || [] + + {:ok, source_slug_mappings} + end) + end + + def market_segment(%Project{id: id}, _args, %{context: %{loader: loader}}) do + loader + |> Dataloader.load(SanbaseDataloader, :market_segments, id) + |> on_load(fn loader -> + query = Dataloader.get(loader, SanbaseDataloader, :market_segments, id) + list = List.wrap(query) + {:ok, list |> Enum.map(& &1.name) |> List.first()} + end) + end + + def market_segments(%Project{id: id}, _args, %{context: %{loader: loader}}) do + loader + |> Dataloader.load(SanbaseDataloader, :market_segments, id) + |> on_load(fn loader -> + query = Dataloader.get(loader, SanbaseDataloader, :market_segments, id) + list = List.wrap(query) + {:ok, Enum.map(list, & &1.name)} + end) + end + + def project_market_segment_tags(%Project{id: id}, _args, %{context: %{loader: loader}}) do + loader + |> Dataloader.load(SanbaseDataloader, :market_segments, id) + |> on_load(fn loader -> + query = Dataloader.get(loader, SanbaseDataloader, :market_segments, id) + {:ok, List.wrap(query)} + end) + end + def roi_usd(%Project{} = project, _args, _resolution) do roi = Project.roi_usd(project) diff --git a/lib/sanbase_web/graphql/schema/types/project_types.ex b/lib/sanbase_web/graphql/schema/types/project_types.ex index 393c49004a..083b111526 100644 --- a/lib/sanbase_web/graphql/schema/types/project_types.ex +++ b/lib/sanbase_web/graphql/schema/types/project_types.ex @@ -451,102 +451,35 @@ defmodule SanbaseWeb.Graphql.ProjectTypes do end field :main_contract_address, :string do - cache_resolve( - dataloader(SanbaseRepo, :contract_addresses, - callback: fn contract_addresses, _project, _args -> - case contract_addresses do - [_ | _] -> - main = Project.ContractAddress.list_to_main_contract_address(contract_addresses) - address = if main, do: main.address - {:ok, address} - - _ -> - {:ok, nil} - end - end - ), - fun_name: :project_main_contract_address - ) + cache_resolve(&ProjectResolver.project_main_contract_address/3) end field :contract_addresses, list_of(:contract_address) do - cache_resolve( - dataloader(SanbaseRepo, :contract_addresses, - callback: fn contract_addresses, _project, _args -> - {:ok, contract_addresses |> Enum.reject(&is_nil(&1.decimals))} - end - ), - fun_name: :project_contract_addresses - ) + cache_resolve(&ProjectResolver.project_contract_addresses/3) end field :eth_addresses, list_of(:eth_address) do - cache_resolve( - dataloader(SanbaseRepo), - fun_name: :eth_addresses_resolver_fun - ) + cache_resolve(&ProjectResolver.eth_addresses_resolver_fun/3) end field :social_volume_query, :string do - cache_resolve( - dataloader(SanbaseRepo, :social_volume_query, - callback: fn - nil, project, _args -> - {:ok, Project.SocialVolumeQuery.default_query(project)} - - svq, _project, _args -> - case svq.query do - query when query in [nil, ""] -> {:ok, svq.autogenerated_query} - _ -> {:ok, svq.query} - end - end - ), - fun_name: :social_volume_query - ) + cache_resolve(&ProjectResolver.social_volume_query/3) end field :source_slug_mappings, list_of(:source_slug_mapping) do - cache_resolve( - dataloader(SanbaseRepo, :source_slug_mappings, - callback: fn query, _project, _args -> {:ok, query} end - ), - fun_name: :source_slug_mappings - ) + cache_resolve(&ProjectResolver.source_slug_mappings/3) end field :market_segment, :string do - # Introduce a different function name so it does not share cache with the - # :market_segments as they query the same data - cache_resolve( - dataloader(SanbaseRepo, :market_segments, - callback: fn query, _project, _args -> - {:ok, query |> Enum.map(& &1.name) |> List.first()} - end - ), - fun_name: :market_segment - ) + cache_resolve(&ProjectResolver.market_segment/3) end field :market_segments, list_of(:string) do - cache_resolve( - dataloader(SanbaseRepo, :market_segments, - callback: fn query, _project, _args -> - {:ok, query |> Enum.map(& &1.name)} - end - ), - fun_name: :market_segments - ) + cache_resolve(&ProjectResolver.market_segments/3) end field :tags, list_of(:project_tag) do - cache_resolve( - dataloader(SanbaseRepo, :market_segments, - callback: fn query, _project, _args -> - {:ok, query} - end, - fun_name: :project_market_segment_tags - ) - ) + cache_resolve(&ProjectResolver.project_market_segment_tags/3) end field :is_trending, :boolean do diff --git a/mix.exs b/mix.exs index 3ad3246a39..cb0be4df69 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,7 @@ defmodule Sanbase.Mixfile do {:absinthe, "~> 1.5"}, {:brod, "~> 4.0"}, {:browser, "~> 0.5"}, - {:cachex, "~> 3.4"}, + {:cachex, "~> 4.0"}, {:cidr, "~> 1.1"}, {:clickhouse_ecto, github: "santiment/clickhouse_ecto", branch: "migrate-ecto-3"}, {:clickhousex, github: "santiment/clickhousex", override: true}, diff --git a/mix.lock b/mix.lock index 3023748f3e..ef4c2ec3bc 100644 --- a/mix.lock +++ b/mix.lock @@ -6,7 +6,7 @@ "brod": {:hex, :brod, "4.3.3", "deff96d806af05b15da092b5fd732932bb54616056211a6f928366a182e9c164", [:rebar3], [{:kafka_protocol, "4.1.10", [hex: :kafka_protocol, repo: "hexpm", optional: false]}], "hexpm", "e5a7aefcc0590c548a9759d66b929bef03876194cea521a775baa4dfdda8ed16"}, "browser": {:hex, :browser, "0.5.4", "d8f5e125004c64bff67f576ab264fdfdefad20e82f7134ada745b9c070801b9d", [:mix], [{:plug, "~> 1.2", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ecdd6d197b1e49a56e5edaef76846697840f0d12d9baf88aed8c7ddd52819e0a"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, + "cachex": {:hex, :cachex, "4.1.1", "574c5cd28473db313a0a76aac8c945fe44191659538ca6a1e8946ec300b1a19f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d6b7449ff98d6bb92dda58bd4fc3189cae9f99e7042054d669596f56dc503cd8"}, "castle": {:hex, :castle, "0.3.0", "47b1a550b2348a6d7e60e43ded1df19dca601ed21ef6f267c3dbb1b3a301fbf5", [:mix], [{:forecastle, "~> 0.1.0", [hex: :forecastle, repo: "hexpm", optional: false]}], "hexpm", "dbdc1c171520c4591101938a3d342dec70d36b7f5b102a5c138098581e35fcef"}, "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"}, "certifi": {:hex, :certifi, "2.16.0", "a4edfc1d2da3424d478a3271133bf28e0ec5e6fd8c009aab5a4ae980cb165ce9", [:rebar3], [], "hexpm", "8a64f6669d85e9cc0e5086fcf29a5b13de57a13efa23d3582874b9a19303f184"}, @@ -47,6 +47,7 @@ "ex_aws": {:hex, :ex_aws, "2.5.8", "0393cfbc5e4a9e7017845451a015d836a670397100aa4c86901980e2a2c5f7d4", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.3", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8f79777b7932168956c8cc3a6db41f5783aa816eb50de356aed3165a71e5f8c3"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.6", "d135983bbd8b6df6350dfd83999437725527c1bea151e5055760bfc9b2d17c20", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "9874e12847e469ca2f13a5689be04e546c16f63caf6380870b7f25bf7cb98875"}, "ex_aws_ses": {:hex, :ex_aws_ses, "2.4.1", "1aa945610121c9891054c27d0f71f5799b2e0a2062044d742d89c1cee251f9e2", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "dddac42d4d7b826f7099bbe7402a35e68eb76434d6c58bfa332002ea2b522645"}, + "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"}, "ex_keccak": {:hex, :ex_keccak, "0.7.5", "f3b733173510d48ae9a1ea1de415e694b2651f35c787e63f33b5ed0013fbfd35", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8a5e1cb7f96fff5e480ff6a121477b90c4fd8c150984086dffd98819f5d83763"}, "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, diff --git a/test/sanbase_web/cache/cachex_provider_test.exs b/test/sanbase_web/cache/cachex_provider_test.exs index ebb457b9e0..a8033fe667 100644 --- a/test/sanbase_web/cache/cachex_provider_test.exs +++ b/test/sanbase_web/cache/cachex_provider_test.exs @@ -13,117 +13,567 @@ defmodule SanbaseWeb.Graphql.CachexProviderTest do :ok end - test "return plain nil when no value stored" do - assert nil == CacheProvider.get(@cache_name, "somekeyj") + # --------------------------------------------------------------------------- + # get/2 + # --------------------------------------------------------------------------- + + test "get returns nil when key is not present" do + assert nil == CacheProvider.get(@cache_name, "missing_key") end - test "return {:ok, value} when {:ok, value} is explicitly stored" do - key = 123_123 - cache_key = {key, 60} - value = "something" - CacheProvider.store(@cache_name, cache_key, {:ok, value}) - assert {:ok, value} == CacheProvider.get(@cache_name, key) + test "get with a TTL tuple key and a plain key both resolve to the same stored entry" do + CacheProvider.store(@cache_name, {"ttl_key", 60}, {:ok, "hello"}) + assert {:ok, "hello"} == CacheProvider.get(@cache_name, "ttl_key") + assert {:ok, "hello"} == CacheProvider.get(@cache_name, {"ttl_key", 60}) end - test "only one computation is run if slow function is accessed multiple times" do - test_pid = self() + # --------------------------------------------------------------------------- + # store/3 + # --------------------------------------------------------------------------- - get_or_store_fun = fn -> - CacheProvider.get_or_store( - @cache_name, - :same_key, - fn -> - Process.sleep(1000) - send(test_pid, "message from precalculation") - {:ok, "Hello"} - end, - & &1 - ) - end + test "store then get returns the value" do + CacheProvider.store(@cache_name, {123_123, 60}, {:ok, "something"}) + assert {:ok, "something"} == CacheProvider.get(@cache_name, 123_123) + end + + test "store with a plain key (no TTL) works" do + CacheProvider.store(@cache_name, "plain_key", {:ok, 99}) + assert {:ok, 99} == CacheProvider.get(@cache_name, "plain_key") + end + + test "store does not cache error values" do + CacheProvider.store(@cache_name, "error_key", {:error, "boom"}) + assert nil == CacheProvider.get(@cache_name, "error_key") + end + + test "store does not cache nocache values and sets :do_not_cache_query in the caller" do + CacheProvider.store(@cache_name, "nocache_key", {:nocache, {:ok, "ignored"}}) + assert nil == CacheProvider.get(@cache_name, "nocache_key") + assert true == Process.get(:do_not_cache_query) + end + + test "complex Elixir terms survive the gzip + term_to_binary round-trip" do + value = {:ok, %{name: "Alice", tags: [:a, :b], nested: %{x: {1, 2}}}} + CacheProvider.store(@cache_name, "complex_key", value) + assert value == CacheProvider.get(@cache_name, "complex_key") + end + + # --------------------------------------------------------------------------- + # count/1, clear_all/1, size/1 + # --------------------------------------------------------------------------- + + test "count returns 0 for an empty cache" do + assert 0 == CacheProvider.count(@cache_name) + end + + test "count increases as values are stored, ignoring errors" do + CacheProvider.store(@cache_name, "ck1", {:ok, 1}) + CacheProvider.store(@cache_name, "ck2", {:ok, 2}) + CacheProvider.store(@cache_name, "ck_err", {:error, "oops"}) + assert 2 == CacheProvider.count(@cache_name) + end + + test "clear_all removes all entries" do + CacheProvider.store(@cache_name, "del1", {:ok, 1}) + CacheProvider.store(@cache_name, "del2", {:ok, 2}) + CacheProvider.clear_all(@cache_name) + assert 0 == CacheProvider.count(@cache_name) + assert nil == CacheProvider.get(@cache_name, "del1") + end + + test "size returns a non-negative float" do + size = CacheProvider.size(@cache_name) + assert is_float(size) and size >= 0.0 + end + + # --------------------------------------------------------------------------- + # get_or_store/4 — basic single-caller behaviour + # --------------------------------------------------------------------------- + + test "get_or_store returns {:ok, value} and caches it" do + result = CacheProvider.get_or_store(@cache_name, "ok_key", fn -> {:ok, 42} end, & &1) + assert {:ok, 42} == result + # Second call hits the cache, function is not called again + assert {:ok, 42} == CacheProvider.get(@cache_name, "ok_key") + end + + test "get_or_store returns {:error, reason} and does not cache it" do + result = + CacheProvider.get_or_store(@cache_name, "err_key", fn -> {:error, "oops"} end, & &1) + + assert {:error, "oops"} == result + assert nil == CacheProvider.get(@cache_name, "err_key") + end - for _ <- 1..10, do: spawn(get_or_store_fun) + test "get_or_store returns {:ok, value} for nocache and does not cache it" do + result = + CacheProvider.get_or_store(@cache_name, "nc_key", fn -> {:nocache, {:ok, "raw"}} end, & &1) - Process.sleep(1050) - assert_receive("message from precalculation") - refute_receive("message from precalculation") + assert {:ok, "raw"} == result + assert nil == CacheProvider.get(@cache_name, "nc_key") end - test "value is actually cached and not precalculated" do - key = {123_123, 60} + test "get_or_store nocache sets :do_not_cache_query in the calling process" do + CacheProvider.get_or_store(@cache_name, "nc_flag", fn -> {:nocache, {:ok, "v"}} end, & &1) + assert true == Process.get(:do_not_cache_query) + end + + test "get_or_store middleware tuple calls the second function and does not cache" do test_pid = self() CacheProvider.get_or_store( @cache_name, - key, + "mw_key", fn -> - send(test_pid, "message from precalculation") - {:ok, "Hello"} + send(test_pid, :func_called) + {:middleware, "arg2", "arg3"} end, - & &1 + fn _, _, _ -> + send(test_pid, :middleware_called) + {:ok, "Hello"} + end ) - assert_receive("message from precalculation") - + assert_receive :func_called + assert_receive :middleware_called + # Not cached — second call triggers both functions again CacheProvider.get_or_store( @cache_name, - key, + "mw_key", fn -> - send(test_pid, "message from precalculation") - {:ok, "Hello"} + send(test_pid, :func_called) + {:middleware, "arg2", "arg3"} end, - & &1 + fn _, _, _ -> + send(test_pid, :middleware_called) + {:ok, "Hello"} + end ) - refute_receive("message from precalculation") + assert_receive :func_called + assert_receive :middleware_called end - test "error value is not stored" do - key = "somekey" + test "get_or_store with a {key, ttl} tuple stores and retrieves under the plain key" do + result = + CacheProvider.get_or_store( + @cache_name, + {"ttl_gs_key", 60}, + fn -> {:ok, "stored"} end, + & &1 + ) + + assert {:ok, "stored"} == result + assert {:ok, "stored"} == CacheProvider.get(@cache_name, "ttl_gs_key") + end + + # --------------------------------------------------------------------------- + # get_or_store/4 — concurrent: happy path + # --------------------------------------------------------------------------- + + test "only one computation runs when the same key is requested concurrently" do test_pid = self() - CacheProvider.get_or_store( - @cache_name, - key, - fn -> - send(test_pid, "message from precalculation") - {:error, "Goodbye"} - end, - & &1 - ) + for _ <- 1..10 do + spawn(fn -> + CacheProvider.get_or_store( + @cache_name, + :dedup_key, + fn -> + Process.sleep(300) + send(test_pid, :computed) + {:ok, "result"} + end, + & &1 + ) + end) + end + + assert_receive :computed, 5000 + refute_receive :computed + end + + test "all concurrent callers receive the same computed value" do + test_pid = self() + n = 10 + + for _ <- 1..n do + spawn(fn -> + result = + CacheProvider.get_or_store( + @cache_name, + :shared_value_key, + fn -> + Process.sleep(300) + {:ok, "shared"} + end, + & &1 + ) + + send(test_pid, {:result, result}) + end) + end + + results = + Enum.map(1..n, fn _ -> + assert_receive {:result, result}, 5000 + result + end) + + assert Enum.all?(results, &(&1 == {:ok, "shared"})) + end + + test "a caller that arrives mid-computation is deduplicated rather than spawning a new computation" do + test_pid = self() + + # First caller starts a slow computation + spawn(fn -> + result = + CacheProvider.get_or_store( + @cache_name, + :mid_key, + fn -> + Process.sleep(500) + send(test_pid, :computed) + {:ok, "result"} + end, + & &1 + ) + + send(test_pid, {:first, result}) + end) + + # Second caller arrives while the computation is still in flight + Process.sleep(50) + + spawn(fn -> + result = + CacheProvider.get_or_store( + @cache_name, + :mid_key, + # This function must NOT run — it would be a second computation + fn -> + send(test_pid, :computed) + {:ok, "different"} + end, + & &1 + ) + + send(test_pid, {:second, result}) + end) + + assert_receive :computed, 5000 + refute_receive :computed + + # Both callers get the result from the single computation + assert_receive {:first, {:ok, "result"}}, 5000 + assert_receive {:second, {:ok, "result"}}, 5000 + end + + test "computations for different keys run in parallel without blocking each other" do + test_pid = self() + + for key <- [:parallel_key_a, :parallel_key_b] do + spawn(fn -> + CacheProvider.get_or_store( + @cache_name, + key, + fn -> + Process.sleep(300) + send(test_pid, {:computed, key}) + {:ok, key} + end, + & &1 + ) + end) + end + + # If they ran serially this would take ~600ms; 1500ms budget is generous + # but we primarily care that BOTH computations run + assert_receive {:computed, _}, 1500 + assert_receive {:computed, _}, 1500 + end + + # --------------------------------------------------------------------------- + # get_or_store/4 — concurrent: error path + # --------------------------------------------------------------------------- - assert_receive("message from precalculation") + test "when the computation errors, all concurrent callers receive the error" do + test_pid = self() + n = 5 + + for _ <- 1..n do + spawn(fn -> + result = + CacheProvider.get_or_store( + @cache_name, + :concurrent_error_key, + fn -> + Process.sleep(100) + send(test_pid, :computed) + {:error, "transient failure"} + end, + & &1 + ) + + send(test_pid, {:result, result}) + end) + end + + # Exactly one computation runs + assert_receive :computed, 5000 + refute_receive :computed + + # Every caller gets the error + results = + Enum.map(1..n, fn _ -> + assert_receive {:result, result}, 5000 + result + end) + + assert Enum.all?(results, &(&1 == {:error, "transient failure"})) + end + + test "an error result is not cached — the next call retries the computation" do + test_pid = self() + n = 3 + + # First wave: all error + for _ <- 1..n do + spawn(fn -> + CacheProvider.get_or_store( + @cache_name, + :retry_after_error_key, + fn -> + Process.sleep(100) + {:error, "down"} + end, + & &1 + ) + + send(test_pid, :wave1_done) + end) + end + + for _ <- 1..n, do: assert_receive(:wave1_done, 5000) + + # Fresh call after the error wave — must trigger a new computation + result = + CacheProvider.get_or_store( + @cache_name, + :retry_after_error_key, + fn -> + send(test_pid, :retried) + {:ok, "recovered"} + end, + & &1 + ) + + assert_receive :retried, 5000 + assert {:ok, "recovered"} == result + end + + # --------------------------------------------------------------------------- + # get_or_store/4 — concurrent: nocache path + # --------------------------------------------------------------------------- + + test "when the computation returns nocache, all concurrent callers get {:ok, value} but nothing is cached" do + test_pid = self() + n = 5 + + for _ <- 1..n do + spawn(fn -> + result = + CacheProvider.get_or_store( + @cache_name, + :concurrent_nocache_key, + fn -> + Process.sleep(100) + send(test_pid, :computed) + {:nocache, {:ok, "ephemeral"}} + end, + & &1 + ) + + send(test_pid, {:result, result}) + end) + end + + assert_receive :computed, 5000 + refute_receive :computed + + results = + Enum.map(1..n, fn _ -> + assert_receive {:result, result}, 5000 + result + end) + + assert Enum.all?(results, &(&1 == {:ok, "ephemeral"})) + assert nil == CacheProvider.get(@cache_name, :concurrent_nocache_key) + end + + test "a nocache result is not cached — the next call retries the computation" do + test_pid = self() + n = 3 + + for _ <- 1..n do + spawn(fn -> + CacheProvider.get_or_store( + @cache_name, + :retry_after_nocache_key, + fn -> + Process.sleep(100) + {:nocache, {:ok, "skip me"}} + end, + & &1 + ) + + send(test_pid, :wave1_done) + end) + end + + for _ <- 1..n, do: assert_receive(:wave1_done, 5000) CacheProvider.get_or_store( @cache_name, - key, + :retry_after_nocache_key, fn -> - send(test_pid, "message from precalculation") - {:error, "Goodbye"} + send(test_pid, :retried) + {:ok, "now cached"} end, & &1 ) - assert_receive("message from precalculation") + assert_receive :retried, 5000 + assert {:ok, "now cached"} == CacheProvider.get(@cache_name, :retry_after_nocache_key) end - test "the second function is called in case of :middleware tuple" do - key = "somekey" + # --------------------------------------------------------------------------- + # get_or_store/4 — concurrent: exception path + # --------------------------------------------------------------------------- + + test "when the computation raises, all concurrent callers receive an error tuple" do test_pid = self() + n = 5 - CacheProvider.get_or_store( - @cache_name, - key, - fn -> - send(test_pid, "message from precalculation") - {:middleware, "fake second arg", "fake third arg"} - end, - fn _, _, _ -> - send(test_pid, "message from the other function") - {:ok, "Hello"} + for _ <- 1..n do + spawn(fn -> + result = + CacheProvider.get_or_store( + @cache_name, + :concurrent_raise_key, + fn -> + Process.sleep(100) + raise "something went wrong" + end, + & &1 + ) + + send(test_pid, {:result, result}) + end) + end + + results = + Enum.map(1..n, fn _ -> + assert_receive {:result, result}, 5000 + result + end) + + assert Enum.all?(results, &match?({:error, _}, &1)) + # Nothing was cached + assert nil == CacheProvider.get(@cache_name, :concurrent_raise_key) + end + + test "after a raised exception, the next call retries the computation" do + test_pid = self() + + spawn(fn -> + CacheProvider.get_or_store( + @cache_name, + :raise_then_retry_key, + fn -> + Process.sleep(50) + raise "crash" + end, + & &1 + ) + + send(test_pid, :wave1_done) + end) + + assert_receive :wave1_done, 5000 + + result = + CacheProvider.get_or_store( + @cache_name, + :raise_then_retry_key, + fn -> + send(test_pid, :retried) + {:ok, "success after crash"} + end, + & &1 + ) + + assert_receive :retried, 5000 + assert {:ok, "success after crash"} == result + end + + # --------------------------------------------------------------------------- + # configuration: default_ttl_seconds, reclaim, max_entries, limit_check_interval_ms + # --------------------------------------------------------------------------- + + describe "configuration" do + test "uses custom default_ttl_seconds when provided and entry expires after TTL" do + name = :cachex_config_ttl_test + id = :cachex_config_ttl_id + + {:ok, pid} = + CacheProvider.start_link( + name: name, + id: id, + default_ttl_seconds: 1 + ) + + on_exit(fn -> Process.exit(pid, :normal) end) + + CacheProvider.store(name, "expiring_key", {:ok, "v"}) + assert {:ok, "v"} == CacheProvider.get(name, "expiring_key") + + Process.sleep(2_000) + assert nil == CacheProvider.get(name, "expiring_key") + end + + test "uses custom reclaim when provided and limit hook prunes to max_entries minus reclaim" do + name = :cachex_config_reclaim_test + id = :cachex_config_reclaim_id + + {:ok, pid} = + CacheProvider.start_link( + name: name, + id: id, + max_entries: 5, + reclaim: 0.4, + limit_check_interval_ms: 100 + ) + + on_exit(fn -> Process.exit(pid, :normal) end) + + for i <- 1..15 do + CacheProvider.store(name, "key_#{i}", {:ok, i}) end - ) - assert_receive("message from precalculation") - assert_receive("message from the other function") + Process.sleep(250) + + count = CacheProvider.count(name) + expected_after_reclaim = 5 - round(5 * 0.4) + + assert count == expected_after_reclaim, + "expected count #{expected_after_reclaim} after reclaim 0.4, got #{count}" + end + + test "uses defaults when no config params are provided" do + CacheProvider.store(@cache_name, "default_ttl_key", {:ok, "v"}) + assert {:ok, "v"} == CacheProvider.get(@cache_name, "default_ttl_key") + end end end diff --git a/test/sanbase_web/graphql/clickhouse/api_call_data_api_test.exs b/test/sanbase_web/graphql/clickhouse/api_call_data_api_test.exs index 230f907f6a..ab3156ba1b 100644 --- a/test/sanbase_web/graphql/clickhouse/api_call_data_api_test.exs +++ b/test/sanbase_web/graphql/clickhouse/api_call_data_api_test.exs @@ -4,7 +4,7 @@ defmodule SanbaseWeb.Graphql.ApiCallDataApiTest do import SanbaseWeb.Graphql.TestHelpers import Sanbase.Factory - @moduletag skip: true + # @moduletag skip: true @moduletag capture_log: true setup_all do @@ -148,7 +148,9 @@ defmodule SanbaseWeb.Graphql.ApiCallDataApiTest do end) # Only the successful one is exported and counted - assert assert api_calls == [%{query: "getMetric|mvrv_usd"}] + assert %{query: "getMetric|mvrv_usd"} in api_calls + refute %{query: "getMetric|mvrv_usds"} in api_calls + refute %{query: "getMetric|nvts"} in api_calls end) end diff --git a/test/sanbase_web/graphql/projects/project_api_social_volume_query_test.exs b/test/sanbase_web/graphql/projects/project_api_social_volume_query_test.exs index 6e8765d987..1e2949db7c 100644 --- a/test/sanbase_web/graphql/projects/project_api_social_volume_query_test.exs +++ b/test/sanbase_web/graphql/projects/project_api_social_volume_query_test.exs @@ -31,6 +31,7 @@ defmodule SanbaseWeb.Graphql.ProjectApiSocialVolumeQueryTest do p2 = insert(:random_erc20_project) insert(:social_volume_query, %{project: p1, query: "something"}) insert(:social_volume_query, %{project: p2, query: "something else"}) + result = execute_query(conn, all_projects_social_volume_query(), "allProjects") expected_result = [