1. Po co w ogóle zadania asynchroniczne w ChatGPT App
Gdyby świat był idealny, każde Twoje narzędzie MCP wyrabiałoby się w kilkaset milisekund. W praktyce wszystko, co najciekawsze — bywa długie i ciężkie:
- parsowanie dużego pliku CSV z historią zakupów użytkownika;
- agregacja danych z kilku zewnętrznych API, z których każde raz „śpi”, raz odpowiada 503;
- budowanie złożonych rekomendacji z masą kroków pośrednich;
- generowanie obszernych raportów i prezentacji.
Jeśli spróbujesz wcisnąć to w jedno synchroniczne wywołanie narzędzia (tool‑call), natkniesz się na trzy problemy.
Po pierwsze, timeouty. Sesja ChatGPT, infrastruktura HTTP, klient MCP — to wszystko nie jest przygotowane na odpowiedzi „za pięć minut”. Serwer, który utrzymuje połączenie zbyt długo, będzie wyglądał na „zawieszony” zarówno dla ChatGPT, jak i dla użytkownika.
Po drugie, zarządzanie obciążeniem. Jeśli stu użytkowników jednocześnie uruchomi „super‑analizę prezentów na Nowy Rok”, nie chcesz, by serwer MCP synchronicznie trzymał sto długich zadań bezpośrednio w strumieniach HTTP. Potrzebujesz warstwy pośredniej, która potrafi przyjąć skoki ruchu, rozłożyć zadania do kolejki i obsługiwać je przez kilka workerów.
Po trzecie, UX. Użytkownik kliknął przycisk w widżecie GiftGenius i wpatruje się w jeden spinner przez 40 sekund — odczucie jak w starych bankach internetowych. Model „szybka odpowiedź + postęp + możliwość anulowania” jest dużo przyjemniejszy.
Te problemy rozwiązuje wspólny schemat: „start → kolejka → tło → zdarzenia”.
2. Podstawowa architektura async‑job w kontekście MCP
Weźmy nasz GiftGenius. Załóżmy, że pojawił się nowy ciężki scenariusz: „Głęboka analiza preferencji na podstawie historii zakupów i mediów społecznościowych znajomego”. Taka operacja może trwać kilka minut, dlatego:
- Narzędzie MCP (tool) przyjmuje parametry żądania od modelu.
- Zamiast od razu wszystko liczyć, tworzy rekord Job w bazie.
- Umieszcza zadanie w kolejce.
- Błyskawicznie odpowiada ChatGPT: „Analiza uruchomiona, oto jobId”.
- Worker w tle pobiera zadanie z kolejki, wykonuje ciężką pracę, po drodze wysyła zdarzenia MCP job.progress i job.partial, a na końcu — job.completed lub job.failed.
Z punktu widzenia architektury wygląda to mniej więcej tak:
flowchart LR
subgraph ChatGPT
U[Użytkownik] --> GPT[Model + ChatGPT UI]
end
GPT -->|call_tool analyze_preferences| MCP[MCP-serwer]
subgraph Backend
MCP -->|utworzyć Job| DB[(Baza zadań)]
MCP -->|enqueue| Q[Kolejka]
W[Worker] -->|take job| Q
W -->|update status/progress| DB
W -->|MCP events: job.progress/job.completed| MCP
end
MCP -->|SSE events| GPT
Ważna myśl: serwer MCP nie musi być monolitem. Często pełni rolę fasady nad Twoją wewnętrzną infrastrukturą asynchroniczną: przyjmuje wywołania narzędzi (tool‑calls), tworzy joby i wysyła zdarzenia, a ciężką pracę wykonują osobne procesy‑workery.
3. Model danych dla zadania asynchronicznego
Zaczniemy od prostego modelu Job. Użyjemy TypeScriptu i przykładowego serwera Node/MCP, żeby od razu było widać, jak to wpisuje się w stos technologiczny.
Najprostszy model w pamięci/bazie może wyglądać tak:
// openai/jobs/model.ts
export type JobStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'canceled';
export interface GiftJob {
id: string; // jobId
type: 'deep_gift_analysis';
status: JobStatus;
payload: {
recipientProfile: string; // tekst/ID profilu
budget: number;
};
result?: unknown; // końcowe rekomendacje
error?: string; // przyczyna błędu
attempts: number; // ile razy próbowano wykonać
createdAt: Date;
updatedAt: Date;
}
W realnym projekcie będziesz przechowywać GiftJob w Postgresie, DynamoDB, Firestore lub gdzie indziej, ale dla tej lekcji istotne są pola:
- status — bieżący stan zadania, który będzie odzwierciedlony i w zdarzeniach, i w UX;
- attempts — licznik dla retry;
- error — do logów i debugowania;
- payload — dane wejściowe, które worker wykorzystuje do przetwarzania.
4. Narzędzie MCP, które tworzy async‑job
Wyobraźmy sobie narzędzie start_deep_analysis. Kiedyś wykonywałoby wszystko synchronicznie, a teraz tylko wkłada zadanie do kolejki i zwraca jobId.
// openai/tools/startDeepAnalysis.ts
import { v4 as uuid } from 'uuid';
import { createJobAndEnqueue } from '../jobs/queue';
// Pseudotypy dla MCP SDK
type StartDeepAnalysisInput = {
recipientProfile: string;
budget: number;
};
type StartDeepAnalysisOutput = {
jobId: string;
message: string;
};
export async function startDeepAnalysisTool(
input: StartDeepAnalysisInput
): Promise<StartDeepAnalysisOutput> {
const jobId = uuid();
await createJobAndEnqueue({
id: jobId,
type: 'deep_gift_analysis',
status: 'pending',
payload: {
recipientProfile: input.recipientProfile,
budget: input.budget,
},
attempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
return {
jobId,
message: `Rozpoczęto głęboką analizę. ID zadania: ${jobId}. Będę przesyłać aktualizacje w miarę postępów.`,
};
}
Ważne jest to, że:
- narzędzie MCP działa szybko: maksymalnie kilka zapytań do bazy/kolejki;
- zwraca ustrukturyzowaną odpowiedź z jobId, której ChatGPT może użyć w „wyjaśnieniu dla użytkownika” i którą widżet GiftGenius może zapisać w swoim widgetState.
Twoje JSON Schema dla tego narzędzia po prostu opisuje jobId jako string i message jako tekst czytelny dla człowieka — model zrozumie, że to identyfikator zadania i będzie mógł się do niego odwoływać w kolejnych krokach dialogu.
5. Prosta kolejka i worker: wersja dydaktyczna
Aby nie ciągnąć teraz Redisa, RabbitMQ i całej reszty, zrobimy uproszczoną kolejkę w pamięci. W prawdziwej produkcji będzie to oczywiście osobna usługa (SQS/BullMQ/Cloud Tasks itd.), ale logika pozostanie taka sama.
Najpierw szkic kolejki:
// openai/jobs/queue.ts
import type { GiftJob } from './model';
const jobs = new Map<string, GiftJob>(); // "BD" w pamięci
export const queue: string[] = []; // uproszczona kolejka po id
export async function createJobAndEnqueue(job: GiftJob) {
jobs.set(job.id, job);
queue.push(job.id);
}
export function getJob(id: string): GiftJob | undefined {
return jobs.get(id);
}
export function updateJob(id: string, patch: Partial<GiftJob>) {
const job = jobs.get(id);
if (!job) return;
const updated: GiftJob = { ...job, ...patch, updatedAt: new Date() };
jobs.set(id, updated);
}
Teraz prymitywny worker, który okresowo zagląda do kolejki, bierze job i go przetwarza:
// openai/jobs/worker.ts
import { getJob, updateJob } from './queue';
import { emitJobEvent } from './events';
async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
updateJob(jobId, { status: 'in_progress' });
await emitJobEvent(jobId, 'job.started', {});
try {
// Tutaj wywołujemy długą logikę biznesową
const result = await doDeepGiftAnalysis(job.id, job.payload);
updateJob(jobId, { status: 'completed', result });
await emitJobEvent(jobId, 'job.completed', { resultSummary: summarize(result) });
} catch (err) {
updateJob(jobId, {
status: 'failed',
error: (err as Error).message,
});
await emitJobEvent(jobId, 'job.failed', { error: 'Internal error' });
}
}
I sam „cykliczny” worker, który można uruchomić gdzieś przy starcie aplikacji:
// openai/jobs/workerLoop.ts
import { queue } from './queue';
import { processJob } from './worker';
export function startWorkerLoop() {
setInterval(async () => {
const jobId = queue.shift(); // docelowo potrzebna ochrona przed warunkami wyścigu
if (!jobId) return;
await processJob(jobId);
}, 1000); // sprawdzamy kolejkę co sekundę
}
To przykład dydaktyczny. W prawdziwym świecie zamiast setInterval będzie normalna kolejka, która sama „budzi” workera, gdy pojawia się nowe wiadomości. Ale ogólna idea jest jasna: worker jest oddzielony od narzędzia MCP, działa w tle i komunikuje się z serwerem MCP poprzez zdarzenia.
6. Generowanie zdarzeń MCP z workera
W poprzednich lekcjach widziałeś już format zdarzeń MCP: typ (type), unikalny event_id, timestamp, job_id i payload. Teraz pokażemy, jak worker może wywoływać helper emitJobEvent, a ten — dostarczać zdarzenia do ChatGPT przez kanał SSE serwera MCP.
Przykładowy prosty helper:
// openai/jobs/events.ts
import { randomUUID } from 'crypto';
import { sendMcpEvent } from '../mcp/eventBus';
export async function emitJobEvent(
jobId: string,
type: 'job.started' | 'job.progress' | 'job.completed' | 'job.failed',
payload: unknown
) {
const event = {
event_id: randomUUID(),
type,
job_id: jobId,
timestamp: new Date().toISOString(),
payload,
};
await sendMcpEvent(event);
}
A sendMcpEvent wewnątrz serwera MCP już wie, jak dokładnie wcisnąć to zdarzenie do SSEServerTransport z MCP SDK: na przykład przez lokalną szynę zdarzeń lub Redis Pub/Sub, jak omawialiśmy w module 12.
Kluczowa myśl: worker nie komunikuje się z ChatGPT bezpośrednio. Rozmawia z serwerem MCP, a ten utrzymuje połączenia SSE i przekazuje zdarzenia do klientów.
7. Postęp i częściowe wyniki (partial results) z workera
Teraz najciekawsze: postęp i częściowe wyniki. W GiftGenius długą analizę można podzielić na etapy:
- zbieranie i normalizacja danych;
- budowa podstawowych segmentów;
- generowanie wstępnych pomysłów na prezenty;
- finalny ranking i tekstowe objaśnienie.
Na każdym etapie możemy wysyłać job.progress i czasem — job.partial, aby UI mógł już pokazać pierwsze prezenty.
Przykładowy worker:
async function doDeepGiftAnalysis(jobId: string, payload: GiftJob['payload']) {
await emitJobEvent(jobId, 'job.progress', { step: 1, totalSteps: 4 });
const normalized = await collectAndNormalizeData(payload);
await emitJobEvent(jobId, 'job.progress', { step: 2, totalSteps: 4 });
const roughGifts = await generateInitialGifts(normalized);
await emitJobEvent(jobId, 'job.partial', { gifts: roughGifts.slice(0, 3) });
await emitJobEvent(jobId, 'job.progress', { step: 3, totalSteps: 4 });
const finalGifts = await rerankAndBeautify(roughGifts);
await emitJobEvent(jobId, 'job.progress', { step: 4, totalSteps: 4 });
return finalGifts;
}
Widżet, słuchając zdarzeń, może najpierw pokazać 3 „robocze” prezenty z adnotacją „Jeszcze doprecyzowujemy”, a po job.completed — zaktualizować listę i usunąć wskaźnik ładowania. Wszystko to idealnie wpisuje się w wzorce UX, o których mówiliśmy w lekcji 3.
8. Logika retry dla workerów
Teraz najbardziej newralgiczne miejsce: błędy i ponowne próby.
Wyobraź sobie, że worker podczas przetwarzania zadania wywołuje zewnętrzne API listy produktów, a to okresowo odpowiada 500 albo 429. Porzucać zadanie po pierwszym błędzie — to dziwne. Ale i restartować w nieskończoność nie wolno: w ten sposób zrobisz DDoS samemu sobie albo zewnętrznej usłudze.
Potrzebujemy strategii retry z wykładniczym opóźnieniem i ograniczeniem liczby prób.
Zacznijmy od klasyfikacji błędów, która przyda się nam dalej w kursie:
- tymczasowe (transient) — timeouty, 500, 503, 429;
- stałe (permanent) — błędne wejście, nieistniejący zasób;
- fatalne (bug) — błędy w kodzie, TypeError, nieoczekiwany wyjątek.
Powtarzać ma sens tylko błędy tymczasowe. Pozostałe należy uczciwie oznaczać jako 'failed'.
Uprośćmy i zróbmy helper:
// openai/jobs/retry.ts
export function shouldRetry(error: unknown): boolean {
if (!(error instanceof Error)) return false;
// Umownie: HTTP 5xx lub 429
return /5\d\d|429/.test(error.message);
}
export function getDelayMs(base: number, attempt: number): number {
const jitter = Math.random() * 100; // niewielki jitter
return base * 2 ** attempt + jitter; // wykładniczy backoff
}
Teraz zaktualizujemy workera tak, aby uwzględniał attempts w GiftJob:
// openai/jobs/worker.ts
import { getJob, updateJob } from './queue';
import { emitJobEvent } from './events';
import { shouldRetry, getDelayMs } from './retry';
const MAX_ATTEMPTS = 5;
export async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
updateJob(jobId, { status: 'in_progress' });
try {
const result = await doDeepGiftAnalysis(job.id, job.payload);
updateJob(jobId, { status: 'completed', result });
await emitJobEvent(jobId, 'job.completed', {
resultSummary: summarize(result),
});
} catch (err) {
const attempts = job.attempts + 1;
const error = err as Error;
if (attempts <= MAX_ATTEMPTS && shouldRetry(error)) {
const delay = getDelayMs(1000, attempts); // 1s,2s,4s...
updateJob(jobId, { attempts, status: 'pending', error: error.message });
setTimeout(() => {
// W prawdziwej kolejce "reenqueue'owałbyś" job z opóźnieniem
processJob(jobId);
}, delay);
await emitJobEvent(jobId, 'job.progress', {
retry: attempts,
nextAttemptInMs: delay,
});
} else {
updateJob(jobId, { status: 'failed', error: error.message });
await emitJobEvent(jobId, 'job.failed', {
error: 'Nie udało się zakończyć analizy po kilku próbach',
});
}
}
}
Tu ważnych jest kilka kwestii.
Po pierwsze, attempts jest przechowywane w samym zadaniu — to wygodne zarówno do logowania, jak i do obserwowalności (na wykresie będzie ładnie widać, ile zadań przechodzi z retry).
Po drugie, przy każdym retry wysyłamy job.progress z wyraźną informacją, że to próba nr N. Model może wykorzystać tę informację, by wyjaśnić użytkownikowi, że „serwer prezentów odpowiada niestabilnie, próbuję ponownie”.
Po trzecie, gwarantujemy, że w każdym przypadku wyśle się albo job.completed, albo job.failed. Żadnych wiszących zadań „ani żywe, ani martwe”.
Anulowanie ('canceled') — to kolejny ważny status. W przykładach dydaktycznych go nie implementujemy, ale w produkcji zwykle ustawiany jest z inicjatywy użytkownika (przycisk „Anuluj” w widżecie) lub przez timeout. W takim przypadku worker przy następnym pobraniu zadania z kolejki widzi status: 'canceled', nie uruchamia przetwarzania, a serwer MCP wysyła końcowe zdarzenie job.canceled.
9. Idempotentność i retry: nie wdepnij dwa razy w tę samą pułapkę
Gdy wprowadzasz retry, od razu pojawia się ryzyko „zrobienia tego samego dwa razy”. W modułach commerce to krytyczne (np. podwójne obciążenie karty), ale i w GiftGenius są scenariusze, gdzie powtórka jest zła: wysłanie dwóch identycznych maili do znajomego, zduplikowanie wpisu w wewnętrznej analityce itp.
Dlatego kieruj się dwoma zasadami.
Po pierwsze: handler zadania powinien być idempotentny.
Jeśli wywołujesz go z tym samym jobId kilka razy (w ramach retry lub przez pomyłkę), świat nie powinien się „psuć”. W tym celu:
- wszystkie efekty uboczne (zapis do bazy, wysyłka maili, tworzenie zamówień) powinny być powiązane z jobId lub innym naturalnym identyfikatorem, aby w kodzie można było szybko sprawdzić, czy dany krok był już wykonany;
- jeśli job.status to już 'completed' albo 'failed', ponowne wywołanie można zignorować albo po prostu zwrócić gotowy wynik.
Przykład prostej ochrony:
export async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
if (job.status === 'completed' || job.status === 'failed') {
// Zadanie jest już pomyślnie lub ostatecznie zakończone
return;
}
// ... reszta kodu
}
Po drugie: zdarzenia również powinny być idempotentne.
Już mówiliśmy o event_id i o tym, że klient może filtrować duplikaty, ale po stronie serwera też warto uważać: przy restarcie workera czy odtworzeniu z kolejki nie spamuj klienta tymi samymi job.progress bez potrzeby.
10. Gdzie w Twojej architekturze znajdują się kolejki i workery
Na schemacie wszystko wygląda pięknie, ale gdzie fizycznie działa worker? Jest kilka typowych wariantów.
Zintegrowany worker: serwer MCP i worker — to ten sam proces/deploy. Ten sam przyjmuje tool‑calls i uruchamia pętlę workera. Plus to prostota: mniej usług, prostszy deploy. Minus — skalowanie: aby dodać workery, trzeba skalować cały serwer MCP.
Wydzielony worker: serwer MCP — to jedna usługa, workery — druga. Pomiędzy nimi — kolejka i ewentualnie Pub/Sub dla zdarzeń. To właśnie o tym dużo się pisze w kontekście BullMQ/Redis i zdarzeń MCP: serwer MCP subskrybuje kanał Redisa 'mcp:events', workery publikują tam zdarzenia.
Wariant kombinowany: jeden instancja serwera MCP uruchamia też workera, pozostałe instancje — tylko HTTP/SSE. To może być użyteczne, jeśli wdrażasz na Vercel lub innej platformie serverless, gdzie stałe procesy w tle są „średnią przyjemnością”.
W naszym dydaktycznym GiftGenius na razie dopuszczamy pierwszy wariant: serwer MCP + jeden prosty worker w procesie. Gdy dojdziesz do modułów o produkcji i skalowaniu, możesz przenieść workery do osobnej usługi.
11. Przykład: pełny pipeline async w GiftGenius
Przeanalizujmy spójnie, co się dzieje, gdy użytkownik w czacie pisze:
„Potrzebuję złożonego doboru prezentu dla fana kosmosu, z uwzględnieniem jego wcześniejszych zakupów”.
- Model decyduje się wywołać narzędzie start_deep_analysis z parametrami profilu obdarowywanego i budżetu.
- Narzędzie tworzy GiftJob w bazie ze statusem 'pending', wkłada go do kolejki i zwraca jobId + wiadomość potwierdzającą.
- ChatGPT wyjaśnia użytkownikowi, że analiza została uruchomiona, i może przekazać jobId do widżetu GiftGenius.
- Widżet subskrybuje zdarzenia dla tego jobId przez SSE, pokazuje pasek postępu i status „Zbieramy i analizujemy dane”.
- Worker, widząc nowe zadanie w kolejce, aktualizuje status na 'in_progress' i wysyła job.started.
- W trakcie pracy wysyła kilka razy job.progress (etapy) oraz job.partial (pierwsze 2–3 prezenty).
- Jeśli po drodze padnie zewnętrzne API, worker próbuje ponownie z wykładniczym backoffem, aktualizując attempts i wysyłając zdarzenie z informacją o ponownej próbie.
- Na koniec wysyła albo job.completed z krótkim podsumowaniem i finalnymi rekomendacjami, albo job.failed z jasnym objaśnieniem.
- Widżet na podstawie tych zdarzeń aktualizuje UI, a ChatGPT może przygotować tekstową syntezę i zaproponować follow‑up: „Pokaż więcej pomysłów”, „Zawęź budżet”, „Zmień typ prezentu”.
Z punktu widzenia użytkownika to „żywy”, długi proces, ale pod kontrolą. Z punktu widzenia backendu — normalny pipeline asynchroniczny z kolejką, workerami i retry.
12. Małe ćwiczenie (dla samodzielnej praktyki)
Jeśli chcesz utrwalić materiał, spróbuj dla GiftGenius:
- zaprojektować schemat tabeli jobs dla prawdziwej bazy: jakie indeksy będą potrzebne, które pola będą używane w filtracji (po użytkowniku, statusie, dacie utworzenia);
- naszkicować typ TypeScript dla endpointu HTTP /api/jobs/:id, aby widżet mógł w ostateczności robić polling statusu, jeśli SSE jest niedostępne;
- opisać politykę retry: ile prób, bazowe opóźnienie, co zrobić z zadaniami, które i tak upadły (prosta tabela dead‑letter czy logowanie + alert).
To zadanie przyda się później, gdy w modułach o produkcji i obserwowalności będziemy mówić o metrykach typu „ile zadań utknęło w statusie pending dłużej niż N minut”.
13. Typowe błędy przy pracy z zadaniami asynchronicznymi
Błąd nr 1: wszystko synchronicznie w tool‑call.
Najczęstsza pułapka — spróbować upchnąć całą ciężką pracę w jedno narzędzie MCP bez kolejki. Gdy zapytań jest mało, to niby działa. Gdy tylko rośnie obciążenie albo zewnętrzne API zaczynają zwalniać, łapiesz timeouty, zawieszanie czatu i bardzo nerwowy UX. Każdą operację, która potencjalnie może trwać dziesiątki sekund i dłużej, lepiej od razu projektować jako async‑job z jobId.
Błąd nr 2: brak wyraźnego modelu Job.
Czasem deweloperzy próbują obejść się „samymi wiadomościami w kolejce”, bez przechowywania stanu zadań w bazie. W efekcie trudno odpowiedzieć na podstawowe pytania: „jaki jest status zadania?”, „ile razy próbowaliśmy je wykonać?”, „dlaczego upadło?”. Jasny model Job z polami status, attempts, error, createdAt — to podstawa debugowania, monitoringu i UX.
Błąd nr 3: brak retry albo przeciwnie — nieskończone próby.
Jedni w ogóle nie robią retry i upadają przy pierwszym 500, inni piszą while (!success) i nie ograniczają liczby prób. W pierwszym przypadku tracisz mnóstwo zadań przez krótkotrwałe awarie, w drugim — wywołujesz „sztormy” obciążenia i ryzykujesz zablokowanie zewnętrznych API. Potrzebny jest rozsądny środek: ograniczona liczba prób + wykładnicze opóźnienie + rozróżnienie błędów tymczasowych i stałych.
Błąd nr 4: nieidempotentne handlery.
Jeśli przy każdej próbie, na przykład, tworzysz nowy wpis w systemie zewnętrznym bez sprawdzenia, realizujesz tę samą płatność albo wysyłasz ten sam e‑mail — retry szybko staje się problemem. Handler powinien rozpoznać, że zadanie z tym jobId zostało już doprowadzone do końca, i nie powtarzać niebezpiecznych efektów ubocznych.
Błąd nr 5: brak zdarzeń przy błędach.
Bywa, że worker pada z nieoczekiwanym wyjątkiem, loguje go do konsoli i to wszystko. Użytkownik siedzi i czeka na job.completed, nie wiedząc, że wszystko dawno umarło. Każda ścieżka, w której przetwarzanie zakończyło się błędem, powinna finalnie prowadzić do job.failed i aktualizacji statusu Job w bazie. Bez tego Twoje strumienie MCP zmieniają się w jednostronną „czarną skrzynkę”.
Błąd nr 6: zbyt częste zdarzenia postępu.
Chęć „bycia uczciwym” i wysyłania job.progress przy każdym jednym procencie wykonania prowadzi do przeciążenia sieci, klienta i serwera MCP. Lepiej wysyłać postęp przy zmianie etapu lub większej delcie (np. co 10 %), a resztę trzymać tylko w wewnętrznych logach.
Błąd nr 7: używanie kolejki w pamięci w produkcji.
Przykład dydaktyczny z queue: string[] i Map — dobry do zrozumienia architektury, ale w prawdziwym systemie produkcyjnym rozpadnie się przy pierwszym restarcie procesu czy padzie serwera. Do poważnej eksploatacji potrzebne są zewnętrzne kolejki i magazyny: SQS, Pub/Sub, RabbitMQ, Redis Streams itd. Warianty in‑memory nadają się wyłącznie do lokalnego developmentu i prostych dem.
GO TO FULL VERSION