Phase 69m — Dispatcher body + argument-parse fastpath (migration)
Phase 69m — Dispatcher body + argument-parse fastpath (migration)
What changes
Two related perf wins on the ToolUp.Remoting dispatcher hot path:
F2 — argument-parse single-walk. Previously the dispatcher parsed the outer arguments-array JSON into a JsonDocument, captured each element's raw JSON text via GetRawText(), then re-parsed each captured string into its own JsonDocument via JsonSerializer.Deserialize<'inp>(argText, opts). For an N-argument method that's N + 1 parses per call.
After 69m: parseArgumentArray returns JsonElement list (each element is Clone()d so it survives the parent JsonDocument's use scope). The per-arg deserialise calls JsonElement.Deserialize<'inp>(opts) which walks the existing element's tokens — no re-parse. One parse total per call.
F3 — body re-read elimination. The Giraffe adapter maintains a lazy body cache (bytes / text / hash) populated when an upstream pre-flight stage (validation / audit / idempotency-hash) demands it. Pre-69m the proxy still independently read ctx.Request.Body via a StreamReader, materialising the body string a second time. For a 1 MiB body on a validation-armed method that was ~2 MiB of string allocations per request.
After 69m: InvocationProps<'impl> carries InputBytes: byte[] option. The Giraffe adapter populates it from cachedBodyBytesCell.Value just before invoking the proxy. When populated, the proxy parses directly from the bytes via parseArgumentArrayBytes (using JsonDocument.Parse(ReadOnlyMemory bytes) — no string materialisation). When None (cache wasn't forced; or non-Giraffe adapters without a cache), the proxy falls back to the StreamReader path unchanged.
G — audit reuses validation-parsed args. When both [<Validate>] and [<Audit>] are armed for the same method, the audit-payload extraction at the end of dispatch now reuses the first-arg value validation already parsed (cached in a per-request validationParsedFirstArg cell). Saves one parseFirstArgFromBody call per request on dual-armed methods.
Net effect on a 1 MiB validation + audit + multi-arg call: from ~3 string materialisations × 1 MiB + N+1 JsonDocument parses → 1 byte-array materialisation + 1 JsonDocument parse + 1 parseFirstArgFromBody (validation; audit reuses).
Diff to apply
No consumer source change required for SDK consumers. The wire format is unchanged (the byte-pin tests in the Remoting suite confirm it). Consumers pick up the win automatically at the next package upgrade.
Custom-adapter authors who build their own InvocationProps<'impl> (third-party adapters that compose against the public ToolUp.Remoting.Server.Proxy surface) MUST add InputBytes = None to their record construction:
let props : InvocationProps<'impl> = {
ImplementationBuilder = ...
EndpointName = ...
Input = ctx.Request.Body
InputBytes = None // ← Phase 69m additive field; None = fall back to Input stream
IsProxyHeaderPresent = ...
HttpVerb = ...
InputContentType = ...
Output = output
}
In-tree forge adapters (Giraffe + AspNetCore middleware) have already been updated.
The InvocationPropsInt.Arguments field changed from Choice<byte[], string> list to Choice<byte[], JsonElement> list. This type is internal so no consumer-visible break.
Verification
dotnet build ToolUp.Forge.sln— clean.dotnet run --project src/ToolUp.Platform.Tests/ToolUp.Platform.Tests.fsproj— green (newPhase 69m — Dispatcher body + arg-parse fastpathpack adds 10 source-audit tests pinning the JsonElement-shape,InputBytesplumbing, audit-reuse cache, and exception-path body-text materialisation).- The 69m test pack lives in
src/ToolUp.Platform.Tests/InProcess/DispatcherBodyAndArgFastpathTests.fs. - An integration-shape allocation-pin test (compose a multi-arg API + dispatch through a TestServer; assert one outer
JsonDocument.Parsecall and zeroStreamReaderallocations on the cached-bytes path) is deferred to a follow-up TIDY-UP item — same TestServer-scaffold gap as the Phase 69l deferral. - Wire format byte-identical: the existing 704+ Remoting byte-pin tests cover the regression class.
Rollback
Revert in this order:
- Per-arg path in
Proxy.fs'smakeEndpointProxy— restore theChoice2Of2 argText :: t -> deserialiseArgWithBackend<'inp> ... argTextshape. deserialiseArgWithBackendsignature back tostring -> 'inpusingJsonSerializer.Deserialize<'inp>(argText, opts).parseArgumentArraysignature back tostring listviael.GetRawText().parseArgumentArrayBytes— delete.- Multipart text section — restore the
Choice2Of2(System.Text.Encoding.UTF8.GetString(buffer.GetReadOnlySequence().ToArray()))shape. Types.fs:InvocationPropsInt.Argumentsback toChoice<byte[], string> list; removeInvocationProps<'impl>.InputBytes.GiraffeAdapter.fs: remove thepropsWithCacherebuild + theInputBytesline in initial props.Middleware.fs(AspNetCore): remove theInputBytes = Noneline.Proxy.fs:memberVisitorbody-read: restore theuse sr = new StreamReader(props.Input); let! text = sr.ReadToEndAsync()direct path.- Exception path — restore
requestBodyText(drop theresolvedBodyTextmaterialisation).
See also
69b-remoting-platform-seams.md— theToolUp.Remoting.Serverplatform seams this fastpath composes on top of.69l-telemetry-zero-cost-gate.md— sister perf phase; the allocation-pin pattern.69n-fromcontextasync-build-once.md— sequenced after this phase; lifts the per-request dispatcher rebuild on thefromContextAsyncarm.