Skip to content

fix: protect lazy workClient init with double-checked lock#79

Open
rrp-bot wants to merge 2 commits into
openshift-online:mainfrom
rrp-bot:fix/ci-nightly-ephemeral-2026-04-05
Open

fix: protect lazy workClient init with double-checked lock#79
rrp-bot wants to merge 2 commits into
openshift-online:mainfrom
rrp-bot:fix/ci-nightly-ephemeral-2026-04-05

Conversation

@rrp-bot

@rrp-bot rrp-bot commented Apr 27, 2026

Copy link
Copy Markdown

Summary

Supersedes #62.

PR #62 introduced lazy re-initialization of c.workClient in CreateManifestWork to recover from the maestro startup-ordering race, but the check-then-act sequence was unsynchronized. Under concurrent requests arriving while c.workClient is still nil, multiple goroutines could each pass the outer nil check and race to call NewMaestroGRPCSourceWorkClient, leaking connections and overwriting each other's result.

This PR keeps the lazy-init approach from #62 and adds a sync.Mutex (workClientMu) to the Client struct, implementing a double-checked lock:

  • Outer unsynchronized nil check — lock-free fast path once the client is initialized; no overhead on the hot path.
  • workClientMu.Lock() + inner nil check — ensures exactly one goroutine runs NewMaestroGRPCSourceWorkClient even under concurrent requests.
  • The mutex is unlocked explicitly (not deferred) before returning, keeping the critical section minimal and not holding the lock during the gRPC call itself.

Changes

  • pkg/clients/maestro/client.go: add "sync" import, add workClientMu sync.Mutex field to Client, replace bare nil-guard in CreateManifestWork with double-checked lock.

Test Plan

  • Nightly ephemeral job passes with this fix
  • POST /api/v0/work returns 201 even when platform-api pod starts before maestro-server is fully ready
  • No data race detected under go test -race
  • No regressions in existing unit tests

🤖 Generated with Claude Code

rosa-regional-platform-ci and others added 2 commits April 5, 2026 06:10
… race

When the platform-api pod starts before maestro-server finishes its
database-migration init container, NewMaestroGRPCSourceWorkClient fails
and leaves workClient nil.  Previously this caused every subsequent
POST /api/v0/work request to return HTTP 500 for the lifetime of the
pod, since the nil check returned a plain error (not maestro.Error) which
the work handler mapped to 500.

Fix: attempt a lazy re-initialization on the first CreateManifestWork
call when workClient is nil.  By that point maestro is typically healthy
(the e2e tests run ~44 min after provision starts), so the re-init
succeeds and the request proceeds normally without needing a pod restart.

Fixes nightly-ephemeral CI failure observed on 2026-04-05 (build
2040641173546078208): "should be able to create new manifestwork on each
of the registered management clusters" — expected 201, got 500.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Concurrent calls to CreateManifestWork could each pass the nil check
and race to create multiple gRPC clients. Serialize the check-then-init
sequence with workClientMu so only one goroutine ever calls
NewMaestroGRPCSourceWorkClient; the outer unsynchronized nil check
keeps the hot path lock-free once the client is initialized.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@openshift-ci
openshift-ci Bot requested a review from cdoan1 April 27, 2026 11:05
@openshift-ci

openshift-ci Bot commented Apr 27, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: rrp-bot
Once this PR has been reviewed and has the lgtm label, please assign theautoroboto for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@openshift-ci
openshift-ci Bot requested a review from psav April 27, 2026 11:05
@openshift-ci openshift-ci Bot added the needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. label Apr 27, 2026
@openshift-ci

openshift-ci Bot commented Apr 27, 2026

Copy link
Copy Markdown

Hi @rrp-bot. Thanks for your PR.

I'm waiting for a openshift-online member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Tip

We noticed you've done this a few times! Consider joining the org to skip this step and gain /lgtm and other bot rights. We recommend asking approvers on your previous PRs to sponsor you.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced error handling for client connection initialization failures, ensuring safe recovery and proper error reporting when setup issues occur.

Walkthrough

The changes implement safe lazy initialization for the gRPC work client in the maestro client. When c.workClient is nil during a CreateManifestWork call, the code acquires a mutex, rechecks the client state, and initializes it via grpcsource.NewMaestroGRPCSourceWorkClient. Initialization comments were updated to reflect this defensive behavior.

Changes

Cohort / File(s) Summary
Lazy Client Initialization
pkg/clients/maestro/client.go
Adds mutex-protected double-check locking pattern for lazy initialization of the gRPC work client in CreateManifestWork. Initializes the client on-demand if nil and wraps initialization errors with error formatting.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: protect lazy workClient init with double-checked lock' accurately describes the main change: adding mutex protection to the lazy initialization of the workClient field.
Description check ✅ Passed The description is clearly related to the changeset, providing detailed context about the race condition being fixed, the solution approach with double-checked locking, and a concrete test plan.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

@psav

psav commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

/ok-to-test

@openshift-ci openshift-ci Bot added ok-to-test Indicates a non-member PR verified by an org member that is safe to test. and removed needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. labels Apr 27, 2026

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/clients/maestro/client.go`:
- Around line 418-424: The lazy-init branch constructs the long-lived gRPC work
client with the per-request ctx, which can bind the stored Client.grpc work
client to a cancellable request context; update the lazy-init call to
grpcsource.NewMaestroGRPCSourceWorkClient to use context.Background() (same as
the startup path) so the created client’s lifetime isn’t tied to a request;
locate the construction in the lazy-init code that passes ctx along with
c.openapiClient, c.grpcOpts, and c.sourceID and replace the ctx argument with
context.Background().
- Around line 412-432: The double-checked locking on the plain interface field
c.workClient is a data race; replace the plain field + workClientMu double-check
with a concurrency-safe pattern: store the work client in an atomic.Value (e.g.,
c.workClientVal) holding the workv1client.WorkV1Interface and use
atomic.Value.Load() for the lock-free fast path and atomic.Value.Store() inside
the locked initialization block that calls
grpcsource.NewMaestroGRPCSourceWorkClient; alternatively, if you accept
single-attempt initialization, replace the whole lazy-init with sync.Once to
call NewMaestroGRPCSourceWorkClient and set c.workClient. Ensure all
reads/writes that reference c.workClient (calls like
ManifestWorks(...).Create(...)) are changed to read from the atomic.Value (or
the sync.Once-populated field) to avoid racy interface accesses while keeping
the existing log/adapter (loggerAdapter) and error-handling behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Enterprise

Run ID: 97f31425-3a1e-43ed-9cae-66a58ec24631

📥 Commits

Reviewing files that changed from the base of the PR and between dcfaef4 and e8710b3.

📒 Files selected for processing (1)
  • pkg/clients/maestro/client.go

Comment on lines 412 to 432
if c.workClient == nil {
return nil, fmt.Errorf("gRPC work client not initialized")
c.workClientMu.Lock()
if c.workClient == nil {
c.logger.Info("gRPC work client not initialized at startup, attempting lazy initialization",
"cluster", clusterName)
adaptedLogger := &loggerAdapter{logger: c.logger}
wc, err := grpcsource.NewMaestroGRPCSourceWorkClient(
ctx,
adaptedLogger,
c.openapiClient,
c.grpcOpts,
c.sourceID,
)
if err != nil {
c.workClientMu.Unlock()
return nil, fmt.Errorf("gRPC work client initialization failed: %w", err)
}
c.workClient = wc
}
c.workClientMu.Unlock()
}

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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm there is no existing concurrent test exercising the DCL path, and inspect the
# return type of NewMaestroGRPCSourceWorkClient to choose between atomic.Value and
# atomic.Pointer[wrapper].
fd -e go . | xargs rg -nP '\bCreateManifestWork\s*\(' -C2
rg -nP --type=go -C3 '\bNewMaestroGRPCSourceWorkClient\s*\('

Repository: openshift-online/rosa-regional-platform-api

Length of output: 3273


🏁 Script executed:

# Search for grpcsource.NewMaestroGRPCSourceWorkClient implementation to understand return type and context handling
rg -nP --type=go -B2 -A10 'func NewMaestroGRPCSourceWorkClient'

Repository: openshift-online/rosa-regional-platform-api

Length of output: 69


🏁 Script executed:

# Check the workClient field declaration in the Client struct
rg -nP --type=go -B2 -A2 'type Client struct' pkg/clients/maestro/client.go | head -50

Repository: openshift-online/rosa-regional-platform-api

Length of output: 213


🏁 Script executed:

# Look for any race detector configuration in tests (go.mod, Makefile, test files)
find . -name 'Makefile' -o -name '*.mk' -o -name 'go.mod' | xargs grep -l 'race' 2>/dev/null || echo "No race config found"
fd -e go pkg/clients/maestro | xargs grep -l 'TestCreateManifestWork\|CreateManifestWork.*test' 2>/dev/null || echo "No unit tests for CreateManifestWork found"

Repository: openshift-online/rosa-regional-platform-api

Length of output: 550


🏁 Script executed:

# Get the full Client struct definition
sed -n '76,120p' pkg/clients/maestro/client.go

Repository: openshift-online/rosa-regional-platform-api

Length of output: 1463


🏁 Script executed:

# Check imports in client.go to understand where grpcsource comes from
head -20 pkg/clients/maestro/client.go

Repository: openshift-online/rosa-regional-platform-api

Length of output: 522


🏁 Script executed:

# Check Makefile for race detector configuration
grep -A3 -B3 'race' ./Makefile | head -20

Repository: openshift-online/rosa-regional-platform-api

Length of output: 721


🏁 Script executed:

# Search for any integration or concurrent tests
fd '.*test.*\.go$' pkg/handlers | xargs grep -l 'CreateManifestWork\|t\.Parallel\|sync\.' 2>/dev/null | head -5

Repository: openshift-online/rosa-regional-platform-api

Length of output: 150


🏁 Script executed:

# Check work_test.go for concurrent tests and CreateManifestWork usage
cat -n pkg/handlers/work_test.go

Repository: openshift-online/rosa-regional-platform-api

Length of output: 19277


🏁 Script executed:

# Check if there are any imports or references to grpcsource return type documentation
rg -C5 'workv1client.WorkV1Interface' pkg/clients/maestro/

Repository: openshift-online/rosa-regional-platform-api

Length of output: 756


🏁 Script executed:

# Check the actual context usage in the lazy initialization path vs startup
sed -n '126,135p' pkg/clients/maestro/client.go
sed -n '412,432p' pkg/clients/maestro/client.go

Repository: openshift-online/rosa-regional-platform-api

Length of output: 1012


🌐 Web query:

gRPC context lifetime NewClient context.Background vs request context retained

💡 Result:

In gRPC-Go, the ClientConn created by grpc.NewClient(target, opts...) internally uses a long-lived context created from context.Background: cc.ctx, cc.cancel = context.WithCancel(context.Background). This context is cancelled only when ClientConn.Close is called, managing the overall lifetime of the connection, including name resolution, connection establishment, and reconnection attempts. This differs from grpc.DialContext(ctx, target, opts...), where the provided ctx controls only the initial connection attempt (if using WithBlock). The DialContext ctx does not persist or affect the ClientConn's lifetime after creation; RPCs use their own per-call contexts. For individual RPCs (e.g., client.Method(ctx, req)), the provided request ctx always controls that specific call's lifetime, including cancellation and deadlines. These per-request contexts are independent of the ClientConn's internal context and are not "retained" from the NewClient/Dial call. Each RPC must pass an appropriate ctx (typically derived from context.Background or a parent ctx with timeout/deadline). Using context.Background for NewClient (implicitly) or DialContext is correct for long-lived clients, as it never cancels. Per-request contexts should carry cancellation/deadlines appropriate to each call. The ClientConn context ensures graceful shutdown on Close, but RPCs propagate their own ctx cancellation to servers. Official gRPC-Go docs discourage pre-connecting with Dial/WithBlock (anti-pattern), favoring NewClient which starts idle and connects on first RPC.

Citations:


Double-checked locking on a plain interface field is a data race in Go — go test -race will flag it.

The outer if c.workClient == nil at line 412 reads the interface without holding workClientMu, while another goroutine may concurrently write c.workClient = wc at line 429 under the lock. Per the Go memory model this is undefined behavior:

  1. The race detector will fail this code when go test -race runs (which your Makefile already does for all unit tests).
  2. workv1client.WorkV1Interface is an interface value (two words: type + data pointer). A racy concurrent read can observe a partially-written state—e.g., a non-nil type word with a nil/garbage data pointer—causing the fast path to proceed and panic with a nil dereference inside ManifestWorks(...).Create(...) at line 435.

Use atomic.Value to store the client interface instead of a plain field. This preserves your lock-free fast-path read and retry-on-failure semantics while making reads and writes safe under concurrency. Alternatively, sync.Once works if you can accept no retry (fail once, stay failed).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/clients/maestro/client.go` around lines 412 - 432, The double-checked
locking on the plain interface field c.workClient is a data race; replace the
plain field + workClientMu double-check with a concurrency-safe pattern: store
the work client in an atomic.Value (e.g., c.workClientVal) holding the
workv1client.WorkV1Interface and use atomic.Value.Load() for the lock-free fast
path and atomic.Value.Store() inside the locked initialization block that calls
grpcsource.NewMaestroGRPCSourceWorkClient; alternatively, if you accept
single-attempt initialization, replace the whole lazy-init with sync.Once to
call NewMaestroGRPCSourceWorkClient and set c.workClient. Ensure all
reads/writes that reference c.workClient (calls like
ManifestWorks(...).Create(...)) are changed to read from the atomic.Value (or
the sync.Once-populated field) to avoid racy interface accesses while keeping
the existing log/adapter (loggerAdapter) and error-handling behavior.

