0

I have been working on a solution to upgrade our current application to support timezones properly. The original idea of "everything is in our own timezone" is not panning out as well as expected for an app that has things that start and end at specific times for people all over the world. I need to add that our system consists of a few Azure Web App instances distributed in different timezones.

Our application is written in .Net Core 6 LTS and we use EFCore 6.0.3. Most of our entities have multiple nested levels so a solution of 'just change them out upon load' wont work. The objects are very complicated.

I've already written a decent ISaveChangesInterceptor that looks for changed entries of type DateTime / Datetime? and converts them to UTC to store in the db. This is acceptable, since it is our 'admin' section and its ok to look up the user's stored TimeZoneInfo.Id upon save, since generally it happens once per button press.

So now, I'm working on the part that converts back from UTC to the user's local timezone. I've started writing a DbCommandInterceptor with an override for GetDateTime but I'm finding that this code runs many times for each object and it is not a good idea to attempt to pull the user's data that many times.

I have some ideas to fix this but they all feel like bandaids...

  • Store the users data in memory somehow
  • Somehow pass the user into the CommandExecutedEventData but I have no idea how
  • Load user once, and use memorycache for all followup queries
  • some other idea that i haven't thought up / i'm not even sure the concept of "user" exists all the way down in a Data Reader

Most of the solutions on SO so far explain how to do this but they are either outdated (EntityFramework 6) or they are relying on the server local datetime which again, wont work for us since someone in Saudi Arabia might be connecting to an Azure server in who knows where.

Edit: Also I'd like to minimize changes to the DB, and currently all 'time' fields are datetime2.

My code is based heavily on what I learned here:

Obligatory Code. Notice where I suspect I need to convert back to local where I commented Get user info somehow here

public class UtcInterceptor : DbCommandInterceptor
{
    public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
    {
        if (eventData.Context != null)
        {
            if (result is not UtcDateTimeConvertingDbDataReader)
            {
                result = new UtcDateTimeConvertingDbDataReader(result);
            }
        }

        return base.ReaderExecuted(command, eventData, result);
    }

    public override ValueTask<DbDataReader> ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, DbDataReader result,
        CancellationToken cancellationToken = new CancellationToken())
    {
        if (eventData.Context != null)
        {
            if (result is not UtcDateTimeConvertingDbDataReader)
            {
                result = new UtcDateTimeConvertingDbDataReader(result);
            }
        }
        return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
    }
}

class UtcDateTimeConvertingDbDataReader : DelegatingDbDataReader
{
    public UtcDateTimeConvertingDbDataReader(DbDataReader source) : base(source) { }
    public override DateTime GetDateTime(int ordinal)
    {
        // Get user info somehow here
        return DateTime.SpecifyKind(base.GetDateTime(ordinal), DateTimeKind.Utc);
    }
}


public class DelegatingDbDataReader : DbDataReader
{
    // Since DbDataReader is an irritating override of an abstract class,
    // I have to do something like this below to actually create a way to specify datetime kind is utc
    private readonly DbDataReader source;

    public DelegatingDbDataReader(DbDataReader source)
    {
        this.source = source;
    }

    public override object this[string name] { get { return source[name]; } }
    public override object this[int ordinal] { get { return source[ordinal]; } }
    /// truncated for brevity
CarComp
  • 1,929
  • 1
  • 21
  • 47
  • 2
    As an aside, assuming these start and end times are in the future, storing in UTC is often not the best idea given time zone rules can change (e.g. US recently decided to make DST permanent). The calculated UTC time could prove inaccurate. [Storing UTC is not a silver bullet](https://codeblog.jonskeet.uk/2019/03/27/storing-utc-is-not-a-silver-bullet/) is a good read. – Charles Mager Apr 06 '22 at 14:32
  • Its annoying there are so many ways to do this. My efforts are purely exploratory / POC at this point. I have probably 3 more days before I need to report back my findings. FWIW I had read that and decided that pursuing UTC at this point is better than our "ignore the timezones completely" approach we have now. Also I'd like to minimize changes to the DB, and currently all 'time' fields are datetime2. – CarComp Apr 06 '22 at 14:43
  • @CarComp the correct way is to *NOT* do it. If timezones matter, use DateTimeOffset and return that to the client. Let the client decide how to display this. All .NET UI technologies can format a DateTimeOffset correctly, as local time. If you can't do that, use UTC everywhere and never try to change to local until the last minute, typically while displaying the date in the UI. Without the offset there's no way to know what a datetime refers to. And "local" changes twice a year for most countries – Panagiotis Kanavos Apr 06 '22 at 14:50
  • Its a virtual event scheduling system. I can't think of a way to _not_ use timezones. People will be showing up for live presentations at specific times. Our hiccup was that displaying the times on the frontend was freaking out momentjs and we are trying to mitigate that. – CarComp Apr 06 '22 at 14:52
  • As for `ignore the timezones completely` you aren't doing that. You're assuming the offset is the same as the current end user's. This is wrong half the year even for users in the same country. For users in other countries, it's almost always wrong. – Panagiotis Kanavos Apr 06 '22 at 14:52
  • Its irritating to understand it but not be able to write it in code. – CarComp Apr 06 '22 at 14:54
  • Even offsets aren't enough, simply because timezone rules change. The best option is to store the IANA timezone *name* along with the local time. The IANA timezone database is the de-facto standard for timezone rules, going back to the 1800s (at least). All OSs except Windows use it. Whether the US changes their DST rules next year, `7pm America/New_York` will be valid. .NET's own types don't use it but NodaTime bundles it and even allows you to use your own custom tzdb - useful when you want to deploy a new tzdb before a new NodaTime release – Panagiotis Kanavos Apr 06 '22 at 14:56

0 Answers0