Skip to content

Commit afd7a5e

Browse files
authored
Misc (#81)
1 parent f336f00 commit afd7a5e

12 files changed

Lines changed: 719 additions & 196 deletions

File tree

.cursor/rules/usage-rules.mdc

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
---
2-
description: All rules from `mix usage_rules.sync`
3-
globs:
4-
alwaysApply: true
2+
alwaysApply: false
53
---
64
<-- usage-rules-start -->
75
<-- igniter-start -->

README.md

Lines changed: 183 additions & 70 deletions
Large diffs are not rendered by default.

config/dev.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ config :geo, GeoWeb.Endpoint,
8282
# Enable dev routes for dashboard and mailbox
8383
config :geo, dev_routes: true
8484

85-
# Do not include metadata nor timestamps in development logs
86-
config :logger, :console, format: "[$level] $message\n"
85+
# Include ISO8601 timestamps in development logs
86+
config :logger, :console, format: "$dateT$time [$level] $message\n"
8787

8888
# Set a higher stacktrace during development. Avoid configuring such
8989
# in production as building large stacktraces may be expensive.

lib/geo/application.ex

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,11 @@ defmodule Geo.Application do
3737
start:
3838
{:poolboy, :start_link,
3939
[
40-
[
41-
name: {:local, :country_cache_pool},
42-
worker_module: Geo.Geography.Country.Cache.Server,
43-
# 5 permanent workers
44-
size: 5,
45-
# No overflow workers (fixed pool size)
46-
max_overflow: 0
47-
]
40+
[
41+
name: {:local, :country_cache},
42+
worker_module: Geo.Geography.Country.Cache.Server,
43+
size: min(5, System.schedulers_online())
44+
]
4845
]}
4946
},
5047
# Start the Finch HTTP client for sending emails
@@ -62,4 +59,12 @@ defmodule Geo.Application do
6259
GeoWeb.Endpoint.config_change(changed, removed)
6360
:ok
6461
end
62+
63+
# Add shutdown logging
64+
@impl true
65+
def stop(reason) do
66+
require Logger
67+
Logger.info("Geo.Application terminating with reason: #{inspect(reason)}")
68+
:ok
69+
end
6570
end

lib/geo/geography/country/cache.ex

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
defmodule Geo.Geography.Country.Cache do
22
@moduledoc """
33
API for country cache operations. Only used by the Country resource.
4-
Uses Poolboy to manage a pool of 5 cache GenServer workers for load balancing.
4+
Uses Poolboy to manage a pool of cache GenServer workers for load balancing.
5+
Starts with 0 permanent workers and can overflow up to min(8, System.schedulers_online()) workers.
56
"""
67

78
require Logger
89

9-
@pool_name :country_cache_pool
10+
@pool_name :country_cache
1011

