Skip to content

Status: ✅ Implemented Date: November 27, 2025 Replaces: Redis/BullMQ + QStash


Executive Summary

TendSocial has migrated from Redis/BullMQ and QStash to Google Cloud Tasks for all asynchronous job processing. This provides:

  • Zero-cost scaling for low volumes (3M free operations/month)
  • Simpler architecture - no persistent worker containers needed
  • Better cost efficiency - pay only for what you execute
  • Native GCP integration - works seamlessly with Cloud Run
  • Built-in reliability - automatic retries with exponential backoff

Architecture Overview

Before (Redis/BullMQ + QStash)

┌──────────────┐
│  Frontend    │
└──────┬───────┘
       │ API Call

┌──────────────────────────────────────────┐
│     Cloud Run (Fastify API)              │
│  - Accept request                        │
│  - Enqueue to Redis                      │
│  - Return immediately                    │
└──────┬───────────────────────────────────┘

       ├──► Upstash Redis (Job Queue)
       │    - Holds pending jobs
       │    - Polls every 30s
       │    - Cost: $1/month continuous

       └──► QStash Webhook
            - Scheduled post execution
            - Cost: $0.50 per 100 ops after free tier

After (Cloud Tasks)

┌──────────────┐
│  Frontend    │
└──────┬───────┘
       │ API Call

┌──────────────────────────────────────────┐
│     Cloud Run (Fastify API)              │
│  - Accept request                        │
│  - Create Cloud Task                     │
│  - Return immediately                    │
└──────┬───────────────────────────────────┘
       │ HTTPS

┌──────────────────────────────────────────┐
│   Google Cloud Tasks                     │
│  - Manages scheduling                    │
│  - Handles retries                       │
│  - Invokes webhook at scheduled time     │
│  - Cost: Free for 3M ops/month           │
└──────┬───────────────────────────────────┘
       │ HTTPS

┌──────────────────────────────────────────┐
│ Cloud Run Webhook Handler                │
│  - POST /api/webhooks/publish-post       │
│  - POST /api/webhooks/sync-analytics     │
│  - Processes job, updates DB             │
│  - Returns 200 (success) or 500 (retry)  │
└──────────────────────────────────────────┘

Key Components

1. Cloud Tasks Service (cloudTasks.service.ts)

Wrapper around Google Cloud Tasks API:

typescript
// Enqueue a job
const taskName = await cloudTasks.enqueueTask({
  url: 'https://api.tendsocial.com/webhooks/publish-post',
  method: 'POST',
  body: { scheduledPostId: '123', platform: 'linkedin' },
  scheduleTime: new Date('2025-11-28 10:00:00'),
  idempotencyKey: 'publish-123-linkedin' // Prevents duplicates
});

// Cancel a job
await cloudTasks.cancelTask(taskName);

// Get job status
const task = await cloudTasks.getTask(taskName);

Features:

  • Idempotency keys prevent duplicate task creation
  • Automatic exponential backoff on failure
  • OIDC token verification for webhook security

2. Scheduled Post Service (scheduledPost.service.ts)

Business logic for managing scheduled posts:

typescript
// Schedule a post for publishing
const scheduledPostId = await scheduledPostService.schedulePost({
  companyId: 'acme-123',
  userId: 'user-456',
  content: 'Check out our new blog post!',
  platforms: [
    { platform: 'linkedin', accountId: 'li-account-1' },
    { platform: 'twitter', accountId: 'tw-account-1' },
    { platform: 'instagram', accountId: 'ig-account-1' }
  ],
  scheduledAt: new Date('2025-11-28 10:00:00')
});

// Cancel a scheduled post
await scheduledPostService.cancelScheduledPost(companyId, scheduledPostId);

// Reschedule for later
const newId = await scheduledPostService.reschedulePost(
  companyId,
  scheduledPostId,
  new Date('2025-11-29 10:00:00')
);

