Packages
electric
1.7.2
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
1.5.1
1.5.0
1.4.16
1.4.16-beta-1
1.4.15
1.4.14
1.4.13
1.4.12
1.4.11
1.4.10
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.4
1.3.3
1.3.2
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.14
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
retired
1.1.5
retired
1.1.4
retired
1.1.3
retired
1.1.2
1.1.1
1.1.0
1.0.24
1.0.23
1.0.22
1.0.21
1.0.20
1.0.19
1.0.18
1.0.17
1.0.15
1.0.13
1.0.12
1.0.11
1.0.10
1.0.9
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-beta.23
1.0.0-beta.22
1.0.0-beta.20
1.0.0-beta.19
1.0.0-beta.18
1.0.0-beta.17
1.0.0-beta.16
1.0.0-beta.15
1.0.0-beta.14
1.0.0-beta.13
1.0.0-beta.12
1.0.0-beta.11
1.0.0-beta.10
1.0.0-beta.9
1.0.0-beta.8
1.0.0-beta.7
1.0.0-beta.6
1.0.0-beta.5
1.0.0-beta.4
1.0.0-beta.3
1.0.0-beta.2
1.0.0-beta.1
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.1
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.5.2
0.4.4
Postgres sync engine. Sync little subsets of your Postgres data into local apps and services.
Current section
Files
Jump to
Current section
Files
lib/electric/shapes/api/response.ex
defmodule Electric.Shapes.Api.Response do
alias Electric.Plug.Utils
alias Electric.Shapes.Api
alias Electric.Shapes.Shape
alias Electric.Telemetry.OpenTelemetry
require Logger
@electric_cursor_header "electric-cursor"
@electric_handle_header "electric-handle"
@electric_offset_header "electric-offset"
@electric_schema_header "electric-schema"
@electric_up_to_date_header "electric-up-to-date"
@electric_has_data_header "electric-has-data"
@electric_known_error_header "electric-internal-known-error"
@retry_after_header "retry-after"
# List of all Electric-specific headers that may be included in API responses
@electric_headers [
@electric_cursor_header,
@electric_handle_header,
@electric_has_data_header,
@electric_offset_header,
@electric_schema_header,
@electric_up_to_date_header,
@electric_known_error_header,
@retry_after_header
]
defstruct [
:handle,
:offset,
:shape_definition,
:known_error,
:retry_after,
api: %Api{},
chunked: false,
up_to_date: false,
no_changes: false,
response_type: :normal_log,
params: %Api.Params{},
status: 200,
trace_attrs: %{},
body: [],
finalized?: false
]
@type shape_handle :: Electric.shape_handle()
@type t() :: %__MODULE__{
api: Api.t(),
handle: nil | shape_handle(),
offset: nil | Electric.Replication.LogOffset.t(),
shape_definition: nil | Shape.t(),
chunked: boolean(),
params: Api.Params.t(),
up_to_date: boolean(),
no_changes: boolean(),
status: pos_integer(),
trace_attrs: %{optional(atom()) => term()},
body: Enum.t(),
finalized?: boolean(),
response_type: :normal_log | :subset
}
@shape_definition_mismatch %{
message:
"The specified shape definition and handle do not match. " <>
"Please ensure the shape definition is correct or omit " <>
"the shape handle from the request to obtain a new one."
}
@before_all_offset Electric.Replication.LogOffset.before_all()
def shape_definition_mismatch(request) do
error(request, @shape_definition_mismatch)
end
def error(api_or_request, message, args \\ [])
@spec error(Api.t() | Api.Request.t(), term(), keyword()) :: t()
def error(%Api{} = api, message, args) do
opts =
args
|> Keyword.put_new(:status, 400)
|> Keyword.put(:body, error_body(api, message, args))
|> Keyword.put(:api, api)
|> Keyword.put(:finalized?, true)
struct(__MODULE__, opts)
end
def error(%Api.Request{} = request, message, args) do
opts =
args
|> Keyword.put_new(:status, 400)
|> Keyword.put(:body, error_body(request, message, args))
|> Keyword.put(:shape_definition, request.params.shape_definition)
|> Keyword.put(:api, request.api)
response = struct(__MODULE__, opts)
# if the request has been registered then we need to clean it up
# by appending the cleanup/1 operation after the response stream
if is_pid(request.new_changes_pid) do
ensure_cleanup(response)
else
final(response)
end
end
def final(%__MODULE__{} = response) do
%{response | finalized?: true}
end
def invalid_request(api_or_request, args) do
error(api_or_request, "Invalid request", args)
end
defp error_body(api_or_request, message, args) when is_binary(message) do
error_body(api_or_request, %{message: message}, args)
end
defp error_body(api_or_request, message, args) do
body =
if errors = Keyword.get(args, :errors) do
Map.put(message, :errors, errors)
else
message
end
Api.encode_error_message(api_or_request, body)
end
@spec send(Plug.Conn.t(), t()) :: Plug.Conn.t()
def send(%Plug.Conn{} = conn, %__MODULE__{chunked: false} = response) do
validate_response_finalized!(response)
conn
|> put_resp_headers(response)
|> Plug.Conn.send_resp(response.status, Enum.into(response.body, []))
end
def send(%Plug.Conn{} = conn, %__MODULE__{} = response) do
validate_response_finalized!(response)
conn
|> put_resp_headers(response)
|> send_stream(response)
end
# append the cleanup operations onto the end of the stream for every response
# by concating a dummy stream with only an `after` function.
#
# the cleanup **has** to come after the body has been read, not when we
# return the body to the conn otherwise we'd be reducing the active reader
# count either before or during the period when the client is reading the
# response.
def ensure_cleanup(%__MODULE__{finalized?: true} = response) do
response
end
def ensure_cleanup(%__MODULE__{} = response) do
%{response | finalized?: true, body: append_cleanup_operation(response)}
end
defp append_cleanup_operation(%{body: body} = response) do
request_pid = self()
Stream.transform(
body,
fn ->
# ensure that we read the response from the same process that created it
# because otherwise the clean up operations will run on the wrong process
# and we may end up with dangling readers in the global state unless the
# request process exits. We could allow for reading from other processes
# but that doesn't seem to be a requirement ATM and if we can avoid the
# complexity it's worth it.
if request_pid != self(),
do: raise("Response body must be read in same process as request")
response
end,
fn elem, response -> {[elem], response} end,
&clean_up/1
)
end
defp clean_up(response) do
response
|> clean_up_change_listener()
end
defp clean_up_change_listener(%__MODULE__{handle: shape_handle} = response)
when not is_nil(shape_handle) do
%{api: %{stack_id: stack_id}} = response
# Ensure registry is still runnning and unregister handle
registry = Electric.StackSupervisor.registry_name(stack_id)
if GenServer.whereis(registry) != nil,
do: Registry.unregister(registry, shape_handle)
response
end
defp clean_up_change_listener(%__MODULE__{} = response), do: response
defp put_resp_headers(conn, %__MODULE__{response_type: :subset} = response) do
conn
|> put_cache_header("cache-control", "no-cache", response.api)
|> Plug.Conn.put_resp_header("electric-snapshot", "true")
|> put_shape_handle_header(response)
|> put_schema_header(response)
|> put_offset_header(response)
|> put_known_error_header(response)
end
defp put_resp_headers(conn, response) do
conn
|> put_cache_headers(response)
|> put_cursor_headers(response)
|> put_etag_headers(response)
|> put_shape_handle_header(response)
|> put_schema_header(response)
|> put_up_to_date_header(response)
|> put_has_data_header(response)
|> put_offset_header(response)
|> put_known_error_header(response)
|> put_retry_after_header(response)
|> put_sse_headers(response)
end
defp put_shape_handle_header(conn, %__MODULE__{handle: nil}) do
conn
end
defp put_shape_handle_header(conn, %__MODULE__{} = response) do
Plug.Conn.put_resp_header(conn, @electric_handle_header, response.handle)
end
defp put_schema_header(conn, %__MODULE__{params: %{live: false}, status: 200} = response) do
Plug.Conn.put_resp_header(
conn,
@electric_schema_header,
response |> Api.schema() |> Jason.encode!()
)
end
defp put_schema_header(conn, _response) do
conn
end
# Do not cache responses for any methods other then GET and OPTIONS
defp put_cache_headers(%Plug.Conn{method: method} = conn, %__MODULE__{api: api})
when method not in ["GET", "OPTIONS"] do
conn
|> put_cache_header("cache-control", "no-cache", api)
end
# Briefly cache 409s as they act as shape redirects, when the requested shape
# is either invalidated or does not match the requested definition, and thus
# can benefit from persisting this cache for a brief period of time to avoid
# surges of traffic hitting the server whenever a shape is invalidated
defp put_cache_headers(conn, %__MODULE__{status: status, api: api, handle: handle})
when status in [409] do
# if handle is not present, cache for a minimum time just to allow request coalescing,
# as 409s without handles are suboptimal redirects
age = if is_nil(handle), do: 1, else: 60
conn
|> put_cache_header("cache-control", "public, max-age=#{age}, must-revalidate", api)
end
# All other 4xx and 5xx responses should never be cached
# Use no-store (not no-cache) to prevent CDN caching of error responses
defp put_cache_headers(conn, %__MODULE__{status: status, api: api})
when status >= 400 do
conn
|> put_cache_header("cache-control", "no-store", api)
|> put_cache_header("surrogate-control", "no-store", api)
end
# If the offset is -1, set a 1 week max-age, 1 hour s-maxage (shared cache)
# and 1 month stale-while-revalidate We want private caches to cache the
# initial offset for a long time but for shared caches to frequently
# revalidate so they're serving a fairly fresh copy of the initials shape
# log.
defp put_cache_headers(conn, %__MODULE__{params: %{offset: @before_all_offset}, api: api}) do
conn
|> put_cache_header(
"cache-control",
"public, max-age=604800, s-maxage=3600, stale-while-revalidate=2629746",
api
)
end
# For live SSE requests we want to cache for just under the
# sse_timeout, in order to enable request collapsing.
defp put_cache_headers(conn, %__MODULE__{
params: %{live: true, live_sse: true},
api: api
}) do
conn
|> put_cache_header(
"cache-control",
"public, max-age=#{max(1, div(api.sse_timeout, 1000) - 1)}",
api
)
end
# For live requests we want short cache lifetimes and to update the live cursor
defp put_cache_headers(conn, %__MODULE__{params: %{live: true}, api: api}) do
conn
|> put_cache_header("cache-control", "public, max-age=5, stale-while-revalidate=5", api)
end
defp put_cache_headers(conn, %__MODULE__{params: %{live: false}, api: api}) do
conn
|> put_cache_header(
"cache-control",
"public, max-age=#{api.max_age}, stale-while-revalidate=#{api.stale_age}",
api
)
end
defp put_cache_header(conn, header, value, %{send_cache_headers?: true}) do
Plug.Conn.put_resp_header(conn, header, value)
end
defp put_cache_header(conn, _header, _value, %{send_cache_headers?: false}) do
conn
end
# For live requests we want short cache lifetimes and to update the live cursor
defp put_cursor_headers(
%{query_params: query_params} = conn,
%__MODULE__{params: %{live: true}, api: api} = _response
) do
conn
|> Plug.Conn.put_resp_header(
"electric-cursor",
api.long_poll_timeout
|> Utils.get_next_interval_timestamp(query_params["cursor"])
|> Integer.to_string()
)
end
defp put_cursor_headers(conn, _), do: conn
# Responses that don't correspond to a shape should not be revalidateable
defp put_etag_headers(conn, %__MODULE__{handle: nil}), do: conn
defp put_etag_headers(conn, %__MODULE__{} = response) do
# etag values should be in double quotes: https://www.rfc-editor.org/rfc/rfc7232#section-2.3
Plug.Conn.put_resp_header(conn, "etag", etag(response))
end
defp put_has_data_header(conn, %__MODULE__{status: status}) when status >= 400 do
conn
end
defp put_has_data_header(conn, %__MODULE__{no_changes: true}) do
Plug.Conn.put_resp_header(conn, @electric_has_data_header, "false")
end
defp put_has_data_header(conn, %__MODULE__{no_changes: false}) do
Plug.Conn.put_resp_header(conn, @electric_has_data_header, "true")
end
defp put_up_to_date_header(conn, %__MODULE__{up_to_date: true}) do
Plug.Conn.put_resp_header(conn, @electric_up_to_date_header, "")
end
defp put_up_to_date_header(conn, %__MODULE__{up_to_date: false}) do
Plug.Conn.delete_resp_header(conn, @electric_up_to_date_header)
end
defp put_offset_header(conn, %__MODULE__{offset: nil}) do
conn
end
defp put_offset_header(conn, %__MODULE__{offset: offset}) do
Plug.Conn.put_resp_header(conn, @electric_offset_header, "#{offset}")
end
defp put_known_error_header(conn, %__MODULE__{known_error: nil}) do
conn
end
defp put_known_error_header(conn, %__MODULE__{known_error: known_error}) do
Plug.Conn.put_resp_header(conn, @electric_known_error_header, "#{known_error}")
end
# keeping this function close to `put_known_error_header/2` above so that we know exactly
# which value to expect for a set known_error header: i.e. "true" or "false" (and absent when
# known_error is nil), as opposed to e.g. "1" etc.
def conn_has_known_error?(conn) do
Plug.Conn.get_resp_header(conn, @electric_known_error_header) == ["true"]
end
defp put_retry_after_header(conn, %__MODULE__{retry_after: nil}) do
conn
end
defp put_retry_after_header(conn, %__MODULE__{retry_after: seconds}) do
Plug.Conn.put_resp_header(conn, "retry-after", "#{seconds}")
end
defp validate_response_finalized!(%__MODULE__{finalized?: false} = _response) do
raise "Send of un-finalized response"
end
defp validate_response_finalized!(%__MODULE__{finalized?: true} = _response) do
:ok
end
defp put_sse_headers(conn, %__MODULE__{params: %{live: true, live_sse: true}}) do
conn
|> Plug.Conn.put_resp_header("content-type", "text/event-stream")
|> Plug.Conn.put_resp_header("connection", "keep-alive")
end
defp put_sse_headers(conn, _response) do
conn
end
defp send_stream(%Plug.Conn{} = conn, %__MODULE__{status: status} = response) do
validate_response_finalized!(response)
stack_id = Api.stack_id(response)
conn = Plug.Conn.send_chunked(conn, status)
{conn, bytes_sent} =
response.body
|> Enum.reduce_while({conn, 0}, fn chunk, {conn, bytes_sent} ->
chunk_size = IO.iodata_length(chunk)
OpenTelemetry.with_span(
"shape_get.plug.stream_chunk",
[chunk_size: chunk_size],
stack_id,
fn ->
case Plug.Conn.chunk(conn, chunk) do
{:ok, conn} ->
{:cont, {conn, bytes_sent + chunk_size}}
{:error, reason} when reason in ["closed", :closed] ->
error_str = "Connection closed unexpectedly while streaming response"
conn = Plug.Conn.assign(conn, :error_str, error_str)
{:halt, {conn, bytes_sent}}
{:error, reason} ->
error_str = "Error while streaming response: #{inspect(reason)}"
Logger.error(error_str)
conn = Plug.Conn.assign(conn, :error_str, error_str)
{:halt, {conn, bytes_sent}}
end
end
)
end)
Plug.Conn.assign(conn, :streaming_bytes_sent, bytes_sent)
end
def etag(response, opts \\ [])
# When response contains no changes, in order to uniquely identify it and avoid
# infinite revalidations we add the current monotonic time to the etag.
# Any attempt by the CDN to revalidate an empty live response will fail and require
# actually holding and collapsing live requests.
def etag(%__MODULE__{handle: handle, offset: offset, params: params, no_changes: true}, opts) do
"#{handle}:#{params.offset}:#{offset}:#{System.monotonic_time()}"
|> format_etag(opts)
end
def etag(%__MODULE__{handle: handle, offset: offset, params: params}, opts) do
"#{handle}:#{params.offset}:#{offset}"
|> format_etag(opts)
end
defp format_etag(etag, opts) do
if Keyword.get(opts, :quote, true),
do: ~s|"#{etag}"|,
else: etag
end
def electric_headers, do: @electric_headers
end