7. modulAszinkron munka: Queues, Cron, Workflows, Durable Objects
Cloudflare for Devs · 7. modul · bővített kiadás

Aszinkron munka: Queues, Cron, Workflows, Durable Objects

Ami az AWS-en SQS + EventBridge + Step Functions (+ egy csipet DynamoDB-lock), az itt négy, egymást kiegészítő primitív. Ez a modul hosszabb a többinél: mind a négy eszközt kidolgozott, SaaS-közeli példákon és a buktatóikon keresztül vesszük végig.

7.1Queues: az SQS-ed megfelelője — polling nélkül

A Queues fogalmilag ismerős: üzenetek, batchelés, retry, dead letter queue. A strukturális különbség az SQS-hez képest, hogy nincs consumer-infrastruktúra és nincs polling: a consumer egy queue handler egy Workerben (2. modul), amit a platform hívogat a batchekkel, és a párhuzamos fogyasztók számát automatikusan skálázza a torlódáshoz.

Producer Worker env.JOBS.send(msg) Queue batchelés, ütemezés Consumer Worker async queue(batch) — push! ✓ ack max_retries után → DLQ
7/1. ábra — Producer és consumer lehet ugyanaz a Worker két handlerrel; a platform push-olja a batcheket és skálázza a fogyasztást.

A konfiguráció — és hogy mit jelentenek a számok

wrangler.jsonc
{
  "queues": {
    "producers": [{ "binding": "JOBS", "queue": "jobs" }],
    "consumers": [{
      "queue": "jobs",
      "max_batch_size": 50,        // legfeljebb ennyi üzenet egy batchben…
      "max_batch_timeout": 5,      // …vagy ennyi mp után indul, ami hamarabb teljesül
      "max_retries": 5,
      "retry_delay": 120,          // alapértelmezett várakozás újrapróbálás előtt (mp)
      "max_concurrency": 10,       // felső korlát a párhuzamos consumereknek (opcionális)
      "dead_letter_queue": "jobs-dlq"
    }]
  }
}

A max_batch_size / max_batch_timeout páros a klasszikus áteresztés-vs-latency trade-off: nagy batch + hosszabb timeout = kevesebb Worker-hívás és jobb költség; kicsi batch + rövid timeout = gyorsabb reakció. Emailküldésnél az 50/5s remek; „a user rákattintott és várja az eredményt" jellegű munkánál 10/1s körül járj.

1. példa: számla-email — a legegyszerűbb minta

A cél: a checkout API-d ne várjon az emailküldő külső API-jára. A producer-oldal egyetlen sor, a válasz azonnal megy a usernek:

server/api/checkout.post.ts (Nuxt)
export default defineEventHandler(async (event) => {
  const { env } = event.context.cloudflare;
  const order = await createOrder(env, await readBody(event));

  // nem await-oljuk az emailküldést — a sor majd gondoskodik róla
  await env.JOBS.send({ type: "invoice-email", orderId: order.id });

  return { ok: true, orderId: order.id };   // azonnali válasz
});

2. példa: consumer részleges hibával — üzenetenkénti ack

Ez a Queues egyik legfontosabb mintája. Egy 50-es batchből mondjuk 48 sikerül, 2 nem — nem akarod az egész batchet újrafuttatni (az SQS-nél ez volt a visibility-timeout fejfájás). Üzenetenként döntesz:

consumer — üzenetenkénti sors
export default {
  async queue(batch, env, ctx) {
    for (const msg of batch.messages) {
      try {
        switch (msg.body.type) {
          case "invoice-email": await sendInvoice(env, msg.body.orderId); break;
          case "thumbnail":     await makeThumb(env, msg.body.key);       break;
        }
        msg.ack();                              // ✓ kész, soha többé nem jön
      } catch (err) {
        if (isRateLimit(err)) {
          msg.retry({ delaySeconds: 600 });     // külső API fullad → 10 perc múlva
        } else {
          msg.retry();                          // default retry_delay-jel
        }
      }
    }
    // amire se ack, se retry nem hívódott, azt a platform retry-olja
  },
};

