[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Replace Fragile Cron Jobs with Event-Driven Durable Workflows

Replace Fragile Cron Jobs with Event-Driven Durable Workflows

A fintech team replaces 30+ fragile cron jobs with durable event-driven workflows — using Inngest for event routing and step functions, Temporal for long-running financial processes, and dead letter queues for failure handling — eliminating silent failures, duplicate processing, and the 3 AM PagerDuty alerts that come from cron jobs that nobody understands.

Development#workflow#event-driven#durable#serverless#typescript
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 87/100·All SAFE
>

inngest

v1.1.0

Inngest is a durable workflow engine for TypeScript: functions are split into retryable steps whose results are saved, so a failure resumes where it stopped. Use it when a user asks for background jobs, scheduled (cron) jobs, multi-step workflows, delayed or drip emails, fan-out/fan-in processing, debouncing or rate limiting, or event-driven functions on serverless platforms such as Vercel, Netlify, AWS Lambda or Cloudflare. Covers the current v4 SDK (triggers inside the options object).

93/100 quality
10.00× impact
SAFE· stale score
View skill
>

temporal

v1.1.0

Temporal is a durable execution platform: you write workflows as ordinary code and the server replays them after crashes, so long-running processes survive restarts. Use it when the user asks to orchestrate microservices, build sagas with compensation, run long-lived or scheduled workflows, add reliable retries to background jobs, or wait on signals and timers.

87/100 quality
8.50× impact
SAFE· stale score
View skill
>

amqplib

v1.1.0

amqplib is the Node.js client library for RabbitMQ and other AMQP 0-9-1 brokers. Use it to publish and consume messages from JavaScript or TypeScript: work queues, topic exchanges, dead-letter queues, publisher confirms, manual acknowledgments, prefetch limits and automatic reconnection. Use when someone asks to "connect Node.js to RabbitMQ", "send messages to a queue from Node", "consume RabbitMQ messages in TypeScript", "retry failed jobs with a dead-letter queue" or "reconnect amqplib after the broker restarts". Covers amqplib 2.x.

83/100 quality
6.00× impact
SAFE· stale score
View skill
>

trigger-dev

v1.1.0

Trigger.dev is an open-source background jobs platform for TypeScript: long-running tasks with no timeouts, automatic retries, cron schedules, durable waits, queues and concurrency limits, deployed to Trigger.dev Cloud or self-hosted. Use when the user wants to run background jobs, scheduled tasks or cron in TypeScript, move work off a request handler, retry flaky API calls, wait for hours or days inside a job, or replace BullMQ and Redis.

93/100 quality
8.30× impact
SAFE· stale score
View skill
>

otel-js

v1.1.0

OpenTelemetry for Node.js adds distributed tracing and metrics to a service and exports them over OTLP to any backend. Use when a user asks to add distributed tracing, collect metrics, instrument HTTP requests, trace database queries, or set up observability for microservices. Covers the current SDK (resourceFromAttributes, ESM loader hook, OTLP endpoint rules).

77/100 quality
10.00× impact
SAFE· stale score
View skill
$

Sam is an SRE at a fintech company. The system has 34 cron jobs running on 3 servers. Nobody knows exactly what each one does. The documentation is a 2-year-old wiki page that says "DO NOT TOUCH." Last month, the settlement cron ran twice due to a clock sync issue and double-charged 200 customers. The month before, the reconciliation job silently failed for 2 weeks because the error went to a log file nobody reads. Sam's weekend PagerDuty rotation is 80% cron-related alerts.

The Problem: Why Cron Jobs Break

Every cron job has the same failure modes:

  1. No idempotency: If it runs twice, bad things happen (double charges, duplicate emails)
  2. Silent failures: Job crashes at 3 AM, nobody notices until a customer complains
  3. No retry logic: Transient API failure = entire job fails, no partial recovery
  4. No observability: "Did the job run? Did it succeed? How long did it take?" — nobody knows
  5. Coupling: Job A must complete before Job B starts, enforced by scheduling Job B 30 minutes later and hoping
  6. State management: Long-running jobs track state in temp files or database flags; restarts lose progress

Step 1: Inventory and Classify

Sam maps every cron job to one of four patterns:

EVENT-TRIGGERED (replace with event handlers):
  - Send welcome email after signup         → trigger: user.created
  - Generate invoice after payment          → trigger: payment.completed
  - Sync user to CRM after profile update   → trigger: user.updated
  - Send Slack alert on failed payment      → trigger: payment.failed

SCHEDULED (replace with durable scheduled tasks):
  - Daily settlement processing             → schedule: every day at 2 AM
  - Weekly reconciliation report            → schedule: every Monday at 6 AM
  - Monthly billing cycle                   → schedule: 1st of month
  - Hourly metrics aggregation              → schedule: every hour

LONG-RUNNING (replace with durable workflows):
  - End-of-day batch settlement             → workflow: multi-step with retries
  - Customer onboarding pipeline            → workflow: days-long, multi-step
  - Annual compliance report generation     → workflow: hours-long, data-intensive

Step 2: Event-Triggered Jobs → Inngest Functions

The simplest migration: jobs that react to events don't need schedules at all. They should run when the event happens, not on a timer.

typescript
// functions/payment-events.ts — Event-driven, replaces 4 cron jobs
import { inngest } from "./client";

// Was: cron job running every 5 minutes checking for new payments
// Now: triggers immediately when payment completes
export const generateInvoice = inngest.createFunction(
  {
    id: "generate-invoice",
    retries: 3,
    concurrency: { limit: 10 },           // Max 10 concurrent invoice generations
    idempotency: "event.data.paymentId",   // Same payment never processed twice
  },
  { event: "payment/completed" },
  async ({ event, step }) => {
    const payment = event.data;

    // Step 1: Generate PDF invoice (retries independently)
    const invoice = await step.run("generate-pdf", async () => {
      return await invoiceService.generatePDF({
        customerId: payment.customerId,
        amount: payment.amount,
        currency: payment.currency,
        items: payment.lineItems,
      });
    });

    // Step 2: Store in S3
    const url = await step.run("upload-to-s3", async () => {
      return await s3.upload(`invoices/${invoice.id}.pdf`, invoice.pdf);
    });

    // Step 3: Send to customer
    await step.run("send-email", async () => {
      await email.send({
        to: payment.customerEmail,
        template: "invoice",
        data: { invoiceUrl: url, amount: payment.amount },
      });
    });

    // Step 4: Update accounting system
    await step.run("sync-accounting", async () => {
      await xero.createInvoice({
        contactId: payment.xeroContactId,
        amount: payment.amount,
        invoiceNumber: invoice.id,
      });
    });

    return { invoiceId: invoice.id, url };
  },
);

// Was: cron job running every minute checking for failed payments
// Now: triggers immediately on failure
export const handleFailedPayment = inngest.createFunction(
  {
    id: "handle-failed-payment",
    retries: 0,                            // Don't retry the handler itself
  },
  { event: "payment/failed" },
  async ({ event, step }) => {
    const { customerId, amount, failureReason, attemptCount } = event.data;

    // Notify team
    await step.run("notify-slack", async () => {
      await slack.send("#payment-alerts", {
        text: `⚠️ Payment failed for customer ${customerId}: ${failureReason} (attempt ${attemptCount})`,
      });
    });

    // If 3rd failure, escalate
    if (attemptCount >= 3) {
      await step.run("create-ticket", async () => {
        await linear.createIssue({
          teamId: "payments",
          title: `Recurring payment failure: ${customerId}`,
          priority: 2,
          description: `Customer has ${attemptCount} consecutive failed payments. Last failure: ${failureReason}. Amount: ${amount}`,
        });
      });

      // Downgrade to free plan after 3 failures
      await step.run("downgrade-plan", async () => {
        await db.customers.update(customerId, { plan: "free", downgradeReason: "payment_failure" });
      });
    }
  },
);

Each step is independently retryable. If S3 upload fails, it retries just the upload — not the entire PDF generation. If the email service is down, the invoice is already generated and stored; only the email retries.

Step 3: Daily Settlement → Durable Workflow

The settlement process is the scariest cron job: it moves money. It must be idempotent, resumable, and auditable.

typescript
// functions/settlement.ts — Durable workflow, replaces the most dangerous cron job
export const dailySettlement = inngest.createFunction(
  {
    id: "daily-settlement",
    retries: 5,
    concurrency: { limit: 1 },            // NEVER run two settlements simultaneously
    idempotency: "event.data.settlementDate", // Same date never processed twice
  },
  { cron: "0 2 * * *" },                  // 2 AM daily (but now with all the safety guarantees)
  async ({ step }) => {
    const today = new Date().toISOString().split("T")[0];

    // Step 1: Lock settlement (prevent concurrent runs)
    const lock = await step.run("acquire-lock", async () => {
      const existing = await db.settlements.findFirst({
        where: { date: today, status: { in: ["processing", "completed"] } },
      });
      if (existing) throw new Error(`Settlement already ${existing.status} for ${today}`);

      return db.settlements.create({
        data: { date: today, status: "processing", startedAt: new Date() },
      });
    });

    // Step 2: Gather all pending transactions
    const transactions = await step.run("gather-transactions", async () => {
      return db.transactions.findMany({
        where: { status: "pending", createdAt: { lt: new Date(`${today}T00:00:00Z`) } },
      });
    });

    if (transactions.length === 0) {
      await step.run("mark-empty", async () => {
        await db.settlements.update(lock.id, { status: "completed", transactionCount: 0 });
      });
      return { settled: 0 };
    }

    // Step 3: Process in batches (each batch is a separate step = separate retry)
    const BATCH_SIZE = 100;
    let settled = 0;

    for (let i = 0; i < transactions.length; i += BATCH_SIZE) {
      const batch = transactions.slice(i, i + BATCH_SIZE);
      const batchResult = await step.run(`settle-batch-${i}`, async () => {
        return await paymentProcessor.settleBatch(batch.map(t => ({
          transactionId: t.id,
          amount: t.amount,
          destination: t.merchantBankAccount,
        })));
      });

      settled += batchResult.successCount;

      // Update progress (visible in dashboard)
      await step.run(`update-progress-${i}`, async () => {
        await db.settlements.update(lock.id, {
          processedCount: settled,
          totalCount: transactions.length,
        });
      });
    }

    // Step 4: Generate report
    await step.run("generate-report", async () => {
      const report = await generateSettlementReport(today, settled, transactions.length);
      await email.send({
        to: "finance@ourcompany.com",
        template: "settlement-report",
        data: { date: today, settled, total: transactions.length, reportUrl: report.url },
      });
    });

    // Step 5: Mark complete
    await step.run("mark-complete", async () => {
      await db.settlements.update(lock.id, {
        status: "completed",
        completedAt: new Date(),
        transactionCount: settled,
      });
    });

    return { date: today, settled, total: transactions.length };
  },
);

Results

After migrating all 34 cron jobs over 3 months:

  • Silent failures eliminated: Every function has built-in alerting; failures are visible in Inngest dashboard within seconds
  • Double-processing eliminated: Idempotency keys prevent duplicate runs; the settlement double-charge incident is now architecturally impossible
  • PagerDuty alerts: Down 85% on weekends; remaining 15% are genuine infrastructure issues, not cron mysteries
  • Retry success rate: 94% of transient failures self-heal via automatic retries; only 6% need human intervention
  • Settlement reliability: Zero settlement failures in 3 months (was 2-3/month with cron)
  • Observability: Every step of every workflow is visible in the dashboard — when it ran, how long it took, what it returned
  • Development speed: New event handlers ship in hours, not days; no crontab editing, no server SSH
  • Documentation: The code IS the documentation; function definitions describe exactly what happens and when
  • Cost: $89/month for Inngest (was $0 for cron, but $50K+/year in incident response and customer refunds)