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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user