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.