Skip to content

fix: guard LDAP connection reconnect with a mutex to remove data race - #323

Open
mvanhorn wants to merge 2 commits into
redhat-data-and-ai:mainfrom
mvanhorn:fix/277-guard-ldap-reconnect-mutex
Open

fix: guard LDAP connection reconnect with a mutex to remove data race#323
mvanhorn wants to merge 2 commits into
redhat-data-and-ai:mainfrom
mvanhorn:fix/277-guard-ldap-reconnect-mutex

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Changes

📝 Description

What changed?

getConn() in pkg/clients/ldap/client.go now takes a sync.Mutex before it checks IsClosing(), reconnects, and writes l.conn. Previously these steps ran without synchronization, so two goroutines could both reconnect and write l.conn at the same time. A new mu sync.Mutex field guards every read and write of l.conn.

Why is this change needed?

When the reconciler, the offboarding job, and LDAP query resolution call getConn() concurrently while the connection is closing, each goroutine sees IsClosing() true and dials a new connection. That leaks all but one of the new connections and races on the unsynchronized l.conn = newConn write. This fixes #277.

Dependencies

  • N/A

🧪 Testing

Test Coverage

Added two tests in pkg/clients/ldap/client_test.go:

  • TestGetLdapConnection_ConcurrentReconnect: 50 goroutines call getConn() at once on a closing connection and assert exactly one reconnect, one bind, and a single shared l.conn.
  • TestGetLdapConnection_ReconnectFailureKeepsExistingConnection: when the dial fails, getConn() returns nil and leaves the existing l.conn untouched.

Commands run:

  • go build ./... passed.
  • go test -race ./pkg/clients/ldap/... passed.

The reconnect dial is now reached through a dialLDAP package variable so the concurrency test can inject a stub without a live LDAP server. Real behavior is unchanged: it dials the same URL with the same 5s timeout.

Performance Impact

  • N/A. The mutex is held only across the closing-check and reconnect on a single connection object; the common healthy-connection path takes and releases the lock once with no contention.

🚀 Deployment

Deploy Steps

  1. N/A

Prerequisites

  • N/A

Post-Deployment Monitoring

  • N/A

Rollback Plan

  • N/A

⚠️ Breaking Changes

  • This PR contains breaking changes
  • Migration guide provided (if applicable)

Details:

  • N/A

⚙️ Configuration Changes

  • N/A

✅ Developer Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added positive and negative tests that prove my fix is effective or that my feature works
  • Relevant documentation (README, tech specs, etc.) has been added or updated
  • All CI/CD checks are passing

Fixes #277

Summary by CodeRabbit

  • Bug Fixes

    • Improved LDAP connection stability during concurrent reconnects.
    • Prevented stale reconnect attempts from replacing healthy connections.
    • Preserved existing connections when reconnection attempts fail.
  • Tests

    • Added coverage for concurrent reconnect handling and connection replacement.
    • Added validation that failed reconnects retain the current connection.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a data race in the LDAP client's connection management. By introducing a mutex to guard the connection retrieval process, it ensures that reconnection logic is executed atomically, preventing multiple redundant connections and potential race conditions during concurrent access.

Highlights

  • Concurrency Fix: Added a sync.Mutex to the LDAPConn struct to ensure thread-safe access to the underlying connection.
  • Race Condition Resolution: Prevented multiple concurrent goroutines from triggering redundant reconnects when the connection is closing.
  • Test Coverage: Added comprehensive tests to verify concurrent reconnection behavior and failure handling using a stubbed dialer.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.


A mutex stands guard at the gate, / To keep the connection state straight. / No races shall run, / With locks, we are done, / And concurrency meets its own fate.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces thread safety to the LDAP client connection retrieval by adding a mutex to the LDAPConn struct and refactoring connection creation into a testable dialLDAP function, supported by new concurrent tests. The review feedback recommends optimizing this concurrency model by switching from sync.Mutex to sync.RWMutex. This allows a fast-path read lock check in getConn() when the connection is healthy, preventing unnecessary exclusive lock contention on every LDAP operation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread pkg/clients/ldap/client.go Outdated
}

type LDAPConn struct {
mu sync.Mutex

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Using a simple sync.Mutex means that every call to getConn() will acquire an exclusive lock, even when the connection is perfectly healthy. Since getConn() is called on every LDAP operation, this introduces unnecessary lock contention and serializes all LDAP queries across the operator.

Using a sync.RWMutex allows us to use a fast-path read lock (RLock) to check if the connection is healthy, which completely eliminates contention on the hot path.

Suggested change
mu sync.Mutex
mu sync.RWMutex
References
  1. The repository style guide recommends using sync.RWMutex and utilizing .RLock() for read-only operations as a future enhancement to improve concurrency. (link)

Comment thread pkg/clients/ldap/client.go Outdated
Comment on lines 83 to 87
func (l *LDAPConn) getConn() LDAPConnClient {
l.mu.Lock()
defer l.mu.Unlock()

if l.conn != nil && l.conn.IsClosing() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Implement a fast-path check using a read lock (RLock) to avoid exclusive lock contention when the connection is healthy. If the connection is closing, release the read lock and acquire the write lock (Lock) to perform the reconnect safely with double-checking.

Suggested change
func (l *LDAPConn) getConn() LDAPConnClient {
l.mu.Lock()
defer l.mu.Unlock()
if l.conn != nil && l.conn.IsClosing() {
func (l *LDAPConn) getConn() LDAPConnClient {
l.mu.RLock()
if l.conn != nil && !l.conn.IsClosing() {
conn := l.conn
l.mu.RUnlock()
return conn
}
l.mu.RUnlock()
l.mu.Lock()
defer l.mu.Unlock()
if l.conn != nil && l.conn.IsClosing() {
References
  1. The repository style guide recommends using sync.RWMutex and utilizing .RLock() for read-only operations as a future enhancement to improve concurrency. (link)

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mvanhorn, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e8edcf3-217a-4b0b-9c47-b2eb56aa2341

📥 Commits

Reviewing files that changed from the base of the PR and between c07e20c and 3d6a5e3.

📒 Files selected for processing (1)
  • pkg/clients/ldap/client.go
📝 Walkthrough

Walkthrough

Changes

LDAP reconnect handling

Layer / File(s) Summary
Synchronized connection lifecycle
pkg/clients/ldap/client.go
LDAPConn uses an injectable dialer, synchronized connection access, generation checks, and generation updates after successful reconnection.
Concurrent reconnect validation
pkg/clients/ldap/client_test.go
Tests verify single-flight reconnect behavior, connection preservation after dial failure, and thread-safe mock connection counters.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: iambibhas

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the LDAP reconnect synchronization fix and the data race it addresses.
Description check ✅ Passed The description covers the required change, rationale, testing, deployment impact, breaking changes, configuration, and checklist items.
Linked Issues check ✅ Passed The changes satisfy issue #277 by synchronizing LDAP connection access, preventing duplicate reconnects, and adding race-focused tests.
Out of Scope Changes check ✅ Passed The dialer injection, generation tracking, locking changes, and tests directly support the LDAP reconnect race fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (3)
pkg/clients/ldap/client.go (2)

85-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider deferring the read-lock release for consistency.

l.mu.RUnlock() is called manually in two branches (Line 89 and Line 92). The write-lock section below uses defer l.mu.Unlock() (Line 95). Use the same defer pattern for the read lock, immediately after l.mu.RLock(), so a future code path added between the lock and return cannot skip the unlock and cause a permanent read-lock hold on this shared connection guard.

♻️ Proposed refactor
 	l.mu.RLock()
+	defer l.mu.RUnlock()
 	conn := l.conn
 	connGeneration := l.connGeneration
 	if conn != nil && !conn.IsClosing() {
-		l.mu.RUnlock()
 		return conn
 	}
-	l.mu.RUnlock()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/clients/ldap/client.go` around lines 85 - 92, Update the read-lock
handling in the connection retrieval block to defer l.mu.RUnlock() immediately
after l.mu.RLock(), and remove both manual unlock calls while preserving the
existing return behavior for valid connections and the subsequent write-lock
flow.

94-117: 🩺 Stability & Availability | 🔵 Trivial

Note the retry behavior under sustained dial failures.

The reconnect (dial + bind) runs while holding the exclusive write lock, which is expected since there's a single shared connection. However, a failed dial does not advance connGeneration (only the success path at Line 116 does). So if the LDAP server stays unreachable, each caller that arrives after a prior failure re-attempts its own full dial+bind serially under the lock rather than reusing the just-failed result. With many concurrent callers during an outage, this can serialize to N × 5s (the dial timeout) before all callers give up. Consider a short-lived failure cache or backoff so concurrent callers arriving during an active outage window don't each pay the full dial timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/clients/ldap/client.go` around lines 94 - 117, Update the reconnect logic
around the connection-generation check and dialLDAP/UnauthenticatedBind flow to
cache failed reconnect attempts or apply a short backoff window. Record the
failed attempt and its expiry while holding the existing lock, and have callers
arriving during that active outage window return the current connection without
retrying dial and bind; preserve the existing successful reconnect behavior and
generation increment.
pkg/clients/ldap/client_test.go (1)

104-124: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extend coverage for two related reconnect paths.

This test covers a dial failure. Two related paths in getConn() remain untested:

  • A bind failure after a successful dial (client.go Lines 108-114), which should call newConn.Close() and increment closeCount on the mock.
  • Concurrent callers hitting a failing dial at the same time, to confirm connGeneration stays unchanged and no caller observes a partially-updated connection.

Add these two cases to increase confidence in the new locking and generation logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/clients/ldap/client_test.go` around lines 104 - 124, Extend
TestGetLdapConnection_ReconnectFailureKeepsExistingConnection coverage with a
bind-failure case that uses a successfully dialed new connection, verifies the
bind error, confirms newConn.Close() increments the mock closeCount, and
preserves the existing connection. Add a concurrent failing-dial case using
synchronized callers, asserting all callers receive failure, connGeneration
remains unchanged, and ldapConn.conn is never partially replaced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@pkg/clients/ldap/client_test.go`:
- Around line 104-124: Extend
TestGetLdapConnection_ReconnectFailureKeepsExistingConnection coverage with a
bind-failure case that uses a successfully dialed new connection, verifies the
bind error, confirms newConn.Close() increments the mock closeCount, and
preserves the existing connection. Add a concurrent failing-dial case using
synchronized callers, asserting all callers receive failure, connGeneration
remains unchanged, and ldapConn.conn is never partially replaced.

In `@pkg/clients/ldap/client.go`:
- Around line 85-92: Update the read-lock handling in the connection retrieval
block to defer l.mu.RUnlock() immediately after l.mu.RLock(), and remove both
manual unlock calls while preserving the existing return behavior for valid
connections and the subsequent write-lock flow.
- Around line 94-117: Update the reconnect logic around the
connection-generation check and dialLDAP/UnauthenticatedBind flow to cache
failed reconnect attempts or apply a short backoff window. Record the failed
attempt and its expiry while holding the existing lock, and have callers
arriving during that active outage window return the current connection without
retrying dial and bind; preserve the existing successful reconnect behavior and
generation increment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a64102b-64d0-4fb7-a821-0578da0d105a

📥 Commits

Reviewing files that changed from the base of the PR and between 7e12efa and c07e20c.

📒 Files selected for processing (2)
  • pkg/clients/ldap/client.go
  • pkg/clients/ldap/client_test.go

getConn runs on every LDAP operation, so taking an exclusive lock even when the
connection is healthy serialized all queries. Use an RWMutex: check the healthy
case under RLock, and only escalate to the write lock when a reconnect is
actually needed. A generation counter makes the escalation safe -- if another
goroutine reconnected while we were upgrading, we return its connection instead
of dialing a second time.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn
mvanhorn force-pushed the fix/277-guard-ldap-reconnect-mutex branch from c07e20c to 3d6a5e3 Compare August 2, 2026 22:59
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.

[H6] LDAP connection reconnect has a data race

1 participant