Skip to content

Commit

Permalink
Beacon.ErrorHandler (#605)
Browse files Browse the repository at this point in the history
  • Loading branch information
APB9785 authored Nov 5, 2024
1 parent 6dcb14d commit 449702b
Show file tree
Hide file tree
Showing 18 changed files with 257 additions and 262 deletions.
2 changes: 2 additions & 0 deletions lib/beacon/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule Beacon.Application do

@impl true
def start(_type, _args) do
{:module, _} = Code.ensure_loaded(Beacon.ErrorHandler)

# Starts just the minimum required apps for beacon to work.
# - Keep loading sites as children of main sup to have control of where and when to trigger it.
# - Loading repo allows to run seeds without triggering module and css recompilation.
Expand Down
52 changes: 8 additions & 44 deletions lib/beacon/beacon.ex
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ defmodule Beacon do
"""
@spec boot(Beacon.Types.Site.t()) :: :ok
def boot(site) when is_atom(site) do
Beacon.Boot.live_init(site)
Beacon.Boot.init(site)
:ok
end

Expand All @@ -129,46 +129,16 @@ defmodule Beacon do
end

@doc false
# Provides a safer `apply` for cases where `module` is being recompiled,
# and also raises with more context about the called mfa.
#
# This should always be used when calling dynamic modules
# This should always be used when calling dynamic modules to provide better error messages
def apply_mfa(module, function, args, opts \\ []) when is_atom(module) and is_atom(function) and is_list(args) and is_list(opts) do
context = Keyword.get(opts, :context, nil)
do_apply_mfa(module, function, args, 0, context)
end

@max_retries 5

defp do_apply_mfa(module, function, args, failure_count, context) when is_atom(module) and is_atom(function) and is_list(args) do
if :erlang.module_loaded(module) do
apply(module, function, args)
else
raise Beacon.RuntimeError, message: apply_mfa_error_message(module, function, args, "module is not loaded", context, nil)
end
apply(module, function, args)
rescue
error in UndefinedFunctionError ->
case {failure_count, error} do
{failure_count, _} when failure_count >= @max_retries ->
mfa = Exception.format_mfa(module, function, length(args))
Logger.debug("failed to call #{mfa} after #{failure_count} tries")
reraise Beacon.RuntimeError, [message: apply_mfa_error_message(module, function, args, "exceeded retries", context, error)], __STACKTRACE__

{_, %UndefinedFunctionError{module: ^module, function: ^function}} ->
mfa = Exception.format_mfa(module, function, length(args))
Logger.debug("failed to call #{mfa} for the #{failure_count + 1} time, retrying...")
:timer.sleep(100 * (failure_count * 2))
do_apply_mfa(module, function, args, failure_count + 1, context)

{_, error} ->
reraise Beacon.RuntimeError,
[message: apply_mfa_error_message(module, function, args, nil, context, error)],
__STACKTRACE__
end

error ->
Logger.debug(apply_mfa_error_message(module, function, args, nil, context, error))
reraise error, __STACKTRACE__
context = Keyword.get(opts, :context, nil)

reraise Beacon.RuntimeError,
[message: apply_mfa_error_message(module, function, args, nil, context, error)],
__STACKTRACE__
end

defp apply_mfa_error_message(module, function, args, reason, context, error) do
Expand All @@ -181,10 +151,4 @@ defmodule Beacon do
lines = for line <- [summary, reason, context, error], line != nil, do: line
Enum.join(lines, "\n\n")
end

@doc false
# https://github.com/phoenixframework/phoenix_live_view/blob/8fedc6927fd937fe381553715e723754b3596a97/lib/phoenix_live_view/channel.ex#L435-L437
def exported?(m, f, a) do
function_exported?(m, f, a) || (Code.ensure_loaded?(m) && function_exported?(m, f, a))
end
end
42 changes: 10 additions & 32 deletions lib/beacon/boot.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,31 @@ defmodule Beacon.Boot do
Beacon.Registry.via({site, __MODULE__})
end

def init(%{site: site, mode: :manual}) do
Logger.debug("Beacon.Boot is disabled for site #{site} on manual mode")

# Router helpers are always available
# TODO: we should be able to remove the next line after implementing `:error_handler` callbacks
Beacon.Loader.reload_routes_module(site)
def init(site) when is_atom(site) do
init(%{site: site, mode: :live})
end

def init(%{site: site, mode: :manual}) when is_atom(site) do
Logger.debug("Beacon.Boot is disabled for site #{site} on manual mode")
:ignore
end

def init(%{site: site, mode: :testing}) do
def init(%{site: site, mode: :testing}) when is_atom(site) do
Logger.debug("Beacon.Boot is disabled for site #{site} on testing mode")

# reload shared modules used by layouts and pages
# Router helpers are always available
# TODO: we should be able to remove the next lines after implementing `:error_handler` callbacks
# reload modules that are expected to be available, even empty
Beacon.Loader.reload_routes_module(site)
Beacon.Loader.reload_components_module(site)
Beacon.Loader.reload_live_data_module(site)

:ignore
end

def init(config), do: live_init(config.site)

# TODO: we should be able to remove most of the Loader calls here, probably keep only runtime js/css
def live_init(site) do
def init(%{site: site, mode: :live}) when is_atom(site) do
Logger.info("Beacon.Boot booting site #{site}")
task_supervisor = Beacon.Registry.via({site, TaskSupervisor})

# temporary disable module reloadin so we can populate data more efficiently
# temporary disable module reloading so we can populate data more efficiently
%{mode: :manual} = Beacon.Config.update_value(site, :mode, :manual)
Beacon.Loader.populate_default_media(site)
Beacon.Loader.populate_default_components(site)
Expand All @@ -55,31 +49,15 @@ defmodule Beacon.Boot do

%{mode: :live} = Beacon.Config.update_value(site, :mode, :live)

# Sigils and router helpers
# still needed to test Beacon itself
Beacon.Loader.reload_routes_module(site)

# Layouts and pages depend on the components module so we need to load them first
Beacon.Loader.reload_components_module(site)

assets = [
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_runtime_js(site) end),
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_runtime_css(site) end)
]

modules = [
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_stylesheet_module(site) end),
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_snippets_module(site) end),
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_live_data_module(site) end),
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_layouts_modules(site) end),
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_error_page_module(site) end),
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_pages_modules(site, per_page: 20) end),
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_info_handlers_module(site) end),
Task.Supervisor.async(task_supervisor, fn -> Beacon.Loader.reload_event_handlers_module(site) end)
# TODO: load main pages (order_by: path, per_page: 10) to avoid SEO issues
]

Task.await_many(modules, :timer.minutes(10))

# TODO: revisit this timeout after we upgrade to Tailwind v4
Task.await_many(assets, :timer.minutes(5))

Expand Down
71 changes: 12 additions & 59 deletions lib/beacon/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,23 @@ defmodule Beacon.Compiler do
@moduledoc false

require Logger
alias Beacon.Loader

if Beacon.Config.env_test?() do
@max_retries 2
else
@max_retries 4
end

@type diagnostics :: [Code.diagnostic(:warning | :error)]

@spec compile_module(Beacon.Site.t(), Macro.t(), String.t()) ::
@spec compile_module(Macro.t(), String.t()) ::
{:ok, module(), diagnostics()} | {:error, module(), {Exception.t(), diagnostics()}} | {:error, Exception.t() | :invalid_module}
def compile_module(site, quoted, file \\ "nofile") do
def compile_module(quoted, file \\ "nofile") do
Logger.debug("compiling #{inspect(file)}")

case module_name(quoted) do
{:ok, module} ->
do_compile_module(site, module, quoted, hash(quoted), file)
compile(module, quoted, file)

{:error, error} ->
{:error, error}
end
end

defp do_compile_module(site, module, quoted, hash, file) do
case {:erlang.module_loaded(module), current_hash(site, module) == hash} do
{true, true} ->
{:ok, module, []}

{true, _} ->
unload(module)
compile_and_register(site, module, quoted, hash, file)

{false, _} ->
compile_and_register(site, module, quoted, hash, file)
end
end

def module_name({:defmodule, _, [{:__aliases__, _, [module]}, _]}) do
{:ok, Module.concat([module])}
end
Expand All @@ -55,23 +36,23 @@ defmodule Beacon.Compiler do
invalid module given to Beacon.Compiler,
expected a quoted expression containing a single module.
Got: #{inspect(quoted)}
Got:
#{inspect(quoted)}
""")

