Skip to content

fix(serialization): polymorphic object read — no JsonDocument + UTF-16 copy per nesting level (#5555 HEAPSTEP allocator) - #5767

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/5555-polymorphic-read-no-string-roundtrip
Sep 26, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/5555-polymorphic-read-no-string-roundtrip

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

What the instrument named

[HEAPSTEP] (#5665) is live on ci.9408 on both portals. It read 43 lines on memex-cloud and 88 on memex over 8 h, none cut (Ops/Actions/logs-{memexcloud,memex}-20260926-heapstep-5555). Every step past warm-up has the same mix:

  • System.String 53–56 %
  • System.Byte[] 25–31 %
  • System.Text.Json.JsonDocument 2–4 %
  • 4–7 GiB sampled in a 10 s window

String ≈ 2 × Byte[] plus a JsonDocument means one JSON value was held twice: once as UTF-8 and once as UTF-16.

The steps are churn, not retention. On …6c7669df84-nbrdx, tick 314 read heap=4.44GiB. Tick 315 ran 2 gen-0, 1 gen-1 and 1 gen-2 collections and read 3.03 GiB.

The defect

ObjectPolymorphicConverter.ReadObject handled every object-typed value in three steps:

  1. JsonDocument.ParseValue, which copies the value into a byte[].
  2. GetRawText(), which builds a UTF-16 string.
  3. Deserialize(string) on that string.

Every nested object member re-enters the converter, so this repeats once per nesting level.

The fix

  • $type first, a registered type, no $id/$ref/$values/$defs: the converter deserializes straight from the reader. It probes on a copy of the reader, so declining leaves the reader untouched. When a registered type's JSON no longer fits, the value is still preserved as raw JSON and a warning is logged, as before.
  • Every other shape takes the general path unchanged, except that it now deserializes from UTF-8 instead of a string. JsonElementNormalizer.Deserialize replaces GetNormalizedRawText, and the read-only collection converters use it too.

Repro

PolymorphicReadAllocationTest measures one read of a value nested 8 levels deep with a 400,000-byte leaf string:

allocated
before (negative control: src reverted, same test) 4,006,624 bytes, red
after 402,024 bytes (the leaf itself)
$type last, general path 6,414,112 → 2,810,792

Local Release runs: MeshWeaver.Messaging.Hub.Test 527/527, MeshWeaver.Data.Test 555/555. -warnaserror is clean on Messaging.Hub, both test projects and Documentation.

Not established

The sampler names types, not call sites, so this PR does not show that this converter is the dominant caller in production. The falsifier is the String share in [HEAPSTEP] on the first image carrying this change. If it stays above 50 %, some other caller holds the JSON as text, and #5555 stays open for it.

Refs #5555, refs #4824. Both are severity issues, so they close on post-roll verification, not on this merge.

Pairs-with: none — only internal members changed (JsonElementNormalizer is internal); no public surface removed.

🤖 Generated with Claude Code

…m the reader — no JsonDocument + string copy per nesting level

The [HEAPSTEP] sampler (#5665), live on ci.9408, names the same allocation mix
in every heap step on both portals: String 53-56%, Byte[] 25-31%, JsonDocument
2-4%. That is one JSON value held as UTF-8 and again as UTF-16.

ObjectPolymorphicConverter.ReadObject did exactly that for every object-typed
value: JsonDocument.ParseValue (a byte[] copy), GetRawText() (a UTF-16
string), then Deserialize(string). Every nested object member re-enters the
converter, so it repeated once per nesting level.

- $type first + registered type + no reference metadata: deserialize from the
  reader directly (probe on a reader copy; declining leaves the reader untouched).
- every other shape: the general path, now deserializing from UTF-8 instead of
  a string (JsonElementNormalizer.Deserialize replaces GetNormalizedRawText,
  also for the read-only collection converters).

PolymorphicReadAllocationTest: one read of a value nested 8 levels with a
400,000-byte leaf allocated 4,006,624 bytes before, 402,024 after.

Refs #5555, #4824

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Address the outstanding polymorphic metadata, duplicate $type, regression coverage, and immutable lookup concerns.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

This PR optimizes polymorphic JSON deserialization by reducing intermediate allocations and adding allocation regression coverage.

Changes:

  • Adds direct reader-based deserialization for registered $type values.
  • Replaces string-based normalization with UTF-8 deserialization.
  • Adds allocation tests and documents production findings.
File Summary
test/​MeshWeaver.Messaging.Hub.Test/​PolymorphicReadAllocationTest.cs Verifies allocation reductions and fallback behavior.
src/​MeshWeaver.Messaging.Hub/​Serialization/​ReadOnlyCollectionConverterFactory.cs Uses UTF-8 normalization for collections.
src/​MeshWeaver.Messaging.Hub/​Serialization/​ObjectPolymorphicConverter.cs Adds the optimized polymorphic read path.
src/​MeshWeaver.Documentation/​Data/​Architecture/​WhichKindOfSilence.md Documents heap findings and remediation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +223 to +227
foreach (var metadata in MetadataPropertyNames)
if (probe.ValueTextEquals(metadata))
return false;
if (!probe.Read() || !probe.TrySkip())
return false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 319acde: TypeFirstWithReferenceMetadata_IsStrippedAndTyped is a theory over $id, $ref, $values and $defs. Each case is a $type-first object that must come back typed, with the metadata stripped. Negative control: with the probe's metadata check disabled, all four cases go red (they come back as untyped JsonElement). With the check restored, all 7 tests in the class pass.

return cleanedElement.Clone();
}

private static readonly string[] MetadataPropertyNames = ["$id", "$ref", "$values", "$defs"];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 319acde: the static array is gone. The four names are now inline ValueTextEquals comparisons in the probe, so no collection is held at process scope.

…irst + reference metadata to the stripping path

Negative control: with the probe's metadata check disabled, all four
TypeFirstWithReferenceMetadata_IsStrippedAndTyped cases go red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

  1 files  ±0    1 suites  ±0   3m 13s ⏱️ +55s
348 tests ±0  348 ✅ ±0  0 💤 ±0  0 ❌ ±0 
352 runs  ±0  352 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 319acde. ± Comparison against base commit b43e228.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

444 tests   - 16   444 ✅  - 16   1m 2s ⏱️ +5s
  2 suites  -  1     0 💤 ± 0 
  2 files    -  1     0 ❌ ± 0 

Results for commit 319acde. ± Comparison against base commit b43e228.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

555 tests   - 1 135   555 ✅  - 1 135   1m 22s ⏱️ - 2m 53s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit 319acde. ± Comparison against base commit b43e228.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    2 files   -   1      2 suites   - 1   5m 42s ⏱️ -30s
2 765 tests +623  2 765 ✅ +623  0 💤 ±0  0 ❌ ±0 
2 766 runs  +623  2 766 ✅ +623  0 💤 ±0  0 ❌ ±0 

Results for commit 319acde. ± Comparison against base commit b43e228.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 666 tests  +914   1 666 ✅ +1 106   9m 50s ⏱️ + 2m 43s
    2 suites  -   1       0 💤  -   192 
    2 files    -   1       0 ❌ ±    0 

Results for commit 319acde. ± Comparison against base commit b43e228.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    5 files  ±  0      5 suites  ±0   14m 26s ⏱️ +35s
3 574 tests  - 667  3 574 ✅  - 665  0 💤  - 2  0 ❌ ±0 
3 578 runs   - 667  3 578 ✅  - 665  0 💤  - 2  0 ❌ ±0 

Results for commit 319acde. ± Comparison against base commit b43e228.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   13 files   -   4     13 suites   - 4   35m 37s ⏱️ +53s
9 352 tests  - 281  9 352 ✅  - 87  0 💤  - 194  0 ❌ ±0 
9 361 runs   - 281  9 361 ✅  - 87  0 💤  - 194  0 ❌ ±0 

Results for commit 319acde. ± Comparison against base commit b43e228.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 26, 2026
Merged via the queue into main with commit f69cac2 Sep 26, 2026
49 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.

2 participants