Oban - dlaczego kolejka zadań na PostgreSQL to najlepsza decyzja architektoniczna w Elixirze

Każdy system, który robi cokolwiek poza wyświetlaniem stron, potrzebuje kolejki zadań. Wyślij email po złożeniu zamówienia. Wygeneruj fakturę PDF. Zsynchronizuj dane z magazynem. Przelicz raport nocny. Wyślij powiadomienie push. Zaimportuj plik CSV z 500 000 wierszami.

Te operacje muszą dziać się w tle - użytkownik nie może czekać 30 sekund na wygenerowanie PDF-a. Muszą być niezawodne - jeśli serwer padnie w trakcie wysyłania emaila, email musi zostać wysłany po restarcie. I muszą być kontrolowane - nie możesz wysłać 10 000 emaili naraz, bo dostawca poczty Cię zablokuje.

W świecie Java to Celery albo Bull. W Pythonie - Celery z Redisem albo RabbitMQ. W Elixirze to Oban - i to jest jedyna kolejka, której potrzebujesz.

Czym jest Oban

Oban to biblioteka Elixira do przetwarzania zadań w tle. Ale "biblioteka do przetwarzania zadań" to jak powiedzenie, że PostgreSQL to "program do przechowywania danych". Oban to kompletny system, który obejmuje:

  • Kolejkowanie zadań z priorytetami i limitem współbieżności
  • Gwarancja dostarczenia - zadanie nie zaginie, nawet jeśli serwer padnie
  • Retry z backoff - automatyczne ponawianie przy błędach
  • Harmonogramy (cron) - cykliczne zadania o ustalonej porze
  • Unikalne zadania - "nie twórz duplikatu, jeśli takie zadanie już czeka"
  • Historyczność - pełna historia wykonań, błędów, czasów
  • Monitorowanie - wbudowany dashboard (Oban Web)

I robi to wszystko na jednej technologii: PostgreSQL. Nie potrzebujesz Redisa. Nie potrzebujesz RabbitMQ. Nie potrzebujesz Kafki. Twoja baza danych, którą i tak masz, jest jednocześnie Twoim brokerem wiadomości.

Dlaczego PostgreSQL, a nie Redis

To jest kluczowa decyzja architektoniczna Obana i powód, dla którego go wybieramy.

Transakcyjna spójność

Oto scenariusz, który zdarza się w każdym systemie e-commerce:

  1. Użytkownik składa zamówienie
  2. System zapisuje zamówienie do bazy
  3. System dodaje zadanie "wyślij email z potwierdzeniem" do kolejki
  4. System dodaje zadanie "powiadom magazyn" do kolejki

Co się stanie, jeśli krok 2 się uda, ale serwer padnie przed krokiem 3? Zamówienie istnieje w bazie, ale email nigdy nie zostanie wysłany. Klient nie dostanie potwierdzenia. Magazyn nie wie o zamówieniu.

W systemie z Redisem jako kolejką musisz to rozwiązać sam. Transakcja do bazy i wrzucenie do Redisa to dwie osobne operacje. Nie ma gwarancji, że obie się udadzą. To jest problem znany jako "dual write" i jest koszmarnie trudny do rozwiązania poprawnie.

W Obanie zamówienie i zadania powstają w jednej transakcji PostgreSQL:

Ecto.Multi.new()
|> Ecto.Multi.insert(:zamowienie, zamowienie_changeset)
|> Oban.insert(:email, WyslijPotwierdzenie.new(%{zamowienie_id: id}))
|> Oban.insert(:magazyn, PowiadomMagazyn.new(%{zamowienie_id: id}))
|> Repo.transaction()

Jeśli INSERT zamówienia się nie powiedzie - zadania nie powstają. Jeśli którekolwiek zadanie nie może być zakolejkowane - zamówienie nie powstaje. Albo wszystko albo nic. To nie jest feature - to jest gwarancja, którą daje ACID w PostgreSQL.

Żaden system oparty na Redisie nie może dać tej gwarancji, bo Redis i PostgreSQL to dwie osobne bazy danych bez wspólnej transakcji.

Trwałość danych

Redis domyślnie trzyma dane w pamięci RAM. Restart serwera = utrata zadań. Tak, Redis ma RDB i AOF (mechanizmy persistencji), ale nawet z nimi istnieje okno czasowe, w którym dane mogą zaginąć.

PostgreSQL zapisuje dane na dysk z pełną gwarancją trwałości (WAL + fsync). Serwer może paść w dowolnym momencie - po restarcie wszystkie zakolejkowane zadania nadal czekają. Nie ma okna utraty danych.

Jedno źródło prawdy

Z Obanem Twoje dane biznesowe i Twoje zadania są w tej samej bazie danych. To oznacza:

  • Jeden backup - backup PostgreSQL obejmuje i dane, i kolejkę
  • Jedna replikacja - replika bazy ma i dane, i historię zadań
  • Jedno monitorowanie - pg_stat_statements pokazuje zarówno zapytania biznesowe, jak i operacje kolejki
  • Jedna migracja - mix ecto.migrate ustawia i schemat danych, i tabelę Obana
  • Jeden punkt awarii mniej - nie ma sytuacji "baza działa, ale Redis padł"

Wystarczająca wydajność

"Ale Redis jest szybszy!" - owszem, Redis jest szybszy w operacjach klucz-wartość. Ale pytanie brzmi: czy potrzebujesz tej szybkości?

Oban na PostgreSQL obsługuje dziesiątki tysięcy zadań na minutę na typowym serwerze. Dla porównania:

  • 10 000 emaili na minutę? Twój dostawca emaili (SendGrid, Mailgun) ma limit 100-1000/minutę
  • 1 000 PDF-ów na minutę? Generowanie jednego PDF-a trwa 200ms
  • 5 000 synchronizacji z magazynem na minutę? API magazynu ma rate limit 100/minutę

W praktyce wąskie gardło to nigdy Oban. To zewnętrzne API, I/O, czas generowania. Oban na PostgreSQL jest szybszy niż cokolwiek, co za nim stoi.

Anatomia zadania w Obanie

Zadanie w Obanie to moduł Elixira z jedną funkcją perform/1:

defmodule MojSystem.Workers.WyslijPotwierdzenie do
  use Oban.Worker,
    queue: :email,
    max_attempts: 5

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"zamowienie_id" => zamowienie_id}}) do
    zamowienie = Zamowienia.get!(zamowienie_id)
    klient = Klienci.get!(zamowienie.klient_id)

    case Mailer.send_potwierdzenie(klient.email, zamowienie) do
      {:ok, _} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end
end

Tyle. Zwrócenie :ok oznacza sukces. Zwrócenie {:error, reason} oznacza błąd - Oban ponowi zadanie zgodnie z konfiguracją. Jeśli perform/1 rzuci wyjątek - Oban złapie go, zapisze stack trace i ponowi.

Tworzenie zadania

# Natychmiast
%{zamowienie_id: 123}
|> MojSystem.Workers.WyslijPotwierdzenie.new()
|> Oban.insert()

# Z opóźnieniem - wyślij za 30 minut
%{zamowienie_id: 123}
|> MojSystem.Workers.WyslijPotwierdzenie.new(scheduled_at: DateTime.add(DateTime.utc_now(), 30, :minute))
|> Oban.insert()

# Z priorytetem (0 = najwyższy, 3 = domyślny)
%{zamowienie_id: 123}
|> MojSystem.Workers.WyslijPotwierdzenie.new(priority: 0)
|> Oban.insert()

Funkcje, które eliminują potrzebę zewnętrznych narzędzi

Kolejki z limitem współbieżności

Konfiguracja kolejek w Obanie jest deklaratywna:

config :moj_system, Oban,
  repo: MojSystem.Repo,
  queues: [
    email: 10,        # Max 10 emaili jednocześnie
    pdf: 3,            # Max 3 PDF-y (ciężkie obliczeniowo)
    import: 1,         # Importy jeden po drugim (żeby nie zabić bazy)
    webhooks: 20,      # Webhooki mogą iść równolegle
    default: 5         # Reszta
  ]

Każda kolejka to osobna pula workerów z osobnym limitem. Ciężki import CSV z milionem wierszy nie zablokuje wysyłki emaili. Generowanie 50 PDF-ów nie spowolni webhooków. Izolacja jest wbudowana.

Zmiana limitów? Zmiana jednej liczby w konfiguracji i restart aplikacji. W systemie z RabbitMQ to zmiana konfiguracji brokera, restartowanie konsumerów, sprawdzanie czy nic nie zaginęło.

