[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 IoNamedPipeRaw = "NamedPipe";
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,
// 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
// instead of kernel NamedPipe). Per-chunk SerializeChunkedFramed → PipeWriter slab → drain task
// 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();
_consumeRequest.Set();
AcBinarySerializer.SerializeChunkedFramed(_order, _pipeWriter, _options);
AcBinarySerializer.SerializeChunkedFramed(_order, _pipe, _options, FlushPolicy.Coalesced);
_consumeDone.Wait();
}

View File

@ -7,8 +7,8 @@ using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Cross-platform NamedPipe IPC roundtrip tests for AcBinarySerializer's transport-agnostic
/// streaming helpers (Step 4 of ADR-0003, ACCORE-BIN-T-A3T8).
/// Cross-platform NamedPipe IPC roundtrip tests proving AcBinarySerializer's streaming framework
/// 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 —
/// 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="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
/// primitives, with the receive-side drain implemented via the test-only
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension. This proves the streaming
/// framework works on arbitrary <c>PipeWriter</c>/<c>PipeReader</c> sources (NamedPipe, FileStream,
/// NetworkStream, custom transports) without per-transport adapters in the framework.</para>
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension. The same generic
/// primitives apply to FileStream / NetworkStream / custom transports — consumers own the
/// transport lifecycle, framework stays transport-agnostic.</para>
///
/// <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>

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>
/// <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
/// <paramref name="waitForFlush"/> + <paramref name="flushTimeout"/> tuning safely.</para>
/// <paramref name="flushPolicy"/> + <paramref name="flushTimeout"/> tuning safely.</para>
/// </summary>
/// <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="options">Serializer options (type wrappers, reference handling, interning, etc.).</param>
/// <param name="waitForFlush">
/// Per-chunk flush synchronization. <c>true</c> (default): maximum pipeline parallelism,
/// guaranteed zero-copy + zero-alloc, but slow consumers block the producer thread (bounded by
/// <paramref name="flushTimeout"/>). <c>false</c>: adaptive backpressure via memory threshold
/// (~64KB in-flight) — safer for mixed consumer speeds, never blocks on slow consumers.
/// <param name="flushPolicy">
/// Per-chunk flush synchronization — see <see cref="FlushPolicy"/> for the three trade-off
/// points. <see cref="FlushPolicy.PerChunk"/>: strictly bounded ~chunk × 1 peak memory, no
/// producer/flush parallelism. <see cref="FlushPolicy.DoubleBuffered"/> (default): ~chunk × 2
/// peak memory, max producer/flush parallelism. <see cref="FlushPolicy.Coalesced"/>: up to
/// PauseWriterThreshold (~64 KB), highest throughput on bounded payloads.
/// </param>
/// <param name="flushTimeout">
/// Per-flush timeout. <c>null</c> → wait forever. Positive value: throws
/// <see cref="TimeoutException"/> on stuck consumers.
/// </param>
/// <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));
return SerializeToPipeWriterCore(value, pipe.Writer, options, waitForFlush, flushTimeout, multiMessage: false);
return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: false);
}
/// <summary>
@ -465,11 +466,11 @@ public static partial class AcBinarySerializer
/// (<c>PipeWriter.Create(stream)</c> — NamedPipe / FileStream / NetworkStream / etc.) runs
/// sequentially per chunk because the BCL impl resets <c>_tailMemory</c> on flush completion
/// (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
/// <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>
///
/// <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>
/// <returns>Total serialized bytes written.</returns>
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>
/// 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>
///
/// <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
/// <c>byte[]</c> output, no extra parser needed on the receive side.</para>
/// </summary>
/// <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="options">Serializer options.</param>
/// <param name="waitForFlush">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, 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, FlushPolicy, TimeSpan?)"/>.</param>
/// <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));
return SerializeToPipeWriterCore(value, pipe.Writer, options, waitForFlush, flushTimeout, multiMessage: true);
return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: true);
}
/// <summary>
/// Serialize to any <see cref="System.IO.Pipelines.PipeWriter"/> with per-chunk frame headers
/// (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.
///
/// <para><b>Flush strategy auto-selected by writer type</b> — see
/// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.</para>
/// </summary>
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>
/// Internal flush-tunable framed PipeWriter overload — used by <c>AyCode.Services</c>
/// (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
/// <paramref name="waitForFlush"/> on a guaranteed parallel-capable writer.
/// <paramref name="flushPolicy"/> on a guaranteed parallel-capable writer.
/// </summary>
internal static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, bool waitForFlush, TimeSpan? flushTimeout)
=> SerializeToPipeWriterCore(value, pipeWriter, options, waitForFlush, flushTimeout, multiMessage: true);
internal static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout)
=> SerializeToPipeWriterCore(value, pipeWriter, options, flushPolicy, flushTimeout, multiMessage: true);
/// <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
/// 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).
/// </summary>
internal static int Serialize<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, bool waitForFlush, TimeSpan? flushTimeout)
=> SerializeToPipeWriterCore(value, pipeWriter, options, waitForFlush, flushTimeout, multiMessage: true);
internal static int Serialize<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout)
=> SerializeToPipeWriterCore(value, pipeWriter, options, flushPolicy, flushTimeout, multiMessage: true);
/// <summary>
/// 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
/// <paramref name="multiMessage"/> into the <see cref="AsyncPipeWriterOutput"/> ctor.
/// </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)
{
@ -564,7 +565,7 @@ public static partial class AcBinarySerializer
var runtimeType = value.GetType();
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);
try

View File

@ -30,16 +30,21 @@ namespace AyCode.Core.Serializers.Binaries;
/// and any custom multi-message protocol over a long-lived transport.</item>
/// </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">
/// <item><c>waitForFlush=true</c> (default): Grow() waits for the previous FlushAsync before
/// starting a new chunk. <b>Pro:</b> maximum pipeline parallelism, guaranteed end-to-end zero-copy.
/// <b>Con:</b> slow consumer propagates back as server-thread blocking (bounded by <c>flushTimeout</c>).</item>
/// <item><c>waitForFlush=false</c>: Grow() is fire-and-forget per chunk; only blocks when committed
/// bytes exceed ~60 KB (memory threshold — itself an adaptive backpressure).
/// <b>Pro:</b> no per-chunk waits, safer with mixed consumer speeds.
/// <b>Con:</b> under heavy backpressure may fall back to an owned buffer, losing zero-copy
/// for that chunk.</item>
/// <item><see cref="FlushPolicy.PerChunk"/>: Grow() commits → flushes → awaits → acquires next.
/// <b>Peak memory:</b> ~chunk_size × 1. <b>Pro:</b> strictly bounded peak memory.
/// <b>Con:</b> no producer/flush parallelism. Auto-applied on Stream-backed PipeWriter
/// regardless of chosen policy.</item>
/// <item><see cref="FlushPolicy.DoubleBuffered"/> (default): fire-and-forget previous flush; next
/// Grow waits only if previous flush hasn't completed. <b>Peak memory:</b> ~chunk_size × 2.
/// <b>Pro:</b> max producer/flush parallelism with bounded memory.
/// <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>
///
/// <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 int _chunkSize;
private readonly bool _multiMessage;
private readonly bool _waitForFlush;
private readonly FlushPolicy _flushPolicy;
private readonly bool _serializeFlushAndAcquire;
private readonly TimeSpan _flushTimeout;
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>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>
/// <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"/>
/// (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)
throw new ArgumentOutOfRangeException(nameof(chunkSize), chunkSize, $"Chunk size cannot exceed {MaxChunkSize} (UINT16 max).");
@ -155,7 +162,7 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
_pipeWriter = pipeWriter;
_chunkSize = chunkSize;
_multiMessage = multiMessage;
_waitForFlush = waitForFlush;
_flushPolicy = flushPolicy;
// 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.
@ -222,29 +229,30 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
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.
// Stream-backed writers (NamedPipe / FileStream / NetworkStream) reset internal
// state (_tailMemory) at flush completion → cannot acquire-during-flush concurrently
// (the standard Stream-PipeWriter usage pattern is await-flush-before-next-write).
// waitForFlush / _committedBytes throttling don't apply here — the writer pattern
// enforces sequentiality intrinsically.
// SEQUENTIAL path — fully synchronous per chunk: commit → flush → await → acquire.
// Triggered by either:
// - Stream-backed PipeWriter (NamedPipe / FileStream / NetworkStream / etc.) — auto,
// forced by the BCL StreamPipeWriter._tailMemory reset race.
// - FlushPolicy.PerChunk on a Pipe-based writer — explicit caller choice for
// strictly bounded peak memory (~chunk_size × 1).
// _committedBytes throttling doesn't apply — sequentiality is enforced per chunk.
CommitCurrentChunk(buffer, position);
SyncAwaitFlush(_pipeWriter.FlushAsync());
}
else
{
// PIPE-BASED path (Kestrel / SignalR) — parallel sender: serializer writes the next
// chunk into the PipeWriter's buffer concurrently with the background FlushAsync.
// waitForFlush=true: backpressure — wait for the previous parallel flush before
// starting a new one (prevents unbounded in-flight flushes).
// waitForFlush=false: adaptive — skip the wait, but force-await if _committedBytes
// PARALLEL paths (Pipe-based writer + DoubleBuffered or Coalesced) — serializer writes
// the next chunk into the PipeWriter's buffer concurrently with the background FlushAsync.
// FlushPolicy.DoubleBuffered: wait at next Grow if the previous parallel flush hasn't
// completed yet → max two chunks in flight (~chunk_size × 2 peak memory).
// FlushPolicy.Coalesced: skip the per-chunk wait; only block when _committedBytes
// 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
// is still in progress (waitForFlush=false skip path).
if ((_waitForFlush && !_lastFlush.IsCompleted) || _committedBytes > MaxChunkSize - _chunkSize) SyncAwaitFlush(_lastFlush);
// is still in progress (Coalesced skip path).
if ((_flushPolicy == FlushPolicy.DoubleBuffered && !_lastFlush.IsCompleted) || _committedBytes > MaxChunkSize - _chunkSize) SyncAwaitFlush(_lastFlush);
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
**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)`
**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.
**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:**
- **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.
The framework explicitly does NOT provide type-dispatch infrastructure (no wire-format type-id, no registry, no dispatcher, no handshake). This aligns with:
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>`.
- **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
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.
- **`waitForFlush=false`**: `Grow()` never blocks. Data accumulates in PipeWriter's internal buffer and is sent with the next completed flush. Maximum serialization throughput.
- **`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.
- **`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)
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.

View File

@ -77,9 +77,9 @@ public class AcBinaryHubProtocol : IHubProtocol
protected readonly ILogger? _logger;
/// <summary>
/// AsyncSegment per-chunk flush synchronization — see <see cref="AcBinaryHubProtocolOptions.WaitForFlush"/>.
/// AsyncSegment per-chunk flush synchronization — see <see cref="AcBinaryHubProtocolOptions.FlushPolicy"/>.
/// </summary>
protected readonly bool _waitForFlush;
protected readonly FlushPolicy _flushPolicy;
/// <summary>
/// Per-flush wait limit — see <see cref="AcBinaryHubProtocolOptions.FlushTimeout"/>.
@ -142,7 +142,7 @@ public class AcBinaryHubProtocol : IHubProtocol
_protocolMode = options.ProtocolMode;
_logger = options.Logger;
_waitForFlush = options.WaitForFlush;
_flushPolicy = options.FlushPolicy;
_flushTimeout = options.FlushTimeout;
Name = options.Name;
@ -150,9 +150,9 @@ public class AcBinaryHubProtocol : IHubProtocol
_chunkStates = new ConditionalWeakTable<IInvocationBinder, AsyncChunkState>();
_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,
_waitForFlush, _flushTimeout.TotalMilliseconds,
_flushPolicy, _flushTimeout.TotalMilliseconds,
_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) ---
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);
}

View File

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

View File

@ -79,7 +79,7 @@ Current `PluginNopStartup.ConfigureServices`:
```
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.
- 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": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"WaitForFlush": true,
"FlushPolicy": "PerChunk",
"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`). |
| `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). |
| `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. |
| `Name` | `"acbinary"` | SignalR handshake protocol name. Client and server must match. |
| `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`.
### `WaitForFlush` (AsyncSegment-only)
### `FlushPolicy` (AsyncSegment-only)
| Value | 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`). |
| **`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. |
| Value | Peak memory | Pro | Con |
|-------|------------|-----|-----|
| **`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. |
| **`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)

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.
## 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.