Ошибка PHP с датами в нулевом году: 2000 лет на одного дня
При добавлении поддержки очень древних временных меток (за две тысячи лет в прошлое) команда сервиса 28times обнаружила, что PHP некорректно обрабатывает датирование в феврале нулевого года, а также в конце января. Проблема воспроизводилась с временной меткой 0000-02-03 04:00 Europe/Oslo и затрагивала все часовые пояса.
Разработчики сначала подозревали собственный код, но выяснилось, что при миграции на PHP используются три эквивалентных способа конвертации Unix-временной метки в DateTimeImmutable. Две первые функции работают правильно: DateTimeImmutable::createFromFormat('U', '-62164356180') и (new DateTimeImmutable('@0'))->setTimestamp(-62164356180). Однако третий вариант new DateTimeImmutable('@-62164356180') для февраля нулевого года возвращает значение, отличающееся на один день. Эта ошибка присутствует во всех свежих версиях PHP.
Нулевой год, особый случай. В традиционном юлианском календаре историков он не существует (называется 1 год до нашей эры). В пролептическом григорианском календаре с астрономической нумерацией (используемом на 28times) нулевой год является столетним високосным годом, исключением из исключения, когда годы, делящиеся на 100, не являются високосными, если только они не делятся на 400.
Корень проблемы обнаружился в библиотеке timelib, которая обеспечивает функциональность даты/времени в PHP. В timelib есть два алгоритма для конвертации Unix-временной метки в дату пролептического григорианского календаря. Один из них использует неправильную дату в проверке диапазона, дату в январе нулевого года, примерно на месяц раньше столетнего високосного дня, вместо того чтобы быть после него. Это приводит к тому, что все результаты до столетнего високосного дня смещены на один день.
Разработчик предложил исправление в timelib путём унификации всех вызывающих функций на правильный алгоритм. Для собственных целей 28times переключилась на один из двух работающих методов. Ошибка должна быть исправлена в следующих версиях timelib и PHP.
Ключевые факты
- PHP DateTimeImmutable некорректно обрабатывает временные метки в феврале нулевого года пролептического григорианского календаря, результат смещён на один день
- Проблема в конструкторе
new DateTimeImmutable('@-62164356180'), а не в методах createFromFormat или setTimestamp - Корень ошибки в библиотеке timelib, используемой PHP, неправильная дата в проверке диапазона функции конвертации Unix-временной метки
- Нулевой год, столетний високосный год в астрономической системе, что вызвало срабатывание граничного условия
- Исправление предложено в timelib путём унификации алгоритма; временное решение, использовать createFromFormat или setTimestamp вместо конструктора со строкой
Почему это важно
Хотя древние временные метки редки в промышленных системах, любой код, претендующий на правильную работу с полным диапазоном поддерживаемых дат, должен обрабатывать их корректно. Для сервиса 28times, работающего с историческими временными рядами, это критично. Кроме того, открытие ошибки в стандартной библиотеке timelib, которую использует PHP, помогает улучшить язык для всех разработчиков.
Кому это важно
PHP-разработчикам, работающим с DateTimeImmutable или DateTime для обработки древних дат; авторам систем, обрабатывающих исторические или научные временные данные; мейнтейнерам PHP и timelib; архивам и приложениям для работы с исторической информацией.
Как это применить
Если вы используете PHP и работаете с DateTimeImmutable для древних дат, избегайте конструктора new DateTimeImmutable('@<timestamp>') при работе с временными метками в феврале нулевого года. Вместо этого используйте DateTimeImmutable::createFromFormat('U', '<timestamp>') или (new DateTimeImmutable('@0'))->setTimestamp(<timestamp>). Оба метода работают корректно. Дождитесь обновления PHP/timelib с исправлением или примените обходной путь временно.
Можно ли доверять
Информация полностью опирается на подробный технический анализ разработчика, опубликованный на его блоге с примерами кода и цепочкой отладки. Разработчик открыл pull request в timelib для исправления, что подтверждает реальность проблемы. Ошибка воспроизводима и изолирована.
Риски и подводные камни
Проблема затрагивает только очень специфичный диапазон дат (февраль нулевого года и конец января), поэтому большинство приложений её не встретят. Однако если код обрабатывает полный диапазон дат и опирается на третий вариант конструктора, результаты будут неправильными без видимых ошибок. Переход на другой метод требует изменения кода. Также стоит учесть, что исправление в PHP может быть задержано, рекомендуется немедленный переход на обходной путь для критичного кода.