Retry z exponential backoff

Zewnętrzne API padają. Serwery mailowe odrzucają połączenia. Bazy danych mają momenty przeciążenia. Oban obsługuje to automatycznie:

use Oban.Worker,
  queue: :email,
  max_attempts: 10  # Ponów do 10 razy

# Domyślny backoff: attempt^4 sekund
# Próba 1: natychmiast
# Próba 2: za 16 sekund
# Próba 3: za 81 sekund
# Próba 4: za 256 sekund (~4 minuty)
# Próba 5: za 625 sekund (~10 minut)
# ...

# Własna strategia backoff
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
  # Liniowy backoff: 60 sekund * numer próby
  attempt * 60
end

Po wyczerpaniu prób zadanie trafia do stanu discarded z pełnym logiem błędów. Możesz je przejrzeć, naprawić problem i ponowić ręcznie.

Unikalne zadania

Klasyczny problem: użytkownik klika "Wyślij" 5 razy, bo strona nie reaguje. Bez ochrony - 5 emaili. Z Obanem:

use Oban.Worker,
  queue: :email,
  unique: [period: 300, fields: [:args, :queue]]

# Przez 5 minut nie zostanie utworzone drugie zadanie
# z tymi samymi argumentami w tej samej kolejce

Albo inny scenariusz: cron generuje raport co godzinę, ale raport trwa 2 godziny. Bez ochrony nakładają się na siebie. Z unique: [states: [:available, :executing]] - drugie zadanie nie powstanie, dopóki pierwsze nie skończy.

Harmonogramy (cron)

Nie potrzebujesz systemowego crona, który jest niewidoczny w kodzie i ginie przy wymianie serwera. Oban ma wbudowany cron:

config :moj_system, Oban,
  repo: MojSystem.Repo,
  queues: [email: 10, reports: 2],
  plugins: [
    {Oban.Plugins.Cron, crontab: [
      {"0 8 * * 1", MojSystem.Workers.RaportTygodniowy},           # Poniedziałki o 8:00
      {"0 0 1 * *", MojSystem.Workers.FakturyMiesieczne},          # 1. dzień miesiąca
      {"*/15 * * * *", MojSystem.Workers.SynchronizujMagazyn},     # Co 15 minut
      {"0 3 * * *", MojSystem.Workers.CzyscStareDane},             # Codziennie o 3:00
    ]}
  ]

Harmonogram jest w kodzie. Jest w repozytorium. Jest w code review. Jest w historii git. Nie na karteczce, nie w głowie Tomka, nie ręcznie wpisany na serwerze.

Przycięcie historii

Oban zapisuje historię wszystkich zadań. Bez czyszczenia tabela rosłaby w nieskończoność. Plugin Pruner rozwiązuje to automatycznie:

plugins: [
  {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 30}  # Czyść po 30 dniach
]

Zadania starsze niż 30 dni są automatycznie usuwane. Ale zanim zostaną usunięte, masz pełną historię: kiedy zadanie powstało, kiedy było wykonane, ile prób zajęło, jakie błędy wystąpiły, ile trwało. To jest audytowalność, której nie ma w Redisie.

Realne scenariusze w systemach biznesowych

E-commerce: cykl życia zamówienia

Jedno zamówienie generuje kaskadę zadań w tle:

def handle_event("zloz_zamowienie", params, socket) do
  Ecto.Multi.new()
  |> Ecto.Multi.insert(:zamowienie, build_zamowienie(params))
  |> Oban.insert(:potwierdzenie, WyslijPotwierdzenie.new(%{...}))
  |> Oban.insert(:magazyn, RezerwujStan.new(%{...}))
  |> Oban.insert(:platnosc, InicjujPlatnosc.new(%{...}))
  |> Repo.transaction()
end

Po udanej płatności:

def perform(%{args: %{"zamowienie_id" => id}}) do
  zamowienie = Zamowienia.potwierdz_platnosc!(id)

  # Kaskada kolejnych zadań
  WygenerujFakture.new(%{zamowienie_id: id}) |> Oban.insert()
  WyslijDoKuriera.new(%{zamowienie_id: id}) |> Oban.insert()
  AktualizujStatystyki.new(%{zamowienie_id: id}) |> Oban.insert()
  PowiadomKlienta.new(%{zamowienie_id: id, status: "oplacone"}) |> Oban.insert()

  :ok
