From 58f7a1c2864b24693ce3e2023ca93f7612a278e0 Mon Sep 17 00:00:00 2001 From: Loretta Date: Tue, 5 May 2026 06:55:32 +0200 Subject: [PATCH] [LOADED_DOCS: 3 files, no new loads] Add docs for AcBinary MVC formatters and pipeline updates Comprehensive documentation for new ASP.NET Core MVC formatters supporting AcBinary, including registration, media type, request/response flow, error handling, and future plans. Updated project and topic docs to reference MVC formatters and folder structure. Added performance planning entry for StreamPipeWriter congestion fallback. Expanded markerless schema lane rationale and updated architecture docs to reflect MVC formatter integration. Improved navigation and layering documentation. --- .../docs/BINARY/BINARY_ASYNCPIPE_TODO.md | 28 +++++++ AyCode.Core/docs/BINARY/BINARY_TODO.md | 22 +++++- AyCode.Core/docs/BINARY/README.md | 1 + AyCode.Services/Mvc/README.md | 20 +++++ AyCode.Services/README.md | 5 +- AyCode.Services/docs/MVC/README.md | 75 +++++++++++++++++++ AyCode.Services/docs/README.md | 1 + docs/ARCHITECTURE.md | 2 +- 8 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 AyCode.Services/Mvc/README.md create mode 100644 AyCode.Services/docs/MVC/README.md diff --git a/AyCode.Core/docs/BINARY/BINARY_ASYNCPIPE_TODO.md b/AyCode.Core/docs/BINARY/BINARY_ASYNCPIPE_TODO.md index 57c083d..22093d8 100644 --- a/AyCode.Core/docs/BINARY/BINARY_ASYNCPIPE_TODO.md +++ b/AyCode.Core/docs/BINARY/BINARY_ASYNCPIPE_TODO.md @@ -225,3 +225,31 @@ Once registered, controllers using `[FromBody] T model` and `IActionResult` auto **Sequencing**: implement AFTER `AyCode.Core` reaches its first stable NuGet release (1.0). The formatter package versioning follows `AyCode.Core` — breaking changes there propagate here. Don't ship the formatter package as part of v1.0 — let `AyCode.Core` stabilize first, then add this as a 1.1 / 2.0 optional extension. +## ACCORE-BIN-T-P3F8: Congestion-fallback buffer for the StreamPipeWriter path only +**Priority:** P2 · **Type:** Performance · **Status:** Investigation pending (implementation) · **Related:** `FlushPolicy.Coalesced`, `AsyncPipeWriterOutput.Grow` sequential path, `_serializeFlushAndAcquire` + +**Scope — ONLY StreamPipeWriter.** This entry concerns **exclusively** the `PipeWriter.Create(Stream)` (StreamPipeWriter) target — NamedPipe / FileStream / NetworkStream / SslStream / etc. wrapped via the BCL StreamPipeWriter. **All other PipeWriter implementations (Pipe-based, Kestrel transport, custom IBufferWriter) keep their current behaviour unchanged.** The existing slab-pool path is correct and fast for them; touching it has no upside and risks regressions. + +**Problem.** On the StreamPipeWriter path the writer auto-sets `_serializeFlushAndAcquire = true` because of the BCL `StreamPipeWriter._tailMemory` reset race: **`Advance` cannot be called while `_lastFlush.IsCompleted == false`**. Every chunk Grow therefore becomes synchronous (Commit → FlushAsync → wait → Acquire). Producer/flush parallelism is lost; per-chunk syscall overhead (kernel context switch + transport write) dominates. + +**Established baseline (existing benchmarks).** The NamedPipe transport bench has both a single-shot variant (whole serialized `byte[]` written in one `Stream.WriteAsync`) and a chunked variant (per-chunk `Stream.WriteAsync` due to the sequential constraint). The single-shot variant beats the chunked variant by **at least 30%** wall-clock — driven entirely by the syscall-count difference (1 vs N kernel transitions per payload). That delta is the headroom this entry targets: by lifting the per-chunk synchronous wait, the chunked path can batch multiple chunks into fewer `Stream.WriteAsync` calls (Coalesced window of ~64 KB → ~15× fewer syscalls on a multi-MB payload), closing the gap to the single-shot baseline. + +**Approach (sketch).** Keep the current PipeWriter slab-pool path as the default. **Only** when on the StreamPipeWriter target AND the next `Advance` would race with an in-flight flush, fall back to a temporary ArrayPool buffer where the serializer can keep writing. When `_lastFlush.IsCompleted` returns to true, drain that fallback buffer back into the StreamPipeWriter and resume the slab-pool path. This avoids the per-chunk synchronous wait and reduces `Stream.WriteAsync` syscalls. + +**Risk: code-complexity blow-up.** Two coexisting buffer worlds in the hot path: +1. **PipeWriter's internal slab** (default, zero-copy) +2. **Owned ArrayPool buffer** (only StreamPipeWriter + congestion) + +Both `CommitCurrentChunk` and `Grow` would need branchy semantics. The existing `_ownedBuffer` fallback (for rare non-array-backed memory cases) gives a foretaste of the indirection — promoting it to a hot-path-frequent congestion fallback would amplify the branching across CommitCurrentChunk / AcquireChunk / Flush / GetTotalPosition. **The user's concern: this could turn the writer into ágy-káosz / katyvas.** + +**Investigation focus (implementation, not motivation).** The win is established by the single-shot vs chunked NamedPipe bench (≥30% headroom). Open questions are about the **shape of the fix**: +- Containing the fallback path behind a single `if (_isStreamPipeWriter && congested)` branch in 2-3 hot-path methods — vs amplified branching across the writer. +- Reusing the existing `_ownedBuffer` lazy-rent infrastructure (line 461-477) for the congestion case — vs introducing a separate fallback slot. +- Whether to pair this with a Coalesced-mode-only activation (StreamPipeWriter + Coalesced → fallback enabled; StreamPipeWriter + DoubleBuffered → still synchronous), reducing the mode matrix. + +**Acceptance.** +- Stream-backed chunked path within ≤10% of the single-shot NamedPipe baseline on a multi-MB payload (currently ≥30% slower) +- `Stream.WriteAsync` syscall count reduced ~10× to ~15× vs current sequential path +- Round-trip tests pass on NamedPipe / FileStream / cross-process / TLS-wrapped streams +- Hot-path branching contained: ≤3 `if (_isStreamPipeWriter ...)` checks across CommitCurrentChunk / Grow / AcquireChunk +- BCL StreamPipeWriter `_tailMemory` race avoided — fallback buffer is owned, no race with the BCL internal state diff --git a/AyCode.Core/docs/BINARY/BINARY_TODO.md b/AyCode.Core/docs/BINARY/BINARY_TODO.md index d9b71fb..60c1457 100644 --- a/AyCode.Core/docs/BINARY/BINARY_TODO.md +++ b/AyCode.Core/docs/BINARY/BINARY_TODO.md @@ -878,12 +878,31 @@ The 1.04+ ratio observed in JIT-mode benchmarks (12-50-43, 13-21-27, 13-27-20) w No structural problem in the Phase 3 scalar branch. The investigation directions (Vector256 mixed-content lane, BCL `Utf8.FromUtf16` comparison) remain valid academic improvements but show no meaningful production-time win — closing as Won't Fix. ## ACCORE-BIN-T-S2X9: Markerless schema lane — drop per-property type markers for fixed-shape primitives (SGen) -**Priority:** P3 · **Type:** Wire-format extension · **Related:** `ACCORE-BIN-T-S5L8`, `ACCORE-BIN-T-W7N5` +**Priority:** P2 · **Type:** Wire-format extension · **Related:** `ACCORE-BIN-T-S5L8`, `ACCORE-BIN-T-W7N5` AcBinary is **marker-driven**: every value on the wire carries a 1-byte type code, so the reader can dispatch generically (handles polymorphism, null, intern markers, type-name lookup, etc.). MemPack is **schema-driven**: the SGen reader knows at compile time that "field 3 is `int`, field 4 is `string`" and reads values directly with no type code, no run-time dispatch. For fixed-shape primitive properties (`int`, `bool`, `double`, `Guid`, `DateTime`, …) on `[AcBinarySerializable]` types, the per-property type marker is pure overhead — the SGen-generated reader already has compile-time knowledge of the property type, so the marker only confirms what is already known. Dropping it on this narrow class of properties is a clean wire+CPU win without losing any of the polymorphism / null / intern flexibility that the marker provides for variable-shape values. +### Why P2 — `WireMode = Fast` wire-size parity (NuGet release narrative) + +The `WireMode = Fast` lane currently produces **+1.7% to +8.1% larger wire** than MemPack across all benchmark cells (AOT bench 13-40-29: Small +52 byte, Medium +474, Large +3617, Repeated +1221, Deep +581). The gap is structural: UTF-16 raw-memcpy strings are 2 bytes/char fixed, while MemPack's UTF-8 is 1 byte/char on ASCII content. Touching the string-write path to fix this would either: +- Lose the raw-memcpy guarantee (post-encode ASCII-detect + branchy dispatch — kills the FastWire CPU advantage), or +- Add sentinel-encoding micro-savings (~3-5% wire) which don't close the structural gap. + +**Markerless schema lane is the only path to wire-size parity that preserves the FastWire raw-memcpy hot path.** Per-primitive-property savings (1 byte for non-tiny `int`, `Guid`, `DateTime`, `decimal`, `double`, …) compound on DTO-heavy payloads. Estimated effect on benchmark cells: + +| Cell | Current FastWire | MemPack | Estimated post-S2X9 FastWire | vs MemPack | +|------|------------------|---------|------------------------------|-----------| +| Small (~70 primitive prop) | 3122 | 3070 | ~3050 | -0.7% ✅ | +| Medium (~600 primitive prop) | 10905 | 10431 | ~10300 | -1.3% ✅ | +| Large (~6000 primitive prop) | 68603 | 64986 | ~63500 | -2.3% ✅ | +| Deep (~700 primitive prop) | 15514 | 14933 | ~14800 | -0.9% ✅ | + +The Repeated cell is harder to predict (string-dominated payload, fewer primitives) — likely smaller win, may not fully close the +8.1% gap. Acceptable: the Repeated cell is a string-interning stress test, not a typical DTO workload. + +**NuGet release narrative**: "FastMode beats MemoryPack on **both** wire size AND throughput across all benchmark cells" — currently we have to qualify this with "throughput-only on Compact + i18n workloads"; S2X9 removes the qualifier. This is high-leverage for the public bench shootout. + ### Wire savings per property type | Type | Current encoding | Markerless lane | Wire saved | @@ -919,6 +938,7 @@ Reader-side: SGen-generated code drops the per-property `ReadByte()` + `IsTinyIn ### Acceptance +- **Primary**: `WireMode = Fast` AcBinary wire size **≤ MemPack** across Small/Medium/Large/Deep AOT benchmark cells (AOT release-publish bench is the canonical measurement) - Wire size: ≥ -10% on DTO-heavy payloads (Guid/DateTime-rich) vs current marker-driven format - Round-trip on the markerless lane validated on representative DTO shapes (mixed primitive + string + nested object) - Schema-evolution fragility documented in `BINARY_FEATURES.md` (alongside the existing `PropertySkip` / default-omission caveat from `ACCORE-BIN-I-D9Y2`) diff --git a/AyCode.Core/docs/BINARY/README.md b/AyCode.Core/docs/BINARY/README.md index 541a9b3..aa7b22b 100644 --- a/AyCode.Core/docs/BINARY/README.md +++ b/AyCode.Core/docs/BINARY/README.md @@ -23,6 +23,7 @@ Start with [`BINARY_FEATURES.md`](BINARY_FEATURES.md) (overview), then [`BINARY_ - **Serialization overview** (Toon vs AcBinary vs AcJson, shared infrastructure): `../../Serializers/README.md` - **SignalR binary transport** (uses this serializer): `../../AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/README.md` +- **ASP.NET Core MVC formatter** (uses this serializer): `../../AyCode.Services/docs/MVC/README.md` - **Glossary terms**: `../../../docs/GLOSSARY.md` ## Related ADRs diff --git a/AyCode.Services/Mvc/README.md b/AyCode.Services/Mvc/README.md new file mode 100644 index 0000000..630a011 --- /dev/null +++ b/AyCode.Services/Mvc/README.md @@ -0,0 +1,20 @@ +# Mvc + +ASP.NET Core MVC formatters for AcBinary wire format. Standard MVC pipeline integration — works with both controller-based MVC and Minimal API. + +> **Topic docs:** `AyCode.Services/docs/MVC/README.md` — media type, request/response flow, configuration, ProblemDetails error model. +> **Binary serializer:** `../../AyCode.Core/AyCode.Core/docs/BINARY/README.md` + +## Key Files + +- **`AcBinaryInputFormatter.cs`** — Reads request body via `PipeReader.Create(Request.Body)`, drains chunks into `AsyncPipeReaderInput` on the calling thread while a background `Task.Run` deserializes incrementally via `AcBinaryDeserializer.Deserialize(input, ModelType, opts)`. ProblemDetails error flow on failure (`ModelState.TryAddModelError` → 400 + `application/problem+json`). Cancellation honors `HttpContext.RequestAborted`. +- **`AcBinaryOutputFormatter.cs`** — Wraps `Response.Body` as `PipeWriter`, calls `AcBinarySerializer.SerializeChunked(Object, ObjectType, writer, opts)` (raw mode — pure AcBinary bytes, no per-chunk framing). `pipeWriter.CompleteAsync()` in finally. +- **`AcBinaryMvcBuilderExtensions.cs`** — `IMvcBuilder.AddAcBinaryFormatters(...)` + `IMvcCoreBuilder.AddAcBinaryFormatters(...)`. Inserts both formatters at index 0 of `MvcOptions.InputFormatters` / `OutputFormatters` so AcBinary is preferred when the client's `Accept` header allows. + +## Media Type + +`application/vnd.acbinary` (vendor tree). Configurable per-instance via `SupportedMediaTypes.Add(...)` if a different type is needed. + +## Layering + +The formatter owns the **transport-specific drain-loop** (`PipeReader.ReadAsync` → `input.Feed`). The serializer surface ends at `AsyncPipeReaderInput` — `AcBinaryDeserializer` itself does not know about `PipeReader`. This mirrors the SignalR pattern (`AcBinaryHubProtocol.TryParseChunkData` does the same `state.Buffer.Write(span)`-loop). diff --git a/AyCode.Services/README.md b/AyCode.Services/README.md index 4314eff..18715de 100644 --- a/AyCode.Services/README.md +++ b/AyCode.Services/README.md @@ -4,7 +4,7 @@ type = "framework" } -Shared service implementations: SignalR communication (custom binary protocol), login services, and remote log writers. +Shared service implementations: SignalR communication (custom binary protocol), ASP.NET Core MVC formatters for the AcBinary wire format, login services, and remote log writers. ## Documentation @@ -12,6 +12,7 @@ Shared service implementations: SignalR communication (custom binary protocol), |---|---| | `SIGNALR/README.md` | Client-side SignalR transport (tags, wire protocol, req/resp flow) | | `SIGNALR_BINARY_PROTOCOL/README.md` | Binary-over-SignalR wire format, chunked framing | +| `MVC/README.md` | ASP.NET Core MVC formatters for AcBinary (`application/vnd.acbinary`) | | `LOGGING/README.md` | Remote log writers (HTTP, browser console, SignalR) | ## Folder Structure @@ -20,6 +21,7 @@ Shared service implementations: SignalR communication (custom binary protocol), |---|---| | [`Loggers/`](Loggers/README.md) | Remote log writers: HTTP, browser console (JS interop), SignalR | | [`Logins/`](Logins/README.md) | Base and client-side login service implementations | +| [`Mvc/`](Mvc/README.md) | ASP.NET Core MVC `InputFormatter` / `OutputFormatter` for AcBinary wire format | | [`SignalRs/`](SignalRs/README.md) | Custom binary SignalR protocol, client base, message tagging, serialization | ## Dependencies @@ -30,6 +32,7 @@ Shared service implementations: SignalR communication (custom binary protocol), | `AyCode.Entities` | Entity base classes | | `AyCode.Interfaces` | Service contracts | | `AyCode.Models` | DTOs | +| `Microsoft.AspNetCore.App` (FrameworkReference) | ASP.NET Core MVC formatter base classes (.NET 9+) | | `Microsoft.AspNetCore.SignalR.Client` | SignalR client | | `Microsoft.AspNetCore.SignalR.Common` | `IHubProtocol` for custom binary protocol | | `Microsoft.AspNetCore.Authentication.JwtBearer` | JWT authentication | diff --git a/AyCode.Services/docs/MVC/README.md b/AyCode.Services/docs/MVC/README.md new file mode 100644 index 0000000..5a879be --- /dev/null +++ b/AyCode.Services/docs/MVC/README.md @@ -0,0 +1,75 @@ +# MVC — AcBinary formatters + +ASP.NET Core MVC `InputFormatter` / `OutputFormatter` pair for the AcBinary wire format. Works in controller-based MVC and Minimal API on .NET 9+. The wire payload is the raw `byte[]` produced by `AcBinarySerializer.Serialize(value, opts)` — bit-compatible with the single-shot byte[] API; no MVC-specific envelope. + +> **Code:** `AyCode.Services/Mvc/` (`AcBinaryInputFormatter`, `AcBinaryOutputFormatter`, `AcBinaryMvcBuilderExtensions`) +> **Binary serializer:** `../../../AyCode.Core/AyCode.Core/docs/BINARY/README.md` + +## Registration + +```csharp +// Program.cs +builder.Services.AddControllers() + .AddAcBinaryFormatters(opts => { + opts.UseGeneratedCode = true; + }); +``` + +`AddAcBinaryFormatters` inserts both formatters at **index 0** of `MvcOptions.InputFormatters` / `OutputFormatters` — AcBinary wins content-negotiation when the client's `Accept` header allows. + +## Media Type + +`application/vnd.acbinary` (vendor tree, registered with the IANA pattern but not yet IANA-listed). The same media type is sent on both request (`Content-Type`) and response. + +Override via `SupportedMediaTypes.Add(...)` on a custom formatter instance if a project-specific type is needed. + +## Request flow (InputFormatter) + +``` +HttpContext.Request.Body (Stream) + → PipeReader.Create(Body) (PipeReader) + → drain-loop on calling thread: + while (true) { + result = await reader.ReadAsync(ct); + foreach (segment in result.Buffer) input.Feed(segment.Span); + reader.AdvanceTo(result.Buffer.End); + if (result.IsCompleted) break; + } + input.Complete(); + ↑ background Task.Run feeds AcBinaryDeserializer.Deserialize(input, ModelType, opts) + → ModelType instance → InputFormatterResult.Success +``` + +The drain-loop is **inline** in the formatter — the serializer surface ends at `AsyncPipeReaderInput`. Any I/O-specific draining (PipeReader, NamedPipe, FileStream, custom transport) is the consumer's responsibility. + +## Response flow (OutputFormatter) + +``` +HttpContext.Response.Body (Stream) + → PipeWriter.Create(Body) (PipeWriter) + → AcBinarySerializer.SerializeChunked(value, ObjectType, writer, opts) + (raw mode — pure AcBinary bytes, no [201][UINT16] framing) + → await pipeWriter.CompleteAsync() +``` + +`SerializeChunked` (not `SerializeChunkedFramed`) — the wire is a single self-contained AcBinary blob, identical to `Serialize(value, opts) → byte[]`. No multiplexed framing on the HTTP body. + +## Error model + +Deserialization failure → `ModelState.TryAddModelError(ModelName, ex.Message)` → `InputFormatterResult.Failure()`. ASP.NET pipeline emits `400 Bad Request` with `application/problem+json` (RFC 7807) — **not** an AcBinary-encoded error. The client reads the error body as JSON. + +`OperationCanceledException` (when `RequestAborted` is signalled) is rethrown so the pipeline aborts the response cleanly. + +## Cancellation + +`HttpContext.RequestAborted` flows into both formatters. The InputFormatter passes it to `PipeReader.ReadAsync` and `Task.Run`; the OutputFormatter calls `cancellationToken.ThrowIfCancellationRequested()` after `CompleteAsync`. Mid-request abort releases all pooled resources via the `using` and `finally` blocks. + +## What the formatter does NOT include + +- **No Stream-async API in the binary core** — `AcBinarySerializer` has no `SerializeAsync(Stream, T)` method. The formatter is the wrapper. (See `BINARY_TODO.md#accore-bin-t-t8k3` — parked.) +- **No options thread-safety guard** — `AcBinarySerializerOptions` is currently mutable; if registered as a DI singleton with `Configure<>` it is fine because `Configure` is read-only at runtime, but raw-shared mutable instances across concurrent requests are unsafe. (See `BINARY_ISSUES.md#accore-bin-i-l8n5` and `BINARY_TODO.md#accore-bin-t-b7h4`.) +- **No OpenAPI metadata helpers** — `Microsoft.AspNetCore.OpenApi` / `Swashbuckle.AspNetCore` pick up `SupportedMediaTypes` automatically; no extra integration needed. + +## Future work + +The formatter currently lives in `AyCode.Services` (alongside the SignalR transport). The intent is to extract it into its own NuGet package — `AyCode.AspNetCore.Mvc.Formatters.AcBinary` — when the binary serializer is moved to a dedicated solution. No code change required at extraction time; only project-file split. diff --git a/AyCode.Services/docs/README.md b/AyCode.Services/docs/README.md index de10cba..d86786f 100644 --- a/AyCode.Services/docs/README.md +++ b/AyCode.Services/docs/README.md @@ -7,6 +7,7 @@ Topic docs for the `AyCode.Services` project (Layer 0, service abstractions). - [`LOGGING/`](LOGGING/README.md) — Remote logger (variant — sends log entries over the wire) - [`SIGNALR/`](SIGNALR/README.md) — SignalR transport (tag-based protocol, generic hub methods) - [`SIGNALR_BINARY_PROTOCOL/`](SIGNALR_BINARY_PROTOCOL/README.md) — Binary-over-SignalR wire format, chunked framing +- [`MVC/`](MVC/README.md) — ASP.NET Core MVC formatters for the AcBinary wire format (InputFormatter / OutputFormatter, `application/vnd.acbinary`) ## Navigation diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 94f413a..5ae915b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -94,7 +94,7 @@ AyCode.Services ← AyCode.Services.Server - **AyCode.Database** — EF Core with generic DAL pattern. Session for reads, Transaction for writes. DAL pooling via `PooledDal`. ### Service Layer -- **AyCode.Services** — Client-side: SignalR client, login service, loggers. +- **AyCode.Services** — Client-side: SignalR client, login service, loggers, ASP.NET Core MVC formatters for AcBinary (`Mvc/`). - **AyCode.Services.Server** — Server-side: SignalR hub with custom binary protocol, email (SendGrid), JWT auth. - **AyCode.Models.Server/DynamicMethods** — Reflection-based tag→method dispatch used by the SignalR hub.