Powrót do strony głównej

Async-background: zadania w tle w Ruby bez Redis i Postgres

Async-background — gem dla Ruby, zapewniający zadania w tle bez zewnętrznych zależności. Rozwiązuje problemy z zarządzaniem zasobami i niezawodnością. Nadaje się do aplikacji na Async i Falcon.

Jak uruchamiać zadania w tle w Ruby bez Redis? Rozwiązanie async-background
Advertisement 728x90

# Async-background: lekki system zadań w tle dla Ruby bez zewnętrznych zależności

Deweloperzy aplikacji Ruby często napotykają konieczność uruchamiania zadań w tle, ale gotowe rozwiązania takie jak Sidekiq wymagają dodatkowej infrastruktury. Nowy gem async-background oferuje alternatywę działającą na Async bez Redis czy Postgres, z precyzyjną kontrolą zasobów.

Dlaczego istniejące rozwiązania nie pasowały

Zazwyczaj do zadań w tle w Ruby używa się Sidekiq, ale wymaga on Redis. Dla małych projektów z kilkoma zadaniami to nadmiar: dodatkowy kontener w docker-compose, kolejna potencjalna awaria i koszty infrastruktury. Co więcej, Sidekiq nie pozwala elastycznie rozdzielać obciążenia między workerami: ciężki nocny raport może spowolnić przetwarzanie maili użytkowników.

Inne opcje też mają wady:

Google AdInline article slot
  • whenever — generuje systemowy crontab, bez kolejki i integracji z aplikacją.
  • rufus-scheduler — działa w oddzielnych wątkach, co w aplikacjach Async powoduje przełączanie kontekstu i problemy z duplikacją przy wielu procesach.
  • good_job i solid_queue — zależą od ActiveRecord i Postgres. To stwarza ryzyka: zadania w tle konkurują z obciążeniem transakcyjnym o zasoby bazy danych, co może spowolnić główną aplikację.

W ten sposób powstała potrzeba rozwiązania, które łączy cron, interwały i dynamiczną kolejkę bez zewnętrznych zależności oraz z możliwością kontroli zużycia zasobów.

Kluczowe zasady projektowania

Autor gema sformułował sześć głównych zasad:

  • Jeden event loop. Harmonogram działa w tej samej reaktywnej pętli co aplikacja, bez oddzielnych wątków.
  • Zero infrastruktury. Kolejka jest przechowywana na dysku (SQLite w trybie WAL lub plik w tmp/), bez Redis czy Postgres.
  • Opcjonalne zależności. Dodatkowe funkcje (np. metryki) podłącza się przez opcjonalne gemy.
  • Bezpieczeństwo wieloprocesowe bez koordynatora. Gwarantuje, że zadanie uruchomi się tylko w jednym procesie przy użyciu Falcon (forki).
  • Trwałość jako ubezpieczenie, szybkość jako osobny mechanizm. Niezawodność zapewnia zapis do SQLite, a szybkość — budzenie przez UNIX-socket.
  • Kontrola zużycia workerów. Deweloper może przypisywać zadania konkretnym workerom przez zmienne środowiskowe i konfigurację YAML.

Te zasady pozwoliły stworzyć gem, który łatwo integruje się z istniejącymi aplikacjami Async i nie wymaga dodatkowej infrastruktury.

Google AdInline article slot

Architektoniczne cechy

Minimalna kopca zamiast ticków

Zamiast pętli z sleep 1 (która budzi się co sekundę i sprawdza wszystkie zadania) używa się binarnej minimalnej kopcy (MinHeap). Pozwala to:

  • Pobrać najbliższe zadanie w O(1) za pomocą peek.
  • Zaktualizować kopcę po uruchomieniu zadania w O(log n) za pomocą replace_top.
  • Spaść dokładnie do momentu następnego zdarzenia, oszczędzając zasoby CPU.

Implementacja zajmuje zaledwie 74 linie i nie wymaga zewnętrznych zależności.

Dwie skale czasu

Dla zadań interwałowych (np. every: 60) używa się monotonicznego zegara (CLOCK_MONOTONIC), aby uniknąć problemów z synchronizacją czasu (NTP). Dla zadań cron (np. cron: "0 3 *") stosuje się zegar ścienny (Time.now), bo harmonogram jest powiązany z czasem kalendarzowym.

Google AdInline article slot

W kodzie wygląda to tak:

next_run_at = if task_config[:interval]
  now + jitter + task_config[:interval]
else
  now_wall = Time.now
  wall_wait = task_config[:cron].next_time(now_wall).to_f - now_wall.to_f
  now + jitter + [wall_wait, MIN_SLEEP_TIME].max
end

Shardowanie przez CRC32

Przy użyciu Falcon (który fork'uje) trzeba zagwarantować, że zadanie uruchomi się tylko w jednym procesie. Stosuje się deterministyczne shardowanie: dla nazwy zadania oblicza się Zlib.crc32(name) % total_workers + 1. Jeśli wynik zgadza się z indeksem bieżącego workera, zadanie jest ładowane.

assigned = config['worker']&.to_i || ((Zlib.crc32(name) % total_workers) + 1)
next unless assigned == worker_index

To eliminuje potrzebę rozproszonych blokad.

Skip-on-overlap

Jeśli zadanie trwa dłużej niż interwał (np. interwał 60 sekund, a zadanie 90), nowe zadanie nie startuje. Zamiast tego bieżące jest pomijane, logowane i przesuwane na następny slot:

if entry.running
  logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
  metrics.job_skipped(entry)
  entry.reschedule(monotonic_now)
  heap.replace_top(entry)
  next
end

Kolejka: SQLite + UNIX-socket

Dynamiczna kolejka używa dwóch mechanizmów:

  • SQLite — źródło prawdy. Zadania są zapisywane na dysk, co gwarantuje niezawodność.
  • UNIX-socket — do natychmiastowego powiadamiania. Po dodaniu zadania do bazy wysyłany jest sygnał do socketa, by worker natychmiast je przetworzył.

Ponadto przewidziano polling co 5 sekund jako zabezpieczenie na wypadek utraty sygnału.

Odzyskiwanie po awariach

Jeśli worker padnie podczas wykonywania zadania, status pozostaje „w trakcie". Przy restarcie Queue::Store#recover znajdzie takie wpisy i przywróci je do kolejki.

Co ważne

  • Brak zależności od zewnętrznych usług. Async-background używa tylko systemu plików, co upraszcza deployment.
  • Kontrola zasobów. Możliwość izolowania workerów dla różnych typów zadań (cron, kolejka) zapobiega konkurencji o zasoby.
  • Niezawodność. Zadania nie giną dzięki zapisowi do SQLite i mechanizmowi odzyskiwania.
  • Elastyczność. Konfiguracja przez YAML i zmienne środowiskowe pozwala łatwo dostosowywać rozkład zadań.
  • Wydajność. Minimalna kopca i UNIX-socket zapewniają niskie opóźnienia i efektywne zużycie CPU.

— Editorial Team

Advertisement 728x90

Czytaj dalej