Browse documentation

Specifications

RankFlow AI — Job Queue & Workflow Engine Documentation

│ Workflow Orchestration │

docs/specs/job-queue-workflows.md
On this page

Version: 1.0.0
Systems: Inngest (durable workflows) + BullMQ (background jobs) + Redis
Purpose: Orchestrate all automation: onboarding, citations, posts, reports, monitoring


1. Architecture Overview#

┌─────────────────────────────────────────────────────────────┐
│                    Workflow Orchestration                   │
│                                                              │
│  ┌─────────────┐      ┌─────────────┐      ┌───────────┐  │
│  │   Inngest   │      │   BullMQ    │      │   Redis   │  │
│  │  (Complex)  │      │  (Simple)   │      │  (Queue   │  │
│  │             │      │             │      │  + Cache)  │  │
│  │ • Onboarding│      │ • GBP Post  │      │           │  │
│  │ • Citation  │      │ • Social    │      │           │  │
│  │   Builder   │      │   Post      │      │           │  │
│  │ • Site      │      │ • Review    │      │           │  │
│  │   Evolution │      │   Monitor   │      │           │  │
│  │ • Monthly   │      │ • NAP Check │      │           │  │
│  │   Report    │      │ • Email     │      │           │  │
│  │ • Content   │      │   Send      │      │           │  │
│  │   Generate  │      │ • Token     │      │           │  │
│  │             │      │   Refresh   │      │           │  │
│  └─────────────┘      └─────────────┘      └───────────┘  │
│        │                      │                            │
│        └──────────────────────┘                            │
│                      │                                       │
│                      ↓                                       │
│              ┌─────────────┐                                │
│              │   Workers   │                                │
│              │  (VPS/Docker)│                                │
│              └─────────────┘                                │
└─────────────────────────────────────────────────────────────┘

When to Use What#

Complexity Use Examples
Multi-step, durable, can wait days Inngest Onboarding (waits for GBP OAuth), citation builder (sleeps 7d then verifies)
Single-step, fire-and-forget BullMQ Publish GBP post, send email, refresh token
Scheduled recurring BullMQ Daily review monitor, weekly posts, monthly NAP check
Event-driven chain Inngest Onboarding → site generate → wait for auth → first post

2. Inngest Workflow Engine#

Client Setup#

// src/lib/inngest.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "rankflow",
  eventKey: process.env.INNGEST_EVENT_KEY,
});

Workflow Functions#

Function ID Event Trigger Purpose Steps
onboarding-pipeline skill/01-practice-onboard Full client onboarding 6 steps
citation-builder skill/13-citation-submit Submit to 30 directories 6 steps
site-evolution cron: 0 2 * * 1 Weekly AI-driven site updates 7 steps
monthly-report cron: 0 3 1 * * Generate & email monthly report 4 steps
content-generate skill/29-content-generate AI content with approval gate 3 steps
token-refresh cron: 0 3 * * * Refresh all OAuth tokens 1 step (batch)

Example: Onboarding Workflow#

// src/server/inngest/functions/onboarding.ts

