Introduction

I am writing ASP.NET Core code for an automated cloud directory synchronization process that will run on Google Cloud Run and Kubernetes. This code requires the current date and time in the local time zone. Simple problem, or so I thought.

This is the original code that I wrote.

public string GetLocalDateTime()
{
        DateTime utcTime = DateTime.UtcNow;

        TimeZoneInfo pstZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");

        DateTime pstTime = TimeZoneInfo.ConvertTimeFromUtc(utcTime, pstZone);

        return pstTime.ToString();
}

This code works just fine on my development system, Microsoft Windows [Version 10.0.19041.508]. This code failed when I deployed to Google Cloud Run based on the container image mcr.microsoft.com/dotnet/core/aspnet:3.1.

The error message:

I then deployed the same code as a Docker container and received the same error. That is good as the debugging cycle is faster with Docker and confirms that this is not a Google Cloud Run issue. Note: the error message is a Bootstrap dialog that reports error messages in the client browser. The actual error text is “The time zone ID ‘Pacific Standard Time’ was not found on the local computer” is reported via an exception caught in the calling code.

The first solution

A quick solution is to add exception handling code in my function and return UTC time instead of local time if my code hits an exception.

public string GetLocalDateTime()
{
    try
    {
        DateTime utcTime = DateTime.UtcNow;

        TimeZoneInfo pstZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");

        DateTime pstTime = TimeZoneInfo.ConvertTimeFromUtc(utcTime, pstZone);

        return pstTime.ToString();
    }
    catch (System.Exception e)
    {
            Console.WriteLine(e.Message);

            DateTime now = DateTime.Now;

            return now.ToString() + " UTC";
    }
}

Some developers might stop there and just report this as fixed with a runtime exception notice in the README. I wanted to understand why this code failed.

Investigate

I then wrote a simple program that I could run under Windows and Docker.

using System;

namespace TestCode
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Time Zone Test Program");

            var timeZones = TimeZoneInfo.GetSystemTimeZones();

            foreach (TimeZoneInfo timeZone in timeZones)
            {
                 Console.WriteLine("ID: {0}", timeZone.Id);
                 Console.WriteLine("   Display Name: {0, 40}", timeZone.DisplayName);
                 Console.WriteLine("   Standard Name: {0, 39}", timeZone.StandardName);
                 Console.WriteLine("   Daylight Name: {0, 39}", timeZone.DaylightName);
            }
        }
    }
}

I ran this program under Windows, and below is a partial output. Looks normal. I am just displaying the entry for Pacific Standard Time to keep this short:

ID: Pacific Standard Time
   Display Name:   (UTC-08:00) Pacific Time (US & Canada)
   Standard Name:                   Pacific Standard Time
   Daylight Name:                   Pacific Daylight Time

I ran the code under Docker. The time zone IDs are different. Linux has its own way of naming things that are often different from Windows.

ID: America/Los_Angeles
   Display Name:                    (UTC-08:00) GMT-08:00
   Standard Name:                               GMT-08:00
   Daylight Name:                               GMT-07:00

This shows that there are two different time zone IDs:

  • Windows -> “Pacific Standard Time”
  • Linux -> “America/Los_Angeles”

Windows maintains a list of time zones in the registry. These values are published here (link).

Linux distributions use the list of time zones maintained by the Internet Assigned Numbers Authority (IANA) (link).

Why didn’t I just use a library or .NET package?

I did not want to introduce another dependency. In our environment, which is high security (government and financial industries), source code must go through a review process. By keeping the required code to a minimum, the code review is simpler. Plus external libraries might not pass our tests delaying the project.

Code, test, investigate and repeat

I rewrote GetLocalDateTime() to catch the exception and try again using the other time zone ID.

public static string GetLocalDateTime()
{
    try
    {
        string tz_id = "Pacific Standard Time";

        DateTime utcTime = DateTime.UtcNow;

        TimeZoneInfo pstZone = TimeZoneInfo.FindSystemTimeZoneById(tz_id);

        DateTime pstTime = TimeZoneInfo.ConvertTimeFromUtc(utcTime, pstZone);

        return pstTime.ToString();
    }
    catch (System.Exception)
    {
        try
        {
            string tz_id = "America/Los_Angeles";

            DateTime utcTime = DateTime.UtcNow;

            TimeZoneInfo pstZone = TimeZoneInfo.FindSystemTimeZoneById(tz_id);

            DateTime pstTime = TimeZoneInfo.ConvertTimeFromUtc(utcTime, pstZone);

            return pstTime.ToString();
        }
        catch (System.Exception e)
        {
            Console.WriteLine(e.Message);

            DateTime now = DateTime.Now;

            return now.ToString() + " UTC";
        }
    }
}

