Ошибки часовых поясов сложны. Или, скорее, их, как правило, сложно обнаружить и полностью понять, но их не так сложно решить (я бы сказал, что это относится к большинству ошибок).

Я могу честно сказать, что успешное преодоление ошибки часового пояса дало мне ценные инструменты для решения подобных ошибок.

И «выжить» — это не преувеличение.

Потому что я уверен, что некоторые из вас могут понять, что, как только появляется ошибка часового пояса, может потребоваться несколько дней, чтобы справиться с ней, и ваша жизнь вернется в нужное русло. (ну может я немного преувеличиваю)

Представьте себе следующий сценарий:

У вас есть поток, который работает раз в неделю без проблем в течение многих лет. Казалось бы, из ниоткуда поток обрывается без единой поправки или изменения. Добавьте к этому тот факт, что баг возникает в разное время, в разные даты и в разных городах мира.

Безумие.

Вот когда вы понимаете, что это летнее время все испортило.

Летнее время (DST)

DST starts = turning the clock 1 hour ahead, 2:00AM -> 3:00 AM 
DST ends = turning the clock 1 hour behind, 2:00AM -> 1:00 AM

Но как? И почему?

Сделай себе чашечку кофе и выслушай меня.

Данные

Допустим, у нас есть БД с объектами типа: Driver-Shift . В Via Driver-Shift содержит все особенности посадки и высадки для каждой поездки, которую водитель выполняет во время своей смены.

Driver-Shift имеет следующие поля, относящиеся к нашей истории:

  • Driver-Shift.start_time -> метка времени начала смены
  • Driver-Shift.end_time -> метка времени, представляющая конец смены
  • Driver-Shift.date -> строка, представляющая дату начала смены (например, 2023-03-14)

Мы можем визуализировать нашу БД следующим образом:

Поток

Каждую неделю мы запускаем поток, который оптимизирует Driver-Shifts на следующую неделю. Например, поток может перемещать поездки из одной смены в другую, если это улучшит маршрут для водителя.

Для наших целей:

The flow = optimization process

Это еженедельный поток, который долгое время не вызывал у нас проблем.

Первое, что делает поток, — это извлекает из БД все Driver-Shifts на предстоящую неделю.

Однажды мы получили предупреждение о том, что процесс оптимизации не удался. Дальнейшая проверка показала, что поток не удалось получить во время процесса выборки смен со следующими err_msg:

"given timespan spans more than 8 days".

Не зная о базовом механизме, который мы используем для запроса наших смен из нашей БД, я был очень сбит с толку. Это еженедельный (7 дней)поток, почему в err_msg упоминается 8 дней? И почему БОЛЬШЕ, чем 8 дней? 🤔

Но, как и любой хороший разработчик, я пробормотал «Я получил это» с уверенностью гусеницы и нырнул с головой в неизвестность, пытаясь понять, что происходит.

Как мы получаем наши смены

Когда мы хотим получить все смены водителя между определенным диапазоном [start-end], мы разбиваем его на 2 шага:

Шаг 1:

  • Извлечь дискретные даты из [start-end]

Пример:

a query for all shifts between `2023.3.15  00:00 AM` - `2023.3.17 00:00AM` will yield the following dates:
   - 2023.3.15
   - 2023.3.16

Шаг 2:

Для каждого date из шага 1:

 1. fetch all shifts that `shift.date` == `date`
 2. fetch only shifts that  `shift.start_time` < `end` 
 3. fetch only shifts that `shift.end_time` > `start` 
 

(Дайте мне все смены, которые пересекаются с start& end)

Это отлично подходит для смен, которые начинаются и заканчиваются в пределах дат, извлеченных с помощью шага 1, но как насчет смен, которые начинаются до первой даты и перекрываются [start-end] со (сменной A)?

Чтобы справиться с этими ночными (перекрывающимися) сменами, мы добавляем еще одну дату перед исходной датой и снова применяем тот же Шаг 2.

Вы можете спросить себя: «Не принесет ли это нам много нежелательных сдвигов после дополнительного свидания?» Ответ отрицательный:
Шаг 2.3 гарантирует, что будут включены только те смены, которые пересекают start
Шаг 2.2 будет верен для всех смен на эту дату.

для удобства Шаг 2 снова упоминается:

 1. fetch all shifts that `shift.date` == `date`
 2. fetch only shifts that  `shift.start_time` < `end` 
 3. fetch only shifts that `shift.end_time` > `start`

Баг

Итак, из объяснения нашего запроса мы узнали, что каждый диапазон [start-end], для которого мы хотим запросить сдвиги, на самом деле запрашиваем диапазон [one_day_before(start)-end].

Это означает, что ошибка является результатом того, как была реализована функция one_day_before(timestamp: int):

def one_day_before(timestamp: int):
  return timestamp - 24 * SECONDS_IN_MINUTE * MINUTES_IN_HOUR

