Skip to content

Format JSON timestamps without DateTime - #451

Merged
whatyouhide merged 2 commits into
elixir-protobuf:mainfrom
bruckner:faster-rfc3339-encode
Oct 9, 2026
Merged

whatyouhide merged 2 commits into
elixir-protobuf:mainfrom
bruckner:faster-rfc3339-encode

Conversation

@bruckner

Copy link
Copy Markdown
Contributor

Motivation

I profiled a Phoenix JSON API that encodes proto messages with Protobuf.JSON (protobuf 0.17.0), running under load (500 simulated users, perf record with +JPperf true, 60 s, ~32k samples). Protobuf.JSON.Encode.encodable/2 took 13.6% of BEAM samples, and 7.5% were inside Protobuf.JSON.RFC3339.encode/2. That is more than half of JSON encoding time spent formatting Google.Protobuf.Timestamp values. The encoder goes through DateTime.from_unix/2, two DateTime.compare/2 range checks, DateTime.to_iso8601/2, and a String.split_at/2 to splice in the fractional seconds.

Note: This is independent of #449, which speeds up the binary encoder; this is the JSON path.

What changed

RFC3339.encode/2 now:

  • checks the allowed range (0001-01-01T00:00:00Z to 9999-12-31T23:59:59Z, i.e. seconds -62_135_596_800..253_402_300_799) with integer comparisons in the function guard;
  • splits the seconds with :calendar.gregorian_seconds_to_datetime/1;
  • writes the fixed-width digits straight into a binary;
  • renders nanos with 0, 3, 6 or 9 digits, as the proto3 JSON mapping asks. This is the same rendering Utils.format_nanoseconds/1 gave.

Inputs outside the range still go through DateTime.from_unix/2, so the error tuples don't change. You still get "timestamp is outside of allowed range" for years -9999..0000 and ":invalid_unix_time" beyond that. The nanos >= 1_000_000_000 clause is untouched. decode/1 is untouched too; datetime_in_allowed_range?/1 and Utils are still used there.

Byte-identity test

test/protobuf/json/rfc3339_test.exs keeps the old DateTime-based encoder as a private reference_encode/2 and asserts RFC3339.encode/2 == reference_encode/2 in two places:

  • Edge cases: min and max, the epoch and one second before it, 2000-02-29, 1900-02-28 and 1900-03-01, and the 0099/0100, 0999/1000 and 1999/2000 rollovers, plus 2038-01-19T03:14:08Z. Each one is crossed with nanos 0, 1, 10, 999, 1_000, 1_001, 1_000_000, 1_000_001, 123_456_789 and 999_999_999.
  • Random sample: 200,000 random seconds across the whole allowed range. The nanos are a mix of zero and millisecond-, microsecond- and nanosecond-precision values.

A third test checks the out-of-range error tuples against the reference, just below the minimum, just above the maximum, and at and below Calendar.ISO's lower bound. The full suite passes (568 tests, 33 properties, 33 doctests, 0 failures) on Elixir 1.19.5 / OTP 28. Changing the new formatter makes the comparison tests fail.

Benchmark

:timer.tc over 100k {seconds, nanos} pairs, MIX_ENV=prod, Elixir 1.19.5 / OTP 28, Apple Silicon. Both runs had a warm-up. Old means the v0.17.0 / main module compiled under another name.

Input Old New Speedup
random seconds in 0001–9999, mixed nanos (5 runs) 85 ms 19 ms 4.3–4.4x
recent timestamps, millisecond nanos (5 runs) 94–101 ms 16–17 ms 5.4–6.2x

Support floor

The code uses only :calendar.gregorian_seconds_to_datetime/1, binary construction and integer arithmetic, all of which are far older than the library's floor (elixir: "~> 1.16"; CI goes down to Elixir 1.16.3 / OTP 26.0). The tests use ~U sigils, DateTime and Enum.random/1 on ranges, which are also available on 1.16.

Encoding a Google.Protobuf.Timestamp to JSON went through
DateTime.from_unix/2, two DateTime.compare/2 range checks,
DateTime.to_iso8601/2 and a String.split_at/2 to splice in the
fraction. In a JSON-heavy service this formatter showed up as more
than half of Protobuf.JSON.Encode's CPU time.

Check the allowed range (0001-01-01 to 9999-12-31) on the integer
seconds, split them with :calendar.gregorian_seconds_to_datetime/1 and
write the fixed-width digits straight into a binary. The fraction keeps
the 3, 6 or 9 digit rendering the proto3 JSON mapping asks for. Inputs
outside the range still go through DateTime.from_unix/2, so the error
tuples are unchanged.

The tests keep the old DateTime-based encoder as a reference and
compare the two byte for byte on edge dates and 200k random timestamps
across the whole range. The new encoder is about 4-6x faster.

@whatyouhide whatyouhide left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Left some comments.

Comment thread lib/protobuf/json/rfc3339.ex Outdated
Comment thread lib/protobuf/json/rfc3339.ex Outdated
Comment thread lib/protobuf/json/rfc3339.ex Outdated
Comment thread test/protobuf/json/rfc3339_test.exs
@bruckner

Copy link
Copy Markdown
Contributor Author

@whatyouhide thanks for the review! I added a new commit incorporating your feedback. One note - I added the property based test (with max_runs: 10k) and left the looping one (200k) in place. They check the same thing, though, so we could drop the latter and bump the former's max_runs up to compensate, happy to adjust however you prefer.

@whatyouhide

Copy link
Copy Markdown
Collaborator

@bruckner let's go with just the property-based test and 10k runs is already whopping, we can probably reduce that and potentially scale the generator more aggressively, up to you and depending on how long that test takes.

@bruckner
bruckner force-pushed the faster-rfc3339-encode branch from e772ec4 to c5d55d6 Compare October 8, 2026 16:40
Spell the allowed range and the Unix epoch as DateTime and :calendar
literals evaluated at compile time, so the guard reads as the dates it
checks rather than as magic numbers, and write the range check with
`in`.

The byte-identity check against the DateTime-based formatter becomes
a stream_data property (5k runs) in place of the hand-rolled random
sample, so a mismatch shrinks to a minimal example.
@bruckner
bruckner force-pushed the faster-rfc3339-encode branch from c5d55d6 to 9869070 Compare October 8, 2026 16:42
@bruckner

bruckner commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@whatyouhide sounds good, I dropped the loop test and reduced max_runs to 5k - runs in ~10ms on my box

@whatyouhide
whatyouhide merged commit 177dd86 into elixir-protobuf:main Oct 9, 2026
7 checks passed
@whatyouhide

Copy link
Copy Markdown
Collaborator

Thank you @bruckner 🫶

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants