# Datetime in Tarantool В результате общения продуктовой команды появился итоговый взгляд на datetime. * Работу проводим в несколько этапов: * Этап 1: объект даты. Конструктор, модификация и форматирование * Этап 2: объект интервала, арифметика дат * Этап 3: парсинг дат * Этап 4: поддержка таймзон * Результатом каждого этапа является PR, прошедший ревью * Весь API должен быть отображен в тестах (tap | luatest) ## Общие замечания Имена полей в базовой структуре должны быть недвусмысленными: * `epoch` (int64) - количество секунд от начала 1970. * `nsec` (uint32) - количество наносекунд от начала последней секунды * 0..2e9 * `tzoffset` (int16) - количество минут - сдвиг таймзоны в минутах от UTC * -1080..1080 * `tzindex` (uint16) - индекс таймзоны, если время определено с его помощью * 0..450+ > Notes: > * Для хранения `tzoffset` и `tzindex` посмотреть на `int16`. Пробенчмаркать. Если всё ок, использовать их. Если нет, то `int` Все исключения бросаются на 1 уровень выше, чтобы точно показывать место, где ошибка в передаваемых параметрах (см. `error(msg, level)`) ## Хранение таймзоны Для хранения идентификаторов таймзоны используется следующий подход: * Самостоятельно нумеруем текущее содержимое `zone.tab`, каждой известной зоне присваиваем номер. * Маппинг TimeZone \<-> Int делаем публично доступным через таблицу `datetime.TZ` * `datetime.TZ['Europe/Moscow'] = N` * `datetime.TZ[N] = 'Europe/Moscow'` Таблица должна быть доступна для записи и расширения из Lua. Таблица может быть ffi-маппингом к низкоуровневой структуре. Содержимое tzdata читается из системно-установленного tzdata. Рассматриваемые сценарии: * Изменение свойств существующих таймзон (максимальная вероятность) * Просто получаем обновления из системы * Добавление новой таймзоны (маловероятно) * Для пользователя есть workaround в виде ручного расширения `datetime.TZ`, что тривиально * Удаление существующих таймзон (крайне маловероятно) * Не рассматриваем Если рассмотреть ситуацию появления новой таймзоны, то проблема для пользователя возникает в случае, если пользователю понадобится работать с этой таймзоной и он не будет знать какой tzindex ей нужно присвоить вручную. Для этой цели можно поддерживать список значений на странице документации модуля. В случае появления новой таймзоны мы расширяем индекс, добавляем поддержку в новую версию модуля, а для старых предлагаем сниппет формата: ```lua local datetime = require "datetime" datetime.TZ['Xxx/Yyy'] = 543 datetime.TZ[543] = 'Xxx/Yyy' ``` Зачем нужно хранить таймзону? С точки зрения конкретного момента времени разницы с tzoffset нет. Разница появляется, когда с этой датой мы захотим выполнять арифметику. Рассмотрим простой пример: один и тот-же момент времени, но в разных временных зонах. Сохраняем их в базе и впоследствии пытаемся вычислить "событие на 1 год позже" ``` 2013-10-26 21:00:00 Europe/Moscow (GMT+4) = 1382806800 2013-10-26 21:00:00 Asia/Dubai (GMT+4) = 1382806800 добавляем 1 год: 2014-10-26 21:00:00 Europe/Moscow (GMT+3) = 1414346400 2014-10-26 21:00:00 Asia/Dubai (GMT+4) = 1414342800 ``` ## Хранение в индексе С точки зрения хранения в индексе можно рассмотреть `datetime` как тапл из 4х полей: `{epoch, nsec, tzoffset, tz}` Эпоха абсолютна для всего мира. Видимое пользователем время определяется при помощи tzoffset/tz, но с точки зрения течения времени мы всегда используем единое координированное время (UTC) Второе поле nsec позволяет достичь максимальной точности при упорядочивании дат, в т.ч. позволяет в будущем сделать поддержку "високосной секунды" при помощи диапазона 1e9..2e9 При этом для абсолютно одинаковых моментов универсального времени мы даём упорядочивание по смещению. Для одинакового смещения мы упорядочиваем таймзону (по нашему индексу) Сравнение дат выполняется по тем же правилам. ## Упаковка в msgpack Договорились: * На уровне msgpack храним всё в integer: * int64 для epoch * int32 для наносекунд * int16 для tzoffset * int16 для tzindex :::spoiler **Not decided yet** > Mons: Остановились пока на том, что команда TeamC посмотрит и предложит варианты Храним всегда в msgpack extention (код `type` разработчик выберет самостоятельно). Это расширение хранит всегда (строго в таком порядке и типах): - тип extention - целое число эпохи - целое число tzoffset в минутах - целое число tz - целое число наносекунд Не храним: - количество сохранённых элементов (общая длина хранится в самом extention) Таким образом время с нулевым таймстемп будет храниться как 7 байт (заголовок extention и четыре нуля) - Если считать до сокращения длины. > Mons: Кажется, что данный формат упаковки не соответствует msgpack spec: https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type ### Сокращение длины | байт 0 | 1 | 2 | 3 | 4 | 5 | 6 | | ------- | ----- | ---- | ------ | -------- | --- | ---- | | Код ext | длина | type | epoch | tzoffset | tz | nsec | | 0xC7 | 4 | 'd' | 2 | 0 | 0 | 1 | | 0xC7 | 0 | 'd' | - | - | - | - | | 0xC7 | xxx | 'd' | 123456 | 180 | - | - | - Если nsec = 0, то он не хранится в msgpack - Если tz == 0 и nsec == 0, то они не хранятся в msgpack - Если tzoffset == 0 и tz == 0 и nsec == 0, то они не хранятся в msgpack - Если все нули - то msgpack хранит только код, длину и type (три байта) ::: ## Сериализация Возврат `__serialize` обычно предполагает сериализацию для 3х представлений: `json`, `yaml`, `msgpack`. `msgpack` в нашем случае рассматривается отдельно. `json` не обладает поддержкой типа `datetime/timestamp`. Для yaml формат сериализации описан (https://yaml.org/type/timestamp.html), но к сожалению ISO8601 не поддерживает указание timezone (https://stackoverflow.com/questions/42194571/identifying-time-zones-in-iso-8601). На текущий момент разрабатывается [черновик](https://github.com/ryzokuken/draft-ryzokuken-datetime-extended) расширения RFC3339, который предусматривает поддержку IANA Timezones, поэтому предлагается использовать его: * Если дата не имеет таймзоны: `2011-12-03T10:15:30.123Z` * Если дата имеет только tzoffset: `2011-12-03T10:15:30.123+01:00` * Если дата имеет указание таймзоны: `2011-12-03T10:15:30.123+01:00[Europe/Paris]` ## Минимальные и максимальные значения Cтандартом не определены минимальные/максимальные значения даты и времени и оставлено на детали реализации, мы принимаем следующие рассуждения за базис Для представления дат в виде epoch используется timestamp разрядностью 52 бита (точное представление double), что даёт нам (грубо) диапазон от ±142 млн лет (`2LL^52 / 864 / 36525 = 142710460`). Данная величина достаточно большая, чтобы представлять любые разумные вычисления дат. Для работы с миллиардами лет не требуется работа с таймзонами или точными значениями наносекунд. Точные значения: * Минимальный год: -142710460, нулевая секунда * -142710460-01-01 00:00:00 +0000 * epoch = -4503569303376000 * Максимальный год: 142710460, последняя секунда * 142710460-12-31 23:59:59 UTC * epoch = 4503445000559999 Для представления максимально больших/максимально малых дат можно использовать ±infinity из спеки double :::spoiler calculator ```perl= #!/usr/bin/env perl use strict; use 5.016; use DateTime; use DateTime::TimeZone; my $mindate = DateTime->new( year => -142710460, month => 01, day => 01, hour => 00, minute => 00, second => 00, nanosecond => 00, time_zone => 'UTC', ); say $mindate->strftime("%F %T %Z"); say $mindate->epoch; my $maxdate = DateTime->new( year => 142710460, month => 12, day => 31, hour => 23, minute => 59, second => 59, nanosecond => 00, time_zone => 'UTC', ); say $maxdate->strftime("%F %T %Z"); say $maxdate->epoch; ``` ::: :::spoiler old considerations Для представления дат в виде epoch используется timestamp разрядностью 64 бита, что даёт нам (грубо) диапазон от -292 млрд лет до +292 млрд лет (`2LL^63 / 864 / 36525 = -292271023045`, `( 2LL^63-1 ) / 864 / 36525 = 292271023045`) Но при этом для хранения значения year часто (напр. в структуре `struct tm`) используется `int`, что даёт диапазон лет от -2147483648 до 2147483647 Немного прикинем: * возраст вселенной около 14 млрд лет * Земле - 4.5 млрд. * Время жизни Солнца - 10 млрд лет * Через 2-3 млрд лет Солнце превратится в красного гиганта Такким образом, если только мы не планируем хранить и обрабатывать в нашем datetime объекты вселенско-космических масштабов, мы можем смело ограничить значение `year` размерностью int32 (-2147483648..2147483647). Это даст приемлемый диапазон эпохи приблизительно в диапазоне -2^55 .. 2^55 Точные значения: * Минимальный год: -2147483648, нулевая секунда * -2147483648-01-01 00:00:00 UTC * epoch = -67768100567971200 * Максимальный год: 2147483647, последняя секунда * 2147483647-12-31 23:59:59 UTC * epoch = 67767976233532799 ::: ## Mutability & immutability С точки зрения пользования API есть 2 варианта взаимодействия: вызов метода на объекте и арифметические операции. При применении метода на объекте подразумевается мутация объекта с возвратом этого же объекта. При применении арифметических операций объекты остаются неизменными и возвращается новый сконструированный объект ## TODO: * immutability — обсуждается * pros: thread safety * cong: gc pressure & performance * `:add`/`:sub` vs intervals vs `mt.__add`/`mt.__sub` * mutable методы нужны в разных бизнес-задачах по манипуляциям с датами * установка тайзоны (пересчёт) * вычисление новых значений в бухгалтерских операциях * и т.п. * арифметика интервалов. сложение неассоциативно * арифметика месяцев. расширенное API для "округления" последнего дня :::spoiler * leap second — предложить решение (с учётом хранения) * https://dba.stackexchange.com/questions/105514/leap-second-in-database-system-postgresql-and-sql-server * leap seconds не поддерживаются операционной системой. поэтому при парсинге можно поддержать, но сохраняем с потерей * в будущем можно поддержать в диапазоне nsec переполнение до 1e9..2e9-1 для сохранения информации о leap second * дефолты для `.format()`/`.parse()` * формат без параметров - `"%F %T %Z"` * синонимы `strftime`/`strptime` — убрать * поддержка -infinity / +infinity * https://www.postgresql.org/docs/9.0/datatype-datetime.html#AEN5647 * поддерживается при помощи double * epoch int64 vs double. -inf / + inf ? * Вариант 1: * храним внутри в int64_t * если размерность |epoch| < 142 млн лет (2^52), то возвращаем как double (lua number) * если размерность больше 2^52, то возвращается `cdata<int64_t>` * infinity не поддерживается, можно сделать константы min_date, max_date * Вариант 2 (выбрали его): * храним epoch внутри в double (только целая часть) * поддерживаем +/- inf * epoch всегда возвращается как целый lua number * поддерживаем меньший диапазон дат * zone\.tab/tzinfo mapping * описано ::: ## Этап 1. Объект даты. Конструктор, модификация и форматирование Реализуем базовый объект `datetime` с конструктором, аксессорами для получения отдельных значений полей, функциями для изменения значения даты и базовым форматтером ### Конструктор Принимает таблицу значений. Там где применимо, заимствуем названия полей у os.date. Значения полей имеют натуральные и привычные для человека интервалы. ```lua local dt = datetime.new { nsec = 123456789, -- наносекунд от последней секунды usec = 123456, -- или микросекунд от последней секунды msec = 123, -- или миллисекунд от последней секунды sec = 19, -- секунд в часе 0..59 (60?) min = 29, -- минут 0..59 hour = 18, -- часов 0..23 day = 20, -- число 1..31 month = 8, -- месяц 1..12 year = 2021, -- год * timestamp = 1629476485.123, -- эпоха. целое или дробное tzoffset = 180, -- смещение времени от UTC в минутах -1080 .. 1080. 180 = GMT+3 tz = "Europe/Moscow", -- таймзона по Olson } ``` * Конструктор должен игнорировать любые неизвестные поля. * Конструктор должен игнорировать поля `isdst`, `wday`, `yday` для возможности передачи в параметры результата вызова `os.date('*t')` или `datetime.totable` * Если аргумент не таблица, бросаем исключение. * Если одновременно передаётся nsec, usec или msec, то бросается исключение. Исключения на противоречиях: переданы микросекунды+наносекунды, наносекунды+милисекунды (любой вариант) * **UNera**: Если передано дробное значение timestamp и ненулевое значение частей секунд (микросекунды, наносекунды итп) * Значения по умолчанию (если применимо) равны 0 * `day`, `month` по умолчанию равны 1 * Для "последнего дня месяца" предусматривается специальное значение дня — `-1` * `year` по умолчанию равен 1970 - Если передаётся одновременно tz и tzoffset: - если tzoffset для tz равняется переданному, используем tz, tzoffset игнорируем - иначе бросаем ошибку (`error`). - Если передаётся `timestamp`, наличие полей кроме `tzoffset`/`tz` и вызывает исключение - Если вместе с полем `timestamp` передаётся отдельно дробная часть (`nsec`/`usec`/`msec`), то значение `timestamp` округляется до целого и в качестве дробной части используется переданное отдельно поле. - *Вызов с передачей `tz` временно может бросать исключение `Not yet implemented`* ### Доступ к атрибутам Доступ к атрибутам делается через `__index`. Все атрибуты readonly. Реализуются следующие атрибуты: ```lua dt.nsec dt.usec dt.msec dt.sec -- как в os.date dt.min -- как в os.date dt.hour -- как в os.date dt.day -- как в os.date dt.month -- как в os.date dt.year -- как в os.date dt.wday -- как в os.date. week day dt.yday -- как в os.date. year day dt.isdst -- как в os.date. dt.tzoffset dt.tz -- Like "Europe/Moscow". Доступно после этапа 3 dt.timestamp -- double ``` ### Сравнение дат Для сравнения дат реализуются метаметоды сравнения (`__gt`/`__lt`), которые сравнивают значения epoch и nsec. Для проверки на равенство реализуется `__eq`, который сравнивает не только epoch и nsec, но и tz/tzoffset. Т.е. 2 даты будут считаться равными только в том случае, если они равны с точностью до таймзоны. ### Конвертация в таблицу Для конвертации даты в простую таблицу (возможно для конструирования копии даты) используется метод `:totable`. Метод возвращает поля, аналогичные таковым в вызове `os.date('*t')` плюс наносекунды (`nsec`) ```lua local t = dt:totable() --[[ t.nsec t.sec t.min t.hour t.day t.month t.year t.isdst t.wday t.yday --]] local dt2 = datetime.new(dt:totable()) ``` ### Форматирование даты Для форматирования даты применяется реализация POSIX strftime с расширением на наносекуды (`%?f`) В качестве имени метода используем `:format`. Формат по умолчанию — `'%F %T %Z'` (обсудить. не добавить ли доли секунд?) ```lua dt:format('%Y-%m-%dT%H:%M:%S.%3f') -- 2021-08-21T14:53:34.032 ``` В метатаблице определяется `__tostring`, который вызывает `:format` по умолчанию ### Модификация даты Объекты даты-времени **мутабельны**. Для модификации предусмотрен метод прямого изменения значений `:set`. Явная установка в атрибуты посредством `__newindex` запрещена. Устанавливаемые значения проверяются на приемлемый диапазон аналогично конструктору. Применяются правила взаимного исклчения аргументов, как в `.new` Возвращаемое значение: объект `self`. Это позволяет связывать модифицирующие операции в цепочки. В случае, если результирующее значение даты получается некорректным, выбрасывается исключение. ```lua dt:set { nsec = 123456789, usec = 123456, msec = 123, sec = 19, min = 29, hour = 18, day = 20, month = 8, year = 2021, tzoffset = 180, -- or tz = "Europe/Moscow", } dt:set { timestamp = 1629476485.124, tzoffset = 180, -- or tz = "Europe/Moscow", } -- создать новую дату из существующей с изменением одного поля: local dt2 = datetime.new(dt:totable()):set{ day = 1 } local dt2 = datetime.new(dt:totable()):set{ day = -1 } -- установить последний день месяца ``` Этап 1 должен заканчиваться PR со следующим результатом: * Реализация модуля datetime * Тесты на каждый отдельный метод и каждый отдельный аксессор в формате tap/luatest, кроме поддержки `tz` --- ## Этап 2. Объект интервал. Интервальное вычисление дат > UNera: На втором этапе делаем > - Объекты интервалов > - функции :add, :sub. Эти функции принимают на вход как таблицу, так и интервал. Чтобы потом их не рефакторить Монс принял решение вынести функции :add и :sub из первого этапа и соединить их с реализацией интервалов. > > Ещё раз: > - функция :add{таблица}, > - функция :add(interval) > - функция :sub{таблица} > - функция :sub(interval) Для создания интервала используется `datetime.interval` (может быть `datetime.interval.new`? `interval.new`?) > UNera: используем `datetime.intevral.new` для единообразия с `datetime.new`. ### Конструктор Принимает таблицу значений. Там где применимо, заимствуем названия полей у os.date. ```lua local iv = datetime.interval { nsec = 123456789, -- наносекунд usec = 123456, -- или микросекунд msec = 123, -- или миллисекунд sec = 19, -- секунд min = 29, -- минут hour = 18, -- часов day = 20, -- дней week = 54, -- недель month = 8, -- месяцев year = 2021, -- лет } ``` * Конструктор должен игнорировать любые неизвестные поля. * Если аргумент не таблица, бросаем исключение. * Все значения по умолчанию равны 0. * Значения атрибутов могут принимать произвольные значения, не ограниченные диапазоном. ### Доступ к атрибутам Доступ к атрибутам делается через `__index`. Все атрибуты readonly. Реализуются все атрибуты конструктора. ### Методы арифметики > TODO: арифметика с double: считать число днями или секундами? > UNera: считать число секундами Для модификации существующего объекта даты предусмотрена арифметика дат на интервалах с двумя методами: `:add` и `:sub`. Методы принимают объект `interval` или эквивалентную ему таблицу. Для получения нового объекта даты из существующей даты и интервала применяется арифметическая операция `+` (`__add`) или `-` (`__sub`) При арифметике значения вычисляются последовательно от годов к наносекундам: * `year` - годы * `month` - месяцы * `week` - недели * `day` - дни * `hour` - часы * `min` - минуты * `sec` - секунды * `msec` - миллисекунды * `usec` - микросекунды * `nsec` - наносекунды В случае, если результирующее значение даты получается некорректным, выбрасывается исключение Возвращаемое значение методов - объект `self`. Позволяет собирать модифицирующие методы в цепочки. ```lua -- add 9000 years, 82 months, 5 weeks, 201 days, 183 hours, 292 minutes -- and 191.001239234 seconds to given date dt:add{ year = 9000, month = 82, week = 5, day = 201, sec = 191, min = 292, hour = 183, nsec = 1239234, } dt:sub{ year = 9000, month = 82, week = 5, day = 201, sec = 191, min = 292, hour = 183, nsec = 1239234, } -- создать новую дату из существующей с добавлением одного дня local dt2 = datetime.new(dt:totable()):add{ day = 1 } ``` ### Арифметика интервалов - Сумма двух интервалов = интервал поля которого есть сумма соответствующих полей слагаемых - Разность так же как сумма - Сумма интервала и даты -- новая дата, вычисленная путём прибавления отдельных составляющих интервала от года к наносекундам (то есть сперва обрабатываем год, потом месяц итп до наносекунд). ### Особенности вычислений дат по интервалам - Прибавление лет к времени даёт ту же дату в другом году кроме случая 29 февраля. Для невисокосных итоговых годов получаем 28 февраля. ``` 28 февраля невисокосного года + 1 год = 28 февраля високосного года 29 февраля високосного года + 1 год = 28 февраля невисокосного года ``` - Прибавление месяцев к времени даёт ту же дату в другом месяце, кроме случаев, когда в итоговом месяце меньше дней нежели в исходном. В этом случае получаем последний день. ``` 31 января + 1 месяц = 28 или 29 февраля 30 января + 1 месяц = 28 или 29 февраля 29 февраля + 1 месяц = 29 марта 31 марта + 1 месяц = 30 апреля ``` - Прибавление месяцев к последнему дню месяца (требует обсуждения). При прибавлении месяцев к последнему дню месяца надо получать последний день месяца. ``` 31 января + 1 месяц = 28 или 29 февраля 29 февраля + 1 месяц = 31 марта 31 марта + 1 месяц = 30 апреля 30 апреля + 1 месяц = 31 мая 28 февраля 2001 + 1 месяц = 28 марта 2001 28 февраля 2004 + 1 месяц = 28 марта 2004 ``` > Upd: "бухгалтерская" логика с "последним днём месяца" убирается под опцию. Таким образом убираем лишнее исключение из расчёта и неочевидное поведение, когда мы "случайно" цепляем 28 февраля. > Для получения логики "последний день месяца" в функцию `:add` необходимо передавать дополнительный параметр. Альтернативный вариант — цепочка с установкой явно последнего дня: `:add{ month=1 }:set{ day=-1 }` #### Параметр `adjust` Если требуется альтернативная арифметика "последнего дня", то в функции `:add`/`:sub` или конструктор интервала можно передавать параметр `adjust`. Поддерживаемые значения: - `none`: последний день не вычисляется. при арифметике выполняется только округление вниз, если номер дня превышает допустимый. используется по умолчанию. (impl details: DT_LIMIT) ```lua -- 29.02.* -> 29.03.* dt:add( {month = 1, adjust = "none" } ) ``` - `last`: последний день вычисляется. если в данной дате день является последним, то при прибавлении/вычитании месяцев в результирующей дате день должен быть последним. (impl details: DT_SNAP) ```lua -- 28.02.2001 -> 31.03.2001 -- last day in Feb 2001 -- 28.02.2004 -> 28.03.2004 -- not a last day in Feb => no abjustments -- 29.02.2004 -> 31.03.2001 -- last day in Feb 2004 dt:add( {month = 1, adjust = "last" } ) ``` - `excess`: выполняется переполнение. можно не делать. (impl details: DT_EXCESS) ### Сериализация Возврат `__serialize` должен вернуть таблицу, аналогичную передаваемой в конструктор. `__tostring` должен возвращать строковое представление. Формат представления можно позаимствовать у PostgreSQL. ## Этап 3. Парсинг дат по формату На этом этапе даём возможность пользователям парсить различные форматы данных. Для парсинга дат реализуем метод `:parse` с предустановленным, но расширяемым набором форматов. В качестве форматной строки используется POSIX strptime с расширенным форматом `%?f`. ### Парсинг даты по предопределённому формату Поддерживаемые форматы: * iso8601 * rfc3339 * strptime format * без указания формата - эвристический формат (то что есть сейчас, понимающее разные форматы “похожие” на 8601 с разного рода эвристикой: с пробелами/без и т.п.) * поддержать rfc3339 [upcoming draft](https://github.com/ryzokuken/draft-ryzokuken-datetime-extended) ```lua local dt = datetime.parse("20050809T183142", { format = "iso8601" }) local dt = datetime.parse("20050809T183142", { format = "iso8601", tz = "Europe/Moscow" }) local dt = datetime.parse("1937-01-01T12:00:27.87+00:20", { format = "rfc3339" }) local dt = datetime.parse("1937-01-01T12:00:27.87", { format = "rfc3339", tzoffset = 20 }) local dt = datetime.parse("2011-12-03T10:15:30.123+01:00[Europe/Paris]", { format = "rfc3339" }) ``` ### Парсинг даты по формату Для парсинга даты применяем метод `.parse`. В качестве формата используем `strptime` (см. `man strptime`) с расширением для наносекунд (`%f`). ```lua local dt = datetime.parse('2020-01-11 22:21:20.351', { format = '%F %T.%f' }) ``` ## Этап 4. Полноценная поддержка таймзон Реализуем возможность преобразования timezone строк вроде "Europe/Moscow" в `tzindex`. `tzindex` - это индекс в Olson БД: tzdata. После этого этапа должны заработать все возможности с атрибутом `tz` Логика работы конструктора `tz`. * Если в tz передано смещение, просто берём смещение и сохраняем в `tzoffset`. в `tzindex` сохраняем -1 * Выполняем поиск в tzdata нужной таймзоны. Если не найдено - бросаем исключение * Выбираем из найденной таймзоны смещение, сохраняем в tzoffset, а в поле tzindex сохраняем индекс в БД tzdata При вычислении :add/:sub и установленном tzindex вычисление производится с учётом tzdata. Этап завершается PR, включающим работу с полями `tz` в конструкторе и `tzindex` в С-структуре. С покрытием тестами. ## Links * [RFC3339](https://www.rfc-editor.org/rfc/rfc3339) * [Identifying time zones in ISO 8601](https://stackoverflow.com/questions/42194571/identifying-time-zones-in-iso-8601) * [MsgPack Spec](https://github.com/msgpack/msgpack/blob/master/spec.md) * [Falsehoods programmers believe about time](https://gist.github.com/timvisee/fcda9bbdff88d45cc9061606b4b923ca)