What it does:

  • Creates ONE Cloud Task per platform (for granular control)
  • Tracks each platform's publishing status separately
  • Handles partial failures (some platforms succeed, others fail)
  • Maintains audit trail in PublishLog table

3. Webhook Handlers (routes/jobs.ts)

Cloud Tasks invokes these endpoints when it's time to execute a job:

/api/jobs/publish-post (POST)

Called by Cloud Tasks when a scheduled post is ready to publish.

Request:

json
{
  "scheduledPostId": "uuid",
  "platform": "linkedin",
  "accountId": "acme-li-1",
  "content": "Blog post text",
  "mediaAssetIds": ["asset-1", "asset-2"],
  "idempotencyKey": "publish-uuid-linkedin"
}

Behavior:

  1. Verify webhook secret (from X-Webhook-Secret header)
  2. Check idempotency key - skip if already published
  3. Call platform publishing service
  4. Update PublishLog with result
  5. If all platforms succeed, mark ScheduledPost as published
  6. Return 200 (success) or 500 (trigger retry)

/api/jobs/sync-analytics (POST)

Called by Google Cloud Scheduler periodically to sync analytics.

Request:

json
{
  "companyId": "uuid (optional)",
  "idempotencyKey": "sync-analytics-2025-11-27-10:00"
}

Behavior:

  1. Verify webhook secret
  2. Check idempotency key
  3. Trigger analytics sync for all platforms
  4. Create child Cloud Tasks for parallel metric collection
  5. Return 200 with count of jobs created

Data Models

ScheduledPost (Updated)

prisma
model ScheduledPost {
  id                String
  companyId         String
  userId            String

  content           String
  mediaAssetIds     String[]

  // Scheduling
  scheduledAt       DateTime
  timezone          String
  status            String  // pending, processing, published, failed, cancelled

  // Cloud Tasks tracking
  cloudTaskNames    String[]  // Cloud Task resource names

  // Idempotency & tracking
  idempotencyKey    String @unique

  // Results
  results           Json?
  errorMessage      String?
  attempts          Int
  lastAttemptAt     DateTime?

  // Multi-platform targets
  platforms         Json  // [{ platform, accountId, customContent? }]

  // Audit trail
  publishLogs       PublishLog[]
}

PublishLog (New)

Tracks the publishing status for each platform separately:

prisma
model PublishLog {
  id                String
  scheduledPostId   String

  // Platform info
  platform          String  // "linkedin", "twitter", etc.
  accountId         String  // Which account on the platform

  // Result tracking
  status            String  // success, failed, pending, retry
  remoteId          String?  // Platform's post ID
  remoteUrl         String?  // Platform's post URL

  // Error handling
  errorCode         String?  // Platform-specific error
  errorMessage      String?

  // Retry tracking
  attempts          Int
  nextRetryAt       DateTime?
  lastRetryAt       DateTime?

  // Idempotency
  idempotencyKey    String @unique

  // Debugging
  rawResponse       Json?
}

AnalyticsSyncJob (Updated)

prisma
model AnalyticsSyncJob {
  id                String

  // Scope
  companyId         String?
  contentType       ContentType?
  platform          String?

  // Scheduling
  scheduledFor      DateTime
  priority          String

  // Status
  status            String  // pending, running, completed, failed
  startedAt         DateTime?
  completedAt       DateTime?

  // Results
  itemsProcessed    Int
  itemsFailed       Int
  errorMessage      String?

  // Cloud Tasks tracking
  cloudTaskName     String?
  idempotencyKey    String @unique
}

Publishing Flow (Multi-Platform)

User Schedule Social Post to LinkedIn + Twitter + Instagram

1. Frontend: POST /api/social-posts/schedule
   {
     "content": "...",
     "platforms": [
       { "platform": "linkedin", "accountId": "..." },
       { "platform": "twitter", "accountId": "..." },
       { "platform": "instagram", "accountId": "..." }
     ],
     "scheduledAt": "2025-11-28T10:00:00Z"
   }

2. Backend (scheduledPost.service.ts):
   ✓ Create ScheduledPost record (status: pending)
   ✓ Create 3 Cloud Tasks (one per platform)
   ✓ Create 3 PublishLog records (status: pending)
   ✓ Return scheduledPostId to frontend

3. Frontend displays:
   - "Your post is scheduled for Nov 28 at 10:00 AM"
   - Platforms: LinkedIn ⏳ | Twitter ⏳ | Instagram ⏳

4. At Nov 28, 10:00 AM:
   Cloud Tasks invokes webhook 3 times:

   a) POST /api/jobs/publish-post
      { scheduledPostId, platform: "linkedin", ... }
      → Publishes to LinkedIn
      → Updates PublishLog (status: success/failed)

   b) POST /api/jobs/publish-post
      { scheduledPostId, platform: "twitter", ... }
      → Publishes to Twitter
      → Updates PublishLog (status: success/failed)

   c) POST /api/jobs/publish-post
      { scheduledPostId, platform: "instagram", ... }
      → Publishes to Instagram
      → Updates PublishLog (status: success/failed)

5. Webhook checks all PublishLogs:
   - If all succeeded: ScheduledPost.status = "published"
   - If some failed: ScheduledPost.status = "failed"
   - Frontend polls and updates UI

6. Example Result:
   - LinkedIn ✅ | Twitter ✅ | Instagram ❌
   - "2 of 3 platforms published successfully"
   - User can retry Instagram separately

Cost Comparison

At 100 Clients, Scheduling 2 Posts/Week

Old (Redis + QStash):

  • Upstash Redis: $10/month (continuous instance)
  • QStash: 200 messages/month = $0.50 (free tier covers 3,000/month)
  • Total: $10.50/month

New (Cloud Tasks):

  • Google Cloud Tasks: 200 tasks/month = FREE (3M free ops/month)
  • Total: $0/month

At 1,000 Clients

Old:

  • Upstash Redis: $50/month
  • QStash: 2,000 messages/month = $5
  • Total: $55/month

New:

  • Google Cloud Tasks: 2,000 tasks/month = FREE
  • Total: $0/month

At 10,000 Clients (Scale)

Old:

  • Upstash Redis: $200+/month
  • QStash: 20,000 messages/month = $50
  • Total: $250+/month

New:

  • Google Cloud Tasks: 20,000 tasks/month = FREE
  • Total: $0/month

Environment Variables

Required (Cloud Tasks)

bash
GCP_PROJECT_ID=tendsocial
GCP_LOCATION=us-central1
GCP_TASKS_QUEUE=default
WEBHOOK_URL=https://tendsocial-backend-xxxxx-uc.a.run.app
WEBHOOK_SECRET=your-secret-key-minimum-32-chars

Optional

bash
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
# (Auto-detected in Cloud Run via workload identity)

Database Migration

sql
-- Create PublishLog table
CREATE TABLE "PublishLog" (
  id TEXT PRIMARY KEY,
  "scheduledPostId" TEXT NOT NULL REFERENCES "ScheduledPost"(id) ON DELETE CASCADE,
  platform TEXT NOT NULL,
  "accountId" TEXT NOT NULL,
  status TEXT NOT NULL,
  "remoteId" TEXT,
  "remoteUrl" TEXT,
  "errorCode" TEXT,
  "errorMessage" TEXT,
  attempts INT DEFAULT 1,
  "nextRetryAt" TIMESTAMP,
  "lastRetryAt" TIMESTAMP,
  "idempotencyKey" TEXT UNIQUE NOT NULL,
  "rawResponse" JSON,
  "createdAt" TIMESTAMP DEFAULT NOW(),
  "updatedAt" TIMESTAMP DEFAULT NOW()
);

-- Update ScheduledPost table
ALTER TABLE "ScheduledPost" ADD COLUMN "cloudTaskNames" TEXT[] DEFAULT '{}';
ALTER TABLE "ScheduledPost" ADD COLUMN "idempotencyKey" TEXT UNIQUE;
ALTER TABLE "ScheduledPost" ADD COLUMN "errorMessage" TEXT;
ALTER TABLE "ScheduledPost" ADD COLUMN "attempts" INT DEFAULT 0;
ALTER TABLE "ScheduledPost" ADD COLUMN "lastAttemptAt" TIMESTAMP;

-- Update AnalyticsSyncJob table
ALTER TABLE "AnalyticsSyncJob" ADD COLUMN "cloudTaskName" TEXT;
ALTER TABLE "AnalyticsSyncJob" ADD COLUMN "idempotencyKey" TEXT UNIQUE;
-- Remove old BullMQ reference
ALTER TABLE "AnalyticsSyncJob" DROP COLUMN "jobId";

-- Create indexes
CREATE INDEX "PublishLog_scheduledPostId" ON "PublishLog"("scheduledPostId");
CREATE INDEX "PublishLog_platform" ON "PublishLog"(platform);
CREATE INDEX "PublishLog_status" ON "PublishLog"(status);
CREATE INDEX "ScheduledPost_idempotencyKey" ON "ScheduledPost"("idempotencyKey");
CREATE INDEX "AnalyticsSyncJob_idempotencyKey" ON "AnalyticsSyncJob"("idempotencyKey");

Google Cloud Scheduler Setup

To trigger analytics sync every 6 hours:

bash
# Create scheduler job
gcloud scheduler jobs create pubsub analytics-sync-job \
  --location=us-central1 \
  --schedule="0 */6 * * *" \
  --http-method=POST \
  --uri=https://tendsocial-backend-xxxxx-uc.a.run.app/api/jobs/sync-analytics \
  --message-body='{"companyId": null, "idempotencyKey": "sync-analytics-$(date +%s)"}'

# Or use Cloud Tasks API directly:
gcloud tasks create-app-engine-task \
  --project=tendsocial \
  --queue=default \
  --schedule-time=2025-11-28T10:00:00Z \
  --http-method=POST \
  --url=https://tendsocial-backend-xxxxx-uc.a.run.app/api/jobs/sync-analytics

Error Handling & Retries

Cloud Tasks Retry Behavior

  • Max Retries: 3 (default)
  • Backoff: Exponential (5s, 10s, 20s)
  • Timeout: 30 seconds per task
  • Failure Action: Dead Letter Queue (after max retries)

Application-Level Idempotency

Each webhook includes an idempotencyKey to prevent duplicate processing:

typescript
// Check if already processed
const existing = await db.publishLog.findUnique({
  where: { idempotencyKey }
});

if (existing && existing.status === 'success') {
  // Return cached result (don't re-publish)
  return reply.code(200).send({ success: true, result: existing.remoteId });
}

// Otherwise, process and store result with idempotencyKey

Monitoring & Debugging

View Cloud Tasks

bash
# List pending tasks
gcloud tasks list --queue=default --location=us-central1

# Get task details
gcloud tasks describe [TASK_NAME] --queue=default --location=us-central1

# Delete a task (cancel scheduling)
gcloud tasks delete [TASK_NAME] --queue=default --location=us-central1

View Cloud Logs

bash
# Logs for publish-post webhook
gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=tendsocial-backend AND jsonPayload.message:publish-post" --limit 50

# Logs for Cloud Tasks
gcloud logging read "resource.type=cloud_tasks_queue" --limit 50

Metrics in Cloud Monitoring

  • cloudtasks.googleapis.com/task/execution_time - Task execution time
  • cloudtasks.googleapis.com/task/attempt_count - Number of retries
  • cloudtasks.googleapis.com/queue/task_attempts - Total attempts per queue

Removed Components

❌ Upstash Redis

Before:

typescript
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_URL,
  token: process.env.UPSTASH_REDIS_API_KEY,
});

await redis.lpush('publish-queue', job);

Reason for removal:

  • Continuous subscription model adds cost
  • No longer needed with Cloud Tasks
  • Reduces external dependencies

❌ QStash

Before:

typescript
import { Client } from '@upstash/qstash';

const client = new Client({
  token: process.env.QSTASH_TOKEN,
});

await client.publishJSON({
  url: 'https://...',
  body: { ... },
  delay: '2d'
});

Reason for removal:

  • Cloud Tasks provides better integration
  • Simplifies architecture (all in GCP)
  • Lower cost at scale

❌ BullMQ Worker

Before:

typescript
// Separate worker process polling Redis
const worker = new Worker('publish-queue', async (job) => {
  await publishToSocial(job.data);
});

Removed:

  • No longer needed - Cloud Tasks handles scheduling
  • Reduces infrastructure complexity
  • Scales to zero automatically

Rollback Plan

If you need to revert to Redis/QStash:

  1. Restore old dependencies:

    bash
    pnpm add @upstash/redis @upstash/qstash
  2. Restore old code:

    bash
    git checkout HEAD~N -- apps/backend/src/services/cloudTasks.service.ts
    git checkout HEAD~N -- apps/backend/src/services/scheduledPost.service.ts
  3. Restore old routes:

    bash
    git checkout HEAD~N -- apps/backend/src/routes/jobs.ts
  4. Restore old database schema:

    bash
    prisma migrate resolve --rolled-back [migration_name]

FAQ

Q: What if Cloud Tasks goes down?

A: Cloud Tasks is a managed GCP service with 99.95% uptime SLA. In the unlikely event of an outage, Cloud Tasks queues jobs and retries them when service is restored.

Q: Can I use Cloud Scheduler instead?

A: Cloud Scheduler is simpler but less flexible. Use it for:

  • Periodic analytics syncs (every 6 hours)
  • One-off cleanup jobs

Use Cloud Tasks for:

  • User-scheduled posts (variable scheduling)
  • Multi-platform publishing (fan-out pattern)

Q: How do I test webhooks locally?

A: Use the Cloud Tasks emulator or ngrok:

bash
# Option 1: Cloud Tasks emulator
gcloud beta emulators firestore start
# Then set FIRESTORE_EMULATOR_HOST in env

# Option 2: ngrok (easier)
ngrok http 3001
# Then update WEBHOOK_URL in .env to ngrok URL
# Then manually create tasks via curl:
curl -X POST https://your-ngrok-url/api/jobs/publish-post \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Secret: your-secret" \
  -d '{ "scheduledPostId": "...", ... }'

Q: What about idempotency key collisions?

A: Cloud Tasks uses idempotency keys to prevent duplicate task creation. If you create a task with the same key twice, the second request returns the existing task without creating a duplicate. This is by design.

Q: Can I see failed tasks?

A: Yes:

bash
# View dead letter queue (failed tasks)
gcloud tasks list --queue=default-dlq --location=us-central1

Then you can retry them manually:

bash
gcloud tasks create-app-engine-task \
  --project=tendsocial \
  --queue=default \
  --http-method=POST \
  --url=https://...

Next Steps

  1. Update schema - Run Prisma migrations
  2. Install dependencies - pnpm install @google-cloud/tasks
  3. Update environment - Add GCP_PROJECT_ID, GCP_LOCATION, WEBHOOK_SECRET
  4. Deploy to Cloud Run - New code with Cloud Tasks integration
  5. Create Cloud Scheduler job - For analytics sync (every 6 hours)
  6. Test end-to-end - Schedule a post, verify webhook execution
  7. Monitor - Watch logs and metrics during initial rollout
  8. Update documentation - API docs, dev guide, etc.

Architecture designed by: Marc M + Claude Implementation date: November 27, 2025 Status: Ready for deployment

TendSocial Documentation