Current section

Files

Jump to
uma_db_client lib uma_db_client.ex
Raw

lib/uma_db_client.ex

defmodule UmaDbClient do
@moduledoc """
Elixir gRPC client for UmaDB's DCB (Dynamic Consistency Boundary) service.
## Usage
{:ok, channel} = UmaDbClient.connect("localhost:50051")
# Append events
event = UmaDbClient.Builder.event(type: "OrderPlaced", tags: ["order:1"], data: Jason.encode!(%{id: 1}))
{:ok, position} = UmaDbClient.append(channel, [event])
# Read events as a lazy stream
{:ok, stream} = UmaDbClient.read(channel)
Enum.each(stream, fn %UmaDb.V1.SequencedEvent{position: pos, event: e} ->
IO.inspect({pos, e.event_type})
end)
## TLS
Pass gRPC credentials via `opts` to `connect/2`:
cred = GRPC.Credential.new(ssl: [cacertfile: "/path/to/ca.pem"])
{:ok, channel} = UmaDbClient.connect("myserver:443", cred: cred)
## Encoding and decoding payloads
Event `data` is raw bytes, so callers normally encode and decode by hand. To
configure that once, `use` this module to generate a client facade:
defmodule MyApp.DB do
use UmaDbClient, encode: &Jason.encode!/1, decode: &Jason.decode!/1
end
{:ok, channel} = MyApp.DB.connect("localhost:50051")
event = MyApp.DB.event(type: "OrderPlaced", tags: ["order:1"], data: %{id: 1})
{:ok, position} = MyApp.DB.append(channel, [event])
{:ok, stream} = MyApp.DB.read(channel)
Enum.each(stream, fn %UmaDbClient.Event{data: data} -> IO.inspect(data) end)
See `__using__/1` for the full list of generated functions.
"""
alias UmaDb.V1.DCB.Stub
alias UmaDb.V1
@type channel :: GRPC.Channel.t()
@type position :: non_neg_integer()
@doc """
Generates a client facade with payload encoding and decoding configured.
## Options
- `:encode` — applied to `event/1`'s `:data` before it is sent
- `:decode` — applied to each event's `data` when read
Both are optional and independent. Each accepts a one-argument function, a
`{module, function}` tuple, or a module — exporting `encode!/1` for `:encode`
and `decode!/1` for `:decode`, so `encode: Jason, decode: Jason` works.
## Generated functions
- `event/1` — like `UmaDbClient.Builder.event/1`, but `:data` is a term run
through `:encode`. Pass `:raw_data` instead to supply already-encoded bytes;
passing both raises `ArgumentError`.
- `read/2` and `subscribe/2` — return a lazy stream of `UmaDbClient.Event`
structs with `data` decoded via `:decode`.
- `connect/2`, `append/3`, `head/1`, `get_tracking_info/2` — delegate to this
module.
- `query/1`, `query_item/2`, `append_condition/1`, `tracking_info/2`
delegate to `UmaDbClient.Builder`.
"""
defmacro __using__(opts) do
encode = Keyword.get(opts, :encode)
decode = Keyword.get(opts, :decode)
quote do
@doc """
Builds an `UmaDb.V1.Event`, encoding `:data` with the configured encoder.
"""
@spec event(keyword()) :: UmaDb.V1.Event.t()
def event(opts) when is_list(opts) do
opts
|> UmaDbClient.Builder.__encode_opts__(unquote(encode))
|> UmaDbClient.Builder.event()
end
@doc """
Reads events, returning a lazy stream of decoded `UmaDbClient.Event` structs.
"""
@spec read(UmaDbClient.channel(), keyword()) :: {:ok, Enumerable.t()} | {:error, term()}
def read(channel, opts \\ []) do
UmaDbClient.__decode_stream__(UmaDbClient.read(channel, opts), unquote(decode))
end
@doc """
Subscribes to events, returning a lazy stream of decoded `UmaDbClient.Event` structs.
"""
@spec subscribe(UmaDbClient.channel(), keyword()) ::
{:ok, Enumerable.t()} | {:error, term()}
def subscribe(channel, opts \\ []) do
UmaDbClient.__decode_stream__(UmaDbClient.subscribe(channel, opts), unquote(decode))
end
defdelegate connect(target, opts \\ []), to: UmaDbClient
defdelegate append(channel, events, opts \\ []), to: UmaDbClient
defdelegate head(channel), to: UmaDbClient
defdelegate get_tracking_info(channel, source), to: UmaDbClient
defdelegate query(items), to: UmaDbClient.Builder
defdelegate query_item(types \\ [], tags \\ []), to: UmaDbClient.Builder
defdelegate append_condition(opts \\ []), to: UmaDbClient.Builder
defdelegate tracking_info(source, position), to: UmaDbClient.Builder
end
end
@doc false
def __decode_stream__({:ok, stream}, decode) do
{:ok, Stream.map(stream, &UmaDbClient.Event.from_sequenced(&1, decode))}
end
def __decode_stream__({:error, _} = err, _decode), do: err
@doc """
Opens a gRPC channel to a UmaDB server.
`target` is a `"host:port"` string. `opts` are passed directly to
`GRPC.Stub.connect/2` (e.g. `cred:` for TLS, `interceptors:` etc.).
"""
@spec connect(String.t(), keyword()) :: {:ok, channel()} | {:error, term()}
def connect(target, opts \\ []) do
GRPC.Stub.connect(target, opts)
end
@doc """
Returns the current head position of the event log.
Returns `{:ok, nil}` when the log is empty.
"""
@spec head(channel()) :: {:ok, position() | nil} | {:error, term()}
def head(channel) do
case Stub.head(channel, %V1.HeadRequest{}) do
{:ok, %V1.HeadResponse{position: pos}} -> {:ok, pos}
{:error, _} = err -> err
end
end
@doc """
Returns the last tracked position for the given source identifier.
Returns `{:ok, nil}` when no tracking record exists for `source`.
"""
@spec get_tracking_info(channel(), String.t()) :: {:ok, position() | nil} | {:error, term()}
def get_tracking_info(channel, source) do
case Stub.get_tracking_info(channel, %V1.TrackingRequest{source: source}) do
{:ok, %V1.TrackingResponse{position: pos}} -> {:ok, pos}
{:error, _} = err -> err
end
end
@doc """
Appends one or more events to the log.
Returns `{:ok, position}` where `position` is the log position after the append.
## Options
- `:condition` — an `UmaDb.V1.AppendCondition` struct for optimistic concurrency
- `:tracking_info` — an `UmaDb.V1.TrackingInfo` struct to update a tracking cursor
"""
@spec append(channel(), [V1.Event.t()], keyword()) :: {:ok, position()} | {:error, term()}
def append(channel, events, opts \\ []) do
request = %V1.AppendRequest{
events: events,
condition: Keyword.get(opts, :condition),
tracking_info: Keyword.get(opts, :tracking_info)
}
case Stub.append(channel, request) do
{:ok, %V1.AppendResponse{position: pos}} -> {:ok, pos}
{:error, _} = err -> err
end
end
@doc """
Returns a lazy `Enumerable` of `UmaDb.V1.SequencedEvent` structs from the log.
The stream is server-driven: each element is received from the server as it is
produced. Iterate with `Enum.to_list/1`, `Stream.each/2`, etc.
Mid-stream gRPC errors raise `GRPC.RPCError`. Wrap iteration in `try/rescue`
if you need to recover from them.
## Options
- `:query``UmaDb.V1.Query` to filter by event type and/or tags
- `:start` — starting position (inclusive)
- `:backwards` — if `true`, read in reverse order
- `:limit` — maximum number of events to return
- `:subscribe` — if `true`, keep the stream open for new events after catching up
- `:batch_size` — number of events per server response batch
"""
@spec read(channel(), keyword()) :: {:ok, Enumerable.t()} | {:error, term()}
def read(channel, opts \\ []) do
request = %V1.ReadRequest{
query: Keyword.get(opts, :query),
start: Keyword.get(opts, :start),
backwards: Keyword.get(opts, :backwards),
limit: Keyword.get(opts, :limit),
subscribe: Keyword.get(opts, :subscribe),
batch_size: Keyword.get(opts, :batch_size)
}
case Stub.read(channel, request) do
{:ok, grpc_stream} ->
{:ok, flatten_sequenced_events(grpc_stream, V1.ReadResponse)}
{:error, _} = err ->
err
end
end
@doc """
Returns a lazy `Enumerable` of `UmaDb.V1.SequencedEvent` structs, starting
from the given position and continuing indefinitely as new events arrive.
This is a long-running server-push stream. Blocking iteration (e.g. via
`Enum.each/2`) will not return until the server closes the stream or an
error occurs. Consider running it in a dedicated process.
## Options
- `:query``UmaDb.V1.Query` to filter by event type and/or tags
- `:after` — only receive events after this position
- `:batch_size` — number of events per server response batch
"""
@spec subscribe(channel(), keyword()) :: {:ok, Enumerable.t()} | {:error, term()}
def subscribe(channel, opts \\ []) do
request = %V1.SubscribeRequest{
query: Keyword.get(opts, :query),
after: Keyword.get(opts, :after),
batch_size: Keyword.get(opts, :batch_size)
}
case Stub.subscribe(channel, request) do
{:ok, grpc_stream} ->
{:ok, flatten_sequenced_events(grpc_stream, V1.SubscribeResponse)}
{:error, _} = err ->
err
end
end
defp flatten_sequenced_events(grpc_stream, response_module) do
Stream.flat_map(grpc_stream, fn
{:ok, response} ->
Map.fetch!(response, :events)
{:error, reason} ->
raise GRPC.RPCError,
status: GRPC.Status.internal(),
message: "stream error from #{inspect(response_module)}: #{inspect(reason)}"
end)
end
end