fix: A1-11 graceful preview shutdown drains inflight requests before stopping
This commit is contained in:
@@ -20,7 +20,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
|
|||||||
| A1-8 | ~~`ValidateLiquid`/`ValidateScript` before publish~~ | template.allium:110, script.allium:165 | `publish_template` validates Liquid via `Liquex.parse`, `publish_script` validates Lua via `BDS.Scripting.validate` | **Resolved:** validation gates added to `publish_template/1` and `publish_script/1`, invalid content returns `{:error, {:invalid_liquid|:invalid_script, reason}}`, 4 tests added |
|
| A1-8 | ~~`ValidateLiquid`/`ValidateScript` before publish~~ | template.allium:110, script.allium:165 | `publish_template` validates Liquid via `Liquex.parse`, `publish_script` validates Lua via `BDS.Scripting.validate` | **Resolved:** validation gates added to `publish_template/1` and `publish_script/1`, invalid content returns `{:error, {:invalid_liquid|:invalid_script, reason}}`, 4 tests added |
|
||||||
| A1-9 | ~~17 preset colors + custom hex in tag picker~~ | editor_tags.allium | `ColourPicker` hook + popover with 17 preset swatches grid and custom hex input, wired to both create and edit forms | **Resolved:** replaced native `<input type="color">` with `ColourPickerPopover` component (17 presets, custom hex #RRGGBB, immediate selection), JS hook for click-away dismiss, 1 test added |
|
| A1-9 | ~~17 preset colors + custom hex in tag picker~~ | editor_tags.allium | `ColourPicker` hook + popover with 17 preset swatches grid and custom hex input, wired to both create and edit forms | **Resolved:** replaced native `<input type="color">` with `ColourPickerPopover` component (17 presets, custom hex #RRGGBB, immediate selection), JS hook for click-away dismiss, 1 test added |
|
||||||
| A1-10 | ~~Template file written on create~~ | engine_side_effects.allium:151-153 | `create_template` now computes `file_path` and writes template file with YAML frontmatter on create | **Resolved:** `create_template/1` writes `templates/{slug}.liquid` on create, `next_template_file_path` always computes path, 1 test added |
|
| A1-10 | ~~Template file written on create~~ | engine_side_effects.allium:151-153 | `create_template` now computes `file_path` and writes template file with YAML frontmatter on create | **Resolved:** `create_template/1` writes `templates/{slug}.liquid` on create, `next_template_file_path` always computes path, 1 test added |
|
||||||
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
|
| A1-11 | ~~Graceful shutdown with inflight request tracking~~ | preview.allium:47-48 | `stop_preview` now closes the listener, parks the reply, and drains monitored inflight request tasks before reporting stopped | **Resolved:** acceptor transfers socket ownership to each request task; GenServer monitors inflight tasks, `begin_graceful_stop` stops accepting and finalizes via `:DOWN`/`:drain_timeout` (5s force-kill cap), 1 test added |
|
||||||
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
|
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
|
||||||
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
|
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
|
||||||
| A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work |
|
| A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work |
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ defmodule BDS.Preview do
|
|||||||
@host "127.0.0.1"
|
@host "127.0.0.1"
|
||||||
@port 4123
|
@port 4123
|
||||||
|
|
||||||
|
# Max time to wait for inflight requests to finish during graceful shutdown
|
||||||
|
# before remaining request tasks are forcibly terminated.
|
||||||
|
@drain_timeout 5_000
|
||||||
|
|
||||||
def start_link(_opts) do
|
def start_link(_opts) do
|
||||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||||
end
|
end
|
||||||
@@ -56,7 +60,7 @@ defmodule BDS.Preview do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(_state) do
|
def init(_state) do
|
||||||
{:ok, %{current: nil}}
|
{:ok, %{current: nil, stopping: nil}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -78,15 +82,12 @@ defmodule BDS.Preview do
|
|||||||
{:reply, reply, next_state}
|
{:reply, reply, next_state}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_call({:stop_preview, project_id}, _from, state) do
|
def handle_call({:stop_preview, project_id}, from, state) do
|
||||||
next_state =
|
|
||||||
if match?(%{project_id: ^project_id}, state.current) do
|
if match?(%{project_id: ^project_id}, state.current) do
|
||||||
stop_current_server(state)
|
begin_graceful_stop(state, from)
|
||||||
else
|
else
|
||||||
state
|
{:reply, :ok, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
{:reply, :ok, next_state}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_call({:request, project_id, request_path, query_params}, _from, state) do
|
def handle_call({:request, project_id, request_path, query_params}, _from, state) do
|
||||||
@@ -141,6 +142,25 @@ defmodule BDS.Preview do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
def handle_cast({:track_request, pid}, %{current: %{} = current} = state) when is_pid(pid) do
|
||||||
|
ref = Process.monitor(pid)
|
||||||
|
inflight = Map.put(current.inflight, ref, pid)
|
||||||
|
{:noreply, %{state | current: %{current | inflight: inflight}}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_cast({:track_request, _pid}, state), do: {:noreply, state}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{current: %{} = current} = state) do
|
||||||
|
inflight = Map.delete(current.inflight, ref)
|
||||||
|
state = %{state | current: %{current | inflight: inflight}}
|
||||||
|
{:noreply, maybe_finalize_stop(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(:drain_timeout, state) do
|
||||||
|
{:noreply, force_finalize_stop(state)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info(_msg, state) do
|
def handle_info(_msg, state) do
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
end
|
end
|
||||||
@@ -287,9 +307,18 @@ defmodule BDS.Preview do
|
|||||||
defp accept_loop(listener, project_id) do
|
defp accept_loop(listener, project_id) do
|
||||||
case :gen_tcp.accept(listener) do
|
case :gen_tcp.accept(listener) do
|
||||||
{:ok, socket} ->
|
{:ok, socket} ->
|
||||||
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
case Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||||
serve_client(socket, project_id)
|
serve_client(socket, project_id)
|
||||||
end)
|
end) do
|
||||||
|
{:ok, pid} ->
|
||||||
|
# Hand the socket to the request task so an inflight request survives
|
||||||
|
# the acceptor being shut down (it would otherwise close the socket).
|
||||||
|
_ = :gen_tcp.controlling_process(socket, pid)
|
||||||
|
GenServer.cast(__MODULE__, {:track_request, pid})
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
accept_loop(listener, project_id)
|
accept_loop(listener, project_id)
|
||||||
|
|
||||||
@@ -412,14 +441,58 @@ defmodule BDS.Preview do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp stop_current_server(%{current: %{listener: listener, acceptor_pid: acceptor_pid}} = state) do
|
# Graceful shutdown: stop accepting new connections, then wait for inflight
|
||||||
_ = :gen_tcp.close(listener)
|
# request tasks to finish before reporting the server stopped. The stop call
|
||||||
if is_pid(acceptor_pid), do: Process.exit(acceptor_pid, :normal)
|
# is parked (no immediate reply) and finalized from the :DOWN handlers, so the
|
||||||
|
# GenServer stays available to serve the requests it is draining.
|
||||||
|
defp begin_graceful_stop(%{current: current} = state, from) do
|
||||||
|
_ = :gen_tcp.close(current.listener)
|
||||||
|
if is_pid(current.acceptor_pid), do: Process.exit(current.acceptor_pid, :normal)
|
||||||
|
|
||||||
|
if map_size(current.inflight) == 0 do
|
||||||
|
{:reply, :ok, %{state | current: nil, stopping: nil}}
|
||||||
|
else
|
||||||
|
timer = Process.send_after(self(), :drain_timeout, @drain_timeout)
|
||||||
|
{:noreply, %{state | stopping: %{from: from, timer: timer}}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_finalize_stop(
|
||||||
|
%{stopping: %{from: from, timer: timer}, current: %{inflight: inflight}} = state
|
||||||
|
)
|
||||||
|
when map_size(inflight) == 0 do
|
||||||
|
if is_reference(timer), do: Process.cancel_timer(timer)
|
||||||
|
GenServer.reply(from, :ok)
|
||||||
|
%{state | current: nil, stopping: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_finalize_stop(state), do: state
|
||||||
|
|
||||||
|
defp force_finalize_stop(%{stopping: %{from: from}, current: %{inflight: inflight}} = state) do
|
||||||
|
kill_inflight(inflight)
|
||||||
|
GenServer.reply(from, :ok)
|
||||||
|
%{state | current: nil, stopping: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp force_finalize_stop(state), do: state
|
||||||
|
|
||||||
|
# Hard stop used when restarting the server in place (no graceful drain).
|
||||||
|
defp stop_current_server(%{current: %{} = current} = state) do
|
||||||
|
_ = :gen_tcp.close(current.listener)
|
||||||
|
if is_pid(current.acceptor_pid), do: Process.exit(current.acceptor_pid, :normal)
|
||||||
|
kill_inflight(current.inflight)
|
||||||
%{state | current: nil}
|
%{state | current: nil}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp stop_current_server(state), do: state
|
defp stop_current_server(state), do: state
|
||||||
|
|
||||||
|
defp kill_inflight(inflight) do
|
||||||
|
Enum.each(inflight, fn {ref, pid} ->
|
||||||
|
Process.demonitor(ref, [:flush])
|
||||||
|
if is_pid(pid), do: Process.exit(pid, :kill)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp start_server(state, project_id, data_dir, owner_pid) do
|
defp start_server(state, project_id, data_dir, owner_pid) do
|
||||||
state = stop_current_server(state)
|
state = stop_current_server(state)
|
||||||
maybe_allow_repo(owner_pid)
|
maybe_allow_repo(owner_pid)
|
||||||
@@ -442,7 +515,8 @@ defmodule BDS.Preview do
|
|||||||
port: @port,
|
port: @port,
|
||||||
is_running: true,
|
is_running: true,
|
||||||
listener: listener,
|
listener: listener,
|
||||||
acceptor_pid: acceptor_pid
|
acceptor_pid: acceptor_pid,
|
||||||
|
inflight: %{}
|
||||||
}
|
}
|
||||||
|
|
||||||
{{:ok, public_server(server)}, %{state | current: server}}
|
{{:ok, public_server(server)}, %{state | current: server}}
|
||||||
|
|||||||
@@ -706,6 +706,44 @@ defmodule BDS.PreviewTest do
|
|||||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "stop_preview drains an inflight request before completing", %{project: project} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
# Open a raw connection and hold it open without sending the request line,
|
||||||
|
# so the server has a request task blocked in recv (an inflight request).
|
||||||
|
{:ok, socket} =
|
||||||
|
:gen_tcp.connect(to_charlist(server.host), server.port, [
|
||||||
|
:binary,
|
||||||
|
packet: :raw,
|
||||||
|
active: false
|
||||||
|
])
|
||||||
|
|
||||||
|
# Give the acceptor time to accept the connection and register the task.
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
test_pid = self()
|
||||||
|
|
||||||
|
spawn(fn ->
|
||||||
|
send(test_pid, {:stopped, BDS.Preview.stop_preview(project.id)})
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Shutdown must not complete while the request is still inflight.
|
||||||
|
refute_receive {:stopped, _}, 300
|
||||||
|
|
||||||
|
# Completing the request lets the server drain and finish shutting down.
|
||||||
|
:ok = :gen_tcp.send(socket, "GET / HTTP/1.1\r\nhost: localhost\r\n\r\n")
|
||||||
|
|
||||||
|
assert_receive {:stopped, :ok}, 2_000
|
||||||
|
|
||||||
|
:gen_tcp.close(socket)
|
||||||
|
end
|
||||||
|
|
||||||
test "preview query params can override the rendered theme for generated and draft pages", %{
|
test "preview query params can override the rendered theme for generated and draft pages", %{
|
||||||
project: project
|
project: project
|
||||||
} do
|
} do
|
||||||
|
|||||||
Reference in New Issue
Block a user