fix: preserve MessageHeader.Bag keys verbatim through serialization (#4151)#4215
Conversation
…4151) DictionaryStringObjectJsonConverter applied JsonSerialisationOptions' PropertyNamingPolicy (camelCase) to dictionary keys as well as property names, so a bag key written as "SessionId" was serialized as "sessionId". Bag keys are arbitrary user-defined identifiers, not C# property names, so consumers that look the key up by its original name (the ASB SessionId lookup in #4054, and any Python Lambda reading a PascalCase key off an SNS notification per #4132) silently missed it. Root cause of the #4054-class bugs: stop applying the naming policy to dictionary keys and write them verbatim. PropertyNamingPolicy still governs real MessageHeader property names, so the wire format for properties is unchanged; only Bag keys now round-trip as written. This addresses the whole class of read-side workarounds (the policy-aware lookup in #4205 stays as belt-and-braces for messages already persisted under the old camelCasing during an upgrade drain). The origin of the key mutation was traced to the converter's introduction (#1531), where it was copied from the reference implementation it is based on rather than added to fix a specific Brighter problem. Tests: - new BagKeyRoundTripTests pins that PascalCase and lowercase bag keys survive verbatim. - the CloudEvents subject-migration test previously characterised the bug ("converts to camelCase"); it now asserts the key is preserved, matching the fix. The Header.Subject guidance from #4132 remains valid and its tests are unchanged. Closes #4151 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review — fix: preserve MessageHeader.Bag keys verbatim (#4215)Nice, tightly-scoped fix with a clear root-cause writeup. The core change is correct and I'd be comfortable merging it after a couple of small doc cleanups. Details below. ✅ Correctness — the fix is soundThe key observation that makes this safe: I also confirmed real 🟠 Stale/misleading XML docs left behind (please fix — in-scope)Two doc comments now describe behaviour that this PR removes, and they'll actively mislead users into camelCasing their keys defensively:
Since these comments document the precise behaviour being changed, updating them is part of the fix, not scope creep. Leaving them stale is arguably worse than before, because now the code and the docs disagree. 🟡 Scope is broader than "Bag keys" — worth a reviewer note
🟢 Tests
RiskThe drain-window analysis in the PR body is accurate and the decision to keep the #4205 policy-aware reader as a compat shim is the right call. Agree this deserves a release-notes callout, and that the persistent-outbox suites (SQL/Mongo/Dynamo) should run in CI before merge since they weren't exercised here. Verdict: Approve once the two stale doc comments are updated. Everything else is optional polish. 🤖 Generated with Claude Code |
…d-trip test Addresses PR review feedback on #4215: - MessageHeader.Bag and JsonSerialisationOptions XML docs no longer advise camelCasing bag keys defensively — keys now round-trip verbatim, so they describe that instead. - Add a MessageHeader-level round-trip test alongside the bare-dictionary pin, guarding the real user-facing serialization path (a PascalCase "SessionId" bag key is readable after a full MessageHeader serialize/deserialize). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — good review, and the Required (stale docs) — done:
Optional test — done:
Global-scope note (making it explicit, as requested): Left as-is:
Still open before merge (flagged, not code): run the persistent-outbox suites (SQL/Mongo/Dynamo) in CI, and a release-notes callout for the drain-window compat (covered by keeping #4205's policy-aware reader). |
Review: preserve
|
What
Fixes #4151 at the root:
MessageHeader.Bagkeys now round-trip verbatim through Brighter's serialization instead of being rewritten by the camelCase naming policy.Closes #4151.
Closes #4214.
Root cause
DictionaryStringObjectJsonConverter.WriteappliedJsonSerialisationOptions.Options.PropertyNamingPolicy(defaultJsonNamingPolicy.CamelCase) to dictionary keys as well as property names. A bag key written as"SessionId"was therefore serialized as"sessionId". Bag keys are arbitrary user-defined identifiers, not C# property names, so any consumer that looks the key up by its original name missed it — silently.Note (from review):
DictionaryStringObjectJsonConverter.Readnever applied the policy — it reads keys viareader.GetString()verbatim. SoWriteandReadwere asymmetric, and even a same-process serialize→deserialize already mangled PascalCase keys. This change makesWritesymmetric with the existingRead, rather than introducing new behaviour.This is the shared root cause behind a cluster of issues/bugs:
SessionIddropped after an Outbox round-trip.Subjectput in the Bag arrived camelCased, breaking downstream consumers (e.g. Python Lambdas reading SNS notifications by PascalCase key); that PR worked around it by movingSubjecttoMessageHeader.Subject.Keys/Tag, AWSmessageDeduplicationId, coreProducerTopic, ASB scheduler).Origin of the mutation
Traced to the converter's introduction in #1531 — the key transformation was copied from the reference implementation the converter is based on (Josef Ottoson's), not added to fix a specific Brighter problem. So there is no known behaviour that intentionally relies on it.
Fix
Stop applying the naming policy to dictionary keys — write them verbatim.
PropertyNamingPolicystill governs realMessageHeaderproperty names, so the property wire format is unchanged; only Bag keys are affected.Scope
DictionaryStringObjectJsonConverteris registered globally inJsonSerialisationOptionsand applies to everyDictionary<string, object?>Brighter serializes (MessageHeader.Bag,RequestContextbags, outbox/scheduler payloads, any internal dictionary) — not justMessageHeader.Bag. This is intended: dictionary keys are data, not property names, and should not be case-mangled. BecauseReadnever applied the policy, no in-process consumer could have depended on the oldWritecasing.Why this closes #4214
#4214 catalogued the other exact-match
Bag.TryGetValue(constant)lookups that the camelCase policy could break (RocketMQKeys/Tag, AWSmessageDeduplicationId, coreProducerTopic; plus the ASB scheduler, which reads an empty bag and was never live). Now that keys round-trip verbatim under any policy, every one of those lookups is correct as written — the "make each read site policy-aware" direction #4214 proposed is no longer needed. The #4205 policy-aware reader (ASBConstants.IsBagKey) is retained for ASB only, as a belt-and-braces compat shim for messages persisted under the old camelCasing during an upgrade drain.Tests
BagKeyRoundTripTests— pins that PascalCase (SessionId,PartitionKey) and lowercase (customer.header) bag keys survive verbatim, both through the converter directly and through a fullMessageHeaderround-trip.When_migrating_subject_from_bag_to_header— previously a characterisation test that asserted the bug (converts_to_camelCase); now asserts the key is preserved. TheHeader.Subjectguidance from fix(cloud-events): preserve message header values in CloudEventsTransformer #4132 remains valid and those tests are unchanged.Paramore.Brighter.Core.Tests: 849 passed, 0 failed, 7 skipped. The only test that changed behaviour was the characterisation test above.Risk / reviewer notes
"sessionId"; after this change they are read back verbatim. Code looking for"SessionId"won't match during the drain. ASB is covered by the fix: case-insensitive SessionId and reserved-header handling for ASB (#4054) #4205 policy-aware reader; the other gateways folded in from Header-bag key lookups are not naming-policy-aware (latent SessionId/#4054-class bugs) #4214 (RocketMQ, AWS, coreProducerTopic) have no such shim, so their pre-upgrade in-flight messages would miss the lookup during the drain — transient and niche, but worth a release-notes callout.🤖 Generated with Claude Code