Skip to content
This repository was archived by the owner on Jun 18, 2025. It is now read-only.

Fixes #77 - settimout #78

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions SETTIMEOUT_LIMIT_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# setTimeout Limit Fix for Pulse

## Problem

JavaScript's `setTimeout` function has a maximum delay value of `2^31 - 1` milliseconds (approximately 24.8 days). When a value larger than this is passed to `setTimeout`, the behavior becomes unpredictable and the timeout may fire immediately or not at all.

In Pulse, when jobs are scheduled for dates beyond this limit, the original code would pass the raw time difference to `setTimeout`, causing unpredictable behavior for long-term scheduled jobs.

## Solution

The fix implemented in `src/utils/process-jobs.ts` adds a check for the setTimeout limit:

```typescript
// JavaScript's setTimeout has a maximum value of 2^31 - 1 milliseconds (approximately 24.8 days)
// If the delay exceeds this limit, setTimeout behaves unpredictably
const MAX_TIMEOUT = Math.pow(2, 31) - 1;

if (runIn > MAX_TIMEOUT) {
// For jobs scheduled beyond the setTimeout limit, use a shorter timeout and re-check periodically
const checkInterval = Math.min(MAX_TIMEOUT, 24 * 60 * 60 * 1000); // Check every 24 hours or at the limit
debug('[%s:%s] nextRunAt exceeds setTimeout limit (%d > %d), scheduling periodic check in %d ms',
job.attrs.name, job.attrs._id, runIn, MAX_TIMEOUT, checkInterval);

setTimeout(() => {
// Re-enqueue the job for processing, which will re-evaluate the timing
enqueueJobs(job);
}, checkInterval);
} else {
debug('[%s:%s] nextRunAt is in the future, calling setTimeout(%d)', job.attrs.name, job.attrs._id, runIn);
setTimeout(jobProcessing, runIn);
}
```

## How It Works

1. **Check the delay**: Before calling `setTimeout`, we check if the delay exceeds the maximum safe value.

2. **Periodic re-evaluation**: For jobs scheduled beyond the limit, instead of using the full delay, we schedule a periodic check (every 24 hours or at the maximum safe timeout value, whichever is smaller).

3. **Re-enqueue**: When the periodic check fires, it re-enqueues the job for processing, which causes the timing to be re-evaluated. This continues until the job's scheduled time is within the safe setTimeout range.

## Benefits

- **Predictable behavior**: Jobs scheduled far in the future will now behave predictably
- **No immediate execution**: Jobs won't accidentally fire immediately due to setTimeout overflow
- **Efficient**: Uses the maximum safe timeout value to minimize the number of periodic checks
- **Backward compatible**: Jobs scheduled within the 24.8-day limit continue to work exactly as before

## Testing

The fix includes comprehensive tests in `test/unit/setTimeout-limits.spec.ts`:

- Unit tests that verify the setTimeout limit logic
- Tests that confirm values exceeding the limit are handled correctly
- Demonstration script showing the fix in action

Run the tests:
```bash
npm test -- test/unit/setTimeout-limits.spec.ts
```

Run the demonstration:
```bash
npx tsx examples/setTimeout-limit-demo.ts
```

## Impact

This fix ensures that Pulse can reliably handle jobs scheduled weeks, months, or even years in the future without the unpredictable behavior caused by JavaScript's setTimeout limitations.
121 changes: 121 additions & 0 deletions examples/setTimeout-limit-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Demonstration of the setTimeout limit fix in Pulse
*
* This example shows how Pulse now handles jobs scheduled beyond
* JavaScript's setTimeout maximum value (2^31 - 1 milliseconds ≈ 24.8 days)
*/

import { Pulse } from '../src';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { MongoClient } from 'mongodb';

async function demonstrateSetTimeoutLimitFix() {
console.log('🚀 Demonstrating setTimeout limit fix in Pulse\n');

// Setup in-memory MongoDB
const mongod = await MongoMemoryServer.create();
const uri = mongod.getUri();
const mongo = await MongoClient.connect(uri);

// Create Pulse instance
const pulse = new Pulse({ mongo: mongo.db() });

// Define a simple job
pulse.define('futureJob', async (job) => {
console.log(`✅ Job executed: ${job.attrs.data?.message}`);
return 'completed';
});

// Constants
const MAX_TIMEOUT = Math.pow(2, 31) - 1;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const TEN_DAYS_MS = 10 * ONE_DAY_MS;
const THIRTY_DAYS_MS = 30 * ONE_DAY_MS;
const ONE_YEAR_MS = 365 * ONE_DAY_MS;

console.log(`📊 setTimeout limits:`);
console.log(` Maximum setTimeout value: ${MAX_TIMEOUT.toLocaleString()} ms (≈ ${(MAX_TIMEOUT / ONE_DAY_MS).toFixed(1)} days)`);
console.log(` 10 days: ${TEN_DAYS_MS.toLocaleString()} ms (within limit: ${TEN_DAYS_MS < MAX_TIMEOUT})`);
console.log(` 30 days: ${THIRTY_DAYS_MS.toLocaleString()} ms (within limit: ${THIRTY_DAYS_MS < MAX_TIMEOUT})`);
console.log(` 1 year: ${ONE_YEAR_MS.toLocaleString()} ms (within limit: ${ONE_YEAR_MS < MAX_TIMEOUT})\n`);

// Mock setTimeout to track calls
const originalSetTimeout = global.setTimeout;
const setTimeoutCalls: number[] = [];

global.setTimeout = ((callback: any, delay: number) => {
if (typeof delay === 'number') {
setTimeoutCalls.push(delay);
console.log(`⏰ setTimeout called with delay: ${delay.toLocaleString()} ms (within limit: ${delay <= MAX_TIMEOUT})`);
}
return originalSetTimeout(callback, Math.min(delay, 1000)); // Use short delay for demo
}) as any;

try {
// Start Pulse
await pulse.start();

console.log('📅 Scheduling jobs with different future dates:\n');

// Schedule job 10 days in the future (within setTimeout limit)
const tenDaysJob = pulse.create('futureJob', { message: 'Job scheduled 10 days ahead' });
tenDaysJob.schedule(new Date(Date.now() + TEN_DAYS_MS));
await tenDaysJob.save();
console.log('✓ Scheduled job 10 days in the future');

// Schedule job 30 days in the future (exceeds setTimeout limit)
const thirtyDaysJob = pulse.create('futureJob', { message: 'Job scheduled 30 days ahead' });
thirtyDaysJob.schedule(new Date(Date.now() + THIRTY_DAYS_MS));
await thirtyDaysJob.save();
console.log('✓ Scheduled job 30 days in the future');

// Schedule job 1 year in the future (far exceeds setTimeout limit)
const oneYearJob = pulse.create('futureJob', { message: 'Job scheduled 1 year ahead' });
oneYearJob.schedule(new Date(Date.now() + ONE_YEAR_MS));
await oneYearJob.save();
console.log('✓ Scheduled job 1 year in the future');

// Give Pulse time to process the jobs
await new Promise(resolve => setTimeout(resolve, 2000));

console.log('\n📈 setTimeout call analysis:');
console.log(` Total setTimeout calls: ${setTimeoutCalls.length}`);

const validCalls = setTimeoutCalls.filter(delay => delay <= MAX_TIMEOUT);
const invalidCalls = setTimeoutCalls.filter(delay => delay > MAX_TIMEOUT);

console.log(` Calls within limit: ${validCalls.length}`);
console.log(` Calls exceeding limit: ${invalidCalls.length}`);

if (invalidCalls.length === 0) {
console.log('✅ SUCCESS: All setTimeout calls are within the safe limit!');
} else {
console.log('❌ ISSUE: Some setTimeout calls exceed the safe limit');
invalidCalls.forEach(delay => {
console.log(` - ${delay.toLocaleString()} ms (${(delay / ONE_DAY_MS).toFixed(1)} days)`);
});
}

} finally {
// Cleanup
global.setTimeout = originalSetTimeout;
await pulse.stop();
await mongo.close();
await mongod.stop();
}
}

// Run the demonstration
if (require.main === module) {
demonstrateSetTimeoutLimitFix()
.then(() => {
console.log('\n🎉 Demonstration completed!');
process.exit(0);
})
.catch((error) => {
console.error('❌ Error during demonstration:', error);
process.exit(1);
});
}

export { demonstrateSetTimeoutLimitFix };
Loading