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

camatcode / basenji / 8478c9b5d6af5aed7c1bd2bd3646666960ee2d85

17 Jul 2025 02:52AM UTC coverage: 59.248% (+0.8%) from 58.437%
8478c9b5d6af5aed7c1bd2bd3646666960ee2d85

push

github

web-flow
Merge pull request #50 from camatcode/work/predictive-cache

feat: predictive cache pre-caches images for flawless page traversal

42 of 48 new or added lines in 2 files covered. (87.5%)

977 of 1649 relevant lines covered (59.25%)

348.8 hits per line

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

88.64
/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

10
  require Logger
11

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

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

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

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

33
  def get_comic_page_from_cache(%Comic{} = comic, page_num) do
34
    result = fetch_page_from_cache(comic, page_num)
5✔
35

36
    prefetch_next_pages(comic, page_num)
5✔
37

38
    result
5✔
39
  end
40

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

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

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

57
  def handle_info({:do_prefetch, comic, current_page}, state) do
58
    prefetch_key = "#{comic.id}_#{current_page}"
5✔
59

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

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

74
  defp prefetch_pages(comic, current_page) do
75
    max_page = comic.page_count
5✔
76

77
    forward_pages = calculate_forward_pages(current_page, max_page)
5✔
78
    backward_pages = calculate_backward_pages(current_page)
5✔
79

80
    all_pages = forward_pages ++ backward_pages
5✔
81

82
    Logger.debug("Prefetching pages #{inspect(all_pages)} for comic #{comic.id}")
5✔
83

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

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

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

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

112
  defp prefetch_single_page(comic, page_num) do
113
    cache_key = page_cache_key(comic.id, page_num)
11✔
114

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

NEW
122
          _ ->
×
123
            :error
124
        end
125

126
      {:ok, true} ->
8✔
127
        :cached
128

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

135
  defp page_cache_key(comic_id, page_num) do
136
    %{comic_id: comic_id, page_num: page_num, optimized: true}
19✔
137
  end
138

139
  defp fetch_page_from_cache(%Comic{id: comic_id} = comic, page_num) do
140
    Cachex.fetch(
141
      :basenji_cache,
142
      page_cache_key(comic_id, page_num),
143
      fn _key ->
144
        Comics.get_page(comic, page_num)
145
        |> case do
5✔
146
          {:ok, page, mime} ->
147
            {:commit, {page, mime}, [ttl: to_timeout(minute: 1)]}
5✔
148

NEW
149
          {_, resp} ->
×
150
            {:ignore, {:error, resp}}
151
        end
152
      end
153
    )
154
    |> case do
8✔
NEW
155
      {:ignore, {:error, error}} ->
×
156
        {:error, error}
157

158
      {_, {page, mime}} ->
159
        {:ok, page, mime}
8✔
160

161
      response ->
NEW
162
        response
×
163
    end
164
  end
165

166
  defp prefetch_next_pages(%Comic{} = comic, current_page) do
167
    GenServer.cast(PredictiveCache, {:prefetch, comic, current_page})
5✔
168
  end
169
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