export const onboardingWorkflow = inngest.createFunction(
  {
    id: "onboarding-pipeline",
    retries: 3,
    concurrency: { limit: 5 },
  },
  { event: "skill/01-practice-onboard" },
  async ({ event, step }) => {
    const { practice_id, user_id } = event.data;
    
    // Step 1: Validate practice exists
    const practice = await step.run("validate-practice", async () => {
      return await db.practice.findUnique({ 
        where: { id: practice_id },
        include: { locations: true },
      });
    });
    
    // Step 2: Generate landing page content
    const siteContent = await step.run("generate-site", async () => {
      return await site.generate(practice_id);
    });
    
    // Step 3: Deploy landing page
    await step.run("deploy-site", async () => {
      return await site.deploy(practice_id, siteContent);
    });
    
    // Step 4: Notify client to connect GBP
    await step.run("notify-gbp-auth", async () => {
      const authUrl = await gbpAuth.getAuthUrl(practice_id);
      await email.send({
        to: practice.owner.email,
        subject: "Connect your Google Business Profile",
        body: `Please connect: ${authUrl}`,
      });
    });
    
    // Step 5: WAIT for OAuth callback (can wait up to 7 days)
    const oauthResult = await step.waitForEvent("gbp/oauth-callback", {
      timeout: "7d",
      match: "data.practice_id",
    });
    
    // Step 6: Generate first GBP post
    await step.run("create-first-post", async () => {
      const content = await ai.generate({
        task: "gbp_post",
        context: { practice, location: practice.locations[0] },
      });
      await bullmq.queues.gbpPostPublish.add("first-post", {
        practiceId: practice_id,
        content,
      });
    });
    
    // Step 7: Schedule recurring posts
    await step.run("schedule-recurring", async () => {
      await scheduleRecurringJobs(practice_id);
    });
    
    // Step 8: Sleep 1 day, then send welcome report
    await step.sleep("1d");
    
    await step.run("send-welcome-report", async () => {
      const report = await generateWelcomeReport(practice_id);
      await email.send({
        to: practice.owner.email,
        subject: "Your RankFlow Welcome Report",
        body: report,
      });
    });
    
    return { practice_id, status: "onboarded" };
  }
);

Example: Citation Builder Workflow#

// src/server/inngest/functions/citation-build.ts

export const citationBuilder = inngest.createFunction(
  {
    id: "citation-builder",
    retries: 3,
    concurrency: { limit: 3 }, // Conservative — directory sites are slow
  },
  { event: "skill/13-citation-submit" },
  async ({ event, step }) => {
    const { practice_id, location_id } = event.data;
    
    // Step 1: Get practice and location
    const [practice, location] = await step.run("get-data", async () => {
      return await Promise.all([
        db.practice.findUnique({ where: { id: practice_id } }),
        db.location.findUnique({ where: { id: location_id } }),
      ]);
    });
    
    // Step 2: Get active directories
    const directories = await step.run("get-directories", async () => {
      return await db.citationDirectory.findMany({ where: { isActive: true } });
    });
    
    // Step 3: Generate unique descriptions for each directory
    const descriptions = await step.run("generate-descriptions", async () => {
      return await Promise.all(
        directories.map(async (dir) => ({
          directoryId: dir.id,
          description: await ai.generate({
            task: "citation_description",
            context: { practice, location, directory: dir },
          }),
        }))
      );
    });
    
    // Step 4: Submit to each directory (parallel with concurrency limit)
    const results = await Promise.all(
      directories.map(async (dir, i) => {
        return await step.run(`submit-${dir.name}`, async () => {
          try {
            if (dir.submissionType === "API") {
              return await submitViaApi(dir, practice, location, descriptions[i]);
            } else {
              return await submitViaBrowser(dir, practice, location, descriptions[i]);
            }
          } catch (error) {
            return { success: false, error: (error as Error).message };
          }
        });
      })
    );
    
    // Step 5: Store results
    await step.run("store-results", async () => {
      await db.citation.createMany({
        data: results.map((r, i) => ({
          practiceId: practice_id,
          locationId: location_id,
          directoryName: directories[i].name,
          status: r.success ? "SUBMITTED" : "FAILED",
          submittedAt: r.success ? new Date() : null,
          errorMessage: r.error || null,
        })),
      });
    });
    
    // Step 6: Schedule NAP verification (1 week later)
    await step.sleep("7d");
    
    await step.run("schedule-verification", async () => {
      await inngest.send({
        name: "skill/14-citation-verify-nap",
        data: { practice_id, location_id },
      });
    });
    
    return { 
      submitted: results.filter(r => r.success).length, 
      failed: results.filter(r => !r.success).length 
    };
  }
);

Inngest Event Types#

// src/server/inngest/events.ts

export type RankFlowEvents = {
  "skill/01-practice-onboard": {
    data: { practice_id: string; user_id: string };
  };
  "skill/13-citation-submit": {
    data: { practice_id: string; location_id: string };
  };
  "skill/14-citation-verify-nap": {
    data: { practice_id: string; location_id: string };
  };
  "skill/32-report-generate": {
    data: { practice_id: string; period_start: string; period_end: string };
  };
  "gbp/oauth-callback": {
    data: { practice_id: string; account_email: string };
  };
  "social/oauth-callback": {
    data: { practice_id: string; platform: string; connection_id: string };
  };
};

3. BullMQ Job Queue#

Queue Setup#

// src/server/bullmq/queue.ts

import { Queue } from "bullmq";
import { redis } from "@/lib/redis";

export const queues = {
  gbpPostPublish: new Queue("gbp-post-publish", { connection: redis }),
  socialPostPublish: new Queue("social-post-publish", { connection: redis }),
  reviewMonitor: new Queue("review-monitor", { connection: redis }),
  napCheck: new Queue("nap-check", { connection: redis }),
  emailSend: new Queue("email-send", { connection: redis }),
  tokenRefresh: new Queue("token-refresh", { connection: redis }),
  citationSubmit: new Queue("citation-submit", { connection: redis }),
  pdfGenerate: new Queue("pdf-generate", { connection: redis }),
};

Worker Setup#

// src/server/bullmq/worker.ts

import { Worker } from "bullmq";
import { redis } from "@/lib/redis";
import { logger } from "@/lib/logger";

export function createWorkers() {
  const workers = [
    new Worker("gbp-post-publish", gbpPostProcessor, { 
      connection: redis, 
      concurrency: 3,
      limiter: { max: 30, duration: 60000 }, // 30/min per queue
    }),
    
    new Worker("social-post-publish", socialPostProcessor, { 
      connection: redis, 
      concurrency: 5,
      limiter: { max: 25, duration: 60000 }, // Platform limits
    }),
    
    new Worker("review-monitor", reviewMonitorProcessor, { 
      connection: redis, 
      concurrency: 2,
    }),
    
    new Worker("nap-check", napCheckProcessor, { 
      connection: redis, 
      concurrency: 2,
    }),
    
    new Worker("email-send", emailSendProcessor, { 
      connection: redis, 
      concurrency: 5,
    }),
    
    new Worker("token-refresh", tokenRefreshProcessor, { 
      connection: redis, 
      concurrency: 1, // Sequential to avoid rate limits
    }),
    
    new Worker("citation-submit", citationSubmitProcessor, {
      connection: redis,
      concurrency: 3,
    }),
    
    new Worker("pdf-generate", pdfGenerateProcessor, {
      connection: redis,
      concurrency: 2,
    }),
  ];
  
  return workers;
}

Scheduled Recurring Jobs#

// src/server/bullmq/scheduler.ts

export async function scheduleRecurringJobs() {
  // GBP post scheduler — 2-3x per week per client
  await queues.gbpPostPublish.add(
    "schedule-posts",
    {},
    { repeat: { pattern: "0 9 * * 1,3,5" } } // Mon, Wed, Fri at 9 AM IST
  );
  
  // Review monitor — daily at 8 AM
  await queues.reviewMonitor.add(
    "check-reviews",
    {},
    { repeat: { pattern: "0 8 * * *" } }
  );
  
  // NAP check — 1st of month at 2 AM
  await queues.napCheck.add(
    "check-nap",
    {},
    { repeat: { pattern: "0 2 1 * *" } }
  );
  
  // Token refresh — daily at 3 AM
  await queues.tokenRefresh.add(
    "refresh-tokens",
    {},
    { repeat: { pattern: "0 3 * * *" } }
  );
  
  // Monthly report — 1st of month at 4 AM
  await queues.emailSend.add(
    "monthly-reports",
    {},
    { repeat: { pattern: "0 4 1 * *" } }
  );
  
  // Site evolution — every Monday at 2 AM
  await queues.gbpPostPublish.add(
    "site-evolution",
    {},
    { repeat: { pattern: "0 2 * * 1" } }
  );
}

4. Job Types & Scheduling#

Complete Job Registry#

Job Type Trigger Frequency Duration Priority Queue
Onboarding Pipeline Client signup One-time 5-10 min High Inngest
Citation Builder Onboarding / Monthly refresh Per client / month 10-15 min Medium Inngest
Landing Page Deploy Onboarding / Content update Per client / month 1-2 min High Inngest
GBP Post Publish Cron 2-3x / week / client 30 sec Medium BullMQ
Social Post Publish Cron 2-4x / week / client 30 sec Medium BullMQ
Review Monitor Cron Daily / client 1-2 min High BullMQ
NAP Consistency Check Cron Monthly / client 5-10 min Low BullMQ
Monthly Report Cron (1st of month) Monthly / client 2-3 min Medium Inngest
Content Refresh Cron Monthly / client 10-15 min Medium Inngest
Token Refresh Cron Daily (batch) 1-2 min High BullMQ
Email Send Event-driven On-demand 5-10 sec Medium BullMQ
PDF Generate Event-driven On-demand 10-30 sec Medium BullMQ
Citation Verify Event (7d after submit) Per citation 2-3 min Low Inngest
Site Evolution Cron (weekly) Weekly 5-10 min Low Inngest

5. Retry Policies & Dead Letter Queue#

Default Retry Config#

const DEFAULT_RETRY = {
  attempts: 3,
  backoff: {
    type: "exponential" as const,
    delay: 5000, // 5s initial
  },
};

const JOB_RETRY_CONFIGS: Record<string, { attempts: number; delay: number }> = {
  "gbp-post-publish": { attempts: 3, delay: 5000 },
  "social-post-publish": { attempts: 3, delay: 10000 },
  "review-monitor": { attempts: 5, delay: 60000 },
  "nap-check": { attempts: 3, delay: 300000 }, // 5min
  "citation-submit": { attempts: 5, delay: 60000 },
  "email-send": { attempts: 3, delay: 5000 },
  "token-refresh": { attempts: 5, delay: 60000 },
  "pdf-generate": { attempts: 2, delay: 10000 },
};

Dead Letter Queue#

// src/server/bullmq/dead-letter.ts

import { Queue } from "bullmq";

export const deadLetterQueue = new Queue("dead-letter", { connection: redis });

export async function handleFailedJob(job: Job, err: Error) {
  if (job.attemptsMade >= (JOB_RETRY_CONFIGS[job.name]?.attempts || 3)) {
    await deadLetterQueue.add(job.name, {
      originalJobId: job.id,
      payload: job.data,
      error: err.message,
      stack: err.stack,
      failedAt: new Date().toISOString(),
      attemptsMade: job.attemptsMade,
    });
    
    // Notify admin
    await email.send({
      to: "admin@rankflow.ai",
      subject: `Job Failed: ${job.name}`,
      body: `Job ${job.id} failed after ${job.attemptsMade} attempts. Error: ${err.message}`,
    });
    
    logger.error({
      jobId: job.id,
      jobName: job.name,
      error: err.message,
      attempts: job.attemptsMade,
    }, "Job moved to dead letter queue");
  }
}

Manual Retry from Admin#

// Admin can retry dead letter jobs via dashboard
export async function retryDeadLetterJob(jobId: string) {
  const job = await deadLetterQueue.getJob(jobId);
  if (!job) throw new Error("Job not found in dead letter queue");
  
  const { payload, originalJobId } = job.data;
  
  // Re-queue to original queue
  const targetQueue = queues[job.name as keyof typeof queues];
  if (targetQueue) {
    await targetQueue.add(job.name, payload, {
      jobId: originalJobId, // Preserve original ID
    });
    await job.remove(); // Remove from DLQ
  }
}

6. Observability & Monitoring#

Structured Logging#

// Every job logs:
logger.info({
  event: "job_started",
  jobId: job.id,
  jobName: job.name,
  practiceId: job.data.practiceId,
  queue: queue.name,
  attempt: job.attemptsMade + 1,
}, "Job started");

logger.info({
  event: "job_completed",
  jobId: job.id,
  jobName: job.name,
  practiceId: job.data.practiceId,
  durationMs: Date.now() - startTime,
}, "Job completed");

logger.error({
  event: "job_failed",
  jobId: job.id,
  jobName: job.name,
  error: err.message,
  attempt: job.attemptsMade,
}, "Job failed");

Metrics to Track#

Metric Source Alert Threshold
Queue depth BullMQ > 100 jobs pending
Job failure rate BullMQ > 5% in 1 hour
Avg job duration BullMQ > 2x baseline
Inngest run latency Inngest dashboard > 5 min per step
Dead letter count DLQ > 10 in 24h
Worker CPU VPS > 80% for 5 min

Health Check Endpoint#

// src/app/api/health/jobs/route.ts

export async function GET() {
  const queueStatuses = await Promise.all(
    Object.entries(queues).map(async ([name, queue]) => {
      const [waiting, active, completed, failed] = await Promise.all([
        queue.getWaitingCount(),
        queue.getActiveCount(),
        queue.getCompletedCount(),
        queue.getFailedCount(),
      ]);
      return { name, waiting, active, completed, failed };
    })
  );
  
  return NextResponse.json({
    timestamp: new Date().toISOString(),
    queues: queueStatuses,
    overall: {
      totalWaiting: queueStatuses.reduce((s, q) => s + q.waiting, 0),
      totalActive: queueStatuses.reduce((s, q) => s + q.active, 0),
      totalFailed: queueStatuses.reduce((s, q) => s + q.failed, 0),
    },
  });
}

7. Scalability Strategy#

Horizontal Scaling#

Web Server (Vercel/Dokploy)          Job Workers (VPS/Docker)
┌─────────────────────┐               ┌─────────────────────┐
│ Next.js App         │               │ Worker Container 1  │
│ - API routes        │               │ - GBP posts         │
│ - tRPC handlers     │               │ - Social posts      │
│ - Inngest handler   │               │ - Review monitor    │
└─────────────────────┘               ├─────────────────────┤
                                      │ Worker Container 2  │
                                      │ - Citation submit   │
                                      │ - NAP check         │
                                      │ - PDF generate      │
                                      ├─────────────────────┤
                                      │ Worker Container N  │
                                      │ (scale by queue depth)│
                                      └─────────────────────┘

Scaling Rules#

Condition Action
Queue depth > 50 Start additional worker container
Queue depth > 200 Alert admin, consider throttling new jobs
Worker CPU > 80% Scale to next container tier
Job duration > 2x baseline Investigate, may need optimization

Concurrency Limits#

Job Type Max Concurrent Reason
GBP Post 3 per account API rate limits
Social Post 5 per platform Platform limits
Citation Submit 3 total Directory sites are slow
Review Monitor 2 per account API quotas
NAP Check 2 total Proxy bandwidth

8. Idempotency & Safety#

Idempotency Keys#

// All jobs must be idempotent
export function generateJobId(practiceId: string, jobType: string, date?: string): string {
  return `${practiceId}:${jobType}:${date || new Date().toISOString().slice(0, 10)}`;
}

// Example: GBP post for client on 2025-01-15
await queues.gbpPostPublish.add(
  "weekly-post",
  { practiceId: "abc", content: "..." },
  { jobId: generateJobId("abc", "gbp-post", "2025-01-15") }
);
// If re-run, same jobId prevents duplicate

Safety Checks#

Check Implementation
Duplicate prevention Job ID based on practice + type + date
GBP suspension guard Max 3 posts/week per location
Social rate limit Per-platform queue limiters
Token expiry Refresh 24h before expiry
Cost cap Per-practice monthly AI budget
Data integrity All DB writes in transactions

Graceful Shutdown#

// src/server/bullmq/graceful-shutdown.ts

export async function gracefulShutdown(workers: Worker[]) {
  logger.info("Starting graceful shutdown...");
  
  // Stop accepting new jobs
  for (const worker of workers) {
    await worker.pause();
  }
  
  // Wait for active jobs to complete (30s timeout)
  await Promise.all(
    workers.map(worker => worker.close())
  );
  
  logger.info("All workers closed. Shutdown complete.");
}

End of Job Queue & Workflow Engine Documentation