Skip to content

🐛 should send the header immediately - #194

Merged
openshift-merge-bot[bot] merged 1 commit into
open-cluster-management-io:mainfrom
skeeey:header
Jan 27, 2026
Merged

🐛 should send the header immediately#194
openshift-merge-bot[bot] merged 1 commit into
open-cluster-management-io:mainfrom
skeeey:header

Conversation

@skeeey

@skeeey skeeey commented Jan 23, 2026

Copy link
Copy Markdown
Member

Summary

we meet the error failed to subscribe after connection" err="expected exactly one subscription-id header, got 0" sometimes,

Related issue(s)

Fixes #

Summary by CodeRabbit

  • Bug Fixes

    • More immediate and reliable subscription header delivery to clients.
    • Improved reconnection and concurrent subscription handling to reduce races and dropped confirmations.
    • Added server-side error propagation and a timeout path when subscription IDs are missing.
    • Clearer gRPC status responses for authorization failures.
    • Subscribe now retries with backoff on failures to improve resiliency.
  • Tests

    • Added tests for header delivery, reconnection scenarios, concurrent subscriptions, and rapid reconnect cycles.

✏️ Tip: You can customize this high-level summary in your review settings.

@openshift-ci
openshift-ci Bot requested review from deads2k and qiujian16 January 23, 2026 10:22
@coderabbitai

coderabbitai Bot commented Jan 23, 2026

Copy link
Copy Markdown

Walkthrough

Pre-generates and emits a subscription-id header before registering subscribers in the gRPC broker; adds registerSubscriber and unregister methods; refactors Subscribe to register a handler that forwards CloudEvents via an internal channel; adds tests for header timing/reconnect/concurrency; updates client header handling and gRPC auth error types.

Changes

Cohort / File(s) Summary
Core subscription refactor
pkg/cloudevents/server/grpc/broker.go
Replaced register with registerSubscriber(ctx, id, ...), added unregister(ctx, id); Subscribe now pre-generates subID, sends subscription-id header immediately, augments logging/context with subID, and registers a handler that converts CloudEvent→protobuf and forwards events via an internal channel; adjusted lifecycle, heartbeat, and dispatch logging.
Subscription tests
pkg/cloudevents/server/grpc/broker_test.go
Added tests: TestSubscriptionHeaderImmediateSend, TestReconnectionScenario, TestConcurrentSubscriptions, TestMultipleRapidReconnections to validate immediate header emission, reconnection behavior, concurrent subscriptions, and rapid reconnect races.
Client header handling
pkg/cloudevents/generic/options/v2/grpc/transport.go
When subscription-id header count is unexpected, defers to Recv() (in goroutine) with timeout to surface server-side errors or timeouts instead of immediate header-count error.
gRPC auth error types
pkg/server/grpc/server.go
Authorization error returns converted from plain errors to gRPC status errors (PermissionDenied, Unauthenticated, Internal) in unary and stream auth paths.
Client subscribe resilience
pkg/cloudevents/generic/clients/baseclient.go
subscribe now retries Subscribe calls with backoff until success or context cancel before proceeding to signal receiver start/resync.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • deads2k
  • qiujian16
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description identifies the problem being fixed but lacks implementation details and is incomplete compared to the template requirements. Add a detailed summary explaining how the header is now sent immediately, which files were modified, and why this prevents the error. Fill in the Related issue(s) section if applicable.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title directly addresses the main change: sending the subscription-id header immediately to fix the intermittent subscription error.

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

