Add service infrastructure for long-running deployment

- Add configuration system (config/*.exs, OrgGarden.Config)
- Refactor supervision tree with DynamicSupervisor and Registry
- Add OrgGarden.Server for serve mode lifecycle management
- Add health check HTTP endpoints (Bandit/Plug on :9090)
- Add telemetry events for export and watcher operations
- Implement graceful shutdown with SIGTERM handling
- Add Mix Release support with overlay scripts
- Add NixOS module for systemd service deployment
- Update documentation with service usage
This commit is contained in:
Ignacio Ballesteros
2026-02-21 20:38:47 +01:00
parent 6476b45f04
commit 01805dbf39
23 changed files with 1147 additions and 83 deletions

View File

@@ -29,6 +29,7 @@ defmodule OrgGarden.Watcher do
require Logger
@debounce_ms 500
@drain_timeout 5_000
# -------------------------------------------------------------------
# Client API
@@ -49,12 +50,21 @@ defmodule OrgGarden.Watcher do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Check if the watcher is running.
"""
def running? do
Process.whereis(__MODULE__) != nil
end
# -------------------------------------------------------------------
# GenServer callbacks
# -------------------------------------------------------------------
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
notes_dir = Keyword.fetch!(opts, :notes_dir)
output_dir = Keyword.fetch!(opts, :output_dir)
content_dir = Keyword.fetch!(opts, :content_dir)
@@ -78,7 +88,8 @@ defmodule OrgGarden.Watcher do
pipeline_opts: pipeline_opts,
watcher_pid: watcher_pid,
initialized_transforms: initialized_transforms,
pending: %{}
pending: %{},
processing: false
}}
end
@@ -103,7 +114,7 @@ defmodule OrgGarden.Watcher do
@impl true
def handle_info({:debounced, path, event_type}, state) do
state = %{state | pending: Map.delete(state.pending, path)}
state = %{state | pending: Map.delete(state.pending, path), processing: true}
case event_type do
:deleted ->
@@ -113,12 +124,27 @@ defmodule OrgGarden.Watcher do
handle_change(path, state)
end
{:noreply, state}
emit_telemetry(:file_processed, %{path: path, event: event_type})
{:noreply, %{state | processing: false}}
end
@impl true
def terminate(_reason, state) do
def terminate(reason, state) do
Logger.info("Watcher: shutting down (#{inspect(reason)})")
# Drain pending timers
drain_pending(state.pending)
# Wait for any in-flight processing
if state.processing do
Logger.info("Watcher: waiting for in-flight processing...")
Process.sleep(100)
end
# Teardown transforms
OrgGarden.teardown_transforms(state.initialized_transforms)
:ok
end
@@ -136,20 +162,25 @@ defmodule OrgGarden.Watcher do
} = state
md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
IO.puts("==> Changed: #{Path.relative_to(orgfile, notes_dir)}")
Logger.info("Changed: #{Path.relative_to(orgfile, notes_dir)}")
start_time = System.monotonic_time(:millisecond)
case OrgGarden.Export.export_file(orgfile, notes_dir, output_dir) do
{:ok, _} ->
IO.puts(" exported: #{Path.relative_to(md_path, content_dir)}")
Logger.info(" exported: #{Path.relative_to(md_path, content_dir)}")
{:ok, stats} = OrgGarden.run_on_files_with([md_path], initialized_transforms, pipeline_opts)
{:ok, stats} =
OrgGarden.run_on_files_with([md_path], initialized_transforms, pipeline_opts)
Enum.each(stats, fn {mod, count} ->
if count > 0, do: IO.puts(" #{inspect(mod)}: #{count} file(s) modified")
if count > 0, do: Logger.info(" #{inspect(mod)}: #{count} file(s) modified")
end)
regenerate_index(content_dir)
IO.puts("==> Done")
duration = System.monotonic_time(:millisecond) - start_time
Logger.info(" done in #{duration}ms")
{:error, reason} ->
Logger.error("Watcher: export failed for #{orgfile}: #{inspect(reason)}")
@@ -160,18 +191,17 @@ defmodule OrgGarden.Watcher do
%{notes_dir: notes_dir, content_dir: content_dir} = state
md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
IO.puts("==> Deleted: #{Path.relative_to(orgfile, notes_dir)}")
Logger.info("Deleted: #{Path.relative_to(orgfile, notes_dir)}")
if File.exists?(md_path) do
File.rm!(md_path)
IO.puts(" removed: #{Path.relative_to(md_path, content_dir)}")
Logger.info(" removed: #{Path.relative_to(md_path, content_dir)}")
# Clean up empty parent directories left behind
cleanup_empty_dirs(Path.dirname(md_path), content_dir)
end
regenerate_index(content_dir)
IO.puts("==> Done")
end
# -------------------------------------------------------------------
@@ -197,6 +227,25 @@ defmodule OrgGarden.Watcher do
%{state | pending: Map.put(state.pending, path, ref)}
end
defp drain_pending(pending) when map_size(pending) == 0, do: :ok
defp drain_pending(pending) do
Logger.info("Watcher: draining #{map_size(pending)} pending event(s)")
# Cancel all timers and log what we're dropping
Enum.each(pending, fn {path, ref} ->
Process.cancel_timer(ref)
Logger.debug("Watcher: dropped pending event for #{path}")
end)
# Give a moment for any final messages to arrive
receive do
{:debounced, _, _} -> :ok
after
@drain_timeout -> :ok
end
end
defp org_file?(path), do: String.ends_with?(path, ".org")
defp temporary_file?(path) do
@@ -233,4 +282,12 @@ defmodule OrgGarden.Watcher do
end
end
end
# -------------------------------------------------------------------
# Telemetry
# -------------------------------------------------------------------
defp emit_telemetry(event, metadata) do
:telemetry.execute([:org_garden, :watcher, event], %{}, metadata)
end
end