Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

100 years difference in time_point after serialization using std::put_time and std::get_time

Tags:

c++

datetime

I am implementing json serialization and deserialization of timestamps with nlohmann::json . After transforming std::chrono::time_point to mm/dd/yy hh:mm:ss style date-time (by our requirements, it must be human-readable) naturally I am losing some information, so my criterion for the time points equality after deserialization is "different by no more than a second".

All was working fine on my local machine with gcc 11.4.0, but it fails on production server with gcc 8.5.0.

Minimal code to reproduce the issue live example on godbolt:

#include <iostream>
#include <iomanip>
#include <chrono>
#include <string>

int main() {
    // serialization
    auto ts = std::chrono::system_clock::now();
    auto timeS_t = std::chrono::system_clock::to_time_t(ts);
    std::ostringstream outStream;
    outStream << std::put_time(std::localtime(&timeS_t), "%x %X");
    std::string tsStr = outStream.str();

    // deserialization
    std::istringstream ss(tsStr);
    std::tm t = {};
    ss >> std::get_time(&t, "%x %X");
    auto parsedTs = std::chrono::system_clock::from_time_t(std::mktime(&t));

    // difference in seconds
    int secDiff = std::chrono::duration_cast<std::chrono::seconds>(ts - parsedTs).count();

    // just debug prints
    std::cout << secDiff << std::endl;
    std::time_t ttp1 = std::chrono::system_clock::to_time_t(ts);
    std::time_t ttp2 = std::chrono::system_clock::to_time_t(parsedTs);
    std::cout << "ts1: " << std::ctime(&ttp1);
    std::cout << "ts2: " << std::ctime(&ttp2);

    // my equality criterion
    if (std::abs(secDiff) > 1) {
        std::cout << "FAIL\n";
    }
}

With newer compiler I get the equal results, e.g.

ts1: Thu Feb 22 16:06:25 2024
ts2: Thu Feb 22 16:06:25 2024

but with the old one I see the difference in time points by exactly 100 years:

ts1: Thu Feb 22 16:06:17 2024
ts2: Fri Feb 22 16:06:17 1924

I guess it might have something to do with std::localtime(&timeS_t) in serialization routine, but I didn't find the "reverse" function for deserializer. What can I change to have a consistent behaviour?

like image 463
pptaszni Avatar asked Aug 30 '25 17:08

pptaszni


2 Answers

You do not want to serialize a date/time to a locale-dependent format. Instead, serialize something that is independent of locale, like milliseconds since epoch:

// Serialize a time_point to a string
std::string serialize(const std::chrono::system_clock::time_point& time_point) {
    auto duration_since_epoch = time_point.time_since_epoch();
    auto milliseconds_since_epoch = std::chrono::duration_cast<std::chrono::milliseconds>(duration_since_epoch).count();
    return std::to_string(milliseconds_since_epoch);
}

// Deserialize a string to a time_point
std::chrono::system_clock::time_point deserialize(const std::string& str) {
    std::istringstream iss(str);
    long long milliseconds_since_epoch;
    iss >> milliseconds_since_epoch;
    auto duration_since_epoch = std::chrono::milliseconds(milliseconds_since_epoch);
    return std::chrono::system_clock::time_point(duration_since_epoch);
}

int main() {
    // Get the current time
    auto now = std::chrono::system_clock::now();

    // Serialize the time to a string
    std::string serialized_time = serialize(now);
    std::cout << "Serialized time: " << serialized_time << std::endl;

    // Deserialize the string back to a time_point
    auto deserialized_time = deserialize(serialized_time);
    std::cout << "Deserialized time: " << std::chrono::duration_cast<std::chrono::milliseconds>(deserialized_time.time_since_epoch()).count() << " milliseconds since epoch" << std::endl;

    return 0;
}

Live Demo

like image 188
AndyG Avatar answered Sep 02 '25 06:09

AndyG


Problem is how to interpret two digit year. When human sees '80 he assumes year 1980. And when he sees '22 he assumes 2022.

Now this human bias was not reflected in older version library so std::get_time just assumes that two digit year refers to 1900. New version of library tries to balance this and depeding on two digit value it assumes 1900 or 2000.

Here is demo of the problem including possible workaround for gcc 8.5: https://godbolt.org/z/MThhac943

struct Param
{
    std::string fullTime;
    std::string shortTime;
};

class MagicTimeStringTest : public testing::TestWithParam<Param>
{
public:
    using clock = std::chrono::system_clock;
    
    static clock::time_point parse(std::string in, std::string format)
    {
        std::istringstream stream(in);
        std::tm t = {};
        EXPECT_TRUE(stream >> std::get_time(&t, format.c_str()));
#ifdef WORAROUND
        if (t.tm_year < 69) {
            t.tm_year += 100;
        }
#endif
        return clock::from_time_t(std::mktime(&t));
    }

    static std::string toString(clock::time_point p)
    {
        std::ostringstream stream;
        auto t = clock::to_time_t(p);
        stream << std::put_time(std::localtime(&t), "%a %b %d %T %Y");
        return stream.str();
    }
};

TEST_P(MagicTimeStringTest, BarReturnsExpectedValue)
{
    auto fromShort = parse(GetParam().shortTime, "%x %X");
    auto fromFull = parse(GetParam().fullTime, "%b %d %T %Y");

    EXPECT_EQ(fromShort, fromFull) << toString(fromShort) << '\n' << toString(fromFull);

    auto diffHours = std::chrono::duration_cast<std::chrono::hours>(fromFull - fromShort).count();
    EXPECT_EQ(diffHours, 0);
}

const Param data[] {
    {"Feb 22 16:51:30 1994", "02/22/94 16:51:30"},
    {"Feb 22 16:51:30 1974", "02/22/74 16:51:30"},
    {"Feb 22 16:51:30 1970", "02/22/70 16:51:30"},
    {"Feb 22 16:51:30 1969", "02/22/69 16:51:30"},
    {"Feb 22 16:51:30 2068", "02/22/68 16:51:30"}, // note here change in test data it is 2068 not 1968
    {"Feb 22 16:51:30 2024", "02/22/24 16:51:30"},
};

INSTANTIATE_TEST_SUITE_P(StackOverflow, MagicTimeStringTest, testing::ValuesIn(data));

It would be nice to find explanation of this behavior in documentation. I would not be surprised, if border between switching centuries moves as is moving with current time, so future 2100 is addressed to.


Edit:

With help of perplexity.ai I was able to find some documentation which explains this behavior (google and bing failed on this search):

date

If century is not specified, then values in the range [69,99] shall refer to years 1969 to 1999 inclusive, and values in the range [00,68] shall refer to years 2000 to 2068 inclusive. The current year is the default if yy is omitted. [Option End]

Note:

It is expected that in a future version of IEEE Std 1003.1-2001 the default century inferred from a 2-digit year will change. (This would apply to all commands accepting a 2-digit year as input.)

So this behavior is standardized by IEEE Std 1003.1-2001 and wit is expected to be updated in a future. So far I was unable to find publicly available document with this standard.

Howard Hinnant has provide nice link to C++20 standard as comment:

[time.parse]

%y

The last two decimal digits of the year.

If the century is not otherwise specified (e.g., with %C), values in the range [69, 99] are presumed to refer to the years 1969 to 1999, and values in the range [00, 68] are presumed to refer to the years 2000 to 2068.

The modified command %Ny specifies the maximum number of characters to read.

If N is not specified, the default is 2.

Leading zeroes are permitted but not required.

The modified commands %Ey and %Oy interpret the locale's alternative representation.

like image 23
Marek R Avatar answered Sep 02 '25 06:09

Marek R