From 1aa07c5ee71dbe958412df11d423a5a9e5d064fb Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:33:58 -1000 Subject: [PATCH 01/16] fix(sequin): skip sentry.package_source_code for self-hosted builds (#1084) --- Dockerfile | 4 +- lib/sequin/consumers/meilisearch_sink.ex | 6 ++- lib/sequin/sinks/meilisearch/client.ex | 10 ++++- lib/sequin/transforms/transforms.ex | 6 ++- test/sequin/meilisearch_client_test.exs | 53 ++++++++++++++++++++++++ test/sequin/meilisearch_sink_test.exs | 16 +++++++ 6 files changed, 88 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7040d9c34..b47aae904 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,8 +86,8 @@ ENV RELEASE_VERSION=${RELEASE_VERSION} # Compile the release RUN mix compile -# Ensure stacktraces we send to Sentry are complete -RUN mix sentry.package_source_code +# Ensure stacktraces we send to Sentry are complete (skip for self-hosted — no DSN) +RUN if [ "$SELF_HOSTED" != "1" ]; then mix sentry.package_source_code; fi # Changes to config/runtime.exs don't require recompiling the code COPY config/runtime.exs config/ diff --git a/lib/sequin/consumers/meilisearch_sink.ex b/lib/sequin/consumers/meilisearch_sink.ex index b7a926052..9f3f6045a 100644 --- a/lib/sequin/consumers/meilisearch_sink.ex +++ b/lib/sequin/consumers/meilisearch_sink.ex @@ -5,7 +5,7 @@ defmodule Sequin.Consumers.MeilisearchSink do import Ecto.Changeset - @derive {Jason.Encoder, only: [:endpoint_url, :index_name, :primary_key]} + @derive {Jason.Encoder, only: [:endpoint_url, :index_name, :primary_key, :document_mode]} @derive {Inspect, except: [:api_key]} @primary_key false @@ -18,6 +18,7 @@ defmodule Sequin.Consumers.MeilisearchSink do field(:batch_size, :integer, default: 100) field(:timeout_seconds, :integer, default: 5) field(:routing_mode, Ecto.Enum, values: [:dynamic, :static]) + field(:document_mode, Ecto.Enum, values: [:replace, :update], default: :replace) end def changeset(struct, params) do @@ -29,7 +30,8 @@ defmodule Sequin.Consumers.MeilisearchSink do :api_key, :batch_size, :timeout_seconds, - :routing_mode + :routing_mode, + :document_mode ]) |> validate_required([:endpoint_url, :api_key]) |> validate_routing() diff --git a/lib/sequin/sinks/meilisearch/client.ex b/lib/sequin/sinks/meilisearch/client.ex index 26effd614..3e9fec1e6 100644 --- a/lib/sequin/sinks/meilisearch/client.ex +++ b/lib/sequin/sinks/meilisearch/client.ex @@ -110,7 +110,15 @@ defmodule Sequin.Sinks.Meilisearch.Client do body: jsonl ) - case Req.post(req) do + # :replace (default) uses POST = add or replace (full document replacement) + # :update uses PUT = add or update (partial merge — only overwrites fields present) + result = + case sink.document_mode do + :update -> Req.put(req) + _ -> Req.post(req) + end + + case result do {:ok, %{body: %{"taskUid" => task_id}}} -> wait_for_task(sink, task_id) diff --git a/lib/sequin/transforms/transforms.ex b/lib/sequin/transforms/transforms.ex index ebd12dffc..e4d6c39e1 100644 --- a/lib/sequin/transforms/transforms.ex +++ b/lib/sequin/transforms/transforms.ex @@ -413,7 +413,8 @@ defmodule Sequin.Transforms do index_name: sink.index_name, primary_key: sink.primary_key, api_key: SensitiveValue.new(sink.api_key, show_sensitive), - timeout_seconds: sink.timeout_seconds + timeout_seconds: sink.timeout_seconds, + document_mode: if(sink.document_mode != :replace, do: to_string(sink.document_mode)) }) end @@ -1305,7 +1306,8 @@ defmodule Sequin.Transforms do primary_key: attrs["primary_key"], api_key: attrs["api_key"], batch_size: attrs["batch_size"], - timeout_seconds: attrs["timeout_seconds"] + timeout_seconds: attrs["timeout_seconds"], + document_mode: attrs["document_mode"] }} end diff --git a/test/sequin/meilisearch_client_test.exs b/test/sequin/meilisearch_client_test.exs index d6441c692..46071d589 100644 --- a/test/sequin/meilisearch_client_test.exs +++ b/test/sequin/meilisearch_client_test.exs @@ -67,6 +67,59 @@ defmodule Sequin.Sinks.Meilisearch.ClientTest do end end + describe "import_documents/3 with document_mode: :update" do + @update_sink %MeilisearchSink{ + type: :meilisearch, + endpoint_url: "http://127.0.0.1:7700", + index_name: "test", + primary_key: "id", + api_key: "token", + document_mode: :update + } + + test "uses PUT when document_mode is :update" do + records = [SinkFactory.meilisearch_record()] + + Req.Test.expect(Client, fn conn -> + assert conn.method == "PUT" + assert conn.request_path == "/indexes/test/documents" + + Req.Test.json(conn, %{"taskUid" => 1}) + end) + + Req.Test.expect(Client, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/tasks/1" + + response_data = %{"status" => "succeeded"} + send_gzipped_response(conn, 200, response_data) + end) + + assert :ok = Client.import_documents(@update_sink, "test", records) + end + + test "uses POST when document_mode is :replace (default)" do + records = [SinkFactory.meilisearch_record()] + + Req.Test.expect(Client, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/indexes/test/documents" + + Req.Test.json(conn, %{"taskUid" => 1}) + end) + + Req.Test.expect(Client, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/tasks/1" + + response_data = %{"status" => "succeeded"} + send_gzipped_response(conn, 200, response_data) + end) + + assert :ok = Client.import_documents(@sink, "test", records) + end + end + describe "delete_documents/2" do test "successfully delete batch" do records = [SinkFactory.meilisearch_record(), SinkFactory.meilisearch_record()] diff --git a/test/sequin/meilisearch_sink_test.exs b/test/sequin/meilisearch_sink_test.exs index 6f797d602..c1b280aae 100644 --- a/test/sequin/meilisearch_sink_test.exs +++ b/test/sequin/meilisearch_sink_test.exs @@ -55,5 +55,21 @@ defmodule Sequin.Consumers.MeilisearchSinkTest do refute :index_name in changeset.changes end + + test "defaults document_mode to :replace", %{valid_params: params} do + changeset = MeilisearchSink.changeset(%MeilisearchSink{}, params) + assert Ecto.Changeset.get_field(changeset, :document_mode) == :replace + end + + test "accepts document_mode :update", %{valid_params: params} do + changeset = MeilisearchSink.changeset(%MeilisearchSink{}, Map.put(params, :document_mode, :update)) + assert Sequin.Error.errors_on(changeset) == %{} + assert Ecto.Changeset.get_field(changeset, :document_mode) == :update + end + + test "rejects invalid document_mode", %{valid_params: params} do + changeset = MeilisearchSink.changeset(%MeilisearchSink{}, Map.put(params, :document_mode, :invalid)) + assert Sequin.Error.errors_on(changeset)[:document_mode] != nil + end end end From 2460cfa71f2ee1851119f9c29ab4cbeddcb25eed Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 6 Apr 2026 21:39:42 -1000 Subject: [PATCH 02/16] chore: update SST-generated types --- assets/sst-env.d.ts | 10 ++++++++++ examples/sequin-cdc-to-temporal/sst-env.d.ts | 10 ++++++++++ examples/user-specific-webhooks/sst-env.d.ts | 10 ++++++++++ 3 files changed, 30 insertions(+) create mode 100644 assets/sst-env.d.ts create mode 100644 examples/sequin-cdc-to-temporal/sst-env.d.ts create mode 100644 examples/user-specific-webhooks/sst-env.d.ts diff --git a/assets/sst-env.d.ts b/assets/sst-env.d.ts new file mode 100644 index 000000000..301538ccb --- /dev/null +++ b/assets/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/examples/sequin-cdc-to-temporal/sst-env.d.ts b/examples/sequin-cdc-to-temporal/sst-env.d.ts new file mode 100644 index 000000000..9111ecdfa --- /dev/null +++ b/examples/sequin-cdc-to-temporal/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/examples/user-specific-webhooks/sst-env.d.ts b/examples/user-specific-webhooks/sst-env.d.ts new file mode 100644 index 000000000..9111ecdfa --- /dev/null +++ b/examples/user-specific-webhooks/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file From 1e70c9715acdd08494e1ffe6a2db893eb7bd4e31 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:27:55 -1000 Subject: [PATCH 03/16] fix(sequin): treat empty SENTRY_DSN as nil for self-hosted builds (#1088) --- config/prod.exs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config/prod.exs b/config/prod.exs index e9ce90920..e17a0acca 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -8,8 +8,14 @@ import Config self_hosted = System.get_env("SELF_HOSTED", "0") in ~w(1 true) +# Empty string DSN crashes Sentry — treat "" as nil for self-hosted builds +sentry_dsn = case System.get_env("SENTRY_DSN") do + "" -> nil + dsn -> dsn +end + config :sentry, - dsn: System.get_env("SENTRY_DSN"), + dsn: sentry_dsn, release: System.get_env("RELEASE_VERSION") config :sequin, Sequin.ConsoleLogger, drop_metadata_keys: [:mfa] From 18c815040782779fa3e104e0b348b69c196cd6be Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:08:13 -1000 Subject: [PATCH 04/16] chore(sequin): gitignore sst-env.d.ts and remove generated files (#1092) --- .gitignore | 3 +++ assets/sst-env.d.ts | 10 ---------- examples/sequin-cdc-to-temporal/sst-env.d.ts | 10 ---------- examples/user-specific-webhooks/sst-env.d.ts | 10 ---------- 4 files changed, 3 insertions(+), 30 deletions(-) delete mode 100644 assets/sst-env.d.ts delete mode 100644 examples/sequin-cdc-to-temporal/sst-env.d.ts delete mode 100644 examples/user-specific-webhooks/sst-env.d.ts diff --git a/.gitignore b/.gitignore index 14e883b19..0839516bd 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ test-config/ typesense-data/ meilisearch-data/ .expert/ + +# SST auto-generated type stubs (from monorepo builds that scan package.json dirs) +sst-env.d.ts diff --git a/assets/sst-env.d.ts b/assets/sst-env.d.ts deleted file mode 100644 index 301538ccb..000000000 --- a/assets/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// - -import "sst" -export {} \ No newline at end of file diff --git a/examples/sequin-cdc-to-temporal/sst-env.d.ts b/examples/sequin-cdc-to-temporal/sst-env.d.ts deleted file mode 100644 index 9111ecdfa..000000000 --- a/examples/sequin-cdc-to-temporal/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// - -import "sst" -export {} \ No newline at end of file diff --git a/examples/user-specific-webhooks/sst-env.d.ts b/examples/user-specific-webhooks/sst-env.d.ts deleted file mode 100644 index 9111ecdfa..000000000 --- a/examples/user-specific-webhooks/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// - -import "sst" -export {} \ No newline at end of file From 888bf4a8eed2f81a0f41fdd536ee79f69ecaecec Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:50:23 -1000 Subject: [PATCH 05/16] feat(sequin): disable built-in login and add Cloudflare Access (#1096) --- lib/sequin/accounts/accounts.ex | 10 ++++++ lib/sequin_web/user_auth.ex | 64 ++++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/lib/sequin/accounts/accounts.ex b/lib/sequin/accounts/accounts.ex index 26ce7f585..037823f08 100644 --- a/lib/sequin/accounts/accounts.ex +++ b/lib/sequin/accounts/accounts.ex @@ -97,6 +97,16 @@ defmodule Sequin.Accounts do """ def get_user!(id), do: Repo.get!(User, id) + def get_first_user do + User + |> Ecto.Query.first() + |> Repo.one() + |> case do + nil -> nil + user -> Repo.preload(user, [:accounts, :accounts_users]) + end + end + def get_user_with_preloads!(user_id) do User |> Repo.get!(user_id) diff --git a/lib/sequin_web/user_auth.ex b/lib/sequin_web/user_auth.ex index 2f980b44b..38cb50997 100644 --- a/lib/sequin_web/user_auth.ex +++ b/lib/sequin_web/user_auth.ex @@ -206,12 +206,19 @@ defmodule SequinWeb.UserAuth do if socket.assigns.current_user do {:cont, socket} else - socket = - socket - |> Phoenix.LiveView.put_flash(:toast, %{kind: :error, title: "Please log in to continue."}) - |> Phoenix.LiveView.redirect(to: ~p"/login") + if auth_disabled?() do + case Accounts.get_first_user() do + nil -> {:cont, socket} + user -> {:cont, Phoenix.Component.assign(socket, :current_user, user)} + end + else + socket = + socket + |> Phoenix.LiveView.put_flash(:toast, %{kind: :error, title: "Please log in to continue."}) + |> Phoenix.LiveView.redirect(to: ~p"/login") - {:halt, socket} + {:halt, socket} + end end end @@ -279,20 +286,24 @@ defmodule SequinWeb.UserAuth do if conn.assigns[:current_user] do conn else - {title, redirect_to} = - case Keyword.get(opts, :unauthenticated_redirect, :login) do - :login -> - {"Please log in to continue.", ~p"/login"} + if auth_disabled?() do + auto_login_default_user(conn) + else + {title, redirect_to} = + case Keyword.get(opts, :unauthenticated_redirect, :login) do + :login -> + {"Please log in to continue.", ~p"/login"} - :register -> - {"Please register to continue.", ~p"/register"} - end + :register -> + {"Please register to continue.", ~p"/register"} + end - conn - |> put_flash(:toast, %{kind: :error, title: title}) - |> maybe_store_return_to() - |> redirect(to: redirect_to) - |> halt() + conn + |> put_flash(:toast, %{kind: :error, title: title}) + |> maybe_store_return_to() + |> redirect(to: redirect_to) + |> halt() + end end end @@ -309,4 +320,23 @@ defmodule SequinWeb.UserAuth do defp maybe_store_return_to(conn), do: conn defp signed_in_path(_conn), do: ~p"/" + + defp auth_disabled? do + System.get_env("AUTH_DISABLED") in ~w(true 1) + end + + defp auto_login_default_user(conn) do + case Accounts.get_first_user() do + nil -> + conn + + user -> + token = Accounts.generate_user_session_token(user) + + conn + |> renew_session() + |> put_token_in_session(token) + |> assign(:current_user, user) + end + end end From a2a65495c6dac0d4fad42356962d9247c479d4ec Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:21:11 -1000 Subject: [PATCH 06/16] feat(sequin): add batch_timeout_ms to YAML parser + set 10s for ahrefs-enqueue sink (#1318) --- lib/sequin/transforms/transforms.ex | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/sequin/transforms/transforms.ex b/lib/sequin/transforms/transforms.ex index e4d6c39e1..2da46d4f8 100644 --- a/lib/sequin/transforms/transforms.ex +++ b/lib/sequin/transforms/transforms.ex @@ -861,6 +861,15 @@ defmodule Sequin.Transforms do {:halt, {:error, Error.validation(summary: "batch_size must be a positive integer <= 1000")}} end + "batch_timeout_ms" -> + case value do + ms when is_integer(ms) and ms > 0 -> + {:cont, {:ok, Map.put(acc, :batch_timeout_ms, ms)}} + + _ -> + {:halt, {:error, Error.validation(summary: "batch_timeout_ms must be a positive integer")}} + end + "actions" -> {:cont, {:ok, Map.put(acc, :actions, value)}} From ef600d0586c17c42dd4b9b92b9ce82cfeff2f9a7 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:31:40 -1000 Subject: [PATCH 07/16] fix(infra): kill Sequin per-deploy rebuild churn (.dockerignore + Date.now bomb) (#1592) --- .dockerignore | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e75724741 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# 2026-04-28: Added to stop Sequin's Docker image from rebuilding on every +# SST deploy. Root cause: SST regenerates `sst-env.d.ts` (and matching +# subpackage versions) inside this directory on every `sst deploy`. The +# files are tiny (~30 lines of TypeScript reference declarations) but they +# CHANGE on every deploy, so docker.Image v4's content-hash check sees the +# context as "changed" and rebuilds — burning ~4 minutes per ship. +# +# This .dockerignore excludes the regenerated files (Elixir doesn't need +# them anyway) plus the standard build/dev cruft that bloats the build +# context send. + +# SST-generated TypeScript declarations — regenerated every deploy +**/sst-env.d.ts + +# Standard build / dev artifacts not needed inside the container +**/node_modules +**/_build +**/deps +**/.elixir_ls +**/cover +**/erl_crash.dump +**/.git +**/.gitignore +**/.gitkeep +**/.DS_Store +**/*.log +**/*.tmp +**/tmp + +# Editor / IDE +**/.vscode +**/.idea +**/.aider* +**/CLAUDE.md From a58410214d7e08515c130f1dc190c0a870a55255 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Sun, 3 May 2026 16:05:32 -1000 Subject: [PATCH 08/16] fix(sequin): meili task verification uses timeout_seconds as deadline (#2140) (#1695) --- lib/sequin/sinks/meilisearch/client.ex | 142 +++++++++++++----------- test/sequin/meilisearch_client_test.exs | 4 +- 2 files changed, 79 insertions(+), 67 deletions(-) diff --git a/lib/sequin/sinks/meilisearch/client.ex b/lib/sequin/sinks/meilisearch/client.ex index 3e9fec1e6..0b1166fca 100644 --- a/lib/sequin/sinks/meilisearch/client.ex +++ b/lib/sequin/sinks/meilisearch/client.ex @@ -19,79 +19,91 @@ defmodule Sequin.Sinks.Meilisearch.Client do defp decode_body(_), do: %{} + # Polls Meilisearch's /tasks/{id} endpoint until the task reaches a terminal + # state (succeeded/failed) or the deadline derived from sink.timeout_seconds + # elapses. Replaces the upstream Req-retry-based loop, whose hardcoded + # 5-retry budget (~3-6s) was way under our auctions index's normal + # enqueued→finished latency (15-20s under load), causing every batch to + # appear to fail even though Meilisearch was indexing fine. + # Cherry-picked from sequinstream/sequin#2140 (PierreMesure). defp wait_for_task(%MeilisearchSink{} = sink, task_id) do - req = - sink - |> base_request() - |> Req.merge( - url: "/tasks/#{task_id}", - # We need to disable this if we use a custom retry that emits delays - retry_delay: nil, - max_retries: 5, - retry: fn request, response_or_exception -> - should_retry = - case response_or_exception do - %Req.Response{status: 200, body: encoded_body} -> - # NOTE: Req does not automatically decode on retry functions - case encoded_body |> :zlib.gunzip() |> Jason.decode() do - {:ok, %{"status" => status}} when status in ["enqueued", "processing"] -> - true - - _ -> - false - end - - %Req.Response{status: status} when status in [408, 429, 500, 502, 503, 504] -> - true - - %Req.TransportError{reason: reason} when reason in [:timeout, :econnrefused, :closed] -> - true - - _ -> - false - end - - if should_retry do - # Req tracks retry count internally via request.private[:req_retry_count] - count = request.private[:req_retry_count] || 0 - delay = Sequin.Time.exponential_backoff(200, count, 10_000) - - Logger.debug("[Meilisearch] Task #{task_id} has not succeeded, retrying in #{delay}ms (attempt #{count + 1})") - - {:delay, delay} - else - false + deadline_ms = System.monotonic_time(:millisecond) + sink.timeout_seconds * 1000 + do_wait_for_task(sink, task_id, deadline_ms, 0, nil) + end + + defp do_wait_for_task(%MeilisearchSink{} = sink, task_id, deadline_ms, attempt, last_status) do + now_ms = System.monotonic_time(:millisecond) + remaining_ms = deadline_ms - now_ms + + if remaining_ms <= 0 do + {:error, + Error.service( + service: :meilisearch, + message: "Task verification timed out", + details: %{task_id: task_id, last_status: last_status} + )} + else + req = + sink + |> base_request() + |> Req.merge( + url: "/tasks/#{task_id}", + receive_timeout: min(1_000, remaining_ms), + retry: false, + max_retries: 0 + ) + + case Req.get(req) do + {:ok, %{status: 200, body: body}} -> + decoded_body = decode_body(body) + + case decoded_body do + %{"status" => status} when status in ["succeeded", "success"] -> + :ok + + %{"status" => "failed", "error" => error} -> + message = extract_error_message(error) + {:error, Error.service(service: :meilisearch, message: message, details: error)} + + %{"status" => status} when status in ["enqueued", "processing"] -> + retry_task_poll(sink, task_id, deadline_ms, attempt, status) + + _ -> + {:error, Error.service(service: :meilisearch, message: "Invalid response format")} end - end - ) - case Req.get(req) do - {:ok, %{body: body}} -> - decoded_body = decode_body(body) + {:error, %Req.TransportError{reason: reason}} + when reason in [:timeout, :econnrefused, :closed] -> + retry_task_poll(sink, task_id, deadline_ms, attempt, last_status) - case decoded_body do - %{"status" => status} when status in ["succeeded", "success"] -> - :ok + {:ok, %{status: status}} when status in [408, 429, 500, 502, 503, 504] -> + Logger.debug("[Meilisearch] Task #{task_id} check returned #{status}, retrying") + retry_task_poll(sink, task_id, deadline_ms, attempt, last_status) - %{"status" => "failed", "error" => error} -> - message = extract_error_message(error) - {:error, Error.service(service: :meilisearch, message: message, details: error)} + {:error, reason} -> + {:error, Error.service(service: :meilisearch, message: "Unknown error", details: reason)} + end + end + end - %{"status" => status} when status in ["enqueued", "processing"] -> - # This means we exhausted retries - {:error, - Error.service( - service: :meilisearch, - message: "Task verification timed out", - details: %{task_id: task_id, last_status: status} - )} + defp retry_task_poll(%MeilisearchSink{} = sink, task_id, deadline_ms, attempt, last_status) do + now_ms = System.monotonic_time(:millisecond) + remaining_ms = deadline_ms - now_ms - _ -> - {:error, Error.service(service: :meilisearch, message: "Invalid response format")} - end + if remaining_ms <= 0 do + {:error, + Error.service( + service: :meilisearch, + message: "Task verification timed out", + details: %{task_id: task_id, last_status: last_status} + )} + else + delay = min(Sequin.Time.exponential_backoff(200, attempt, 10_000), remaining_ms) - {:error, reason} -> - {:error, Error.service(service: :meilisearch, message: "Unknown error", details: reason)} + Logger.debug("[Meilisearch] Task #{task_id} has not succeeded, retrying in #{delay}ms (attempt #{attempt + 1})") + + Process.sleep(delay) + do_wait_for_task(sink, task_id, deadline_ms, attempt + 1, last_status) end end diff --git a/test/sequin/meilisearch_client_test.exs b/test/sequin/meilisearch_client_test.exs index 46071d589..f506fed80 100644 --- a/test/sequin/meilisearch_client_test.exs +++ b/test/sequin/meilisearch_client_test.exs @@ -358,7 +358,7 @@ defmodule Sequin.Sinks.Meilisearch.ClientTest do assert_receive {:task_check, 3}, 2000 end - test "wait_for_task times out after exhausting retries" do + test "wait_for_task times out after exhausting the timeout budget" do test_pid = self() call_count = :counters.new(1, []) records = [SinkFactory.meilisearch_record()] @@ -392,7 +392,7 @@ defmodule Sequin.Sinks.Meilisearch.ClientTest do assert error.details.task_id == 456 assert error.details.last_status == "processing" - # Verify we made all 6 attempts (1 initial + 5 retries) + # Verify we polled until the total timeout budget was exhausted. for i <- 0..5 do assert_receive {:task_check, ^i}, 3_000 end From bbe403d38902769d5ac2767f47273f80e3c4e2c9 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Mon, 11 May 2026 20:24:37 -1000 Subject: [PATCH 09/16] =?UTF-8?q?fix(infra):=20deploy-speed=20=E2=80=94=20?= =?UTF-8?q?sequin=20.dockerignore=20whitelist,=20stable=20triggers,=20getS?= =?UTF-8?q?ecret=20memo=20(#1893)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 62 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/.dockerignore b/.dockerignore index e75724741..3a55300bf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,18 +1,56 @@ -# 2026-04-28: Added to stop Sequin's Docker image from rebuilding on every -# SST deploy. Root cause: SST regenerates `sst-env.d.ts` (and matching -# subpackage versions) inside this directory on every `sst deploy`. The -# files are tiny (~30 lines of TypeScript reference declarations) but they -# CHANGE on every deploy, so docker.Image v4's content-hash check sees the -# context as "changed" and rebuilds — burning ~4 minutes per ship. +# 2026-05-11: Upgraded from blacklist to *whitelist*-style. The Dockerfile +# (packages/sequin/Dockerfile) COPYs a very specific set of paths — every +# other path in this directory ships across the SSH tunnel to Hetzner +# on every deploy for zero benefit. Audit before the change: # -# This .dockerignore excludes the regenerated files (Elixir doesn't need -# them anyway) plus the standard build/dev cruft that bloats the build -# context send. +# docs/ — 28MB of mostly markdown (only docs/snippets/ +# function-transform-snippet.mdx is actually COPYd) +# test/ — 1.5MB tests, never built into the prod image +# website/ — 216KB marketing site +# examples/ — 376KB usage examples +# cli/ — 384KB Go code; the Dockerfile builds the CLI from +# `git clone` in a separate `cli-builder` stage, our +# local copy is dead weight +# deployment/ — 268KB k8s/helm templates +# *.md root — 12-32KB each, none referenced by build +# +# Stripping these cuts the build context from 39MB → ~9MB. Smaller +# context means less time spent sending bytes over SSH, fewer files for +# BuildKit to hash, and fewer paths where stat/mtime jitter can trick +# the context hash into "different" on otherwise-identical content. +# +# Original 2026-04-28 fix (sst-env.d.ts + blacklist) is preserved +# inside the explicit exclusions below. -# SST-generated TypeScript declarations — regenerated every deploy -**/sst-env.d.ts +# Exclude everything by default — whitelist below allows back what +# Dockerfile actually uses +* -# Standard build / dev artifacts not needed inside the container +# ─── Whitelist: paths the Dockerfile COPYs ──────────────────────── +!.iex.exs +!Dockerfile +!mix.exs +!mix.lock +!config +!lib +!priv +!assets +!rel +!scripts +!docs + +# Subtree restrictions inside whitelisted dirs — exclude all then +# allow back the single needed file. +docs/* +!docs/snippets +docs/snippets/* +!docs/snippets/function-transform-snippet.mdx +scripts/* +!scripts/start_commands.sh + +# ─── Re-blacklist common cruft under whitelisted dirs ───────────── +# These supersede whitelist for transient / regenerated / dev-only files. +**/sst-env.d.ts **/node_modules **/_build **/deps From e620ff0ee8f1c7e88e8575dc24b0c9ff34d4f0e8 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Thu, 14 May 2026 18:11:01 -1000 Subject: [PATCH 10/16] =?UTF-8?q?fix(sequin):=20bump=20SlotMessageStore=20?= =?UTF-8?q?caps=20=E2=80=94=2050K=E2=86=92500K=20count,=20128=E2=86=92512?= =?UTF-8?q?=20memory=20default=20(#1949)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/sequin/consumers/sink_consumer.ex | 10 +++++++++- lib/sequin/runtime/slot_message_store_state.ex | 12 +++++++++++- lib/sequin/transforms/transforms.ex | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/sequin/consumers/sink_consumer.ex b/lib/sequin/consumers/sink_consumer.ex index 13bbb61a6..b82bcd0c9 100644 --- a/lib/sequin/consumers/sink_consumer.ex +++ b/lib/sequin/consumers/sink_consumer.ex @@ -93,7 +93,15 @@ defmodule Sequin.Consumers.SinkConsumer do field :batch_size, :integer, default: 1 field :batch_timeout_ms, :integer, default: nil field :annotations, :map, default: %{} - field :max_memory_mb, :integer, default: 128 + # Default bumped 128 → 512 on 2026-05-14 (catchseo fork). Companion + # to the @default_setting_max_messages bump in slot_message_store_state.ex. + # 128 MB × 0.8 = ~107 MB usable per sink was insufficient for our + # GoDaddy inventory-sync burst (100K rows × cascade triggers). + # 512 MB × 0.8 = ~409 MB per sink; with ~6 sinks max = ~2.5 GB worst + # case on the Sequin VM, fine for Hetzner. Existing sinks still keep + # whatever they were created with — this only affects new sinks or + # explicit DB updates. + field :max_memory_mb, :integer, default: 512 field :partition_count, :integer, default: 1 field :legacy_transform, Ecto.Enum, values: [:none, :record_only], default: :none field :timestamp_format, Ecto.Enum, values: [:iso8601, :unix_microsecond], default: :iso8601 diff --git a/lib/sequin/runtime/slot_message_store_state.ex b/lib/sequin/runtime/slot_message_store_state.ex index 2603c3300..325f4724a 100644 --- a/lib/sequin/runtime/slot_message_store_state.ex +++ b/lib/sequin/runtime/slot_message_store_state.ex @@ -15,7 +15,17 @@ defmodule Sequin.Runtime.SlotMessageStore.State do @type message :: ConsumerEvent.t() @type cursor_tuple :: {commit_lsn :: non_neg_integer(), commit_idx :: non_neg_integer()} - @default_setting_max_messages 50_000 + # Bumped from upstream 50_000 → 500_000 on 2026-05-14 (catchseo fork). + # Hit during daily GoDaddy inventory-sync upserts: 100K rows × cascade + # triggers = 300-400K WAL events per ingest cycle, easily flooding the + # 50K default. With the byte-level cap (`max_memory_bytes`, default + # 80% of consumer `max_memory_mb`) still in place as the real OOM + # backstop, raising the count cap is safe — memory is bounded + # independently. Symptom before bump: ~25 `Message count limit + # exceeded` errors/min from the SlotMessageStore during inventory-sync, + # forcing Sequin into discard-on-full mode and dropping CDC events + # mid-burst. + @default_setting_max_messages 500_000 typedstruct do field :consumer, SinkConsumer.t() diff --git a/lib/sequin/transforms/transforms.ex b/lib/sequin/transforms/transforms.ex index 2da46d4f8..6c6b9c1aa 100644 --- a/lib/sequin/transforms/transforms.ex +++ b/lib/sequin/transforms/transforms.ex @@ -870,6 +870,20 @@ defmodule Sequin.Transforms do {:halt, {:error, Error.validation(summary: "batch_timeout_ms must be a positive integer")}} end + # Per-sink memory cap, exposed in catchseo fork so the YAML can + # bump it for high-volume sinks (e.g. meilisearch-auctions during + # daily inventory-sync flood). Falls back to schema default (512 + # MB in catchseo fork) when omitted. Validation matches the + # schema's `greater_than_or_equal_to: 128` rule. + "max_memory_mb" -> + case value do + mb when is_integer(mb) and mb >= 128 -> + {:cont, {:ok, Map.put(acc, :max_memory_mb, mb)}} + + _ -> + {:halt, {:error, Error.validation(summary: "max_memory_mb must be an integer >= 128")}} + end + "actions" -> {:cont, {:ok, Map.put(acc, :actions, value)}} From b34c5531367bed3632fb5ecda73b1dd8dca85be2 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Thu, 14 May 2026 18:25:56 -1000 Subject: [PATCH 11/16] fix(inventory): skip already-expired CSV rows + revert Sequin cap bumps (#1950) --- lib/sequin/consumers/sink_consumer.ex | 10 +--------- lib/sequin/runtime/slot_message_store_state.ex | 12 +----------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/lib/sequin/consumers/sink_consumer.ex b/lib/sequin/consumers/sink_consumer.ex index b82bcd0c9..13bbb61a6 100644 --- a/lib/sequin/consumers/sink_consumer.ex +++ b/lib/sequin/consumers/sink_consumer.ex @@ -93,15 +93,7 @@ defmodule Sequin.Consumers.SinkConsumer do field :batch_size, :integer, default: 1 field :batch_timeout_ms, :integer, default: nil field :annotations, :map, default: %{} - # Default bumped 128 → 512 on 2026-05-14 (catchseo fork). Companion - # to the @default_setting_max_messages bump in slot_message_store_state.ex. - # 128 MB × 0.8 = ~107 MB usable per sink was insufficient for our - # GoDaddy inventory-sync burst (100K rows × cascade triggers). - # 512 MB × 0.8 = ~409 MB per sink; with ~6 sinks max = ~2.5 GB worst - # case on the Sequin VM, fine for Hetzner. Existing sinks still keep - # whatever they were created with — this only affects new sinks or - # explicit DB updates. - field :max_memory_mb, :integer, default: 512 + field :max_memory_mb, :integer, default: 128 field :partition_count, :integer, default: 1 field :legacy_transform, Ecto.Enum, values: [:none, :record_only], default: :none field :timestamp_format, Ecto.Enum, values: [:iso8601, :unix_microsecond], default: :iso8601 diff --git a/lib/sequin/runtime/slot_message_store_state.ex b/lib/sequin/runtime/slot_message_store_state.ex index 325f4724a..2603c3300 100644 --- a/lib/sequin/runtime/slot_message_store_state.ex +++ b/lib/sequin/runtime/slot_message_store_state.ex @@ -15,17 +15,7 @@ defmodule Sequin.Runtime.SlotMessageStore.State do @type message :: ConsumerEvent.t() @type cursor_tuple :: {commit_lsn :: non_neg_integer(), commit_idx :: non_neg_integer()} - # Bumped from upstream 50_000 → 500_000 on 2026-05-14 (catchseo fork). - # Hit during daily GoDaddy inventory-sync upserts: 100K rows × cascade - # triggers = 300-400K WAL events per ingest cycle, easily flooding the - # 50K default. With the byte-level cap (`max_memory_bytes`, default - # 80% of consumer `max_memory_mb`) still in place as the real OOM - # backstop, raising the count cap is safe — memory is bounded - # independently. Symptom before bump: ~25 `Message count limit - # exceeded` errors/min from the SlotMessageStore during inventory-sync, - # forcing Sequin into discard-on-full mode and dropping CDC events - # mid-burst. - @default_setting_max_messages 500_000 + @default_setting_max_messages 50_000 typedstruct do field :consumer, SinkConsumer.t() From 432318d70bcac1abe6361e6400ac2752ad77e0fd Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Fri, 29 May 2026 21:12:00 -1000 Subject: [PATCH 12/16] feat(infra): migrate sequin to GHA+GHCR digest-pin (ADR-0059 PR2) (#2514) * feat(infra): migrate sequin to GHA+GHCR digest-pin (ADR-0059 PR2) * feat(infra): migrate sequin to GHA+GHCR digest-pin (ADR-0059 PR2) --- Dockerfile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b47aae904..74d7bfbb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,11 @@ +# ADR-0059: built in GitHub Actions, consumed by digest. This image builds with +# PACKAGE-DIR context (context: packages/sequin, set via the build workflow's +# per-image matrix) — NOT repo-root — because (a) the COPYs below are +# package-relative, and (b) packages/sequin/.dockerignore is a whitelist that +# keeps the one needed docs/snippets file, whereas the repo-root .dockerignore +# excludes packages/sequin/docs entirely. SELF_HOSTED defaults to 1 (this repo +# always self-hosts; it was the only build-arg the old dockerbuild.Image passed) +# so no workflow build-arg is needed. ARG ELIXIR_VERSION=1.19.4 ARG OTP_VERSION=28.3 ARG DEBIAN_VERSION=trixie-20251208-slim @@ -7,7 +15,7 @@ ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-$ # Ensure Elixir installed on the runtime image for tooling purposes ARG RUNNER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" -ARG SELF_HOSTED=0 +ARG SELF_HOSTED=1 # ---- CLI Build Stage ---- FROM golang:1.24.2-bullseye AS cli-builder From 9701fddfe94e77ec935ed901a1f31519aa6ab1c2 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Sat, 30 May 2026 01:36:12 -1000 Subject: [PATCH 13/16] build(infra): pin remaining Hetzner base images + FROM-pin guardrail (ADR-0059) (#2526) --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 74d7bfbb7..52698fc51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,14 +11,14 @@ ARG OTP_VERSION=28.3 ARG DEBIAN_VERSION=trixie-20251208-slim ARG RELEASE_VERSION -ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}@sha256:68e6d26c37a8f86b5ec6294cbbf034b9cab5268c7b4179f585f58a7b9c32e825" # Ensure Elixir installed on the runtime image for tooling purposes -ARG RUNNER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}@sha256:68e6d26c37a8f86b5ec6294cbbf034b9cab5268c7b4179f585f58a7b9c32e825" ARG SELF_HOSTED=1 # ---- CLI Build Stage ---- -FROM golang:1.24.2-bullseye AS cli-builder +FROM golang:1.24.2-bullseye@sha256:f50ff25f8331682b44c1582974eb9e620fcb08052fc6ed434f93ca24636fc4d6 AS cli-builder ARG SEQUIN_CLI_VERSION=0.13.1 RUN git clone --depth 1 --branch v${SEQUIN_CLI_VERSION} https://github.com/sequinstream/sequin.git /tmp/sequin \ From 028468e9733f47bd83bc180718605ae6a80c21c3 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:56:45 -1000 Subject: [PATCH 14/16] =?UTF-8?q?feat(sequin):=20dead-letter=20observabili?= =?UTF-8?q?ty=20=E2=80=94=20discard=20counter,=20warn=20log,=20critical=20?= =?UTF-8?q?alert=20(#3400)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/sequin/metrics/prometheus.ex | 12 ++++++++++++ lib/sequin/runtime/slot_message_store.ex | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/sequin/metrics/prometheus.ex b/lib/sequin/metrics/prometheus.ex index 45159dbb8..e5eb2b48e 100644 --- a/lib/sequin/metrics/prometheus.ex +++ b/lib/sequin/metrics/prometheus.ex @@ -156,6 +156,12 @@ defmodule Sequin.Prometheus do labels: [:consumer_id, :consumer_name] ) + Counter.declare( + name: :sequin_message_discard_count, + help: "Total messages permanently discarded (dead-lettered) after exhausting max_retry_count.", + labels: [:consumer_id, :consumer_name] + ) + Gauge.new( name: :sequin_messages_in_delivery, labels: [:consumer_id, :consumer_name], @@ -321,6 +327,12 @@ defmodule Sequin.Prometheus do Counter.inc([name: :sequin_message_deliver_failure_count, labels: [consumer_id, consumer_name]], count) end + @spec increment_message_discard_count(consumer_id :: String.t(), consumer_name :: String.t(), count :: number()) :: + :ok + def increment_message_discard_count(consumer_id, consumer_name, count \\ 1) do + Counter.inc([name: :sequin_message_discard_count, labels: [consumer_id, consumer_name]], count) + end + @spec observe_messages_ingested_latency(consumer_id :: String.t(), consumer_name :: String.t(), latency_ms :: number()) :: :ok def observe_messages_ingested_latency(consumer_id, consumer_name, latency_ms) do diff --git a/lib/sequin/runtime/slot_message_store.ex b/lib/sequin/runtime/slot_message_store.ex index 838caf8c1..2417f63cc 100644 --- a/lib/sequin/runtime/slot_message_store.ex +++ b/lib/sequin/runtime/slot_message_store.ex @@ -762,6 +762,16 @@ defmodule Sequin.Runtime.SlotMessageStore do Sequin.ProcessMetrics.increment_throughput("messages_discarded", length(discarded_messages)) Sequin.ProcessMetrics.gauge("message_count", map_size(state.messages)) + if length(discarded_messages) > 0 do + Prometheus.increment_message_discard_count(state.consumer.id, state.consumer.name, length(discarded_messages)) + + Logger.warning( + "[SlotMessageStore] Permanently discarding #{length(discarded_messages)} message(s) after exhausting max_retry_count (consumer=#{state.consumer.name})", + consumer_id: state.consumer.id, + partition: state.partition + ) + end + with :ok <- handle_discarded_messages(state, discarded_messages), :ok <- upsert_messages(state, messages) do state = State.put_persisted_messages(state, messages) From 71135442156067993c3bdf3a2e6bbf375e15112e Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:53:43 -1000 Subject: [PATCH 15/16] fix(sequin): refresh table cache during yaml config apply for existing databases (#3796) --- lib/sequin/yaml_loader.ex | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/sequin/yaml_loader.ex b/lib/sequin/yaml_loader.ex index 55a105e74..52e9944ae 100644 --- a/lib/sequin/yaml_loader.ex +++ b/lib/sequin/yaml_loader.ex @@ -372,6 +372,25 @@ defmodule Sequin.YamlLoader do database = Sequin.Repo.preload(database, :replication_slot) + # Re-introspect the live table list before sink validation. The stored + # struct's `.tables` cache is a snapshot from the last refresh; a config + # that references a table created since (e.g. by a migration in the same + # deploy) would otherwise fail find_table/2 at boot forever — the apply + # crashes before any background refresh can run (crash-loop observed + # live 2026-07-18 adding `ai_classification`). + database = + case Databases.update_tables(database) do + {:ok, refreshed} -> + refreshed + + {:error, error} -> + Logger.warning( + "Could not refresh tables for database '#{database.name}' before config apply: #{inspect(error)}" + ) + + database + end + with {:ok, database_attrs} <- parse_database_update_attrs(db_params, database.replication_slot) do update_database(database, database_attrs) end From dc265d244c9d33e22ca7bee2e86c956b252a1df5 Mon Sep 17 00:00:00 2001 From: DivMode <61065549+DivMode@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:57:17 -1000 Subject: [PATCH 16/16] =?UTF-8?q?ci(oke):=20split=20sequin=20Dockerfile=20?= =?UTF-8?q?deps/builder=20stages=20=E2=80=94=20each=20build=20half=20fits?= =?UTF-8?q?=20the=20disk=20floor=20(#3810)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 52698fc51..47f442c9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ RUN git clone --depth 1 --branch v${SEQUIN_CLI_VERSION} https://github.com/sequi && go build -o /sequin-cli # ---- Elixir Build Stage ---- -FROM ${BUILDER_IMAGE} AS builder +FROM ${BUILDER_IMAGE} AS deps # Pass the SELF_HOSTED arg as an environment variable ARG SELF_HOSTED @@ -67,6 +67,15 @@ RUN mkdir config COPY config/config.exs config/${MIX_ENV}.exs config/ RUN mix deps.compile +# ---- stage boundary (CI cold-build fix, 2026-07-19) ---- +# `deps` above is the heavy, rarely-invalidated half (toolchain + node + hex +# deps compile). CI builds and registry-caches it ALONE: a single-shot cold +# build's scratch breaches the build node's disk-eviction floor, but each half +# fits. `builder` inherits deps' ENV (MIX_ENV, LANG, ERL_FLAGS, SELF_HOSTED, +# SENTRY_DSN) automatically; ARG does not inherit, but none is read below +# except RELEASE_VERSION, which is re-declared where it was already. +FROM deps AS builder + COPY priv priv COPY lib lib