1112
@doc """
1213
Search for countries using the pooled cache workers.
@@ -54,17 +55,24 @@ defmodule Geo.Geography.Country.Cache do
5455

5556
Logger.info("Refreshing #{worker_count} cache workers")
5657

57-
# Refresh each worker in the pool
58+
# Refresh each worker in the pool (if any are running)
5859
refresh_results =
59-
for _ <- 1..worker_count do
60-
:poolboy.transaction(
61-
@pool_name,
62-
fn worker ->
63-
GenServer.call(worker, :refresh)
64-
end,
65-
# 30 second timeout for refresh
66-
30_000
67-
)
60+
if worker_count > 0 do
61+
for _ <- 1..worker_count do
62+
:poolboy.transaction(
63+
@pool_name,
64+
fn worker ->
65+
GenServer.call(worker, :refresh)
66+
end,
67+
# 30 second timeout for refresh
68+
30_000
69+
)
70+
end
71+
else
72+
# No workers running, trigger a cache load by doing a dummy search
73+
# This will create a worker on-demand that will load fresh data
74+
search!("")
75+
[:ok]
6876
end
6977

7078
case Enum.all?(refresh_results, &(&1 == :ok)) do
@@ -79,19 +87,6 @@ defmodule Geo.Geography.Country.Cache do
7987
end
8088
end
8189

82-
@doc """
83-
Check if the cache pool is running and has workers available.
84-
"""
85-
def running? do
86-
try do
87-
workers = :poolboy.status(@pool_name)
88-
total_workers = Keyword.get(workers, :ready, 0) + Keyword.get(workers, :busy, 0)
89-
total_workers > 0
90-
rescue
91-
_ -> false
92-
end
93-
end
94-
9590
@doc """
9691
Get cache pool statistics and status.
9792
"""

lib/geo/geography/country/cache/server.ex

Lines changed: 101 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
defmodule Geo.Geography.Country.Cache.Server do
22
@moduledoc """
33
GenServer that caches country data in memory for fast lookup and search operations.
4-
Loads all countries once at startup and provides efficient search functions.
4+
Uses lazy loading - countries are loaded on first access rather than at startup.
55
Designed to work as a pooled worker with Poolboy - no longer uses named registration.
6+
7+
The server will automatically stop after @stop_interval once countries are loaded.
68
"""
79

810
use GenServer
911
require Logger
1012

11-
@refresh_interval :timer.minutes(30)
13+
@stop_interval :timer.minutes(1)
1214

1315
defmodule State do
1416
@moduledoc """
1517
State structure for the Country Cache Server.
18+
All country-related fields are nil until first access (lazy loading).
19+
The timer_ref is nil until countries are loaded, then starts the stop timer.
1620
"""
1721
defstruct [
1822
:countries_list_by_iso_code,
@@ -24,11 +28,11 @@ defmodule Geo.Geography.Country.Cache.Server do
2428
]
2529

2630
@type t :: %__MODULE__{
27-
countries_list_by_iso_code: [Geo.Geography.Country.t()],
28-
countries_list_by_name: [Geo.Geography.Country.t()],
29-
countries_map_by_iso_code: %{String.t() => Geo.Geography.Country.t()},
30-
countries_map_by_name: %{String.t() => Geo.Geography.Country.t()},
31-
last_refresh: DateTime.t(),
31+
countries_list_by_iso_code: [Geo.Geography.Country.t()] | nil,
32+
countries_list_by_name: [Geo.Geography.Country.t()] | nil,
33+
countries_map_by_iso_code: %{String.t() => Geo.Geography.Country.t()} | nil,
34+
countries_map_by_name: %{String.t() => Geo.Geography.Country.t()} | nil,
35+
last_refresh: DateTime.t() | nil,
3236
timer_ref: reference() | nil
3337
}
3438
end
@@ -39,104 +43,80 @@ defmodule Geo.Geography.Country.Cache.Server do
3943

4044
@impl true
4145
def init(_opts) do
42-
try do
43-
state = load_countries!()
44-
45-
# Schedule periodic refresh
46-
timer_ref = Process.send_after(self(), :refresh, @refresh_interval)
47-
state = %{state | timer_ref: timer_ref}
48-
49-
Logger.info("Cache worker started successfully")
50-
{:ok, state}
51-
rescue
52-
error ->
53-
Logger.error("Failed to initialize cache worker: #{inspect(error)}")
54-
{:stop, error}
55-
end
46+
# Start with empty state - countries will be loaded on first access
47+
# Timer will only start when countries are actually loaded
48+
state = %State{
49+
countries_list_by_iso_code: nil,
50+
countries_list_by_name: nil,
51+
countries_map_by_iso_code: nil,
52+
countries_map_by_name: nil,
53+
last_refresh: nil,
54+
timer_ref: nil
55+
}
56+
57+
Logger.info("Cache worker started successfully: #{inspect(self())}")
58+
{:ok, state}
5659
end
5760

5861
@impl true
5962
def handle_call(:search_all, _from, state) do
60-
{:reply, do_search_all(state), state}
63+
state = ensure_countries_loaded(state)
64+
result = do_search_all(state)
65+
{:reply, result, state}
6166
end
6267

6368
@impl true
6469
def handle_call({:search, query}, _from, state) do
70+
state = ensure_countries_loaded(state)
6571
{:reply, do_search(query, state), state}
6672
end
6773

6874
@impl true
6975
def handle_call({:get_by_iso_code, iso_code}, _from, state) do
70-
country = Map.get(state.countries_map_by_iso_code, String.downcase(iso_code))
71-
{:reply, country, state}
72-
end
76+
state = ensure_countries_loaded(state)
7377

74-
@impl true
75-
def handle_call(:refresh, _from, state) do
76-
try do
77-
# Cancel existing timer if it exists
78-
if state.timer_ref do
79-
Process.cancel_timer(state.timer_ref)
78+
country =
79+
if state.countries_map_by_iso_code do
80+
Map.get(state.countries_map_by_iso_code, String.downcase(iso_code))
81+
else
82+
nil
8083
end
8184

82-
new_state = load_countries!()
83-
84-
# Schedule next refresh
85-
timer_ref = Process.send_after(self(), :refresh, @refresh_interval)
86-
new_state = %{new_state | timer_ref: timer_ref}
87-
88-
Logger.info("Cache worker refreshed successfully")
89-
{:reply, :ok, new_state}
90-
rescue
91-
error ->
92-
Logger.error("Failed to refresh cache worker: #{inspect(error)}")
93-
{:reply, {:error, error}, state}
94-
end
85+
{:reply, country, state}
9586
end
9687

9788
@impl true
9889
def handle_call(:status, _from, state) do
99-
status = %{
100-
countries_count: map_size(state.countries_map_by_iso_code),
101-
last_refresh: state.last_refresh,
102-
worker_pid: self()
103-
}
90+
status =
91+
if state.last_refresh do
92+
%{
93+
countries_count: map_size(state.countries_map_by_iso_code),
94+
last_refresh: state.last_refresh,
95+
worker_pid: self(),
96+
loaded: true
97+
}
98+
else
99+
%{
100+
countries_count: 0,
101+
last_refresh: nil,
102+
worker_pid: self(),
103+
loaded: false
104+
}
105+
end
104106

105107
{:reply, status, state}
106108
end
107109

108110
@impl true
109-
def handle_info(:refresh, state) do
110-
# Periodic refresh
111-
try do
112-
# Cancel existing timer if it exists
113-
if state.timer_ref do
114-
Process.cancel_timer(state.timer_ref)
115-
end
116-
117-
new_state = load_countries!()
118-
119-
Logger.debug("Cache worker auto-refreshed successfully")
120-
121-
# Schedule next refresh
122-
timer_ref = Process.send_after(self(), :refresh, @refresh_interval)
123-
new_state = %{new_state | timer_ref: timer_ref}
124-
125-
{:noreply, new_state}
126-
rescue
127-
error ->
128-
Logger.warning("Failed to auto-refresh cache worker: #{inspect(error)}")
129-
130-
# Still schedule next refresh attempt
131-
timer_ref = Process.send_after(self(), :refresh, @refresh_interval)
132-
new_state = %{state | timer_ref: timer_ref}
133-
{:noreply, new_state}
134-
end
111+
def handle_info(:stop, state) do
112+
Logger.info("Cache worker stopping after #{@stop_interval} ms as scheduled")
113+
{:stop, :normal, state}
135114
end
136115

137116
@impl true
138-
def terminate(_reason, state) do
139-
# Cancel timer when GenServer is stopping
117+
def terminate(reason, state) do
118+
Logger.info("Cache worker #{inspect(self())} terminating with reason: #{inspect(reason)}")
119+
# Cancel stop timer when GenServer is stopping
140120
if state.timer_ref do
141121
Process.cancel_timer(state.timer_ref)
142122
end
@@ -145,8 +125,24 @@ defmodule Geo.Geography.Country.Cache.Server do
145125

146126
# Private functions
147127

148-
# Returns a State struct with loaded countries data
149-
defp load_countries! do
128+
# Ensures countries are loaded in the state, loading them if last_refresh is nil
129+
defp ensure_countries_loaded(state) do
130+
if state.last_refresh do
131+
state
132+
else
133+
try do
134+
load_countries!(state)
135+
rescue
136+
error ->
137+
Logger.error("Failed to load countries: #{inspect(error)}")
138+
# Return state unchanged if loading fails
139+
state
140+
end
141+
end
142+
end
143+
144+
# Returns a State struct with loaded countries data, preserving existing state
145+
defp load_countries!(existing_state) do
150146
# Get countries sorted by iso_code (default sort from the resource)
151147
countries = Geo.Geography.list_countries!(authorize?: false)
152148

@@ -167,17 +163,39 @@ defmodule Geo.Geography.Country.Cache.Server do
167163
{Ash.CiString.to_comparable_string(country.name), country}
168164
end)
169165

166+
# Cancel existing timer if there is one
167+
if existing_state.timer_ref do
168+
Process.cancel_timer(existing_state.timer_ref)
169+
end
170+
171+
# Start stop timer now that countries are loaded
172+
timer_ref = Process.send_after(self(), :stop, @stop_interval)
173+
Logger.info("Countries loaded successfully, worker will stop in #{@stop_interval} ms")
174+
170175
%State{
171176
countries_list_by_iso_code: countries_list_by_iso_code,
172177
countries_list_by_name: countries_list_by_name,
173178
countries_map_by_iso_code: countries_map_by_iso_code,
174179
countries_map_by_name: countries_map_by_name,
175180
last_refresh: DateTime.utc_now(),
176-
timer_ref: nil
181+
timer_ref: timer_ref
177182
}
178183
end
179184

180185
defp do_search(query, state) do
186+
# If countries not loaded, return empty results
187+
if is_nil(state.countries_map_by_name) do
188+
%Geo.Geography.Country.Cache.SearchResult{
189+
by_iso_code: [],
190+
by_name: []
191+
}
192+
else
193+
do_search_with_data(query, state)
194+
end
195+
end
196+
197+
defp do_search_with_data(query, state) do
198+
181199
query_down = String.downcase(query)
182200

183201
# Use exact match from countries_map_by_name for efficiency
@@ -271,10 +289,10 @@ defmodule Geo.Geography.Country.Cache.Server do
271289
end
272290

273291
defp do_search_all(state) do
274-
# Return SearchResults struct with all countries
292+
# Return SearchResults struct with all countries, or empty if not loaded
275293
%Geo.Geography.Country.Cache.SearchResult{
276-
by_iso_code: state.countries_list_by_iso_code,
277-
by_name: state.countries_list_by_name
294+
by_iso_code: state.countries_list_by_iso_code || [],
295+
by_name: state.countries_list_by_name || []
278296
}
279297
end
280298
end

0 commit comments

Comments
 (0)