end

Każde zadanie jest niezależne. Jeśli kurier nie odpowiada - Oban ponowi. Jeśli generator PDF padnie - email z potwierdzeniem i tak poszedł. Izolacja awarii na poziomie zadań, nie na poziomie zamówienia.

ERP: nocna synchronizacja danych

System ERP musi co noc zsynchronizować dane z 5 zewnętrznymi systemami:

# Cron odpala o 2:00 w nocy
{"0 2 * * *", SynchronizacjaNocna}

# Worker orkiestrator
def perform(_job) do
  SyncKontrahenci.new(%{}) |> Oban.insert()
  SyncProdukty.new(%{}) |> Oban.insert()
  SyncCeny.new(%{}) |> Oban.insert()
  SyncStanyMagazynowe.new(%{}) |> Oban.insert()
  SyncKursy.new(%{}) |> Oban.insert()
  :ok
end

Każda synchronizacja to osobne zadanie w osobnej kolejce. Jeśli API dostawcy cen padło - reszta synchronizacji idzie normalnie. Rano administrator widzi w dashboardzie: 4 synchro OK, 1 failed (ceny - timeout API). Ponawia jednym kliknięciem.

SaaS: onboarding nowego klienta

Rejestracja klienta w systemie SaaS to sekwencja zadań:

# Synchronicznie - to musi być szybkie
{:ok, user} = Accounts.create_user(params)

# Asynchronicznie - to może trwać
UtworzOrganizacje.new(%{user_id: user.id}) |> Oban.insert()
WyslijEmailPowitalny.new(%{user_id: user.id}) |> Oban.insert()
ProvisionDatabase.new(%{user_id: user.id}) |> Oban.insert()
ImportujDaneDemowe.new(%{user_id: user.id}) |> Oban.insert()
PowiadomZespol.new(%{user_id: user.id, channel: "sales"}) |> Oban.insert()

Użytkownik widzi stronę "Konfigurujemy Twoje konto..." z postępem. LiveView aktualizuje status w czasie rzeczywistym, gdy kolejne zadania się kończą. Po 30 sekundach - konto gotowe. Bez Obana użytkownik czekałby te 30 sekund na białej stronie.

Import danych: duże pliki

Import CSV z 500 000 wierszami nie może zablokować systemu:

# Worker dzieli plik na paczki po 1000 wierszy
def perform(%{args: %{"file_path" => path}}) do
  path
  |> File.stream!()
  |> CSV.decode(headers: true)
  |> Stream.chunk_every(1000)
  |> Stream.with_index()
  |> Enum.each(fn {chunk, batch_number} ->
    ImportujPaczke.new(%{
      rows: chunk,
      batch: batch_number
    })
    |> Oban.insert()
  end)

  :ok
end

500 paczek po 1000 wierszy. Każda paczka to osobne zadanie. Mogą być przetwarzane równolegle (limit kolejki kontroluje ile naraz). Jeśli paczka nr 347 się nie uda - tylko ona jest ponawiana. Postęp importu jest widoczny w czasie rzeczywistym.

Oban vs alternatywy

Oban vs Celery (Python + Redis/RabbitMQ)

AspektObanCelery
InfrastrukturaPostgreSQL (masz już)Redis lub RabbitMQ (dodatkowy serwer)
TransakcyjnośćPełna (wspólna transakcja z danymi)Brak (dual write problem)
MonitorowanieOban Web (wbudowany)Flower (osobna aplikacja)
KonfiguracjaElixir configOsobny plik + konfiguracja brokera
RetryWbudowane z backoffWbudowane, ale konfiguracja złożona
CronPlugin, jedna liniaCelery Beat (osobny proces)
Unikalne zadaniaWbudowaneWymaga dodatkowej logiki
NiezawodnośćPostgreSQL WAL (zero data loss)Redis: okno utraty danych

Oban vs Sidekiq (Ruby + Redis)

Sidekiq to złoty standard w świecie Ruby. Oban jest jawnie inspirowany Sidekiqiem - ale rozwiązuje jego fundamentalny problem: zależność od Redisa. Sidekiq Pro (płatna wersja) dodaje reliability features, które Oban ma za darmo dzięki PostgreSQL (transakcyjność, trwałość).