3. példa: a poison message és a DLQ-consumer

Egy hibás üzenet (rossz formátum, törölt rekordra hivatkozik) örökké elbukna — a max_retries után a platform átteszi a jobs-dlq sorba. A DLQ ne temető legyen, hanem postafiók: tegyél rá egy saját consumert, ami riaszt és eltárolja a hullákat vizsgálatra:

DLQ-consumer — külön Workerben vagy külön consumer-configgal
async queue(batch, env) {
  for (const msg of batch.messages) {
    await env.DB.prepare(                       // D1-be naplózzuk vizsgálatra
      "INSERT INTO failed_jobs (body, failed_at) VALUES (?, ?)"
    ).bind(JSON.stringify(msg.body), new Date().toISOString()).run();
    msg.ack();                                  // a DLQ-ból már nem retry-olunk
  }
  await notifySlack(env, `${batch.messages.length} üzenet halt be a jobs sorban`);
}

Amit az SQS-hez képest tudnod kell

SQS-fogalomQueues-megfelelő
visibility timeout + deleteexplicit msg.ack() / msg.retry() — nincs „lejárt és visszaugrott" rejtély
long polling, receive loopnincs — push-modell, a platform hív
consumer-flotta méretezésautoskálázás max_concurrency plafonig
DelaySecondsdelaySeconds küldéskor és retry-nál is
FIFO queuenincs! — sorrendgarancia nélkül tervezz (lásd lent)
at-least-onceugyanúgy at-least-once — az idempotencia a te dolgod
A két tervezési kényszer, amit nem lehet megkerülni:Nincs FIFO. Ha a sorrend számít („order created" előbb, mint „order paid"), ne két üzenetre bontsd — vagy egy üzenet vigye az egész állapotot, vagy a feldolgozó nézze meg a DB-ben az aktuális állapotot, vagy használj Workflow-t, ahol a sorrend a kód maga. ② At-least-once = duplikátumok lesznek. A bevált recept: minden üzenetben legyen idempotencia-kulcs (pl. orderId + type), és a feldolgozás első lépése egy feltételes beszúrás egy processed_jobs táblába (D1, INSERT OR IGNORE) — ha már ott van, ack() és kész.

7.2Cron Triggers: az ütemezett munkák

A scheduled handler (2. modul) + egy sor konfig — az EventBridge Scheduler + külön Lambda megfelelője. A cron kifejezések UTC-ben értendők (a 3:00 budapesti hajnali takarítás nyáron 0 1 * * *!), és több cron is routolható egy Workeren belül:

{ "triggers": { "crons": ["0 1 * * *", "*/15 * * * *"] } }
async scheduled(event, env, ctx) {
  switch (event.cron) {
    case "0 1 * * *":    return nightlyJobs(env);
    case "*/15 * * * *": return syncExchangeRates(env);
  }
}

A fontos minta: a cron indító, nem munkás

A cron-futásra is CPU-limit vonatkozik, és ha félúton elhal, nincs beépített folytatás. Ezért az ökölszabály: a scheduled handler csak szétosztja a munkát, a tényleges melót a Queues vagy a Workflows végzi — így kapsz retry-t, láthatóságot és skálázást. Így néz ki a „napi digest email minden tenantnak" a te SaaS-odban:

cron → queue fan-out
async scheduled(event, env, ctx) {
  // 1. cron: kiolvassuk a címzetteket a registryből (5b. modul)
  const tenants = await env.REGISTRY
    .prepare("SELECT id FROM tenants WHERE status = 'active'").all();

  // 2. tenantonként egy üzenet — max 100-asával batchelt küldés
  const msgs = tenants.results.map(t =>
    ({ body: { type: "daily-digest", tenantId: t.id } }));
  for (let i = 0; i < msgs.length; i += 100)
    await env.JOBS.sendBatch(msgs.slice(i, i + 100));

  // 3. a consumer tenantonként, retry-jal, párhuzamosan dolgozik
}

