1

Our ASP.NET MVC web application has a feature to send out regular notification emails to users. We are going to add a feature where users can select a specific time of day to receive their email (i.e. "I would like to receive an email once per day at 5pm").

I plan on storing the selected time using the SQL time type. We'll have a background process which runs every minute and looks for users needing to be emailed. The process is using a LINQ-to-Entities query to find all matching users. I was going to write a query to basically find all users whose daily email time is less than the current UTC time of day - which is where I run into the problem. Each user can belong to a different timezone - we store their timezone as an IANA timezone identifier in the database. We use NodaTime in our project to convert any dates stored in our database as UTC into the user's timezone. To proceed with the way I'm trying to do this, I need something that I can put within a LINQ-to-entities query, so I can't really use any NodaTime functions to convert times and figure out if the current time of day is after the user's specified time of day, in their timezone. I can't just store their specified time of day in UTC time, if I did then it would be off by an hour whenever DST comes around.

The best solution I can come up with right now is to "cache" the current UTC offset for each timezone in our database. So I could have some code that uses NodaTime to grab the current offset for each timezone and saves that to a table in the database that stores the TimeZoneID and the UTCHourOffset. I could have a regular process to run this code and make sure the cached hour offsets stay up-to-date. Then in my query I can convert the specified time of day for each user by looking up the cached UTC offset and adding that to their time of day, to figure out if they should be sent an email.

Is there a simpler way to do what I'm trying to do?

jamebob
  • 312
  • 1
  • 10

1 Answers1

5

Glad to see you think through this carefully. Indeed, you cannot just store the UTC time when you are trying to schedule a recurring event in a user-local time zone. (Don't let anyone tell you "always UTC" - that's a common misunderstanding, and you indeed have a valid case for local time.) I've written about this a few times, and the most thorough answer is here.

Rather than cache the offset, you should consider pre-calculating the next run time as an exact UTC timestamp (both date and time). Then you can query against that to know when to send the email. Alternatively, consider using a timezone-aware job scheduler that already handles this. Quartz.NET is a good one.

You're correct that you cannot currently use Noda Time types with EF. This is due to a long lacking feature of EF tracked in this issue. The good news is the dev work was recently completed for EF Core 2.1 (still in development). After that releases, someone (probably me) will have to apply that feature to create an EF-NodaTime support package. In the meantime, treat SQL time types as a .Net TimeSpan, and use the Buddy Properties approach to expose the NodaTime types. You'll have to write your LINQ queries against the TimeSpan. You can refactor this later if you move to EF Core 2.1.

Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
  • You're my hero, Matt!! You answered another one of my DateTime questions a couple years back and it helped a ton. I wasn't really excited about caching the offset and pre-calculating the next run time in UTC seems like a great solution (and a much simpler query). Looking at your linked post, I guess I'm also gonna have to develop something to handle times that occur during the DST fallback. Yuck. – jamebob Feb 16 '18 at 22:25
  • Also, thanks for the info on using Noda Time types with EF, I didn't even realize that was close to being a possibility! Unfortunately I haven't been able to upgrade our project to ASP.NET Core yet so that might be a little ways off for us. – jamebob Feb 16 '18 at 22:26
  • 1
    Best choice (IMHO) for scheduling around the DST is to pick the first (daylight) time during the fall-back transition (ex: 01:30 => 01:30 PDT), and advance forward by the gap for the spring-forward transition (ex: 02:30 => 03:30 PDT). – Matt Johnson-Pint Feb 16 '18 at 22:28
  • 1
    (FYI - that's the default behavior of NodaTime's "lenient" resolver in 2.x). – Matt Johnson-Pint Feb 16 '18 at 22:35