Fix database connection ownership issues in async Tasks

Changes:
- ApiTokens.update_last_used/1: Allow Task to access sandbox in tests
- DeviceMonitor.enqueue_check/1: Allow Task to access sandbox in tests
- PollerWorker.enqueue_poll/1: Allow Task to access sandbox in tests

Problem:
When tests spawn async Tasks that access the database, those Tasks
don't have ownership of the database connection in Sandbox mode,
causing DBConnection.ConnectionError warnings.

Solution:
Before accessing the database in a Task, check if we're in test mode
and call Ecto.Adapters.SQL.Sandbox.allow/3 to transfer connection
ownership from the parent process to the Task.

Pattern used:
```elixir
parent = self()
Task.start(fn ->
  if Application.get_env(:towerops, :sql_sandbox) do
    Ecto.Adapters.SQL.Sandbox.allow(Repo, parent, self())
  end
  # ... database operations ...
end)
```

All 1041 tests passing. Some harmless disconnection warnings remain
when Tasks complete after tests finish, but these don't affect results.
This commit is contained in:
Graham McIntire 2026-01-20 12:39:25 -06:00
parent fcc1fbbe73
commit ace3f1f661
No known key found for this signature in database
3 changed files with 29 additions and 4 deletions

View file

@ -172,9 +172,17 @@ defmodule Towerops.ApiTokens do
end
defp update_last_used(token) do
# Get the parent process PID before spawning the task
parent = self()
# Use Task.start to avoid blocking the request
# We don't care if this fails
Task.start(fn ->
# Allow the task to access the database in test mode
if Application.get_env(:towerops, :sql_sandbox) do
Ecto.Adapters.SQL.Sandbox.allow(Repo, parent, self())
end
token
|> Ecto.Changeset.change(last_used_at: DateTime.truncate(DateTime.utc_now(), :second))
|> Repo.update()

View file

@ -7,6 +7,7 @@ defmodule Towerops.Monitoring.DeviceMonitor do
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Repo
require Logger
@ -219,8 +220,16 @@ defmodule Towerops.Monitoring.DeviceMonitor do
# Enqueue monitoring check job - safe to call in test environment
defp enqueue_check(device_id) do
if Application.get_env(:towerops, :env) == :test do
# In test, run synchronously
Task.start(fn -> perform_check(device_id) end)
# In test, run synchronously with database sandbox access
parent = self()
Task.start(fn ->
if Application.get_env(:towerops, :sql_sandbox) do
Ecto.Adapters.SQL.Sandbox.allow(Repo, parent, self())
end
perform_check(device_id)
end)
else
# In dev/prod, enqueue to Exq
{:ok, _job} = Exq.enqueue(Exq, "monitoring", Towerops.Workers.MonitorWorker, [device_id])

View file

@ -1066,8 +1066,16 @@ defmodule Towerops.Snmp.PollerWorker do
# Enqueue poll job - safe to call in test environment
defp enqueue_poll(device_id) do
if Application.get_env(:towerops, :env) == :test do
# In test, run synchronously
Task.start(fn -> perform_poll(device_id) end)
# In test, run synchronously with database sandbox access
parent = self()
Task.start(fn ->
if Application.get_env(:towerops, :sql_sandbox) do
Ecto.Adapters.SQL.Sandbox.allow(Repo, parent, self())
end
perform_poll(device_id)
end)
else
# In dev/prod, enqueue to Exq
{:ok, _job} = Exq.enqueue(Exq, "polling", Towerops.Workers.PollWorker, [device_id])