(Несомненно, некоторые из вас уже точно поняли, в чем проблема, но давайте посмотрим весь инцидент, фотографии и все такое.)

Берлин

Мы запустили процесс оптимизации в Берлине, Германия. Мы хотели оптимизировать все Driver-Shifts внутри:

27.3.2023  00:00 - 2.4.2023 23:59

(intial query)

26.3.2023 Берлин меняет часы на начало летнего времени, что означает, что в этот день 2:00 утра превращается в 3:00 утра-. В результате У 26.3.2023 на самом деле было только 23 часа в Берлине, так как он потерял час до перехода на летнее время.

26.3.2023 00:00 - 27.3.2023 00:00

Таким образом, мы добавили еще 24 часа к началу запроса, думая, что запрос приведет к диапазону в общей сложности 8 дней:

  26.3.2023  00:00 - 2.4.2023  00:00

  (desired query)

поскольку между 26.3 и 27.3 всего 23 часа, мы получили следующий запрос:

25.3.2023 @ 23:00 - 2.4.2023 @ 00:00

(actual query)

когда мы выполнили Шаг 1 (см. выше), мы получили следующие даты:

25.3.2023
26.3.2023
27.3.2023
28.3.2023
29.3.2023
30.3.2023
31.3.2023
1.4.2023
2.4.2023
[A total of 9 days]

Это больше, чем наш жесткий лимит в 8 дней. Возникла ошибка. Наш еженедельный поток оптимизации не удался. Наступил хаос.

Ну, не хаос. Это, например, преувеличение. Но не весело, это точно.

Обобщение ошибки

Ошибка в нашем коде может быть описана следующим уравнением:

hour of day - 24h = same hour of previous day

Это означает, что уравнение неверно по крайней мере два раза в год везде в мире, где используется та или иная форма летнего времени.

Когда начинается летнее время, в часовом поясе будет 23 часа этой даты.
Когда летнее время закончится, в часовом поясе будет 25 часов этой даты.

Решение

Итак, теперь, когда мы понимаем проблему в деталях, как мы можем ее решить?

Нам нужно избавиться от нашего ошибочного предположения и попробовать другой подход. Сначала нам нужно определить, что мы хотим:

Что мы хотим?

Для запроса с диапазоном времени: [start-end], где start и end представляют определенный объект datetime, мы хотим преобразовать диапазон времени следующим образом:

[same_hour_of_previous_day(start) - end]

Теперь, когда мы знаем, чего хотим,

давайте быстро определим datetime в предложении, и мы готовы к работе:

- an object that holds both a date and a time in a specific timezone
- example: 2022–12–27 08:26:49 UTC

Теперь давайте представим конкретное решение в псевдо-стиле, вдохновленном Python:

1. извлечь date, hour, minute из начала

2.new_date = date - timedelta(days=1)

3.naive_datetime = datetime.combine(new_date, hour, minute)

4.desired_timezone = pytz.часовой пояс (some_time_zone_str)

5.new_start = desired_timezone.локализировать(naive_datetime)

О решении

Сначала Шаг 2 может показаться очень похожим на наше первоначальное неверное предположение, но есть ключевое отличие:

В нашем неверном предположении мы использовали объект datetime в наших вычислениях, тогда как на шаге 2 мы использовали дату. объект.

Объект date не зависит от времени, а объект datetime — нет. Когда мы вычитаем 1 день из объекта даты, мы получаем другой объект date, который на 1 день раньше исходной даты.

Неважно, проходит ли между этими датами 23, 24 или 25 часов — объекты date не имеют такого разрешения, что нам очень подходит.

Что мы делаем под капотом, так это берем порядковый номер date и вычитаем из него 1.

Порядковая дата — это календарная дата, обычно состоящая из года и порядкового числа в диапазоне от 1 до 366 (начиная с 1 января).

example ⤵️
Date: 2023–05–23
Ordinal Date: 2023–143

После того, как мы извлекли нужные date, hour, minute в переменные, мы полагаемся на наши языковые библиотеки даты и времени, чтобы оставаться честными. поэтому нам не придется проверять особые случаи, такие как начало или конец летнего времени.

Выводы

  • Не смешивайте datetime и чистые арифметические вычисления:
datetime + 60 * 60 != datetimeDriver-Shifthour_later
  • Положитесь на свои пакеты pytz, datetime или любой другой эквивалентный пакет из вашего языка. Используйте и изучайте их встроенные функции или, по крайней мере, их функции управления временем, и пусть они сделают всю тяжелую работу за вас.

Заключительные слова

Я надеюсь, что мой рассказ вас развлек, вы узнали что-то новое и, самое главное, что теперь вы лучше подготовлены к тому, чтобы исправить следующую ошибку часового пояса, которая встретится вам на пути!