That worked. However, the returned date/time string is different (which I am ignoring for this project). Windows 10 is returning the time based upon a 12-hour clock with AM/PM and Linux is returning the time based upon a 24-hour clock. The solution is to use a custom date and time format string (link).

Windows:

  • 9/20/2020 11:08:19 AM

Linux:

  • 09/20/2020 11:08:12

I did not like the new code. Seems sloppy and lazy. Let’s dig further into the error that is happening.

I removed the try/catch code so I could see the exception type and error message. The line of code that is failing is:

TimeZoneInfo pstZone = TimeZoneInfo.FindSystemTimeZoneById(tz_id);

The exception message:

Unhandled exception. System.TimeZoneNotFoundException: The time zone ID 'America/Los_Angeles' was not found on the local computer.
   at System.TimeZoneInfo.FindSystemTimeZoneById(String id)
   at TestCode.Program.Main(String[] args) in C:\work\TestCode-Console\Program.cs:line 30

Now I know the exception name (System.TimeZoneNotFoundException). Reviewing the documentation for FindSystemTimeZoneById() (link), shows that TimeZoneNotFoundException is documented with this description:

Microsoft description:

The time zone identifier specified by id was not found. This means that a time zone identifier whose name matches id does not exist, or that the identifier exists but does not contain any time zone data.

Let’s move FindSystemTimeZoneById() into a separate function passing the two different time zone IDs, one for Windows and the other for Linux, and let the function try each one until success. If neither time zone IDs work, this function returns null.

public static TimeZoneInfo GetTimeZoneInfo(string id_1, string id_2)
{
    try
    {
        TimeZoneInfo info = TimeZoneInfo.FindSystemTimeZoneById(id_1);

        return info;
    }
    catch (System.TimeZoneNotFoundException)
    {
        try
        {
            TimeZoneInfo info = TimeZoneInfo.FindSystemTimeZoneById(id_2);

            return info;
        }
        catch (System.TimeZoneNotFoundException)
        {
            return null;
        }
    }
    catch (System.Exception)
    {
        return null;
    }
}

Now we can rewrite the GetLocalDateTime() function:

public static string GetLocalDateTime()
{
    string tz_id_1 = "Pacific Standard Time";
    string tz_id_2 = "America/Los_Angeles";

    DateTime utcTime = DateTime.UtcNow;

    TimeZoneInfo pstZone = GetTimeZoneInfo(tz_id_1, tz_id_2);

    if (pstZone == null)
    {
        return utcTime.ToString() + " UTC";
    }

    DateTime pstTime = TimeZoneInfo.ConvertTimeFromUtc(utcTime, pstZone);

    return pstTime.ToString();
}

The function GetTimeZoneInfo() uses exceptions to handle errors. Another technique is to loop through the list of time zones and check if the ID exists:

public static TimeZoneInfo GetTimeZoneInfo(string id_1, string id_2)
{
    var timeZones = TimeZoneInfo.GetSystemTimeZones();

    foreach (TimeZoneInfo timeZone in timeZones)
    {
        if (timeZone.Id == id_1)
        {
                return TimeZoneInfo.FindSystemTimeZoneById(id_1);
        }

        if (timeZone.Id == id_2)
        {
                return TimeZoneInfo.FindSystemTimeZoneById(id_2);
        }
    }

    return null;
}

If you prefer to use Linq:

using System.Linq;
using System.Collections.ObjectModel;

public static TimeZoneInfo GetTimeZoneInfo(string id_1, string id_2)
{
    ReadOnlyCollection<TimeZoneInfo> timeZones;

    timeZones = TimeZoneInfo.GetSystemTimeZones();

    if (timeZones.Any(x => x.Id == id_1))
    {
        return TimeZoneInfo.FindSystemTimeZoneById(id_1);
    }

    if (timeZones.Any(x => x.Id == id_2))
    {
        return TimeZoneInfo.FindSystemTimeZoneById(id_2);
    }

    return null;
}

Benchmark

