黙りの既定——スケジューラは、間違うとき音を立てない

Kairos

落ちるバグより怖いもの

サーバが落ちれば、アラートが鳴る。例外が出れば、ログに残る。ところがスケジュールの事故の多くは、そのどちらでもない形でやってくる——プロセスは正常・ログは無風・ただ、違う時刻に実行されている

誰も間違ったことを書いていないのに、こうなる。書いた人は仕様どおりに書き、システムは仕様どおりに動いた。事故の原因は、書かれなかった部分をシステムが黙って埋めたこと——つまり黙りの既定である。

前回の「書けないスケジュール」の博物誌のまとめに、こう書いた。「書けるか」と同じくらい「黙るか、言うか」が分水嶺だった、と。今回はその分水嶺の話をする。既存方式に埋まっている黙りの既定を三つ、実測つきで掘り出して、「黙らない設計は何を言うべきか」まで進む。

黙り 1: OR になる

cron でこう書いたとする。

0 9 13 * 5   alert.sh     # 毎月 13 日の金曜日、のつもり

「13 日」と「金曜」を両方書いたのだから、13 日の金曜日に動く——とは、ならない。POSIX cron の仕様では、日付フィールドと曜日フィールドの両方が指定されたとき、その関係は OR になる。つまりこれは「毎月 13 日、または毎週金曜」であり、月に 5〜6 回発火する。

どれくらい違うか。2026 年で数えると、「13 日かつ金曜」は 3 回(2/13・3/13・11/13)。「13 日または金曜」は 61 回。20 倍の差である。

罪深いのは、この OR がどこにも書かれていないことだ。書いた本人は AND のつもりで、月に数回の誤発火は「まあそういうものか」と流される。数か月後、13 日でも金曜でもある日に 2 重処理が走って初めて調べが始まる(この罠の腑分けは連載の初期に 1 本書いた。今回はこれを「黙りの既定」の代表例として置き直している)。

Kairos ではこの多義がそもそも存在しない。関係は式に書く——書いた関係だけが成立する:

everyDay |> filter(d => ordinalIn(day, month, d) == 13 and weekday(d) == Fri)
#=> 2026-02-13  2026-03-13  2026-11-13

OR が欲しければ or と書く。書かなかった解釈をシステムが補わない——それだけのことが、既存の構文では構造的にできなかった(フィールドの直積で表現するため、フィールド間の関係を書く場所がない)。

黙り 2: スキップする

「毎月 31 日に締め処理」を iCalendar RRULE で書くと:

FREQ=MONTHLY;BYMONTHDAY=31

これは 1 月・3 月・5 月…にだけ発火する。31 日が無い月は、黙ってスキップされる。RFC 5545 にそう書いてあるので仕様どおりである。だが「毎月 31 日」と書いた人の意図はどちらだったか——

  • 意図 1: 31 日ちょうど。無い月は発火しなくてよい
  • 意図 2: 毎月の末日。短い月は 28 日や 30 日に寄ってほしい

RRULE は意図 1 を黙って選ぶ(意図 2 が欲しければ BYMONTHDAY=-1 という別の呪文がある——知っていれば)。2 月と 4 月と 6 月の締め処理が静かに消えて、四半期末の照合で発覚する型の事故である。

Kairos の答えは「既定で選ばない・書き分けさせる」:

everyDay |> within(month) |> nth(31)   # 意図 1: 31 日ちょうど
#=> 2026-01-31  2026-03-31  2026-05-31

monthEnd                               # 意図 2: 毎月の末日
#=> 2026-01-31  2026-02-28  2026-03-31  2026-04-30  2026-05-31  2026-06-30

nth(31) の 2 月が空なのは正当な空である(31 番目の日が存在しないだけ・エラーでも事故でもない)。大事なのは、意図 1 と意図 2 が別の式であること——どちらを選んだかが定義そのものに残るので、レビューで指摘でき、半年後の自分にも読める。

▶ Playground で 2 式を並べて実行する

黙り 3: 作成者の時計が勝つ

三つ目は、premise 入門の回で書いた「どこの 9 時」問題の、いちばん陰湿な形である。

cron の 0 9 * * * の「9」は、サーバの時計の 9 時である。定義には何も書かれていないから、サーバを東京からバージニアに移した瞬間、すべてのジョブが黙って 14 時間ずれる。カレンダーアプリの繰り返し予定も同型で、多くの実装は予定を作成した人のタイムゾーンを黙って既定にする。ニューヨーク出張中に作った「毎週月曜 9 時」の定例が、帰国後もニューヨークの 9 時(東京の 22 時や 23 時)で鳴り続ける——作成者の時計が、明示のないまま定義に焼き付いている。

さらに DST が重なると、同じ「9 時」が年 2 回、存在しなくなったり 2 回現れたりする。このときどうするかも、たいていのシステムは黙って決める(実装依存、と仕様に書いてあれば良い方である)。

Kairos では、時刻の意味を決める前提は前文(premise)に書かないと式が評価できない:

premise JP { calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon }

tz: の宣言がなければ静的エラーであり、「実行環境の時計」という黙りの既定が存在しない。週の始まり(wkst:)も同じ——「月曜始まりか日曜始まりか」は文化圏で割れる多義であり、既定でどちらかに倒すこと自体が黙りになるから、宣言必須である。

黙らない設計は、何を言うか

三つの黙りに共通するのは、多義な入力を、システムが独自の解釈で受理してしまうことだ。黙らない設計の答えは、裏返しの三か条になる。

  • 多義は受理しない。二通りに読める式は、どちらかに倒すのではなく静的エラーにして 書き分けを求める。Kairos では粒度や整列の合わない結合・週の始まりの未宣言・窓の隙間の扱いの 未指定は、すべて実行前に止まる。「黙って空振りする」より「うるさく止まる」を選ぶ。
  • データの端では、結果と一緒に言う。祝日テーブルの範囲を超えた計算は、止めるのでも黙って 劣化するのでもなく、結果に注釈を付けて返す(祝日テーブルは黙って腐るの回)。 信じてよい範囲がどこまでかを、機械が読める形で常に言う。
  • 「言った」の証拠を定義に残す。tz も週の始まりも意図 1/意図 2 の選択も、すべて定義の テキストに現れる。事故調査で「このジョブはどちらのつもりだったのか」を人の記憶に 聞かなくてよい——定義を読めば書いてある。

白状すると、この原則に助けられたのは利用者だけではない。つい先日、参照実装自身に「ある形の定義で、エラーも警告も出ないまま計算結果の点集合が変わる」というバグが(外部の実装者の精密なレビューで)見つかった。処置は三か条の一番目そのもの——多義の根にあった形を静的エラーにして、黙る経路を仕様ごと塞いだ。黙りは実装にも忍び込む。だから「黙らない」を個々の実装の注意力ではなく、言語の規則にしておく——設計中に何度も戻ってきた結論である。

まとめ

  • スケジュールの事故は、書き間違いより書かれなかった部分を埋める黙りの既定から来る
  • cron の OR・RRULE のスキップ・作成者の時計——三つとも「仕様どおり」であり、だからこそ レビューでも監視でも捕まらない
  • 黙らない設計の三か条: 多義は受理しない・データの端では言う・選択の証拠を定義に残す
  • うるさい静的エラーは、黙った誤発火よりずっと安い

次回は少し趣を変えて、この連載が追いかけてきた言語そのものの、大きな節目の報告になる予定である。


Kairos は設計中のスケジュール定義言語です。仕様と参照実装はGitHub で公開しています。

コメント

タイトルとURLをコピーしました