Agenda schedules an unparseable cron expression as a zero-millisecond interval, causing continuous job execution
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 3/5
- Tempo stimato
- 1-2 giorni
- Idoneità per principianti
- 35/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Ferma
- Stack tecnologico
- mongodb, nodejs, typescript
- Ambito
- backend
Direzione di ricerca
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.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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
- Lingua principale
- TypeScript
- Stelle
- 46.5k
- Fork
- 14.1k
- Merge medio
- 1g 21h
- PR unite (30g)
- 381
Preparare l'ambiente
Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di RocketChat/Rocket.Chat
-
Mark the empty state busy while a new search loads, like the table */}Forse già presa @aditya25bcy10173-a11y l’ha presa 2 giorni fa. Apertatodo type: feature
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
RocketChat/Rocket.Chat#43699 · 1 commento · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
Timestamp with a time but no timezone shows as 1 January 1970Forse già presa @Sarthak-agrawal-tech l’ha presa 4 giorni fa. Apertatype: bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
RocketChat/Rocket.Chat#43637 · 6 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Move this mapping from useQuery's old `select` into a usePaginatedQuery `combine`Forse già presa @aditya25bcy10173-a11y l’ha presa 3 giorni fa. Apertatodo type: feature
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
RocketChat/Rocket.Chat#43609 · 4 commenti · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
evaluate switching to the other tab when the selected tab's permission is revoked mid-sessionForse già presa @schizophrenicmaniac l’ha presa 4 giorni fa. Apertatodo type: feature
Difficoltà 2/5 1-3 ore Idoneità per principianti 66/100
RocketChat/Rocket.Chat#43579 · 3 commenti · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
Docs say packages can be installed with `npm` but use unsupported `patch:` URLForse già presa Una pull request collegata a questa issue è aperta o già unita. Apertatype: bug
Difficoltà 1/5 1-3 ore Idoneità per principianti 72/100
RocketChat/Rocket.Chat#43554 · 4 commenti ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di RocketChat/Rocket.Chat
Issue simili
-
Out of order / duplicate key error shows the key bytes as decimal or UTF-8, not hexForse già presa @bardrop l’ha presa oggi. Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 86/100
-
`/leaderboard`, the page and the share image say "be the first" for a program that hasn't startedApertabug good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
codercops/discord-bot#152 ·
I maintainer di solito rispondono entro 1 giorno
-
bug i18n
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
endora-commerce/endora-commerce#1315 ·
I maintainer di solito rispondono entro 1 giorno
-
Two stored artist rows are not act names: "DJS" from a Renate floor heading and "Stevie Cox -"Apertaarea:data-quality importer size:S
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
enorm-labs/event-junkie#5136 ·
I maintainer di solito rispondono entro 1 giorno
-
fix(scripts): set-device-owner.ps1 fails to parse on Windows PowerShell 5.1 (UTF-8 without BOM)Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 84/100
I maintainer di solito rispondono entro 1 giorno