๐ธ Build Thumbnails
๐ Overviewโ
The Build Thumbnails job generates desktop and mobile preview images for funnel pages using headless Chrome (Puppeteer). It runs every minute, identifies steps that need thumbnails (new, changed, or stale), captures screenshots at two viewport sizes, uploads them to Wasabi S3, and updates the database with thumbnail metadata. The system supports stale job recovery (2-hour threshold) and handles blank pages gracefully.
Complete Flow:
- Cron Initialization:
queue-manager/crons/funnels/buildThumbnails.js - Service Processing:
queue-manager/services/funnels/buildThumbnails.js - Queue Definition:
queue-manager/queues/funnels/buildThumbnails.js
Execution Pattern: High-frequency polling (every minute) with batch processing
Queue Name: build_thumbnails
Environment Flag: QM_FUNNELS_BUILD_THUMBNAILS=true (in index.js)
๐ Complete Processing Flowโ
sequenceDiagram
participant CRON as Cron Schedule<br/>(every 1 min)
participant SERVICE as Build Service
participant DB as Funnel Steps DB
participant QUEUE as Thumbnail Queue
participant PUPPETEER as Puppeteer<br/>Browser
participant WASABI as Wasabi S3
CRON->>SERVICE: buildThumbnails()
loop While steps need processing
SERVICE->>DB: Find steps where:<br/>- step_changed = true OR<br/>- thumbnails missing OR<br/>- processing_queued & >2hrs old
DB-->>SERVICE: Steps needing thumbnails
alt No steps found
SERVICE->>CRON: Complete (log: no steps)
else Steps found
loop Each step
SERVICE->>DB: Set flags:<br/>processing_queued=true<br/>thumbnail_process_started_at=now
SERVICE->>QUEUE: Add job: {pageUrl, step_id, type}
end
end
end
QUEUE->>PUPPETEER: Launch browser (if not running)
QUEUE->>PUPPETEER: Navigate to pageUrl<br/>(30s timeout race)
alt Page blank
QUEUE->>QUEUE: Skip (log warning)
else Page has content
QUEUE->>PUPPETEER: Set desktop viewport<br/>(1600x1000)
QUEUE->>PUPPETEER: Capture JPEG (60% quality)
QUEUE->>PUPPETEER: Set mobile viewport<br/>(411x900)
QUEUE->>PUPPETEER: Capture JPEG (60% quality)
loop Each screenshot
QUEUE->>WASABI: Upload to S3<br/>(key: step_id/desktop|mobile.jpg)
QUEUE->>QUEUE: Delete temp file
end
QUEUE->>DB: Update step:<br/>thumbnails={desktop, mobile}<br/>Clear processing flags
end
๐ Source Filesโ
1. Cron Initializationโ
File: queue-manager/crons/funnels/buildThumbnails.js
Purpose: Schedule thumbnail generation every minute
Cron Pattern: * * * * * (every minute)
Initialization with Job Tracking:
const buildThumbnails = require('../../services/funnels/buildThumbnails');
const cron = require('node-cron');
const logger = require('../../utilities/logger');
let inProgress = false;
let cronJobCount = 0;
exports.start = async () => {
try {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: 'Starting thumbnail build cron job scheduler',
});
cron.schedule('* * * * *', async () => {
cronJobCount++;
const jobId = `job-${cronJobCount}-${Date.now()}`;
if (inProgress) {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Skipping job ${jobId} - previous job still in progress`,
additional_data: { jobId },
});
return;
}
try {
inProgress = true;
await buildThumbnails();
} catch (error) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error in thumbnail build process (${jobId})`,
error: error,
additional_data: { jobId },
});
} finally {
inProgress = false;
}
});
} catch (err) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: 'Failed to start thumbnail build cron job',
error: err,
});
}
};
In-Progress Lock: Prevents overlapping executions with enhanced logging.
Job Tracking: Each cron execution gets unique jobId for debugging.
2. Service Processing (THE CONTINUOUS LOOP)โ
File: queue-manager/services/funnels/buildThumbnails.js
Purpose: Find steps needing thumbnails and queue them continuously
Key Features:
- Continuous loop until no more steps found
- Handles new steps, changed steps, and stale processing
- 2-hour stale threshold for recovery
- Batch job queuing with progress logging
Main Service Function:
const Queue = require('../../queues/funnels/buildThumbnails');
const logger = require('../../utilities/logger');
const FunnelStep = require('../../models/funnel.step');
module.exports = async () => {
try {
const queue = await Queue.start();
let totalProcessedCount = 0;
let hasMoreSteps = true;
while (hasMoreSteps) {
const query = {
$or: [
{
$and: [
{
$or: [
{ step_changed: true },
{ thumbnails: null },
{ thumbnails: { $exists: false } },
{ 'thumbnails.desktop': null },
{ 'thumbnails.desktop': { $exists: false } },
],
},
{ processing_queued: { $ne: true } },
],
},
{
$and: [
{ processing_queued: true },
{
$or: [
{ thumbnail_process_started_at: { $exists: false } },
{
thumbnail_process_started_at: {
$lte: new Date(Date.now() - 2 * 60 * 60 * 1000),
},
},
],
},
],
},
],
};
// Get the next steps that need processing
const steps = await FunnelStep.find(query).lean().exec();
if (!steps.length) {
hasMoreSteps = false;
if (totalProcessedCount === 0) {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: 'No funnel steps found to build thumbnails.',
});
}
break;
}
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Found ${steps.length} steps requiring thumbnail generation`,
});
// Add jobs to queue for each step
for (const step of steps) {
const queueData = {
funnel_id: step.funnel_id,
step_id: step._id,
type: 'funnel',
pageUrl: `https://preview.urlme.app/${step.funnel_id}${step.base_path}`,
filePrefix: step._id,
};
await queue.add(queueData, {
attempts: 1,
removeOnComplete: true,
removeOnFail: true,
timeout: 150000,
backoff: {
type: 'exponential',
delay: 1000,
},
});
await FunnelStep.updateOne(
{ _id: step._id },
{ $set: { processing_queued: true, thumbnail_process_started_at: new Date() } },
);
}
totalProcessedCount += steps.length;
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Added ${steps.length} thumbnail generation jobs to queue`,
});
}
if (totalProcessedCount > 0) {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Completed processing. Total ${totalProcessedCount} thumbnail generation jobs added to queue`,
});
}
} catch (err) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error occurred while scheduling build thumbnails.`,
error: err,
});
}
};
Query Breakdown:
Condition 1: Steps needing thumbnails (not already queued)
step_changed = true- content modifiedthumbnails = nullor missing - never generatedthumbnails.desktop = nullor missing - incomplete generation- AND
processing_queued โ true- not already queued
Condition 2: Stale processing recovery
processing_queued = true- job was queued- AND
thumbnail_process_started_atmissing or >2 hours old - stuck/stale job
3. Queue Processing (THE PUPPETEER LOGIC)โ
File: queue-manager/queues/funnels/buildThumbnails.js
Purpose: Generate thumbnails using Puppeteer and upload to Wasabi S3
Key Functions:
- Browser management (singleton pattern)
- Screenshot generation at multiple viewports
- Wasabi S3 upload
- Temporary file cleanup
- Blank page detection
Helper Functions (Simplified - full code ~500 lines):
const FunnelStep = require('../../models/funnel.step');
const QueueWrapper = require('../../common/queue-wrapper');
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const Upload = new (require('../../utils/wasabi'))();
const uuid = require('uuid');
const logger = require('../../utilities/logger');
const getDimensions = type => {
switch (type) {
case 'desktop':
return { width: 1600, height: 1000 };
case 'mobile':
return { width: 411, height: 900 };
}
};
const screenshotsGenerator = async (browser, url, types) => {
let page;
try {
page = await browser.newPage();
page.setDefaultTimeout(60000);
page.setDefaultNavigationTimeout(60000);
// Race condition: wait max 30s for page load
await Promise.race([
page.goto(url, { waitUntil: 'networkidle2', timeout: 0 }),
new Promise(resolve => setTimeout(resolve, 30000)),
]);
// Check if page is blank
const isPageBlank = await page.evaluate(() => {
const body = document.querySelector('body');
return body.innerHTML.trim() === '';
});
if (isPageBlank) {
logger.warn({
initiator: 'QM/funnels/build-thumbnails',
message: 'The page is blank, skipping screenshot',
additional_data: { url, types },
});
return null;
}
// Prevent scrollbars in screenshots
await page.evaluate(() => {
document.body.style.overflow = 'hidden';
});
const screenshots = [];
// Generate screenshots sequentially to save resources
for (const type of types) {
await page.setViewport(getDimensions(type));
await new Promise(resolve => setTimeout(resolve, 2000)); // Viewport settle time
const dirPath = getFileName(uuid.v4());
const { fileName, filePath } = dirPath;
await page.screenshot({
path: filePath,
type: 'jpeg',
quality: 60,
fullPage: false,
});
screenshots.push({
fileName,
filePath,
});
}
return screenshots;
} catch (error) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Failed to generate screenshot for ${types.join(', ')}`,
error: error,
additional_data: { url },
});
throw error;
} finally {
if (page) {
await page.close();
}
}
};
let browser;
const getThumbnails = async url => {
try {
if (!browser) {
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-web-security',
'--disable-features=VizDisplayCompositor,TranslateUI,BlinkGenPropertyTrees',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-extensions',
'--disable-plugins',
'--disable-default-apps',
'--disable-background-networking',
'--disable-sync',
'--disable-translate',
'--disable-ipc-flooding-protection',
'--memory-pressure-off',
'--no-zygote',
'--disable-dev-tools',
'--disable-background-mode',
'--no-first-run',
'--disable-accelerated-2d-canvas',
'--noerrdialogs',
],
});
}
const [desktop, mobile] = await screenshotsGenerator(browser, url, ['desktop', 'mobile']);
return {
desktop,
mobile,
};
} catch (error) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: 'Failed to generate thumbnails',
error: error,
additional_data: { url },
});
throw error;
}
};
const getFileName = name => {
const dir = path.join(require('os').tmpdir(), 'funnels/thumbnails/');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const fileName = name.replace(/\s/g, '').replace(/\//g, '') + '.jpg';
const filePath = path.join(dir, fileName);
return {
fileName,
filePath,
};
};
const uploadFile = (fileName, name) => {
const fileContent = fs.readFileSync(fileName);
return Upload.upload([
{ contentType: 'image/jpeg', filename: `${name || uuid.v4()}.jpg`, content: fileContent },
]);
};
const generateThumbnailSet = async job => {
const data = job.data;
if (!data) throw new Error('No data provided');
const pageUrl = data.pageUrl;
const type = data.type;
let thumbnails = null;
try {
if (!pageUrl) throw new Error('No page url provided');
thumbnails = await getThumbnails(pageUrl);
if (!thumbnails) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error while generating thumbnails. No thumbnails found.`,
additional_data: { pageUrl },
});
return null;
}
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Generated thumbnails`,
additional_data: { pageUrl },
});
let files = {};
for (let thumbnail in thumbnails) {
if (!thumbnails[thumbnail]) continue; // Skip null (blank page)
try {
files[thumbnail] = await uploadFile(
thumbnails[thumbnail].filePath,
data?.filePrefix ? `${data?.filePrefix}/${thumbnail}` : null,
);
} catch (uploadError) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error uploading thumbnail`,
error: uploadError,
additional_data: { pageUrl, thumbnail },
});
throw uploadError;
}
}
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Uploaded thumbnails`,
additional_data: { pageUrl },
});
for (let file in files) {
files[file] = files[file][0];
}
for (let thumb in thumbnails) {
files[thumb].size = fs.statSync(thumbnails[thumb].filePath).size;
fs.unlinkSync(thumbnails[thumb].filePath);
thumbnails[thumb].cleaned = true;
}
if (Object.keys(files).length > 0) {
await FunnelStep.updateOne(
{ _id: data.step_id },
{
$set: {
thumbnails: files,
thumbnails_updated_at: Date.now(),
},
$unset: {
step_changed: '',
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: 'Funnel thumbnail processed.',
additional_data: {
job: job.id,
job_data: data,
files,
},
});
} else {
throw new Error('No thumbnails were successfully generated');
}
return files;
} catch (error) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error in thumbnail generation process`,
error,
additional_data: { pageUrl, type },
});
throw error;
} finally {
// Clean up temp files
if (thumbnails) {
for (let thumb in thumbnails) {
if (
thumbnails[thumb].filePath &&
fs.existsSync(thumbnails[thumb].filePath) &&
!thumbnails[thumb].cleaned
) {
try {
fs.unlinkSync(thumbnails[thumb].filePath);
} catch (cleanupErr) {
logger.warn({
initiator: 'QM/funnels/build-thumbnails',
message: `Error during cleanup: ${cleanupErr.message}`,
});
}
}
}
}
}
};
const processCb = async (job, done) => {
try {
await generateThumbnailSet(job);
} finally {
done();
}
};
const failedCb = async (job, err) => {
await FunnelStep.updateMany(
{ _id: job.data.step_id },
{
$unset: {
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);
logger.error({
initiator: 'QM/funnels/build-thumbnails',
error: err,
additional_data: { job: job.id, job_data: job.data },
});
};
const completedCb = async job => {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: 'Thumbnail generation completed.',
additional_data: { job: job.id, job_data: job.data },
});
};
let queue;
exports.start = async () => {
try {
const queueOptions = {
stalledInterval: 30000,
maxStalledCount: 0,
lockDuration: 120000,
lockRenewTime: 60000,
};
if (!queue) {
queue = QueueWrapper(`build_thumbnails`, 'global', {
processCb,
failedCb,
completedCb,
settings: queueOptions,
concurrency: 1,
});
}
return Promise.resolve(queue);
} catch (err) {
logger.error({ message: 'Error while initializing data queue', error: err });
}
};
module.exports.getThumbnails = getThumbnails;
๐๏ธ Collections Usedโ
funnel.stepโ
- Operations: Find, Update
- Model:
shared/models/funnel.step.js - Usage Context: Track thumbnail generation status
Key Fields:
_id: Step identifierfunnel_id: Parent funnel referencebase_path: URL path for page (e.g.,/landing)step_changed: Boolean - content modified, needs new thumbnailthumbnails: Object - stores thumbnail metadatadesktop: Object - desktop thumbnail info (key, size, url)mobile: Object - mobile thumbnail info (key, size, url)
thumbnails_updated_at: Timestamp - last thumbnail generationprocessing_queued: Boolean - job queued flagthumbnail_build_in_progress: Boolean - generation in progressthumbnail_process_started_at: Date - when processing started (for stale detection)
๐ง Job Configurationโ
Cron Scheduleโ
'* * * * *'; // Every minute
Frequency: 60 times per hour
Rationale: High frequency ensures quick thumbnail generation for newly published pages.
Stale Job Thresholdโ
const TWO_HOURS = 2 * 60 * 60 * 1000;
const staleThreshold = new Date(Date.now() - TWO_HOURS);
Threshold: 2 hours
Production Behavior: Prod may use different threshold (verify with environment variable if implemented).
Queue Settingsโ
const queueOptions = {
stalledInterval: 30000, // Check for stalled jobs every 30 seconds
maxStalledCount: 0, // Never mark jobs as stalled (rely on service stale detection)
lockDuration: 120000, // 2-minute lock (max processing time)
lockRenewTime: 60000, // Renew lock every 60 seconds
};
QueueWrapper(`build_thumbnails`, 'global', {
processCb,
failedCb,
completedCb,
settings: queueOptions,
concurrency: 1, // Process 1 thumbnail at a time
});
Job Optionsโ
{
attempts: 1, // No retries (service will re-queue stale jobs)
removeOnComplete: true, // Clean up successful jobs immediately
removeOnFail: true, // Clean up failed jobs immediately
timeout: 150000, // 2.5-minute timeout (150 seconds)
backoff: {
type: 'exponential',
delay: 1000, // Start with 1-second delay (not used with attempts=1)
},
}
Viewport Configurationsโ
Desktop:
{ width: 1600, height: 1000 }
Mobile:
{ width: 411, height: 900 }
Image Settingsโ
- Format: JPEG
- Quality: 60%
- Full Page: false (viewport height only)
๐ Processing Logic - Detailed Flowโ
1. Service Query Logicโ
Find Steps Needing Thumbnails:
Scenario A: Never generated or changed (not queued)
{
$and: [
{
$or: [
{ step_changed: true }, // Content modified
{ thumbnails: null }, // Never generated
{ thumbnails: { $exists: false } }, // Field missing
{ 'thumbnails.desktop': null }, // Incomplete
{ 'thumbnails.desktop': { $exists: false } }, // Incomplete
],
},
{ processing_queued: { $ne: true } }, // Not already queued
];
}
Scenario B: Stale processing (>2 hours)
{
$and: [
{ processing_queued: true },
{
$or: [
{ thumbnail_process_started_at: { $exists: false } }, // Missing timestamp
{
thumbnail_process_started_at: {
$lte: new Date(Date.now() - 2 * 60 * 60 * 1000), // >2 hours ago
},
},
],
},
];
}
2. Continuous Processing Loopโ
While Loop Pattern:
while (hasMoreSteps) {
const steps = await FunnelStep.find(query);
if (!steps.length) {
hasMoreSteps = false;
break;
}
// Queue all found steps
for (const step of steps) {
await queue.add(queueData, options);
await FunnelStep.updateOne(
{ _id: step._id },
{ processing_queued: true, thumbnail_process_started_at: new Date() },
);
}
totalProcessedCount += steps.length;
}
Rationale: Ensures all pending steps are queued in single cron execution.
3. Puppeteer Browser Managementโ
Singleton Pattern:
let browser; // Module-level variable
const getThumbnails = async url => {
if (!browser) {
browser = await puppeteer.launch({ ... });
}
// Reuse existing browser
};
Benefits:
- Faster subsequent screenshot generation
- Lower memory usage
- Reduced process overhead
Caveat: Browser persists across jobs (potential memory leak if not managed).
4. Screenshot Generationโ
Process:
- Open New Page:
await browser.newPage() - Set Timeouts: 60-second default timeout
- Navigate: Race between
networkidle2and 30-second timeout - Blank Check: Evaluate
document.body.innerHTML.trim() === '' - Hide Scrollbars: Set
overflow: hidden - For Each Viewport:
- Set viewport dimensions
- Wait 2 seconds (settle time)
- Generate unique filename
- Capture JPEG screenshot
- Close Page:
await page.close()
Sequential Processing: One viewport at a time to conserve resources.
5. Wasabi Uploadโ
Upload Pattern:
const uploadFile = (fileName, name) => {
const fileContent = fs.readFileSync(fileName);
return Upload.upload([
{
contentType: 'image/jpeg',
filename: `${name || uuid.v4()}.jpg`,
content: fileContent,
},
]);
};
// Usage with step-based prefix
files[thumbnail] = await uploadFile(
thumbnails[thumbnail].filePath,
`${step._id}/${thumbnail}`, // e.g., "507f1f77bcf86cd799439011/desktop"
);
S3 Key Structure: {step_id}/{viewport}.jpg
- Example:
507f1f77bcf86cd799439011/desktop.jpg - Example:
507f1f77bcf86cd799439011/mobile.jpg
6. Temporary File Cleanupโ
Lifecycle:
- Create temp file:
os.tmpdir()/funnels/thumbnails/{uuid}.jpg - Upload to S3
- Mark as cleaned:
thumbnails[thumb].cleaned = true - Delete temp file:
fs.unlinkSync(filePath)
Finally Block: Ensures cleanup even on error
finally {
if (thumbnails) {
for (let thumb in thumbnails) {
if (exists && !cleaned) {
fs.unlinkSync(filePath);
}
}
}
}
7. Database Updateโ
Success:
await FunnelStep.updateOne(
{ _id: step_id },
{
$set: {
thumbnails: files,
thumbnails_updated_at: Date.now(),
},
$unset: {
step_changed: '',
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);
Failure (in failedCb):
await FunnelStep.updateMany(
{ _id: step_id },
{
$unset: {
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);
๐จ Error Handlingโ
Common Error Scenariosโ
Page Load Timeoutโ
Scenario: Page doesn't reach networkidle2 within 30 seconds
Handling: Take screenshot anyway (race condition resolves)
Impact: May capture partially loaded page
Blank Page Detectionโ
Scenario: Page renders with empty body
Handling:
if (isPageBlank) {
logger.warn({ message: 'The page is blank, skipping screenshot' });
return null;
}
Impact: No thumbnail generated, processing flags cleared, retry on next run
Puppeteer Launch Failureโ
Scenario: Browser fails to launch (resource constraints, missing dependencies)
Handling: Error thrown, job fails, flags cleared
Impact: All queued jobs fail until browser issue resolved
Upload Failureโ
Scenario: Wasabi S3 upload fails (network, permissions, quota)
Handling: Error thrown, temp files cleaned in finally block, job fails
Impact: Flags cleared, retry on next service run
File System Errorsโ
Scenario: Temp directory full, permissions issues
Handling: Error thrown, cleanup attempted in finally block
Impact: Job fails, retry on next run
Failed Job Callbackโ
const failedCb = async (job, err) => {
await FunnelStep.updateMany(
{ _id: job.data.step_id },
{
$unset: {
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);
logger.error({
initiator: 'QM/funnels/build-thumbnails',
error: err,
additional_data: { job: job.id, job_data: job.data },
});
};
Actions:
- Clear processing flags (allows retry)
- Log error with job details
๐ Monitoring & Loggingโ
Success Loggingโ
Cron Level:
- Job started with unique
jobId - Job skipped (previous still in progress)
Service Level:
- No steps found to process
- Found X steps requiring thumbnails
- Added X jobs to queue
- Completed processing: total X jobs
Queue Level:
- Generated thumbnails for URL
- Uploaded thumbnails for URL
- Funnel thumbnail processed
Error Loggingโ
Cron Level:
- Error in thumbnail build process
Service Level:
- Error scheduling build thumbnails
Queue Level:
- Failed to generate screenshot
- Failed to generate thumbnails
- Error uploading thumbnail
- Error in thumbnail generation process
- Error during cleanup
Performance Metricsโ
- Typical Page Load: 5-15 seconds
- Screenshot Generation: 2-5 seconds per viewport
- Upload Time: 1-3 seconds per image
- Total Job Time: 10-30 seconds per step
- Concurrent Processing: 1 (sequential)
๐ Integration Pointsโ
Triggers This Jobโ
- Cron Schedule: Every minute (no external triggers)
- Database Flags:
step_changed=truetriggers inclusion in query
External Dependenciesโ
- Puppeteer: Headless Chrome for screenshots
- Wasabi S3: Image storage
- Preview Domain:
preview.urlme.appfor rendering pages
Jobs That Depend On Thisโ
- Funnel Publishing: Requires thumbnails for page previews
- Funnel Listing: Displays thumbnails in funnel manager
Related Featuresโ
- Funnel Editor: Sets
step_changed=trueon save - Funnel Dashboard: Displays generated thumbnails
โ ๏ธ Important Notesโ
Side Effectsโ
- โ ๏ธ Resource Usage: Puppeteer consumes significant CPU/memory
- โ ๏ธ Storage Costs: Creates S3 objects (2 per step)
- โ ๏ธ Temp Files: Created in OS temp directory (cleanup critical)
- โ ๏ธ Browser Process: Singleton browser persists (memory leak risk)
Performance Considerationsโ
- Sequential Processing: One job at a time prevents resource exhaustion
- Browser Reuse: Singleton browser pattern improves performance
- Image Quality: 60% JPEG reduces file size without visible degradation
- Stale Threshold: 2-hour threshold prevents indefinite stuck state
- Job Cleanup:
removeOnCompleteandremoveOnFailprevent queue bloat
Business Logicโ
Why Every Minute?
- Quick thumbnail generation for newly published pages
- User expectations for fast preview updates
Why 2-Hour Stale Threshold?
- Puppeteer can hang on complex pages
- Network issues may cause timeouts
- 2 hours balances recovery speed vs false positives
Why Two Viewports?
- Mobile and desktop previews critical for responsive design
- Users need to verify appearance on both devices
Why JPEG 60%?
- Balances file size and quality
- Sufficient for preview thumbnails (not print-quality)
Why Sequential Viewport Processing?
- Reduces peak memory usage
- Prevents resource contention
- Acceptable performance tradeoff
Maintenance Notesโ
- Stale Threshold: 2 hours hardcoded (consider environment variable)
- Viewport Sizes: Hardcoded (desktop: 1600x1000, mobile: 411x900)
- JPEG Quality: 60% hardcoded (consider environment variable)
- Browser Args: Extensive optimization flags (review periodically)
- Concurrency: 1 hardcoded (consider scaling for high-volume systems)
Code Quality Issuesโ
Issue 1: Browser Singleton Memory Leak Risk
let browser; // Never cleaned up
Suggestion: Implement browser lifecycle management:
// Close browser after idle period
let browserLastUsed = Date.now();
setInterval(() => {
if (Date.now() - browserLastUsed > 10 * 60 * 1000 && browser) {
browser.close();
browser = null;
}
}, 60000);
Issue 2: Hardcoded URLs
pageUrl: `https://preview.urlme.app/${step.funnel_id}${step.base_path}`,
Suggestion: Use environment variable:
pageUrl: `${process.env.PREVIEW_DOMAIN}/${step.funnel_id}${step.base_path}`,
Issue 3: Missing Done Call Might Cause Issues
const processCb = async (job, done) => {
try {
await generateThumbnailSet(job);
} finally {
done(); // Always called, even on error
}
};
Note: This is actually correct! The done() is called in finally block.
Issue 4: Viewport Settle Time Hardcoded
await new Promise(resolve => setTimeout(resolve, 2000)); // 2 seconds
Suggestion: Make configurable or use Puppeteer's waitForNetworkIdle.
๐งช Testingโ
Manual Triggerโ
# Via API (if QM_HOOKS=true)
POST http://localhost:6002/api/trigger/funnels/buildThumbnails
Flag Step for Thumbnail Generationโ
const FunnelStep = await FunnelSteps.findOne({});
// Flag for regeneration
await FunnelStep.updateOne({ _id: FunnelStep._id }, { $set: { step_changed: true } });
console.log('Flagged step for thumbnail generation');
// Wait for next cron run (up to 1 minute)
// Or trigger manually via API
Verify Thumbnail Generationโ
// Before generation
const beforeStep = await FunnelSteps.findById(stepId);
console.log('Before:', {
step_changed: beforeStep.step_changed,
thumbnails: beforeStep.thumbnails,
});
// Wait for processing
// After generation
const afterStep = await FunnelSteps.findById(stepId);
console.log('After:', {
step_changed: afterStep.step_changed,
thumbnails: afterStep.thumbnails,
thumbnails_updated_at: afterStep.thumbnails_updated_at,
});
// Verify S3 objects
console.log('Desktop URL:', afterStep.thumbnails.desktop.url);
console.log('Mobile URL:', afterStep.thumbnails.mobile.url);
Test Blank Page Handlingโ
// Create step with blank content
const blankStep = await FunnelSteps.create({
name: 'Blank Test',
funnel_id: funnelId,
base_path: '/blank',
raw_html: '<html><body></body></html>',
step_changed: true,
});
// Trigger generation
await buildThumbnails();
// Verify no thumbnails generated
const result = await FunnelSteps.findById(blankStep._id);
console.log('Thumbnails:', result.thumbnails); // Should be null or empty
console.log('Flags cleared:', !result.step_changed && !result.processing_queued);
Test Stale Job Recoveryโ
// Create stale processing state
await FunnelSteps.updateOne(
{ _id: stepId },
{
$set: {
processing_queued: true,
thumbnail_process_started_at: new Date(Date.now() - 3 * 60 * 60 * 1000), // 3 hours ago
},
},
);
// Trigger service
await buildThumbnails();
// Verify re-queued
setTimeout(async () => {
const step = await FunnelSteps.findById(stepId);
console.log('Re-queued:', step.processing_queued);
console.log('New timestamp:', step.thumbnail_process_started_at);
}, 5000);
Monitor Queueโ
# Watch logs during thumbnail generation
tail -f logs/queue-manager.log | grep "build-thumbnails"
# Expected outputs:
# "Found X steps requiring thumbnail generation"
# "Generated thumbnails"
# "Uploaded thumbnails"
# "Funnel thumbnail processed."
Test Puppeteer Directlyโ
const { getThumbnails } = require('./queue-manager/queues/funnels/buildThumbnails');
// Generate thumbnails for any URL
const thumbnails = await getThumbnails(
'https://preview.urlme.app/507f1f77bcf86cd799439011/landing',
);
console.log('Desktop:', thumbnails.desktop);
console.log('Mobile:', thumbnails.mobile);
// Verify temp files created
console.log('Desktop file exists:', fs.existsSync(thumbnails.desktop.filePath));
console.log('Mobile file exists:', fs.existsSync(thumbnails.mobile.filePath));
Job Type: Scheduled with High-Frequency Polling
Execution Frequency: Every minute
Average Duration: 10-30 seconds per step
Status: Active