Skip to content

Fix the classic engine's publish pipeline: refill after keep-alive recovery, and bound every producer - #4274

Merged
marcschier merged 4 commits into
masterfrom
marcschier/startpublishing-reservation-review
Aug 18, 2026
Merged

Fix the classic engine's publish pipeline: refill after keep-alive recovery, and bound every producer#4274
marcschier merged 4 commits into
masterfrom
marcschier/startpublishing-reservation-review

Conversation

@marcschier

@marcschier marcschier commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Description

Session.OnKeepAlive recovers a session whose keep alives had stopped by marking every outstanding publish request defunct — dropping GoodPublishRequestCount to zero — and then calling StartPublishing to refill the pipeline. Those written off requests never return.

The classic engine's publish reservation, added in #4232, could not observe that. It counted the requests the engine had sent and released a slot only when a request completed, so the write off left the reservations held forever: TryReservePublishRequest refused on the first iteration, StartPublishing issued nothing, and the session never published again. Recovery from a keep-alive outage is exactly the path that stops working.

That counter was a second, independent accounting of the same thing the session already tracks, and the two could not be reconciled. Both prior behaviours were wrong in one direction:

StartPublishing Outcome
Unbounded (as originally merged in #4232) Overshoot — it runs once per subscription create and stacked a pipeline per call: 84 and 165 outstanding against a cap of 50 in CI
Bounded by the send-side counter (current master) Permanent stall after a write off, as above

What changed

The reservation is now reconciled with the session's own accounting instead of duplicating it.

The engine tracks only the requests it has sent that the session has not recorded yet, and the limit is checked against GoodPublishRequestCount + unrecorded:

  • GoodPublishRequestCount is authoritative and already honours the write off, so a written off pipeline refills immediately — the recovery valve behaviour is preserved as a property of the accounting rather than as an unbounded escape hatch.
  • The unrecorded count covers the window before AsyncRequestStarted records a request, which is the lag that made the original check-then-act overshoot: concurrent callers all read the same stale value and each sent.
  • The reservation is released when the request is recorded, not when it completes, so no engine-side counter can be stranded by a request that never returns.

Reads take the unrecorded count before the session count, so a request moving between the two is counted twice rather than missed — the error is always towards sending less.

The third producer: the keep-alive nudge

Bounding the two automatic producers was not enough. CI kept failing with roughly twice the desired requests outstanding (90 and 100 against a cap of 50), which neither bounded producer could explain, so the remaining one was instrumented rather than guessed at: a local run logged 23 calls into Subscription.HandleOnKeepAliveStopped, which sends an uncapped publish whenever a subscription has seen no notification. One nudge per subscription adds a second full pipeline, which matches the observed numbers — and the server agrees the surplus is real: the failing CI run answered it with BadTooManyPublishRequests 59 times.

That nudge now goes through the same reservation. Sending past the desired count cannot help, because the server already holds that many requests and rejects the rest. The limit is at least one, so an empty pipeline is always refillable, and because the reservation reconciles against the session's count, a pipeline whose requests were written off still refills — the case the nudge exists for.

Behaviour change worth reviewing: ISession.BeginPublish now returns false instead of sending when the pipeline is already at the desired count. The signature and the documented contract ("true if the request was sent") are unchanged, and Subscription.HandleOnKeepAliveStopped is its only in-tree production caller.

Testing

StartPublishingRefillsPipelineAfterRequestsAreWrittenOff reproduces the stall deterministically: it fills the pipeline, writes the requests off the way Session.OnKeepAlive does, and asserts the refill. It issued 0 of 5 before this change.

ConcurrentPublishReEvaluationDoesNotExceedDesiredRequestCount (the overshoot guard from #4232) still passes, so both bounds hold at once.

BeginPublishIsBoundedByTheDesiredRequestCount covers the nudge from both sides: twelve nudges across three subscriptions issue three requests, and the nudge gets through again once the outstanding requests are written off.

Run locally on this branch:

Suite net10.0 net48
Opc.Ua.Client.Tests 2127 / 2127 2129 / 2131 (2 skipped)
Opc.Ua.Subscriptions.Classic.Tests 32 / 32 32 / 32
ClassicSubscriptionEngineTests 25 / 25, five consecutive runs
Opc.Ua.Client build, all TFMs 0 warnings, 0 errors

PublishRequestCountAsync is the integration test that caught the overshoot in CI. Under deliberate CPU contention (6 burners on 8 cores) it was run repeatedly and no run reproduced the overshoot; the failures that remain under that load are setup and teardown timeouts on a starved machine (BadRequestTimeout in CreateSubscriptionAsync), not publish accounting.

Related Issues

No tracked issue exists yet — this is a follow-up regression fix to #4232, found while investigating the Subscriptions.Classic CI failures on that PR. Happy to open one if maintainers would rather track it separately.

Checklist

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added all necessary documentation. (the reservation itself is internal; the one behavioural change — BeginPublish returning true when the pipeline is already primed — is documented on the method's <returns>/<remarks> and called out under "The third producer" above)
  • I have verified that my changes do not introduce (new) build or analyzer warnings.
  • I ran all tests locally using the UA.slnx solution against at least .net framework and .net 10, and all passed. (ran the affected suites on both TFMs as tabled above, not the full solution)
  • I fixed all failing and flaky tests in the CI pipelines and all CodeQL warnings. (the PublishRequestCountAsync overshoot is fixed at the source; the residual transport-timeout flake under heavy agent load is pre-existing and reproduces identically on the pre-merge parent — see the merge note below)
  • I have addressed all PR feedback received.

The classic engine reserved a slot before sending a publish request and
released it when the request completed. That counter was a second,
independent accounting of the outstanding requests and it only ever
shrank on completion, so it could not observe a request the session had
written off.

Session.OnKeepAlive writes off exactly that way: when keep alives
recover it marks every outstanding publish request defunct - dropping
GoodPublishRequestCount to zero - and calls StartPublishing to refill
the pipeline. The written off requests never complete, so their
reservations were held forever, StartPublishing could reserve nothing,
and the session stopped publishing for good. Bounding StartPublishing on
that counter is what removed the last escape hatch; leaving it unbounded
instead is what let the pipeline overshoot on every subscription create.

Reserve against the session's count instead. The engine now tracks only
the requests it has sent that the session has not recorded yet, and the
limit is checked against that plus GoodPublishRequestCount. The session's
count is authoritative and already honours the write off, so a written
off pipeline refills immediately; the unrecorded count covers the window
before AsyncRequestStarted records a request, which is the lag that made
the original check-then-act overshoot. The reservation is released once
the request is recorded rather than when it completes, so no engine side
counter can be left stranded by a request that never returns.

StartPublishingRefillsPipelineAfterRequestsAreWrittenOff reproduces the
stall: it fills the pipeline, writes the requests off the way the
session does, and asserts the refill. It issued 0 of 5 before this
change. ConcurrentPublishReEvaluationDoesNotExceedDesiredRequestCount
still holds, so both bounds hold at once.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3f5ab76a-c0ba-4f47-87ac-54d1f4182c6b
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

Code coverage

Coverage gate passed.

Check Result Threshold
✅ Project line rate 86.13% (226906/263455 lines) >= 70.00%
✅ Project branch rate 75.66% >= 60.00%
✅ Patch coverage 100.00% (18/18 changed lines) >= 60.00% (<= 100 changed lines, advisory)
ℹ️ Baseline delta (advisory) +12.53 pp 73.60% recorded

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 85.25%, branch 74.87%.

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.03%. Comparing base (333de83) to head (7861ec0).
⚠️ Report is 3 commits behind head on master.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4274      +/-   ##
==========================================
- Coverage   80.71%   80.03%   -0.69%     
==========================================
  Files        1831     1926      +95     
  Lines      253449   263455   +10006     
  Branches    44116    46103    +1987     
==========================================
+ Hits       204583   210860    +6277     
- Misses      33510    36392    +2882     
- Partials    15356    16203     +847     
Flag Coverage Δ
actions 80.03% <100.00%> (-0.69%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
.../Session/Subscription/ClassicSubscriptionEngine.cs 76.76% <100.00%> (-6.81%) ⬇️

... and 176 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@marcschier
marcschier marked this pull request as ready for review August 17, 2026 16:29
Copilot AI lite review requested due to automatic review settings August 17, 2026 16:29
@marcschier
marcschier enabled auto-merge (squash) August 17, 2026 16:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a publish pipeline stall in the classic subscription engine after keep-alive recovery by reconciling the engine’s publish “reservation” with the session’s authoritative outstanding publish accounting, and adds a regression test to cover the recovery path.

Changes:

  • Replace the engine-side “in flight” counter with an “unrecorded publish requests” reservation that is checked against GoodPublishRequestCount + unrecorded.
  • Release the reservation when the session records the request (via AsyncRequestStarted) instead of waiting for request completion.
  • Add a deterministic test reproducing keep-alive recovery writing off outstanding publish requests and verifying StartPublishing refills the pipeline.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/Opc.Ua.Client/Session/Subscription/ClassicSubscriptionEngine.cs Reworks publish request reservation logic to avoid overshoot while still allowing refill after session write-off.
tests/Opc.Ua.Client.Tests/Session/ClassicSubscriptionEngineTests.cs Adds a regression test validating pipeline refill after outstanding publishes are written off during keep-alive recovery.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/Opc.Ua.Client.Tests/Session/ClassicSubscriptionEngineTests.cs Outdated
Both engine tests handed the mocked PublishAsync a TaskCompletionSource
created without RunContinuationsAsynchronously. The engine hangs its
continuation on that task with OnCompleted, so completing it on the test
thread ran OnPublishComplete inline, which re-enters the engine and can
issue further publishes while the assertion is being evaluated - a deep
synchronous call chain and a timing dependent result.

Create both sources with RunContinuationsAsynchronously, and sample the
issued count before completing them so each assertion is fixed by the
call under test alone rather than by whatever the completions go on to
do.

The same pattern was already present in the pre-existing concurrency
test, so it is corrected there too: the assertion is a bound on the
number of requests issued, which an inline completion could inflate.

Addresses review feedback on #4274.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3f5ab76a-c0ba-4f47-87ac-54d1f4182c6b
@marcschier

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@marcschier

Copy link
Copy Markdown
Collaborator Author

CI note: PublishRequestCountAsync is a pre-existing flake, not from this PR

The Fast PR test Tests (net10.0) ... Subscriptions.Classic leg is red on this branch. It is not caused by this change.

The same test fails the same way on master. Build 17152 on refs/heads/master at aad8350b — which does not contain this PR — failed Subscriptions.Classic net10.0 (linux) with the identical signature:

Failed PublishRequestCountAsync
  Expected: greater than or equal to 90
  But was:  50

This branch's failure is the same assertion with a different number (100 vs 50).

It is not deterministic with respect to this change either. Build 17153 (b163a1f) passed both the net10.0 and net48 legs; build 17164 (7539e7d) failed net10.0. The engine is byte-identical between those two commits — 7539e7d only touches the test file. Locally the suite is green 4x on net10.0 and 2x on net48.

Note also that the two failures on the previous run (Gds, PubSub.Kafka) were unrelated infrastructure: both died in checkout with fatal: could not read Username for 'https://github.com', so Test Release was skipped and no test ran. That coincided with a GitHub outage. Re-running with /azp run cleared both.

What is still unexplained. The overshoot is beyond the two producers this PR bounds. QueueBeginPublish and StartPublishing are both capped at the desired count, so the excess comes from somewhere else, and the server agrees it is real — the failing run logs PUBLISH - Too many requests, set limit to GoodPublishRequestCount=... 59 times, i.e. the server returned BadTooManyPublishRequests.

My hypothesis is the uncapped session.BeginPublish(...) in Subscription.HandleOnKeepAliveStopped, which fires one extra publish per subscription when publishing looks stopped: with 50 subscriptions that is up to +50 on top of the capped 50, which matches the observed 90-100. I could not confirm it — the Opc.Ua.Client.Subscription logger category emits nothing in these CI runs, so the PUBLISHING STOPPED trace that would prove it is absent for reasons unrelated to whether the path ran. I would rather flag it as unverified than bound another recovery path on a guess, which is the mistake that made #4232 need this follow-up in the first place.

Happy to chase that down here or in a separate PR/issue - it looks like a genuine pre-existing gap, just not one this PR set out to close.

@marcschier marcschier added the ready Ready to merge once CI Passes label Aug 17, 2026
@romanett

Copy link
Copy Markdown
Contributor

@marcschier should be fixed if you merge: 333de83

PublishRequestCountAsync kept failing on the Windows net10.0 leg with
around twice the desired publish requests outstanding (90 and 100
against a cap of 50). Neither producer this PR already bounds could
account for it, so the remaining one was instrumented rather than
guessed at: a local run logged 23 calls into
Subscription.HandleOnKeepAliveStopped, which sends an uncapped publish
whenever a subscription has seen no notification. With one subscription
per nudge that adds up to a second full pipeline, which matches the
observed numbers, and the server agrees the surplus is real - the
failing CI run answered it with BadTooManyPublishRequests 59 times.

Route that nudge through the same reservation as the automatic top up.
Sending past the desired count cannot help, because the server already
holds that many requests and rejects the rest, while every subscription
nudging at once multiplies the pipeline by the subscription count. The
limit is at least one, so an empty pipeline is always refillable, and
because the reservation reconciles against the session's own count a
pipeline whose requests were written off still refills - which is the
case the nudge exists for. Session.BeginPublish already returns false
when nothing was sent, so the contract is unchanged.

BeginPublishIsBoundedByTheDesiredRequestCount covers both halves: twelve
nudges across three subscriptions issue three requests, and the nudge
gets through again once the outstanding requests are written off.

Verified with the integration suite green on net10.0 and net48, and
under deliberate CPU contention no run reproduced the overshoot any
more; the failures that remain under that load are setup and teardown
timeouts on a starved machine, not publish accounting.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3f5ab76a-c0ba-4f47-87ac-54d1f4182c6b
@marcschier marcschier changed the title Fix the classic engine's publish pipeline stalling after keep-alive recovery Fix the classic engine's publish pipeline: refill after keep-alive recovery, and bound every producer Aug 18, 2026
@romanett

Copy link
Copy Markdown
Contributor

@marcschier please use the solution already present in master

…lishing-reservation-review

# Conflicts:
#	src/Opc.Ua.Client/Session/Subscription/ClassicSubscriptionEngine.cs
@marcschier

Copy link
Copy Markdown
Collaborator Author

Merged master, and a note on converging with #4272

master (333de83, via #4272) independently changed ClassicSubscriptionEngine.BeginPublish to bound the keep-alive nudge — the same conclusion this PR reached in d8df575. That was the one merge conflict; everything else merged cleanly.

Resolution keeps both intents.

Upstream's version is built on m_publishRequestsInFlight, the send-side-only counter that this PR replaces. That counter is only decremented when a request completes, so it cannot observe a request the session has written off — which is precisely the stall this PR exists to fix. On master today, after Session.OnKeepAlive writes off the pipeline, upstream's BeginPublish reserves nothing, falls into the QueueBeginPublish() branch, and that call re-checks the same pinned counter and also refuses. It then returns true having sent nothing. StartPublishingRefillsPipelineAfterRequestsAreWrittenOff covers exactly that path.

So the merged version keeps this PR's reconciled reservation (GoodPublishRequestCount + unrecorded), which is what makes the bound safe, and adopts upstream's two deliberate choices on top:

  • Bound the nudge by the desired count — both sides wanted this; already the behaviour here.
  • Do not report failure when the pipeline is already primedBeginPublish now returns true in that case rather than false. Upstream's QueueBeginPublish() call in that branch is not carried over: with the reservation checked against the same limit it is a guaranteed no-op, so it would only be misleading. The <returns> doc is updated to match ("true if the pipeline holds the requested publish request when this returns, whether it was sent here or was already outstanding").

Math.Max(1, ...) subsumes upstream's desiredCount == 0 special case: an empty pipeline is always refillable, but the request still goes through the reservation so the counter stays balanced.

Validation after the merge: Subscriptions.Classic 32/32 on net10.0, ClassicSubscriptionEngineTests 25/25, Opc.Ua.Client build 0 warnings / 0 errors across all TFMs.

One observation for whoever looks at the CI logs: PublishRequestCountAsync still flakes on a heavily loaded machine, but no longer on the overshoot assertion — it now fails as BadRequestTimeout / BadSessionIdInvalid in subscription setup or teardown. I confirmed that is not from this merge by running the merged tree and its pre-merge parent interleaved, back to back: 3/4 passed on each side, with the same two transport failure modes on both. The overshoot assertion did not reappear in any run.

@marcschier

Copy link
Copy Markdown
Collaborator Author

Re: "please use the solution already present in master"

Master is merged in as of 7861ec0, and its overshoot fix is preserved — BeginPublish is bounded by the desired count here exactly as it is in 333de83. That part of the request is done.

On adopting master's implementation wholesale: I checked it empirically rather than argue from reading, and it does not hold up. I checked out 333de83, dropped this PR's two regression tests onto it unchanged, and ran them against master's engine:

Failed StartPublishingRefillsPipelineAfterRequestsAreWrittenOff
  StartPublishing must refill a pipeline whose requests were written off,
  otherwise the session never publishes again.
  Expected: 5
  But was:  0

Failed BeginPublishIsBoundedByTheDesiredRequestCount
  Expected: 4
  But was:  3

Total tests: 25.  Passed: 23.  Failed: 2.

The other 23 pass on master untouched, so this is not the tests disagreeing with master's style — they isolate one behaviour.

Why it fails. Master's bound is checked against m_publishRequestsInFlight, which is only decremented when a request completes. Session.OnKeepAlive recovers a stalled session by marking every outstanding publish request defunct — dropping GoodPublishRequestCount to zero — and then calling StartPublishing to refill. Those written-off requests never complete, so their reservations are never released and the counter stays pinned at the old value. TryReservePublishRequest then refuses forever: StartPublishing sends nothing (0 of 5 above), and BeginPublish falls into the QueueBeginPublish() branch, which re-checks the same pinned counter, also refuses, and returns true having sent nothing. After a keep-alive outage the session stops publishing permanently.

That is the bug this PR exists to fix, and it is why the counter is reconciled against GoodPublishRequestCount — the session's count is authoritative and already honours the write-off, so a written-off pipeline refills immediately.

What is actually in the branch now is not a competing solution but a superset:

  • master's bound on the keep-alive nudge — kept;
  • master's choice not to report failure when the pipeline is already primed — kept (BeginPublish returns true there);
  • the reservation reconciled with the session's accounting — the part that makes the bound safe instead of fatal.

The one thing not carried over is master's QueueBeginPublish() call in the at-capacity branch: it re-checks the same limit that just refused, so it is a guaranteed no-op and would only mislead. Happy to restore it if you would rather keep the shapes identical.

If you would still prefer master's version to stand on its own, the stall is worth tracking separately — I would rather it be a known open issue than silently shipped. But merging this closes both, and it keeps your fix intact.

@marcschier
marcschier disabled auto-merge August 18, 2026 15:56
@marcschier
marcschier enabled auto-merge (squash) August 18, 2026 15:57
@marcschier
marcschier merged commit bf9696e into master Aug 18, 2026
198 of 200 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready Ready to merge once CI Passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants