cronの*,13と13,*は年52回と年365回になる
0 9 *,13 * 1と0 9 13,* * 1。日のフィールドでカンマの左右を入れ替えただけで、展開すればどちらも1日から31日の全部、まったく同じ値になる。
cronieで2026年ぶんの起動日を数えたら、前者は年52回、後者は年365回だった。
分けているのは値ではない。フィールドの先頭1文字が*かどうか、それだけ。そしてcrontab(5)の文面は、この規則を言い当てていない。
日と曜日を両方書くと「または」になる、はずだった
crontab(5)にはこう書いてある。
If both fields are restricted (i.e., do not contain the ”*” character), the command will be run when either field matches the current time.
日と曜日をどちらも指定したときだけORになる、という有名な癖。30 4 1,15 * 5は「毎月1日と15日、加えて毎週金曜」になる。
問題は「restricted」の判定がどう実装されているか。cronieのentry.cは、フィールドを読む前に先頭1文字を見てフラグを立てているだけだった。
/* DOM (days of month)
*/
if (ch == '*')
e->flags |= DOM_STAR;
ch = get_list(e->dom, FIRST_DOM, LAST_DOM, PPC_NULL, ch, file);
曜日側も同じ形でDOW_STARを立てる。そして起動判定はこう。
/* here we test whether time is NOW */
if (bit_test(e->minute, minute) &&
bit_test(e->hour, hour) &&
bit_test(e->month, month) &&
(((e->flags & DOM_STAR) || (e->flags & DOW_STAR))
? (bit_test(e->dow, dow) && bit_test(e->dom, dom))
: (bit_test(e->dow, dow) || bit_test(e->dom, dom))
)
) {
読むべき点が2つある。
ひとつ、ORに参加するかどうかはstarフラグ、つまり先頭1文字だけで決まる。*/2も*,13も先頭が*なのでstarになる。13,*は先頭が1なのでstarにならない。
ふたつ、starでも展開済みのビットは消えない。starだからANDに落ちるだけで、bit_test(e->dom, dom)はそのまま効き続ける。*/2なら奇数日のビットしか立っていないので、ANDの片側として奇数日を要求する。
manはどの版も実装を言い当てていない
引用した「do not contain the ”*” character」は今のcronieの文面。もとのVixie cron由来の文面は「aren’t *」で、cronieが2010年のコミットで書き換えている。
-restricted (ie, aren't *), the command will be run when
+restricted (i.e., do not contain the "*" character), the command will be run when
どちらの文面も、実装の「先頭1文字」とは違うことを言っている。曜日を1に固定して、日のフィールドだけ変えたときの読みと実測を並べる。
| 日のフィールド | 旧文面の読み | 現文面の読み | 実装 | 2026年の起動回数 |
|---|---|---|---|---|
13 | OR | OR | OR | 62 |
* | AND | AND | AND | 52 |
*/2 | OR | AND | AND | 26 |
1-31/2 | OR | OR | OR | 212 |
*,13 | OR | AND | AND | 52 |
13,* | OR | AND | OR | 365 |
旧文面は*/2と*,13で外れる。現文面はそこは直ったけれど、13,*で外れる。13,*は*を含むから「restrictedではない」はずなのに、実装は先頭が1なのでOR側へ行く。
年52回と年365回の差が、カンマの左右だけで生まれる。
数え方は、cronieのentry.cにcrontabの行を食わせて、cron.cの判定式をそのまま写した小さなCを回した。判定式は空白を潰して文字列比較し、cron.cの該当行と一致することを確かめてある。
0 9 */2 * 1の正解は「奇数日の月曜」
いちばん引っかかりやすいのがこれ。2026年1月1日から数えて最初の5回を、3つの実装で出してみる。
| 実装 | 最初の5回 |
|---|---|
| cronie crond | 01-05(月) 01-19(月) 02-09(月) 02-23(月) 03-09(月) |
| cronie cronnext | 01-05(月) 01-12(月) 01-19(月) 01-26(月) 02-02(月) |
| npmのcron-parser | 01-01(木) 01-03(土) 01-05(月) 01-07(水) 01-09(金) |
3通りに割れた。しかも上の2つは同じcronieのパッケージに入っている別コマンドで、cronnextは「次にcronが実行する時刻を計算する」ためのツール。
cronnextの判定はこうなっている。
if (e->flags & DOW_STAR)
return bit_test(e->dom, current.tm_mday - 1);
if (e->flags & DOM_STAR)
return bit_test(e->dow, current.tm_wday);
return bit_test(e->dom, current.tm_mday - 1) ||
bit_test(e->dow, current.tm_wday);
DOM_STARが立っていたら曜日だけを見て、日のビットを捨てている。crondはANDで両方見るので、奇数日でない月曜が落ちる。*/2のように「starだけど全部ではない」式でだけ、この2つが食い違う。
npmのライブラリは素直に文面どおりのORを取っていた。日を11通り、曜日を11通り組み合わせた121式を2026年ぶん展開してcrondと突き合わせると、不一致はこうなった。
| ライブラリ | 版 | 121式中の不一致 |
|---|---|---|
| cron-parser | 5.10.0 | 37 |
| croner | 10.0.1 | 43 |
| cron | 4.4.0 | 45 |
cron-parserの37件は全部starの規則だった。日か曜日に*/2のようなステップ付きの*が入っている式が、ちょうど37通り。0 9 */2 * 1なら年26回のはずが年212回になる。8倍の差がついたまま、どちらもエラーにならない1。
展開した値だけでは足りない
自分用にcron式のパーサを書いたとき、最初は展開済みの値だけを持つ設計にしていた。フィールドは「昇順・重複なしの数値の配列」で、*は1から31を全部並べたもの、と。
これだと日と曜日のOR判定が書けない。0 9 13 * *と0 9 13 * 0-6を並べると分かる。
| 式 | 曜日の展開値 | 2026年の起動回数 |
|---|---|---|
0 9 13 * * | 0,1,2,3,4,5,6 | 12 |
0 9 13 * 0-6 | 0,1,2,3,4,5,6 | 365 |
曜日の値はどちらも日曜から土曜の7つで、まったく同じ。なのに前者は毎月13日の年12回、後者は毎日の年365回になる。*はANDに落として13日だけを残し、0-6はORなので「13日または毎日」で毎日に化ける。
なので値とは別にフラグを持ち回ることにした。
export interface CronField {
/** 元の式が * ちょうどだったか。読み下しで「毎月」「毎日」を省けるかの判定に使う */
any: boolean
// 元の式の先頭が * だったか。Vixie cron / cronie の star フラグと同じで、
// ステップ付きの * (2日おきなど)も star になる。日と曜日の OR 判定はこちらで見る
star: boolean
values: number[]
}
anyとstarを分けているのは、用途が違うから。starはraw.startsWith('*')で、OR判定に使う。anyはraw === '*'で、読み下しのときに「毎月」を省けるかの判定に使う。*/2はstarだけが真になる。
判定はcronieの式をそのまま移した。
function matchesDate(cron: ParsedCron, date: Date): boolean {
if (!cron.month.values.includes(date.getMonth() + 1)) return false
const dayHit = cron.day.values.includes(date.getDate())
const weekdayHit = cron.weekday.values.includes(date.getDay())
if (!cron.day.star && !cron.weekday.star) return dayHit || weekdayHit
return dayHit && weekdayHit
}
121式それぞれの2026年の起動日をcrond側と1日ずつ突き合わせたら、17,009日ぶんが完全に一致した。
読み下しと実行判定は同じ規則から出す
このパーサは次回実行だけでなく、式を日本語に読み下すのも仕事にしていた。最初はここで事故った。
実行判定はstarフラグで見るように直したのに、読み下しのほうは「日と曜日の両方に指定があれば『または』」のままだった。結果、0 9 */2 * 1が画面上で「2日ごとまたは月曜」と読まれて、その下に奇数日の月曜だけが並ぶ。同じ画面で言っていることと出しているものが食い違う。
つなぎ方もmatchesDateと同じ規則で選ぶようにした。
if (hasDay && hasWeekday) {
const both = !cron.day.star && !cron.weekday.star
return `${day.text}${both ? 'または' : 'かつ'}${weekday.text}`
}
読み下し側にはもうひとつ厄介なところがある。ParsedCronは展開済みの値しか持っていないので、日本語にするには元の記法を推測し直すしかない。値の間隔がそろっているかを見て、範囲なのかステップなのかを判定している。
推測なので、元の書き方までは戻らない。
*/5 * * * * → 5分ごと
0,5,10,15,20,25,30,35,40,45,50,55 * * * * → 5分ごと
0-59/5 * * * * → 5分ごと
3つとも同じ文になる。これは読み下しとしては正しいので直す必要がない。むしろ困るのは*/2と1-31/2のほうで、値も同じ、読み下しの句も「2日ごと」で同じ、それでも意味が違う。
0 9 */2 * 1 → 2日ごとかつ月曜の9時0分
0 9 1-31/2 * 1 → 2日ごとまたは月曜の9時0分
かつ、またはの1語しか違わない。この1語が年26回と年212回を分けている。
分送りはDateの正規化に任せる
次回実行は、開始時刻の次の分から1分ずつ見ていって当たった時刻を集める、という素朴な探索にした。日が当たらなければ翌日0時へ、時が当たらなければ翌時0分へ飛ばすので、5年ぶん探しても実測1〜2msで返る。
このとき月末・年またぎ・うるう年を自前で書かなくて済む。Dateのコンストラクタが桁あふれを勝手に繰り上げてくれる。
function nextMinute(date: Date): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes() + 1,
0,
0,
)
}
分に60を渡せば次の時になるし、new Date(2026, 0, 32)は2月1日になる。実測。
| 入力 | nextMinuteの結果 |
|---|---|
| 2026-01-31 23:59 | 2026-02-01 00:00 |
| 2026-12-31 23:59 | 2027-01-01 00:00 |
| 2028-02-28 23:59 | 2028-02-29 00:00 |
| 2026-02-28 23:59 | 2026-03-01 00:00 |
当たらない日を飛ばすほうも同じ理屈で、0 0 30 2 *のように永久に来ない式は5年ぶん探して打ち切る。0 0 29 2 *は5年で1件(2028-02-29)だけ返ってきた。
夏時間の飛びもDateが吸ってくれる。TZをAmerica/New_Yorkにして3月8日の1時58分から回すとこうなる。
Sun Mar 08 2026 01:58:00 GMT-0500
Sun Mar 08 2026 01:59:00 GMT-0500
Sun Mar 08 2026 03:00:00 GMT-0400
Sun Mar 08 2026 03:01:00 GMT-0400
存在しない2時台をまたいで3時へ出る。自分で書いたら踏み抜いていたと思う。
ただし戻る側は取りこぼす。11月1日の1時台は2回来るはずだけれど、この歩き方だと1時59分(EDT)の次が2時0分(EST)になって、EST側の1時台を通らない。cron.cは時計が戻ったぶんを分岐で別扱いしていて、重複した1時間には分と時が*のジョブだけを走らせ、時刻固定のジョブは走らせない。そこまで合わせるなら1分ずつの素朴な探索では足りない。
まとめ
- 日と曜日のOR判定は、フィールドの先頭1文字が
*かどうかだけで決まる。*/2も*,13もstarになり、13,*はならない - starでも展開済みの値は効き続ける。
0 9 */2 * 1は「奇数日の月曜」で年26回 - crontab(5)の文面は旧版も現行版も、この規則とずれている
- cronie同梱の
cronnextもnpmの主要ライブラリも、crondとは違う答えを返す - 自前で持つなら、展開した値のほかにstarフラグが要る。読み下しの「または」と「かつ」も同じフラグから出す
Footnotes
-
cronは加えて0-6を*と同じに畳んでいて、0 9 13 * 0-6が年12回になる(crondは年365回)。cronerは0 9 1 * 1と0 9 1,15 * 1で2026-03-01だけを取りこぼした。日が1でORに落ちる式の月またぎで、何か踏んでいるらしい。 ↩