Skip to content
Merged

Misc #81

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .cursor/rules/usage-rules.mdc
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
---
description: All rules from `mix usage_rules.sync`
globs:
alwaysApply: true
alwaysApply: false
---
<-- usage-rules-start -->
<-- igniter-start -->
Expand Down
253 changes: 183 additions & 70 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ config :geo, GeoWeb.Endpoint,
# Enable dev routes for dashboard and mailbox
config :geo, dev_routes: true

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

# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
Expand Down
21 changes: 13 additions & 8 deletions lib/geo/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,11 @@ defmodule Geo.Application do
start:
{:poolboy, :start_link,
[
[
name: {:local, :country_cache_pool},
worker_module: Geo.Geography.Country.Cache.Server,
# 5 permanent workers
size: 5,
# No overflow workers (fixed pool size)
max_overflow: 0
]
[
name: {:local, :country_cache},
worker_module: Geo.Geography.Country.Cache.Server,
size: min(5, System.schedulers_online())
]
]}
},
# Start the Finch HTTP client for sending emails
Expand All @@ -62,4 +59,12 @@ defmodule Geo.Application do
GeoWeb.Endpoint.config_change(changed, removed)
:ok
end

# Add shutdown logging
@impl true
def stop(reason) do
require Logger
Logger.info("Geo.Application terminating with reason: #{inspect(reason)}")
:ok
end
end
45 changes: 20 additions & 25 deletions lib/geo/geography/country/cache.ex
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
defmodule Geo.Geography.Country.Cache do
@moduledoc """
API for country cache operations. Only used by the Country resource.
Uses Poolboy to manage a pool of 5 cache GenServer workers for load balancing.
Uses Poolboy to manage a pool of cache GenServer workers for load balancing.
Starts with 0 permanent workers and can overflow up to min(8, System.schedulers_online()) workers.
"""

require Logger

@pool_name :country_cache_pool
@pool_name :country_cache

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

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

# Refresh each worker in the pool
# Refresh each worker in the pool (if any are running)
refresh_results =
for _ <- 1..worker_count do
:poolboy.transaction(
@pool_name,
fn worker ->
GenServer.call(worker, :refresh)
end,
# 30 second timeout for refresh
30_000
)
if worker_count > 0 do
for _ <- 1..worker_count do
:poolboy.transaction(
@pool_name,
fn worker ->
GenServer.call(worker, :refresh)
end,
# 30 second timeout for refresh
30_000
)
end
else
# No workers running, trigger a cache load by doing a dummy search
# This will create a worker on-demand that will load fresh data
search!("")
[:ok]
end

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

@doc """
Check if the cache pool is running and has workers available.
"""
def running? do
try do
workers = :poolboy.status(@pool_name)
total_workers = Keyword.get(workers, :ready, 0) + Keyword.get(workers, :busy, 0)
total_workers > 0
rescue
_ -> false
end
end

@doc """
Get cache pool statistics and status.
"""
Expand Down
184 changes: 101 additions & 83 deletions lib/geo/geography/country/cache/server.ex
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
defmodule Geo.Geography.Country.Cache.Server do
@moduledoc """
GenServer that caches country data in memory for fast lookup and search operations.
Loads all countries once at startup and provides efficient search functions.
Uses lazy loading - countries are loaded on first access rather than at startup.
Designed to work as a pooled worker with Poolboy - no longer uses named registration.

The server will automatically stop after @stop_interval once countries are loaded.
"""

use GenServer
require Logger

@refresh_interval :timer.minutes(30)
@stop_interval :timer.minutes(1)

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

@type t :: %__MODULE__{
countries_list_by_iso_code: [Geo.Geography.Country.t()],
countries_list_by_name: [Geo.Geography.Country.t()],
countries_map_by_iso_code: %{String.t() => Geo.Geography.Country.t()},
countries_map_by_name: %{String.t() => Geo.Geography.Country.t()},
last_refresh: DateTime.t(),
countries_list_by_iso_code: [Geo.Geography.Country.t()] | nil,
countries_list_by_name: [Geo.Geography.Country.t()] | nil,
countries_map_by_iso_code: %{String.t() => Geo.Geography.Country.t()} | nil,
countries_map_by_name: %{String.t() => Geo.Geography.Country.t()} | nil,
last_refresh: DateTime.t() | nil,
timer_ref: reference() | nil
}
end
Expand All @@ -39,104 +43,80 @@ defmodule Geo.Geography.Country.Cache.Server do

@impl true
def init(_opts) do
try do
state = load_countries!()

# Schedule periodic refresh
timer_ref = Process.send_after(self(), :refresh, @refresh_interval)
state = %{state | timer_ref: timer_ref}

Logger.info("Cache worker started successfully")
{:ok, state}
rescue
error ->
Logger.error("Failed to initialize cache worker: #{inspect(error)}")
{:stop, error}
end
# Start with empty state - countries will be loaded on first access
# Timer will only start when countries are actually loaded
state = %State{
countries_list_by_iso_code: nil,
countries_list_by_name: nil,
countries_map_by_iso_code: nil,
countries_map_by_name: nil,
last_refresh: nil,
timer_ref: nil
}

