Skip to content

Commit c8b52de

Browse files
Merge pull request #171 from appunite/feat/lazy-sidecar-startup
feat: accept traffic before sidecars are ready on cold start
2 parents 0c509d0 + 05d3707 commit c8b52de

9 files changed

Lines changed: 123 additions & 19 deletions

File tree

cloud-run/service.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ spec:
1313
autoscaling.knative.dev/minScale: "0"
1414
autoscaling.knative.dev/maxScale: "2"
1515
run.googleapis.com/execution-environment: gen2
16-
run.googleapis.com/container-dependencies: '{"ksef-hub":["pdf-renderer","invoice-extractor","invoice-classifier"]}'
1716
run.googleapis.com/startup-cpu-boost: "true"
1817
spec:
1918
containerConcurrency: 80

config/test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ config :ksef_hub, :invoice_classifier, KsefHub.InvoiceClassifier.Mock
4545
config :ksef_hub, :invoice_extractor, KsefHub.InvoiceExtractor.Mock
4646
config :ksef_hub, :emoji_generator, KsefHub.EmojiGenerator.Mock
4747

48+
config :ksef_hub, extractor_retry_delay_ms: 0
49+
4850
config :ksef_hub,
4951
invoice_extractor_req_options: [
5052
plug: {Req.Test, KsefHub.InvoiceExtractor.Client},

lib/ksef_hub/invoice_extractor/client.ex

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule KsefHub.InvoiceExtractor.Client do
1111
require Logger
1212

1313
@receive_timeout 120_000
14+
@max_retries 3
1415
@output_schema Path.join(__DIR__, "output_schema.json")
1516
|> File.read!()
1617
|> Jason.decode!()
@@ -24,7 +25,7 @@ defmodule KsefHub.InvoiceExtractor.Client do
2425
{:ok, token} <- fetch_token() do
2526
filename = opts |> Keyword.get(:filename, "invoice.pdf") |> sanitize_filename()
2627
context = Keyword.get(opts, :context)
27-
do_extract(base_url, token, pdf_binary, filename, context)
28+
do_extract(base_url, token, pdf_binary, filename, context, @max_retries)
2829
end
2930
end
3031

@@ -51,16 +52,32 @@ defmodule KsefHub.InvoiceExtractor.Client do
5152
end
5253
end
5354

54-
@spec do_extract(String.t(), String.t(), binary(), String.t(), String.t() | nil) ::
55+
@spec do_extract(
56+
String.t(),
57+
String.t(),
58+
binary(),
59+
String.t(),
60+
String.t() | nil,
61+
non_neg_integer()
62+
) ::
5563
{:ok, map()} | {:error, term()}
56-
defp do_extract(base_url, token, pdf_binary, filename, context) do
57-
case base_url
58-
|> build_req()
59-
|> Req.post(
60-
url: "/extract",
61-
form_multipart: build_form_parts(pdf_binary, filename, context),
62-
headers: [{"authorization", "Bearer #{token}"}]
63-
) do
64+
defp do_extract(base_url, token, pdf_binary, filename, context, retries) do
65+
result =
66+
base_url
67+
|> build_req()
68+
|> Req.post(
69+
url: "/extract",
70+
form_multipart: build_form_parts(pdf_binary, filename, context),
71+
headers: [{"authorization", "Bearer #{token}"}]
72+
)
73+
74+
case result do
75+
{:error, %Req.TransportError{reason: :econnrefused}} when retries > 0 ->
76+
delay = Application.get_env(:ksef_hub, :extractor_retry_delay_ms, 1_000)
77+
Logger.warning("Invoice extractor not ready, retrying in #{delay}ms (#{retries} left)")
78+
Process.sleep(delay)
79+
do_extract(base_url, token, pdf_binary, filename, context, retries - 1)
80+
6481
{:ok, %{status: 200, body: %{"success" => true, "data" => data}}} when is_map(data) ->
6582
{:ok, data}
6683

@@ -69,7 +86,6 @@ defmodule KsefHub.InvoiceExtractor.Client do
6986

7087
{:ok, %{status: status}} ->
7188
Logger.error("Invoice extractor returned #{status} for /extract")
72-
7389
{:error, {:extractor_error, status}}
7490

7591
{:error, %{__struct__: struct_name} = reason} ->

lib/ksef_hub/service_health.ex

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,28 @@ defmodule KsefHub.ServiceHealth do
1515
"""
1616
@spec check_all() :: results()
1717
def check_all do
18-
services = [
18+
check_services([
1919
{:pdf_renderer, &pdf_renderer().health/0},
2020
{:invoice_extractor, &invoice_extractor().health/0},
2121
{:invoice_classifier, &invoice_classifier().health/0}
22-
]
22+
])
23+
end
24+
25+
@doc """
26+
Warms the sidecars required for PDF upload processing (extractor + classifier).
27+
28+
Called on the upload LiveView mount to use idle file-browsing time productively.
29+
"""
30+
@spec warm_upload_sidecars() :: results()
31+
def warm_upload_sidecars do
32+
check_services([
33+
{:invoice_extractor, &invoice_extractor().health/0},
34+
{:invoice_classifier, &invoice_classifier().health/0}
35+
])
36+
end
2337

38+
@spec check_services([{atom(), (-> {:ok, map()} | {:error, term()})}]) :: results()
39+
defp check_services(services) do
2440
services
2541
|> Enum.map(fn {name, check_fn} ->
2642
{name, Task.async(fn -> safe_check(check_fn) end)}

lib/ksef_hub_web/live/invoice_live/show.ex

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ defmodule KsefHubWeb.InvoiceLive.Show do
106106
payment_status: payment_status,
107107
public_link: public_link,
108108
invoice_payment_requests: invoice_payment_requests,
109-
html_preview: generate_preview(invoice),
109+
html_preview: nil,
110+
preview_ref: nil,
111+
preview_loading: false,
110112
categories: Invoices.list_categories(company.id),
111113
editing: auto_edit && can_mutate,
112114
edit_form: build_edit_form(invoice),
@@ -128,10 +130,27 @@ defmodule KsefHubWeb.InvoiceLive.Show do
128130
tag_confidence_threshold: InvoiceClassifier.tag_confidence_threshold()
129131
)
130132
|> stream(:activity_log, activity_entries)
133+
|> start_preview_task(invoice)
131134
|> refresh_tabs()}
132135
end
133136
end
134137

138+
@spec start_preview_task(Phoenix.LiveView.Socket.t(), Invoice.t()) ::
139+
Phoenix.LiveView.Socket.t()
140+
defp start_preview_task(socket, %{xml_file: %{content: content}} = invoice)
141+
when is_binary(content) and content != "" do
142+
if connected?(socket) do
143+
task =
144+
Task.Supervisor.async_nolink(KsefHub.TaskSupervisor, fn -> generate_preview(invoice) end)
145+
146+
assign(socket, preview_ref: task.ref, preview_loading: true)
147+
else
148+
socket
149+
end
150+
end
151+
152+
defp start_preview_task(socket, _invoice), do: socket
153+
135154
@spec refresh_tabs(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
136155
defp refresh_tabs(socket), do: assign(socket, :visible_tabs, visible_tabs(socket.assigns))
137156

@@ -845,6 +864,16 @@ defmodule KsefHubWeb.InvoiceLive.Show do
845864
|> put_flash(:error, "Re-extraction crashed. Please try again.")}
846865
end
847866

867+
def handle_info({ref, html}, %{assigns: %{preview_ref: ref}} = socket) when is_reference(ref) do
868+
Process.demonitor(ref, [:flush])
869+
{:noreply, assign(socket, html_preview: html, preview_ref: nil, preview_loading: false)}
870+
end
871+
872+
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{assigns: %{preview_ref: ref}} = socket)
873+
when is_reference(ref) do
874+
{:noreply, assign(socket, preview_ref: nil, preview_loading: false)}
875+
end
876+
848877
def handle_info({:new_activity, audit_log}, socket) do
849878
{:noreply,
850879
socket
@@ -1270,6 +1299,12 @@ defmodule KsefHubWeb.InvoiceLive.Show do
12701299
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
12711300
Preview
12721301
</h2>
1302+
<div
1303+
:if={@preview_loading}
1304+
class="flex items-center justify-center flex-1 min-h-[600px] text-muted-foreground text-sm"
1305+
>
1306+
Loading preview&hellip;
1307+
</div>
12731308
<div
12741309
:if={@html_preview}
12751310
class="border border-border rounded-lg overflow-hidden flex-1 min-h-[600px]"
@@ -1283,7 +1318,7 @@ defmodule KsefHubWeb.InvoiceLive.Show do
12831318
</iframe>
12841319
</div>
12851320
<div
1286-
:if={!@html_preview && @invoice.pdf_file}
1321+
:if={!@preview_loading && !@html_preview && @invoice.pdf_file}
12871322
class="border border-border rounded-lg overflow-hidden flex-1 min-h-[600px]"
12881323
>
12891324
<iframe
@@ -1294,7 +1329,7 @@ defmodule KsefHubWeb.InvoiceLive.Show do
12941329
</iframe>
12951330
</div>
12961331
<p
1297-
:if={!@html_preview && !@invoice.pdf_file}
1332+
:if={!@preview_loading && !@html_preview && !@invoice.pdf_file}
12981333
class="text-muted-foreground text-sm"
12991334
>
13001335
No preview available. XML content may be missing.

lib/ksef_hub_web/live/invoice_live/upload.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule KsefHubWeb.InvoiceLive.Upload do
1212
alias KsefHub.Authorization
1313
alias KsefHub.Companies.Company
1414
alias KsefHub.Invoices
15+
alias KsefHub.ServiceHealth
1516

1617
import KsefHubWeb.UploadHelpers, only: [format_bytes: 1, upload_error_to_string: 1]
1718

@@ -20,6 +21,12 @@ defmodule KsefHubWeb.InvoiceLive.Upload do
2021
@spec mount(map(), map(), Phoenix.LiveView.Socket.t()) ::
2122
{:ok, Phoenix.LiveView.Socket.t()}
2223
def mount(_params, _session, socket) do
24+
if connected?(socket) do
25+
Task.Supervisor.start_child(KsefHub.TaskSupervisor, fn ->
26+
ServiceHealth.warm_upload_sidecars()
27+
end)
28+
end
29+
2330
{:ok,
2431
socket
2532
|> assign(page_title: "Upload PDF Invoice", uploading: false, upload_refs: MapSet.new())

test/ksef_hub/invoice_extractor/client_test.exs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,27 @@ defmodule KsefHub.InvoiceExtractor.ClientTest do
6060
Client.extract("pdf data", filename: "test.pdf")
6161
end
6262

63+
test "retries on econnrefused and succeeds when service becomes available" do
64+
setup_extractor_config()
65+
66+
{:ok, attempts} = Agent.start_link(fn -> 0 end)
67+
68+
Req.Test.stub(Client, fn conn ->
69+
attempt = Agent.get_and_update(attempts, fn n -> {n, n + 1} end)
70+
71+
if attempt == 0 do
72+
Req.Test.transport_error(conn, :econnrefused)
73+
else
74+
Req.Test.json(conn, %{"seller_nip" => "1234567890"})
75+
end
76+
end)
77+
78+
assert {:ok, %{"seller_nip" => "1234567890"}} =
79+
Client.extract("pdf data", filename: "test.pdf")
80+
81+
assert Agent.get(attempts, & &1) == 2
82+
end
83+
6384
test "sends context as form field when provided in opts" do
6485
setup_extractor_config()
6586

test/ksef_hub_web/live/invoice_live/show_test.exs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,10 @@ defmodule KsefHubWeb.InvoiceLive.ShowTest do
7676
{:ok, "<html>preview</html>"}
7777
end)
7878

79-
{:ok, _view, html} = live(conn, ~p"/c/#{company.id}/invoices/#{invoice.id}")
80-
assert html =~ "preview"
79+
{:ok, view, _html} = live(conn, ~p"/c/#{company.id}/invoices/#{invoice.id}")
80+
# Allow the async preview task to complete
81+
:timer.sleep(100)
82+
assert render(view) =~ "preview"
8183
end
8284
end
8385

test/ksef_hub_web/live/invoice_live/upload_test.exs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ defmodule KsefHubWeb.InvoiceLive.UploadTest do
1414
setup :set_mox_global
1515
setup :verify_on_exit!
1616

17+
setup do
18+
stub(KsefHub.InvoiceExtractor.Mock, :health, fn -> {:ok, %{"status" => "ok"}} end)
19+
stub(KsefHub.InvoiceClassifier.Mock, :health, fn -> {:ok, %{"status" => "ok"}} end)
20+
:ok
21+
end
22+
1723
setup %{conn: conn} do
1824
{:ok, user} =
1925
Accounts.get_or_create_google_user(%{

0 commit comments

Comments
 (0)