Comment on lines +418 to +424
wc, err := grpcsource.NewMaestroGRPCSourceWorkClient(
ctx,
adaptedLogger,
c.openapiClient,
c.grpcOpts,
c.sourceID,
)

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In the openshift-online/maestro Go SDK, does grpcsource.NewMaestroGRPCSourceWorkClient retain the ctx argument for the lifetime of the returned client (e.g., for background goroutines or watch streams), or is it only used during initial connection setup?

💡 Result:

No definitive source code or documentation found for grpcsource.NewMaestroGRPCSourceWorkClient in the openshift-online/maestro Go SDK despite extensive searches of GitHub, pkg.go.dev, and related OpenShift repositories. The repository https://github.com/openshift-online/maestro exists and contains gRPC APIs as described, but specific function details are not publicly indexed or accessible in search results. In standard Go gRPC clients (e.g., generated from protoc), the ctx argument to NewClient is used only for dialing/connection setup, not retained for the client lifetime. For long-lived clients used in background goroutines or watch streams, a background context (context.Background or long-lived ctx) should be passed during client creation, and per-call contexts used for individual RPCs/streams. The client connection itself is intended to be reused across multiple calls.

Citations:


🏁 Script executed:

#!/bin/bash
# Locate the vendored/imported source of NewMaestroGRPCSourceWorkClient to inspect ctx usage.
fd -e go . | xargs rg -nP -C5 'func\s+NewMaestroGRPCSourceWorkClient\s*\('