Logger.info("Cache worker started successfully: #{inspect(self())}")
{:ok, state}
end

@impl true
def handle_call(:search_all, _from, state) do
{:reply, do_search_all(state), state}
state = ensure_countries_loaded(state)
result = do_search_all(state)
{:reply, result, state}
end

@impl true
def handle_call({:search, query}, _from, state) do
state = ensure_countries_loaded(state)
{:reply, do_search(query, state), state}
end

@impl true
def handle_call({:get_by_iso_code, iso_code}, _from, state) do
country = Map.get(state.countries_map_by_iso_code, String.downcase(iso_code))
{:reply, country, state}
end
state = ensure_countries_loaded(state)

@impl true
def handle_call(:refresh, _from, state) do
try do
# Cancel existing timer if it exists
if state.timer_ref do
Process.cancel_timer(state.timer_ref)
country =
if state.countries_map_by_iso_code do
Map.get(state.countries_map_by_iso_code, String.downcase(iso_code))
else
nil
end

new_state = load_countries!()

# Schedule next refresh
timer_ref = Process.send_after(self(), :refresh, @refresh_interval)
new_state = %{new_state | timer_ref: timer_ref}

Logger.info("Cache worker refreshed successfully")
{:reply, :ok, new_state}
rescue
error ->
Logger.error("Failed to refresh cache worker: #{inspect(error)}")
{:reply, {:error, error}, state}
end
{:reply, country, state}
end

@impl true
def handle_call(:status, _from, state) do
status = %{
countries_count: map_size(state.countries_map_by_iso_code),
last_refresh: state.last_refresh,
worker_pid: self()
}
status =
if state.last_refresh do
%{
countries_count: map_size(state.countries_map_by_iso_code),
last_refresh: state.last_refresh,
worker_pid: self(),
loaded: true
}
else
%{
countries_count: 0,
last_refresh: nil,
worker_pid: self(),
loaded: false
}
end

{:reply, status, state}
end

@impl true
def handle_info(:refresh, state) do
# Periodic refresh
try do
# Cancel existing timer if it exists
if state.timer_ref do
Process.cancel_timer(state.timer_ref)
end

new_state = load_countries!()

Logger.debug("Cache worker auto-refreshed successfully")

# Schedule next refresh
timer_ref = Process.send_after(self(), :refresh, @refresh_interval)
new_state = %{new_state | timer_ref: timer_ref}

{:noreply, new_state}
rescue
error ->
Logger.warning("Failed to auto-refresh cache worker: #{inspect(error)}")

# Still schedule next refresh attempt
timer_ref = Process.send_after(self(), :refresh, @refresh_interval)
new_state = %{state | timer_ref: timer_ref}
{:noreply, new_state}
end
def handle_info(:stop, state) do
Logger.info("Cache worker stopping after #{@stop_interval} ms as scheduled")
{:stop, :normal, state}
end

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

# Private functions

# Returns a State struct with loaded countries data
defp load_countries! do
# Ensures countries are loaded in the state, loading them if last_refresh is nil
defp ensure_countries_loaded(state) do
if state.last_refresh do
state
else
try do
load_countries!(state)
rescue
error ->
Logger.error("Failed to load countries: #{inspect(error)}")
# Return state unchanged if loading fails
state
end
end
end

# Returns a State struct with loaded countries data, preserving existing state
defp load_countries!(existing_state) do
# Get countries sorted by iso_code (default sort from the resource)
countries = Geo.Geography.list_countries!(authorize?: false)

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

# Cancel existing timer if there is one
if existing_state.timer_ref do
Process.cancel_timer(existing_state.timer_ref)
end

# Start stop timer now that countries are loaded
timer_ref = Process.send_after(self(), :stop, @stop_interval)
Logger.info("Countries loaded successfully, worker will stop in #{@stop_interval} ms")

%State{
countries_list_by_iso_code: countries_list_by_iso_code,
countries_list_by_name: countries_list_by_name,
countries_map_by_iso_code: countries_map_by_iso_code,
countries_map_by_name: countries_map_by_name,
last_refresh: DateTime.utc_now(),
timer_ref: nil
timer_ref: timer_ref
}
end

defp do_search(query, state) do
# If countries not loaded, return empty results
if is_nil(state.countries_map_by_name) do
%Geo.Geography.Country.Cache.SearchResult{
by_iso_code: [],
by_name: []
}
else
do_search_with_data(query, state)
end
end

defp do_search_with_data(query, state) do

query_down = String.downcase(query)

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

defp do_search_all(state) do
# Return SearchResults struct with all countries
# Return SearchResults struct with all countries, or empty if not loaded
%Geo.Geography.Country.Cache.SearchResult{
by_iso_code: state.countries_list_by_iso_code,
by_name: state.countries_list_by_name
by_iso_code: state.countries_list_by_iso_code || [],
by_name: state.countries_list_by_name || []
}
end
end
Loading
Loading