Lokálisan tesztelni: wrangler dev --test-scheduled, majd curl "http://localhost:8787/__scheduled?cron=0+1+*+*+*" — nem kell hajnali egyig várnod.

7.3Workflows: a Step Functions, ha kódban írhatnád

A Step Functions-höz képest a leglényegesebb különbség: nincs ASL JSON, nincs vizuális state machine-szerkesztő — a folyamat egy sima TypeScript-osztály. A platform durable execution-t garantál: minden step.do eredménye perzisztálódik, és bármilyen hiba vagy újraindulás után az instance a legutóbbi sikeres lépés utáni ponttól folytatódik.

A mentális modell: a run() újrajátszódik, a lépések nem

Ezt az egy dolgot kell igazán megérteni, minden más ebből következik. Amikor egy instance folytatódik (retry, alvás utáni ébredés, platform-újraindulás), a run() metódus az elejétől újra lefut — de a már teljesített step.do-k nem futnak le újra, hanem a perzisztált eredményüket adják vissza azonnal. Ebből következik a három vasszabály:

1. futás — a „charge card" lépésnél a gép meghal: load order ✓ reserve stock ✓ charge card ✗ 2. futás (replay) — a kész lépések a tárból jönnek, a munka ott folytatódik: tárból ⚡ tárból ⚡ charge card ▶ send email…
7/2. ábra — Durable execution: a run() újrajátszódik, de a kész stepek eredménye a tárból jön — ezért kell minden mellékhatásnak steppen belül lennie.

1. példa: tenant-onboarding — az 5b. modul köre bezárul

src/workflows/onboard-tenant.ts
import { WorkflowEntrypoint } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";

export class OnboardTenant extends WorkflowEntrypoint<Env, { slug: string, region: string }> {
  async run(event, step) {
    const { slug, region } = event.payload;

    // 1) D1 létrehozása a tenant régiójában (5b.2!) — REST API-val
    const dbId = await step.do("create tenant db", async () => {
      const db = await createD1(this.env, `tenant-${slug}`, region);
      return db.uuid;                       // szerializálható → perzisztálódik
    });

    // 2) séma felhúzása — ha elhal, CSAK ez ismétlődik, a create nem
    await step.do("apply schema",
      { retries: { limit: 5, delay: "10 seconds", backoff: "exponential" } },
      () => queryTenant(this.env, dbId, SCHEMA_SQL, []));

    // 3) registry-bejegyzés — üzletileg érvénytelen slug? ne is próbálkozzunk újra
    await step.do("register", async () => {
      const ok = await registerTenant(this.env, slug, dbId, region);
      if (!ok) throw new NonRetryableError("slug already taken");
    });

    // 4) welcome email — mehet sorba is, de itt látszik a folyamatban
    await step.do("welcome email", () => sendWelcome(this.env, slug));
  }
}

Figyeld meg a három retry-viselkedést: a create tenant db a default szabályokkal megy; az apply schema saját, exponenciális backoffot kap; a register pedig NonRetryableError-ral azonnal, végleg elbukik, ha az ok üzleti (nem átmeneti) — ez a „retry storm" ellenszere.

2. példa: várakozás külső eseményre — a webhook-minta

trial-lejárat kezelése: alvás + esemény, versenyben
async run(event, step) {
  await step.do("start trial", () => activateTrial(this.env, event.payload.tenantId));

  try {
    // max 14 napig várunk arra, hogy előfizet — a webhook-endpoint ébreszt:
    //   const wf = await env.ONBOARD.get(instanceId);
    //   await wf.sendEvent({ type: "subscribed", payload: { plan } });
    const sub = await step.waitForEvent("wait for subscribe",
      { type: "subscribed", timeout: "14 days" });
    await step.do("activate paid plan", () => activate(this.env, sub.payload.plan));
  } catch {
    // timeout: nem fizetett elő — lezárjuk szépen
    await step.do("downgrade", () => downgradeToFree(this.env, event.payload.tenantId));
    await step.do("feedback email", () => sendFeedbackAsk(this.env, event.payload.tenantId));
  }
}

Vedd észre, mi nincs ebben a kódban: nincs „trial_expires_at" oszlop + napi cron, ami lejáratokat vadászik + állapotgép a DB-ben. A 14 napos üzleti folyamat egyetlen, olvasható függvény — és az alvás alatt nem fut és nem fizetsz semmit. Kiegészítésül van step.sleepUntil(dátum) fix időpontra, és saga-jellegű rollback is: a step.do-hoz kompenzáló handlert regisztrálhatsz (készlet-feloldás, refund), ami későbbi hibánál fordított sorrendben fut le.

Indítás, követés, üzemeltetés

// indítás a Workerből / API route-ból
const instance = await env.ONBOARD.create({
  id: `onboard-${slug}`,               // determinisztikus ID = dedup ingyen!
  params: { slug, region: "weur" },
});

// státusz bármikor: fut? melyik stepnél? mi volt a hiba?
const status = await (await env.ONBOARD.get(`onboard-${slug}`)).status();

A determinisztikus instance-ID egy rejtett gyöngyszem: ha a „subscribe" webhook kétszer érkezik és kétszer próbálnád indítani ugyanazt az onboardingot, a második create ugyanarra az ID-re elutasítódik — beépített dedup. A dashboardon (és wranglerrel) instance-onként látod a lépések állapotát, időzítését, hibáit — ez a Step Functions execution-history megfelelője.

7.4Durable Objects: az eszköz, aminek nincs AWS-megfelelője

Az 5b. modulból már tudod, mi ez a motorháztető alatt — most nézzük fejlesztői szemmel. A DO három tulajdonság együttese, és pont ez a kombináció nincs meg az AWS-en:

1. példa: rate limiter tenantonként

a klasszikus — pontos számláló verseny nélkül
import { DurableObject } from "cloudflare:workers";

export class RateLimiter extends DurableObject {
  async check(limit = 100) {
    const w = currentMinute();
    const row = this.ctx.storage.sql
      .exec("SELECT count FROM hits WHERE window = ?", w).one();
    if ((row?.count ?? 0) >= limit) return false;
    this.ctx.storage.sql.exec(
      "INSERT INTO hits(window,count) VALUES(?,1) ON CONFLICT(window) DO UPDATE SET count=count+1", w);
    return true;
  }
}

// a Workerben — névre címezve, RPC-hívásként:
const limiter = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName(tenantId));
if (!(await limiter.check(plan.rateLimit)))
  return new Response("Too many requests", { status: 429 });

Miért nem jó erre semmi más a palettáról? A KV eventually consistent és ~1 írás/s/kulcs — a számláló szétcsúszna. A D1-nél minden check egy hálózati kör a primary-ba — lassú és terheli a DB-t. A DO-nál a tenant limitere egy helyen, memóriaközel számol.

2. példa: készletfoglalás — a D1 „nincs interaktív tranzakció" rés betömése

Az 5. modulban függőben maradt a kérdés: mi van, ha a „read → üzleti döntés kódban → write" mintához tényleg erős konzisztencia kell? Íme a válasz — a jegyfoglalás tankönyvi példáján:

