Time::Piece の時間範囲の罠について

こちらは Perl Advent Calendar 2024 の21日目の記事になります。

今年はなかなか書くネタが見つからなかったので、
時間に関するちょっとした小ネタを紹介します。

---

昨年、MySQL 8.0移行に際して、以下の記事を書きました。

https://qiita.com/teckl/items/060c1ce1c3ee7c42a2f6#%E6%98%94%E3%81%AE%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E3%81%AB-0000-00-00-%E3%81%8C%E5%85%A5%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E5%95%8F%E9%A1%8C

> ## 昔のテーブルに 0000-00-00 が入っている問題
> 
> MySQL 8.0から、DATETIME型のZERO_DATEの扱いが更に厳格になりました。
(5.7ではWARNINGのみだったものが、エラーになってしまう。 sql_mode で変えることも出来ますが、できるだけ標準に合わせておきたい）
> 
> 大昔に作成されたテーブルには、 0000-00-00 の値が結構入っていたので、
> DATETIME型の最小値である 1000-01-01 に更新することでひとまず対応しました。


この時の対応で、後ほど一点問題が見つかったのでせっかくなので紹介させていただきます。

ZERO_DATEの対応として、`0000-00-00` -> `1000-01-01` に一括で変更していて、
概ねうまくいっていたのですが、ある箇所で以下のような500エラーが発生していることが後になって判明しました。

`undef error - Error parsing time at /usr/local/perl-5.38/lib/5.38.2/x86_64-linux/Time/Piece.pm line 598.`

エラーの箇所を追ってみると、以下のstrptime呼び出しの箇所で発生しているようでした。

https://metacpan.org/dist/Time-Piece/source/Piece.pm#L598

`Time::Piece` では `1000-01-01` の日付には対応していなかったようです。

```perl
$ perl -MTime::Piece -E "say Time::Piece->strptime('1001-01-01', '%Y-%m-%d')"
Error parsing time at /usr/local/perl-5.38/lib/5.38.2/x86_64-linux/Time/Piece.pm line 598.

$ perl -MTime::Piece -E "say Time::Piece->strptime('1899-12-31', '%Y-%m-%d')"
Error parsing time at /usr/local/perl-5.38/lib/5.38.2/x86_64-linux/Time/Piece.pm line 598.

$ perl -MTime::Piece -E "say Time::Piece->strptime('1900-01-01', '%Y-%m-%d')"
Mon Jan  1 00:00:00 1900
```

MySQLのDATETIME型の最小値としては `1000-01-01` ですが、
Time::Pieceの最小値は `1900-01-01` となっていたという問題でした。

これまで日付の範囲についてさほど意識したことがなかったため、
なんなくPerlの内部ではUNIXエポックの1970年が境目で、Time::Pieceもそのあたりなのかと考えていた程度でした。

この時は `1900-01-01` にするか、もしくは `1970-01-01` にするかで悩んだのですが、
どちらもDB側のカラムを見ただけだとPerlの時間の扱い由来であることがすぐにわかりにくいかと思い、`2000-01-01` にすることで暫定対応を行いました。（これも微妙ですが。。）

さきほどのstrptimeのパース箇所は

https://metacpan.org/release/ESAYM/Time-Piece-1.3401/source/Piece.xs

の方で定義されているようで、自分はXS側に詳しくないので解説は省略させていただきますが、おそらくCのライブラリ側で制限されていそうでした。
日付の範囲について日頃あまり意識してなかったので、今回ちょっとだけ試してみました。


# 時間系モジュールの取りうる範囲

32bit時代であれば、例の2038年問題が該当してくるかと思います。

```perl
$ perl -MTime::Local -e 'print "Min: ", scalar localtime(-2**31), \
"\nMax: ", scalar localtime(2**31-1), "\n";'
Min: Sat Dec 14 05:45:52 1901
Max: Tue Jan 19 12:14:07 2038
```

https://ja.wikipedia.org/wiki/2038%E5%B9%B4%E5%95%8F%E9%A1%8C


が、さすがに今どきは64bit環境になっているとおもうので、64bit環境のみとします。


## Time::Piece

```perl
$ perl -MTime::Piece -E "say Time::Piece->strptime('1899-12-31', '%Y-%m-%d')"
Error parsing time at /System/Library/Perl/5.34/darwin-thread-multi-2level/Time/Piece.pm line 598.
$ perl -MTime::Piece -E "say Time::Piece->strptime('1900-01-01', '%Y-%m-%d')"
Mon Jan  1 00:00:00 1900
$ perl -MTime::Piece -E "say Time::Piece->strptime('9999-12-31', '%Y-%m-%d')"
Fri Dec 31 00:00:00 9999
$ perl -MTime::Piece -E "say Time::Piece->strptime('10000-12-31', '%Y-%m-%d')"
Error parsing time at /usr/local/perl-5.38/lib/5.38.2/x86_64-linux/Time/Piece.pm line 598.
```

`1900-01-01` - `9999-12-31` のようでした。


## DateTime
```perl
$ perl -MDateTime -E "say DateTime->new(year => -9999999999999999, month => 1, day => 1)"
-9999999999999999-01-01T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => -9999, month => 1, day => 1)"
-9999-01-01T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 0, month => 1, day => 1)"
0000-01-01T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 1, month => 1, day => 1)"
0001-01-01T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 1001, month => 1, day => 1)"
1001-01-01T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 9999, month => 1, day => 1)"
9999-01-01T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 10000, month => 1, day => 1)"
10000-01-01T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 1000000000000000, month => 12, day => 31)"
1000000000000000-12-31T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 10000000000000000, month => 12, day => 31)"
-2626367463883276--02-4611686018427387893T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 10000000000000000000, month => 12, day => 31)"
-12330607835522389-10-22T00:00:00
$ perl -MDateTime -E "say DateTime->new(year => 100000000000000000000, month => 12, day => 31)"
-0001-12-31T00:00:00
```

オーバーフローはしてしまうものの、特に下限、上限の範囲は設定されていないようでした。



# まとめ

もともとMySQL 8.0以前からのZERO_DATEのようなカラムがあったとして、それを意味のある数字に変更する際には、Perlにおいては、取り急ぎ1900年より前の値は使わないようにしておいた方が無難そう。
それよりは、NULLを許容するように定義を変えたり、アプリケーション側でDATETIME型だけど実体を持たない場合に適当なデフォルト値を入れないで済むような組み方に変えていった方が良いと思いました。

MySQL DATE/DATETIME型の範囲は '1000-01-01' から '9999-12-31' ですが、そのままアプリケーションからも扱えるとは思わない方が良さそうです。
2038年問題周りは64bit時代でもまだ予期せぬ問題がありそうなので、また時間を取って調査してみたいです。


# 参考

https://gihyo.jp/dev/serial/01/perl-hackers-hub/006601