Oban vs Bull/BullMQ (Node.js + Redis)

Ten sam wzorzec: świetna biblioteka, ale wymaga Redisa. I ten sam problem: brak transakcyjności z bazą danych aplikacji. W Node.js nie masz też izolacji procesów BEAM, więc zadanie, które zużywa dużo CPU, blokuje event loop.

Czego Oban nie robi (i co jest OK)

Pub/sub w czasie rzeczywistym - Oban to kolejka zadań, nie broker wiadomości. Do pub/sub w Elixirze masz Phoenix PubSub + LISTEN/NOTIFY w PostgreSQL.

Event streaming - jeśli potrzebujesz Kafka-style event log z wielodniową retencją i consumer groups, Oban nie jest do tego. Ale pytanie brzmi: czy naprawdę tego potrzebujesz?

Komunikacja między serwisami - Oban działa w ramach jednej aplikacji. Do komunikacji między mikroserwisami potrzebujesz HTTP/gRPC lub message brokera. Ale... czy na pewno potrzebujesz mikroserwisów?

Oban Pro i Oban Web

Oban ma wersję open source (darmową) i komercyjną (Pro). Różnice:

Oban (darmowy):

  • Kolejki, retry, cron, unikalne zadania
  • Wszystko, co opisaliśmy wyżej
  • Wystarczający dla 90% projektów

Oban Pro (płatny):

  • Workflows - zależności między zadaniami ("wygeneruj PDF po opłaceniu zamówienia")
  • Batch processing - grupowanie zadań z callbackiem po zakończeniu całej grupy
  • Chunk - przetwarzanie dużych zbiorów partiami z kontrolą współbieżności
  • Smart Engine - zaawansowane strategie rate limiting i partycjonowania
  • Oban Web - dashboard do monitorowania, ponownego uruchamiania, przeglądania historii

Dla systemu ERP z setkami typów zadań Oban Pro się zwraca w pierwszym tygodniu - same Workflows eliminują godziny kodu koordynacyjnego.

Testowanie zadań

Oban ma wbudowane wsparcie dla testów - bez uruchamiania prawdziwej kolejki:

# W test_helper.exs
config :moj_system, Oban, testing: :inline  # Zadania wykonują się synchronicznie

# W teście
test "złożenie zamówienia tworzy zadanie emaila" do
  assert {:ok, %{zamowienie: zamowienie}} =
    Zamowienia.zloz(%{produkt_id: 1, ilosc: 2})

  # Sprawdź, że zadanie zostało zakolejkowane
  assert_enqueued worker: WyslijPotwierdzenie,
    args: %{zamowienie_id: zamowienie.id}
end

test "worker emaila wysyła potwierdzenie" do
  zamowienie = insert(:zamowienie)

  # Wykonaj zadanie bezpośrednio
  assert :ok = perform_job(WyslijPotwierdzenie, %{zamowienie_id: zamowienie.id})

  # Sprawdź efekt
  assert_email_sent to: zamowienie.klient.email
end

Testy są szybkie (milisekundy), deterministyczne (brak race conditions) i nie wymagają uruchomionej bazy danych ani brokera. Tryb :inline wykonuje zadania synchronicznie w procesie testowym - pełna kontrola.

Dlaczego Oban w każdym naszym projekcie

Oban jest częścią naszego standardowego stacku nie dlatego, że jest modny. Dlatego, że eliminuje całą kategorię problemów architektonicznych:

  • Nie zarządzamy Redisem - bo go nie mamy
  • Nie debugujemy dual write - bo transakcja jest jedna
  • Nie tracimy zadań - bo PostgreSQL nie traci danych
  • Nie piszemy kodu retry - bo Oban robi to za nas
  • Nie zarządzamy cronem na serwerze - bo harmonogram jest w kodzie
  • Nie szukamy zduplikowanych zadań - bo unikalność jest wbudowana

Jedna zależność Hex. Jedna tabela w PostgreSQL. Zero dodatkowej infrastruktury. System kolejek gotowy na produkcję w 10 minut od dodania do projektu.

Budujesz system, który wymaga przetwarzania w tle, i nie chcesz zarządzać Redisem ani RabbitMQ? Porozmawiajmy - pokażemy, jak Oban upraszcza architekturę i redukuje koszty operacyjne.