prop/lib/microwaveprop/workers/contact_import_worker.ex
2026-06-16 12:38:08 -05:00

159 lines
5 KiB
Elixir

defmodule Microwaveprop.Workers.ContactImportWorker do
@moduledoc """
Processes one chunk of rows out of an `ImportRun` — the async half of
the CSV import flow.
Each job receives an `import_run_id`, an `offset`, and a `limit`. The
worker loads the run, takes that slice of rows, routes them through
`CsvImport.commit_rows/1`, atomically bumps the per-run counters, and
broadcasts the updated run on `"csv_import:<id>"` so the submitting
LiveView can reflect progress.
Counters are updated with a single `UPDATE ... SET col = col + ?`
query so concurrent workers finishing at the same time can't clobber
each other. The worker that pushes `processed_rows` past `total_rows`
flips `status` to `"complete"` and sets `completed_at`; any worker
that sees `status = 'pending'` on its first increment flips it to
`"running"` and sets `started_at`.
"""
use Oban.Pro.Worker,
queue: :contact_import,
max_attempts: 3,
unique: [
period: :infinity,
fields: [:worker, :args],
keys: [:import_run_id, :offset],
states: [:available, :scheduled, :executing, :retryable, :suspended, :completed]
]
import Ecto.Query
alias Microwaveprop.Radio.CsvImport
alias Microwaveprop.Radio.ImportRun
alias Microwaveprop.Repo
require Logger
@pubsub Microwaveprop.PubSub
@impl Oban.Pro.Worker
def process(%Oban.Job{args: %{"import_run_id" => run_id, "offset" => offset, "limit" => limit}}) do
case Repo.get(ImportRun, run_id) do
nil ->
Logger.warning("ContactImportWorker: import_run #{run_id} not found; discarding chunk")
:ok
%ImportRun{} = run ->
process_chunk(run, offset, limit)
end
end
defp process_chunk(run, offset, limit) do
all_rows = get_in(run.rows, ["rows"]) || []
chunk = Enum.slice(all_rows, offset, limit)
commit_input = build_commit_input(chunk)
commit_result = CsvImport.commit_rows(commit_input)
updates = build_counter_updates(commit_result, length(chunk))
updated_run = apply_chunk_updates(run.id, updates)
_ = broadcast(run.id, updated_run)
:ok
end
defp build_commit_input(chunk) do
{valid, refinements} =
Enum.reduce(chunk, {[], []}, fn row, {valid, refinements} ->
case CsvImport.decode_row(row) do
{:valid, decoded} -> {[decoded | valid], refinements}
{:refinement, decoded} -> {valid, [decoded | refinements]}
end
end)
%{valid: Enum.reverse(valid), refinements: Enum.reverse(refinements)}
end
defp build_counter_updates(commit_result, chunk_len) do
errors_map =
Map.new(commit_result.errors, fn {row_num, messages} ->
{to_string(row_num), messages}
end)
%{
chunk_len: chunk_len,
imported_count: length(commit_result.imported),
refined_count: length(commit_result.refined),
error_count: length(commit_result.errors),
errors_map: errors_map
}
end
# Atomic per-chunk update. All counter fields are incremented in one
# UPDATE so concurrent chunk-finishers can't clobber each other, and
# the status / timestamp transitions are expressed as CASEs off the
# pre-increment values.
defp apply_chunk_updates(run_id, u) do
now = DateTime.utc_now()
chunk_len = u.chunk_len
imported_count = u.imported_count
refined_count = u.refined_count
error_count = u.error_count
errors_map = u.errors_map
query =
from(r in ImportRun,
where: r.id == ^run_id,
update: [
set: [
updated_at: ^now,
processed_rows: fragment("? + ?", r.processed_rows, ^chunk_len),
imported_count: fragment("? + ?", r.imported_count, ^imported_count),
refined_count: fragment("? + ?", r.refined_count, ^refined_count),
error_count: fragment("? + ?", r.error_count, ^error_count),
status:
fragment(
"""
CASE
WHEN ? + ? >= ? THEN 'complete'
WHEN ? = 'pending' THEN 'running'
ELSE ?
END
""",
r.processed_rows,
^chunk_len,
r.total_rows,
r.status,
r.status
),
started_at:
fragment(
"CASE WHEN ? IS NULL THEN ? ELSE ? END",
r.started_at,
^now,
r.started_at
),
completed_at:
fragment(
"CASE WHEN ? + ? >= ? THEN ? ELSE ? END",
r.processed_rows,
^chunk_len,
r.total_rows,
^now,
r.completed_at
),
errors: fragment("? || ?::jsonb", r.errors, ^errors_map)
]
]
)
{1, _} = Repo.update_all(query, [])
Repo.get!(ImportRun, run_id)
end
defp broadcast(run_id, run) do
Phoenix.PubSub.broadcast(@pubsub, "csv_import:#{run_id}", {:import_progress, run_id, run})
end
end