SeatReservation — check-then-act, garantáltan verseny nélkül
export class EventSeats extends DurableObject {
  async reserve(userId: string, qty: number) {
    // egyszálú vagyok: e két sor közé SENKI nem ékelődhet be
    const free = this.ctx.storage.sql
      .exec("SELECT free FROM seats").one().free;
    if (free < qty) return { ok: false, free };

    this.ctx.storage.sql.exec("UPDATE seats SET free = free - ?", qty);
    this.ctx.storage.sql.exec(
      "INSERT INTO holds(user_id, qty, expires_at) VALUES (?,?,?)",
      userId, qty, Date.now() + 600_000);

    // alarm: 10 perc múlva ébredek és felszabadítom a lejárt holdokat
    await this.ctx.storage.setAlarm(Date.now() + 600_000);
    return { ok: true };
  }

  async alarm() {   // a DO saját magának időzít — nincs külön cron!
    this.ctx.storage.sql.exec(
      "UPDATE seats SET free = free + (SELECT COALESCE(SUM(qty),0) FROM holds WHERE expires_at < ?)", Date.now());
    this.ctx.storage.sql.exec("DELETE FROM holds WHERE expires_at < ?", Date.now());
  }
}

Két dolgot mutat ez a példa. Egy: az egyszálúság miatt a SELECT és az UPDATE közötti üzleti logika helyesen versenymentes — az, amit Postgresben SELECT … FOR UPDATE-tel vagy serializable tranzakcióval csináltál. Kettő: az alarm — a DO saját magának időzíthet jövőbeli futást, így az „elengedett kosár / lejárt hold takarítás" nem igényel külön cron-t és sweep-query-ket: minden objektum a saját szemetét takarítja.

3. példa: élő jelenlét (presence) WebSockettel

ki nézi most ezt a dokumentumot? — hibernation API-val
export class DocPresence extends DurableObject {
  async fetch(request) {
    const [client, server] = Object.values(new WebSocketPair());
    this.ctx.acceptWebSocket(server);          // hibernation: alvó kapcsolat ≈ ingyen
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws, msg) {
    // broadcast mindenkinek, aki ugyanezt a dokumentumot nézi
    for (const peer of this.ctx.getWebSockets())
      if (peer !== ws) peer.send(msg);
  }
}

// routing a Workerből: dokumentumonként EGY objektum
const doc = env.PRESENCE.get(env.PRESENCE.idFromName(docId));
return doc.fetch(request);   // WebSocket upgrade továbbadása

AWS-en ugyanez: API Gateway WebSocket + Lambda + DynamoDB a connection-táblának + fan-out logika. Itt a „szoba" maga az objektum: a kapcsolatlista az állapota, a broadcast egy for-ciklus. A hibernation a költség-kulcs: a csendben ülő kapcsolatok alatt az objektum kikerül a memóriából, és nem fizetsz futásidőt — tízezres kapcsolatszám is olcsó.

DO-buktatók, amiket érdemes előre tudni:Soha ne legyen „egy globális" objektumod — az a rendszered szűk keresztmetszete (~500–1000 kérés/mp példányonként). Mindig kis egységekre címezz (tenant, szoba, dokumentum), túlterhelésnél pedig a névvel shardolj: idFromName(`${tenantId}:${shard}`). ② A DO ott jön létre, ahol először hívják — egy sydney-i user által életre hívott tenant-objektum Ausztráliában marad akkor is, ha a tenant többi usere Európában van. Fontos objektumoknál az első hívás helyét érdemes tudatosan megválasztani (vagy location hintet adni). ③ A konstruktorban ne dolgozz sokat — inicializálásra ott a blockConcurrencyWhile, de kérésenkénti használata sorosítja az egész objektumot.

7.5Melyiket mikor? — szcenáriókon keresztül

