Skip to content

fix: prevent throttling deadlock in cloudwatchlogs output#2190

Open
lorespiz wants to merge 1 commit into
mainfrom
fix/logthrottle-retryer-deadlock
Open

fix: prevent throttling deadlock in cloudwatchlogs output#2190
lorespiz wants to merge 1 commit into
mainfrom
fix/logthrottle-retryer-deadlock

Conversation

@lorespiz

@lorespiz lorespiz commented Jul 9, 2026

Copy link
Copy Markdown

Description of the issue

On a ThrottlingException for CreateLogStream/CreateLogGroup, log-stream creation could wedge for the entire output plugin with nothing logged.

LogThrottleRetryer.ShouldRetry performed an unconditional blocking send to a capacity-1 throttle-event channel drained by a single consumer goroutine. The shared TargetManager was bound (via sync.Once) to the first destination's client and retryer; when that destination stopped (e.g. log rotation with auto_removal), its retryer's consumer goroutine exited. The next throttle then filled the buffer and blocked the send forever, and because TargetManager.InitTarget holds a mutex across the create call, all other targets waiting on that mutex stalled as well.

Description of changes

Two changes, either of which breaks the deadlock; both applied:

  • logthrottle.go: make the throttle-event send non-blocking (select with a default) so a full channel or stopped consumer can never block ShouldRetry. The retry decision is unchanged.
  • cloudwatchlogs.go: give the shared TargetManager its own dedicated retryer and client owned by the plugin, stopped only in Close(), instead of reusing the first destination's client. This decouples the TargetManager lifecycle from any single destination.

License

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Tests

  • TestShouldRetryDoesNotBlockAfterStop: ShouldRetry returns after the retryer is stopped (fails via timeout on the pre-fix code).
  • TestSharedRetryerLifecycle: stopping the first destination does not prevent creating additional destinations.
  • TestInitTargetNoDeadlockUnderThrottling: end-to-end regression driving the real SDK retry loop against an always-throttling endpoint through InitTarget with a stopped retryer consumer; deadlocks on the pre-fix code, passes with the fix.

Manually validated agent behaviour before and after the fix using a local HTTP server mocking CreateLogGroup / CreateLogStream throttled requests

Requirements

Before commiting your code, please do the following steps.

  1. Run make fmt and make fmt-sh
  2. Run make lint

Integration Tests

To run integration tests against this PR, add the ready for testing label.

On a ThrottlingException for CreateLogStream/CreateLogGroup, log-stream
creation could wedge for the entire output plugin with nothing logged.

LogThrottleRetryer.ShouldRetry performed an unconditional blocking send to
a capacity-1 throttle-event channel drained by a single consumer goroutine.
The shared TargetManager was bound (via sync.Once) to the first
destination's client and retryer; when that destination stopped (e.g. log
rotation with auto_removal), its retryer's consumer goroutine exited. The
next throttle then filled the buffer and blocked the send forever, and
because TargetManager.InitTarget holds a mutex across the create call, all
other targets waiting on that mutex stalled as well.

Two changes, either of which breaks the deadlock; both applied:

- logthrottle.go: make the throttle-event send non-blocking (select with a
  default) so a full channel or stopped consumer can never block
  ShouldRetry. The retry decision is unchanged.
- cloudwatchlogs.go: give the shared TargetManager its own dedicated
  retryer and client owned by the plugin, stopped only in Close(), instead
  of reusing the first destination's client. This decouples the
  TargetManager lifecycle from any single destination.

Tests:
- TestShouldRetryDoesNotBlockAfterStop: ShouldRetry returns after the
  retryer is stopped (fails via timeout on the pre-fix code).
- TestSharedRetryerLifecycle: stopping the first destination does not
  prevent creating additional destinations.
- TestInitTargetNoDeadlockUnderThrottling: end-to-end regression driving
  the real SDK retry loop against an always-throttling endpoint through
  InitTarget with a stopped retryer consumer; deadlocks on the pre-fix
  code, passes with the fix.
@lorespiz lorespiz requested a review from a team as a code owner July 9, 2026 16:52
@lorespiz lorespiz added the ready for testing Indicates this PR is ready for integration tests to run label Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for testing Indicates this PR is ready for integration tests to run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant