Skip to content

Commit

Permalink
feat: add config option to disable cache headers (#2425)
Browse files Browse the repository at this point in the history
for embedded electric, in dev mode we don't want the browser to cache
responses because it causes problems with restarts/migrations etc, so
add a flag to just turn them off.

also includes allowing configuration of the stack_ready_timeout and a
higher default for that (was 100ms which is a bit harsh)
  • Loading branch information
magnetised authored Mar 7, 2025
1 parent 73b6225 commit ac9af08
Show file tree
Hide file tree
Showing 7 changed files with 100 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/smart-apples-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@core/sync-service": patch
---

Add configuration flag to disable HTTP cache headers
4 changes: 3 additions & 1 deletion packages/sync-service/lib/electric/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ defmodule Electric.Application do
long_poll_timeout: get_env(opts, :long_poll_timeout),
max_age: get_env(opts, :cache_max_age),
stale_age: get_env(opts, :cache_stale_age),
allow_shape_deletion: get_env(opts, :allow_shape_deletion?)
allow_shape_deletion: get_env(opts, :allow_shape_deletion?),
stack_ready_timeout: get_env(opts, :stack_ready_timeout),
send_cache_headers?: get_env(opts, :send_cache_headers?)
)
|> Keyword.merge(Keyword.take(opts, [:encoder]))
end
Expand Down
2 changes: 2 additions & 0 deletions packages/sync-service/lib/electric/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ defmodule Electric.Config do
allow_shape_deletion?: false,
service_port: 3000,
listen_on_ipv6?: false,
stack_ready_timeout: 5_000,
send_cache_headers?: true,
## Storage
storage_dir: "./persistent",
storage: &Electric.Config.Defaults.storage/0,
Expand Down
6 changes: 4 additions & 2 deletions packages/sync-service/lib/electric/shapes/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ defmodule Electric.Shapes.Api do
max_age: [type: :integer],
stack_ready_timeout: [type: :integer],
stale_age: [type: :integer],
send_cache_headers?: [type: :boolean],
encoder: [type: :atom]
]
@schema NimbleOptions.new!(@options)
Expand All @@ -48,8 +49,9 @@ defmodule Electric.Shapes.Api do
allow_shape_deletion: false,
long_poll_timeout: 20_000,
max_age: 60,
stack_ready_timeout: 100,
stack_ready_timeout: 5_000,
stale_age: 300,
send_cache_headers?: true,
encoder: Electric.Shapes.Api.Encoder.JSON,
configured: false
]
Expand Down Expand Up @@ -595,7 +597,7 @@ defmodule Electric.Shapes.Api do

@spec stack_id(Api.t() | Request.t()) :: String.t()
def stack_id(%Api{stack_id: stack_id}), do: stack_id
def stack_id(%Request{api: %{stack_id: stack_id}}), do: stack_id
def stack_id(%{api: %{stack_id: stack_id}}), do: stack_id

defp encode_log(%Request{api: api}, stream) do
encode(api, :log, stream)
Expand Down
47 changes: 35 additions & 12 deletions packages/sync-service/lib/electric/shapes/api/response.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ defmodule Electric.Shapes.Api.Response do
defp put_resp_headers(conn, response) do
conn
|> put_cache_headers(response)
|> put_cursor_headers(response)
|> put_etag_headers(response)
|> put_location_header(response)
|> put_shape_handle_header(response)
Expand Down Expand Up @@ -152,7 +153,7 @@ defmodule Electric.Shapes.Api.Response do
conn
end

defp put_cache_headers(conn, %__MODULE__{} = response) do
defp put_cache_headers(conn, %__MODULE__{api: api} = response) do
case response do
# 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
Expand All @@ -161,31 +162,53 @@ defmodule Electric.Shapes.Api.Response do
# log.
%{params: %{offset: @before_all_offset}} ->
conn
|> Plug.Conn.put_resp_header(
|> put_cache_header(
"cache-control",
"public, max-age=604800, s-maxage=3600, stale-while-revalidate=2629746"
"public, max-age=604800, s-maxage=3600, stale-while-revalidate=2629746",
api
)

# For live requests we want short cache lifetimes and to update the live cursor
%{params: %{live: true}, api: api} ->
conn
|> Plug.Conn.put_resp_header(
|> put_cache_header(
"cache-control",
"public, max-age=5, stale-while-revalidate=5",
api
)

%{params: %{live: false}, api: api} ->
conn
|> put_cache_header(
"cache-control",
"public, max-age=5, stale-while-revalidate=5"
"public, max-age=#{api.max_age}, stale-while-revalidate=#{api.stale_age}",
api
)
end
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

defp put_cursor_headers(conn, %__MODULE__{} = response) do
case response do
# For live requests we want short cache lifetimes and to update the live cursor
%{params: %{live: true}, api: api} ->
conn
|> Plug.Conn.put_resp_header(
"electric-cursor",
api.long_poll_timeout
|> Utils.get_next_interval_timestamp(conn.query_params["cursor"])
|> Integer.to_string()
)

%{params: %{live: false}, api: api} ->
_response ->
conn
|> Plug.Conn.put_resp_header(
"cache-control",
"public, max-age=#{api.max_age}, stale-while-revalidate=#{api.stale_age}"
)
end
end

Expand Down Expand Up @@ -214,8 +237,8 @@ defmodule Electric.Shapes.Api.Response do
Plug.Conn.put_resp_header(conn, "electric-offset", "#{offset}")
end

defp send_stream(%Plug.Conn{} = conn, %__MODULE__{body: stream, status: status}) do
stack_id = Api.stack_id(conn.assigns.request)
defp send_stream(%Plug.Conn{} = conn, %__MODULE__{body: stream, status: status} = response) do
stack_id = Api.stack_id(response)
conn = Plug.Conn.send_chunked(conn, status)

{conn, bytes_sent} =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,12 @@ defmodule Electric.Plug.ServeShapePlugTest do
assert Plug.Conn.get_resp_header(conn, "electric-offset") == [next_offset_str]
assert Plug.Conn.get_resp_header(conn, "electric-up-to-date") == [""]
assert Plug.Conn.get_resp_header(conn, "electric-schema") == []

expected_cursor =
Electric.Plug.Utils.get_next_interval_timestamp(long_poll_timeout(ctx), nil)
|> to_string()

assert {"electric-cursor", expected_cursor} in conn.resp_headers
end

test "handles shape rotation", ctx do
Expand Down
46 changes: 45 additions & 1 deletion packages/sync-service/test/electric/shapes/api_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defmodule Electric.Shapes.ApiTest do
use ExUnit.Case, async: true
use Plug.Test

alias Electric.Postgres.Lsn
alias Electric.Replication.LogOffset
Expand Down Expand Up @@ -60,7 +61,8 @@ defmodule Electric.Shapes.ApiTest do
max_age: max_age(ctx),
stale_age: stale_age(ctx),
allow_shape_deletion: true,
encoder: Electric.Shapes.Api.Encoder.Term,
send_cache_headers?: send_cache_headers?(ctx),
encoder: api_encoder(ctx),
persistent_kv: ctx.persistent_kv
)
end
Expand All @@ -75,6 +77,8 @@ defmodule Electric.Shapes.ApiTest do
defp max_age(ctx), do: Access.get(ctx, :max_age, 60)
defp stale_age(ctx), do: Access.get(ctx, :stale_age, 300)
defp long_poll_timeout(ctx), do: Access.get(ctx, :long_poll_timeout, 20_000)
defp send_cache_headers?(ctx), do: Access.get(ctx, :send_cache_headers?, true)
defp api_encoder(ctx), do: Access.get(ctx, :api_encoder, Electric.Shapes.Api.Encoder.Term)

setup :verify_on_exit!

Expand Down Expand Up @@ -405,6 +409,46 @@ defmodule Electric.Shapes.ApiTest do
}
]
end

@tag send_cache_headers?: false
@tag api_encoder: Electric.Shapes.Api.Encoder.JSON
test "doesn't send cache headers when configured", ctx do
test_shape_handle = "test-shape-without-deltas"
next_offset = LogOffset.increment(@first_offset)

Mock.ShapeCache
|> expect(:get_or_create_shape_handle, fn %{root_table: {"public", "users"}, replica: :full},
_opts ->
{test_shape_handle, @test_offset}
end)
|> stub(:has_shape?, fn ^test_shape_handle, _opts -> true end)
|> expect(:await_snapshot_start, fn ^test_shape_handle, _ -> :started end)

Mock.Storage
|> stub(:for_shape, fn ^test_shape_handle, _opts -> @test_opts end)
|> expect(:get_chunk_end_log_offset, fn @before_all_offset, _ ->
next_offset
end)
|> expect(:get_log_stream, fn @before_all_offset, _, @test_opts ->
[Jason.encode!(%{key: "log", value: "foo", headers: %{}, offset: next_offset})]
end)

assert {:ok, request} =
Api.validate(
ctx.api,
%{
table: "public.users",
offset: "-1",
replica: "full"
}
)

assert response = Api.serve_shape_log(conn(:get, "/"), request)
assert response.status == 200

assert ["max-age=0, private, must-revalidate"] =
Plug.Conn.get_resp_header(response, "cache-control")
end
end

describe "validate_for_delete/2" do
Expand Down

0 comments on commit ac9af08

Please sign in to comment.