✨ Finishing touches
  • 📝 Generate docstrings

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.

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@pkg/cloudevents/server/grpc/broker_test.go`:
- Around line 302-307: The error reporting loop uses the loop index i which is
not the actual client ID because errors arrive out-of-order; change errCh from
chan error to a channel that carries both clientID and error (e.g., a struct
{clientID int; err error} or a tuple), update the client goroutine(s) that send
to errCh to send their clientID along with the error, then in the receiver loop
read the result (e.g., res := <-errCh) and use res.clientID when logging
failures instead of i; keep numClients and the loop bounds the same.
🧹 Nitpick comments (2)
pkg/cloudevents/server/grpc/broker.go (1)

127-149: The registerSubscriber method always returns nil - consider simplifying.

The method signature returns error, but the implementation always returns nil at line 148. This makes the error check at lines 224-226 dead code currently. Either:

  1. Simplify to return nothing if no error conditions are expected, or
  2. Keep as-is for future-proofing if error conditions might be added later

Given this is a new internal helper, the current defensive pattern is acceptable for extensibility.

pkg/cloudevents/server/grpc/broker_test.go (1)

139-166: Consider extracting common test setup to a helper function.

The server/broker/listener setup code (approximately lines 140-166) is duplicated across all four new tests and the existing TestServer. Extracting this to a helper would reduce duplication and make tests easier to maintain.

Example helper extraction
func setupTestBroker(t *testing.T, ctx context.Context) (*GRPCBroker, *testService, net.Listener, func()) {
	t.Helper()
	grpcServer := grpc.NewServer()
	broker := NewGRPCBroker(NewBrokerOptions())
	pbv1.RegisterCloudEventServiceServer(grpcServer, broker)

	svc := &testService{evts: make(map[string]*cloudevents.Event)}
	broker.RegisterService(ctx, dataType, svc)

	lis, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("failed to listen: %v", err)
	}

	go func() {
		if err := grpcServer.Serve(lis); err != nil {
			t.Errorf("failed to serve: %v", err)
		}
	}()

	cleanup := func() {
		grpcServer.GracefulStop()
		_ = lis.Close()
	}

	return broker, svc, lis, cleanup
}

Comment on lines +302 to +307
// Wait for all clients to complete
for i := 0; i < numClients; i++ {
if err := <-errCh; err != nil {
t.Errorf("client %d failed: %v", i, err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Misleading client ID in error message.

The variable i in the error message is the loop counter for receiving from errCh, not the actual clientID that failed. Since errors arrive in non-deterministic order, this could report the wrong client number.

Proposed fix to track actual client ID
+type clientResult struct {
+	clientID int
+	err      error
+}
+
 // Create and subscribe multiple clients concurrently
 numClients := 10
-errCh := make(chan error, numClients)
+resultCh := make(chan clientResult, numClients)

 for i := 0; i < numClients; i++ {
 	go func(clientID int) {
 		grpcClientOptions := grpccli.NewGRPCOptions()
 		grpcClientOptions.Dialer = &grpccli.GRPCDialer{URL: lis.Addr().String()}
 		agentOption := grpcv2.NewAgentOptions(grpcClientOptions, "cluster1", "agent1", dataType)

 		if err := agentOption.CloudEventsTransport.Connect(ctx); err != nil {
-			errCh <- err
+			resultCh <- clientResult{clientID, err}
 			return
 		}

 		if err := agentOption.CloudEventsTransport.Subscribe(ctx); err != nil {
-			errCh <- err
+			resultCh <- clientResult{clientID, err}
 			return
 		}

-		errCh <- nil
+		resultCh <- clientResult{clientID, nil}
 	}(i)
 }

 // Wait for all clients to complete
 for i := 0; i < numClients; i++ {
-	if err := <-errCh; err != nil {
-		t.Errorf("client %d failed: %v", i, err)
+	result := <-resultCh
+	if result.err != nil {
+		t.Errorf("client %d failed: %v", result.clientID, result.err)
 	}
 }
🤖 Prompt for AI Agents
In `@pkg/cloudevents/server/grpc/broker_test.go` around lines 302 - 307, The error
reporting loop uses the loop index i which is not the actual client ID because
errors arrive out-of-order; change errCh from chan error to a channel that
carries both clientID and error (e.g., a struct {clientID int; err error} or a
tuple), update the client goroutine(s) that send to errCh to send their clientID
along with the error, then in the receiver loop read the result (e.g., res :=
<-errCh) and use res.clientID when logging failures instead of i; keep
numClients and the loop bounds the same.

@skeeey

skeeey commented Jan 23, 2026

Copy link
Copy Markdown
Member Author

/hold

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/server/grpc/server.go (1)

226-240: Avoid logging full request payloads on authz denial.

Logging req can leak sensitive data and inflate logs. Prefer method + request type instead.

🔧 Suggested change
-				klog.FromContext(ctx).Error(err, "access denied", "req", req)
+				klog.FromContext(ctx).Error(err, "access denied", "method", info.FullMethod, "reqType", fmt.Sprintf("%T", req))
♻️ Duplicate comments (1)
pkg/cloudevents/server/grpc/broker_test.go (1)

278-307: Use actual client IDs when reporting concurrent failures.

Errors can arrive out of order, so the loop index doesn’t reflect the client that failed.

🔧 Suggested change
-	// Create and subscribe multiple clients concurrently
+	// Create and subscribe multiple clients concurrently
 	numClients := 10
-	errCh := make(chan error, numClients)
+	type clientResult struct {
+		clientID int
+		err      error
+	}
+	resultCh := make(chan clientResult, numClients)

 	for i := 0; i < numClients; i++ {
 		go func(clientID int) {
 			grpcClientOptions := grpccli.NewGRPCOptions()
 			grpcClientOptions.Dialer = &grpccli.GRPCDialer{URL: lis.Addr().String()}
 			agentOption := grpcv2.NewAgentOptions(grpcClientOptions, "cluster1", "agent1", dataType)

 			if err := agentOption.CloudEventsTransport.Connect(ctx); err != nil {
-				errCh <- err
+				resultCh <- clientResult{clientID: clientID, err: err}
 				return
 			}

 			if err := agentOption.CloudEventsTransport.Subscribe(ctx); err != nil {
-				errCh <- err
+				resultCh <- clientResult{clientID: clientID, err: err}
 				return
 			}

-			errCh <- nil
+			resultCh <- clientResult{clientID: clientID, err: nil}
 		}(i)
 	}

 	// Wait for all clients to complete
 	for i := 0; i < numClients; i++ {
-		if err := <-errCh; err != nil {
-			t.Errorf("client %d failed: %v", i, err)
-		}
+		result := <-resultCh
+		if result.err != nil {
+			t.Errorf("client %d failed: %v", result.clientID, result.err)
+		}
 	}
🧹 Nitpick comments (1)
pkg/server/grpc/server.go (1)

307-322: Use the stream context for denial logs.

context.TODO() drops request-scoped values; prefer ss.Context() for traceability.

🔧 Suggested change
-				klog.FromContext(context.TODO()).Error(err, "stream access denied")
+				klog.FromContext(ss.Context()).Error(err, "stream access denied", "method", info.FullMethod)

if err := grpcprotocol.WritePBMessage(handlerCtx, binding.ToMessage(evt), pbEvt); err != nil {
return fmt.Errorf("failed to convert cloudevent to protobuf for resource(%s): %v", evt.ID(), err)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should new a handlerLogger from handleCtx

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

fixed

Comment thread pkg/server/grpc/server.go Outdated
return handler(ctx, req)
case authz.DecisionDeny:
return nil, fmt.Errorf("access denied: %v", err)
klog.FromContext(ctx).Error(err, "access denied", "req", req)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

maybe be V(4)? this can generate a lot of logs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

removed

Comment thread pkg/server/grpc/server.go Outdated
return handler(srv, authorizedStream)
case authz.DecisionDeny:
return fmt.Errorf("access denied: %v", err)
klog.FromContext(context.TODO()).Error(err, "stream access denied")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same, and do we need log here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

removed

// Generate subscription ID and send header IMMEDIATELY, before any other operations
// This ensures the client receives the header as soon as possible after the stream is established
subID := uuid.NewString()
if err := subServer.SendHeader(metadata.Pairs(constants.GRPCSubscriptionIDKey, subID)); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if this is an issue in grpc, let's create an issue and put link here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@pkg/cloudevents/generic/options/v2/grpc/transport.go`:
- Around line 121-132: The Subscribe code currently calls subClient.Recv() while
holding t.mu which can block indefinitely if the server never sends an event;
change this to a bounded wait: spawn a goroutine to call subClient.Recv() (or
use Recv with a context/timeout) and select between the result and a timeout so
Subscribe does not hold t.mu waiting forever; on timeout close the stream (call
Close() / Cancel on the underlying client) and return a timeout error. Ensure
you update the logic around header.Get(constants.GRPCSubscriptionIDKey) so you
release t.mu before waiting or use a non-blocking pattern that closes the stream
on timeout to avoid stalling Close().
♻️ Duplicate comments (1)
pkg/cloudevents/server/grpc/broker_test.go (1)

278-307: Misleading client ID in concurrent error reporting.

The loop index i is not necessarily the client that failed because results arrive out of order. Track the actual client ID alongside the error to avoid misleading failures.

🧪 Proposed fix
+type clientResult struct {
+	clientID int
+	err      error
+}
+
 // Create and subscribe multiple clients concurrently
 numClients := 10
-errCh := make(chan error, numClients)
+resultCh := make(chan clientResult, numClients)

 for i := 0; i < numClients; i++ {
 	go func(clientID int) {
 		grpcClientOptions := grpccli.NewGRPCOptions()
 		grpcClientOptions.Dialer = &grpccli.GRPCDialer{URL: lis.Addr().String()}
 		agentOption := grpcv2.NewAgentOptions(grpcClientOptions, "cluster1", "agent1", dataType)

 		if err := agentOption.CloudEventsTransport.Connect(ctx); err != nil {
-			errCh <- err
+			resultCh <- clientResult{clientID, err}
 			return
 		}

 		if err := agentOption.CloudEventsTransport.Subscribe(ctx); err != nil {
-			errCh <- err
+			resultCh <- clientResult{clientID, err}
 			return
 		}

-		errCh <- nil
+		resultCh <- clientResult{clientID, nil}
 	}(i)
 }

 // Wait for all clients to complete
 for i := 0; i < numClients; i++ {
-	if err := <-errCh; err != nil {
-		t.Errorf("client %d failed: %v", i, err)
+	result := <-resultCh
+	if result.err != nil {
+		t.Errorf("client %d failed: %v", result.clientID, result.err)
 	}
 }

Comment thread pkg/cloudevents/generic/options/v2/grpc/transport.go Outdated
@skeeey
skeeey force-pushed the header branch 2 times, most recently from 87b0ebd to a1760bc Compare January 26, 2026 03:29
Signed-off-by: Wei Liu <liuweixa@redhat.com>

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/server/grpc/server.go (1)

226-239: Fix gRPC status code semantics and unify error handling in authorization interceptors.

Line 239 and 319 incorrectly return codes.Unauthenticated for "no authorizer found"—this should be codes.PermissionDenied. Per gRPC spec: UNAUTHENTICATED indicates the caller cannot be identified (missing/invalid credentials), while PERMISSION_DENIED indicates an authenticated caller lacks authorization. A caller with valid credentials but no matching authorizer policy is authorized (denied), not unauthenticated.

Additionally, line 236 returns a bare aggregated error without a status code wrapper, while line 316 wraps it as codes.Internal. Both should wrap authorization errors consistently—consider using codes.PermissionDenied for failed authorization checks rather than codes.Internal (which implies an unexpected system failure).

🧹 Nitpick comments (1)
pkg/cloudevents/generic/clients/baseclient.go (1)

213-230: Consider moving backoff state to per-client instance for independent retry timing.

DelayFn is a global shared backoff function used by multiple clients; while thread-safe by design, it causes all client retries to advance the same backoff progression. Moving the backoff into each baseClient instance provides independent backoff state per client, avoiding timing coupling between separate client reconnect and subscribe operations. This is particularly useful in multi-client scenarios where you want each client's retry behavior to be isolated.

♻️ Suggested refactor (per-client backoff)
-// the reconnect backoff will stop at [5s, 1min) interval. If we don't backoff for 10min, we reset the backoff.
-var DelayFn = wait.Backoff{
-	Duration: 5 * time.Second,
-	Cap:      1 * time.Minute,
-	Steps:    12, // now a required argument
-	Factor:   5.0,
-	Jitter:   1.0,
-}.DelayWithReset(&clock.RealClock{}, 10*time.Minute)
+// backoff will stop at [5s, 1min) interval. If we don't backoff for 10min, we reset the backoff.
+
 type baseClient struct {
 	clientID               string
 	transport              options.CloudEventTransport
 	cloudEventsRateLimiter flowcontrol.RateLimiter
 	receiverChan           chan int
 	subscribedChan         chan struct{}
 	subscribeChan          chan struct{}
 	connected              atomic.Bool
 	subscribed             atomic.Bool
+	delayFn                func() time.Duration
 }
 
 func newBaseClient(clientID string, transport options.CloudEventTransport, limit utils.EventRateLimit) *baseClient {
 	return &baseClient{
 		clientID:               clientID,
 		transport:              transport,
 		cloudEventsRateLimiter: utils.NewRateLimiter(limit),
 		subscribedChan:         make(chan struct{}, 1),
 		subscribeChan:          make(chan struct{}, 1),
 		receiverChan:           make(chan int, 2), // Allow both stop and start signals to be buffered
+		delayFn: wait.Backoff{
+			Duration: 5 * time.Second,
+			Cap:      1 * time.Minute,
+			Steps:    12,
+			Factor:   5.0,
+			Jitter:   1.0,
+		}.DelayWithReset(&clock.RealClock{}, 10*time.Minute),
 	}
 }
-					<-wait.RealTimer(DelayFn()).C()
+					<-wait.RealTimer(c.delayFn()).C()
-						case <-wait.RealTimer(DelayFn()).C():
+						case <-wait.RealTimer(c.delayFn()).C():

@qiujian16

Copy link
Copy Markdown
Member

/approve
/lgtm

@openshift-ci

openshift-ci Bot commented Jan 26, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: qiujian16, skeeey

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

The pull request process is described 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

@skeeey

skeeey commented Jan 27, 2026

Copy link
Copy Markdown
Member Author

/unhold

@openshift-merge-bot
openshift-merge-bot Bot merged commit 9e9f97a into open-cluster-management-io:main Jan 27, 2026
12 checks passed
@skeeey
skeeey deleted the header branch January 28, 2026 01:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants