Skip to content

Commit 2cfb2d9

Browse files
committed
feat: scheduled external sync
1 parent 34c90d7 commit 2cfb2d9

File tree

12 files changed

+857
-75
lines changed

12 files changed

+857
-75
lines changed

lib/fuzzy_catalog/admin_settings.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@ defmodule FuzzyCatalog.AdminSettings do
9898
put_setting("email_verification_required", required)
9999
end
100100

101+
@doc """
102+
Returns the provider refresh interval setting.
103+
Defaults to "disabled" if not set.
104+
"""
105+
def get_provider_refresh_interval do
106+
get_setting_value("provider_refresh_interval", "disabled")
107+
end
108+
109+
@doc """
110+
Sets the provider refresh interval.
111+
Accepts time intervals like "1h", "30m", "15m" or "disabled".
112+
"""
113+
def set_provider_refresh_interval(interval) when is_binary(interval) do
114+
put_setting("provider_refresh_interval", interval)
115+
end
116+
101117
@doc """
102118
Returns all settings as a map.
103119
"""

lib/fuzzy_catalog/application.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ defmodule FuzzyCatalog.Application do
1313
{DNSCluster, query: Application.get_env(:fuzzy_catalog, :dns_cluster_query) || :ignore},
1414
{Phoenix.PubSub, name: FuzzyCatalog.PubSub},
1515
FuzzyCatalog.SyncStatusManager,
16+
FuzzyCatalog.ProviderScheduler,
1617
# Start a worker by calling: FuzzyCatalog.Worker.start_link(arg)
1718
# {FuzzyCatalog.Worker, arg},
1819
# Start to serve requests, typically the last entry
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
defmodule FuzzyCatalog.ProviderScheduler do
2+
@moduledoc """
3+
GenServer that handles periodic provider synchronization based on admin settings.
4+
5+
The scheduler can be configured with different intervals like "1h", "30m", "15m" or "disabled".
6+
When the interval is changed, the scheduler immediately updates its timer without requiring a restart.
7+
"""
8+
9+
use GenServer
10+
require Logger
11+
alias FuzzyCatalog.{AdminSettings, Catalog.ExternalLibrarySync, SyncStatusManager}
12+
13+
@name __MODULE__
14+
15+
# Client API
16+
17+
@doc """
18+
Starts the provider scheduler.
19+
"""
20+
def start_link(_opts) do
21+
GenServer.start_link(__MODULE__, %{}, name: @name)
22+
end
23+
24+
@doc """
25+
Updates the refresh interval and immediately reschedules the next sync.
26+
"""
27+
def update_interval(interval) when is_binary(interval) do
28+
GenServer.call(@name, {:update_interval, interval})
29+
end
30+
31+
@doc """
32+
Gets the current interval setting.
33+
"""
34+
def get_interval do
35+
GenServer.call(@name, :get_interval)
36+
end
37+
38+
# Server Callbacks
39+
40+
@impl true
41+
def init(_state) do
42+
# Get initial interval from settings
43+
interval = AdminSettings.get_provider_refresh_interval()
44+
Logger.info("ProviderScheduler started with interval: #{interval}")
45+
46+
state = %{
47+
interval: interval,
48+
timer_ref: nil
49+
}
50+
51+
# Schedule first sync if enabled
52+
new_state = schedule_next_sync(state)
53+
54+
{:ok, new_state}
55+
end
56+
57+
@impl true
58+
def handle_call({:update_interval, interval}, _from, state) do
59+
Logger.info("ProviderScheduler interval updated to: #{interval}")
60+
61+
# Cancel existing timer if any
62+
new_state = cancel_timer(state)
63+
64+
# Update interval and schedule next sync
65+
updated_state = %{new_state | interval: interval}
66+
final_state = schedule_next_sync(updated_state)
67+
68+
{:reply, :ok, final_state}
69+
end
70+
71+
@impl true
72+
def handle_call(:get_interval, _from, state) do
73+
{:reply, state.interval, state}
74+
end
75+
76+
@impl true
77+
def handle_info(:sync_providers, state) do
78+
Logger.info("ProviderScheduler: Triggering periodic provider sync")
79+
80+
# Only sync if no providers are currently syncing
81+
if SyncStatusManager.any_syncing?() do
82+
Logger.info("ProviderScheduler: Skipping sync - providers already syncing")
83+
else
84+
# Start sync in background task to avoid blocking the scheduler
85+
Task.start(fn ->
86+
try do
87+
{:ok, summary} = ExternalLibrarySync.sync_all_providers()
88+
89+
Logger.info(
90+
"ProviderScheduler: Periodic sync completed - #{summary.new_books} new books added"
91+
)
92+
rescue
93+
error ->
94+
Logger.error("ProviderScheduler: Periodic sync error - #{inspect(error)}")
95+
end
96+
end)
97+
end
98+
99+
# Schedule next sync
100+
new_state = schedule_next_sync(state)
101+
{:noreply, new_state}
102+
end
103+
104+
@impl true
105+
def handle_info({:timeout, timer_ref, :sync_providers}, %{timer_ref: timer_ref} = state) do
106+
# This handles the case where we use :timer.send_after/3
107+
handle_info(:sync_providers, state)
108+
end
109+
110+
@impl true
111+
def handle_info({:timeout, _old_timer_ref, :sync_providers}, state) do
112+
# Ignore timeouts from old timers that were cancelled
113+
{:noreply, state}
114+
end
115+
116+
# Private functions
117+
118+
defp schedule_next_sync(%{interval: "disabled"} = state) do
119+
Logger.debug("ProviderScheduler: Sync disabled, not scheduling")
120+
%{state | timer_ref: nil}
121+
end
122+
123+
defp schedule_next_sync(%{interval: interval} = state) do
124+
case parse_interval(interval) do
125+
{:ok, milliseconds} ->
126+
Logger.debug("ProviderScheduler: Scheduling next sync in #{interval} (#{milliseconds}ms)")
127+
timer_ref = :timer.send_after(milliseconds, self(), :sync_providers)
128+
%{state | timer_ref: timer_ref}
129+
130+
{:error, reason} ->
131+
Logger.error("ProviderScheduler: Invalid interval '#{interval}': #{reason}")
132+
%{state | timer_ref: nil}
133+
end
134+
end
135+
136+
defp cancel_timer(%{timer_ref: nil} = state), do: state
137+
138+
defp cancel_timer(%{timer_ref: timer_ref} = state) do
139+
:timer.cancel(timer_ref)
140+
%{state | timer_ref: nil}
141+
end
142+
143+
defp parse_interval("disabled"), do: {:error, "disabled"}
144+
defp parse_interval(""), do: {:error, "empty interval"}
145+
defp parse_interval(nil), do: {:error, "nil interval"}
146+
147+
defp parse_interval(interval) when is_binary(interval) do
148+
case Regex.run(~r/^(\d+)([mh])$/, String.downcase(interval)) do
149+
[_, number_str, unit] ->
150+
case Integer.parse(number_str) do
151+
{number, ""} when number > 0 ->
152+
milliseconds =
153+
case unit do
154+
# minutes to milliseconds
155+
"m" -> number * 60 * 1000
156+
# hours to milliseconds
157+
"h" -> number * 60 * 60 * 1000
158+
end
159+
160+
{:ok, milliseconds}
161+
162+
_ ->
163+
{:error, "invalid number: #{number_str}"}
164+
end
165+
166+
nil ->
167+
{:error, "invalid format - expected format like '1h', '30m', '15m'"}
168+
end
169+
end
170+
171+
@doc """
172+
Validates if an interval string is valid.
173+
Returns {:ok, interval} if valid, {:error, reason} if invalid.
174+
"""
175+
def validate_interval("disabled"), do: {:ok, "disabled"}
176+
def validate_interval(""), do: {:error, "Interval cannot be empty"}
177+
def validate_interval(nil), do: {:error, "Interval cannot be nil"}
178+
179+
def validate_interval(interval) when is_binary(interval) do
180+
case parse_interval(interval) do
181+
{:ok, _milliseconds} -> {:ok, interval}
182+
{:error, reason} -> {:error, reason}
183+
end
184+
end
185+
186+
def validate_interval(_), do: {:error, "Interval must be a string"}
187+
end

0 commit comments

Comments
 (0)