Skip to content

Release v6.0.0 — concurrency model, autoscale algorithm, and public API redesign - #38

Merged
FRACerqueira merged 111 commits into
mainfrom
develop
Aug 26, 2026
Merged

Release v6.0.0 — concurrency model, autoscale algorithm, and public API redesign#38
FRACerqueira merged 111 commits into
mainfrom
develop

Conversation

@FRACerqueira

Copy link
Copy Markdown
Owner

This merges the v6.0.0 release into main: 111 commits, 184 files changed (+11,002/−3,228), closed out by a 12-round pre-release audit across correctness, resilience, usability, complexity, performance, and observability.

Full details are in CHANGELOG.md §6.0.0 and doc/audits/v6.0.0-pre-release-audit.md — not restated here in full, only the highlights below.

Driving ADRs

  • ADR006 V02 — mandate for this second product overhaul
  • ADR001 V03 / ADR003 V03 — new concurrency model (Orchestrator/Creator/Removal/Monitor) and autoscale algorithm (percentile + trend, replacing median-of-idle-samples)
  • ADR007 V03 — public fluent API surface cleanup

Breaking changes (selected)

  • Scale-up/scale-down execution now runs off the engine's single-consumer thread — a hung factory call or item disposal no longer blocks other queued commands.
  • Autoscale trigger replaced: backlog-reactive demand signal instead of a raw fault count; algorithm itself replaced by the Monitor (percentile "fair level" + linear-regression trend), tunable via MonitorTuning(...).
  • AutoScaleAcquireFault / IRingBufferAutoScaleBuilder removed — floor guard, backlog signal, and Monitor are now unconditionally active on every elastic pool.
  • SwitchToAsync now requires an explicit pinDuration (no default).
  • BackgroundLogger removed — logging is always synchronous/inline now.
  • HeartBeat changed from Action<RingBufferValue> to Func<T, bool>.
  • WarmupRingBufferAsync removed — warmup now stered IHostedService.
  • ElasticCapacity parameter order/shape changed: (minCapacity, maxCapacity, target = minCapacity, ...).
    Post-release polish (not yet in a tagged release)
    Five commits landed after the v6.0.0 CHANGELOG entry (no v6.0.0 tag exists yet, so these ship together PR): a RingBufferManager split into Creator/Ravior-preserving, 194/194 tests greenbefore/after), a contract-test file split by area, comment/XML-doc trims, sample updates demonstrating MonitorTuning/OnError/maxConcurrentFactoryCalgins on two flaky-prone dispose tests. Nopublic surface change.

Notes

FRACerqueira and others added 30 commits August 20, 2026 11:41
…v5.0.0 audit

Closes all P0 blockers from the v5.0.0 product-viability audit
(TODO/relatorio-viabilidade-ringbufferplus-v5.md):

- The engine's command loop now survives a non-cancellation exception from
  the user's Factory or Dispose (a thrown exception used to fault the engine
  permanently, hanging every public async method forever).
- WarmupCoreAsync/SwitchToAsync no longer hang when their command loses its
  race against disposal and is abandoned unread in the command channel.
- RingBufferValue and RingBufferManager's dispose guards are now atomic
  (Interlocked), closing a race that could hand the same pooled instance to
  two concurrent callers.
- A HeartBeat callback that blocks past its pulse budget no longer stalls the
  heartbeat pump forever; the stranded item is invalidated and replaced.
- DisposeAsync now always drains pooled items and disposes its own
  instrumentation regardless of how a background pump ended, and a lease
  returned after disposal is now disposed instead of silently dropped.
- The README Quickstart and the DI guide no longer contradict the real API
  (an undeclared variable in the former, wrong injection/exception advice in
  the latter).

See TODO/plano-de-acao.md for the full decision log, red/green verification
record, and remaining P1/P2 backlog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Continues the v5.0.0 product-viability audit remediation
(TODO/relatorio-viabilidade-ringbufferplus-v5.md), closing the remaining
P1 findings and two of the three P2 escalated decisions:

- Scale metrics/traces now carry a success outcome (R8): a failed or
  timed-out scale attempt is no longer indistinguishable from a successful
  one in scale.operations/scale.duration or the RingBufferPlus.Scale span.
- The scale-up deadline now scales with the work requested
  (quantity x FactoryTimeout) instead of the unrelated sampling cadence, and
  a scale-up that cannot fully complete keeps the partial capacity it
  already gained instead of discarding it (R5) - recorded as its own
  decision in ADR010, since it changes observable scaling behavior.
- Autoscale-on-fault no longer gets stuck when initialCapacity ==
  minCapacity (R4), and its fault-count comparison now matches its own
  documentation (>= instead of >, closing U-07/R10 by fixing the code
  rather than the four doc sites that were already correct).
- CHANGELOG gained an old-to-new interface map and an explicit note that
  SwitchToAsync no longer exists on IRingBufferService<T> (U-08), and six
  ambiguous/incorrect documentation statements were corrected (U-03 to
  U-06, U-21).
- LockWhenScaling(bool) removed entirely from IRingBufferAutoScaleBuilder<T>
  - a documented no-op that had already misled this repo's own RabbitMQ
  sample - as an explicit exception to both the deprecation-cycle and the
  semver-major-bump policies (ADR007 V02, ADR004 V02). ADR001 V02 closes a
  related stale claim (F8) about what LockWhenScaling was supposed to
  protect, and records that no replacement mechanism is needed.

Conceptual correction to the audit's own framing: v5.0.0 already shipped
and is no longer the target of this work. The target is the next release,
5.1.0, which is why the LockWhenScaling removal needed an explicit,
narrow semver exception rather than forcing a major bump for one symbol
that never carried real behavior.

See TODO/plano-de-acao.md for the full decision log and verification
record. Remaining: P2 decision B (a warmup-failure retry path) and the P3
backlog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A failed warmup was cached forever by the underlying Lazy<Task>, permanently
bricking the instance despite every DI guide recommending singleton
registration - a transient factory failure at startup had no recovery short
of a full process restart. An explicit WarmupAsync() call after a failure now
installs a fresh attempt via CAS and retries; AcquireAsync/SwitchToAsync's
implicit warmup trigger deliberately does not auto-retry, so ordinary acquire
traffic against a still-broken factory can't turn into a retry storm.

Also fixes a background-logger-task duplication bug surfaced by this change:
a retried warmup attempt re-entering WarmupCoreAsync would otherwise start a
second logger pump and orphan the first.

Closes U-22. Documented in ADR011V01. All three P2 decisions (A, B, C) from
the v5.1.0 viability audit are now implemented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The audit process repeats until findings converge to a stable state.
Neither the findings report (point-in-time) nor the action plan
(iteration log) answers whether successive rounds are actually converging
(fewer findings, no regressions) or diverging. This tracks that separately,
updated once per full audit round rather than per fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ting, doc batch

Completes the P3 backlog from the v5.1.0 viability audit - all behavior/code
items plus the full documentation batch:

- F6: dead _scaling guard removed; the real bug it never caught (a burst of
  stale sample ticks right after a scale-up) fixed at the actual source -
  the tick producer now skips enqueueing while a scale is in flight, and the
  sample window resets when one completes.
- F7/R7: the autoscale fault counter no longer piles up unboundedly while
  pinned at MaxCapacity, and a scale-up attempt that fails or only partially
  completes no longer burns the whole fault budget.
- F9: tracking-only fix - same defect as U-04, already closed.
- F11/R9: dead ScaleDownMin config removed (no coherent "scale below the
  floor" semantics ever existed for it).
- R6: scale-down is now opportunistic (take only what's already idle)
  instead of blocking the single-consumer engine - and every other pending
  command - for up to `baseTimer` waiting for busy items to free up.
- R11: the heartbeat pump's own internal acquire no longer counts toward the
  autoscale fault budget - it's a health check, not caller demand.
- R13 (new finding, surfaced while fixing R6): the same engine-blocking
  mechanism exists on the scale-up side via Factory calls. The architectural
  fix (moving Factory calls off the engine loop) is out of scope - it
  revisits ADR001's core invariant. Addressed the documentable half instead:
  FactoryTimeout's role in bounding engine-blocking time, and its inherited,
  uncalibrated 15s default, are now explicit in the XML docs and the
  autoscale guide. Considered and rejected: making the parameter mandatory
  (breaks 50/53 call sites in this repo) and lowering the default to 5s (no
  data for it, and real-world data against it - AWS-documented cold starts
  reach ~6s).
- U-23: RingBuffer<T>.New's second overload was annotated `string?` but
  throws ArgumentNullException on null - annotation corrected to `string`.
- Full documentation batch: U-09, U-11 through U-20, U-24 - exception docs,
  design-rationale notes, missing examples, stale defaults, and a
  CONTRIBUTING.md gap (build/test commands, doc regeneration steps).

All changes verified red-then-green where behavior changed; full suite
green (97/97) across net8.0/net9.0/net10.0, 0 warnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…abandonment, false timeout logs

Fixes the three actionable findings surfaced by an independent 3-pass
re-audit run after the P0-P3 hardening pass (Round 1) closed:

- F12 (Alta): the heartbeat pump's timeout handling disposed the pooled
  resource synchronously while the orphaned, uncancellable callback could
  still be reading/writing it - a genuine use-after-dispose race on the
  caller's own object. Only became reachable once Round 1's R3/P0#4 fix made
  the timeout path itself live. Fixed by separating the two concerns: the
  slot is still replaced immediately (preserving R3/P0#4's guarantee that
  capacity never gets stuck), but the stuck resource itself is now only
  disposed once the orphaned callback actually finishes.

- R14 (Média-Alta): a single failed/timed-out item aborted the rest of a
  scale-up batch even when the overall budget had room for the remaining
  items. Adds an opt-in `Factory(value, timeout, maxConsecutiveFactoryFailures)`
  parameter - default 0 preserves today's fail-fast behavior exactly; a
  higher value tolerates that many consecutive failures (resetting on any
  success) before giving up on the rest of the batch. The naive "always keep
  trying" fix was rejected after finding it would increase worst-case engine
  blocking time (interacts with R13, deliberately out of scope) - the
  opt-in design avoids that cost for anyone who doesn't ask for it.

- R15 (Baixa-Média): a normal DisposeAsync racing an in-progress scale-up or
  heartbeat-triggered replacement was logged as a factory TimeoutException,
  indistinguishable from a genuine unhealthy factory. Both call sites now
  check the caller's own cancellation token before deciding.

Plus a batch of documentation fixes with no behavior change: a stale
off-by-one wording survived from before Decision C's fix (U-25), two guides
and CONTRIBUTING.md linked a superseded ADR version (U-26, U-27), and two
missing/inaccurate <exception> docs (U-28, U-29).

All fixes verified red-then-green; full suite green (102/102) across
net8.0/net9.0/net10.0, 0 warnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r scale-down, doc batch

- F15: DisposeAsync racing a still-running heartbeat callback could dispose the
  pooled resource out from under it during an ordinary shutdown (the F12 fix's
  guard only covered pulse-budget timeouts). Guard changed to check whether the
  callback has completed, not who cancelled it.
- R17: WarmupCoreAsync logged a false "did not reach initial capacity" error
  when a concurrent DisposeAsync raced warmup, via both the local catch and the
  engine loop's own cancellation path.
- R16: AutoScaleDecision.EvaluateScaleDown only evaluated scale-down at the
  exact initial/maximum capacity; a partial scale-up/down (R14/R6) could leave
  the buffer stuck off-tier forever. Now evaluates by band, with a safety
  margin scaled to the buffer's actual current capacity instead of a value
  fixed to a specific tier. ADR003 amended (V02) to record the refinement.
- R18 registered (not fixed): the scaled margin is itself unreachable when
  initial capacity is 2, a pre-existing degenerate case unchanged by this fix.
- R13xR14 documented: tolerating consecutive factory failures multiplies
  FactoryTimeout's existing worst-case blocking window.
- Usability batch (F-U30 to F-U34): stale docs/comments describing pre-fix
  behavior or missing knobs, corrected across guides and one sample.

107/107 tests passing on net8.0/net9.0/net10.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n cap, observability parity

- F16: DisposeAsync could return before an orphaned heartbeat callback's
  deferred resource disposal (F12/F15) actually ran, since that continuation
  was fire-and-forget. Now tracked and awaited with its own bounded timeout.
- F17: a heartbeat tick's own internal acquire could throw an unhandled
  ObjectDisposedException in a narrow shutdown-timing window, logged as an
  unexpected error instead of an ordinary disposal.
- F18: found while testing F17 - a residual gap in the F12/F15 catch guard
  when a fast callback finishes at nearly the same instant as shutdown.
- R18/R19: AutoScaleDecision's scale-down margin collapsed to the buffer's
  own current capacity whenever initialCapacity or minCapacity was 2 (the
  minimum legal value), making scale-down unreachable or requiring exactly
  zero acquisitions. Both thresholds are now capped at currentCapacity - 1.
- O1/O2: scale and acquire telemetry (metrics + activity status) no longer
  report a DisposeAsync-cancelled operation identically to a genuine
  failure - same shutdown-vs-failure distinction already applied to logs,
  now extended to the observability layer, with a new `cancelled` tag.
- Usability batch (U-30 to U-36): stale docs describing a since-removed
  method, false claims about scale-up being undone on timeout, missing
  exception docs, and observability guide gaps.

116/116 tests passing on net8.0/net9.0/net10.0. First "clean" audit round
(zero Critical/High findings in the initial survey).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ger drop, scale-up failure masking

Estabilidade: DisposeAsync's item-drain loop now uses DisposeItemsDefensivelyAsync
instead of a raw foreach (F19, Alta), BackgroundLogger no longer drops DisposeAsync's
own late log messages (F20), and the deferred heartbeat-disposal bag now prunes
completed entries instead of growing unboundedly under a chronically slow HeartBeat
(F21, escalated and approved). Observabilidade: acquire.duration/activity gained
timed_out/cancelled tags (O6), scale-up no longer reports a genuine mid-batch factory
failure as an ordinary cancelled shutdown when a later attempt is raced by DisposeAsync
(O7, escalated and approved), and the heartbeat grace-period-timeout message is now a
LogWarning, not informational (O8). Resiliência/Usabilidade rounds out with doc-only
clarifications (R20/R21, F-U37/F-U38, O9/O10). All behavior fixes verified red->green;
O6 flagged as a deliberate low-risk/additive deviation. 121/121 tests on
net8.0/net9.0/net10.0, 0 warnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…4/R13

F13: remove the per-command catch in RunEngineAsync's engine loop - confirmed
dead code since all 5 EngineCommandKind branches already catch their own
exceptions locally. F14: close as no-evidence (3000 probe iterations across
2 rounds, zero reproductions). R13: close as decided, not pending - the
architectural trade-off (Factory calls blocking the single-consumer engine
loop) was already confirmed twice (Rounds 1 and 2); documentation-only
mitigation stands, no code change. 121/121 tests on net10.0 before and after
the F13 removal (pure dead-code deletion, no behavior change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…c's WhenAll

DisposeAsync's Task.WhenAll(pending) no longer needs its own
OperationCanceledException catch - RunEngineAsync, RunHeartbeatAsync, and
RunSampleTickAsync each already fully own that exception at their own
outermost level (built up incrementally across R3/F3, F15, F17, F18, and
F13's own removal), so none of the tasks in `pending` can ever propagate
one here. The sibling catch (Exception) already covers it as a supertype.

Found via a dedicated dead-code sweep across all 16 production source
files (2486 lines), prompted by the F13 cleanup. Verified structurally
(tracing every propagation path, including via a user-supplied
ErrorHandler throwing OperationCanceledException) and empirically (a
temporary throw-marker probe ran the full 121-test suite without ever
being hit). 121/121 on net10.0 before and after the real removal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…residual masking gap

F23 (Alta, found independently by both Estabilidade and Observabilidade): no call
to a user-supplied Logger/ErrorHandler was guarded against that callback itself
throwing. Under BackgroundLogger(true), this silently killed the background logger
pump on the first bad message. Under the default synchronous mode, on the
heartbeat-timeout path, this permanently lost a pool slot while CurrentCapacity kept
lying about it, and made DisposeAsync() itself throw - contradicting its own "must
never throw" design comment. Fixed with a new SafeInvokeSink helper wrapping every
Logger/ErrorHandler invocation in LogMessage/LogWarning/LogError/RunLoggerAsync.

R22 ("O7-residual", Média): Round 5's O7 fix only threaded hadGenuineFailure through
CreateItemsAsync's tuple-return path, missing the sibling throw path (zero-progress
batch), reopening the same shutdown-vs-genuine-failure masking via that second exit.
Fixed by wrapping the CreateItemsAsync call in MoveToCapacityAsync's scale-up branch.

F-U39/O11: two doc-only fixes, no trade-off.

A third Estabilidade finding (an apparent exception-contract inconsistency in
Warmup/SwitchToAsync) was escalated, an initial fix decision was made and partially
implemented, then reverted after two pre-existing tests were found whose own names
and comments proved the current behavior is deliberate design since Round 1 - no
code change, only a doc line adjusted to stop overclaiming a specific exception type.

All behavior fixes verified red->green. 124/124 tests on net8.0/net9.0/net10.0,
0 warnings across the full solution build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ilure gaps

Instead of a full Round 7, two scoped sweeps targeted the two root-cause themes
that had recurred across nearly every prior round without ever getting a
dedicated pass the way dead code did (F13->F22).

Sweep 1 (unguarded external-callback invocations):
- F24 (Média-Alta): TurnbackAsync's Invalidate() branch permanently lost a pool
  slot if the pooled item's own Dispose()/DisposeAsync() threw, since the
  ReplaceOne enqueue never ran. Fixed with a `finally` so the enqueue always
  happens - the item's own exception still propagates to the caller unchanged,
  no public contract change.
- F25 (Baixa): same class as F23, but in RingBufferBuilder - a throwing OnError
  during ValidateBuild() replaced the real validation failure. Fixed with a
  second SafeInvokeSink-style helper.

Sweep 2 (shutdown-vs-genuine-failure ambiguity):
- O12 (Média): a scale-down can never genuinely fail or be cancelled
  (RemoveItemsAsync never observes a token, never throws, by R6's own design),
  yet a completely normal partial scale-down was reported as
  ActivityStatusCode.Error - indistinguishable from a real problem. Fixed so
  scale-down never reports Error; `success` alone still signals partial
  completion, matching the documented contract.
- F26 (Baixa, hygiene): tracing O12 revealed ProcessTickAsync's catch had become
  unreachable (Tick only ever triggers scale-down, which per O12 never throws) -
  same class as F13/F22, verified the same two-layer way (structural + empirical
  throw-marker probe) before removing.

All behavior fixes verified red->green. 127/127 tests on net8.0/net9.0/net10.0,
0 warnings across the full solution build. This sweep is not "Round 7" - that
round number is preserved for the next full 4-pillar audit pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…OCE masking

F27/F28 (Alta, Estabilidade): a pooled item's own Dispose()/DisposeAsync() had no
bound anywhere - unlike Factory (FactoryTimeout) and the heartbeat callback
(PulseHeartBeat, F16). F27: DisposeAsync()'s idle-item drain loop could hang
indefinitely on a stuck item. F28, more severe: RemoveItemsAsync disposes items
inline on the single-consumer engine's own thread during a scale-down, so a
hung dispose there stalled the entire engine forever - including DisposeAsync's
own unbounded wait on _engineTask, hanging shutdown itself. Both closed by the
same fix in the shared DisposeItemsDefensivelyAsync helper: each item's dispose
is now bounded by PulseHeartBeat (same grace-period precedent F16 established),
logging a warning and continuing in the background if exceeded.

R23/R24/R25 (Resiliência): a factory throwing a raw OperationCanceledException/
TaskCanceledException for its own unrelated reasons (an HttpClient/gRPC/DB
driver's own internal timeout) was indistinguishable from an ordinary shutdown
at three call sites with unfiltered `catch (OperationCanceledException)`
handlers. R24 (Alta) was the worst: SwitchToAsync() swallowed a genuine factory
failure into a silent `false`, no exception at all. Fixed by adding
`when (_lifetime.IsCancellationRequested)` guards, completing (not contradicting)
the Round 6-confirmed "surface the real factory exception" contract.

F-U40/F-U41: two doc-only fixes for residual gaps left by earlier fixes (O12,
F28).

Observabilidade pillar came back fully clean this round - first clean pass in
this audit's history.

All behavior fixes verified red->green. 132/132 tests on net8.0/net9.0/net10.0,
0 warnings across the full solution build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…, unobserved exceptions

Estabilidade: F29 (sequential item dispose, N x PulseHeartBeat) + F30
(BackgroundLogger dropping a message once the queue completed).
Observabilidade: O13 (grace period never applied to a synchronous
Dispose(), same root cause as F29, fixed together) + O14/O15 (doc-only,
merged with matching Usabilidade findings). Resiliencia: R26 (Factory
received the wrong cancellation token, orphaning a cooperative factory
past its own timeout - fixed for the cooperative case only, the
non-cooperative residual documented as caller responsibility rather
than adding a fourth instance of the track-and-observe-in-background
pattern); R27, a governance finding - Round 1's own R1 was marked
fixed but its "perda silenciosa de capacidade via Invalidate()"
symptom was never actually closed for CreateSingleReplacementAsync;
CurrentCapacity now reflects a failed replacement immediately instead
of lying forever; R28 (unobserved task exception on SwitchToAsync's
unlocked path). Usabilidade: F-U42/F-U43/F-U44, doc-only.

Also: RingBufferDefault.PulseHeartBeat's XML doc and HeartBeat's
pulse parameter doc (4 builder interfaces) now describe the general
item-dispose bound, not just the original heartbeat health-check use.

139/139 tests passing on net10.0, 0 warnings. Tracking docs corrected
an arithmetic count that had been stale since Round 7 (108/110 should
have read 110/110; now 121/121).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Decision-quality simulation, not a wall-clock benchmark: compares the
shipping median-threshold algorithm (AutoScaleDecision, ported locally)
against the percentile+regression Monitor proposed in the v6 design
discussion, across synthetic demand traces, isolating the slow/
corrective decision layer from the shared reactive escalation rule.

Answers the evidence gap ADR003/ADR006 left open for revisiting the
median algorithm. Surfaced two things: the shipping algorithm can get
structurally stuck at a coarse tier under sustained-but-reduced demand
(conservative/boundary-sensitive thresholds), and the raw percentile+
regression formula thrashes under flat-but-noisy demand without a
deadband - included here with one, since the v6 design now requires it.

Run with: dotnet run -c Release -- --algo-comparison

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirms with real numbers that pausing the Monitor's sample window
while demand keeps pace with capacity (not just on the trigger tick,
but for the whole active plateau) and resetting it once the episode
ends closes almost all of the post-burst residual lag found earlier:
StepDown and SpikeAndRecover convergence both drop from 18 ticks to 1,
average overprovisioning drops in every scenario, and NoisyFlat shows
no oscillation regression (still 1) with fewer unmet-demand ticks (0).

This mirrors the real, already-shipped `develop` behavior described in
its CHANGELOG ("sample window resets across a scale operation, and
ticks are skipped while one is in flight") rather than inventing new
machinery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Carries the full reasoning from the v6 design analysis (concurrency
model, autoscale algorithm swap, capacity/floor-guard model, manual
scale as a pin, remaining surface decisions) into one document, ahead
of formalizing the individual ADRs that supersede/revise ADR001,
ADR003, ADR006, and ADR007.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ADR006V02 supersedes ADR006V01's SemVer-resumption clause (v5.0.0
verified released on NuGet, 90 downloads, latest - the earlier working
assumption that it was unreleased was wrong) and authorizes v6.0.0 on
its own justified terms, restating that strict SemVer resumes from
v6.0.0 onward.

ADR001V03 generalizes the single-owner concurrency principle to four
named roles (Orquestrador/Fabrica/Remocao/Monitor) without reopening
it, adds the floor guard and backlog-reactive signal, and bounds
Fabrica's concurrency against thundering-herd amplification.

ADR003V03 reverses ADR003V01/V02's "keep the median" decision with the
evidence they required: a real, run comparison simulation showing the
median can get structurally stuck over-provisioned, and the deadband /
window-reset refinements the same simulation proved necessary.

ADR007V03 adds explicit target capacity, redesigns SwitchToAsync as a
mandatory-duration pin, redesigns HeartBeat around Invalidate() instead
of a disposal trap, and closes the HostingExtensions bugs ADR006V01
already named via a proper IHostedService.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- ADR006V02/ADR003V03: correct stale "File title md" header fields
  that still described v5/median after the version's actual content
  moved on (v6.0.0 mandate, percentile+regression algorithm).
- ADR007V03: fix a Links-section contradiction that grouped the
  Monitor with the floor guard/backlog-reactive signals as having
  priority over the manual pin - both the ADR001V03 body and this
  ADR's own Decision Outcome say the opposite (the pin outranks the
  Monitor, not the reverse). Also notes where MaxConcurrentFactoryCalls
  surfaces on the builder.
- ADR004V03 (new): the SemVer policy's Stage (b) resumption clause
  said "from v5.0.0 onward (including v5.1, v6, etc.)" - written
  before v6.0.0's own reset was justified. Updates the resumption
  point to v6.0.0, as a second and explicitly non-repeating exception.
- ADR006V02: promotes the ADR004 reference from "Related" to
  "Supersedes (partially)", matching what ADR004V03 actually does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AutoScaleMonitor.Percentile/Slope/EvaluateTarget port the percentile +
regression algorithm validated in AutoScaleAlgorithmComparison.cs
(PercentileRegressionDecision), with 17 new unit tests covering
percentile interpolation, trend direction, and min/max clamping.

Not yet wired into RingBufferManager's engine loop: this algorithm needs
unclamped demand samples (including unmet demand above current
capacity), which only ADR001V03's backlog-reactive signal can supply
truthfully - AutoScaleDecision's existing idle-count sampling is
clamped and would silently flatten the regression slope under
saturation. Wiring happens together with the floor-guard/backlog-
reactive/pin/Monitor signal-priority model, in the ADR001V03 phase.

AutoScaleDecision.cs and AutoScaleDecisionTests.cs are untouched and
still green - no red/green evidence for ADR003V03 in this commit by
design; that lands when the Monitor is actually wired in.

Full suite: 156/156 passing on net10.0 (no regression).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TurnbackAsync's Invalidate() branch awaited the old item's own
Dispose()/DisposeAsync() before enqueuing EngineCommand.ReplaceOne() in
a finally. A Dispose() that hangs forever means that finally never
runs, so the slot is never replaced and CurrentCapacity is wrong
forever - same bug shape as F27/F28/F29 (Round 7/8), a third call site
those fixes did not touch. Left as an open, paused decision at the end
of Round 8.

Fixed by enqueuing the replacement first, unconditionally, before
awaiting the old item's disposal - not a fourth instance of the
DisposeItemsDefensivelyAsync (Task.Run + WaitAsync(PulseHeartBeat))
pattern used by its two siblings. The blast radius here is local (only
the caller's own DisposeAsync() call blocks if their item's Dispose()
hangs; nothing about the engine loop or a background pump depends on
it), so per the "prefer truthful state over tolerance machinery" lens
adopted in Round 8, the fix makes the pool's own bookkeeping
immediately correct instead of adding more track-and-observe machinery.

Two new tests:
- Invalidate_WhenItemDisposeHangs_StillReplacesTheSlot_WithoutWaitingForIt
  - red (unfixed): factory stuck at 2 calls, 2s deadline hit.
  - green (fixed): 3rd factory call within 44ms.
- Invalidate_AfterManagerDisposed_StillDisposesTheItemExactlyOnce
  - covers the previously-untested combination the reordering made
    newly reachable (TryWrite on an already-completed channel returns
    false, not an exception - verified, not just assumed).

Full suite: 158/158 on net10.0, no regression.

This is a v5-engine bug fix, not an ADR001V03 item - the ADR describes
the target v6 architecture; this happens to land in code the future
DisposalWorker<T> role will own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CreateItemsAsync no longer creates a batch's items one at a time -
up to MaxConcurrentFactoryCalls (new config, default 4, exposed on
ElasticCapacity as a trailing optional parameter) now run concurrently,
bounded by a SemaphoreSlim gate. This is the thundering-herd mitigation
ADR001V03 calls for: a large batch (warmup, scale-up, or a future
floor-guard replenishment) can no longer flood a struggling-but-
technically-accepting downstream with simultaneous connection attempts
one... at... a... time being the only alternative extreme either.

External contract of CreateItemsAsync is unchanged (same return shape,
same partial-progress-kept semantics, same exception-on-total-failure
behavior) - this is an internal execution-model change, not a public
behavior change, except where noted below.

"Consecutive failures" (MaxConsecutiveFactoryFailures) no longer has an
exact, ordered meaning under real concurrency - approximated as a
single shared counter/flag, reset by any success, tripped by exceeding
the tolerance. Two contract tests updated/added to reflect this:
- SwitchToAsync_WithDefaultFailureTolerance_StillAbandonsTheBatchOnTheFirstFailure
  now pins maxConcurrentFactoryCalls: 1 to isolate the tolerance
  behavior from the concurrency behavior (unchanged assertions).
- SwitchToAsync_WithBoundedConcurrency_GivesUpWellBeforeAttemptingTheFullBatch
  (new) covers the real bounded-concurrency case: already-in-flight
  attempts finish regardless, only later waves are stopped - asserted
  as a bound, not an exact count, since a fast success can race a
  slower failure's give-up flag past a small, expected number of
  stragglers (the same "simple, not a circuit-breaker" looseness
  ADR001V03 itself accepts for this mechanism).
- ScaleUp_AchievesRealConcurrentFanOut_NeverExceedingMaxConcurrentFactoryCalls
  (new) is the core Fábrica acceptance criterion: real concurrent
  fan-out happens, and never exceeds the configured bound.
- Invalidate_WhenItemsDisposeThrows_StillQueuesAReplacement updated:
  "which numbered factory call is the throwing one" is no longer
  deterministic under concurrent warmup, so ThrowingOnDisposeProbe
  gained a settable ThrowOnDispose, flipped by identity on the actual
  acquired instance instead of baked in by call order.

Public XML docs on Factory(...)'s maxConsecutiveFactoryFailures param
(all 4 builder interfaces) updated with the concurrency caveat, since
they get regenerated into the shipped API reference.

Full suite: 160/160 on net10.0 (net8.0/net9.0 not yet re-run this
session - due before any push, per this repo's own TFM cadence).

Known open trade-off, not resolved here (flagging per standing
practice, not deciding unilaterally): MaxConsecutiveFactoryFailures
defaulting to 0 combined with MaxConcurrentFactoryCalls defaulting to
4 means a fully-broken factory now receives up to 4 concurrent
attempts before giving up, instead of 1 - a 4x weakening of fail-fast
in exactly the unhealthy-downstream case ADR001V03's own thundering-
herd mitigation is supposed to help with. The ADR's own "growing
backoff after consecutive failures" piece is also not implemented yet.
Whether to raise here, build the backoff now, or accept this trade-off
short-term is open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New _factoryFailureStreak, persisted on the manager (not scoped to one
CreateItemsAsync/CreateSingleReplacementAsync call): incremented on any
genuine (non-cancellation) factory failure, reset by any success.
ApplyFactoryBackoffAsync waits out an exponential delay before the next
attempt when the streak is nonzero - 100ms base, doubling per
consecutive failure, capped at 5s. Internal constants, not exposed as
builder configuration ("simple", per the ADR, is a small fixed policy,
not a new tunable).

Wired into both creation paths: CreateItemsAsync's AttemptAsync (before
acquiring a concurrency slot, so a backed-off attempt doesn't tie one
up) and CreateSingleReplacementAsync (which runs inline on the engine
thread - a long streak there delays every other queued command, an
accepted trade-off until Fábrica's execution is decoupled from that
thread in a later increment).

Found and fixed during review, before committing: the backoff wait
must never be counted against CreateItemsAsync's own quantity *
FactoryTimeout deadline (`overall`). Passing a different token to the
delay isn't sufficient - CancelAfter's timer runs on the wall clock
regardless of which token a wait is bound to - so `overall`'s deadline
is now armed lazily, by whichever attempt clears backoff first, instead
of at method entry. Without this, an elevated streak's backoff could
exceed a short deadline and cancel every attempt before Factory is
ever called even once, on an otherwise perfectly healthy factory -
self-inflicting exactly the "routine scale-up structurally impossible"
failure that deadline exists to prevent.

Two new tests: Factory_BackoffGrows_WithConsecutiveGenuineFailures_AndResetsOnSuccess
(delay grows 0 -> ~100ms -> ~200ms across 3 consecutive replacement
failures, resets to ~0 after a success) and
ScaleUp_WithElevatedBackoffStreak_StillGetsARealAttempt_WithinItsOwnTightDeadline
(the deadline-arming fix's regression test: a streak-3 backoff, ~400ms,
against a scale-up whose own deadline is only 300ms - must still
succeed).

Full suite: 162/162 on net10.0, no regression.

Does NOT close the 4x-amplification concern raised before this
increment: a freshly-broken factory still has streak=0, so the first
wave still fires up to MaxConcurrentFactoryCalls (default 4) concurrent
attempts with no delay - MaxConsecutiveFactoryFailures=0 trips only
after the first of them fails. This backoff fixes repeated hammering
across subsequent attempts/cycles (what the ADR text asks for), not
the first burst. Closing that is a separate, undecided lever (raise
MaxConsecutiveFactoryFailures's default, or lower
MaxConcurrentFactoryCalls's default).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FloorGuardDecision.EvaluateBreach/HasGraceWindowElapsed port the
Orquestrador's floor guard - the highest-priority signal (floor guard
> backlog-reactive > pin > Monitor) - as pure, testable functions, the
same staged approach AutoScaleMonitor.cs (ADR003V03) used before it.

- EvaluateBreach(currentCapacity, minCapacity): breach is strict
  "less than" (not "<="), confirmed against a fixed-capacity buffer's
  steady state (CurrentCapacity == MinCapacity) not being a breach.
  "available" means actual capacity (CurrentCapacity), not an idle
  item count - an explicit maintainer clarification of the ADR's own
  wording, documented in the class remarks.
- HasGraceWindowElapsed(breachDetectedAt, now, factoryTimeout): the
  public "below minimum" signal's grace window, reusing the existing
  FactoryTimeout parameter per the ADR (no new configuration surface).
  Boundary is inclusive (">="), consistent with this codebase's
  existing threshold style (AutoScaleDecision.EvaluateScaleDown's
  lower band).

Not yet wired into RingBufferManager's engine loop: wiring requires
the Orquestrador to actually dispatch a non-blocking replenishment
request to Fábrica and track a breach's detected-at instant across
engine ticks - both belong to a later integration increment, not to
this pure unit.

12 new tests (FloorGuardDecisionTests.cs). Full suite: 174/174 on
net10.0 (162 pre-existing + 12 new), no regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Switch/Fault-triggered scale-ups now dispatch their bounded-concurrent
factory batch onto the thread pool (DispatchScaleUp) instead of awaiting
MoveToCapacityAsync inline in ProcessCommandAsync - the engine's single
consumer thread stays free to process other commands (heartbeat
replacements, future floor-guard/backlog-reactive requests) while a batch
is in flight. The batch reports its outcome back via a new
FactoryBatchCompleted command; _currentCapacity, _scaling and _faultCount
are still only ever mutated on the engine thread when that command is
processed, preserving the Orquestrador's sole-owner guarantee. Warmup and
Tick's scale-down are unchanged - still synchronous via MoveToCapacityAsync.

Correctness (advisor review): at most one batch may be in flight at a
time - Switch and Fault both reject (not queue) a new scale-up request
while _scaling is true, otherwise two overlapping batches could each read
a stale CurrentCapacity and independently add their own `created` on top
of it, overshooting MaxCapacity. This is a caller-visible semantic change:
a legitimate sequential SwitchToAsync call without LockWhenScaling can now
get `false` where it previously got `true`, if issued while a prior
scale-up is still completing in the background. _scaling is cleared
before the caller's completion is resolved, so a caller that awaits
completion (LockWhenScaling) never observes stale in-flight state on its
next call. Tick also defensively re-checks _scaling (RunSampleTickAsync's
own check is write-time only) to avoid a scale-down racing an in-flight
scale-up; this also skips that tick's sample, which is moot since
FactoryBatchCompleted unconditionally clears _samples anyway.

Telemetry (activity/meter) is finalized inside the background task itself,
unconditionally - not deferred to FactoryBatchCompleted - so a batch still
running when DisposeAsync is called still gets reported even if the
_commands channel closes before it finishes. DisposeAsync now also awaits
the in-flight batch (_factoryBatchTask, tracked after _engineTask so no
race on the field) before returning, closing a real gap this change
introduced: without it, two existing observability tests
(ScaleUp_*RacedByDisposeAsync) could lose their metrics/activity to a race
against listener disposal.

New test: SwitchToAsync_ScaleUp_DoesNotBlockTheEngineLoop_WhileTheBatchIsInFlight
- proves a second Switch request is rejected promptly (under 1s) instead
of blocking for the full ~2s in-flight factory delay.

Full net10.0 suite: 163/163, stable across repeated runs.

Known pre-existing issue (not introduced by this change, not fixed here):
AutoScaleAcquireFault_PartialScaleUpLandsOffTier_StillEventuallyScalesDown
fails 3/3 when run in isolation (Expected 7, Actual 6), reproduced
identically on commit 54afc82 (before this change) - a bounded-concurrency
vs. exact-alternating-factory-response timing issue, unrelated to this
increment. Passes under full-suite scheduling. Left for a separate fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports src/RingBufferPlus/Core/FloorGuardDecision.cs and its 12 tests from
the isolated worktree fork - pure, stateless EvaluateBreach/
HasGraceWindowElapsed helpers, not yet wired into RingBufferManager.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause (confirmed pre-existing on 54afc82, before the Fábrica
engine-decoupling work - not a regression from it): the test's factory
assumes a strict alternating fail/succeed-by-callCount pattern to keep
consecutiveFailures from ever reaching 2 in a row (tolerated once via
maxConsecutiveFactoryFailures: 1). With the default maxConcurrentFactoryCalls
(4), several factory calls run genuinely concurrently, so two "odd"
(failing) calls can have their consecutiveFailures bookkeeping race each
other without an intervening success resetting it first - occasionally
tripping give-up one call early (observed: created 2/6 instead of 3/6,
landing capacity at 6 instead of the expected 7).

Fix: pin maxConcurrentFactoryCalls: 1, the same fix already applied to
SwitchToAsync_WithDefaultFailureTolerance_StillAbandonsTheBatchOnTheFirstFailure
for the identical reason. With concurrency pinned to 1, each factory call
and its consecutiveFailures bookkeeping are atomic relative to the next
(gate.Release() only happens after the stateLock update), so callCount
assignment is strictly sequential and the alternating pattern by
construction never produces two consecutive failures.

Verified red (5/5 fail on this test alone, isolated) before the fix,
green (5/5 pass) after. Full net10.0 suite: 175/175, stable across 2 runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…DR001V03)

The Orquestrador now reacts to real-time waiting-caller depth instead of a
count of past acquire faults/timeouts: a caller that cannot be served
immediately reports itself as backlog the instant it starts waiting
(AcquireCoreAsync), well before AcquireTimeout could ever elapse. The gap
between waiting callers and idle items drives the scale-up target
(Math.Min(CurrentCapacity + gap, MaxCapacity)), evaluated both at the moment
a caller starts waiting and as a follow-up when a scale-up batch completes
(only one batch may be in flight at a time). No extra debounce beyond
gap > 0, per explicit design confirmation.

The old EngineCommandKind.Fault path, _faultCount, and the NumberFault
threshold comparison are removed entirely from the engine.
AutoScaleAcquireFault(numberOfFaults) and NumberFault are deliberately left
on the public builder surface - numberOfFaults is now inert, deferred to the
ADR007V03 public-surface redesign - but their XML docs are updated to stop
describing the removed fault-counter mechanism.

_waitingCount's decrement happens right after ReadAsync returns rather than
in AcquireCoreAsync's shared finally, narrowing (not eliminating) a known
race: a served caller can still be briefly counted as backlog by a
concurrent EvaluateBacklogReactive call, occasionally causing one extra
small batch before self-correcting. Documented in code as an accepted
approximation - never risks exceeding MaxCapacity, same spirit as the
existing consecutive-failures-under-concurrency looseness.

Tests: deleted two tests that asserted fault-counter-specific behavior
(exact-threshold trigger, reset-on-trigger) which no longer exists.
Rewrote the remaining fault-trigger-dependent tests to use concurrent
waiters instead, since backlog-reactive intercepts the wait before a
timeout-based fault can ever happen. The off-tier landing test
(PartialScaleUpLandsOffTier) now asserts a range instead of an exact
landing capacity (non-deterministic under the accepted race above), and
ties that landing to the actual injected factory failure via
callCount == landedCapacity + 1, ruling out a false pass from capacity
growth alone. Two SwitchToAsync-based observability tests were left on
manual mode instead of backlog-reactive, since they need a precisely
reconstructable quantity that concurrent-waiter timing can't guarantee.

Verified: full net10.0 suite 173/173 (down from 175 by the two deliberate
deletions), stable across repeated runs; the off-tier test individually
stable 5/5 after each design iteration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The highest-priority signal of all (floor guard > backlog-reactive >
manual pin > Monitor): protects the pool's minimum contractual floor by
detecting CurrentCapacity < MinCapacity and dispatching an immediate,
undebounced replenishment. Uses FloorGuardDecision (already ported and
merged, previously unwired).

Closes a live, previously-unretried gap: CreateSingleReplacementAsync's own
finally block (a failed heartbeat- or Invalidate()-triggered replacement)
can shrink CurrentCapacity below MinCapacity with nothing that currently
retries it. Warmup does not need this guard - WarmupCoreAsync already
throws "did not reach initial capacity" on any shortfall, surfacing it to
the caller synchronously.

EvaluateFloorGuard is called after ReplaceOne and, like
EvaluateBacklogReactive, as a FactoryBatchCompleted follow-up - but before
EvaluateBacklogReactive there, so a floor breach legitimately wins the
single in-flight-batch slot over ordinary backlog demand.
_floorBreachDetectedAt is set once on first detection and persists across
retries (only cleared once the breach resolves), so the grace window
(FactoryTimeout, reused per the ADR, no new configuration surface) measures
time since the breach started, not since the last retry. There is no
public "genuinely below minimum" property yet (deferred to ADR007V03, same
as AutoScaleAcquireFault's numberOfFaults) - an elapsed grace window is
reported via LogError as the closest honest substitute for now.

Fixed Factory_BackoffGrows_WithConsecutiveGenuineFailures_AndResetsOnSuccess:
it used FixedCapacity (MinCapacity == Capacity), so its own deliberate
replacement failures now also breach the floor guard, whose background
retry calls the same Factory concurrently with the test's next deliberate
trigger, corrupting the timestamps it reads to measure backoff. Switched to
ElasticCapacity with headroom (min=2) so the floor guard never engages.
This concurrency (an inline ReplaceOne racing a background DispatchScaleUp
batch's factory calls) is pre-existing since the Fábrica-decoupling commit
(59bab41) - the floor guard just made it easy to hit via repeated
deliberate failures on a fixed-capacity buffer.

Tests: a self-heal test proves the guard retries in the background with no
caller action until the factory recovers (red confirmed by temporarily
reverting the engine change: capacity never recovers without it); a
grace-window test proves the LogError report is not instant, only firing
after the breach has genuinely outlived one FactoryTimeout cycle; an
observability test proves the "floor" scale-operation trigger tag is
recorded like "manual"/"backlog" already are.

Verified: full net10.0 suite 176/176 (174 + 2 new), stable across 3 runs;
the new tests individually stable 3/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FRACerqueira and others added 28 commits August 24, 2026 16:32
Reading only the unlocked SwitchToAsync path (LockWhenScaling=false)
in isolation suggested a genuine factory failure on a manual scale-up
never reached Logger/OnError: ProcessCommandAsync's FactoryBatchCompleted
case always has a non-null cmd.Completion for a Switch-triggered scale,
so it resolves via TrySetException rather than the "nobody is waiting,
LogError it" branch that floor/backlog/auto-triggered scales use, and
the unlocked path's own fault-observing ContinueWith never calls
LogError itself.

What that reading missed: CreateItemsAsync's own per-attempt catch
already calls LogError for every individual failed attempt,
unconditionally, before any batch-level aggregation happens - reproduced
empirically with 4 concurrent failing attempts (MaxConcurrentFactoryCalls
default), all 4 independently logged. A first probe of this test seemed
to confirm the suspicion (only 1 logged error), but that was an artifact
of the probe's factory throwing synchronously rather than through a real
await, letting the first attempt complete (and set giveUp) before the
other three ever called Factory at all.

Kept as a permanent regression guard against this genuinely stopping
working later, not because a bug was found now.

Round 8, v6 pre-release audit (observabilidade).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ask>

ConcurrentBag<T> is optimized for genuine multi-producer thread
affinity - a per-thread local queue - that this field's single logical
writer (RunHeartbeatAsync, which resumes on an arbitrary pool thread
after each await, not a fixed thread) never actually had, per
complexidade's Round 7 finding. Estabilidade reviewed the swap for
correctness (Round 8, scoped review): confirmed via grep that
RunHeartbeatAsync is the only writer, confirmed DisposeAsync's
snapshot-after-await-_heartbeatTask already gets its visibility
guarantee from the Task itself (not from ConcurrentBag), and found no
correctness reason to keep the type.

Also closes, as a side effect, a latent hazard the old take/re-add
pattern had: an exception between the drain loop and the re-add loop
would have silently lost every entry already taken out. A single lock
scope (RemoveAll + Add) can't leave that gap.

Round 8, v6 pre-release audit (complexidade candidate, estabilidade
correctness review). Decided with the maintainer: implement now, given
estabilidade's clearance and the bonus correctness fix, without waiting
for desempenho to measure the (likely negligible, given the mechanism's
own rarity) performance delta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2 Alto findings, both tracing back to the same gap in the Round 7 fix
itself (SwitchToAsync never got it; the new telemetry row was tag-
indistinguishable from an ordinary shutdown race) - fixed with a new
acquire.warmup_failed tag, SwitchToAsync's asymmetry documented as a
known limitation rather than extended. One Medio finding investigated
and refuted (SwitchToAsync logging gap - CreateItemsAsync already logs
per attempt). One Baixo performance finding (extra Stopwatch call,
+20-24ns) accepted as-is. The complexidade-routed ConcurrentBag
candidate cleared by a scoped estabilidade review and implemented.
Verified: 193/193 tests net10.0, clean build (0 warnings, 0 errors)
across the full solution (3 TFMs, samples, benchmarks, docs generator).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dict (Medio)

The HeartBeat callback's "unhealthy" verdict (returns false, item is
Invalidate()'d and replaced) - the most common way a HeartBeat-configured
pool actually operates - had no signal of its own on any channel. The
pump's one log line ("Heart Beat pump iteration finished") fires
identically whether the verdict was healthy or not, so an operator
watching telemetry alone could not tell a pool that's constantly
cycling items from a perfectly healthy one. Confirmed empirically in
Round 9's audit: 11 pulses of an always-unhealthy callback produced the
exact same log sequence a healthy callback would have.

Adds ringbufferplus.heartbeat.invalidations (Counter<long>, tagged
buffer.name) plus a matching Debug-level log message, both emitted right
after Invalidate() is called. Red/green:
HeartBeat_UnhealthyVerdict_RecordsInvalidationCounter failed with zero
records (motivo previsto), passed after the fix.

Round 9, v6 pre-release audit (observabilidade, checklist-format pass).
Decided with the maintainer: both a counter and a log line (option C of
the 4 presented).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Complexidade and usabilidade both hit their first genuinely clean round
in the series (9 rounds in), confirming the trend analysis done before
this round. Desempenho's redefined role (regression verification tied
to code changes, not a converging front) held up in practice: measured
Round 8's two unmeasured changes, found one pure win (ConcurrentBag ->
lock+List, ~3-5x faster, zero allocation) and one small cost confined
to the listener-attached path (acquire.warmup_failed tag), neither
needing a trade-off decision. Observabilidade's checklist-format pass
found one genuine Medio finding (HeartBeat's unhealthy verdict had no
telemetry of its own) - fixed with a new counter + log - and produced a
26-path coverage table as proof, not just a finding list. First round
since Round 3 with zero Alto findings. Verified: 194/194 tests net10.0,
clean build (0 warnings, 0 errors) across the full solution (3 TFMs,
samples, benchmarks, docs generator).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…w cadence (Alto)

EvaluateFloorGuard's LogError had no latch tied to the grace window - it
re-fired on every single evaluation once the window had elapsed, so a
persistently broken factory reported forever at whatever cadence the
surrounding retry mechanism (the shared factory-retry backoff, 100ms
doubling up to 5s) happened to call it - contradicting
usage-elastic-autoscale.md's documented "logging an error once a full
FactoryTimeout cycle has elapsed". The event was also entirely absent
from usage-observability.md's Logging section, which otherwise declares
itself a comprehensive inventory.

Fixed with a new pure decision function, FloorGuardDecision.ShouldReportNow
(same isolated-first pattern as the existing EvaluateBreach/
HasGraceWindowElapsed), tested deterministically rather than via a
timing-sensitive integration test: log once when the grace window first
elapses, then again only once a further grace-window's worth of time has
passed since the last report - decoupling the report's own cadence from
whatever the retry mechanism underneath it happens to do. Docs corrected
to describe the actual (and now intentional) repeat-as-fallback behavior.

Round 10, v6 pre-release audit (observabilidade). Decided with the
maintainer: keep repeating the alert as a fallback (not silent after the
first report), just on a bounded cadence instead of unboundedly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Complexidade hit its 2nd consecutive clean round - first pillar to reach
full formal convergence in this series (not a reduced/scoped pass like
estabilidade/resiliencia's earlier convergence). Desempenho, for the
first time since its redefinition, evaluated a change and explicitly
concluded there was no plausible measurement target rather than forcing
a benchmark - confirms the new role works in practice. Usabilidade did
not repeat its clean round: found its own Round 9 doc text was wrong
("only signal" when two were actually added) - restarts that front's
clean-round count at 1. Observabilidade found 1 real Alto (floor guard's
LogError had no latch, repeating indefinitely and undocumented) and 1
Baixo (Monitor sample-window doc inaccuracy). The Alto was fixed with a
new pure decision function tested deterministically, after a first
timing-based integration test attempt proved unreliable for
distinguishing old from new behavior. Verified: 201/201 tests net10.0,
clean build (0 warnings, 0 errors) across the full solution (3 TFMs,
samples, benchmarks, docs generator).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ractTests.cs

Its 7 tests (acquire success/cancellation, SwitchToAsync to Min/Max/
InitCapacity, warmup, dispose) are a strict subset of scenarios
RingBufferContractTests.cs already covers with equal or greater rigor,
including edge cases (racing shutdown, active pins) this file never had.
Not v4/v5-era residue - it used current v6 symbols throughout
(ElasticCapacity, ScaleSwitch, IsInitCapacity) - just fully superseded
coverage nobody removed once the contract-test suite was built out.
Filename also collided with the production RingBufferManager.cs, an
unrelated hygiene issue.

Found by a dedicated read-only investigation into test-suite residue
from the v4->v5.0->v5.1(discontinued)->v6 version history, requested by
the maintainer alongside the v6 pre-release audit rounds. A sibling file
with the same filename-collision issue (RingBufferExtension.cs, testing
RingBuffer<T>.New's null-argument validation) was checked and kept -
that coverage is not duplicated anywhere else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
concepts.md and usage-fixed-capacity.md both claimed MinCapacity/
MaxCapacity "only exist" for elastic buffers and that a fixed buffer
"never scales"/"has no scale engine activity" - contradicted by
FloorGuardDecision's own class comment ("applies uniformly in any mode,
including fixed capacity"), EvaluateFloorGuard's unconditional dispatch
from the ReplaceOne case regardless of Elastic, and Round 10's own
Invalidate_WhenTheReplacementFactoryStaysBroken_RepeatsBelowMinimumReport_AsAFallback
test, which uses .FixedCapacity(2) specifically to prove the floor guard
reports on a fixed buffer. An operator modeling alerts off these guides
could be surprised by a real "below minimum capacity" LogError or
scale.* telemetry with trigger=floor on a buffer the docs describe as
never scaling.

Round 11, v6 pre-release audit (usabilidade).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both _monitorActive's own comment and ProcessTick's comment above the
sample-window pause/clear logic claimed the "active" state (demand >=
CurrentCapacity, i.e. waiting >= idle) was only realistically reachable
in a narrow edge case - a buffer pinned at MaxCapacity with genuine
backlog - since any other real backlog would already have _scaling true
by the time Tick ran. usage-observability.md's "Neither fires while
demand is saturating capacity" line made the same narrow framing.

Confirmed wrong by direct reproduction (Round 11, observabilidade): an
elastic buffer held at exactly 100% utilization with zero waiters - an
ordinary, common state, not an edge case - produced zero "Monitor tick"
log lines and never scaled across several tick cycles, because
idle=0/waiting=0 satisfies "active" too (0 >= 0), and no backlog signal
ever fires when nobody is actually waiting, so _scaling stays false and
Tick isn't skipped by that gate either - it reaches ProcessTick's own
check and pauses there instead.

Decided with the maintainer: document the real, broader condition
(option A of the 3 presented) rather than narrow the "active" definition
to a waiting-only check (option B) - that would be a real autoscale
algorithm change needing the same decision-quality simulation ADR003V03
itself was validated with, which the existing
AutoScaleAlgorithmComparison tool doesn't currently model (it feeds a
synthetic demand sequence directly, not a separate idle/waiting split).
Judged not proportional to a single audit finding, and out of scope for
a documentation fix - left as an explicitly evaluated, deferred design
question rather than a pending defect.

Round 11, v6 pre-release audit (observabilidade).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Complexidade stayed out of rotation (converged in Round 10). Desempenho
produced its 2nd "no measurement needed" response, this time backed by a
deductive proof (Round 10's new predicate is a strict narrowing of the
old one, so it logically cannot regress) rather than just "no plausible
target". Usabilidade did not repeat a clean round: found concepts.md/
usage-fixed-capacity.md incorrectly claiming FixedCapacity buffers never
scale, contradicted by the floor guard itself and a Round 10 test.
Observabilidade found a real Alto: the Monitor's "active" state (which
pauses its own sample collection) was characterized in code comments and
docs as a narrow edge case, but is algebraically true for ordinary full
utilization too, confirmed by direct empirical reproduction requested by
the maintainer before deciding the fix. A behavioral fix (narrowing
"active") was evaluated in detail at the maintainer's request and found
to require extending the project's autoscale simulation tool rather than
just running it - decided to document the real condition instead,
recording the behavioral option as a deliberately deferred design
question. A parallel investigation (outside the round format) removed
one redundant test file superseded by RingBufferContractTests.cs - no
v4/v5.0/v5.1 dead-code residue found beyond that. Verified: 194/194 tests
net10.0, clean build (0 warnings, 0 errors) across the full solution
(3 TFMs, samples, benchmarks, docs generator).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Baixo+Medio)

Round 12, usabilidade (reduced scope), found the same class of gap
usabilidade/observabilidade have hit repeatedly since Round 6: a
decision documented in one guide during the round it was made, not
propagated to every other guide making the same claim.

- usage-fixed-capacity.md:35 said MinCapacity/MaxCapacity "would be
  meaningless" for a fixed pool - directly contradicting the same
  file's own line 39 (fixed in Round 11) and concepts.md, both of which
  now correctly state the floor guard reads and acts on them regardless
  of mode. The real reason they're not separate builder options is that
  there's only one capacity value to configure, not that they carry no
  meaning.
- usage-elastic-autoscale.md:30 said a demand sample joins the Monitor's
  sliding window "on every sampling tick" - false whenever the tick
  finds demand at or above capacity (Round 11's own finding: this
  includes ordinary full utilization with nobody waiting, not just
  genuine backlog), since ProcessTick returns before adding a sample in
  that case. This guide wasn't touched when Round 11 fixed the same
  claim in usage-observability.md and 2 code comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…als (Baixo)

The internal Elastic property's own XML doc grouped the floor guard
together with the backlog-reactive signal and Monitor as all gated by
this property - "a fixed pool has nothing to scale". The floor guard is
not gated by Elastic at all: EvaluateFloorGuard's three call sites are
all unconditional (unlike EvaluateBacklogReactive's explicit `!Elastic`
check and the Monitor's own conditionally-started sample-tick task), and
FloorGuardDecision's own class comment already states this applies
uniformly in every mode. RingBufferManager<T> is internal and not part
of the generated public docs, so this never reached an external
consumer - only a future maintainer/auditor reading this property first.

Round 12, v6 pre-release audit (observabilidade).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 12's 3 findings (usabilidade x2, observabilidade x1) were all
direct, unambiguous factual corrections with no trade-off - propagating
decisions already made in Round 11 to guides/comments that hadn't been
touched yet, plus one internal-only XML doc gap. No new bug class, no
new behavior, nothing pending a decision. This matches the stop
criterion set before this round started (agreed with the maintainer
after a trend analysis across Rounds 1-11): if usabilidade and
observabilidade came back with only residual/trivial findings, close
the audit here rather than open another round.

Final state across the 12-round series (2026-08-23 to 2026-08-24): 3 of
6 fronts reached full formal convergence (estabilidade/resiliencia at
Round 6, complexidade at Round 10). Desempenho's role was redefined at
Round 9 into a permanent regression-verification function tied to code
changes, not a front that converges by finding progressively less -
that redefinition is now recorded permanently in the shared
auditoria-desempenho.md agent definition (C:\Sources\EA4AI). Usabilidade
and observabilidade never reached 2 consecutive clean rounds, but the
last 6 rounds (7-12) show a clear plateau: findings became increasingly
residual/self-referential (a round's own fix not fully propagated)
rather than fresh discoveries in the original v6 code, with no new
behavior bug since Round 10's floor-guard latch fix. Zero Critico since
Round 1. A separate, parallel test-suite hygiene investigation removed
one redundant test file; no v4/v5.0/v5.1(discontinued) dead-code residue
found. One design question (narrowing the Monitor's "active" condition)
was evaluated with an explicit cost assessment and deliberately deferred
rather than implemented, since it would require extending the existing
autoscale simulation tool, not a mechanical fix.

All commits remain local on the v6 branch - nothing was pushed to the
remote during this audit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds doc/audits/v6.0.0-pre-release-audit.md summarizing the 12-round
pre-release audit's method and outcome, converts CHANGELOG.md's
Unreleased section into the dated 6.0.0 release entry (including the
audit's material behavior/telemetry fixes and known issues), bumps
PackageVersion to 6.0.0, and updates README's latest-version blurb.
Removes the ephemeral TODO/ audit tracking files, now superseded by
the durable audit report per the pre-release-audit skill's own
convention for concluding an audit.
SECURITY.md's supported-versions table and policy note, CONTRIBUTING's
"receive no further fixes" tense, and the actively-packaged
src/RingBufferPlus/README.txt's "What's new" section still described
v5.0.0 as the latest/current release. Updated all three to v6.0.0.
Historical records (the CHANGELOG's own [5.0.0] entry, superseded ADR
versions, the v6 design proposal) are left untouched, consistent with
this project's ADR-immutability convention - they document what was
true when written, not the current state.

Also re-validated all 5 sample projects against the v6.0.0 API
surface (ElasticCapacity(min,max,target,...), required-duration
SwitchToAsync, Func<T,bool> HeartBeat, automatic AddRingBuffer<T>
warmup, no AutoScaleAcquireFault/BackgroundLogger/
WarmupRingBufferAsync) - all already conform, no code changes needed.
All three items (SwitchToAsync telemetry gap, constructor/gauge
ordering, Monitor's active condition) were evaluated and explicitly
accepted as intentional trade-offs during the pre-release audit, not
open defects - "Known issues" mislabeled them as unresolved problems.
Two of the three already have a proper home as documented trade-offs
in usage-observability.md/usage-elastic-autoscale.md, and the third
is an internal, consumer-invisible ordering window documented as a
code comment in RingBufferManager.cs; the Monitor design question is
also covered in doc/audits/v6.0.0-pre-release-audit.md. Nothing is
lost by removing the duplicate CHANGELOG section.
The file had zero inbound links from anywhere in the docs and zero
outbound links of its own - undiscoverable via navigation, found only
by knowing the filename. Its content already lives on in the ADRs it
fed into (ADR001V03, ADR003V03, ADR006V02, ADR007V03), so nothing is
lost by removing it.

ADR006V02's Context section cited this file by path; dropped the
citation while keeping the surrounding sentence and its reasoning
intact, since the ADR itself is unaffected.
README.md, the packaged README.txt, and the NuGet Title/Description
led with the mechanism ("generic ring buffer with auto-scaler")
instead of the benefit. Reframed the tagline and opening around what
a consumer gets (stop provisioning for worst case), added a scannable
"Why RingBufferPlus" section to README.md, and added an ElasticCapacity
example to the Quickstart alongside the existing FixedCapacity one -
the feature the project is named after didn't appear in the first
code sample.

Left ADRs, the guides, CONTRIBUTING.md, and the architecture overview
untouched - those are precision-first documents for people who've
already decided to use or maintain this, and selling to them there
would cost more credibility than it buys.

Also fixed a dangling reference to the old src/docs/docindex.md path
in README.txt, left over from its relocation to doc/api.
The Test step was skipped on macos-latest since 2026-08-12 (commit
b0be2de), after the test-host was observed crashing at startup on
that runner - confirmed at the time to be an infra-level flake (ruled
out coverlet), not a code issue, but never reconfirmed since.
Re-enabling it now to see whether that has stabilized.

Added a 15-minute job timeout as a safety net: the original failure
mode was an indefinite hang before any test ran, and without a cap
a recurrence would run to GitHub's 360-minute default before failing.
Generated via 'dotnet sln RingBufferPlus.sln migrate' (SDK 10.0.400).
Fixed a stale src/docs/docindex.md solution-item reference in the
process - a leftover from the earlier doc/api relocation that the old
.sln itself still carried.

The CI workflow's path filters already expected **/*.slnx (likely
written ahead of this migration); dotnet build/test/restore in both
build.yml and publish.yml auto-discover the solution/project with no
file argument, so nothing there needed to change - verified that
auto-discovery resolves cleanly now that only the .slnx is present.
Found via a full markdown sweep (Portuguese-specific accented
characters, then a targeted word list): severity levels
(Crítico/Alto/Médio/Baixo) and audit-front names
(Estabilidade/Resiliência/Usabilidade/Complexidade/Desempenho/
Observabilidade) in doc/audits/v6.0.0-pre-release-audit.md, and
"Rodada N"/"Resiliência" inside ADR003V02's historical amendment
notes.

ADR003V02 is a superseded ADR, but this only translates round/front
naming - no decision, fact, or consequence in its content changed,
so it doesn't conflict with this project's ADR-immutability
convention (that protects substance, not language).

Ruled out as false positives during the sweep: "TODO" (English
acronym, matched by a case-insensitive "todo" pattern), filename
citations to now-deleted TODO/*.md working files (their real
Portuguese names are historical fact, not a language choice), and
the "×" multiplication sign (a bash/grep locale artifact, not an
accented character - confirmed clean via a Python Unicode sweep).
Windows and macOS runs on run 32845176035 failed ScaleDown_WhenDisposalHangs_StillLetsAnUnrelatedReplaceOneRunPromptly (2.03s vs a 1s budget) and DisposeAsync_WhenMultipleIdleItemsAllHangOnDispose_TheWholeBatchIsBoundedByOnePulseHeartBeat_NotN (missed a 700ms race window), both well short of the real bad-case bounds (10s grace period; 4x200ms sequential) the tests exist to guard against. ubuntu-latest passed clean, consistent with runner scheduling jitter rather than a product regression. Widened the elapsed budget on the first test to 5s and raised the second test's HeartBeat pulse to 1000ms with a 2500ms race window, preserving what each test proves while giving CI noise more room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…samples

These configuration knobs were documented but never exercised by any
runnable sample - BasicTriggerScale now tunes the Monitor's deadband
(needed for its narrow capacity range to scale at all), and RabbitSample
wires up OnError and a raised maxConcurrentFactoryCalls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AutoScaleMonitor and FloorGuardDecision's class comments restated
ADR003V03/ADR001V03 decision text near-verbatim; trimmed to the
implementation-level detail the ADRs don't cover, with a plain ADR
pointer for the rest. IRingBufferElasticBuilder dropped a stale,
undated "OPEN QUESTION" note sitting on a public declaration in favor
of flagging it separately. RingBufferValue used the invalid <br>/</br>
tag pair in an XML doc summary; replaced with <para>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RingBufferManager.cs had grown to 2123 lines. Extracted the two
self-contained pieces of its engine loop that never touch
_currentCapacity: bounded-concurrent factory execution with backoff
(Creator<T>, mirroring the AutoScaleMonitor/FloorGuardDecision
pattern) and defensive item disposal (Removal, stateless like those
two). CreateSingleReplacementAsync stays on RingBufferManager since it
does mutate capacity directly (ADR001V03's sole-owner rule), but now
delegates backoff/failure-streak bookkeeping to Creator.

Split what remained into partial-class files by responsibility
(.Service.cs, .Engine.cs, .Pumps.cs, .Logging.cs) - pure relocation,
same convention as the extraction above. Fixed five comments whose
"below"/"above" spatial references broke across the new file
boundaries, and two comments citing an ADR without a version where the
project's convention (and the superseded chain) makes that ambiguous.

Verified behavior-preserving: full suite green on net8.0/net9.0/net10.0
before and after (194/194), no test added or changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RingBufferContractTests.cs had grown to 3905 lines and 86 [Fact]
tests with no internal grouping. Split into 9 partial-class files by
feature (SwitchToAsync, Dispose, Acquire, Warmup, ScaleUpDown,
HeartBeat, Invalidate, Autoscale, Misc); the two shared private
helpers (CreateFixedManager, GetPrivateField) stay on the main file
and remain directly accessible via partial class, same pattern used
for RingBufferManager.

Fixed comments whose "the test above/below" references broke because
this reorganization moved individual tests out of their original
sequential order (unlike the RingBufferManager split, where whole
contiguous regions moved together) - checked each one against the
original ordering rather than assuming every such reference had
broken. Also removed a dead reference to TODO/plano-de-acao.md
("P2 Decision B"), an action-plan file that no longer exists in this
repo; kept the ADR011 reference next to it, which still resolves.

Verified: 86 [Fact] methods present across the new files (same count
as before), full suite green on net8.0/net9.0/net10.0 (194/194), no
test added, changed, or reordered in behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread src/RingBufferPlus/Core/AutoScaleMonitor.cs Dismissed
Comment thread src/RingBufferPlus/Core/AutoScaleMonitor.cs Dismissed
Comment thread src/RingBufferPlus/Core/Removal.cs Dismissed
Comment thread src/RingBufferPlus/Core/RingBufferBuilder.cs Dismissed
Comment thread src/RingBufferPlus/Core/RingBufferManager.Engine.cs Dismissed
Comment thread src/RingBufferPlus/Core/RingBufferManager.Service.cs Dismissed
Comment thread src/RingBufferPlus/Core/RingBufferManager.Service.cs Dismissed
Comment thread src/RingBufferPlus/Core/RingBufferManager.Service.cs Dismissed
Comment thread src/RingBufferPlus/Core/RingBufferManager.Service.cs Dismissed
Comment thread src/RingBufferPlus/Core/RingBufferManager.Service.cs Dismissed
@FRACerqueira
FRACerqueira merged commit 8afd31e into main Aug 26, 2026
7 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants