Normal view

There are new articles available, click to refresh the page.
Before yesterdayMain stream

Never work with children, animals or time: nailing the nanosecond

By: hoakley
26 May 2025 at 14:30

Quite unintentionally, last week became a saga about time, and a good demonstration of how you should do your utmost to avoid working with it. This all started with an irksome problem in my new log browser LogUI.

LogUI departs from my previous log browsers in accessing log entries through the macOS API added in Catalina. When it first arrived, I found it opaque, and its documentation too incomplete to support coding a competitive browser at that time. Since then I have revisited this on several occasions, and each time retreated to using the log show command to obtain log extracts. As far as time is concerned, that presents me with two alternatives: a formatted string containing a date and timestamp down to the microsecond (10^-6 second), and a Mach timestamp giving the ‘ticks’ from an arbitrary start time.

Although the latter can only give relative time, the timestamp provided suffices for most purposes, and comes already formatted as
2025-05-25 07:51:05.200099+0100
for example.

Milliseconds

When you use the macOS API, date and time don’t come formatted, but are supplied as a Date, an opaque structure that can be formatted using a DateFormatter
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
let dateString = dateFormatter.string(from: date)

to provide
2025-05-24 13:46:12.683+0100

That only gives time down to the millisecond (10^-3 second), which is inadequate for many purposes. But changing the formatting string to "yyyy-MM-dd HH:mm:ss.SSSSSSZ"
just returns
2025-05-24 13:46:12.683000+0100
with zeroed microseconds. As I can’t find any documentation that states the expected time resolution of Dates, it’s unclear whether this is a bug or feature, but either way a different approach is needed to resolve time beyond milliseconds.

Nanoseconds

The only method I can see to recover higher precision in the macOS API is using DateComponents to provide nanoseconds (10^-9 second).
Calendar.current.component(.nanosecond, from: date)
returns an integer ready to format into a string using
nanosecStr = String(format: "%09d", nanoSeconds)
to give all nine digits.

Inserting that into a formatted date is a quick and simple way to construct what we want,
2025-05-24 13:46:12.683746842+0100
down to the nanosecond.

It’s only when you come to examine more closely whether the numbers returned as nanoseconds match changes seen in Mach timestamp, that you realise they’re a fiction, and what’s given as nanoseconds is in fact microseconds with numerical decoration.

Microseconds

The answer then is to round what’s given as nanoseconds to the nearest microsecond, which then matches what’s shown in Mach timestamps
let nanoSeconds = Int((Double(Calendar.current.component(.nanosecond, from: date))/1000.0).rounded())
and that can be converted into a string using
nanosecStr = String(format: "%06d", nanoSeconds)

Rather than manually format the rest of the date and timestamp, you can splice microseconds into the @ position of the format string
"yyyy-MM-dd HH:mm:ss.@Z"
turning
2025-05-24 13:46:12.@+0100
into
2025-05-24 13:46:12.683747+0100

Unfortunately, that’s too simple. If you test that method using times, you’ll discover disconcerting anomalies arising from the fact that seconds and microseconds are rounded differently. This is reflected in a sequence such as
2025-05-24 13:46:12.994142+0100
2025-05-24 13:46:13.999865+0100
2025-05-24 13:46:13.000183+0100

where the second rounds up to the next second even though the microseconds component hasn’t yet rounded up. The only way to address that is to format all the individual components in the string using DateComponents. And that leaves a further problem: how to get the time zone in standard format like +0100?

Time zones

Current time zone is available as an opaque TimeZone structure, obtained as
TimeZone.current
Note that this doesn’t need to be obtained individually for each Date, as its components are obtained using current Calendar settings, not those at the time the Date was set in that log entry. This should have the beneficial side-effect of unifying times to the same time zone and DST setting.

But that doesn’t offer it in a format like +0100, so that has to be calculated and formatted as
let timeZone = (TimeZone.current.secondsFromGMT())/36
let tzStr = String(format: "%+05d", timeZone)

Solution

The complete solution is thus:
let timeZone = (TimeZone.current.secondsFromGMT())/36
let tzStr = String(format: "%+05d", timeZone)
let dateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond, from: date)
let yearStr = String(format: "%04d", dateComponents.year ?? 0)
let monthStr = String(format: "%02d", dateComponents.month ?? 0)
let dayStr = String(format: "%02d", dateComponents.day ?? 0)
let hourStr = String(format: "%02d", dateComponents.hour ?? 0)
let minuteStr = String(format: "%02d", dateComponents.minute ?? 0)
let secondStr = String(format: "%02d", dateComponents.second ?? 0)
let nanoSeconds = Int((Double(dateComponents.nanosecond ?? 0)/1000.0).rounded())
let nanosecStr = String(format: "%06d", nanoSeconds)

then concatenate those together with punctuation marks as separators to deliver the string
2025-05-24 13:46:12.683746+0100

If you’re coding in Swift, you might instead consider using Date.FormatStyle, although its documentation only refers to handling milliseconds, so I suspect that might turn out to be another wild goose chase.

If you know of a better way of handling this explicitly, don’t hesitate to let my code therapist know.

❌
❌