Skip to content

GH-3645: accept both timestamp header formats on read (5.x backport of #3656) - #4583

Merged
jeremydmiller merged 1 commit into
5.0from
gh-3645-backport-reader-tolerance-5.0
Sep 23, 2026
Merged

jeremydmiller merged 1 commit into
5.0from
gh-3645-backport-reader-tolerance-5.0

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

5.x backport of #3656 (step one of #3645). Targets 5.0, not main.

Why this is needed on the 5.x line

6.x is going to flip EnvelopeMapper's timestamp writers to "o" — the one format a stock date parser can read back. Measured against what the mapper writes today (2026-07-30 18:25:40:482802 Z):

parser result
DateTime.Parse throws FormatException
DateTimeOffset.TryParse false
XmlConvert.ToDateTime throws FormatException
TryParseExact with the literal format ✅

The : before the fractional seconds and the bare trailing Z are recognised by nothing else.

Readers have to tolerate both formats in a shipped release before the writers can flip. Without this, a 6.x sender that has flipped meeting a 5.x receiver binds null for ScheduledTime / DeliverBy in silence — the message is delivered, the schedule is simply gone. That is exactly the failure mode of #1716 and #3613.

5.x is the old side of that migration, so 5.x is the side that has to be taught tolerance. main already has it (#3656, landed 2026-07-26); 5.0 has no tryReadTimestamp at all, which is what makes this a blocker for step two.

What changes

EnvelopeMapper's two read helpers now go through one TryParseTimestamp that tries the legacy format first — it is still what every 5.x mapper writes, so it is the common case on this line's wire — then falls back to a standard invariant parse.

What deliberately does not change

The 5.x writing side. A 5.x host keeps emitting the legacy format, which is what 5.x receivers already understand; flipping writers here would create the very mixed-version break this backport exists to prevent. the_writer_still_emits_the_legacy_format pins that.

This is purely additive tolerance for a format a 5.x reader will now meet.

Divergence from the 6.x original, deliberate

  • Two parse sites, not four. 6.x reads timestamps through compiled-delegate setters as well as the two protected helpers, so EnvelopeMapper reads both timestamp header formats (#3645, step 1 of 2) #3656 had four sites to route. The 5.x mapper dispatches through reflection (getDateTimeOffset / getNullableDateTimeOffset), so the two helpers are the only parse sites.
  • The format string stays a private const. EnvelopeConstants.TransportHeaderDateTimeFormat does not exist on this branch, and introducing it would be churn on a maintenance line for no behavioural gain.

One thing found and not changed, recorded so it is not lost: EnvelopeSerializer on 5.x reads these values with a bare DateTimeOffset.TryParse, so it already accepts "o" and does not accept the legacy mapper format. That is the mirror of this bug — 6.x's #3641 — and it is a separate change that the writer flip does not depend on.

Verification

envelope_mapper_reads_both_timestamp_formats_3645, ported from 6.x.

Red baseline: on the unmodified branch, 3 of its 6 portable tests fail — reads_the_round_trippable_format, reads_a_deliver_by_in_the_round_trippable_format, and a_value_written_by_the_envelope_serializer_is_readable_by_the_mapper. That is the same 3-of-6 split #3656 reported on main, which is the best evidence the backport is faithful rather than merely green. The baseline's own build was asserted clean first, so it could not have been a stale binary reporting a false result.

The other three pin unchanged behaviour. One test is new here: the_legacy_format_is_unreadable_by_a_stock_parser asserts DateTimeOffset.TryParse rejects what this mapper writes, which is why TryParseTimestamp cannot collapse into a single loose parse.

Full CoreTests on 5.0 green, and a clean pinned Release build of wolverine.slnx -c Release -f net9.0 with no new warnings in the touched files.

What comes next

#3645 stays open. Once this ships in a 5.x release, step two flips the 6.x writers to "o" and the assertion pinning the legacy writer on main flips with it — deliberately, not by accident.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

…#3656)

EnvelopeMapper's two read helpers only ever accepted the mapper's own
"yyyy-MM-dd HH:mm:ss:ffffff Z". A round-trippable "o" value -- from EnvelopeSerializer, from a
non-Wolverine producer, or from a 6.x host that has flipped its writers -- bound as default/null in
silence. The message arrives and the schedule is simply gone, which is exactly the failure mode of
GH-1716 and GH-3613.

Both helpers now go through one TryParseTimestamp that tries the legacy format first -- it is still
what every 5.x mapper writes, so it is the common case on this line's wire -- then falls back to a
standard invariant parse.

## Why this belongs on 5.x

6.x is going to flip these writers to "o", the one format a stock parser can read back: DateTime.Parse,
DateTimeOffset.TryParse and XmlConvert.ToDateTime all reject the legacy shape, because the ':' before
the fractional seconds and the bare trailing ' Z' are recognised by nothing else.

Readers have to tolerate both in a SHIPPED release before that can happen. Without this, a 6.x sender
meeting a 5.x receiver during a migration reintroduces the silent null for ScheduledTime / DeliverBy.
5.x is the old side of that migration, so it is the side that has to be taught tolerance.

## What deliberately does not change

The 5.x writing side. A 5.x host keeps emitting the legacy format, which is what 5.x receivers already
understand; flipping writers here would create the very mixed-version break this backport exists to
prevent. A test pins that.

This is purely additive tolerance for a format a 5.x reader will now meet.

## Divergence from the 6.x original

6.x reads timestamps through compiled-delegate setters as well as the two protected helpers, so #3656
had four parse sites to route. The 5.x mapper dispatches through reflection (getDateTimeOffset /
getNullableDateTimeOffset), so the two helpers are the only parse sites and the change is correspondingly
smaller. The format string also stays a private const here rather than moving to
EnvelopeConstants.TransportHeaderDateTimeFormat, which does not exist on this branch -- less churn on a
maintenance line.

Not touched, but worth recording: EnvelopeSerializer on 5.x reads these values with a bare
DateTimeOffset.TryParse, so it already accepts "o" and does NOT accept the legacy mapper format. That is
the mirror of this bug and it is 6.x's #3641; it is a separate change and not needed for the writer flip.

## Tests

envelope_mapper_reads_both_timestamp_formats_3645, ported from 6.x. On the unmodified branch, 3 of its 6
portable tests fail -- reads_the_round_trippable_format, reads_a_deliver_by_in_the_round_trippable_format,
and a_value_written_by_the_envelope_serializer_is_readable_by_the_mapper -- the same 3-of-6 split #3656
reported on main, with the baseline's own build asserted green first so it could not be a stale binary.
The other three pin unchanged behaviour, including that the writer still emits the legacy format.

One test is new here: the_legacy_format_is_unreadable_by_a_stock_parser asserts DateTimeOffset.TryParse
rejects what this mapper writes, which is why TryParseTimestamp cannot collapse to a single loose parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
@jeremydmiller
jeremydmiller merged commit 7c684c6 into 5.0 Sep 23, 2026
21 checks passed
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.

1 participant