# 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:
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_jobisolid_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.
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.
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
Brak komentarzy.