Current section
12 Versions
Jump to
Current section
12 Versions
Compare versions
7
files changed
+315
additions
-56
deletions
| @@ -416,6 +416,32 @@ The `cache` function runs once per entity per request. If the same user is linke | |
| 416 416 | |
| 417 417 | Modules without `cache` are unaffected — single-argument `links` and `view` work as before. |
| 418 418 | |
| 419 | + ### Batch queries for lists (`cache_many`) |
| 420 | + |
| 421 | + A query inside `cache` runs once per entity — an index of 100 posts runs it 100 times. Use `cache_many` to load the data for all of them with one query: |
| 422 | + |
| 423 | + ```elixir |
| 424 | + defmodule PostJSON do |
| 425 | + use Carve.View, :post |
| 426 | + |
| 427 | + # Runs once per render with all posts being rendered. |
| 428 | + # Return a map of post id => value. |
| 429 | + cache_many fn posts -> |
| 430 | + counts = Comments.count_for(Enum.map(posts, & &1.id)) |
| 431 | + Map.new(posts, fn post -> {post.id, %{comment_count: counts[post.id]}} end) |
| 432 | + end |
| 433 | + |
| 434 | + view fn post, cached -> |
| 435 | + %{ |
| 436 | + id: hash(post.id), |
| 437 | + comment_count: cached.comment_count |
| 438 | + } |
| 439 | + end |
| 440 | + end |
| 441 | + ``` |
| 442 | + |
| 443 | + `view` and `links` receive each entity's own value as the second argument, same as with `cache`. Entities missing from the returned map get `nil`. A view defines either `cache` or `cache_many`, not both. |
| 444 | + |
| 419 445 | ## How does it work? |
| 420 446 | |
| 421 447 | * Carve macros create view functions `index(%{ result: users })` and `show(%{ result: user })` |
| @@ -4,9 +4,9 @@ | |
| 4 4 | <<"DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically.">>}. |
| 5 5 | {<<"elixir">>,<<"~> 1.12">>}. |
| 6 6 | {<<"files">>, |
| 7 | - [<<"lib">>,<<"lib/links.ex">>,<<"lib/cache.ex">>,<<"lib/config.ex">>, |
| 8 | - <<"lib/carve.ex">>,<<"lib/view.ex">>,<<"lib/hashids.ex">>, |
| 9 | - <<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>]}. |
| 7 | + [<<"lib">>,<<"lib/batch.ex">>,<<"lib/links.ex">>,<<"lib/cache.ex">>, |
| 8 | + <<"lib/config.ex">>,<<"lib/carve.ex">>,<<"lib/view.ex">>, |
| 9 | + <<"lib/hashids.ex">>,<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>]}. |
| 10 10 | {<<"licenses">>,[<<"MIT">>]}. |
| 11 11 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/azer/carve">>}]}. |
| 12 12 | {<<"name">>,<<"carve">>}. |
| @@ -31,4 +31,4 @@ | |
| 31 31 | {<<"optional">>,false}, |
| 32 32 | {<<"repository">>,<<"hexpm">>}, |
| 33 33 | {<<"requirement">>,<<"~> 3.6">>}]]}. |
| 34 | - {<<"version">>,<<"0.5.1">>}. |
| 34 | + {<<"version">>,<<"0.6.0">>}. |
| @@ -0,0 +1,81 @@ | |
| 1 | + defmodule Carve.Batch do |
| 2 | + @moduledoc """ |
| 3 | + Batched cache warm-up for views that define `cache_many`. |
| 4 | + |
| 5 | + `warm/3` resolves the cached value for a set of entities of one view in a |
| 6 | + single `__cache_many__/1` call, instead of one `__cache__/1` call per |
| 7 | + entity. Views without `cache_many` fall back to the per-entity path, |
| 8 | + preserving the existing `cache` behavior exactly. |
| 9 | + """ |
| 10 | + |
| 11 | + @doc """ |
| 12 | + Returns a map of entity id => cached value for every entity in `items`, |
| 13 | + warming the request cache along the way. |
| 14 | + |
| 15 | + For a view with `cache_many`, entities already present in the request |
| 16 | + cache are excluded from the batch call; ids missing from the returned |
| 17 | + map are cached as nil. Batching also works when caching is disabled |
| 18 | + (`cache_key` is nil): the batch still runs once and the results are |
| 19 | + returned directly. |
| 20 | + """ |
| 21 | + def warm(module, items, cache_key) when is_list(items) do |
| 22 | + items = |
| 23 | + items |
| 24 | + |> Enum.reject(&(entity_id(&1) == nil)) |
| 25 | + |> Enum.uniq_by(&entity_id/1) |
| 26 | + |
| 27 | + if batched?(module) do |
| 28 | + warm_batched(module, items, cache_key) |
| 29 | + else |
| 30 | + Map.new(items, fn item -> |
| 31 | + id = entity_id(item) |
| 32 | + |
| 33 | + value = |
| 34 | + Carve.Cache.fetch(cache_key, {module, :cache, id}, fn -> |
| 35 | + module.__cache__(item) |
| 36 | + end) |
| 37 | + |
| 38 | + {id, value} |
| 39 | + end) |
| 40 | + end |
| 41 | + end |
| 42 | + |
| 43 | + @doc """ |
| 44 | + Whether the view module declares `cache_many`. |
| 45 | + """ |
| 46 | + def batched?(module) do |
| 47 | + Code.ensure_loaded?(module) and function_exported?(module, :__cache_many__, 1) |
| 48 | + end |
| 49 | + |
| 50 | + @doc false |
| 51 | + def entity_id(%{id: id}), do: id |
| 52 | + def entity_id(%{"id" => id}), do: id |
| 53 | + def entity_id(_), do: nil |
| 54 | + |
| 55 | + defp warm_batched(module, items, cache_key) do |
| 56 | + {hits, misses} = |
| 57 | + Enum.reduce(items, {%{}, []}, fn item, {hits, misses} -> |
| 58 | + id = entity_id(item) |
| 59 | + |
| 60 | + case Carve.Cache.peek(cache_key, {module, :cache, id}) do |
| 61 | + {:ok, value} -> {Map.put(hits, id, value), misses} |
| 62 | + :error -> {hits, [item | misses]} |
| 63 | + end |
| 64 | + end) |
| 65 | + |
| 66 | + case Enum.reverse(misses) do |
| 67 | + [] -> |
| 68 | + hits |
| 69 | + |
| 70 | + misses -> |
| 71 | + batch = module.__cache_many__(misses) |
| 72 | + |
| 73 | + Enum.reduce(misses, hits, fn item, acc -> |
| 74 | + id = entity_id(item) |
| 75 | + value = Map.get(batch, id) |
| 76 | + Carve.Cache.put(cache_key, {module, :cache, id}, value) |
| 77 | + Map.put(acc, id, value) |
| 78 | + end) |
| 79 | + end |
| 80 | + end |
| 81 | + end |
| @@ -35,37 +35,59 @@ defmodule Carve.Cache do | |
| 35 35 | |
| 36 36 | def fetch(cache_key, key, fun) when is_function(fun, 0) do |
| 37 37 | if Carve.Config.caching_enabled?() do |
| 38 | - cache_data = |
| 39 | - case Cachex.get(:carve_cache, cache_key) do |
| 40 | - {:ok, nil} -> %{} |
| 41 | - {:ok, data} -> data |
| 42 | - {:error, _} -> %{} |
| 43 | - end |
| 44 | - |
| 45 | - case Map.get(cache_data, key) do |
| 46 | - nil -> |
| 47 | - value = fun.() |
| 48 | - |
| 49 | - # Re-read cache to pick up writes from nested fetch calls |
| 50 | - current_cache = |
| 51 | - case Cachex.get(:carve_cache, cache_key) do |
| 52 | - {:ok, nil} -> %{} |
| 53 | - {:ok, data} -> data |
| 54 | - {:error, _} -> %{} |
| 55 | - end |
| 56 | - |
| 57 | - updated_cache = Map.put(current_cache, key, value) |
| 58 | - Cachex.put(:carve_cache, cache_key, updated_cache, ttl: Carve.Config.cache_ttl()) |
| 59 | - value |
| 60 | - |
| 61 | - cached_value -> |
| 38 | + # Map.fetch, not Map.get: a cached nil (e.g. a missing entity or a |
| 39 | + # cache_many id absent from the batch result) is a hit, not a miss. |
| 40 | + case Map.fetch(read(cache_key), key) do |
| 41 | + {:ok, cached_value} -> |
| 62 42 | cached_value |
| 43 | + |
| 44 | + :error -> |
| 45 | + value = fun.() |
| 46 | + put(cache_key, key, value) |
| 47 | + value |
| 63 48 | end |
| 64 49 | else |
| 65 50 | fun.() |
| 66 51 | end |
| 67 52 | end |
| 68 53 | |
| 54 | + @doc """ |
| 55 | + Looks up a cached value without computing it. |
| 56 | + Returns `{:ok, value}` (value may be nil) or `:error` when the key is |
| 57 | + absent or caching is disabled. |
| 58 | + """ |
| 59 | + def peek(nil, _key), do: :error |
| 60 | + |
| 61 | + def peek(cache_key, key) do |
| 62 | + if Carve.Config.caching_enabled?() do |
| 63 | + Map.fetch(read(cache_key), key) |
| 64 | + else |
| 65 | + :error |
| 66 | + end |
| 67 | + end |
| 68 | + |
| 69 | + @doc """ |
| 70 | + Stores a value in the request cache. No-op when caching is disabled. |
| 71 | + """ |
| 72 | + def put(nil, _key, _value), do: :ok |
| 73 | + |
| 74 | + def put(cache_key, key, value) do |
| 75 | + if Carve.Config.caching_enabled?() do |
| 76 | + # Re-read before writing to pick up writes from nested fetch calls |
| 77 | + updated = Map.put(read(cache_key), key, value) |
| 78 | + Cachex.put(:carve_cache, cache_key, updated, ttl: Carve.Config.cache_ttl()) |
| 79 | + end |
| 80 | + |
| 81 | + :ok |
| 82 | + end |
| 83 | + |
| 84 | + defp read(cache_key) do |
| 85 | + case Cachex.get(:carve_cache, cache_key) do |
| 86 | + {:ok, %{} = data} -> data |
| 87 | + _ -> %{} |
| 88 | + end |
| 89 | + end |
| 90 | + |
| 69 91 | @doc """ |
| 70 92 | Gets the current cache key from the process dictionary, or creates a new one. |
| 71 93 | Returns nil if caching is disabled. |
| @@ -56,6 +56,8 @@ defmodule Carve.Links do | |
| 56 56 | when is_list(data_list) do |
| 57 57 | cache_key = cache_key || Carve.Cache.get_or_create_context() |
| 58 58 | |
| 59 | + warm_level(module, data_list, visited, whitelist, cache_key) |
| 60 | + |
| 59 61 | {links, visited} = |
| 60 62 | Enum.reduce(data_list, {[], visited}, fn item, {acc, vis} -> |
| 61 63 | {new_links, vis} = do_get_links_by_data(module, item, vis, whitelist, cache_key) |
| @@ -82,9 +84,11 @@ defmodule Carve.Links do | |
| 82 84 | |
| 83 85 | cached = fetch_cached(module, data, id, cache_key) |
| 84 86 | |
| 85 | - links = |
| 86 | - module.declare_links(data, cached) |
| 87 | - |> filter_and_evaluate_links(whitelist) |
| 87 | + raw_links = fetch_links(module, data, id, cached, cache_key) |
| 88 | + |
| 89 | + warm_link_targets([raw_links], visited, whitelist, cache_key) |
| 90 | + |
| 91 | + links = filter_and_evaluate_links(raw_links, whitelist) |
| 88 92 | |
| 89 93 | {links, visited} = |
| 90 94 | Enum.reduce(links, {[], visited}, fn {link_module, link_data_or_ids}, {acc, vis} -> |
| @@ -120,6 +124,91 @@ defmodule Carve.Links do | |
| 120 124 | end) |
| 121 125 | end |
| 122 126 | |
| 127 | + # ── batched warm-up (cache_many) ───────────────────────────────────── |
| 128 | + |
| 129 | + # Memoized declare_links, so the warm-up pre-pass and the traversal |
| 130 | + # evaluate each entity's links function once per request. |
| 131 | + defp fetch_links(module, data, id, cached, cache_key) do |
| 132 | + Carve.Cache.fetch(cache_key, {module, :links, id}, fn -> |
| 133 | + module.declare_links(data, cached) |
| 134 | + end) |
| 135 | + end |
| 136 | + |
| 137 | + # Cross-item warm-up for a list render: batch the list's own cache, then |
| 138 | + # batch the cache of every linked entity set whose view defines |
| 139 | + # cache_many, so the per-item traversal below hits the cache throughout. |
| 140 | + # Skipped when caching is disabled (nil cache_key) — the traversal stays |
| 141 | + # correct through the per-entity batch-of-one __cache__ fallback. |
| 142 | + defp warm_level(_module, _data_list, _visited, _whitelist, nil), do: :ok |
| 143 | + |
| 144 | + defp warm_level(module, data_list, visited, whitelist, cache_key) do |
| 145 | + items = |
| 146 | + Enum.filter(data_list, fn item -> |
| 147 | + with true <- is_map(item), |
| 148 | + {:ok, id} <- fetch_id(item) do |
| 149 | + !Map.get(visited, {module, id}) |
| 150 | + else |
| 151 | + _ -> false |
| 152 | + end |
| 153 | + end) |
| 154 | + |
| 155 | + cached_by_id = Carve.Batch.warm(module, items, cache_key) |
| 156 | + |
| 157 | + raw_links_list = |
| 158 | + Enum.map(items, fn item -> |
| 159 | + {:ok, id} = fetch_id(item) |
| 160 | + fetch_links(module, item, id, Map.get(cached_by_id, id), cache_key) |
| 161 | + end) |
| 162 | + |
| 163 | + warm_link_targets(raw_links_list, visited, whitelist, cache_key) |
| 164 | + end |
| 165 | + |
| 166 | + # Warm the cache of link targets, grouped by view module, one batch per |
| 167 | + # module that defines cache_many. Lazy (function) links are left to the |
| 168 | + # traversal so they are never evaluated twice. |
| 169 | + defp warm_link_targets(_raw_links_list, _visited, _whitelist, nil), do: :ok |
| 170 | + |
| 171 | + defp warm_link_targets(raw_links_list, visited, whitelist, cache_key) do |
| 172 | + raw_links_list |
| 173 | + |> Enum.flat_map(fn raw_links -> |
| 174 | + for {link_module, value} <- raw_links, |
| 175 | + not is_function(value), |
| 176 | + link_whitelisted?(link_module, whitelist), |
| 177 | + Carve.Batch.batched?(link_module), |
| 178 | + target <- normalize_link_ids(value), |
| 179 | + do: {link_module, target} |
| 180 | + end) |
| 181 | + |> Enum.group_by(fn {module, _} -> module end, fn {_, target} -> target end) |
| 182 | + |> Enum.each(fn {link_module, targets} -> |
| 183 | + entities = |
| 184 | + targets |
| 185 | + |> Enum.reject(fn target -> |
| 186 | + case fetch_id(target) do |
| 187 | + {:ok, id} -> Map.get(visited, {link_module, id}) != nil |
| 188 | + :error -> true |
| 189 | + end |
| 190 | + end) |
| 191 | + |> Enum.map(fn |
| 192 | + id when is_number(id) or is_binary(id) -> |
| 193 | + Carve.Cache.fetch(cache_key, {link_module, :get, id}, fn -> |
| 194 | + link_module.get_by_id(id) |
| 195 | + end) |
| 196 | + |
| 197 | + data -> |
| 198 | + data |
| 199 | + end) |
| 200 | + |> Enum.reject(&is_nil/1) |
| 201 | + |
| 202 | + Carve.Batch.warm(link_module, entities, cache_key) |
| 203 | + end) |
| 204 | + end |
| 205 | + |
| 206 | + defp link_whitelisted?(_module, nil), do: true |
| 207 | + defp link_whitelisted?(_module, []), do: false |
| 208 | + |
| 209 | + defp link_whitelisted?(module, whitelist) when is_list(whitelist), |
| 210 | + do: module.type_name() in whitelist |
| 211 | + |
| 123 212 | defp process_single_link(module, id, visited, cache_key) when is_number(id) or is_binary(id) do |
| 124 213 | case Map.get(visited, {module, id}) do |
| 125 214 | nil -> |
Loading more files…