-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Time zone behaviour and functionality (RFC/WIP) #7914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
I really like where this is going 👏. Thank you for working on this! Here are couple design considerations when it comes to the time zone database interface - I haven't looked at the functions in
|
lib/elixir/lib/calendar/datetime.ex
Outdated
def from_naive(naive_datetime, time_zone) | ||
@spec from_naive(NaiveDateTime.t(), Calendar.time_zone(), TimeZoneDatabase.t() | :from_config) :: | ||
{:ok, t} | ||
| {:ambiguous, [TimeZoneDatabase.time_zone_period()]} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be {:ambiguous, [t]}
and {:gap, [t]}
lib/elixir/lib/calendar/datetime.ex
Outdated
@@ -177,20 +177,134 @@ defmodule DateTime do | |||
Converts the given `NaiveDateTime` to `DateTime`. | |||
|
|||
It expects a time zone to put the NaiveDateTime in. | |||
Currently it only supports "Etc/UTC" as time zone. | |||
|
|||
It only supports "Etc/UTC" as time zone if a TimeZoneDatabase |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would include backticks for autolinking in `TimezoneDatabase`
lib/elixir/lib/calendar/datetime.ex
Outdated
|
||
case by_wall(time_zone_data_module, time_zone, gregorian_seconds) do | ||
{:single, period} -> | ||
do_from_naive( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think about from_naive_and_period(naive_datetime, time_zone_period)
, if time_zone
is included in the time_zone_period
map?
includes and begins from the begining of second 63594810000 and lasts until | ||
just before second 63612954000. | ||
""" | ||
@type time_zone_period :: %{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would match the fields related to the time zone information in the DateTime
struct: :zone_abbr
, :utc_offset
, :std_offset
, and :time_zone
.
|
||
@doc """ | ||
Takes a time zone name and a point in time for UTC and returns a | ||
`time_zone_period` for that point in time. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you have to use t:time_zone_period/0
for linking types
@callback by_utc(Calendar.time_zone(), gregorian_seconds) :: | ||
{:ok, time_zone_period} | {:error, :time_zone_not_found} | ||
|
||
@doc """ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the first line should be a summary of the function.
| {:error, :time_zone_not_found} | ||
|
||
@doc """ | ||
Returns a list of all known leap seconds. Each element in the list is a tuple |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would add a new line after the first sentence, so Returns a list of all known leap seconds.
becomes the summary.
from winter time to summer time, a tuple with `:gap` and a list of two time zone periods are returned. The first | ||
period in the list is the period before the gap and the second period is the period just after the gap. | ||
|
||
If there is only a single possible period for the provided `gregorian_seconds`, the a tuple with `:single` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo s/the a tuple/then a tuple
lib/elixir/lib/calendar/datetime.ex
Outdated
@doc """ | ||
Takes a DateTime and a time zone. | ||
|
||
Returns a DateTime for the same point in time, but instead at the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would make this line the first one so it becomes the summary
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would also include backticks in DateTime and reference the variable (and some rewording):
Returns a `DateTime` for the same point in time in the given `time_zone`.
lib/elixir/lib/calendar/datetime.ex
Outdated
|
||
Returns a DateTime for the same point in time, but instead at the | ||
time zone provided. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would include ## Examples
header and new line after the examples
lib/elixir/lib/calendar/datetime.ex
Outdated
time_zone, | ||
time_zone_data_module \\ :from_config | ||
) do | ||
in_utc = datetime |> to_unix |> from_unix! |> Map.put(:microsecond, datetime.microsecond) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at this dance to_unix |> from_unix!
, what do you think about including a to_utc/1
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this was a quick way to have something working. It can be optimised in various ways. For instance if the DateTime total offset (UTC + standard) is 0 then it does not have to be changed.
lib/elixir/lib/calendar/datetime.ex
Outdated
in_utc = datetime |> to_unix |> from_unix! |> Map.put(:microsecond, datetime.microsecond) | ||
|
||
gregorian_seconds_utc = | ||
:calendar.datetime_to_gregorian_seconds(in_utc |> NaiveDateTime.to_erl()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would write this either:
gregorian_seconds_utc = :calendar.datetime_to_gregorian_seconds(NaiveDateTime.to_erl(in_utc))
or
gregorian_seconds_utc =
in_utc
|> NaiveDateTime.to_erl()
|> :calendar.datetime_to_gregorian_seconds()
lib/elixir/lib/calendar/datetime.ex
Outdated
|
||
{:gap, [latest_datetime_before, first_datetime_after]} | ||
|
||
{:error, _} = error -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would match here on :time_zone_not_found
like you did on shift_zone/3
lib/elixir/lib/calendar/datetime.ex
Outdated
| {:gap, [TimeZoneDatabase.time_zone_period()]} | ||
| {:error, :time_zone_not_found} | ||
|
||
def from_naive(naive_datetime, time_zone, time_zone_data_module \\ :from_config) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe drop the _module
suffix and use time_zone_database
?
lib/elixir/lib/calendar/datetime.ex
Outdated
end | ||
|
||
def now(time_zone, time_zone_data_module) do | ||
utc_now() |> shift_zone(time_zone, time_zone_data_module) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think single pipes are discouraged, maybe shift_zone(utc_now(), time_zone, time_zone_data_module)
?
lib/elixir/lib/calendar/datetime.ex
Outdated
end | ||
|
||
@doc """ | ||
Returns the current datetime in the provided time zone. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would reference the variable:
Returns the current datetime in the provided `time_zone`.
lib/elixir/lib/calendar/datetime.ex
Outdated
try do | ||
time_zone_data_module.by_wall(time_zone, gregorian_seconds) | ||
rescue | ||
UndefinedFunctionError -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might move these checks to the function clause time_zone_data_module_from_parameter(:from_config)
:
defp time_zone_data_module_from_parameter(:from_config) do
case :elixir_config.safe_get(:time_zone_module, :from_config) do
time_zone_data_module -> time_zone_module
nil -> raise "<A good error message>"
end
end
If a time_zone_data_module
is provided, it should implement the TimeZoneDatabase
behaviour. If the module does not, I think the error is quite easy to understand: ** (UndefinedFunctionError) function MyTimeZoneDatabase.by_utc/2 is undefined or private
.
Beside, I would use the same name for the variable (now time_zone_data_module
) and for the key in the configuration (now time_zone_module
).
@callback leap_seconds() :: [{:calendar.datetime(), integer()}] | ||
|
||
@doc """ | ||
Returns a datetime tuple with the UTC datetime for when the leap second |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe clarify that this datetime is not an Elixir's DateTime
map/struct saying something like Returns an Erlang datetime tuple
?
On the other hand, being this defined in the Elixir world, what are the benefits of returning :calendar.datetime()
instead of Calendar.naive_datetime()
(or Calendar.datetime()
)?
Since this is an earlier proposal, we should focus on reviewing the code and its features. Once we agree on the API and everything else, we can do a more thorough review on the docs. :) |
Thank you for the feedback @michalmuskala
A nice thing about gregorian seconds is that it is a simple integer and it fits well with the time zone data. The tzdata from IANA is not calendar agnostic -- it is in the proleptic Gregorian calendar (which Calendar.ISO implements). Furthermore the level of detail is at the seconds level. So implementing the database and querying can be done for instance by comparing integers in a guard clause in a macro or in an ETS query. Imagine that you have a UNIX timestamp (1531785600) and want to find out the time for that in a certain timezone. All you have to do is: However, in the examples I have called the functions in the Erlang calendar module directly for converting to and from gregorian seconds. To avoid that we can instead use functions in This way we avoid adding complexity to the database API and the database implementations.
Yeah the calling :elixir_config directly part is one of the details that aren't ready yet and was just to have something simple working.
The other ones are used for when there is a gap. Then it is nice to know what the limits of the gap are. However currently only
Yes, there are always two periods if it is ambiguous or there is a gap. Having them directly in the tuple sounds good.
Regarding a mock database that only has UTC, I could see that having some advantages, but we might want to have special shortcuts for UTC anyway even when there is a proper database. And then for the user how would that be explained? "You don't have a database (but internally there really is one anyway)" or "You haven't configured a database, so there is this mock database that doesn't really have any data". I think there are advantages to saying: "error, you have not configured a database". And the concept of a lack of a database being more simple than a mock one. If someone tries to get the time for a timezone when there is no timezone database the error should probably be something like "database missing" instead of "that timezone doesn't exist - well maybe it does, but this is a mock database".
We also want to know for instance "is this a leap second"? "What is the net leap second difference between these two datetimes"? There will be more functions in the API. If we want more or a different functionality in the future then the API will have to change. If we just have all of the leap seconds, then the API won't have to change, but of course it adds more complication to the Elixir (TimeZoneDatabase client) side. It might make sense to implement the features we want now and then see what features are needed and see what would be needed for those in the API. I will do some work on that. |
We can likely still pass iso_days to the database and the database can then convert it to gregorian seconds and perform the lookups. We should not mix the contract with what is the most convenient implementation. :) In particular, we have removed all the dependencies we had in the |
I switched from ISO seconds to Erlang style datetime tuples ( Non-ISO calendars are now supported in the time zone functions. They have to be compatible (convertible) with the ISO calendar because the time zones are defined in ISO terms. |
Thanks @lau! We worked really hard to no longer depend on Erlang's You have already said that the timezone database would compare integers, as that is the most efficient format, and the iso days already get you half way there without wasting CPU work, so why not rely on iso_days? To be more precise, iso_days is |
To be more clear about what I wrote the other day:
Ie. we don't have to depend on the Gregorian seconds functions in Erlang's :calendar module even if using ISO seconds. One of the functions already has a pure Elixir implementation in Calendar.ISO ( elixir/lib/elixir/lib/calendar/iso.ex Line 802 in c114300
ISO days (with the fraction needed) is not an integer, but rather a nested tuple with different integers. So the TimeZoneDatabase implementation would probably still want to convert it to an integer like ISO seconds. If you provide a ISO days seems like a useful solution for converting between calendars, but this is all Gregorian. The time zones are defined in Gregorian terms in the database. All of them would look like this: The Erlang style tuples (e.g. |
There is one implementation that is gregorian, we can't assert all possible implementations would like to stay gregorian. ISO days is the format between different parts of Calendar and we need to stick to it here.
If a time zone database uses ISO seconds, this is not an extra step. After all, the first thing you will is to convert the date to ISO days and the time to ISO seconds. Sure, there is a very minor annoyance which is the ISO days tuple with So if you want to keep passing datetime tuples for now, then sure, but at some point before or after merging you or someone in the Elixir team should rewrite it to ISO days+seconds. At the end of the day, Elixir's calendar code should not have references to the Thanks! |
@josevalim Thank you for your thoughts on it. I enjoy discussing the rationale behind it to exchange ideas on it :) It am not dead set on any particular type. When it comes to supporting leap seconds, we would want a type that supports leap seconds. For instance you can do With ISO days I don't get the same thing back: iex> Calendar.ISO.naive_datetime_to_iso_days(2016, 12, 31, 23, 59, 60, {0, 0}) |> Calendar.ISO.naive_datetime_from_iso_days
{2016, 12, 31, 24, 0, 0, {0, 6}} Should we use ISO days for the time zones and some kind of tuple or map that supports leap seconds for leap seconds? I also remember talk from a long time ago about a NaiveDateTime struct, but just with the struct part removed or ignored. Basically the BTW I saw that P.S. @michalmuskala I forgot to mention that I moved the results out of the list for gaps and ambiguous as you suggested. E.g: |
We will need to do something eventually but that's a separate discussion since |
When trying to create a DateTime with the second being 60 (positive leap second), check to see if that is a leap second known by a TimeZoneDatabase.
When a TimeZoneDatabase has not been set, an error tuple is desired. When something else goes wrong, raising makes sense.
If a leap second is outside of the known range of leap seconds, return it, but not tagged with :ok. It might turn out to be a valid leap second in the future, but we cannot be sure. The user gets to decide what to do.
For potential future use in the DateTime module.
And make sure leap seconds are correct in the examples. There was no leap second in 1971 despite an entry in the `leap-seconds.list` file distributed by IANA.
@josevalim |
Replace with tuples when returning result for a :gap
Hi @lau! I will review this for merge. I will drop comments here, for guidance, but you don't have to worry about them, as I will tackle them post merge. If I have any questions I will ping you. |
I also want to add that, after a IRC convo in July, @lau and I agreed to not build leap seconds or consider leap seconds in the add/diff APIs. That's because if we start building leap seconds and handing off to other APIs, they can flat-out deny leap seconds. So we will follow on the footsteps of JodaTime and parse leap seconds but convert them to the previous second. |
Yes, and I would just like to repeat that this ignoring leap seconds was just about the default behaviour standard add/diff functions. So that leap seconds will be ignored for those functions by default. E.g. if you pass a positive leap second to an add function or diff function it will be treated as the second before. And any leap second between the two datetimes will be ignored in the calculation. Parsing of strings to DateTime/NaiveDateTime/Time structs should still correctly parse the perfectly valid datetime. E.g. Regarding talking to APIs that are not able to handle valid ISO datetimes if they are leap seconds - this should not be a problem as long as the defaults are used. Most computers today do not create timestamps with leap seconds anyway. Even Now for something we also discussed adding to the API at some point: However now that I think about an add operation where we want to take leap seconds into account: Again this is a situation where a user is not using the defaults of an |
def from_naive(%{calendar: calendar} = naive_datetime, time_zone, tz_db_or_config) | ||
when calendar != Calendar.ISO do | ||
# For non-ISO calendars, convert to ISO, create ISO DateTime, and then | ||
# convert to original calendar |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I want to mention this is not the most efficient nor the most correct way to go about this because it doesn't give the opportunity for databases which cannot be converted to ISO to handle timezones. The correct way would probably to add a new callback to Calendar
. However, I am not even sure if there is a calendar that fits this criteria, so I would rather wait until an use case proves this necessary. So I am perfectly happy to ship this as is! 👍 ❤️
end | ||
|
||
def shift_zone(%{calendar: calendar} = datetime, time_zone, tz_db_or_config) | ||
when calendar != Calendar.ISO do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since shift_zone relies on iso days conversion, we can completely skip those conversions and generalize the clause above. I have a patch ready.
Closing in favor of #8383. |
This provides a behaviour for providing time zone data to Elixir (
lib/elixir/lib/calendar/time_zone_database.ex
). It allows anyone to implement a library that provides time zone data and use that with Elixir.In addition to the behaviour there is code changes that enables Elixir to get a
DateTime
for the time right now in any timezone, to change between timezones and to create a newDateTime
in any timezone. All using the behaviour defined byTimeZoneDatabase
.The behaviour includes functions for getting timezone information for a certain period for a certain time zone. Either by the time in UTC or the "wall time". Wall time being the time you would see on a clock on the wall, that is taking into account UTC offset as well as DST.
There are also functions for getting leap second information. This information is provided by IANA in the time zone data tar file. It is useful for validating UTC datetimes and for calculating differences where leap seconds are taken into account. It could also be used for calculating TAI and GPS time. In this PR there is currently not any functions that use the leap second data, but I might update the PR with that later.
Additionally there is functionality that uses the time zone data in
date_time.ex
.The functions take an optional module name with a module that implement the behaviour:
Instead of providing the module as a argument, Elixir can be configured to use a specific module.
:elixir_config.put(:time_zone_module, Tzdata.TimeZoneDatabase)
Then the argument is not needed for the functions and the configured module will be used instead:
gaps and overlaps (ambiguity) for instance during "spring forward" and autumn are handled.
You can get the time right now in any timezone:
Get the same time for a datetime in another timezone:
This is not considered finished. Some of the details could be optimised. The main thing I focused on is the TimeZoneData behaviour. The
iex>
examples above were made with a working implementation of the behaviour not included here.