Which coding technique should I use in my code? I decided to benchmark each GetTimeZoneInfo function. The results surprised me.

I wrote a test harness and called each function ten million times under Windows 10 and under Docker Linux on Windows 10:

Windows 10 native:

Method 1: Execution Time: 1,649 ms
Method 2: Execution Time: 5,038 ms
Method 3: Execution Time: 4,788 ms

Docker Linux on Windows 10:

Method 1: Execution Time: 705,236 ms
Method 2: Execution Time: 40,237 ms
Method 3: Execution Time: 114,558 ms

Highlights:

  • Docker is much slower.
  • Why is Method #1 so much slower on Docker?
  • Why is Method #2 the slowest on Window but the fastest on Docker?

I reversed the order of the comparison by swapping tz_id_1 and tz_id_2. This showed some interesting changes in timings.

Windows 10 native:

Method_1: Execution Time: 267,478 ms
Method_2: Execution Time: 5,007 ms
Method_3: Execution Time: 35,864 ms

Docker Linux on Windows 10:

Method_1: Execution Time: 43,775 ms
Method_2: Execution Time: 41,670 ms
Method_3: Execution Time: 44,485 ms

The numbers look much better for Docker now. My theory for Method #1 is that c# exceptions are expensive.

Since exceptions are expensive, check the platform we are running on to provide hints for our code.

using System.Runtime.InteropServices;

string tz_id_1;
string tz_id_2;

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
        tz_id_1 = "Pacific Standard Time";
        tz_id_2 = "America/Los_Angeles";
} else {
        tz_id_1 = "America/Los_Angeles";
        tz_id_2 = "Pacific Standard Time";
}

New benchmark results.

Windows 10 native:

Method_1: Execution Time: 1,628 ms
Method_2: Execution Time: 5,286 ms
Method_3: Execution Time: 4,951 ms

Docker Linux on Windows 10:

Method_1: Execution Time: 36,004 ms
Method_2: Execution Time: 40,376 ms
Method_3: Execution Time: 40,959 ms

By using hints, Method #1 is now the best method. By selecting the correct time zone ID for the platform, we remove exceptions that are slow.

Final Code

Here are the results of this work. After testing this, I changed the code to put the time zone IDs in appSettings.json and then wired up configurations. The configuration changes are not part of this code to keep things simple.

public static string GetLocalDateTime()
{
    string tz_id_1;
    string tz_id_2;

    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    {
        tz_id_1 = "Pacific Standard Time";
        tz_id_2 = "America/Los_Angeles";
    } else {
        tz_id_1 = "America/Los_Angeles";
        tz_id_2 = "Pacific Standard Time";
    }

    DateTime utcTime = DateTime.UtcNow;

    TimeZoneInfo pstZone = GetTimeZoneInfo(tz_id_1, tz_id_2);

    if (pstZone == null)
    {
        return utcTime.ToString() + " UTC";
    }

    DateTime pstTime = TimeZoneInfo.ConvertTimeFromUtc(utcTime, pstZone);

    return pstTime.ToString();
}

public static TimeZoneInfo GetTimeZoneInfo(string id_1, string id_2)
{
    try
    {
        TimeZoneInfo info = TimeZoneInfo.FindSystemTimeZoneById(id_1);

        return info;
    }
    catch (System.TimeZoneNotFoundException)
    {
        try
        {
            TimeZoneInfo info = TimeZoneInfo.FindSystemTimeZoneById(id_2);

            return info;
        }
        catch (System.TimeZoneNotFoundException)
        {
            return null;
        }
    }
    catch (System.Exception)
    {
        return null;
    }
}

Summary

My solution may not be the best solution for you. Managing time zones is complicated. The details of existing IDs change and new IDs are created. Getting the world to agree on time is very problematic. For example, there was a time zone update this month (September) and one last month (link). Time keeps changing …

  • If you only deploy on one platform, then you will not worry about time zone ID differences between Windows and Linux.
  • You may prefer to use a .NET package and not worry about the details. Example .NET package:
  • Instead of storing two separate time zone IDs, you may prefer a lookup table.
  • Consider storing and displaying all dates and times in UTC. Time zones do not matter for UTC.

Note: I chose not to use a lookup table as that is another item to manage, test and verify. Time zone details keep changing. I decided to require the administrator who configures the application, to lookup the correct time zone IDs instead of hard coding them in source code.

More Information