From 53df1ce22c6d21cf02a80a9d09b083b353114588 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 10 Mar 2026 22:20:08 +0100 Subject: [PATCH 1/2] feat: add graceful shutdown for k8s deployments Add nova:graceful_shutdown/0 that suspends the ranch listener, drains active connections with a configurable timeout, then stops the listener. Called automatically via prep_stop/1 during application shutdown. Configurable via sys.config: - shutdown_delay: ms to wait before suspending (default 0) - shutdown_drain_timeout: max ms to wait for connections to drain (default 15000) Co-Authored-By: Claude Opus 4.6 --- src/nova.erl | 50 +++++++++++++++++++++++++++++++++++++++++++++++- src/nova_app.erl | 7 +++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/nova.erl b/src/nova.erl index b88f259c..1622ee1d 100644 --- a/src/nova.erl +++ b/src/nova.erl @@ -5,6 +5,8 @@ -module(nova). +-include_lib("kernel/include/logger.hrl"). + -export([ get_main_app/0, get_apps/0, @@ -13,7 +15,8 @@ set_env/2, use_stacktrace/1, format_stacktrace/1, - detect_language/0 + detect_language/0, + graceful_shutdown/0 ]). -type state() :: any(). @@ -127,6 +130,51 @@ format_stacktrace(Stacktrace) -> %% of the Elixir and LFE modules, and returns the language. %% @end %%-------------------------------------------------------------------- +-spec graceful_shutdown() -> ok. +graceful_shutdown() -> + Delay = application:get_env(nova, shutdown_delay, 0), + case Delay > 0 of + true -> + ?LOG_NOTICE(#{msg => <<"Graceful shutdown started">>, delay_ms => Delay}), + timer:sleep(Delay); + false -> + ok + end, + ?LOG_NOTICE(#{msg => <<"Suspending listener">>}), + ranch:suspend_listener(nova_listener), + DrainTimeout = application:get_env(nova, shutdown_drain_timeout, 15000), + ?LOG_NOTICE(#{msg => <<"Draining connections">>, timeout_ms => DrainTimeout}), + drain_connections(DrainTimeout), + ?LOG_NOTICE(#{msg => <<"Stopping listener">>}), + cowboy:stop_listener(nova_listener), + ok. + +drain_connections(Timeout) -> + Deadline = erlang:monotonic_time(millisecond) + Timeout, + drain_loop(Deadline). + +drain_loop(Deadline) -> + case ranch:info(nova_listener) of + Info when is_list(Info) -> + case proplists:get_value(active_connections, Info, 0) of + 0 -> + ok; + N -> + Now = erlang:monotonic_time(millisecond), + case Now >= Deadline of + true -> + ?LOG_WARNING(#{msg => <<"Drain timeout reached">>, + remaining_connections => N}), + ok; + false -> + timer:sleep(500), + drain_loop(Deadline) + end + end; + _ -> + ok + end. + -spec detect_language() -> elixir | lfe | erlang. detect_language() -> case {code:which('Elixir.System'), code:which(lfe_eval)} of diff --git a/src/nova_app.erl b/src/nova_app.erl index 511b330e..ed5594d3 100644 --- a/src/nova_app.erl +++ b/src/nova_app.erl @@ -9,7 +9,7 @@ -behaviour(application). %% Application callbacks --export([start/2, stop/1]). +-export([start/2, prep_stop/1, stop/1]). %%==================================================================== %% API @@ -18,7 +18,10 @@ start(_StartType, _StartArgs) -> nova_sup:start_link(). -%%-------------------------------------------------------------------- +prep_stop(State) -> + nova:graceful_shutdown(), + State. + stop(_State) -> ok. From 257c93a11793ca829bf00c68183ac518144e4f7e Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 10 Mar 2026 22:45:09 +0100 Subject: [PATCH 2/2] fix: use ranch 2.x map API in drain_loop ranch:info/1 returns a map in ranch 2.x, not a proplist. Match on map keys directly to satisfy dialyzer. Co-Authored-By: Claude Opus 4.6 --- src/nova.erl | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/nova.erl b/src/nova.erl index 1622ee1d..6e39c5d4 100644 --- a/src/nova.erl +++ b/src/nova.erl @@ -155,24 +155,19 @@ drain_connections(Timeout) -> drain_loop(Deadline) -> case ranch:info(nova_listener) of - Info when is_list(Info) -> - case proplists:get_value(active_connections, Info, 0) of - 0 -> + #{active_connections := 0} -> + ok; + #{active_connections := N} -> + Now = erlang:monotonic_time(millisecond), + case Now >= Deadline of + true -> + ?LOG_WARNING(#{msg => <<"Drain timeout reached">>, + remaining_connections => N}), ok; - N -> - Now = erlang:monotonic_time(millisecond), - case Now >= Deadline of - true -> - ?LOG_WARNING(#{msg => <<"Drain timeout reached">>, - remaining_connections => N}), - ok; - false -> - timer:sleep(500), - drain_loop(Deadline) - end - end; - _ -> - ok + false -> + timer:sleep(500), + drain_loop(Deadline) + end end. -spec detect_language() -> elixir | lfe | erlang.