Dodaj zadanie w tle¶
Praca, która trwa dłużej niż żądanie, jest zadaniem w tabeli
background_jobs: serwis kolejkuje je we własnej transakcji, a worker
(python -m app.worker) je pobiera, wykonuje i zapisuje, jak się skończyło. Ta
sama tabela przechowuje cykliczne zadania wdrożenia. app/core/background.py
zostaje dla drobnej pracy bez gwarancji, którą żądanie przekazuje własnemu
procesowi.
Zadanie jest trwałe tam, gdzie task w pętli API nie jest. Przetrwa restart, jest ponawiane z narastającą przerwą, a zadanie, które zabije swojego workera - także przez brak pamięci - zużyło już swoją próbę, więc nie może się zapętlić. To, co zrobiło, widać na stronie Background jobs.
Krok po kroku¶
1. Zadeklaruj zadanie¶
Rodzaj zadania deklaruje się w app/worker/jobs.py: jego payload, pulę, liczbę
prób, limit czasu i priorytet. Ten moduł importuje tylko Pydantic, więc serwis,
który kolejkuje zadanie, nigdy nie importuje kodu, który je wykona.
# app/worker/jobs.py
class SendNotification(Payload):
notification_id: UUID
SEND_NOTIFICATION = Task(
kind="send-notification",
payload_type=SendNotification,
pool=Pool.SYSTEM,
max_attempts=3,
timeout=timedelta(minutes=5),
priority=Priority.INTERACTIVE,
)
Payload niesie identyfikatory i wartości proste, nigdy wiersz ani sesję. Zadanie czyta wiersz dopiero, gdy się wykonuje, i wtedy odtwarza uprawnienia, których potrzebuje: zanim worker je pobierze, wiersz mógł się zmienić, a osoba, która o nie poprosiła, mogła stracić dostęp.
Pulę wybierz według tego, co zadanie zużywa:
| Pula | Do czego | Jak się wykonuje |
|---|---|---|
agent |
Runy agentów i odpalenia triggerów: długie, czekają na modele | W pętli workera, WORKER_AGENT_CONCURRENCY naraz |
ingest |
Parsowanie i embeddingi: dużo CPU i pamięci | Jedno zadanie na proces potomny, w limicie WORKER_INGEST_MEMORY_MB |
system |
Sprzątanie, porządki, raporty: krótkie | W pętli workera, WORKER_SYSTEM_CONCURRENCY naraz |
2. Napisz handler i go podłącz¶
# app/worker/tasks/notification_tasks.py
async def send_notification(job: SendNotification) -> None:
async with get_worker_db_context() as db:
notification = await notification_repo.get_by_id(db, job.notification_id)
if notification is None:
return # deleted since it was queued: nothing left to send
await NotificationDelivery(db).send(notification)
Podłącz go do jego rodzaju w production() w app/worker/handlers.py:
Wyjątek kończy próbę niepowodzeniem, a zadanie jest ponawiane do max_attempts;
typ wyjątku, komunikat i ostatnie ramki trafiają do wiersza zadania. Payload,
który już się nie waliduje, albo rodzaj, którego żaden worker nie zna, kończy
się od razu, bez ponowień - za każdym razem skończyłby się tak samo. Handler może
zwrócić model Pydantic, który zostaje zapisany jako wynik zadania.
3. Zakolejkuj je z serwisu¶
from app.services.background_jobs import enqueue
from app.worker.jobs import SEND_NOTIFICATION, SendNotification
await enqueue(
self.db,
SEND_NOTIFICATION,
SendNotification(notification_id=notification.id),
organization_id=notification.organization_id,
)
Zadanie jest zapisywane w transakcji żądania, więc istnieje dokładnie wtedy, gdy
istnieje wiersz, który wskazuje: wycofane żądanie niczego nie kolejkuje, a
zatwierdzone budzi workery puli przez NOTIFY dopiero wtedy, gdy wiersz da się
odczytać. To był wyścig z
#417, zamknięty przez samą
konstrukcję, a nie przez spawn_after_commit.
Trzy opcje decydują o tym, jak zachowują się kopie zadania:
dedupe_keytrzyma najwyżej jedną zakolejkowaną kopię. Zdedupe_active=Truetakże trwająca kopia odrzuca nową - synchronizacja, która już trwa, sprawia, że druga prośba o nią jest zbędna.concurrency_keypozwala kopiom czekać w kolejce, ale wykonuje je po jednej, na przykład dwie synchronizacje jednego źródła.run_afteropóźnia pierwszą próbę.
4. Zaplanuj je (opcjonalnie)¶
Zadanie cykliczne deklaruje się w SCHEDULES w app/worker/schedules.py. Jego
payload to Tick, który wskazuje wystąpienie, dla którego zadanie odpalono, więc
zadanie obejmujące okres - raport tygodniowy - wie, którego okresu dotyczy:
Każdy harmonogram to interwał na stałej siatce, trzymanej w
background_schedules, więc po restarcie praca rusza tam, gdzie skończył
poprzedni worker. Dwa ustawienia decydują o tym, co robi niewygodne wystąpienie:
misfire- dla wystąpienia spóźnionego o więcej niżgrace(minuta):latestodpala raz za całą zaległość,skipczeka na następne. Nic nigdy nie odpala się raz za każde przegapione wystąpienie.overlap- dla wystąpienia, które przychodzi, gdy poprzednie zadanie czeka w kolejce albo trwa:skipje pomija,queuepozwala jednemu poczekać za nim,allowwykonuje je równolegle.
Sprzątanie bieżącego stanu zostaw z domyślnymi latest i skip. Zadanie, którego
run obejmuje własny okres, jak raporty użycia, dostaje overlap=QUEUE, żeby
wolny tydzień nie był tygodniem, którego raportu nikt nie dostanie.
5. Uruchom workera¶
make dev # API, worker, frontend, Postgres, Redis
uv run --directory backend python -m app.worker # the worker on its own
WORKER_POOLS ogranicza pule, które obsługuje worker, więc wdrożenie może
wykonywać ingestion na większym hoście. Każdy worker wykonuje harmonogramy; ich
pobranie bierze każdy należny harmonogram raz, niezależnie od tego, ile workerów
tyka w tej samej chwili.
Gdy coś pójdzie nie tak¶
Zadanie, które pięć razy z rzędu zabiło swojego workera, wstrzymuje swój rodzaj: żaden worker go nie pobiera, dopóki właściciel wdrożenia nie wznowi go na stronie Background jobs, a każdy właściciel dostaje powiadomienie w aplikacji i e-mailem. Martwe zadanie można z tej strony ponowić, a zakolejkowane anulować. Każda z tych akcji trafia do dziennika audytu.
Kiedy spawn_after_commit wciąż jest dobrym wyborem
Pracę, której utrata przy restarcie nikomu nie szkodzi - rozgrzanie cache,
powiadomienie o czymś, co widać też gdzie indziej - nadal można przekazać do
spawn_after_commit. Wykonuje się w pętli samego API, więc niech będzie
krótka, a wszystko, co musi się wydarzyć, niech będzie zadaniem.
Testy¶
Handler zadania uruchom bezpośrednio w teście jednostkowym; to zwykła
korutyna. Kolejkowanie i pobieranie są testowane na prawdziwej bazie w
backend/tests/integration/test_background_job_queue.py, a worker naprawdę
zabijany - w backend/tests/integration/test_worker_runtime.py. Zacznij od nich,
gdy zmiana dotyka samej kolejki.