cron でもタスクスケジューラでも「月末 3 営業日前」が書けないので、スケジュール定義言語を作っている

Kairos

発端

「月末の 3 営業日前に締め処理の通知を出したい」。よくある要件。cron で書こうとして、手が止まる。

0 9 26 * *   # 26 日……ではない。月末から数えて 3 営業日前

書けない。cron が持っているのは「毎月 26 日」であって「月末の 3 営業日前」ではない。タスクスケジューラも同じ。「営業日」という概念が、この種のツールにはそもそも存在しない。

で、どうするか。アプリ側に if 文を書く。祝日テーブルをコードに埋める。動く。数年は動く。

そして 2021 年、海の日が移動する。オリンピック特例。祝日テーブルは誰も更新していない。締め通知は祝日に堂々と発火し、誰も読まない。

このパターンを何度か踏んだ結果、スケジュール定義言語を作ることにした。名前は Kairos

既存の道具はどこまで行けるか

先に整理。世の中の道具は大きく三段ある。

  • cron / タスクスケジューラ — 「毎月 25 日 9 時」まで。営業日・祝日の概念なし。
  • RRULE (iCalendar) — 「第 2 月曜」までは書ける。「営業日でなければ前日に倒す」が書けない。
  • 営業日カレンダー付きの製品 — 書ける。ただし定義は GUI の中。バージョン管理できず、テストも書けず、別システムに移植もできない。

欲しいのは「営業日を含むスケジュールを、テキストで、宣言的に、検証可能に書ける」層。そこが空いている。

Kairos で書くとこうなる

冒頭の要件。

@JP
monthEnd |> roll(Preceding, on: bizDay) |> shift(-3, unit: bizDay)

読み下し: 月末を採り、営業日でなければ前営業日へ寄せ、そこから 3 営業日戻す。@JP は前提の宣言(日本のカレンダー・タイムゾーン・週の始まり)。評価するとこうなる。

2026-08-26
2026-09-25
2026-10-27
2026-11-25

9 月が 25 日になっている点に注目。9 月末はシルバーウィーク明けで、9/21〜23 の三連休を営業日カウントが正しく飛び越えている。if 文でこれを書いた経験があるなら、この 4 行の出力の意味が分かるはず。

給料日も一行。

@JP
everyDay |> within(month) |> nth(25) |> roll(Preceding, on: bizDay)

毎月 25 日、休日なら前営業日。出力:

2026-07-24   # 7/25 は土曜 → 前営業日の金曜
2026-08-25
2026-09-25
2026-10-23   # 10/25 は日曜 → 金曜

極めつけは振替休日。「日曜の祝日は翌営業日に振り替え」「祝日に挟まれた平日は休日」——祝日法の条文がほぼそのまま式になる。

@JP
nonHoliday  = everyDay \ statutory
substitutes = statutory |> filter(d => weekday(d) == Sun) |> roll(Following, on: nonHoliday)
sandwiched  = ((statutory |> shift(+1, unit: day)) & (statutory |> shift(-1, unit: day))) \ statutory
substitutes | sandwiched

statutory(法定祝日のリスト)だけを入力に、2026 年で評価した結果。

2026-05-06   # 憲法記念日 5/3 が日曜 → 連休を飛び越えて 5/6 に振替
2026-09-22   # 敬老の日と秋分の日に挟まれた火曜 → 国民の休日

振替先が 5/4 でも 5/5 でもなく 5/6 なのは、連休中の振替が自動で後ろへ送られるから。この導出をハードコードせず規則から出せるのが要点。来年の祝日データを差し替えれば、来年の振替休日が出てくる。

一番作りたかったもの: 「黙って壊れない」

営業日計算の本当の敵は、書きにくさではない。祝日データの鮮度

祝日は法律と官報で決まる。つまり計算では出せず、データをもらってくるしかない。そしてデータには必ず「ここまでしか知らない」という端がある。問題はその端に達したとき。大半のシステムは黙って壊れる。祝日テーブルが 2026 年で尽きていても、2027 年のスケジュールを平然と計算する。祝日を踏んでいても誰も気づかない。

Kairos では、データに**有効範囲(covering)**を宣言する。範囲の外に踏み出した計算には、結果と一緒に「この区間はデータの範囲外」という注釈が並走する。値は出る。ただし観測できる。監視の仕組みに「祝日データの残りが 75 日を切った」と警告させることもできる。

「壊れるな」ではなく「壊れるときは観測できる形で壊れろ」。運用でスケジュールに裏切られてきた人間の設計だと思ってもらえれば。

自作言語に自分が騙された話

作っている間の失敗談を二つ。

その 1。処理系のテストを書いていて、日付リテラル 2026-02-30 をうっかり書いた。エラーにならない。おかしいと思って調べたら、処理系が黙って 3 月 2 日に繰り上げて受理していた。存在しない日付を、作った本人が書き、作った処理系が忖度する。現在は字句エラーに直してある。

字句エラー: 実在しない日付: 2026-02-30(月は 01..12・日は月と閏年規則の実在日のみ)

その 2。「次の金曜へ進める」という演算子の初期実装が、週の後半に評価すると過去の金曜を返してきた。「同じ週の金曜」を先に探す仕様にしていたせい。次と言ったのに過去が返る。タイムマシンの実装予定はない。現在は前方にしか進まない定義に直した。

どちらも仕様の穴がテストで割れた例。この言語は現在、代表例・祝日一式・二十四節気・旧暦まで含めて 469 本のテストで仕様と実装を突き合わせている。二十四節気は国立天文台の暦要項と照合済み。旧暦の閏月がデータから機械的に出てきたときは少し感動した。

できないこと

意図的に書けなくしてあるものもある。

  • 発火そのもの — Kairos が出すのは「時点の列」まで。実際にジョブを起動する層(発報層)は別。言語は計算に徹する。
  • 「前回完了から 5 時間ごと」 — 実行結果が次の入力に戻るフィードバックは書けない。ただし「与えられた時点から次の発火を計算する」は普通に書ける。この線引きの理屈はサイトの仕様書 §7.7 に書いた。
  • 祝日を当てること — 未来の祝日は法律が決める。言語にできるのは、データの鮮度を観測可能にするところまで。

現状

リリース候補(RC5)。1.0 は 2026 年 9 月を目安に準備中。仕様・リファレンス・実装はすべて公開している。

次回はチュートリアル予定。給料日ルールをゼロから書く。

コメント

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