本文へ移動
myowchick

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年の起動回数
13OROROR62
*ANDANDAND52
*/2ORANDAND26
1-31/2OROROR212
*,13ORANDAND52
13,*ORANDOR365

旧文面は*/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 crond01-05(月) 01-19(月) 02-09(月) 02-23(月) 03-09(月)
cronie cronnext01-05(月) 01-12(月) 01-19(月) 01-26(月) 02-02(月)
npmのcron-parser01-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-parser5.10.037
croner10.0.143
cron4.4.045

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,612
0 9 13 * 0-60,1,2,3,4,5,6365

曜日の値はどちらも日曜から土曜の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:592026-02-01 00:00
2026-12-31 23:592027-01-01 00:00
2028-02-28 23:592028-02-29 00:00
2026-02-28 23:592026-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

  1. 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に落ちる式の月またぎで、何か踏んでいるらしい。 ↩

← 一覧へ戻る