Jenkinsとcronで日と曜日を両方指定した時の挙動が違う件


Jenkinsの「定期的に実行」とcronで日と曜日を両方指定した時の挙動が違う件について。
(Jenkinsだと「第何X曜日」の指定ができる)

もしかしたら有名なのかもしれませんが、ざっとググった範囲では言及しているものが見当たらなかったので記事にしてみます。


* 手元では、Jenkinsの2.32.1と 2.121.3で確認しました
* 最初に気付いたのは、Jenkins 1.656(2016/04/03)だったので、おそらくだいぶ前からこの仕様だと思われます
* ざっと[Blame](https://github.com/jenkinsci/jenkins/blame/stable-2.138/core/src/main/java/hudson/scheduler/CronTab.java)した感じだと、8年前(2010年：HudsonからJenkinsになったくらいの時期？)にはこんな感じだったんじゃないのかな、と思います


# cronの仕様
知ってる人は知っていると思いますが、cronの書式において、日(day of month)と曜日(day of week)を両方指定すると`or`で条件を結合します。
(他の条件は`and`なのに！)

例えば、第一月曜日だけ実行したいと思って以下の設定をしてもうまくいきません。

```bash
# 分 時 日 月 曜日
0 10 1-7 * 1 {実行コマンド}
# -> 実際は、1-7日の間の毎日と、全ての月曜日に実行される
```

この仕様は直感的でないので知らないと引っかかります。
これを解決するために世の中では色々な記事が書かれています。
例：[crontabで第？曜日にタスクを実行する方法 - Webサービスで起業を目指すプログラマーblog](http://shinsuke789.hatenablog.jp/entry/2015/08/07/123000)

# Jenkinsの「定期的に実行」の仕様
Jenkinsの「定期的に実行」は、ジョブの実行スケジュールをcron風(違うところもある)の記法で設定する機能です。

しかしこちらでは、日(day of month)と曜日(day of week)に両方指定すると`and`で条件を結合します。[^1]
<img width="916" alt="jenkins-schedule_firstMonday.png" src="https://qiita-image-store.s3.amazonaws.com/0/92278/656126fc-efcb-388e-c6aa-c8e69ffc4a6d.png">

ちゃんと、2018/8/6(月)と2018/9/3(月)が、それぞれ前回の実行と次回の実行として表示されます。

やったね :raised_hands: :raised_hands: :raised_hands:


## Declarative Pipelineの`cron`の仕様は？

Declarative Pipelineでの`cron`の仕様も、ドキュメント([triggers - Pipeline Syntax](https://jenkins.io/doc/book/pipeline/syntax/#triggers))には特に詳しく書いてませんが、
こちらも同様の挙動を確認しました。
(ただ、1度手動実行しないと変更が反映されない気が……？)


# 活用例

これにより、Jenkinsの「定期的に実行」では「毎月最初の平日に実行」という設定ができます

```bash

# 基本的に毎月1日の10時台に実行するが、土日や祝日の場合は次の平日にずらす。
# 祝日に関しては、現時点でわかっているもののみ対応　※通常は翌年の祝日が確定するのは2月
## 1月・5月・11月以外(1日が土日→2日か３日が月曜日で最初の平日)
H 10 1 2-4,6-10,12 1-5
H 10 2,3 2-4,6-10,12 1
## 1月の三賀日対応(実質4日が月初)
H 10 4 1 1-5
H 10 5,6 1 1
## 例年の5月のGW対応　※2019年は年号切り替えで例外
### 5月1日が何曜日かで場合分け
### - 1日が月-金→1日でok
### - 1日が日曜日→2日が月曜日で最初の平日
### - 1日が土曜日→2,3,4,5も休みで6日が木曜日で最初の平日
H 10 1 5 1-5
H 10 2 5 1
H 10 6 5 4
## 11月の文化の日対応(1日が日曜日→2日が月曜日、1日が土曜日→3日が月曜だが祝日で4日が最初の平日)
H 10 1 11 1-5
H 10 2 11 1
H 10 4 11 2


## 2019年5月限定。年号切り替えで5/1が祝日になった場合は、4/30と5/2も祝日になるので、7日が最初の平日で確定
H 10 7 5 1-5
```

# おまけ：Jenkinsの「定期的に実行」周りの実装？

動くとはいえ、`(?)`アイコンで出てくる説明にも載ってないし、たまたまだったりすると困るな、と思って[GitHub - jenkinsci/jenkins](https://github.com/jenkinsci/jenkins)のリポジトリを調べてみました。

`java.hudson.scheduler.CronTab`が怪しそうです。

https://github.com/jenkinsci/jenkins/blob/stable-2.138/core/src/main/java/hudson/scheduler/CronTab.java#L359

```java:CronTab.java#L359-360
if (f.redoAdjustmentIfModified)
    continue OUTER; // when we modify DAY_OF_MONTH and DAY_OF_WEEK, do it all over from the top
```
これは、引数で与えられた時間から見て次に条件を満たす時間を計算する`public Calendar ceil(Calendar cal)`内の断片で、
ここに到達するのは、現在チェックしているフィールド(月/日/曜日/時/分)の値について、`while`開始時点の値と条件を満たす値が異なる場合です。
コメントに書かれているように、`f.redoAdjustmentIfModified`は`f`(フィールド)が日(`DAY_OF_MONTH`)と曜日(`DAY_OF_WEEK`)の場合だけ`true`になり、
その場合`while`の外側からやり直しになります。

これによって、日と曜日の両方が条件を満たすまで、`Calendar`を前に進め続けることになります。

`redoAdjustmentIfModified`というフィールドを設け、
日(`DAY_OF_MONTH`)と曜日(`DAY_OF_WEEK`)の場合だけ`true`にし、
`if (f.redoAdjustmentIfModified)`という条件を使っている以上、
この仕様は意図的なものだと思われます。
("この部分が、「定期的に実行」で使われているコードである"、という推測が正しければ、の話ですが)

--------------------

それでは良きJenkinsライフを :sunny:

[^1]: よく見たら画像中の入力の`# 時 分 ...`のとこ逆ですね……