Repository: openshift-online/rosa-regional-platform-api

Length of output: 71


🏁 Script executed:

#!/bin/bash
# Find the grpcsource import and related context
cd pkg/clients/maestro
head -50 client.go | grep -A5 -B5 import

Repository: openshift-online/rosa-regional-platform-api

Length of output: 163


🏁 Script executed:

#!/bin/bash
# Check what happens with wc after it's created (lines 425+)
cd pkg/clients/maestro
sed -n '418,450p' client.go

Repository: openshift-online/rosa-regional-platform-api

Length of output: 972


🏁 Script executed:

#!/bin/bash
# Check the startup path at line 128
cd pkg/clients/maestro
sed -n '120,140p' client.go

Repository: openshift-online/rosa-regional-platform-api

Length of output: 781


🏁 Script executed:

#!/bin/bash
# Find where grpcsource package is defined or imported from
rg 'grpcsource' pkg/clients/maestro/ -A 2 -B 2

Repository: openshift-online/rosa-regional-platform-api

Length of output: 1126


🏁 Script executed:

#!/bin/bash
# Check go.mod to see maestro dependency
cat go.mod | grep maestro

Repository: openshift-online/rosa-regional-platform-api

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Check if maestro is vendored
ls -la vendor/ | grep maestro

Repository: openshift-online/rosa-regional-platform-api

Length of output: 142


🏁 Script executed:

#!/bin/bash
# Try to find the actual source - search in vendor for grpcsource
find vendor -type f -name "*grpcsource*" 2>/dev/null | head -20

Repository: openshift-online/rosa-regional-platform-api

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Search vendor for NewMaestroGRPCSourceWorkClient implementation
rg -i "NewMaestroGRPCSourceWorkClient" vendor/ -A 20 2>/dev/null | head -100

Repository: openshift-online/rosa-regional-platform-api

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Check if there are test files that show how the client is used
find . -name "*test.go" -type f | grep maestro | head -10

Repository: openshift-online/rosa-regional-platform-api

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Look for any comments or documentation about context usage in client.go
cat pkg/clients/maestro/client.go | grep -i -C3 "context\|lifetime\|cancel\|background"

Repository: openshift-online/rosa-regional-platform-api

Length of output: 8286


🏁 Script executed:

#!/bin/bash
# Check if workClient is accessed/used multiple times
rg "c\.workClient" pkg/clients/maestro/client.go -n

Repository: openshift-online/rosa-regional-platform-api

Length of output: 373


🏁 Script executed:

#!/bin/bash
# Look for concurrent access patterns or request handling
rg -B5 -A5 "workClientMu\|workClient" pkg/clients/maestro/client.go

Repository: openshift-online/rosa-regional-platform-api

Length of output: 69


Use context.Background() in the lazy-init path to match the startup path.

The gRPC work client is stored in the Client struct and reused across multiple requests (lines 429, 435). The startup path initializes it with context.Background() (line 128), but the lazy path passes the per-request ctx (line 418). For long-lived clients that are stored and reused, the context passed to the constructor should never be cancelled; using a per-request context here risks binding the client's lifetime to that first request's context. Ensure both paths use context.Background() consistently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/clients/maestro/client.go` around lines 418 - 424, The lazy-init branch
constructs the long-lived gRPC work client with the per-request ctx, which can
bind the stored Client.grpc work client to a cancellable request context; update
the lazy-init call to grpcsource.NewMaestroGRPCSourceWorkClient to use
context.Background() (same as the startup path) so the created client’s lifetime
isn’t tied to a request; locate the construction in the lazy-init code that
passes ctx along with c.openapiClient, c.grpcOpts, and c.sourceID and replace
the ctx argument with context.Background().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok-to-test Indicates a non-member PR verified by an org member that is safe to test.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants