• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

camatcode / basenji / 4bdf5b2a49bbded9da0655020a5165d02ec2b2ab

19 Jul 2025 04:14AM UTC coverage: 60.024% (+0.2%) from 59.82%
4bdf5b2a49bbded9da0655020a5165d02ec2b2ab

Pull #56

github

camatcode
feat: resize image ops for predictive cache
Pull Request #56: feat: resize image ops for predictive cache

22 of 23 new or added lines in 2 files covered. (95.65%)

3 existing lines in 1 file now uncovered.

1009 of 1681 relevant lines covered (60.02%)

362.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.96
/lib/basenji_web/controllers/api/predictive_cache.ex
1
defmodule BasenjiWeb.PredictiveCache do
2
  @moduledoc false
3

4
  use GenServer
5

6
  alias __MODULE__, as: PredictiveCache
7
  alias Basenji.Comic
8
  alias Basenji.Comics
9
  alias Basenji.ImageProcessor
10

11
  require Logger
12

13
  @prefetch_count 4
14
  @prefetch_behind 2
15
  @max_concurrent_prefetches 3
16
  @prefetch_delay 100
17

18
  def start_link(state) do
19
    GenServer.start_link(PredictiveCache, state, name: PredictiveCache)
1✔
20
  end
21

22
  def init(_opts) do
1✔
23
    {:ok,
24
     %{
25
       active_prefetches: %{},
26
       completed_prefetch_count: 0
27
     }}
28
  end
29

30
  def get_state do
31
    GenServer.call(PredictiveCache, :state)
2✔
32
  end
33

34
  def get_comic_page_from_cache(%Comic{} = comic, page_num, opts \\ []) do
35
    result = fetch_page_from_cache(comic, page_num, opts)
9✔
36

37
    prefetch_next_pages(comic, page_num, opts)
9✔
38

39
    result
9✔
40
  end
41

42
  # for testing / probing
43
  def handle_call(:state, _, state) do
44
    {:reply, state, state}
2✔
45
  end
46

47
  def handle_cast({:prefetch, comic, current_page, opts}, state) do
48
    Process.send_after(self(), {:do_prefetch, comic, current_page, opts}, @prefetch_delay)
9✔
49
    {:noreply, state}
50
  end
51

52
  def handle_cast({:prefetch_complete, prefetch_key}, state) do
53
    prefetches = Map.delete(state.active_prefetches, prefetch_key)
9✔
54
    count = state.completed_prefetch_count + 1
9✔
55
    {:noreply, %{active_prefetches: prefetches, completed_prefetch_count: count}}
56
  end
57

58
  def handle_info({:do_prefetch, comic, current_page, opts}, state) do
59
    prefetch_key = "#{comic.id}_#{current_page}_#{inspect(opts)}"
9✔
60

61
    if Map.has_key?(state.active_prefetches, prefetch_key) do
9✔
62
      {:noreply, state}
63
    else
64
      task =
9✔
65
        Task.start(fn ->
66
          prefetch_pages(comic, current_page, opts)
9✔
67
          GenServer.cast(PredictiveCache, {:prefetch_complete, prefetch_key})
9✔
68
        end)
69

70
      new_state = Map.put(state, :active_prefetches, Map.put(state.active_prefetches, prefetch_key, task))
9✔
71
      {:noreply, new_state}
72
    end
73
  end
74

75
  defp prefetch_pages(comic, current_page, opts) do
76
    max_page = comic.page_count
9✔
77

78
    forward_pages = calculate_forward_pages(current_page, max_page)
9✔
79
    backward_pages = calculate_backward_pages(current_page)
9✔
80

81
    all_pages = forward_pages ++ backward_pages
9✔
82

83
    Logger.debug("Prefetching pages #{inspect(all_pages)} for comic #{comic.id} with #{inspect(opts)}")
9✔
84

85
    all_pages
86
    |> Task.async_stream(
87
      fn page_num -> prefetch_single_page(comic, page_num, opts) end,
23✔
88
      max_concurrency: @max_concurrent_prefetches,
89
      timeout: 30_000
90
    )
91
    |> Stream.run()
9✔
92
  end
93

94
  defp calculate_forward_pages(current_page, max_page) do
95
    (current_page + 1)..(current_page + @prefetch_count)
96
    |> Enum.to_list()
97
    |> Enum.filter(&(&1 <= max_page))
9✔
98
  end
99

100
  defp calculate_backward_pages(current_page) do
101
    start_page = max(1, current_page - @prefetch_behind)
9✔
102
    end_page = current_page - 1
9✔
103

104
    if end_page >= start_page do
9✔
105
      start_page..end_page
106
      |> Enum.to_list()
107
      |> Enum.reverse()
3✔
108
    else
109
      []
110
    end
111
  end
112

113
  defp prefetch_single_page(comic, page_num, opts) do
114
    cache_key = page_cache_key(comic.id, page_num, opts)
23✔
115

116
    case Cachex.exists?(:basenji_cache, cache_key) do
23✔
117
      {:ok, false} ->
118
        case fetch_page_from_cache(comic, page_num, opts) do
16✔
119
          {:ok, _data, _mime} ->
120
            Logger.debug("Prefetched page #{page_num} for comic #{comic.id}")
16✔
121
            :ok
122

123
          _ ->
×
124
            :error
125
        end
126

127
      {:ok, true} ->
7✔
128
        :cached
129

130
      error ->
131
        Logger.warning("Cache check failed for page #{page_num}: #{inspect(error)}")
×
132
        :error
133
    end
134
  end
135

136
  defp page_cache_key(comic_id, page_num, opts) do
137
    %{comic_id: comic_id, page_num: page_num, optimized: true, opts: opts}
48✔
138
  end
139

140
  defp fetch_page_from_cache(%Comic{id: comic_id} = comic, page_num, opts) do
141
    Cachex.fetch(
142
      :basenji_cache,
143
      page_cache_key(comic_id, page_num, opts),
144
      fn _key ->
NEW
145
        with {:ok, page, _mime} <- Comics.get_page(comic, page_num) do
×
146
          ImageProcessor.resize_image(page, opts)
21✔
147
        end
148
        |> case do
21✔
149
          {:ok, page} ->
150
            {:commit, {page, "image/jpeg"}, [ttl: to_timeout(minute: 1)]}
21✔
151

152
          {_, resp} ->
×
153
            {:ignore, {:error, resp}}
154
        end
155
      end
156
    )
157
    |> case do
25✔
158
      {:ignore, {:error, error}} ->
×
159
        {:error, error}
160

161
      {_, {page, mime}} ->
162
        {:ok, page, mime}
25✔
163

164
      response ->
165
        response
×
166
    end
167
  end
168

169
  defp prefetch_next_pages(%Comic{} = comic, current_page, opts) do
170
    GenServer.cast(PredictiveCache, {:prefetch, comic, current_page, opts})
9✔
171
  end
172
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc