Agenda schedules an unparseable cron expression as a zero-millisecond interval, causing continuous job execution
Maintainers usually reply within 1 day
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 35/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Stale
- Tech stack
- mongodb, nodejs, typescript
- Domain
- backend
Research direction
Start in packages/agenda/src/Job.ts, in _computeFromInterval(), where humanInterval(interval) || 0 turns an unparseable schedule into a zero-millisecond interval. Then read the finally guard and computeNextRunAt() to see why nextRunAt ends up equal to lastRunAt, and check _processDbResult() in packages/agenda/src/Agenda.ts. Reproduce with 0 0 1 12 * and */0 * * * *. Done means these schedules are rejected or recorded as failed, not re-run back to back. Linked PR #43394 already targets this, so check it before starting.
Written by the indexing model from the issue text.
Description
Summary
When CronTime cannot parse a repeat interval, Job._computeFromInterval() in packages/agenda/src/Job.ts falls back to humanInterval(). For a cron string that is neither a valid cron expression nor a human interval, humanInterval() returns NaN, which is coerced to 0 by || 0. The job's nextRunAt is then set to lastRunAt + 0, the job is never marked as failed, and Agenda keeps picking it up again as soon as each run completes.
The result is that a job with an unparseable schedule runs back to back, instead of being rejected or recorded as a scheduling failure.
Two schedules that reach this path:
0 0 1 12 *: month12is out of range for[email protected](months are0-11), but it is accepted by thevalidCron()setting rule added in #43254, which usescron-validator(months1-12).*/0 * * * *: a zero step, e.g. whatapps/meteor/server/cron/userDataDownloads.tsbuilds fromUserData_ProcessingFrequency = 0.
Technical details
new CronTime(interval)throws (Field (12) value is out of range, orField (0-59/0) has a step of zero).- The catch block falls back to
humanInterval(interval)(L85), which returnsNaNfor these strings;|| 0turns it into0. - With
lastRunAtset, L91 setsnextRunAt = lastRun + 0. On job creation (nolastRunAt,numberIntervalfalsy) the same line setsnextRunAtto now. - The
finallyguard (L99-L102) only callsfail()whennextRunAtis null orNaN, so it does not trigger andfailReasonstays empty.
Why the job runs back to back rather than once per processEvery:
Job.run()setslastRunAtand callscomputeNextRunAt()before running (L192-L195), sonextRunAtis already in the past.- When the run finishes, the job is unlocked and saved (L212-L214).
_processDbResult()seesnextRunAt < _nextScanAtand callsprocessJobs(job)immediately (Agenda.ts L471-L474), and_lockOnTheFly()(L752) locks it again.
Reproduction and actual test output
Environment
- Rocket.Chat
developat7a174fd056 packages/agenda/src(Agenda.ts,Job.ts) loaded unmodified withtsx- Real
mongod7.0.24 started withmongodb-memory-server-core - Node v26.0.0, macOS 26.6.2,
TZ=UTC - Dependency versions as pinned in
yarn.lock:
| Package | Version |
|---|---|
| cron | 1.8.2 |
| cron-validator | 1.3.1 |
| human-interval | 2.0.1 |
| moment-timezone | 0.5.48 |
| moment | 2.31.0 |
| mongodb | 6.16.0 |
Steps
- Create an
Agendawith the same options aspackages/cron/src/index.ts(db: { collection: 'rocketchat_cron' },defaultConcurrency: 1,processEvery: '1 minute'). - Define three jobs whose handler records a timestamp and takes ~50 ms.
- Schedule them with
agenda.every(schedule, name, {}, {}), the same callcronJobs.add()makes:0 0 1 12 **/0 * * * *0 0 1 11 *(valid control)
- Observe for 75 seconds, then read the job documents.
Reproduction script
// Runs packages/agenda/src (Agenda + Job) unmodified against a real mongod, using the same
// Agenda options as packages/cron/src/index.ts and the same every(schedule, name, {}, {}) call.
const { MongoMemoryServer } = require('mongodb-memory-server-core');
const { MongoClient } = require('mongodb');
const { Agenda } = require(`${process.env.REPO}/packages/agenda/src/Agenda.ts`);
const JOBS: Record<string, string> = { 'month-12': '0 0 1 12 *', 'step-zero': '*/0 * * * *', 'control-month-11': '0 0 1 11 *' };
(async () => {
const mongod = await MongoMemoryServer.create();
const client = await MongoClient.connect(mongod.getUri());
const db = client.db('repro');
const buildInfo = await db.admin().command({ buildInfo: 1 });
console.log(`node ${process.version}, mongod ${buildInfo.version}, TZ=${process.env.TZ}`);
const agenda = new Agenda({ mongo: db, db: { collection: 'rocketchat_cron' }, defaultConcurrency: 1, processEvery: '1 minute' });
agenda.on('error', () => {});
const runs: Record<string, number[]> = {};
const t0 = Date.now();
for (const name of Object.keys(JOBS)) {
runs[name] = [];
// each run takes ~50 ms
agenda.define(name, async () => { runs[name].push(Date.now() - t0); await new Promise((r) => setTimeout(r, 50)); });
}
await agenda.start();
for (const [name, schedule] of Object.entries(JOBS)) await agenda.every(schedule, name, {}, {});
const observeMs = Number(process.env.OBSERVE_MS);
await new Promise((r) => setTimeout(r, observeMs));
console.log(`observed for ${observeMs} ms`);
for (const [name, t] of Object.entries(runs)) console.log(`${name.padEnd(17)} "${JOBS[name]}" runs: ${t.length} first run offsets (ms): ${t.slice(0, 8).join(', ')}${t.length > 8 ? ' ...' : ''}`);
const docs = await db.collection('rocketchat_cron').find({}).toArray();
for (const d of docs) console.log(`db ${d.name.padEnd(17)} nextRunAt=${d.nextRunAt?.toISOString() ?? null} lastRunAt=${d.lastRunAt?.toISOString() ?? '-'} failReason=${d.failReason ?? '-'}`);
await agenda.stop().catch(() => {});
await new Promise((r) => setTimeout(r, 500));
await client.close(); await mongod.stop();
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
Run with: TZ=UTC OBSERVE_MS=75000 REPO=<path to Rocket.Chat checkout> tsx repro-agenda.ts
Actual output
node v26.0.0, mongod 7.0.24, TZ=UTC
observed for 75000 ms
month-12 "0 0 1 12 *" runs: 1398 first run offsets (ms): 45, 98, 150, 204, 260, 316, 371, 425 ...
step-zero "*/0 * * * *" runs: 1399 first run offsets (ms): 45, 98, 151, 205, 261, 316, 371, 426 ...
control-month-11 "0 0 1 11 *" runs: 0 first run offsets (ms):
db month-12 nextRunAt=2026-11-01T06:07:33.459Z lastRunAt=2026-11-01T06:07:33.459Z failReason=-
db step-zero nextRunAt=2026-11-01T06:07:33.459Z lastRunAt=2026-11-01T06:07:33.459Z failReason=-
db control-month-11 nextRunAt=2026-12-01T00:00:00.000Z lastRunAt=- failReason=-
An earlier run of the same setup with only the 0 0 1 12 * job and the 0 0 1 11 * control recorded:
observed for 75000 ms
m12 runs: 1413 first run offsets (ms): 46, 100, 152, 208, 263, 317, 372, 428 ...
m11 runs: 0 first run offsets (ms):
db m12 nextRunAt= 2026-11-01T05:55:13.028Z lastRunAt= 2026-11-01T05:55:13.028Z failReason= -
db m11 nextRunAt= 2026-12-01T00:00:00.000Z lastRunAt= - failReason= -
humanInterval() (human-interval 2.0.1) on these inputs:
"0 0 1 12 *" -> NaN
"*/0 * * * *" -> NaN
"5 minutes" -> 300000
Validator vs. scheduler, per month value
This is Job.computeNextRunAt() from packages/agenda/src/Job.ts called directly on a job whose lastRunAt is 2026-11-01T10:00:00Z:
| Expression | isValidCron (cron-validator 1.3.1) |
CronTime (cron 1.8.2) |
nextRunAt |
nextRunAt == lastRunAt |
|---|---|---|---|---|
0 0 1 0 * |
false | accepted | 2027-01-01T00:00:00Z | false |
0 0 1 1 * |
true | accepted | 2027-02-01T00:00:00Z | false |
0 0 1 6 * |
true | accepted | 2027-07-01T00:00:00Z | false |
0 0 1 11 * |
true | accepted | 2026-12-01T00:00:00Z | false |
0 0 1 12 * |
true | throws Field (12) value is out of range |
2026-11-01T10:00:00Z | true |
For 0 0 1 12 *, a newly created job (repeatEvery(), no lastRunAt) got nextRunAt equal to the creation time (0 ms ahead) and no failReason.
Expected behaviour
An interval that is neither a valid cron expression nor a valid human interval should not produce a recurring job. It should be rejected, or the job should be recorded as failed (for example with nextRunAt: null and a failReason, as the existing finally guard intends). It should never become a job that is immediately due again after every run.
Actual behaviour
The fallback produces a 0 ms interval. nextRunAt equals lastRunAt, no failure is recorded, and the job runs again as soon as the previous run completes.
Root cause
packages/agenda/src/Job.tspackages/agenda/src/Agenda.ts
Impact
- Every job scheduled through
cronJobs.add()is exposed. Any job whose schedule string failsCronTimeparsing runs continuously instead of on its schedule, and nothing in the job document shows a failure. - Each run writes to
CronHistory. Jobs added throughpackages/cronare wrapped inrunCronJobFunctionAndPersistResult()(packages/cron/src/index.ts L9-L40), which inserts and updates one document per run. - Settings can reach this path:
0 0 1 12 *passes thevalidCron()rule onRetentionPolicy_Advanced_Precision_Cronand the other cron settings.UserData_ProcessingFrequency = 0produces*/0 * * * *.
- Limitations: I have not measured this on a running Rocket.Chat server. The run counts above are from the reproduction script with a ~50 ms job; real jobs (for example retention pruning) would loop at a rate set by their own duration.
Related prior work
- #43254 (PR) added
validCron()to the cron settings, usingcron-validator.- Its automated review flagged month
12(discussion):isValidCronaccepts it while[email protected]cannot parse it. - That thread was resolved without a code change.
- The comment did not cover the Agenda fallback or the continuous execution described here.
- Its automated review flagged month
- #43253 (PR) notes that the server's
[email protected]counts months from0. That is why12is out of range for the scheduler. - Open PRs that touch
Job.tsbut not this fallback:- #41902 and #43012 move the
computeNextRunAt()call inrun(). - #43012 also adds a Jest setup for
packages/agenda.
- #41902 and #43012 move the
Suggested direction
Agenda should not silently turn an unparseable cron expression into a 0 ms interval. One option: in the fallback, treat a NaN or 0 result from humanInterval() as invalid and leave nextRunAt null, so the existing fail() path records the failure. Valid human intervals such as '5 minutes' must keep working.
Separately, the validCron() setting rule could also reject expressions that [email protected] cannot parse, so admins get feedback when they save. That would not cover values already stored or values set through OVERWRITE_SETTING_*, so it complements an Agenda-side fix rather than replacing it.
I'd appreciate maintainer feedback on the preferred approach.
Suggested regression tests
- Invalid cron with month
12(0 0 1 12 *):nextRunAtis not equal tolastRunAt, and the job is recorded as failed (nextRunAtnull,failReasonset). - Zero step (
*/0 * * * *): same expectations. - Valid cron
0 0 1 11 *(lastRunAt2026-11-01T10:00:00Z,TZ=UTC):nextRunAtis2026-12-01T00:00:00Z. - Valid human interval
'5 minutes':nextRunAtislastRunAt + 300000. - A job with an invalid schedule is not re-executed after its first run (with a running
Agendaand MongoDB). repeatEvery()on job creation with an invalid schedule does not produce an immediately due job.
Server Setup Information
- Version of Rocket.Chat Server:
developat7a174fd056(source,packages/agenda) - NodeJS Version: v26.0.0
- MongoDB Version: 7.0.24 (reproduction only)
- Deployment Method / License / Users / Instances: not applicable, reproduced at the package level
- Dominant language
- TypeScript
- Stars
- 46.5k
- Forks
- 14.1k
- Avg merge
- 1d 21h
- Merged PRs (30d)
- 381
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from RocketChat/Rocket.Chat
-
Mark the empty state busy while a new search loads, like the table */}Possibly taken @aditya25bcy10173-a11y claimed this 2 days ago. Opentodo type: feature
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
RocketChat/Rocket.Chat#43699 · 1 comment · 1 reaction ·
Maintainers usually reply within 1 day
-
Timestamp with a time but no timezone shows as 1 January 1970Possibly taken @Sarthak-agrawal-tech claimed this 4 days ago. Opentype: bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
RocketChat/Rocket.Chat#43637 · 6 comments ·
Maintainers usually reply within 1 day
-
Move this mapping from useQuery's old `select` into a usePaginatedQuery `combine`Possibly taken @aditya25bcy10173-a11y claimed this 3 days ago. Opentodo type: feature
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
RocketChat/Rocket.Chat#43609 · 4 comments · 1 reaction ·
Maintainers usually reply within 1 day
-
evaluate switching to the other tab when the selected tab's permission is revoked mid-sessionPossibly taken @schizophrenicmaniac claimed this 4 days ago. Opentodo type: feature
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
RocketChat/Rocket.Chat#43579 · 3 comments · 1 reaction ·
Maintainers usually reply within 1 day
-
Docs say packages can be installed with `npm` but use unsupported `patch:` URLPossibly taken A pull request linked to this issue is open or already merged. Opentype: bug
Difficulty 1/5 1-3 hours Newbie friendliness 72/100
RocketChat/Rocket.Chat#43554 · 4 comments ·
Maintainers usually reply within 1 day
All issues in RocketChat/Rocket.Chat
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
open-webui/tether#52 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
voidzero-dev/void#835 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
AykutSarac/jsoncrack.com#616 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
melgarafael/DeskcommCRM#4679 ·
Maintainers usually reply within 1 day