Szcenárió a SaaS-odbólEszközMiért ez?
checkout után számla-emailQueuesfüggetlen, egylépéses, retry-olható; a user nem vár rá
válasz utáni logolás/analitikactx.waitUntil()pár száz ms, ugyanabban a kérésben — sor-overhead felesleges
napi digest minden tenantnakCron → Queuesa cron csak szétoszt; a munka tenantonként retry-olható
tenant-onboarding (DB + séma + registry + email)Workflowstöbblépéses, egymásra épülő, félbeszakadhat — folytatható kell legyen
14 napos trial → előfizetés vagy downgradeWorkflows (waitForEvent)hosszan élő üzleti folyamat, külső eseménnyel
tenantonkénti API rate limitDurable Objectspontos számláló, kérésenkénti írás — KV/D1 alkalmatlan
készlet/ülőhely-foglalásDurable Objectscheck-then-act erős konzisztenciával, alarm a lejáratra
élő jelenlét / kollaborációDurable Objects (WebSocket)a „szoba" címezhető, állapota a kapcsolatlista
R2-feltöltés után thumbnailR2 event → Queuesaz „S3 event → Lambda" minta megfelelője (6. modul)
tenant-flotta séma-migrációjaWorkflowsaz 5b. modul runnere: lépésenkénti retry + resume

7.6Ellenőrizd magad

  1. Mi tűnik el az SQS-rutinodból a Queues-ra váltva, és mi az a két tervezési kényszer, ami marad?
    Válasz

    Eltűnik: polling, visibility timeout, consumer-infrastruktúra és méretezése. Marad: (1) nincs FIFO — sorrendfüggő logikát nem szabad több üzenetre bontani; (2) at-least-once kézbesítés — idempotencia-kulcs + feltételes beszúrás (processed_jobs) kell a duplikátumok ellen.

  2. Egy 50-es batchből 2 üzenet bukik el átmeneti hibával. Mi történik helyes consumer-kód esetén, és mi történne naiv kódnál?
    Válasz

    Helyesen: a 48 sikeres ack()-ot kap, a 2 hibás retry()-t (akár egyedi delay-jel) — csak azok jönnek újra. Naivan (ha a handler egészében dobja a hibát ack-ok nélkül): az egész batch újrafut, és a 48 sikeres művelet duplikálódhat — ezért is kell az idempotencia.

  3. Miért vasszabály, hogy Workflows-ban minden mellékhatás step.do-ba kerüljön?
    Válasz

    Mert a run() minden folytatáskor az elejétől újrajátszódik — csak a stepek eredménye jön a tárból. Egy stepen kívüli sendEmail() minden replay-kor újra elküldené a levelet; steppen belül pontosan egyszer fut le sikeresen.

  4. Mikor dobsz NonRetryableError-t egy stepben, és mi történne nélküle?
    Válasz

    Üzleti (nem átmeneti) hibánál — pl. „a slug már foglalt", érvénytelen input. Nélküle a platform a retry-szabályok szerint újra és újra próbálkozna egy soha nem javuló hibán, késleltetve a bukást és szemetelve a logokat.

  5. A jegyfoglalós példában miért nem jó a D1 batch()-e a DO helyett?
    Válasz

    Mert a döntés („van-e még hely?") az olvasás és az írás között, alkalmazáskódban születik — a batch atomikus, de nem interaktív: nem tudsz kódot futtatni két statement között. A DO egyszálúsága pont ezt a check-then-act rést zárja be.

  6. Mire való a DO alarm()-ja, és mit váltottál ki vele az AWS-világból a hold-lejáratos példában?
    Válasz

    Az objektum saját magának időzít jövőbeli futást. Kiváltja a „cron + sweep query az egész táblán" mintát (vagy SQS delay-üzeneteket): minden objektum a saját lejáró holdjait takarítja, pontosan akkor, amikor kell.

  7. Egy DO-alapú chat-szobád 2000 kérés/mp-nél fulladozik. Mi a helyes lépés?
    Válasz

    Nem „nagyobb példány" (olyan nincs), hanem szétbontás: shardolás névképzéssel, vagy a felelősségek szétválasztása több objektumra (pl. külön presence és külön üzenet-történet), és a Workerben aggregálsz.

Előző6. modul — Tárolás: R2 objektumtár és KV Következő 8. modul — CI/CD, környezetek, migrációk, release-stratégia