{:error, :invalid_module}
end

defp compile_and_register(site, module, quoted, hash, file) do
defp compile(module, quoted, file) do
Code.put_compiler_option(:ignore_module_conflict, true)

case compile_quoted(quoted, file) do
{:ok, module, diagnostics} ->
:ok = Loader.add_module(site, module, {hash, nil, diagnostics})
{:ok, module, diagnostics}

{:error, error, diagnostics} ->
:ok = Loader.add_module(site, module, {hash, error, diagnostics})
{:error, module, {error, diagnostics}}
end
end
Expand All @@ -98,39 +79,11 @@ defmodule Beacon.Compiler do
end
end

defp do_compile_and_load(quoted, file, failure_count \\ 0) do
[{module, _}] = Code.compile_quoted(quoted, file)

case :code.ensure_modules_loaded([module]) do
:ok -> {:ok, module}
{:error, [{_, error}]} -> {:error, error}
end
defp do_compile_and_load(quoted, file) do
[{module, _}] = :elixir_compiler.quoted(quoted, file, fn _, _ -> :ok end)
{:ok, module}
rescue
error in CompileError ->
if failure_count < @max_retries do
:timer.sleep(100 * (failure_count * 2))
do_compile_and_load(quoted, file, failure_count + 1)
else
{:error, error}
end

