Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changes

## 10.12.0.0 08/16/2026

* **Every response was parsed twice and copied to UTF-16 twice** — the cost of reading a response, measured rather than reasoned about. `RequestManager.Resolve` did `JsonSerializer.Deserialize(response.Result?.ToString() ?? "{}", taskInfo.Type, ...)`. `BaseResponse.Result` is typed `object`, which System.Text.Json fills with a `JsonElement` that already owns a private copy of the `result` bytes — so `.ToString()` rendered that element back into a UTF-16 string and the serializer parsed the string a second time. On a `ledger_data` page at `limit=2048` (~1 MB) the four stages measured, per response, at: 1.97 MB for the UTF-16 copy of the message, 1.68 MB for the document built over it, 1.97 MB for the UTF-16 copy of the `result`, 1.68 MB for the second document — **7.30 MB, 7.42x the response size**, all four allocations past the 85 KB large-object threshold. Both halves are now gone:
* `DeserializeResult` works off the parsed node: `element.Deserialize(type, options)` for a typed model, and the element itself when the request asked for `JsonElement` or `object`, which is what a consumer that needs the raw ledger objects asks for (the typed `LOLedgerData.State` drops unknown fields). A `BaseResponse` assembled by hand rather than parsed off the wire keeps the old string path. Behaviour is otherwise unchanged, including a missing or JSON-`null` `result`, which still yields what deserializing `"{}"` yielded
* the socket path carries the frame as it arrived. `Connection` binds `OnBinaryMessage` instead of `OnMessageReceived`, `IsLikelyResponse` and `RequestManager.HandleResponse` have `ReadOnlySpan<byte>` overloads, and the UTF-16 string is materialized — once, lazily — only for what genuinely needs text: stream messages and the `OnWarning`/`OnServerWarning`/`OnError` callbacks. The `string` overloads stay for `Connection.OnMessage(string)` and for external callers
* the warning callbacks no longer pay for listeners that are not there. rippled attaches `warning`/`warnings` to responses under load and on a reporting-mode server, and the dispatch built the UTF-16 text for them before checking whether `OnWarning`/`OnServerWarning` were subscribed — on such a server that is the removed allocation, back on every page. Measured with warnings on all 20 pages and nothing subscribed: 4.28x → 2.08x
* the failure report survives the failure. A response that will not parse is most often a heap that has just run out, and materializing the message for `OnError` is then the largest allocation left on the path — if it throws, the notification is lost inside the handler and the consumer sees silence. The text is now built only when a handler is attached, and an `OutOfMemoryException` while building it falls back to a literal placeholder so the classification still goes out. `Connection.OnMessage(null)` also keeps its old route through `OnError` instead of throwing `ArgumentNullException` out of the entry point
* measured end to end against a local WebSocket server, 600 `ledger_data` pages of ~1 MB: **8.32 → 2.68 MB allocated per response** (8.46x → 2.72x the payload), 11.49 → 7.96 ms per response, 87 → 126 responses/s, peak managed heap 42.4 → 20.8 MB, peak LOH 39.2 → 17.3 MB, peak working set 203.4 → 71.8 MB. Under a lowered `DOTNET_GCHeapHardLimit` the pre-fix path reproduced the production failure exactly — `XrplException: Failed to deserialize response for request <id>: Exception of type 'System.OutOfMemoryException' was thrown`, with `JsonElement.ToString()` at the top of the inner stack — at a ceiling the fixed path completes 15/15 pages under
* the win is not specific to `ledger_data` or to `JsonElement`: the second parse was on the path of every command. The repo's own `BenchmarkLedgerDataCrawl`, which goes through `Request` → `Dictionary<string, object>`, drops from 22.9 to 14.8 MiB allocated per 2 MiB page (11.4x → 7.4x) with LOH ending at 38.2 instead of 115.4 MiB — it stays above the `JsonElement` figure because building a `Dictionary<string, object>` boxes every value, which this change does not address
* `TestUResponseParsing` pins the behaviour that had to survive — the untyped node handed through is self-contained and readable after a forced gen2 collection, a typed model deserializes to the same values, the `string` and UTF-8 overloads agree, a missing `result` still completes, an `error` status still rejects with the parsed `ErrorResponse` attached, a null message does not throw out of the entry point — and holds two allocation budgets at 4x the response size. The first measures `RequestManager` alone, per thread so the class-parallel run cannot perturb it (1.89x now). The second runs 20 pages through `Connection` over a real socket, because nothing else in the suite can see *which* overload the client picks: it reads the process-wide counter and is therefore kept out of the parallel pass, and it separates the two paths with room on both sides — 2.18x as bound, 4.84x with the string callback bound instead. `PagedResponseServer` reuses one response frame per connection and rewrites the id in place so the server contributes nothing to what the client is measured on
* **`error` responses were deserialized a third time** — the `status == "error"` branch of `HandleResponse` re-parsed the whole message into an `ErrorResponse` inside a `try`/`catch` that swallowed everything, to build the exception's `Response`. The message had already been deserialized into an `ErrorResponse` at the top of the same method; the second parse only produced an equal copy, and on a large error payload it was a second large-object allocation on a path that is already failing

## 10.11.1.0 08/13/2026

* **Fix infinite recursion in `LONFTokenConverter.Write` — the metadata of an NFT transaction could not be serialized at all** — regression introduced in 10.3.0.0 with the `Newtonsoft.Json` → `System.Text.Json` migration; affects every release from 10.3.0.0 on. `JsonSerializer.Serialize(tx.Meta)` threw `JsonException: A possible object cycle was detected` for any transaction whose `AffectedNodes` contain an `NFTokenPage`, which is every `NFTokenMint`, `NFTokenBurn`, `NFTokenAcceptOffer` and `NFTokenModify` that touched a page. Verified against mainnet on all six NFT transaction types — the four above failed, `NFTokenCreateOffer` and `NFTokenCancelOffer` (no page in their metadata) went through:
Expand Down
76 changes: 76 additions & 0 deletions Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
using System.Diagnostics;
using System.Threading.Tasks;

using System.Text.Json;

using Xrpl.Client;
using Xrpl.Models.Common;
using Xrpl.Models.Methods;

namespace Xrpl.Tests.ClientLib
{
Expand Down Expand Up @@ -112,6 +116,78 @@ public async Task BenchmarkSequentialPaging()
Console.WriteLine("decile profile (ms/page): " + string.Join(" | ", DecileProfile(pageMs)));
}

/// <summary>
/// Same crawl through <c>GRequest&lt;JsonElement, …&gt;</c> — the path a consumer takes when
/// it needs the raw ledger objects, because the typed <c>LOLedgerData.State</c> drops
/// fields the models do not know. This is where the response path's own cost shows up
/// undiluted: nothing is materialized into a model, so what is measured is receive,
/// route and parse.
/// </summary>
[TestMethod]
public async Task BenchmarkSequentialPagingUntyped()
{
int pages = EnvInt("CRAWL_PAGES", 2000);
int payloadBytes = EnvInt("CRAWL_PAYLOAD_BYTES", 2 * 1024 * 1024);
int fragments = EnvInt("CRAWL_FRAGMENTS", 32);

using PagedResponseServer server = new PagedResponseServer(payloadBytes, fragments);
using XrplClient client = new XrplClient(server.Url);

await client.Connect().ConfigureAwait(false);
await RequestUntypedPageAsync(client).ConfigureAwait(false);

GC.Collect(2, GCCollectionMode.Forced, blocking: true);
GC.WaitForPendingFinalizers();
GC.Collect(2, GCCollectionMode.Forced, blocking: true);

long allocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
int gen2Before = GC.CollectionCount(2);
long lohBefore = LohBytes();
long startTicks = Stopwatch.GetTimestamp();

for (int i = 0; i < pages; i++)
{
await RequestUntypedPageAsync(client).ConfigureAwait(false);
}

double totalSeconds = (Stopwatch.GetTimestamp() - startTicks) / (double)Stopwatch.Frequency;
long allocated = GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore;
long lohAfter = LohBytes();

await client.Disconnect().ConfigureAwait(false);

Console.WriteLine("=== ledger_data crawl benchmark (untyped JsonElement result) ===");
Console.WriteLine($"pages : {pages}");
Console.WriteLine($"payload : {payloadBytes / 1024.0 / 1024.0:F2} MiB");
Console.WriteLine($"total time : {totalSeconds:F2} s ({pages / totalSeconds:F2} pages/s)");
Console.WriteLine($"allocated per page: {allocated / (double)pages / 1024.0 / 1024.0:F1} MiB " +
$"({allocated / (double)pages / payloadBytes:F1}x payload)");
Console.WriteLine($"gen2 collections : {GC.CollectionCount(2) - gen2Before}");
Console.WriteLine($"LOH size : {lohBefore / 1024.0 / 1024.0:F1} -> {lohAfter / 1024.0 / 1024.0:F1} MiB");
}

private static async Task RequestUntypedPageAsync(XrplClient client)
{
JsonElement result = await client
.GRequest<JsonElement, LedgerDataRequest>(new LedgerDataRequest
{
LedgerIndex = new LedgerIndex(96000000),
Binary = true,
Limit = 2048
})
.ConfigureAwait(false);

if (result.ValueKind != JsonValueKind.Object || !result.TryGetProperty("state", out JsonElement state))
{
throw new InvalidOperationException("empty ledger_data response");
}

if (state.GetArrayLength() == 0)
{
throw new InvalidOperationException("ledger_data page carried no objects");
}
}

private static async Task RequestPageAsync(XrplClient client)
{
Dictionary<string, object> request = new Dictionary<string, object>
Expand Down
53 changes: 48 additions & 5 deletions Tests/Xrpl.Tests/Client/PagedResponseServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,25 @@ namespace Xrpl.Tests
/// </summary>
internal sealed class PagedResponseServer : WebSocketTestServerBase
{
/// <summary>The id `Connection` sends: a quoted GUID in `D` format, always 38 characters.</summary>
private const int QuotedGuidLength = 38;

private readonly int _fragments;
private readonly string _resultBody;
private readonly bool _withWarnings;
private int _served;

/// <param name="approximatePayloadBytes">Target size of each response, in bytes.</param>
/// <param name="fragments">Number of WebSocket frames each response is split into.</param>
public PagedResponseServer(int approximatePayloadBytes, int fragments)
/// <param name="withWarnings">
/// Attach <c>warning</c> and <c>warnings</c> to every response, the way rippled does under
/// load and on a reporting-mode server.
/// </param>
public PagedResponseServer(int approximatePayloadBytes, int fragments, bool withWarnings = false)
{
_fragments = Math.Max(1, fragments);
_resultBody = BuildResultBody(approximatePayloadBytes);
_withWarnings = withWarnings;

StartAccepting();
}
Expand Down Expand Up @@ -79,6 +88,9 @@ private static void AppendHex(StringBuilder builder, int seed, int length)

protected override async Task ServeAsync(NetworkStream stream)
{
// One frame buffer per connection, so two clients cannot rewrite each other's id.
byte[]? frame = null;

while (!Token.IsCancellationRequested)
{
string? message = await ReadTextFrameAsync(stream).ConfigureAwait(false);
Expand All @@ -88,15 +100,46 @@ protected override async Task ServeAsync(NetworkStream stream)
}

string id = ExtractId(message);
string envelope = "{\"id\":" + id + ",\"status\":\"success\",\"type\":\"response\",\"result\":" +
_resultBody + "}";

await WriteFragmentedMessageAsync(stream, Encoding.UTF8.GetBytes(envelope), _fragments)
.ConfigureAwait(false);
await WriteFragmentedMessageAsync(stream, BuildFrame(id, ref frame), _fragments).ConfigureAwait(false);
Interlocked.Increment(ref _served);
}
}

/// <summary>
/// Builds the response frame for <paramref name="id"/>. The usual case - a quoted GUID, the
/// only form <c>Connection</c> sends - reuses one buffer and rewrites the id in place, so
/// the server contributes nothing to what a caller measures on the client side. Anything
/// else falls back to assembling the envelope.
/// </summary>
private byte[] BuildFrame(string id, ref byte[]? reusable)
{
if (id.Length != QuotedGuidLength)
{
return Encoding.UTF8.GetBytes(Envelope(id));
}

reusable ??= Encoding.UTF8.GetBytes(Envelope("\"" + new string('0', QuotedGuidLength - 2) + "\""));

int idOffset = "{\"id\":".Length;
for (int i = 0; i < QuotedGuidLength; i++)
{
reusable[idOffset + i] = (byte)id[i];
}

return reusable;
}

private string Envelope(string id)
{
string warnings = _withWarnings
? ",\"warning\":\"load\",\"warnings\":[{\"id\":1001,\"message\":\"This is a reporting server.\"}]"
: string.Empty;

return "{\"id\":" + id + ",\"status\":\"success\",\"type\":\"response\",\"result\":" + _resultBody +
warnings + "}";
}

/// <summary>Pulls the JSON string value of the request's "id" property.</summary>
private static string ExtractId(string message)
{
Expand Down
Loading
Loading