[LOADED_DOCS: 3 files, no new loads]

Switch to FlushPolicy enum for streaming flush control

Replaces the legacy bool waitForFlush with a new FlushPolicy enum (PerChunk, DoubleBuffered, Coalesced) across all binary streaming serialization APIs and SignalR protocol options. Updates all code, configuration, and documentation to use the new policy, clarifies memory/throughput trade-offs, and closes related TODOs. Stream-backed writers remain sequential; only parallel-capable Pipe-based writers honor the policy.
This commit is contained in:
Loretta 2026-05-03 08:13:59 +02:00
parent 67589f6b6f
commit e7b12a1100
15 changed files with 304 additions and 136 deletions

View File

@ -63,7 +63,7 @@ public static class Program
private const string IoNamedPipe = "NamedPipe"; private const string IoNamedPipe = "NamedPipe";
private const string IoNamedPipeRaw = "NamedPipe"; private const string IoNamedPipeRaw = "NamedPipe";
private const string IoInMemoryPipe = "Pipe(in-mem)"; private const string IoInMemoryPipe = "Pipe(in-mem)";
private const string IoInMemoryRaw = "Bytes(in-mem)"; private const string IoInMemoryRaw = "Pipe(in-mem)";
// Single source of truth for the chunk size used by ALL pipe-related benchmarks (NamedPipe PipeChunk, // Single source of truth for the chunk size used by ALL pipe-related benchmarks (NamedPipe PipeChunk,
// NamedPipe PipeRaw, in-memory Pipe, in-memory RawMem) AND the NamedPipe server's inBufferSize/outBufferSize. // NamedPipe PipeRaw, in-memory Pipe, in-memory RawMem) AND the NamedPipe server's inBufferSize/outBufferSize.
@ -1505,10 +1505,17 @@ public static class Program
// Same 2-task streaming pipeline as NamedPipe variant — only the transport differs (in-memory Pipe // Same 2-task streaming pipeline as NamedPipe variant — only the transport differs (in-memory Pipe
// instead of kernel NamedPipe). Per-chunk SerializeChunkedFramed → PipeWriter slab → drain task // instead of kernel NamedPipe). Per-chunk SerializeChunkedFramed → PipeWriter slab → drain task
// reads from PipeReader → input.Feed → consumer Deserialize<T> consumes byte-by-byte. // reads from PipeReader → input.Feed → consumer Deserialize<T> consumes byte-by-byte.
//
// Uses the Pipe-overload (instead of the PipeWriter-overload) so the FlushPolicy parameter is
// exposed for tuning. Toggle between FlushPolicy.PerChunk (bounded peak memory, per-chunk await
// FlushAsync) and FlushPolicy.Coalesced (fire-and-forget per chunk, pipe-coalesced flushes up to
// PauseWriterThreshold ~64 KB) to A/B-test the streaming-pipeline overhead. FlushPolicy.PerChunk
// is functionally equivalent to the PipeWriter-overload (both internally route to
// SerializeToPipeWriterCore with FlushPolicy.PerChunk).
_consumeDone.Reset(); _consumeDone.Reset();
_consumeRequest.Set(); _consumeRequest.Set();
AcBinarySerializer.SerializeChunkedFramed(_order, _pipeWriter, _options); AcBinarySerializer.SerializeChunkedFramed(_order, _pipe, _options, FlushPolicy.Coalesced);
_consumeDone.Wait(); _consumeDone.Wait();
} }

View File

@ -7,8 +7,8 @@ using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization; namespace AyCode.Core.Tests.Serialization;
/// <summary> /// <summary>
/// Cross-platform NamedPipe IPC roundtrip tests for AcBinarySerializer's transport-agnostic /// Cross-platform NamedPipe IPC roundtrip tests proving AcBinarySerializer's streaming framework
/// streaming helpers (Step 4 of ADR-0003, ACCORE-BIN-T-A3T8). /// works on arbitrary <c>PipeWriter</c>/<c>PipeReader</c> sources without per-transport adapters.
/// ///
/// <para>The serializer/deserializer surface intentionally has NO NamedPipe-specific helpers — /// <para>The serializer/deserializer surface intentionally has NO NamedPipe-specific helpers —
/// the tests own the <see cref="NamedPipeServerStream"/> / <see cref="NamedPipeClientStream"/> /// the tests own the <see cref="NamedPipeServerStream"/> / <see cref="NamedPipeClientStream"/>
@ -16,9 +16,9 @@ namespace AyCode.Core.Tests.Serialization;
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> + /// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> +
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/> /// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
/// primitives, with the receive-side drain implemented via the test-only /// primitives, with the receive-side drain implemented via the test-only
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension. This proves the streaming /// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension. The same generic
/// framework works on arbitrary <c>PipeWriter</c>/<c>PipeReader</c> sources (NamedPipe, FileStream, /// primitives apply to FileStream / NetworkStream / custom transports — consumers own the
/// NetworkStream, custom transports) without per-transport adapters in the framework.</para> /// transport lifecycle, framework stays transport-agnostic.</para>
/// ///
/// <para>With <c>BufferWriterChunkSize = 256</c>, even small test payloads cross multiple chunk /// <para>With <c>BufferWriterChunkSize = 256</c>, even small test payloads cross multiple chunk
/// boundaries on the wire — exercises the real chunking + sliding-window cycling behavior.</para> /// boundaries on the wire — exercises the real chunking + sliding-window cycling behavior.</para>

View File

@ -434,26 +434,27 @@ public static partial class AcBinarySerializer
/// <para><b>Why <see cref="System.IO.Pipelines.Pipe"/> instead of <see cref="System.IO.Pipelines.PipeWriter"/>?</b> /// <para><b>Why <see cref="System.IO.Pipelines.Pipe"/> instead of <see cref="System.IO.Pipelines.PipeWriter"/>?</b>
/// <c>Pipe.Writer</c> is always the BCL <c>PipeWriterImpl</c>, which is parallel-capable /// <c>Pipe.Writer</c> is always the BCL <c>PipeWriterImpl</c>, which is parallel-capable
/// (no <c>_tailMemory</c> reset race like <c>StreamPipeWriter</c>). This overload exposes the /// (no <c>_tailMemory</c> reset race like <c>StreamPipeWriter</c>). This overload exposes the
/// <paramref name="waitForFlush"/> + <paramref name="flushTimeout"/> tuning safely.</para> /// <paramref name="flushPolicy"/> + <paramref name="flushTimeout"/> tuning safely.</para>
/// </summary> /// </summary>
/// <param name="value">The value to serialize; <c>null</c> writes a single null marker.</param> /// <param name="value">The value to serialize; <c>null</c> writes a single null marker.</param>
/// <param name="pipe">Target pipe — caller drains <c>pipe.Reader</c> elsewhere.</param> /// <param name="pipe">Target pipe — caller drains <c>pipe.Reader</c> elsewhere.</param>
/// <param name="options">Serializer options (type wrappers, reference handling, interning, etc.).</param> /// <param name="options">Serializer options (type wrappers, reference handling, interning, etc.).</param>
/// <param name="waitForFlush"> /// <param name="flushPolicy">
/// Per-chunk flush synchronization. <c>true</c> (default): maximum pipeline parallelism, /// Per-chunk flush synchronization — see <see cref="FlushPolicy"/> for the three trade-off
/// guaranteed zero-copy + zero-alloc, but slow consumers block the producer thread (bounded by /// points. <see cref="FlushPolicy.PerChunk"/>: strictly bounded ~chunk × 1 peak memory, no
/// <paramref name="flushTimeout"/>). <c>false</c>: adaptive backpressure via memory threshold /// producer/flush parallelism. <see cref="FlushPolicy.DoubleBuffered"/> (default): ~chunk × 2
/// (~64KB in-flight) — safer for mixed consumer speeds, never blocks on slow consumers. /// peak memory, max producer/flush parallelism. <see cref="FlushPolicy.Coalesced"/>: up to
/// PauseWriterThreshold (~64 KB), highest throughput on bounded payloads.
/// </param> /// </param>
/// <param name="flushTimeout"> /// <param name="flushTimeout">
/// Per-flush timeout. <c>null</c> → wait forever. Positive value: throws /// Per-flush timeout. <c>null</c> → wait forever. Positive value: throws
/// <see cref="TimeoutException"/> on stuck consumers. /// <see cref="TimeoutException"/> on stuck consumers.
/// </param> /// </param>
/// <returns>Total serialized bytes written.</returns> /// <returns>Total serialized bytes written.</returns>
public static int SerializeChunked<T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, bool waitForFlush = true, TimeSpan? flushTimeout = null) public static int SerializeChunked<T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null)
{ {
if (pipe is null) throw new ArgumentNullException(nameof(pipe)); if (pipe is null) throw new ArgumentNullException(nameof(pipe));
return SerializeToPipeWriterCore(value, pipe.Writer, options, waitForFlush, flushTimeout, multiMessage: false); return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: false);
} }
/// <summary> /// <summary>
@ -465,11 +466,11 @@ public static partial class AcBinarySerializer
/// (<c>PipeWriter.Create(stream)</c> — NamedPipe / FileStream / NetworkStream / etc.) runs /// (<c>PipeWriter.Create(stream)</c> — NamedPipe / FileStream / NetworkStream / etc.) runs
/// sequentially per chunk because the BCL impl resets <c>_tailMemory</c> on flush completion /// sequentially per chunk because the BCL impl resets <c>_tailMemory</c> on flush completion
/// (race-incompatible with parallel send). Other PipeWriter implementations (Kestrel transport, /// (race-incompatible with parallel send). Other PipeWriter implementations (Kestrel transport,
/// custom impls) run with the safe <c>waitForFlush=true</c> default — max parallelism, zero-alloc.</para> /// custom impls) run with the safe <see cref="FlushPolicy.DoubleBuffered"/> default — max parallelism, zero-alloc.</para>
/// ///
/// <para><b>Need runtime tuning of the flush strategy?</b> Build a /// <para><b>Need runtime tuning of the flush strategy?</b> Build a
/// <see cref="System.IO.Pipelines.Pipe"/> instance and use /// <see cref="System.IO.Pipelines.Pipe"/> instance and use
/// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, bool, TimeSpan?)"/> /// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>
/// — only Pipe-based writers can guarantee parallel-capable flush behavior.</para> /// — only Pipe-based writers can guarantee parallel-capable flush behavior.</para>
/// ///
/// <para><b>Need a multiplexed wire format with per-chunk frame headers?</b> See /// <para><b>Need a multiplexed wire format with per-chunk frame headers?</b> See
@ -480,7 +481,7 @@ public static partial class AcBinarySerializer
/// <param name="options">Serializer options (type wrappers, reference handling, interning, etc.).</param> /// <param name="options">Serializer options (type wrappers, reference handling, interning, etc.).</param>
/// <returns>Total serialized bytes written.</returns> /// <returns>Total serialized bytes written.</returns>
public static int SerializeChunked<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options) public static int SerializeChunked<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore(value, pipeWriter, options, waitForFlush: true, flushTimeout: null, multiMessage: false); => SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
/// <summary> /// <summary>
/// Serialize a value into a chunked stream where each chunk carries a self-describing /// Serialize a value into a chunked stream where each chunk carries a self-describing
@ -499,58 +500,58 @@ public static partial class AcBinarySerializer
/// this exact wire format to interleave many HubMessages over a single connection.</para> /// this exact wire format to interleave many HubMessages over a single connection.</para>
/// ///
/// <para><b>Need a simpler streaming output without per-chunk metadata?</b> Use /// <para><b>Need a simpler streaming output without per-chunk metadata?</b> Use
/// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, bool, TimeSpan?)"/> /// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>
/// — bit-compatible with <see cref="Serialize{T}(T, AcBinarySerializerOptions)"/>'s /// — bit-compatible with <see cref="Serialize{T}(T, AcBinarySerializerOptions)"/>'s
/// <c>byte[]</c> output, no extra parser needed on the receive side.</para> /// <c>byte[]</c> output, no extra parser needed on the receive side.</para>
/// </summary> /// </summary>
/// <param name="value">The value to serialize; <c>null</c> writes a single null marker.</param> /// <param name="value">The value to serialize; <c>null</c> writes a single null marker.</param>
/// <param name="pipe">Target pipe — caller drains <c>pipe.Reader</c> elsewhere.</param> /// <param name="pipe">Target pipe — caller drains <c>pipe.Reader</c> elsewhere.</param>
/// <param name="options">Serializer options.</param> /// <param name="options">Serializer options.</param>
/// <param name="waitForFlush">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, bool, TimeSpan?)"/>.</param> /// <param name="flushPolicy">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>.</param>
/// <param name="flushTimeout">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, bool, TimeSpan?)"/>.</param> /// <param name="flushTimeout">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>.</param>
/// <returns>Total serialized data bytes (excluding framing overhead).</returns> /// <returns>Total serialized data bytes (excluding framing overhead).</returns>
public static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, bool waitForFlush = true, TimeSpan? flushTimeout = null) public static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null)
{ {
if (pipe is null) throw new ArgumentNullException(nameof(pipe)); if (pipe is null) throw new ArgumentNullException(nameof(pipe));
return SerializeToPipeWriterCore(value, pipe.Writer, options, waitForFlush, flushTimeout, multiMessage: true); return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: true);
} }
/// <summary> /// <summary>
/// Serialize to any <see cref="System.IO.Pipelines.PipeWriter"/> with per-chunk frame headers /// Serialize to any <see cref="System.IO.Pipelines.PipeWriter"/> with per-chunk frame headers
/// (multiplexed wire format). See /// (multiplexed wire format). See
/// <see cref="SerializeChunkedFramed{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, bool, TimeSpan?)"/> /// <see cref="SerializeChunkedFramed{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>
/// for the wire format details and use-cases. /// for the wire format details and use-cases.
/// ///
/// <para><b>Flush strategy auto-selected by writer type</b> — see /// <para><b>Flush strategy auto-selected by writer type</b> — see
/// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.</para> /// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.</para>
/// </summary> /// </summary>
public static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options) public static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore(value, pipeWriter, options, waitForFlush: true, flushTimeout: null, multiMessage: true); => SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
/// <summary> /// <summary>
/// Internal flush-tunable framed PipeWriter overload — used by <c>AyCode.Services</c> /// Internal flush-tunable framed PipeWriter overload — used by <c>AyCode.Services</c>
/// (SignalR hub protocol) on Kestrel transport output, which is parallel-capable. External /// (SignalR hub protocol) on Kestrel transport output, which is parallel-capable. External
/// callers should use the <see cref="System.IO.Pipelines.Pipe"/> overload to safely tune /// callers should use the <see cref="System.IO.Pipelines.Pipe"/> overload to safely tune
/// <paramref name="waitForFlush"/> on a guaranteed parallel-capable writer. /// <paramref name="flushPolicy"/> on a guaranteed parallel-capable writer.
/// </summary> /// </summary>
internal static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, bool waitForFlush, TimeSpan? flushTimeout) internal static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout)
=> SerializeToPipeWriterCore(value, pipeWriter, options, waitForFlush, flushTimeout, multiMessage: true); => SerializeToPipeWriterCore(value, pipeWriter, options, flushPolicy, flushTimeout, multiMessage: true);
/// <summary> /// <summary>
/// Internal legacy alias for <see cref="SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions, bool, TimeSpan?)"/> /// Internal legacy alias for <see cref="SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>
/// — kept until the SignalR hub protocol (<c>AcBinaryHubProtocol.cs</c>) is migrated to the /// — kept until the SignalR hub protocol (<c>AcBinaryHubProtocol.cs</c>) is migrated to the
/// new name in a separate, isolated step. Identical behavior to <c>SerializeChunkedFramed</c> /// new name in a separate, isolated step. Identical behavior to <c>SerializeChunkedFramed</c>
/// (framed wire format with <c>[201][UINT16][data]</c> per chunk + <c>[202]</c> end marker). /// (framed wire format with <c>[201][UINT16][data]</c> per chunk + <c>[202]</c> end marker).
/// </summary> /// </summary>
internal static int Serialize<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, bool waitForFlush, TimeSpan? flushTimeout) internal static int Serialize<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout)
=> SerializeToPipeWriterCore(value, pipeWriter, options, waitForFlush, flushTimeout, multiMessage: true); => SerializeToPipeWriterCore(value, pipeWriter, options, flushPolicy, flushTimeout, multiMessage: true);
/// <summary> /// <summary>
/// Common pipe-output serialization core. Same loop for both raw (<see cref="SerializeChunked{T}"/>) /// Common pipe-output serialization core. Same loop for both raw (<see cref="SerializeChunked{T}"/>)
/// and framed (<see cref="SerializeChunkedFramed{T}"/>) modes — the only difference flows through /// and framed (<see cref="SerializeChunkedFramed{T}"/>) modes — the only difference flows through
/// <paramref name="multiMessage"/> into the <see cref="AsyncPipeWriterOutput"/> ctor. /// <paramref name="multiMessage"/> into the <see cref="AsyncPipeWriterOutput"/> ctor.
/// </summary> /// </summary>
private static int SerializeToPipeWriterCore<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, bool waitForFlush, TimeSpan? flushTimeout, bool multiMessage) private static int SerializeToPipeWriterCore<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout, bool multiMessage)
{ {
if (value == null) if (value == null)
{ {
@ -564,7 +565,7 @@ public static partial class AcBinarySerializer
var runtimeType = value.GetType(); var runtimeType = value.GetType();
var context = BinarySerializationContextPool<AsyncPipeWriterOutput>.Get(options); var context = BinarySerializationContextPool<AsyncPipeWriterOutput>.Get(options);
context.Output = new AsyncPipeWriterOutput(pipeWriter, options.BufferWriterChunkSize, multiMessage, waitForFlush, flushTimeout); context.Output = new AsyncPipeWriterOutput(pipeWriter, options.BufferWriterChunkSize, multiMessage, flushPolicy, flushTimeout);
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd); context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
try try

View File

@ -30,16 +30,21 @@ namespace AyCode.Core.Serializers.Binaries;
/// and any custom multi-message protocol over a long-lived transport.</item> /// and any custom multi-message protocol over a long-lived transport.</item>
/// </list> /// </list>
/// ///
/// <para><b>Backpressure modes</b> (controlled by <c>waitForFlush</c>) — independent of framing:</para> /// <para><b>Backpressure modes</b> (controlled by <see cref="FlushPolicy"/>) — independent of framing:</para>
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>waitForFlush=true</c> (default): Grow() waits for the previous FlushAsync before /// <item><see cref="FlushPolicy.PerChunk"/>: Grow() commits → flushes → awaits → acquires next.
/// starting a new chunk. <b>Pro:</b> maximum pipeline parallelism, guaranteed end-to-end zero-copy. /// <b>Peak memory:</b> ~chunk_size × 1. <b>Pro:</b> strictly bounded peak memory.
/// <b>Con:</b> slow consumer propagates back as server-thread blocking (bounded by <c>flushTimeout</c>).</item> /// <b>Con:</b> no producer/flush parallelism. Auto-applied on Stream-backed PipeWriter
/// <item><c>waitForFlush=false</c>: Grow() is fire-and-forget per chunk; only blocks when committed /// regardless of chosen policy.</item>
/// bytes exceed ~60 KB (memory threshold — itself an adaptive backpressure). /// <item><see cref="FlushPolicy.DoubleBuffered"/> (default): fire-and-forget previous flush; next
/// <b>Pro:</b> no per-chunk waits, safer with mixed consumer speeds. /// Grow waits only if previous flush hasn't completed. <b>Peak memory:</b> ~chunk_size × 2.
/// <b>Con:</b> under heavy backpressure may fall back to an owned buffer, losing zero-copy /// <b>Pro:</b> max producer/flush parallelism with bounded memory.
/// for that chunk.</item> /// <b>Con:</b> slow consumer blocks producer at next Grow (bounded by <c>flushTimeout</c>).</item>
/// <item><see cref="FlushPolicy.Coalesced"/>: Grow() never awaits; Pipe coalesces flushes up to
/// <c>PauseWriterThreshold</c> (~64 KB). <b>Peak memory:</b> up to PauseWriterThreshold.
/// <b>Pro:</b> highest throughput on bounded payloads.
/// <b>Con:</b> peak memory unbounded by chunk_size; under heavy backpressure may fall back to
/// an owned buffer, losing zero-copy for that chunk.</item>
/// </list> /// </list>
/// ///
/// <para><b>Flush strategy</b> auto-selects on writer type — Stream-backed PipeWriters /// <para><b>Flush strategy</b> auto-selects on writer type — Stream-backed PipeWriters
@ -111,7 +116,7 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
private readonly PipeWriter _pipeWriter; private readonly PipeWriter _pipeWriter;
private readonly int _chunkSize; private readonly int _chunkSize;
private readonly bool _multiMessage; private readonly bool _multiMessage;
private readonly bool _waitForFlush; private readonly FlushPolicy _flushPolicy;
private readonly bool _serializeFlushAndAcquire; private readonly bool _serializeFlushAndAcquire;
private readonly TimeSpan _flushTimeout; private readonly TimeSpan _flushTimeout;
private int _committedBytes; private int _committedBytes;
@ -144,10 +149,12 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
/// + <c>[202]</c> end-of-message marker on Flush. Receiver auto-resets between messages. /// + <c>[202]</c> end-of-message marker on Flush. Receiver auto-resets between messages.
/// <c>false</c> → single-message: raw AcBinary bytes only, byte-compatible with the single-shot <c>byte[]</c> output; /// <c>false</c> → single-message: raw AcBinary bytes only, byte-compatible with the single-shot <c>byte[]</c> output;
/// caller signals end-of-message by closing the writer. See class summary.</param> /// caller signals end-of-message by closing the writer. See class summary.</param>
/// <param name="waitForFlush">See class summary — pipeline parallelism (true) vs adaptive (false).</param> /// <param name="flushPolicy">See class summary — strictly bounded (<see cref="FlushPolicy.PerChunk"/>),
/// double-buffered (<see cref="FlushPolicy.DoubleBuffered"/>, default), or coalesced
/// (<see cref="FlushPolicy.Coalesced"/>).</param>
/// <param name="flushTimeout">Per-flush timeout. <c>null</c> → <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> /// <param name="flushTimeout">Per-flush timeout. <c>null</c> → <see cref="System.Threading.Timeout.InfiniteTimeSpan"/>
/// (wait forever — legacy behavior). Pass a positive value to fail fast on stuck consumers.</param> /// (wait forever — legacy behavior). Pass a positive value to fail fast on stuck consumers.</param>
public AsyncPipeWriterOutput(PipeWriter pipeWriter, int chunkSize = 4096, bool multiMessage = true, bool waitForFlush = true, TimeSpan? flushTimeout = null) public AsyncPipeWriterOutput(PipeWriter pipeWriter, int chunkSize = 4096, bool multiMessage = true, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null)
{ {
if (chunkSize > MaxChunkSize) if (chunkSize > MaxChunkSize)
throw new ArgumentOutOfRangeException(nameof(chunkSize), chunkSize, $"Chunk size cannot exceed {MaxChunkSize} (UINT16 max)."); throw new ArgumentOutOfRangeException(nameof(chunkSize), chunkSize, $"Chunk size cannot exceed {MaxChunkSize} (UINT16 max).");
@ -155,7 +162,7 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
_pipeWriter = pipeWriter; _pipeWriter = pipeWriter;
_chunkSize = chunkSize; _chunkSize = chunkSize;
_multiMessage = multiMessage; _multiMessage = multiMessage;
_waitForFlush = waitForFlush; _flushPolicy = flushPolicy;
// null → Timeout.InfiniteTimeSpan ("wait forever" — natively supported by Task.Wait as -1ms). // null → Timeout.InfiniteTimeSpan ("wait forever" — natively supported by Task.Wait as -1ms).
// A positive value enables bounded waiting; on timeout a TimeoutException propagates to the caller. // A positive value enables bounded waiting; on timeout a TimeoutException propagates to the caller.
@ -222,29 +229,30 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)] [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
public void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed) public void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed)
{ {
if (_serializeFlushAndAcquire) if (_serializeFlushAndAcquire || _flushPolicy == FlushPolicy.PerChunk)
{ {
// STREAMPIPEWRITER path — sequential per chunk: commit → flush → await → acquire. // SEQUENTIAL path — fully synchronous per chunk: commit → flush → await → acquire.
// Stream-backed writers (NamedPipe / FileStream / NetworkStream) reset internal // Triggered by either:
// state (_tailMemory) at flush completion → cannot acquire-during-flush concurrently // - Stream-backed PipeWriter (NamedPipe / FileStream / NetworkStream / etc.) — auto,
// (the standard Stream-PipeWriter usage pattern is await-flush-before-next-write). // forced by the BCL StreamPipeWriter._tailMemory reset race.
// waitForFlush / _committedBytes throttling don't apply here — the writer pattern // - FlushPolicy.PerChunk on a Pipe-based writer — explicit caller choice for
// enforces sequentiality intrinsically. // strictly bounded peak memory (~chunk_size × 1).
// _committedBytes throttling doesn't apply — sequentiality is enforced per chunk.
CommitCurrentChunk(buffer, position); CommitCurrentChunk(buffer, position);
SyncAwaitFlush(_pipeWriter.FlushAsync()); SyncAwaitFlush(_pipeWriter.FlushAsync());
} }
else else
{ {
// PIPE-BASED path (Kestrel / SignalR) — parallel sender: serializer writes the next // PARALLEL paths (Pipe-based writer + DoubleBuffered or Coalesced) — serializer writes
// chunk into the PipeWriter's buffer concurrently with the background FlushAsync. // the next chunk into the PipeWriter's buffer concurrently with the background FlushAsync.
// waitForFlush=true: backpressure — wait for the previous parallel flush before // FlushPolicy.DoubleBuffered: wait at next Grow if the previous parallel flush hasn't
// starting a new one (prevents unbounded in-flight flushes). // completed yet → max two chunks in flight (~chunk_size × 2 peak memory).
// waitForFlush=false: adaptive — skip the wait, but force-await if _committedBytes // FlushPolicy.Coalesced: skip the per-chunk wait; only block when _committedBytes
// approaches the Pipe's PauseWriterThreshold (~64 KB), preventing runaway buffer // approaches the Pipe's PauseWriterThreshold (~64 KB), preventing runaway buffer
// growth when the consumer is slow. // growth under a slow consumer.
// The conditional FlushAsync at the end avoids double-flush if the previous flush // The conditional FlushAsync at the end avoids double-flush if the previous flush
// is still in progress (waitForFlush=false skip path). // is still in progress (Coalesced skip path).
if ((_waitForFlush && !_lastFlush.IsCompleted) || _committedBytes > MaxChunkSize - _chunkSize) SyncAwaitFlush(_lastFlush); if ((_flushPolicy == FlushPolicy.DoubleBuffered && !_lastFlush.IsCompleted) || _committedBytes > MaxChunkSize - _chunkSize) SyncAwaitFlush(_lastFlush);
CommitCurrentChunk(buffer, position); CommitCurrentChunk(buffer, position);

View File

@ -0,0 +1,62 @@
namespace AyCode.Core.Serializers.Binaries;
/// <summary>
/// Controls per-chunk flush synchronization on the parallel-capable Pipe-based send path
/// (<see cref="AsyncPipeWriterOutput"/>). Replaces the historical <c>bool waitForFlush</c>
/// parameter with three explicit memory-vs-throughput trade-off points.
///
/// <para><b>Stream-backed PipeWriter exception</b> — <see cref="System.IO.Pipelines.PipeWriter.Create(System.IO.Stream, System.IO.Pipelines.StreamPipeWriterOptions)"/>
/// (NamedPipe, FileStream, NetworkStream, etc.) is intrinsically sequential per chunk because of
/// the <c>StreamPipeWriter._tailMemory</c> reset race, regardless of policy. The auto-detected
/// sequential path is functionally equivalent to <see cref="PerChunk"/>. Only Pipe-based writers
/// (BCL <c>PipeWriterImpl</c>, Kestrel transport, custom parallel-capable impls) honour the policy
/// distinction between the three values.</para>
/// </summary>
public enum FlushPolicy
{
/// <summary>
/// Sequential — every chunk fully flushes and awaits completion before the next is acquired.
/// Producer-side: commit → <c>FlushAsync</c> → await → acquire-next.
/// <para><b>Peak memory:</b> ~chunk_size × 1 (one in-flight chunk).</para>
/// <para><b>Pro:</b> strictly bounded peak memory regardless of consumer speed; simplest
/// memory profile to reason about. Auto-applied on Stream-backed PipeWriter regardless of
/// chosen policy.</para>
/// <para><b>Con:</b> no producer/flush parallelism — wall-clock = sum of (serialize + flush)
/// per chunk.</para>
/// <para>Recommended for unpredictable / unbounded payloads where memory must stay strictly
/// minimal regardless of consumer behaviour.</para>
/// </summary>
PerChunk,
/// <summary>
/// Double-buffered — fire-and-forget the previous flush; block at the NEXT chunk's <c>Grow</c>
/// only if the previous flush hasn't completed yet. Allows two chunks in flight simultaneously:
/// the one being serialised + the one being flushed.
/// <para><b>Peak memory:</b> ~chunk_size × 2 (current + previous overlapping).</para>
/// <para><b>Pro:</b> maximum producer/flush parallelism with bounded memory; serializer
/// continues with the next chunk while the previous one's flush completes in parallel.
/// Wall-clock = max(serialize, flush) × N_chunks instead of sum.</para>
/// <para><b>Con:</b> a slow consumer propagates back as producer-thread blocking at the next
/// <c>Grow</c> (bounded by <c>flushTimeout</c>).</para>
/// <para>Recommended <b>default</b> for typical streaming scenarios — the best memory/throughput
/// trade-off when the payload is sized comparably to or larger than chunk_size.</para>
/// </summary>
DoubleBuffered,
/// <summary>
/// Coalesced — producer never awaits per-chunk flushes. The underlying <c>Pipe</c> coalesces
/// flushes adaptively up to its <c>PauseWriterThreshold</c> (~64 KB committed bytes by default).
/// Producer continues serializing in parallel with the consumer's drain; if the consumer
/// catches up earlier, the pipe flushes sooner and the producer keeps going without waiting.
/// <para><b>Peak memory:</b> grows up to <c>PauseWriterThreshold</c> (~64 KB) under slow
/// consumer; close to chunk_size × 2 under fast consumer.</para>
/// <para><b>Pro:</b> highest throughput on bounded payloads; never blocks for fast consumers;
/// pipe-managed adaptive backpressure kicks in only when actually needed.</para>
/// <para><b>Con:</b> peak memory unbounded by chunk_size — grows to PauseWriterThreshold under
/// slow-consumer conditions; under heavy backpressure may fall back to an owned buffer
/// (losing zero-copy for that chunk).</para>
/// <para>Recommended when payload size is known and bounded (REST request/response, fixed-size
/// IPC message), and the consumer is reliably fast.</para>
/// </summary>
Coalesced
}

View File

@ -146,7 +146,7 @@ The multi-message wire format (`AsyncPipeWriterOutput` + `AsyncPipeReaderInput`
### ACCORE-BIN-I-T6V2: Single fixed type per long-lived stream ### ACCORE-BIN-I-T6V2: Single fixed type per long-lived stream
**Status:** Open (intentional limit) **Status:** Open (out of framework scope — consumer responsibility per CLAUDE.md Rule #7)
**Affects:** `AcBinaryDeserializer.Deserialize<T>(AsyncPipeReaderInput input, AcBinarySerializerOptions options)` **Affects:** `AcBinaryDeserializer.Deserialize<T>(AsyncPipeReaderInput input, AcBinarySerializerOptions options)`
**Reach:** any consumer that wants a long-lived `AsyncPipeReaderInput` to receive **mixed-type messages** (e.g. `Request` then `Response` then `Heartbeat` on the same connection — the typical RPC/Hub pattern). **Reach:** any consumer that wants a long-lived `AsyncPipeReaderInput` to receive **mixed-type messages** (e.g. `Request` then `Response` then `Heartbeat` on the same connection — the typical RPC/Hub pattern).
@ -154,10 +154,20 @@ The multi-message wire format (`AsyncPipeWriterOutput` + `AsyncPipeReaderInput`
**Root cause:** AcBinary's wire format does **not** include a type-discriminator before the payload. The serializer writes the object graph directly, and the deserializer must know the target type up-front. This is by design — the format is optimized for size, not for self-description. **Root cause:** AcBinary's wire format does **not** include a type-discriminator before the payload. The serializer writes the object graph directly, and the deserializer must know the target type up-front. This is by design — the format is optimized for size, not for self-description.
**Why intentional:** Type discriminators (4-byte hash, length-prefixed type-name string, etc.) cost wire bytes per message and require shared registries between producer and consumer. The framework keeps these concerns out of the AcBinary core and pushes them to the **dispatch layer** above (where they can be application-tuned: short tags, hash maps, type-id enums). **Why this stays out of scope (firm framework doctrine):**
**Workarounds:** The framework explicitly does NOT provide type-dispatch infrastructure (no wire-format type-id, no registry, no dispatcher, no handshake). This aligns with:
- **Tag-based dispatch above AcBinary**: prefix each message with an `int` (or enum) tag the consumer reads first to choose `Deserialize<TypeForTag>`. The consumer encapsulates the tag-read + type-dispatch in its own deserialization wrapper.
1. **CLAUDE.md Rule #7***"Tag-based transport... Request types are conventionally identified by `int` tags"* — dispatch is the consumer's concern, not the serializer's.
2. **Framework-First Design Principle***"Generic (reusable across any consumer)? → belongs HERE. Consumer-specific (business logic, ...)? → NOT HERE"* — type-dispatch policy (which types are registered, which handler runs for them) is inherently consumer-aware.
3. **Industry precedent** — Protobuf, MessagePack, MemoryPack, System.Text.Json, Cap'n Proto all keep their core as a pure (T ↔ bytes) serializer; type-dispatch sits in the consumer/RPC layer above (gRPC service methods, MagicOnion handlers, ASP.NET routing, SignalR `IInvocationBinder`, etc.).
4. **Existing planned Layer-1 hosts** (`AyCode.Services.SignalRs` and the planned `AyCode.Core.AspNetCore` formatter package — see [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-a8r5`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-a8r5-aspnet-core-mvc-formatters-acbinaryinputformatter--acbinaryoutputformatter)) **do not need** wire-format type-id — they get type info from their respective dispatch infrastructure (`IInvocationBinder.GetParameterTypes()`, HTTP routing → controller `[FromBody] T`).
5. **No third Layer-1 host is planned** — if a future consumer needs raw-IPC type dispatch, they build their own using one of the workarounds below. Speculative framework-side infrastructure for hypothetical consumers would violate the layer-purity doctrine.
This is a permanent architectural decision, not a "TODO not yet done." Do not file new TODOs proposing wire-format type-id, registry, or session/handshake infrastructure inside `AyCode.Core`.
**Workarounds (canonical patterns the consumer implements):**
- **Tag-based dispatch above AcBinary** (recommended — matches CLAUDE.md Rule #7): prefix each message with an `int` (or enum) tag the consumer reads first to choose `Deserialize<TypeForTag>`. The consumer encapsulates the tag-read + type-dispatch in its own deserialization wrapper. Reference example: `AyCode.Services.Server/SignalRs/AcSignalRDataSource` uses `SignalRCrudTags`.
- **Polymorphic envelope type**: define a single `T = Envelope` containing a discriminator field + raw payload bytes; the consumer deserializes the envelope, switches on the discriminator, and re-deserializes the payload as the concrete type from the inner `byte[]`. Adds a small layer of indirection but works on top of fix-T `Deserialize<Envelope>`. - **Polymorphic envelope type**: define a single `T = Envelope` containing a discriminator field + raw payload bytes; the consumer deserializes the envelope, switches on the discriminator, and re-deserializes the payload as the concrete type from the inner `byte[]`. Adds a small layer of indirection but works on top of fix-T `Deserialize<Envelope>`.
- **One input per type-stream**: separate streams per message-class. Practical when the type-set is small and the transport can afford multiple connections. - **One input per type-stream**: separate streams per message-class. Practical when the type-set is small and the transport can afford multiple connections.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -113,16 +113,19 @@ Each chunk has a 3-byte header reserved via **header reservation** (skip 3 bytes
### Backpressure Modes ### Backpressure Modes
Constructor parameter `waitForFlush` (default `true`): Constructor parameter `flushPolicy` of type `FlushPolicy` (default `FlushPolicy.DoubleBuffered`):
- **`waitForFlush=true`**: `Grow()` blocks if previous `FlushAsync` is still in-flight. Max ~2 chunks in memory. - **`FlushPolicy.PerChunk`**: `Grow()` commits → flushes → awaits → acquires next chunk. Strictly bounded peak memory (~chunk_size × 1). No producer/flush parallelism — wall-clock = sum of (serialize + flush) per chunk. Auto-applied on Stream-backed PipeWriter regardless of policy. Recommended for memory-sensitive scenarios where payload size is unpredictable.
- **`waitForFlush=false`**: `Grow()` never blocks. Data accumulates in PipeWriter's internal buffer and is sent with the next completed flush. Maximum serialization throughput. - **`FlushPolicy.DoubleBuffered`** (default): `Grow()` is fire-and-forget for the previous flush; only blocks at the NEXT chunk's `Grow` if the previous flush hasn't completed. Peak memory ~chunk_size × 2 (current + previous overlapping). Maximum producer/flush parallelism with bounded memory — wall-clock = max(serialize, flush) × N_chunks. The recommended balanced default for typical streaming.
- **`FlushPolicy.Coalesced`**: `Grow()` does not wait per-chunk. The underlying Pipe coalesces flushes adaptively up to its `PauseWriterThreshold` (~64 KB committed bytes). Highest serialization throughput on bounded payloads; memory grows to `PauseWriterThreshold` under slow-consumer conditions.
In both modes, flush is only initiated when `_lastFlush.IsCompleted` — no overlapping FlushAsync calls. In all three modes, flush is only initiated when `_lastFlush.IsCompleted` — no overlapping FlushAsync calls.
> **Migration note**: `FlushPolicy` replaces the historical `bool waitForFlush` parameter. Mapping: old `true``FlushPolicy.DoubleBuffered`, old `false``FlushPolicy.Coalesced`. The new `FlushPolicy.PerChunk` value is a NEW capability that previously was only auto-applied on Stream-backed PipeWriter; it can now be explicitly chosen on Pipe-based writers for strictly bounded peak memory.
### Two parallel-flush regimes (auto-detected) ### Two parallel-flush regimes (auto-detected)
Runtime check `pipeWriter.GetType()` splits flush behavior into two regimes — auto-detected at ctor via `_serializeFlushAndAcquire = StreamPipeWriterType.IsInstanceOfType(pipeWriter)`. No caller intervention. Orthogonal to `waitForFlush` and to the wire-format mode choice (`Bytes` / `Segment` / `AsyncSegment`). Runtime check `pipeWriter.GetType()` splits flush behavior into two regimes — auto-detected at ctor via `_serializeFlushAndAcquire = StreamPipeWriterType.IsInstanceOfType(pipeWriter)`. No caller intervention. Orthogonal to `FlushPolicy` and to the wire-format mode choice (`Bytes` / `Segment` / `AsyncSegment`).
**True parallel** — Pipe-based / parallel-capable PipeWriters: `new Pipe().Writer`, Kestrel transport output, custom parallel-capable impls. `Grow()` uses `FlushAsync().Forget()` pattern: serializer continues with the next chunk while the network async-flushes the previous one. Round-trip wall-clock = `max(serialize, flush) × N_chunks` — flush hides behind serialize-time. Production-stable on SignalR / Kestrel; "minimally slower than raw byte[]" empirically. **True parallel** — Pipe-based / parallel-capable PipeWriters: `new Pipe().Writer`, Kestrel transport output, custom parallel-capable impls. `Grow()` uses `FlushAsync().Forget()` pattern: serializer continues with the next chunk while the network async-flushes the previous one. Round-trip wall-clock = `max(serialize, flush) × N_chunks` — flush hides behind serialize-time. Production-stable on SignalR / Kestrel; "minimally slower than raw byte[]" empirically.

View File

@ -77,9 +77,9 @@ public class AcBinaryHubProtocol : IHubProtocol
protected readonly ILogger? _logger; protected readonly ILogger? _logger;
/// <summary> /// <summary>
/// AsyncSegment per-chunk flush synchronization — see <see cref="AcBinaryHubProtocolOptions.WaitForFlush"/>. /// AsyncSegment per-chunk flush synchronization — see <see cref="AcBinaryHubProtocolOptions.FlushPolicy"/>.
/// </summary> /// </summary>
protected readonly bool _waitForFlush; protected readonly FlushPolicy _flushPolicy;
/// <summary> /// <summary>
/// Per-flush wait limit — see <see cref="AcBinaryHubProtocolOptions.FlushTimeout"/>. /// Per-flush wait limit — see <see cref="AcBinaryHubProtocolOptions.FlushTimeout"/>.
@ -142,7 +142,7 @@ public class AcBinaryHubProtocol : IHubProtocol
_protocolMode = options.ProtocolMode; _protocolMode = options.ProtocolMode;
_logger = options.Logger; _logger = options.Logger;
_waitForFlush = options.WaitForFlush; _flushPolicy = options.FlushPolicy;
_flushTimeout = options.FlushTimeout; _flushTimeout = options.FlushTimeout;
Name = options.Name; Name = options.Name;
@ -150,9 +150,9 @@ public class AcBinaryHubProtocol : IHubProtocol
_chunkStates = new ConditionalWeakTable<IInvocationBinder, AsyncChunkState>(); _chunkStates = new ConditionalWeakTable<IInvocationBinder, AsyncChunkState>();
_logger?.LogInformation( _logger?.LogInformation(
"AcBinaryHubProtocol initialized name={Name} mode={ProtocolMode} isBrowser={IsBrowser} chunkSize={ChunkSize} initCap={InitCap} waitForFlush={WaitForFlush} flushTimeoutMs={FlushTimeoutMs} useGen={UseGen} wireMode={WireMode} interning={Interning} compression={Compression}", "AcBinaryHubProtocol initialized name={Name} mode={ProtocolMode} isBrowser={IsBrowser} chunkSize={ChunkSize} initCap={InitCap} flushPolicy={FlushPolicy} flushTimeoutMs={FlushTimeoutMs} useGen={UseGen} wireMode={WireMode} interning={Interning} compression={Compression}",
Name, _protocolMode, IsBrowser, _options.BufferWriterChunkSize, _options.InitialBufferCapacity, Name, _protocolMode, IsBrowser, _options.BufferWriterChunkSize, _options.InitialBufferCapacity,
_waitForFlush, _flushTimeout.TotalMilliseconds, _flushPolicy, _flushTimeout.TotalMilliseconds,
_options.UseGeneratedCode, _options.WireMode, _options.UseStringInterning, _options.UseCompression); _options.UseGeneratedCode, _options.WireMode, _options.UseStringInterning, _options.UseCompression);
} }
@ -509,7 +509,7 @@ public class AcBinaryHubProtocol : IHubProtocol
// --- CHUNK_DATA ([201][UINT16 size][data] per chunk, all committed by output) --- // --- CHUNK_DATA ([201][UINT16 size][data] per chunk, all committed by output) ---
if (streamedArg != null) if (streamedArg != null)
{ {
dataBytes = AcBinarySerializer.Serialize(streamedArg, pipeWriter, _options, _waitForFlush, _flushTimeout); dataBytes = AcBinarySerializer.Serialize(streamedArg, pipeWriter, _options, _flushPolicy, _flushTimeout);
_logger?.LogDebug("WriteMessageChunked CHUNK_DATA serialized dataBytes={DataBytes}", dataBytes); _logger?.LogDebug("WriteMessageChunked CHUNK_DATA serialized dataBytes={DataBytes}", dataBytes);
} }

View File

@ -27,17 +27,20 @@ public sealed class AcBinaryHubProtocolOptions
/// <summary> /// <summary>
/// Per-chunk flush synchronization in <see cref="BinaryProtocolMode.AsyncSegment"/> mode. /// Per-chunk flush synchronization in <see cref="BinaryProtocolMode.AsyncSegment"/> mode.
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>true</c> (default): every Grow waits for the previous FlushAsync to complete /// <item><see cref="FlushPolicy.PerChunk"/>: commit → flush → await each chunk before
/// before starting the next chunk. Guarantees end-to-end zero-copy (no owned-buffer fallback) /// acquiring the next. Strictly bounded peak memory (~chunk × 1). No producer/flush
/// and maximum pipeline parallelism. Best for high-throughput servers with fast consumers.</item> /// parallelism. Best for memory-sensitive servers and unpredictable payload sizes.</item>
/// <item><c>false</c>: fire-and-forget flush per chunk; blocks only when committed bytes exceed /// <item><see cref="FlushPolicy.DoubleBuffered"/>: fire-and-forget previous flush; next chunk
/// the memory threshold (~60 KB). Itself an adaptive backpressure mode — a fast consumer /// waits only if previous flush hasn't completed. Peak memory ~chunk × 2 with maximum
/// never triggers a wait, a slow consumer naturally throttles through buffer pressure. /// producer/flush parallelism — the recommended balanced choice for typical streaming.</item>
/// Safer for mixed consumer speeds and memory-sensitive environments.</item> /// <item><see cref="FlushPolicy.Coalesced"/> (default for SignalR): no per-chunk wait; the
/// underlying Pipe coalesces flushes adaptively up to its PauseWriterThreshold (~64 KB).
/// Highest throughput on bounded payloads — fast consumer never triggers a wait, slow
/// consumer naturally throttles through buffer pressure.</item>
/// </list> /// </list>
/// Ignored for Bytes and Segment modes. /// Ignored for Bytes and Segment modes.
/// </summary> /// </summary>
public bool WaitForFlush { get; set; } = false; public FlushPolicy FlushPolicy { get; set; } = FlushPolicy.Coalesced;
/// <summary> /// <summary>
/// Maximum wait for a single synchronous <c>FlushAsync</c> before throwing /// Maximum wait for a single synchronous <c>FlushAsync</c> before throwing
@ -106,7 +109,7 @@ public sealed class AcBinaryHubProtocolOptions
SerializerOptions = SerializerOptions, SerializerOptions = SerializerOptions,
ProtocolMode = ProtocolMode, ProtocolMode = ProtocolMode,
BufferSize = BufferSize, BufferSize = BufferSize,
WaitForFlush = WaitForFlush, FlushPolicy = FlushPolicy,
FlushTimeout = FlushTimeout, FlushTimeout = FlushTimeout,
Name = Name, Name = Name,
Logger = Logger Logger = Logger

View File

@ -79,7 +79,7 @@ Current `PluginNopStartup.ConfigureServices`:
``` ```
What's missing: What's missing:
- No `services.Configure<AcBinaryHubProtocolOptions>(configuration.GetSection("AyCode:SignalR:Protocol"))``ProtocolMode`, `BufferSize`, `WaitForFlush`, `FlushTimeout` are all hardcoded / default. - No `services.Configure<AcBinaryHubProtocolOptions>(configuration.GetSection("AyCode:SignalR:Protocol"))``ProtocolMode`, `BufferSize`, `FlushPolicy`, `FlushTimeout` are all hardcoded / default.
- The `appsettings.json` has no `AyCode:SignalR` (or equivalent) section at all — so per-deploy tuning (e.g. increasing `FlushTimeout` for a satellite link, switching `ProtocolMode` for diagnostics) requires a code change + redeploy. - The `appsettings.json` has no `AyCode:SignalR` (or equivalent) section at all — so per-deploy tuning (e.g. increasing `FlushTimeout` for a satellite link, switching `ProtocolMode` for diagnostics) requires a code change + redeploy.
- Manual `new Logger(...)` sidesteps the DI `ILogger<AcBinaryHubProtocol>` auto-resolution that `BuildProtocol` provides → creates a parallel logger instance (see `../../../AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md#accore-log-i-m4c9`). - Manual `new Logger(...)` sidesteps the DI `ILogger<AcBinaryHubProtocol>` auto-resolution that `BuildProtocol` provides → creates a parallel logger instance (see `../../../AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md#accore-log-i-m4c9`).

View File

@ -56,7 +56,7 @@ services.AddSignalR(hubOptions => { /* unchanged */ })
"Protocol": { "Protocol": {
"ProtocolMode": "AsyncSegment", "ProtocolMode": "AsyncSegment",
"BufferSize": 4096, "BufferSize": 4096,
"WaitForFlush": true, "FlushPolicy": "PerChunk",
"FlushTimeout": "00:00:10" "FlushTimeout": "00:00:10"
} }
} }

View File

@ -159,19 +159,20 @@ Hub protocol settings via **`AcBinaryHubProtocolOptions`** (mutable class). Pass
| `SerializerOptions` | `AcBinarySerializerOptions.Default` | Binary serializer options (also usable standalone via `ToBinary`/`BinaryTo`). | | `SerializerOptions` | `AcBinarySerializerOptions.Default` | Binary serializer options (also usable standalone via `ToBinary`/`BinaryTo`). |
| `ProtocolMode` | `Bytes` | Wire format and pipeline strategy — see **BinaryProtocolMode** below. | | `ProtocolMode` | `Bytes` | Wire format and pipeline strategy — see **BinaryProtocolMode** below. |
| `BufferSize` | 4096 | Per-chunk size. 4 KB aligns with Kestrel's slab. Max 65535 (UINT16). | | `BufferSize` | 4096 | Per-chunk size. 4 KB aligns with Kestrel's slab. Max 65535 (UINT16). |
| `WaitForFlush` | `true` | AsyncSegment flush strategy — see trade-off below. | | `FlushPolicy` | `Coalesced` | AsyncSegment flush strategy — see trade-off below. |
| `FlushTimeout` | 10 s | Per-flush wait limit. `Timeout.InfiniteTimeSpan` = disabled. | | `FlushTimeout` | 10 s | Per-flush wait limit. `Timeout.InfiniteTimeSpan` = disabled. |
| `Name` | `"acbinary"` | SignalR handshake protocol name. Client and server must match. | | `Name` | `"acbinary"` | SignalR handshake protocol name. Client and server must match. |
| `Logger` | `null` | Optional `ILogger`; injected from DI when registered. | | `Logger` | `null` | Optional `ILogger`; injected from DI when registered. |
Inner `AcBinarySerializerOptions` defaults relevant for SignalR: `UseGeneratedCode=true` (hybrid source-gen + reflection), `UseStringInterning=All`, `InitialBufferCapacity=16384`. Inner `AcBinarySerializerOptions` defaults relevant for SignalR: `UseGeneratedCode=true` (hybrid source-gen + reflection), `UseStringInterning=All`, `InitialBufferCapacity=16384`.
### `WaitForFlush` (AsyncSegment-only) ### `FlushPolicy` (AsyncSegment-only)
| Value | Pro | Con | | Value | Peak memory | Pro | Con |
|-------|-----|-----| |-------|------------|-----|-----|
| **`true`** (default) | Max pipeline parallelism + guaranteed end-to-end zero-copy on send. | Slow consumer propagates back as server-thread blocking (bounded by `FlushTimeout`). | | **`FlushPolicy.PerChunk`** | ~chunk × 1 | Strictly bounded peak memory regardless of consumer speed; guaranteed end-to-end zero-copy. | No producer/flush parallelism — wall-clock = sum of (serialize + flush) per chunk. |
| **`false`** | Adaptive — fire-and-forget per chunk, blocks only when ~60 KB memory threshold is hit. | Under heavy backpressure a chunk may fall back to an owned (copied) buffer, losing zero-copy for that chunk. | | **`FlushPolicy.DoubleBuffered`** | ~chunk × 2 | Maximum producer/flush parallelism with bounded memory — wall-clock = max(serialize, flush) × N_chunks. Recommended for balanced streaming. | Slow consumer propagates back as server-thread blocking at next `Grow` (bounded by `FlushTimeout`). |
| **`FlushPolicy.Coalesced`** (default) | up to ~64 KB | Highest throughput on bounded payloads — pipe coalesces flushes up to `PauseWriterThreshold`. Never blocks on fast consumers. | Peak memory grows to `PauseWriterThreshold` under slow consumer; under heavy backpressure a chunk may fall back to an owned (copied) buffer, losing zero-copy for that chunk. |
### `FlushTimeout` rationale (10 s default) ### `FlushTimeout` rationale (10 s default)

View File

@ -46,7 +46,7 @@ Alternative to wire-detection: use SignalR handshake message's `extensions` JSON
Zero first-message overhead, fully explicit. Both sides advertise their send-modes; pick intersection. Specification to be drafted; compatibility with non-AC clients (pure JSON etc.) must remain. Zero first-message overhead, fully explicit. Both sides advertise their send-modes; pick intersection. Specification to be drafted; compatibility with non-AC clients (pure JSON etc.) must remain.
## ACCORE-SBP-T-G7T2: Migrate `AcBinaryHubProtocol.TryParseChunkData` to `AsyncPipeReaderInput`; delete `SegmentBufferReader` + `SegmentBufferReaderInput` (Step 6 of ADR-0003) ## ACCORE-SBP-T-G7T2: Migrate `AcBinaryHubProtocol.TryParseChunkData` to `AsyncPipeReaderInput`; delete `SegmentBufferReader` + `SegmentBufferReaderInput` (Step 6 of ADR-0003)
**Priority:** P1 · **Type:** Refactor · **Related ADR:** [`docs/adr/0003-acbinary-streaming-receive-architecture.md`](../../../docs/adr/0003-acbinary-streaming-receive-architecture.md) Step 6 · **Depends on:** `ACCORE-BIN-T-B5Y6` (and all earlier BIN steps) **Priority:** P1 · **Type:** Refactor · **Related ADR:** [`docs/adr/0003-acbinary-streaming-receive-architecture.md`](../../../docs/adr/0003-acbinary-streaming-receive-architecture.md) Step 6 · **Depends on:** `ACCORE-BIN-T-V7C9` (last completed step in the BIN streaming-framework chain — Steps 4 & 5 NamedPipe / FileStream helpers were dropped; framework stays transport-agnostic)
Switch `AcBinaryHubProtocol.TryParseChunkData` from `SegmentBufferReader.Write(span)` to `AsyncPipeReaderInput.Feed(span)`. Update `AsyncChunkState` field type from `SegmentBufferReader Buffer` to `AsyncPipeReaderInput Input`. Lazy `Task.Run` deser-task start (after first chunk), `CHUNK_END` lifecycle (`Complete()` + `Dispose()` + `_chunkStates.Remove`), and the WASM synchronous-deser path all preserved. Wire format unchanged. Switch `AcBinaryHubProtocol.TryParseChunkData` from `SegmentBufferReader.Write(span)` to `AsyncPipeReaderInput.Feed(span)`. Update `AsyncChunkState` field type from `SegmentBufferReader Buffer` to `AsyncPipeReaderInput Input`. Lazy `Task.Run` deser-task start (after first chunk), `CHUNK_END` lifecycle (`Complete()` + `Dispose()` + `_chunkStates.Remove`), and the WASM synchronous-deser path all preserved. Wire format unchanged.