Skip to content
Merged
31 changes: 29 additions & 2 deletions SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
sc.Property(i => i.Id).ValueGeneratedOnAdd();
sc.HasIndex(i => i.Code).IsUnique();
});

modelBuilder.Entity<WorkGroup>(wg =>
{
wg.HasKey(i => i.Id);
wg.Property(i => i.Id).ValueGeneratedOnAdd();
wg.Property(p => p.BusMessageName).IsRequired().IsUnicode(false).HasMaxLength(100);
wg.Property(p => p.Options).StoreAsJson();

});

modelBuilder.Entity<Partner>(b =>
{
Expand Down Expand Up @@ -148,6 +157,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.HasOne<Subscription>().WithMany().HasForeignKey(p => p.AggregationForId).IsRequired(false)
.HasConstraintName("FK_Subscriptions_AggFor").OnDelete(DeleteBehavior.Restrict);
b.HasOne(i => i.Category).WithMany().HasForeignKey(i => i.CategoryId);
b.HasOne(i => i.WorkGroup).WithMany().HasForeignKey(i => i.WorkGroupId);
b.Property(p => p.MatchExpression).HasConversion(
domainObject =>
domainObject == null ? null : MatchSpecValueConverter.SerializeMatchSpec(domainObject),
Expand Down Expand Up @@ -302,8 +312,25 @@ async public override Task<int> SaveChangesAsync(CancellationToken cancellationT
ChangeTracker.ApplyAuditValues(requestContext.GetNameIdentifier());
//using var transaction = await Database.BeginTransactionAsync();
var affectedRecords = await base.SaveChangesAsync(cancellationToken);
await ChangeTracker.PublishDomainEvents(publish);
//await transaction.CommitAsync();
//await ChangeTracker.PublishDomainEvents(publish);
var entitiesWithEvents = ChangeTracker.Entries<IGeneratesDomainEvents>()
.Select(e => e.Entity)
.Where(e => e.Events.Any())
.ToArray();

foreach (var entity in entitiesWithEvents)
{
var events = entity.Events.ToArray();
entity.Events.Clear();
foreach (var domainEvent in events)
if (domainEvent is IHasWorkGroup hasWorkGroup)
await publish.Publish(hasWorkGroup.GetBusMessageName(),
JsonConvert.SerializeObject(new XchangeMessage { Id = hasWorkGroup.Id }));
else
await publish.Publish(domainEvent.GetType().Name, JsonConvert.SerializeObject(domainEvent));
}
Comment on lines 314 to +331

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 | 🟠 Major

Domain event publishing after SaveChangesAsync may lose events on failure.

The current flow commits database changes first (line 314), then publishes domain events (lines 321-331). If publishing fails (e.g., message broker unavailable), the database transaction has already committed but the events are lost. This can lead to data/event inconsistency.

Consider:

  1. Using the outbox pattern to persist events in the same transaction
  2. Wrapping both operations in a distributed transaction
  3. At minimum, adding error handling/retry logic for the publish calls

Also, line 315 contains commented-out code that should be removed.

🔒 Minimal improvement: add try-catch with logging
 var affectedRecords = await base.SaveChangesAsync(cancellationToken);
-//await ChangeTracker.PublishDomainEvents(publish);
 var entitiesWithEvents = ChangeTracker.Entries<IGeneratesDomainEvents>()
     .Select(e => e.Entity)
     .Where(e => e.Events.Any())
     .ToArray();

 foreach (var entity in entitiesWithEvents)
 {
     var events = entity.Events.ToArray();
     entity.Events.Clear();
     foreach (var domainEvent in events)
+    {
+        try
+        {
             if (domainEvent is IHasWorkGroup hasWorkGroup)
                 await publish.Publish(hasWorkGroup.GetBusMessageName(),
                     JsonConvert.SerializeObject(new XchangeMessage { Id = hasWorkGroup.Id }));
             else
                 await publish.Publish(domainEvent.GetType().Name, JsonConvert.SerializeObject(domainEvent));
+        }
+        catch (Exception ex)
+        {
+            // Log the failure - consider implementing outbox pattern for reliability
+            // logger.LogError(ex, "Failed to publish domain event {EventType}", domainEvent.GetType().Name);
+            throw; // or handle gracefully based on requirements
+        }
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var affectedRecords = await base.SaveChangesAsync(cancellationToken);
await ChangeTracker.PublishDomainEvents(publish);
//await transaction.CommitAsync();
//await ChangeTracker.PublishDomainEvents(publish);
var entitiesWithEvents = ChangeTracker.Entries<IGeneratesDomainEvents>()
.Select(e => e.Entity)
.Where(e => e.Events.Any())
.ToArray();
foreach (var entity in entitiesWithEvents)
{
var events = entity.Events.ToArray();
entity.Events.Clear();
foreach (var domainEvent in events)
if (domainEvent is IHasWorkGroup hasWorkGroup)
await publish.Publish(hasWorkGroup.GetBusMessageName(),
JsonConvert.SerializeObject(new XchangeMessage { Id = hasWorkGroup.Id }));
else
await publish.Publish(domainEvent.GetType().Name, JsonConvert.SerializeObject(domainEvent));
}
var affectedRecords = await base.SaveChangesAsync(cancellationToken);
var entitiesWithEvents = ChangeTracker.Entries<IGeneratesDomainEvents>()
.Select(e => e.Entity)
.Where(e => e.Events.Any())
.ToArray();
foreach (var entity in entitiesWithEvents)
{
var events = entity.Events.ToArray();
entity.Events.Clear();
foreach (var domainEvent in events)
{
try
{
if (domainEvent is IHasWorkGroup hasWorkGroup)
await publish.Publish(hasWorkGroup.GetBusMessageName(),
JsonConvert.SerializeObject(new XchangeMessage { Id = hasWorkGroup.Id }));
else
await publish.Publish(domainEvent.GetType().Name, JsonConvert.SerializeObject(domainEvent));
}
catch (Exception ex)
{
// Log the failure - consider implementing outbox pattern for reliability
// logger.LogError(ex, "Failed to publish domain event {EventType}", domainEvent.GetType().Name);
throw; // or handle gracefully based on requirements
}
}
}
🤖 Prompt for AI Agents
In `@SW.Bitween.Api/Data/BitweenDbContext.cs` around lines 314 - 331, The code
commits DB changes in SaveChangesAsync then publishes domain events via
ChangeTracker entries (IGeneratesDomainEvents) and publish.Publish, which can
lose events if publishing fails; remove the commented-out
ChangeTracker.PublishDomainEvents call, implement the outbox pattern (persist
events to an Outbox table/entity within the same transaction inside
SaveChangesAsync or the method that calls base.SaveChangesAsync), and change the
loop that currently uses publish.Publish (and XchangeMessage creation) to
enqueue events into the outbox instead of directly publishing; if you need a
quicker mitigation, wrap the publish.Publish calls in a try-catch with
retry/backoff and log failures (using your logger) and do not clear
entity.Events until publish succeeds or the event is moved to the outbox so
IGeneratesDomainEvents entities (and methods like GetBusMessageName/GetType) are
updated accordingly.



return affectedRecords;
}
}
Expand Down
11 changes: 7 additions & 4 deletions SW.Bitween.Api/Domain/Subscription/Subscription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,28 @@ public Subscription()
}

//receiving
public Subscription(string name, int documentId) : this(name, documentId, SubscriptionType.Receiving)
public Subscription(string name, int documentId) : this(WorkGroup.None, name, documentId, SubscriptionType.Receiving)
{
Inactive = true;
}

//aggregation
public Subscription(string name, int aggregationFor, int partnerId) : this(name, Document.AggregationDocumentId,
public Subscription(string name, int aggregationFor, int partnerId) : this(WorkGroup.None,name, Document.AggregationDocumentId,
SubscriptionType.Aggregation, partnerId, aggregationFor)
{
Inactive = true;
}

//apiresult or filter
public Subscription(string name, int documentId, SubscriptionType type, int partnerId) : this(name, documentId,
public Subscription(string name, int documentId, SubscriptionType type, int partnerId) : this(WorkGroup.None,name, documentId,
type, partnerId, null)
{
Inactive = true;
if (!(type == SubscriptionType.ApiCall || type == SubscriptionType.Internal))
throw new ArgumentException();
}

private Subscription(string name, int documentId, SubscriptionType type, int? partnerId = null,
private Subscription(WorkGroup workGroup, string name, int documentId, SubscriptionType type, int? partnerId = null,
int? aggregationForId = null, bool temporary = false)
{
Inactive = true;
Expand All @@ -51,6 +51,7 @@ private Subscription(string name, int documentId, SubscriptionType type, int? pa
ValidatorProperties = new Dictionary<string, string>();
DocumentFilter = new Dictionary<string, string>();
Temporary = temporary;
WorkGroup = workGroup;
}

public string Name { get; set; }
Expand All @@ -59,6 +60,8 @@ private Subscription(string name, int documentId, SubscriptionType type, int? pa
public int? PartnerId { get; private set; }
public int? CategoryId { get; set; }
public SubscriptionCategory Category { get; set; }
public int? WorkGroupId { get; set; }
public WorkGroup WorkGroup { get; set; }
public bool Temporary { get; private set; }
public DateTime? PausedOn { get; private set; }
public string ValidatorId { get; set; }
Expand Down
24 changes: 24 additions & 0 deletions SW.Bitween.Api/Domain/WorkGroup/WorkGroup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using SW.Bitween.Domain;
using SW.Bitween.Model;
using SW.Bus.RabbitMqExtensions;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain;

public interface IWorkGroup
{
string BusMessageName { get; }
string GetBusMessageName();
WorkGroupOptions Options { get; }
}

public class WorkGroup : BaseEntity,IWorkGroup
{
public string Name { get; set; }
public string BusMessageName { get; set; }

public string GetBusMessageName() => $"{Id}{BusMessageName}";
//public string
public static WorkGroup None => new() { BusMessageName = "Ungrouped"};
public WorkGroupOptions Options { get; set; }
}
11 changes: 6 additions & 5 deletions SW.Bitween.Api/Domain/Xchange/Xchange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ private Xchange()
{
}

public Xchange(int documentId, XchangeFile file, string[] references = null, SubscriptionType subscriptionType = SubscriptionType.Internal, string correlationId = null)
public Xchange(int documentId, IWorkGroup workGroup, XchangeFile file, string[] references = null, SubscriptionType subscriptionType = SubscriptionType.Internal, string correlationId = null)
{
Id = Guid.NewGuid().ToString("N");
DocumentId = documentId;
Expand All @@ -34,11 +34,12 @@ public Xchange(int documentId, XchangeFile file, string[] references = null, Sub
};

xchangeEvent.Id = Id;
xchangeEvent.WorkGroup = workGroup ?? WorkGroup.None;
Events.Add(xchangeEvent);
}

public Xchange(Subscription subscription, XchangeFile file, string[] references = null, string correlationId = null) :
this(subscription.DocumentId, file, references, subscription.Type)
this(subscription.DocumentId, subscription.WorkGroup, file, references, subscription.Type)
{
SubscriptionId = subscription.Id;
MapperId = subscription.MapperId;
Expand All @@ -51,8 +52,8 @@ public Xchange(Subscription subscription, XchangeFile file, string[] references
}

//retry xchange
public Xchange(Xchange xchange, XchangeFile file) :
this(xchange.DocumentId, file, xchange.References)
public Xchange(Xchange xchange, XchangeFile file,IWorkGroup workGroup) :
this(xchange.DocumentId,workGroup, file, xchange.References)
{
SubscriptionId = xchange.SubscriptionId;
MapperId = xchange.MapperId;
Expand All @@ -65,7 +66,7 @@ public Xchange(Xchange xchange, XchangeFile file) :
}
//retry with reset subscription properties
public Xchange(Subscription subscription, Xchange xchange, XchangeFile file) :
this(xchange.DocumentId, file, xchange.References)
this(xchange.DocumentId,subscription.WorkGroup, file, xchange.References)
{
SubscriptionId = xchange.SubscriptionId;
MapperId = subscription.MapperId;
Expand Down
10 changes: 8 additions & 2 deletions SW.Bitween.Api/Domain/Xchange/XchangeCreatedEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@

namespace SW.Bitween.Domain
{
internal abstract class XchangeCreatedEvent : BaseDomainEvent
internal class XchangeMessage
{
public string Id { get; set; }

}
internal abstract class XchangeCreatedEvent : BaseDomainEvent,IHasWorkGroup
{
public string Id { get; set; }
public string GetBusMessageName()=> WorkGroup.GetBusMessageName();

public IWorkGroup WorkGroup { get; set; }
Comment on lines +10 to +15

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 | 🟠 Major

Prevent null WorkGroup from breaking message routing.

GetBusMessageName() dereferences WorkGroup directly; if any event is created without a WorkGroup (legacy path or partial construction), this will throw at publish time. Consider a safe fallback.

🐛 Proposed fix
-        public string GetBusMessageName()=> WorkGroup.GetBusMessageName();
+        public string GetBusMessageName() =>
+            (WorkGroup ?? global::SW.Bitween.Domain.WorkGroup.None).GetBusMessageName();
🤖 Prompt for AI Agents
In `@SW.Bitween.Api/Domain/Xchange/XchangeCreatedEvent.cs` around lines 10 - 15,
Update XchangeCreatedEvent.GetBusMessageName to guard against a null WorkGroup:
check WorkGroup before calling WorkGroup.GetBusMessageName() and return a safe
fallback when WorkGroup is null (e.g., a default message name or empty string)
so publishing won’t throw; modify the method on the XchangeCreatedEvent class
(and keep the IWorkGroup usage) to use a null check (WorkGroup) and return
WorkGroup.GetBusMessageName() only when non-null, otherwise return the chosen
fallback value.


}
internal class ApiXchangeCreatedEvent : XchangeCreatedEvent
{
}
Expand Down
5 changes: 3 additions & 2 deletions SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ private XchangeResult()
{
}

public XchangeResult(string xchangeId, XchangeFile outputFile, XchangeFile responseFile = null, string responseXchangeId = null, string exception = null)
public XchangeResult(string xchangeId,WorkGroup workGroup, XchangeFile outputFile, XchangeFile responseFile = null, string responseXchangeId = null, string exception = null)
{
Id = xchangeId;
Success = exception == null;
Expand Down Expand Up @@ -40,7 +40,8 @@ public XchangeResult(string xchangeId, XchangeFile outputFile, XchangeFile respo
{
Id = Id,
Success = Success,
ResponseBad = ResponseBad
ResponseBad = ResponseBad,
WorkGroup = workGroup ?? WorkGroup.None,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace SW.Bitween.Domain
{
public class XchangeResultCreatedEvent : BaseDomainEvent
public class XchangeResultCreatedEvent : BaseDomainEvent,IHasWorkGroup
{
public string Id { get; set; }
public bool Success { get; set; }
public bool ResponseBad { get; set; }
public IWorkGroup WorkGroup { get; set; } = Domain.WorkGroup.None;
public string GetBusMessageName()=> $"{WorkGroup.GetBusMessageName()}{XchangeService.ResultQueueSuffix}";
}
}
4 changes: 4 additions & 0 deletions SW.Bitween.Api/Extensions/InfolinkDbContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,9 @@ where partner.ApiCredentials.Any(cred => cred.Key == partnerKey)

return (par, par.ApiCredentials.First(c => c.Key == partnerKey).Name);
}

public static IQueryable<Subscription> Subscriptions(this BitweenDbContext dbContext) =>
dbContext.Set<Subscription>().Include(s => s.WorkGroup);
}

}
7 changes: 7 additions & 0 deletions SW.Bitween.Api/Interfaces/IHasWorkGroup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SW.Bitween;

public interface IHasWorkGroup
{
public string Id { get; }
string GetBusMessageName();
}
5 changes: 4 additions & 1 deletion SW.Bitween.Api/Interfaces/IInfolinkCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public interface IInfolinkCache


void Revoke();
void BroadcastRevoke();
Task BroadcastRevoke();

Task<WorkGroup[]> ListWorkGroupsAsync();
Task<WorkGroup> WorkGroupByIdAsync(int workGroupId);
Task<WorkGroup> WorkGroupBySubscriptionIdAsync(int subscriptionId);
Comment on lines 16 to +21

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find implementations and call sites for BroadcastRevoke / IInfolinkCache
echo "=== BroadcastRevoke call sites ==="
rg -n --glob '!**/bin/**' --glob '!**/obj/**' 'BroadcastRevoke\s*\('

echo ""
echo "=== IInfolinkCache implementations ==="
rg -n --glob '!**/bin/**' --glob '!**/obj/**' ':\s*IInfolinkCache\b'

Repository: simplify9/Bitween-api

Length of output: 887


Fix unawaited BroadcastRevoke() calls in four locations.

The signature change to async (Task return) is correctly implemented in InMemoryBitweenCache, but four call sites fail to await:

  • SW.Bitween.Api/Resources/WorkGroups/Create.cs:30
  • SW.Bitween.Api/Resources/WorkGroups/Delete.cs:27
  • SW.Bitween.Api/Resources/Documents/Update.cs:54
  • SW.Bitween.Api/Resources/Subscriptions/Update.cs:50

These fire-and-forget calls risk dropping exceptions and race conditions in cache invalidation. Add await to each call site.

🤖 Prompt for AI Agents
In `@SW.Bitween.Api/Interfaces/IInfolinkCache.cs` around lines 16 - 21, Several
call sites invoke the newly async BroadcastRevoke() without awaiting it, causing
fire-and-forget behavior; update each call site to await cache.BroadcastRevoke()
instead of calling it without await, and if the containing method (the
WorkGroups Create handler, WorkGroups Delete handler, Documents Update handler,
and Subscriptions Update handler) is not already async/returning Task, change
its signature to async Task and propagate awaits accordingly so exceptions and
ordering are preserved. Ensure you reference the
IInfolinkCache.BroadcastRevoke() call in the methods named Create (WorkGroups),
Delete (WorkGroups), Update (Documents), and Update (Subscriptions) and replace
the bare call with await cache.BroadcastRevoke().

}
1 change: 1 addition & 0 deletions SW.Bitween.Api/Resources/Subscriptions/Get.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public async Task<object> Handle(int key)
CategoryDescription = subscriber.Category?.Description,
CategoryCode = subscriber.Category?.Code,
CategoryId = subscriber.CategoryId,
WorkGroupId = subscriber.WorkGroupId,
Schedules = subscriber.Schedules.Select(s => new ScheduleView
{
Backwards = s.Backwards,
Expand Down
4 changes: 3 additions & 1 deletion SW.Bitween.Api/Resources/Subscriptions/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ join document in _dbContext.Set<Document>() on subscriber.DocumentId equals docu
MatchExpression = subscriber.MatchExpression,
PartnerId = subscriber.PartnerId,
CategoryId = subscriber.CategoryId,
WorkGroupId = subscriber.WorkGroupId,
CategoryDescription = subscriber.Category.Description,
CategoryCode = subscriber.Category.Code
CategoryCode = subscriber.Category.Code,

};

query = query.AsNoTracking().AsQueryable();
Expand Down
37 changes: 37 additions & 0 deletions SW.Bitween.Api/Resources/WorkGroups/Create.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Threading.Tasks;
using SW.Bitween.Domain;
using SW.Bitween.Model;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.WorkGroups;

public class Create(BitweenDbContext dbContext, RequestContext requestContext,IInfolinkCache _BitweenCache, IBroadcast _broadcast)
: ICommandHandler<CreateWorkGroupModel, object>
{
private readonly RequestContext _requestContext = requestContext;

public async Task<object> Handle(CreateWorkGroupModel request)
{
var workgroup = new WorkGroup()
{
Name = request.Name,
BusMessageName = request.BusMessageName,
Options = new WorkGroupOptions()
{
RabbitMqOptions = new ConsumerSettings
{
Prefetch = request.Options?.RabbitMqOptions?.Prefetch,
Priority = request.Options?.RabbitMqOptions?.Priority
}
}
};
dbContext.Add(workgroup);
await dbContext.SaveChangesAsync();
_BitweenCache.BroadcastRevoke();
await _broadcast.RefreshConsumers();
return new
{
workgroup.Id
};
}
}
31 changes: 31 additions & 0 deletions SW.Bitween.Api/Resources/WorkGroups/Delete.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using SW.Bitween.Domain;
using SW.Bitween.Model;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.WorkGroups;

[HandlerName(nameof(Delete))]
public class Delete(BitweenDbContext dbContext, RequestContext requestContext, IBroadcast _broadcast, IInfolinkCache _infolinkCache)
: ICommandHandler<int, DeleteWorkGroupModel, object>
{
private readonly RequestContext _requestContext = requestContext;

public async Task<object> Handle(int key, DeleteWorkGroupModel _)
{
var category = await dbContext.Set<WorkGroup>().FindAsync(key);
if (category is null)
throw new SWValidationException("CATEGORY_NOT_FOUND", $"Workgroup with id {key} was not found");

if (await dbContext.Set<Subscription>().AnyAsync(i => i.WorkGroupId.Value == category.Id))
throw new SWValidationException("CANT_BE_DELETED", "Workgroup with Subscriptions cant be deleted");

//Todo chek rabbitMq
dbContext.Remove(category);
await dbContext.SaveChangesAsync();
_infolinkCache.BroadcastRevoke();
await _broadcast.RefreshConsumers();
Comment on lines +24 to +28

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 | 🟠 Major

Await cache revocation to avoid fire‑and‑forget failures.

BroadcastRevoke() is async; not awaiting risks unobserved exceptions and races with RefreshConsumers().

🐛 Proposed fix
-        _infolinkCache.BroadcastRevoke();
+        await _infolinkCache.BroadcastRevoke();
🤖 Prompt for AI Agents
In `@SW.Bitween.Api/Resources/WorkGroups/Delete.cs` around lines 24 - 28, The call
to _infolinkCache.BroadcastRevoke() is currently invoked fire-and-forget which
can cause unobserved exceptions and race conditions with
_broadcast.RefreshConsumers(); change the code to await
_infolinkCache.BroadcastRevoke() so the revocation completes (and any exceptions
propagate) before calling await _broadcast.RefreshConsumers(); ensure you keep
the surrounding async method signature that contains
dbContext.SaveChangesAsync(), BroadcastRevoke(), and RefreshConsumers()
(reference: BroadcastRevoke(), RefreshConsumers(), _infolinkCache, _broadcast).

return null;
}
}
Loading