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 tierAfter (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:
// 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:
// 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
PublishLogtable
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:
{
"scheduledPostId": "uuid",
"platform": "linkedin",
"accountId": "acme-li-1",
"content": "Blog post text",
"mediaAssetIds": ["asset-1", "asset-2"],
"idempotencyKey": "publish-uuid-linkedin"
}Behavior:
- Verify webhook secret (from
X-Webhook-Secretheader) - Check idempotency key - skip if already published
- Call platform publishing service
- Update
PublishLogwith result - If all platforms succeed, mark
ScheduledPostas published - Return 200 (success) or 500 (trigger retry)
/api/jobs/sync-analytics (POST)
Called by Google Cloud Scheduler periodically to sync analytics.
Request:
{
"companyId": "uuid (optional)",
"idempotencyKey": "sync-analytics-2025-11-27-10:00"
}Behavior:
- Verify webhook secret
- Check idempotency key
- Trigger analytics sync for all platforms
- Create child Cloud Tasks for parallel metric collection
- Return 200 with count of jobs created
Data Models
ScheduledPost (Updated)
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:
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)
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 separatelyCost 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)
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-charsOptional
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
# (Auto-detected in Cloud Run via workload identity)Database Migration
-- 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:
# 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-analyticsError 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:
// 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 idempotencyKeyMonitoring & Debugging
View Cloud Tasks
# 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-central1View Cloud Logs
# 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 50Metrics in Cloud Monitoring
cloudtasks.googleapis.com/task/execution_time- Task execution timecloudtasks.googleapis.com/task/attempt_count- Number of retriescloudtasks.googleapis.com/queue/task_attempts- Total attempts per queue
Removed Components
❌ Upstash Redis
Before:
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:
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:
// 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:
Restore old dependencies:
bashpnpm add @upstash/redis @upstash/qstashRestore old code:
bashgit checkout HEAD~N -- apps/backend/src/services/cloudTasks.service.ts git checkout HEAD~N -- apps/backend/src/services/scheduledPost.service.tsRestore old routes:
bashgit checkout HEAD~N -- apps/backend/src/routes/jobs.tsRestore old database schema:
bashprisma 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:
# 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:
# View dead letter queue (failed tasks)
gcloud tasks list --queue=default-dlq --location=us-central1Then you can retry them manually:
gcloud tasks create-app-engine-task \
--project=tendsocial \
--queue=default \
--http-method=POST \
--url=https://...Next Steps
- ✅ Update schema - Run Prisma migrations
- ✅ Install dependencies -
pnpm install @google-cloud/tasks - ✅ Update environment - Add GCP_PROJECT_ID, GCP_LOCATION, WEBHOOK_SECRET
- ✅ Deploy to Cloud Run - New code with Cloud Tasks integration
- ⏳ Create Cloud Scheduler job - For analytics sync (every 6 hours)
- ⏳ Test end-to-end - Schedule a post, verify webhook execution
- ⏳ Monitor - Watch logs and metrics during initial rollout
- ⏳ Update documentation - API docs, dev guide, etc.
Architecture designed by: Marc M + Claude Implementation date: November 27, 2025 Status: Ready for deployment