Skip to main content

src/livery_grpc_health.erl

-module(livery_grpc_health).
-moduledoc """
The standard `grpc.health.v1.Health` service, ready to mount.

Register it on a server with `service/0`:

```erlang
livery_grpc:start_server(#{
    port     => 50051,
    services => [my_service_spec, livery_grpc_health:service()]
}).
```

Serving status is held per service name (the empty name `<<>>` is the
overall server status, which defaults to `SERVING`). Set it with
`set_serving/0,1` and `set_not_serving/0,1`. `Check` returns the current
status, or a `not_found` gRPC error for a named service that was never
registered. `Watch` streams the current status and then a new message
every time it changes, until the client disconnects.

Status and watch subscriptions live in `livery_grpc_health_store`, a
gen_server started with the application.
""".

-export([service/0]).
-export([set_serving/0, set_serving/1, set_not_serving/0, set_not_serving/1, status/1]).
-export([check/2, watch/3]).

-export_type([serving_status/0]).

-type serving_status() :: livery_grpc_health_store:serving_status().

%%====================================================================
%% Registration and status control
%%====================================================================

-doc "The service spec to pass in a server's `services` list.".
-spec service() -> livery_grpc_service:registration().
service() ->
    #{proto => health_pb, service => 'Health', handler => ?MODULE}.

-doc "Mark the overall server as serving.".
-spec set_serving() -> ok.
set_serving() -> set_serving(<<>>).

-doc "Mark a named service as serving.".
-spec set_serving(binary()) -> ok.
set_serving(Service) -> livery_grpc_health_store:set(Service, 'SERVING').

-doc "Mark the overall server as not serving.".
-spec set_not_serving() -> ok.
set_not_serving() -> set_not_serving(<<>>).

-doc "Mark a named service as not serving.".
-spec set_not_serving(binary()) -> ok.
set_not_serving(Service) -> livery_grpc_health_store:set(Service, 'NOT_SERVING').

-doc """
The current status for a service name. The overall server (`<<>>`)
defaults to `SERVING`; a never-registered named service is
`SERVICE_UNKNOWN`.
""".
-spec status(binary()) -> serving_status().
status(Service) ->
    case livery_grpc_health_store:status(Service) of
        {ok, Status} -> Status;
        not_found -> 'SERVICE_UNKNOWN'
    end.

%%====================================================================
%% gRPC callbacks
%%====================================================================

-doc "Unary `Check`: the current serving status, or a not_found error.".
-spec check(map(), livery_grpc_server:ctx()) ->
    {ok, map()} | {error, {livery_grpc_status:status(), binary()}}.
check(Request, _Ctx) ->
    case livery_grpc_health_store:status(service_name(Request)) of
        {ok, Status} -> {ok, #{status => Status}};
        not_found -> {error, {not_found, <<"unknown service">>}}
    end.

-doc """
Server-streaming `Watch`: emit the current status, then a new message on
every change, until the client disconnects.
""".
-spec watch(map(), fun((map()) -> ok | {error, term()}), livery_grpc_server:ctx()) -> ok.
watch(Request, Send, _Ctx) ->
    Service = service_name(Request),
    Status = livery_grpc_health_store:subscribe(Service),
    _ = Send(#{status => Status}),
    watch_loop(Service, Send).

%% Block for status changes (pushed by the store) and for the client
%% disconnect signal livery delivers to the worker. Sending stops the loop
%% if the peer is gone.
-spec watch_loop(binary(), fun((map()) -> ok | {error, term()})) -> ok.
watch_loop(Service, Send) ->
    receive
        {grpc_health_watch, Service, Status} ->
            case Send(#{status => Status}) of
                ok -> watch_loop(Service, Send);
                {error, _} -> ok
            end;
        {livery_disconnect, _Ref, _Reason} ->
            ok
    end.

%%====================================================================
%% Internals
%%====================================================================

-spec service_name(map()) -> binary().
service_name(Request) ->
    maps:get(service, Request, <<>>).