-
Notifications
You must be signed in to change notification settings - Fork 0
Add encoder health check and refactor error handling #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9649e3c
Add encoder health check to detect and kill stuck processes
mjc 90a2ccd
Display encoder health alerts on dashboard
mjc 398ac13
Refactor encoder pipeline for simpler error handling
mjc 4bcb784
Record encoding failures in FailureTracker
mjc fb424c4
Address PR review comments for encoder health check
mjc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| defmodule Reencodarr.Encoder.HealthCheck do | ||
| @moduledoc """ | ||
| Monitors the encoder pipeline for stuck states via PubSub events. | ||
|
|
||
| Subscribes to encoding events and detects when ab-av1/ffmpeg hangs silently | ||
| (no progress events for extended periods). Automatically kills the stuck | ||
| process after 1 hour of no progress. | ||
| """ | ||
|
|
||
| use GenServer | ||
| require Logger | ||
|
|
||
| alias Reencodarr.Dashboard.Events | ||
|
|
||
| # Check every 60 seconds | ||
| @check_interval 60_000 | ||
| # Warn at 30 minutes | ||
| @warn_threshold 30 * 60_000 | ||
| # Kill at 1 hour | ||
| @kill_threshold 60 * 60_000 | ||
|
|
||
| def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) | ||
|
|
||
| @impl true | ||
| def init(_opts) do | ||
| # Subscribe to encoding events instead of polling state | ||
| Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) | ||
| schedule_check() | ||
|
|
||
| {:ok, | ||
| %{ | ||
| encoding: false, | ||
| video_id: nil, | ||
| video_path: nil, | ||
| last_progress_time: nil, | ||
| last_progress_percent: nil, | ||
| warned: false, | ||
| os_pid: nil | ||
| }} | ||
| end | ||
|
|
||
| # Handle encoding lifecycle events | ||
| @impl true | ||
| def handle_info({:encoding_started, data}, state) do | ||
| now = System.monotonic_time(:millisecond) | ||
|
|
||
| {:noreply, | ||
| %{ | ||
| state | ||
| | encoding: true, | ||
| video_id: data[:video_id], | ||
| video_path: data[:filename], | ||
| last_progress_time: now, | ||
| last_progress_percent: nil, | ||
| warned: false, | ||
| os_pid: data[:os_pid] | ||
| }} | ||
| end | ||
|
|
||
| @impl true | ||
| def handle_info({:encoding_progress, data}, state) do | ||
| now = System.monotonic_time(:millisecond) | ||
| percent = data[:percent] | ||
|
|
||
| {:noreply, | ||
| %{ | ||
| state | ||
| | last_progress_time: now, | ||
| last_progress_percent: percent, | ||
| warned: false | ||
| }} | ||
| end | ||
|
|
||
| @impl true | ||
| def handle_info({:encoding_completed, _data}, state) do | ||
| {:noreply, | ||
| %{ | ||
| state | ||
| | encoding: false, | ||
| video_id: nil, | ||
| video_path: nil, | ||
| last_progress_time: nil, | ||
| last_progress_percent: nil, | ||
| warned: false, | ||
| os_pid: nil | ||
| }} | ||
| end | ||
|
|
||
| # Periodic health check | ||
| @impl true | ||
| def handle_info(:health_check, state) do | ||
| new_state = perform_health_check(state) | ||
| schedule_check() | ||
| {:noreply, new_state} | ||
| end | ||
|
|
||
| # Ignore other PubSub events | ||
| @impl true | ||
| def handle_info(_msg, state), do: {:noreply, state} | ||
|
|
||
| defp perform_health_check(%{encoding: false} = state), do: state | ||
|
|
||
| defp perform_health_check(%{encoding: true, last_progress_time: nil} = state) do | ||
| # Encoding but no progress time set - initialize it | ||
| %{state | last_progress_time: System.monotonic_time(:millisecond)} | ||
| end | ||
|
|
||
| defp perform_health_check(%{encoding: true} = state) do | ||
| now = System.monotonic_time(:millisecond) | ||
| elapsed = now - state.last_progress_time | ||
|
|
||
| cond do | ||
| # Stuck for 1 hour - kill the process | ||
| elapsed > @kill_threshold -> | ||
| kill_stuck_encoder(state) | ||
|
|
||
| %{ | ||
| state | ||
| | encoding: false, | ||
| video_id: nil, | ||
| video_path: nil, | ||
| last_progress_time: nil, | ||
| last_progress_percent: nil, | ||
| warned: false, | ||
| os_pid: nil | ||
| } | ||
|
|
||
| # Stuck for 30 min - warn | ||
| elapsed > @warn_threshold and not state.warned -> | ||
| warn_stuck_encoder(state) | ||
| %{state | warned: true} | ||
|
|
||
| true -> | ||
| state | ||
| end | ||
| end | ||
|
|
||
| defp warn_stuck_encoder(state) do | ||
| Logger.warning( | ||
| "Encoder may be stuck - no progress for 30+ minutes. " <> | ||
| "Video ID: #{state.video_id}, Path: #{state.video_path}" | ||
| ) | ||
|
|
||
| Events.broadcast_event(:encoder_health_alert, %{ | ||
| reason: :stalled_30_min, | ||
| video_id: state.video_id, | ||
| video_path: state.video_path | ||
| }) | ||
| end | ||
|
|
||
| defp kill_stuck_encoder(%{os_pid: nil} = state) do | ||
| Logger.error( | ||
| "Could not kill stuck encoder - no OS PID available. " <> | ||
| "Video ID: #{state.video_id}, Path: #{state.video_path}" | ||
| ) | ||
| end | ||
|
|
||
| defp kill_stuck_encoder(%{os_pid: os_pid} = state) do | ||
| Logger.error( | ||
| "Killing stuck encoder process (PID: #{os_pid}) after 1 hour of no progress. " <> | ||
| "Video ID: #{state.video_id}, Path: #{state.video_path}" | ||
| ) | ||
|
|
||
| # Run the kill command asynchronously so the HealthCheck GenServer is not blocked | ||
| Task.start(fn -> System.cmd("kill", [to_string(os_pid)]) end) | ||
|
|
||
| Events.broadcast_event(:encoder_health_alert, %{ | ||
| reason: :killed_stuck_process, | ||
| video_id: state.video_id, | ||
| video_path: state.video_path, | ||
| os_pid: os_pid | ||
| }) | ||
| end | ||
|
|
||
| defp schedule_check, do: Process.send_after(self(), :health_check, @check_interval) | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.