error ->
{:error, error}
end

def unload(module) do
:code.purge(module)
:code.delete(module)
end

defp current_hash(site, module) do
case Loader.lookup_module(site, module) do
{^module, {hash, _, _}} -> hash
_ -> nil
end
end

defp hash(quoted) do
:erlang.phash2(quoted)
end
end
12 changes: 6 additions & 6 deletions lib/beacon/content.ex
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ defmodule Beacon.Content do
end
end

# TODO: only publish if there were actual changes compared to the last snapshot
@doc """
Publishes `layout` and reload resources to render the updated layout and pages.
Expand All @@ -197,7 +198,7 @@ defmodule Beacon.Content do
:testing ->
layout
|> insert_published_layout()
|> tap(fn {:ok, layout} -> reload_published_layout(layout.site, layout.id) end)
|> tap(fn {:ok, layout} -> reset_published_layout(layout.site, layout.id) end)

:manual ->
insert_published_layout(layout)
Expand Down Expand Up @@ -641,7 +642,7 @@ defmodule Beacon.Content do
:testing ->
page
|> insert_published_page()
|> tap(fn {:ok, page} -> reload_published_page(page.site, page.id) end)
|> tap(fn {:ok, page} -> reset_published_page(page.site, page.id) end)

:manual ->
insert_published_page(page)
Expand All @@ -659,6 +660,7 @@ defmodule Beacon.Content do
|> publish_page()
end

# TODO: only publish if there were actual changes compared to the last snapshot
@doc """
Publish multiple `pages`.
Expand Down Expand Up @@ -4365,15 +4367,13 @@ defmodule Beacon.Content do
end

@doc false
# TODO: revisit after changing Fixture to only insert data through repo functions instead of Context functions
def reload_published_layout(site, id) do
def reset_published_layout(site, id) do
clear_cache(site, id)
:ok
end

@doc false
# TODO: revisit after changing Fixture to only insert data through repo functions instead of Context functions
def reload_published_page(site, id) do
def reset_published_page(site, id) do
clear_cache(site, id)
page = get_published_page(site, id)
:ok = Beacon.RouterServer.add_page(page.site, page.id, page.path)
Expand Down
2 changes: 1 addition & 1 deletion lib/beacon/content/page_field.ex
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ defmodule Beacon.Content.PageField do

Enum.reduce(mods, %{}, fn mod, acc ->
name = mod.name()
default = if Beacon.exported?(mod, :default, 0), do: mod.default(), else: nil
default = if function_exported?(mod, :default, 0), do: mod.default(), else: nil
value = Map.get(params, "#{name}", default)
errors = Map.get(errors, name, [])

Expand Down
Loading

0 comments on commit 449702b

Please sign in to comment.