Skip to content

fix(cache): a refused telemetry report no longer defeats the catch that made it - #203

Open
cosmin-staicu wants to merge 11 commits into
mainfrom
chore/guard-lock-telemetry
Open

cosmin-staicu wants to merge 11 commits into
mainfrom
chore/guard-lock-telemetry

Conversation

@cosmin-staicu

@cosmin-staicu cosmin-staicu commented Sep 22, 2026

Copy link
Copy Markdown
Member

Why

Every catch in these files exists so the surrounding work carries on, and each reported the caught failure through the telemetry sink directly. A sink that throws therefore takes the recovery with it — the exact failure mode #196 removed from RedisConnector and RedisPlannedMaintenance, still present in four other places.

TelemetrySafeguards landed with #196, so these can route through it now.

Site What a refused report costs today
RedisDistributedLock acquire (×2) The catch exists to degrade acquisition to unavailable. A throw hands the caller an exception instead of the no-op lease, so a telemetry outage becomes a lock failure. The TrackEvent(EventUnavailable) that follows has the same exposure.
RedisDistributedLock release The throw escapes the release path.
FactoryTimeout The record sits between the cancellation and throw new TimeoutException(...), so a refused one replaces the exception the caller is documented to receive.
MemoryCacheSetter The record sits in a finally, where a throw replaces whatever exception is already on its way out — the original cause is lost.
RedisConnectionWarmup The warm-up is explicitly best effort, and this was the one thing that could make it not be.

What

Each of those routes through TryTrackException / TryTrackEvent. TryTrackException gains an optional properties parameter, since the lock sites carry them.

After this, no raw TrackException remains in src outside TelemetrySafeguards itself.

Review then found the same defect in three more places, and enumerating the rest turned up a fourth:

Site What a refused report cost
RedisDistributedLock acquired / timeout Redis had already granted the lock, so a throw past the releaser left it held until it expired; the timeout path threw instead of returning the no-op lease. Every telemetry call in the class is now guarded.
RehydrationCoordinator triggered A throw skipped rehydrateAsync entirely — the whole point of the spawn.
RehydrationCoordinator timed out / failed A throw skipped handles = null, so the finally released the per-key locks instead of holding them for the cooldown. deduped and succeeded are guarded too, so no path in the method depends on the sink.
RedisStreamSubjectWriter.HandleInvalidEvent Reported before adding the id to the acknowledgement list, leaving the poison entry pending forever.
RedisStreamSubjectWriter.TraceReceipt Same shape, found by enumeration rather than review: ids.Add follows it on the valid path.

Not included

Five raw TrackEvent calls remain, in RedisConnector and RedisPlannedMaintenance. Those files are rewritten by #197, so guarding them here would only conflict; they follow once it merges.

Verification

Release build with -warnaserror clean. Full suite on both target frameworks: net10.0 1846, net8.0 1825, zero failures.

Seven new tests, each red against the raw TrackEvent it guards:

  • FactoryTimeoutTestsRunAsync still throws TimeoutException when the sink refuses, and a sink that accepts still records the event, so the first cannot pass by the record going missing.
  • RedisDistributedLockTests — the releaser still reaches the caller and still releases; the timeout path still returns the no-op lease.
  • RehydrationCoordinatorTests — the rehydrate still runs, and the locks stay held for the cooldown.
  • RedisStreamSubjectWriterTests — an invalid event and a same-source event are both still acknowledged.

The cooldown test synchronises on the outer catch rather than on the lock handle: ReleaseInFlight runs at the top of the finally, before the await that releases the locks, so a retrigger succeeding says nothing about the handle. A refusal that escapes is logged in the outer catch, which happens before the finally runs at all.

Contributor declaration

  • I signed off my commits per the DCO (git commit -s).
  • I am contributing on behalf of my employer, or in the course of employment / using employer resources.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The changes are localized, consistent with existing TelemetrySafeguards patterns, and remove a verified failure mode without altering the primary control flow beyond making telemetry best-effort where required.

Review effort: Lite
Findings: None

What changed in this PR

This PR hardens cache/Redis components against telemetry sinks that throw, ensuring telemetry reporting cannot defeat the recovery paths (catch/finally/best-effort sections) that are intended to keep core operations running.

Changes:

  • Extend TelemetrySafeguards.TryTrackException to accept optional telemetry properties and route through TrackException safely.
  • Replace direct TrackException/TrackEvent calls in recovery-sensitive paths (lock acquire/release, factory timeout, warm-up, finally blocks) with TryTrackException / TryTrackEvent.
  • Add clarifying comments at the call sites where a throwing telemetry sink previously could have changed observable behavior.
File Description
src/​UiPath.Caching/​TelemetrySafeguards.cs Adds optional properties to TryTrackException and forwards them safely to TrackException.
src/​UiPath.Caching/​Redis/​RedisConnectionWarmup.cs Makes warm-up telemetry best-effort via TryTrackException.
src/​UiPath.Caching/​MemoryCacheSetter.cs Prevents telemetry in a finally from throwing and altering the method’s outcome via TryTrackEvent.
src/​UiPath.Caching/​Locking/​RedisDistributedLock.cs Ensures telemetry failures don’t turn lock acquisition/release recovery paths into exceptions.
src/​UiPath.Caching/​FactoryTimeout.cs Ensures telemetry recording can’t replace the documented TimeoutException path.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The implementation correctly isolates telemetry failures; only a non-blocking inaccurate comment remains.

Review effort: Balanced
Findings: 1 Low severity

Open (1)

Comment thread src/UiPath.Caching/MemoryCacheSetter.cs Outdated
…at made it

Every catch here exists so the surrounding work carries on, and each
reported through the sink directly, so a sink that threw took the
recovery with it.

- RedisDistributedLock: the acquire catches degrade to an unavailable
  lease; a refused report handed the caller an exception instead. The
  release catch escaped the release.
- FactoryTimeout: a refused record replaced the TimeoutException the
  caller is documented to receive.
- MemoryCacheSetter: the record sits in a finally, where a throw
  replaces whatever exception is already on its way out.
- RedisConnectionWarmup: the warm-up is best effort, and this was the
  one thing that could make it not be.

TryTrackException takes properties now, since these sites carry them.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Remaining raw lock telemetry can still alter acquisition outcomes, and the timeout regression lacks direct coverage.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (1)

Comment thread src/UiPath.Caching/Locking/RedisDistributedLock.cs
Comment thread src/UiPath.Caching/FactoryTimeout.cs
BuildAcquiredLease reported the acquisition after Redis had granted the lock
but before the releaser reached the caller, so a refusing sink threw past it
and left the lock held until expiry. TrackTimeoutNoOp could likewise throw in
place of the no-op lease. Both now go through TryTrackEvent, so every
telemetry call in the class is guarded.

Adds call-site tests rather than only helper tests: RedisDistributedLock keeps
returning the releaser and the no-op lease, and FactoryTimeout.RunAsync still
surfaces TimeoutException, each with a sink that refuses the event in question.
RefusingTelemetryProvider is the shared double.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Remaining raw telemetry calls can still alter rehydration and stream-processing behavior, contradicting the stated guarantee.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)

Comment thread src/UiPath.Caching/FactoryTimeout.cs
…nd the stream writer

Each of these reports before the statement that does the work, so a refusing
sink changed behaviour rather than losing a record:

- RehydrationCoordinator: a throw from the triggered event skipped the
  rehydrate entirely; from the timed-out and failed events it skipped
  `handles = null`, so the finally released the per-key locks instead of
  holding them for the cooldown. Deduped and succeeded are guarded too, so no
  path in the method depends on the sink.
- RedisStreamSubjectWriter: HandleInvalidEvent reports before adding the id to
  the acknowledgement list, leaving the poison entry pending forever.
  TraceReceipt has the same shape -- `ids.Add` follows it on the valid path.

Two regression tests, both red without the guards: the rehydrate still runs,
and the locks stay held for the cooldown.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The cooldown-lock regression test can pass before the asynchronous disposal path completes.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Comment thread tests/UiPath.Caching.Tests/RehydrationCoordinatorTests.cs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The safeguards consistently preserve intended control flow, with focused regression coverage for the critical paths.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

The spawn is fire-and-forget and the refusal is recorded inside the catch,
before the code that follows it runs, so asserting the lease was not disposed
straight after the refusal could win the race against a regression that lets
the finally release it.

