Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dirty-trains-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@core/elixir-client': patch
---

Sync CDN-resilience fixes from the TypeScript client: cache-buster on every 409, self-heal a stuck expired handle cache and synthetic must-refetch header response for all 409s
14 changes: 14 additions & 0 deletions packages/elixir-client/lib/electric/client/expired_shapes_cache.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ defmodule Electric.Client.ExpiredShapesCache do
GenServer.call(__MODULE__, :clear)
end

@doc """
Clear the expired handle for a single shape key.
"""
@spec clear_handle(String.t()) :: :ok
def clear_handle(shape_key) do
GenServer.call(__MODULE__, {:clear_handle, shape_key})
end

@doc """
Get the current number of entries in the cache.

Expand Down Expand Up @@ -102,6 +110,12 @@ defmodule Electric.Client.ExpiredShapesCache do
{:reply, :ok, state}
end

@impl true
def handle_call({:clear_handle, shape_key}, _from, state) do
:ets.delete(@table_name, shape_key)
{:reply, :ok, state}
end

@impl true
def handle_cast({:touch, shape_key}, state) do
timestamp = System.monotonic_time()
Expand Down
66 changes: 49 additions & 17 deletions packages/elixir-client/lib/electric/client/poll.ex
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ defmodule Electric.Client.Poll do
# the client loops infinitely (the URL never changes).
cond do
response_handle == expired_handle ->
handle_stale_response(state)
handle_stale_response(state, shape_key)

# Normal: process response
true ->
Expand All @@ -171,15 +171,35 @@ defmodule Electric.Client.Poll do
{:ok, messages, new_state}
end

defp handle_stale_response(state) do
if state.stale_cache_retry_count >= @max_stale_retries do
{:error,
%Client.Error{
message:
"CDN continues serving stale cached responses after #{@max_stale_retries} retry attempts"
}}
else
{:stale_retry, ShapeState.enter_stale_retry(state)}
defp handle_stale_response(state, shape_key) do
cond do
state.stale_cache_retry_count < @max_stale_retries ->
{:stale_retry, ShapeState.enter_stale_retry(state)}

state.self_heal_attempted? ->
{:error,
%Client.Error{
message:
"CDN continues serving stale cached responses after #{@max_stale_retries} " <>
"retry attempts and one self-heal attempt"
}}

true ->
# Self-heal: clear the expired entry from local cache so the next
# request omits the `expired_handle` param. Since the server never
# reuses handles (SPEC.md S0), the next response should bypass stale
# detection. If it doesn't (broken CDN), we error on the next pass via
# the `self_heal_attempted?` branch above.
ExpiredShapesCache.clear_handle(shape_key)

new_state =
ShapeState.enter_stale_retry(%{
state
| self_heal_attempted?: true,
stale_cache_retry_count: 0
})

{:stale_retry, new_state}
end
end

Expand All @@ -196,13 +216,25 @@ defmodule Electric.Client.Poll do
new_state = ShapeState.reset(state, handle)
new_state = handle_schema(resp, client, new_state)
new_state = ShapeState.clear_stale_retry(new_state)

%{value_mapper_fun: value_mapper_fun} = new_state

messages =
resp.body
|> ensure_enum()
|> Enum.flat_map(&Message.parse(&1, handle, value_mapper_fun, resp.request_timestamp))
# Add a cache-buster on every 409 so that the next request URL cannot match
# a URL the CDN has cached. Without this, a CDN that strips the
# `expired_handle` query param from its cache key keeps serving the same
# cached 409 indefinitely.
new_state = %{new_state | stale_cache_buster: ShapeState.generate_cache_buster()}

# Always emit a synthetic must-refetch control message rather than
# forwarding whatever the server (or a misbehaving proxy) put in the 409
# body. Subscribers must receive the signal to clear local state on every
# 409, even when the body is empty or stripped of the control message.
# Any data rows present in the body refer to the old, expired handle, so
# discarding them is correct.
messages = [
%Message.ControlMessage{
control: :must_refetch,
handle: handle,
request_timestamp: resp.request_timestamp
}
]

{:must_refetch, messages, new_state}
end
Expand Down
5 changes: 4 additions & 1 deletion packages/elixir-client/lib/electric/client/shape_state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ defmodule Electric.Client.ShapeState do
tag_to_keys: %{},
key_data: %{},
stale_cache_retry_count: 0,
self_heal_attempted?: false,
disjunct_positions: nil,
recent_requests: [],
fast_loop_consecutive_count: 0
Expand All @@ -63,6 +64,7 @@ defmodule Electric.Client.ShapeState do
disjunct_positions: [[non_neg_integer()]] | nil,
stale_cache_buster: String.t() | nil,
stale_cache_retry_count: non_neg_integer(),
self_heal_attempted?: boolean(),
recent_requests: [{integer(), Offset.t()}],
fast_loop_consecutive_count: non_neg_integer()
}
Expand Down Expand Up @@ -164,7 +166,8 @@ defmodule Electric.Client.ShapeState do
%{
state
| stale_cache_buster: nil,
stale_cache_retry_count: 0
stale_cache_retry_count: 0,
self_heal_attempted?: false
}
end

Expand Down
Loading
Loading