feat(fcm): Migrate topic management to FCM v1 REST API - #546
lahirumaramba wants to merge 1 commit into
Conversation
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
de98041 to
1c9ee0a
Compare
There was a problem hiding this comment.
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.
| 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"); | ||
| } |
There was a problem hiding this comment.
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");
}| 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); |
There was a problem hiding this comment.
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);
}| private static readonly System.Text.RegularExpressions.Regex TopicNamePattern = | ||
| new System.Text.RegularExpressions.Regex("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$"); |
There was a problem hiding this comment.
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);
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 topicSubscriptionsendpoints for each registration token concurrently (bounded by a semaphore) and handles idempotent subscription scenarios (HTTP 409 / ALREADY_EXISTS). Deprecates the legacy IID topic methods.