The key stays reserved until the finally releases it, so a second trigger
succeeding is the signal that the first spawn is fully done. Two lock handles
keep that second run from muddying the assertion about the first.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The cooldown regression test still has a race that can allow the broken behavior to pass.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Comment thread tests/UiPath.Caching.Tests/RehydrationCoordinatorTests.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The cooldown test still has a completion race, and the stream acknowledgement fixes lack regression coverage.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity · 3 Low severity

Open (4)

Comment thread src/UiPath.Caching/Broadcast/Redis/RedisStreamSubjectWriter.cs Outdated
Comment thread src/UiPath.Caching/Broadcast/Redis/RedisStreamSubjectWriter.cs
Comment thread tests/UiPath.Caching.Tests/FactoryTimeoutTests.cs
Sonar IDE0039 on the Func<bool> the previous commit added.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
ReleaseInFlight runs at the top of the finally, before the await that releases
the locks, so a retrigger succeeding said nothing about the handle. The outer
catch is the deterministic signal instead: a refusal that escapes is logged
there, and that happens before the finally runs at all, so waiting for the
reservation to clear proves the log would already be written. The probe that
drains it takes its own lock handle and its own key.

Adds the two stream-writer regression tests the guards were missing: an
invalid event and a same-source event are both still acknowledged when the
sink refuses their record. Both are red against the raw TrackEvent.

Trims the comments and summaries this branch added.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The rehydration cooldown test probes a different key and therefore does not synchronize with completion of the spawn under test.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (4)

Comment thread tests/UiPath.Caching.Tests/RehydrationCoordinatorTests.cs Outdated
_inFlight is keyed by cache-key name, so the probe on a different key was
reserved independently and succeeded straight away, proving nothing about the
spawn under test. The second lock handle is what keeps the retry from
disposing the handle being asserted on, so the key can stay the same.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Same-source stream entries can still remain pending when metric telemetry throws before acknowledgement.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread src/UiPath.Caching/Broadcast/Redis/RedisStreamSubjectWriter.cs
Guarding the event call left TrackTopicReadMetric on the same-source path
still able to strand the entry: it reports before the id joins the
acknowledgement list, so a sink that refuses metrics reaches the outer handler
and the entry stays pending. Guarding call by call only holds until the next
one is added.

Both paths now add the id first. The entry is handled either way, so nothing
reported afterwards can leave it pending, and the ordering makes the defect
structurally impossible rather than guarded.

The same-source test refuses metrics as well as the event, and is red without
the reorder even with the event guarded. TrackTopicReadMetric in ChangeToken
is the last statement of its branch, so a refusal there costs only the record.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The behavior is consistently safeguarded and regression-tested; only two non-blocking test comments have stale wording.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
Resolved since last review (1)

Comment thread tests/UiPath.Caching.Tests/Broadcast/RedisStreamSubjectWriterTests.cs Outdated
The reorder made both comments describe behaviour the code no longer has.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The functional changes are well-contained and regression-tested; only a non-blocking stale test comment remains.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Low severity Update regression comment to reflect current ID ordering

tests/​UiPath.Caching.Tests/​Broadcast/​RedisStreamSubjectWriterTests.cs:439

This regression comment still describes the old ordering, but the implementation now adds the ID before calling TraceReceipt. Rephrase it as historical context so the test does not contradict the code it covers.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Moderate regression-coverage gaps remain for lock release, memory-cache callbacks, and stream event refusal.

Review effort: Balanced
Findings: None

Lock acquire failure on both paths, lock release, the memory-cache
refresh callback, the stream receipt on the dispatch path, and the
rehydrate failed outcome, which strands the cooldown locks the same way
timed_out does. Each fails against the raw call it guards.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

The fire-and-forget warm-up path still lacks dedicated refusal regression coverage.

Review effort: Balanced
Findings: None

Previously missed (1)

In code that hasn't changed since last review

Low severity Add regression test for throwing telemetry sink during warm-up

src/​UiPath.Caching/​Redis/​RedisConnectionWarmup.cs:44

The existing StartAsync_TracksException_WhenConnectFails test uses CapturingTelemetry, whose TrackException succeeds, so it passes unchanged with the old raw call and does not exercise this new guard. Because WarmUpAsync is fire-and-forget, a refusing exception sink would fault that background task; add a regression test with a throwing TrackException provider and a completion signal that verifies warm-up remains best effort.

This branch has not been deployed

No deployments
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