Skip to content

feat(fcm): Migrate topic management to FCM v1 REST API - #546

Open
lahirumaramba wants to merge 1 commit into
mainfrom
lm-fcm-topics
Open

lahirumaramba wants to merge 1 commit into
mainfrom
lm-fcm-topics

Conversation

@lahirumaramba

Copy link
Copy Markdown
Member

Migrates topic subscription and management from the deprecated Instance ID (IID) service to the FCM v1 REST API. The new implementation calls the FCM v1 topicSubscriptions endpoints for each registration token concurrently (bounded by a semaphore) and handles idempotent subscription scenarios (HTTP 409 / ALREADY_EXISTS). Deprecates the legacy IID topic methods.

@lahirumaramba
lahirumaramba requested a review from a team September 17, 2026 18:21
Migrates topic subscription and unsubscription from the deprecated Instance ID (IID) service to the FCM v1 REST API. The new implementation calls the FCM v1 topicSubscriptions endpoints for each registration token concurrently (bounded by a semaphore) and handles idempotent subscription scenarios (HTTP 409 / ALREADY_EXISTS). Deprecates the legacy IID topic methods.

Testing: Added unit tests in `FirebaseAdmin.Tests/Messaging/FirebaseMessagingClientTest.cs` and updated `FirebaseAdmin.Tests/Messaging/FirebaseMessagingTest.cs` to verify FCM v1 topic management requests, error mappings, argument validations, and legacy endpoints. All unit tests passed across target frameworks.

Context Sources Used:
- id: firebase-admin-dotnet

@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 new non-legacy SubscribeToTopicAsync and UnsubscribeFromTopicAsync methods to FirebaseMessaging and FirebaseMessagingClient that utilize the new v1 FCM API, while deprecating the old Instance ID API-based methods. It also adds validation, concurrent request handling using SemaphoreSlim, updated error mapping, and comprehensive unit tests. The review feedback highlights three key areas for improvement: explicitly catching and rethrowing OperationCanceledException to prevent swallowing cancellations in the generic catch block, properly disposing of the SemaphoreSlim instance to avoid resource leaks, and using RegexOptions.Compiled on the static topic name Regex to optimize performance.

Comment on lines +465 to +478
catch (FirebaseMessagingException e)
{
if (isSubscribe && IsAlreadyExists(e))
{
return TopicResult.Success(index);
}

var reason = ExtractReason(e);
return TopicResult.Failure(index, reason);
}
catch (Exception)
{
return TopicResult.Failure(index, "UNKNOWN_ERROR");
}

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

The generic catch (Exception) block will catch OperationCanceledException (or TaskCanceledException) when the cancellationToken is triggered, converting the cancellation into a failed TopicResult with "UNKNOWN_ERROR". This prevents proper cancellation propagation. Catch and rethrow OperationCanceledException explicitly before catching general exceptions.

            catch (FirebaseMessagingException e)
            {
                if (isSubscribe && IsAlreadyExists(e))
                {
                    return TopicResult.Success(index);
                }

                var reason = ExtractReason(e);
                return TopicResult.Failure(index, reason);
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (Exception)
            {
                return TopicResult.Failure(index, "UNKNOWN_ERROR");
            }

Comment on lines +393 to +420
var semaphore = new SemaphoreSlim(Math.Min(registrationTokens.Count, 100));
var tasks = new List<Task<TopicResult>>(registrationTokens.Count);

for (int i = 0; i < registrationTokens.Count; i++)
{
var index = i;
var token = registrationTokens[i];
tasks.Add(this.SendSingleTopicRequestAsync(
token, encodedTopic, isSubscribe, index, semaphore, cancellationToken));
}

var results = await Task.WhenAll(tasks).ConfigureAwait(false);
var successCount = 0;
var errors = new List<ErrorInfo>();

foreach (var result in results)
{
if (result.IsSuccess)
{
successCount++;
}
else
{
errors.Add(new ErrorInfo(result.Index, result.Reason));
}
}

return new TopicManagementResponse(successCount, errors);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The SemaphoreSlim instance is created per request but is never disposed. Since SemaphoreSlim implements IDisposable and can allocate system wait handles, it should be disposed to prevent potential resource leaks. Wrap its creation and usage in a using block.

            using (var semaphore = new SemaphoreSlim(Math.Min(registrationTokens.Count, 100)))
            {
                var tasks = new List<Task<TopicResult>>(registrationTokens.Count);

                for (int i = 0; i < registrationTokens.Count; i++)
                {
                    var index = i;
                    var token = registrationTokens[i];
                    tasks.Add(this.SendSingleTopicRequestAsync(
                        token, encodedTopic, isSubscribe, index, semaphore, cancellationToken));
                }

                var results = await Task.WhenAll(tasks).ConfigureAwait(false);
                var successCount = 0;
                var errors = new List<ErrorInfo>();

                foreach (var result in results)
                {
                    if (result.IsSuccess)
                    {
                        successCount++;
                    }
                    else
                    {
                        errors.Add(new ErrorInfo(result.Index, result.Reason));
                    }
                }

                return new TopicManagementResponse(successCount, errors);
            }

Comment on lines +41 to +42
private static readonly System.Text.RegularExpressions.Regex TopicNamePattern =
new System.Text.RegularExpressions.Regex("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

For static Regex instances that are used repeatedly, it is highly recommended to specify RegexOptions.Compiled to improve performance by compiling the regular expression to intermediate language instead of interpreting it at runtime.

        private static readonly System.Text.RegularExpressions.Regex TopicNamePattern =
            new System.Text.RegularExpressions.Regex("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$", System.Text.RegularExpressions.RegexOptions.Compiled);

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.

1 participant