diff --git a/blazor/diagram/collaborative-editing/overview.md b/blazor/diagram/collaborative-editing/overview.md index 41d779881e..160bf26307 100644 --- a/blazor/diagram/collaborative-editing/overview.md +++ b/blazor/diagram/collaborative-editing/overview.md @@ -8,37 +8,34 @@ documentation: ug --- # Collaborative Editing in Blazor Diagram - -Collaborative editing enables multiple users to work on the same diagram at the same time. Changes are reflected in real-time, allowing all participants to instantly see updates as they happen. This feature promotes seamless teamwork by eliminating the need to wait for others to finish their edits. As a result, teams can boost productivity, streamline workflows, and ensure everyone stays aligned throughout the design process. +Collaborative editing allows multiple users to work on the same diagram simultaneously. All changes are synchronized in real-time, ensuring that collaborators can instantly see updates as they occur. This feature enhances productivity by removing the need to wait for others to finish their edits and promotes seamless teamwork. +By leveraging Redis as the real-time data store, the application ensures fast and reliable communication between clients, making collaborative diagram editing smooth and efficient. ## Prerequisites +Following things are needed to enable collaborative editing in Diagram Component -- *Real-time Transport Protocol*: Enables instant communication between clients and the server, ensuring that updates during collaborative editing are transmitted and reflected immediately. -- *Distributed Cache or Database*: Serves as temporary storage for the queue of editing operations, helping maintain synchronization and consistency across multiple users. - -### Real time transport protocol +* SignalR +* Redis -- *Managing Connections*: Maintains active connections between clients and the server to enable uninterrupted real-time collaboration. This ensures smooth and consistent communication throughout the editing session. -- *Broadcasting Changes*: Instantly propagates any edits made by one user to all other collaborators. This guarantees that everyone is always working on the most up-to-date version of the diagram, fostering accuracy and teamwork. +## NuGet packages required -### Distributed cache or database +- Blazor Server: + - Microsoft.AspNetCore.SignalR.Client + - Syncfusion.Blazor.Diagram +- Collaboration Hub: + - Microsoft.AspNetCore.SignalR + - Microsoft.AspNetCore.SignalR.StackExchangeRedis + - StackExchange.Redis -Collaborative editing requires a reliable backing system to temporarily store and manage editing operations from all active users. This ensures real-time synchronization and conflict resolution across multiple clients. There are two primary options: +## SignalR +In collaborative editing, real-time communication is essential for users to see each other’s changes instantly. We use a real-time transport protocol to efficiently send and receive data as edits occur. For this, we utilize SignalR, which supports real-time data exchange between the blazor server and collaboration hub. SignalR ensures that updates are transmitted immediately, allowing seamless collaboration by handling the complexities of connection management and offering reliable communication channels. -- *Distributed Cache*: - * Designed for high throughput and low latency. - * Handles significantly more HTTP requests per second compared to a database. - * Example: A server with 2 vCPUs and 8 GB RAM can process up to 125 requests per second using a distributed cache. +To make SignalR work in a distributed environment (with more than one server instance), it needs to be configured with either AspNetCore SignalR Service or a Redis backplane. -- *Database*: - * Suitable for smaller-scale collaboration scenarios. - * With the same server configuration, a database can handle approximately 50 requests per second. +## Redis +Redis is used as a temporary data store to manage real-time diagram collaborative editing operations. It helps queue editing actions and resolve conflicts through versioning mechanisms. -> *Recommendation*: - * If your application expects 50 or fewer requests per second, a database provides a reliable solution for managing the operation queue. - * If your application expects more than 50 requests per second, a distributed cache is highly recommended for optimal performance. +All diagram editing operations performed during collaboration are cached in Redis. To prevent excessive memory usage, old versioning data is periodically removed from the Redis cache. -> Tips to calculate the average requests per second of your application: -Assume the editor in your live application is actively used by 1000 users and each user's edit can trigger 2 to 5 requests per second. The total requests per second of your applications will be around 2000 to 5000. In this case, you can finalize a configuration to support around 5000 average requests per second. +Redis imposes limits on concurrent connections. Select an appropriate Redis configuration based on your expected user load to maintain optimal performance and avoid connection bottlenecks. -> Note: The metrics provided are for illustration purposes only. Actual throughput may vary based on additional server-side operations. It is strongly recommended to monitor your application’s traffic and performance and select a configuration that best meets your real-world requirements. diff --git a/blazor/diagram/collaborative-editing/using-redis-cache-asp-net.md b/blazor/diagram/collaborative-editing/using-redis-cache-asp-net.md index 7ae76439d7..7b3f6c32c1 100644 --- a/blazor/diagram/collaborative-editing/using-redis-cache-asp-net.md +++ b/blazor/diagram/collaborative-editing/using-redis-cache-asp-net.md @@ -7,83 +7,19 @@ control: Diagram documentation: ug --- -# Collaborative Editing with Redis in Blazor Diagram +## How to enable collaborative editing in Blazor server side +### Step 1: Configure SignalR Connection +To enable real-time collaboration, you need to establish a SignalR connection that can send and receive diagram updates. This connection will allow the client to join a SignalR group (room) for collaborative editing, ensuring changes are shared only among users working on the same diagram. -Collaborative editing enables multiple users to work on the same diagram at the same time. Changes are reflected in real-time, allowing collaborators to see updates as they happen. This approach significantly improves efficiency by eliminating the need to wait for others to finish their edits, fostering seamless teamwork. - -## Prerequisites - -Following things are needed to enable collaborative editing in Diagram Component - -* SignalR -* Redis - -## NuGet packages required - -- Client (Blazor): - - Microsoft.AspNetCore.SignalR.Client - - Syncfusion.Blazor.Diagram -- Server: - - Microsoft.AspNetCore.SignalR - - Microsoft.AspNetCore.SignalR.StackExchangeRedis - - StackExchange.Redis - -## SignalR - -In collaborative editing, real-time communication is essential for users to see each other’s changes instantly. We use a real-time transport protocol to efficiently send and receive data as edits occur. For this, we utilize SignalR, which supports real-time data exchange between the client and server. SignalR ensures that updates are transmitted immediately, allowing seamless collaboration by handling the complexities of connection management and offering reliable communication channels. - -To make SignalR work in a distributed environment (with more than one server instance), it needs to be configured with either AspNetCore SignalR Service or a Redis backplane. - -### Scale-out SignalR using AspNetCore SignalR service - -AspNetCore SignalR Service is a scalable, managed service for real-time communication in web applications. It enables real-time messaging between web clients (browsers) and your server-side application(across multiple servers). - -Below is a code snippet to configure SignalR in a Blazor application using AddSignalR - -```csharp -builder.Services.AddSignalR(options => -{ - options.EnableDetailedErrors = true; -}); -``` - - - -### Scale-out SignalR using Redis - -Using a Redis backplane, you can achieve horizontal scaling of your SignalR application. The SignalR leverages Redis to efficiently broadcast messages across multiple servers. This allows your application to handle large user bases with minimal latency. - -In the SignalR app, install the following NuGet package: - -`Microsoft.AspNetCore.SignalR.StackExchangeRedis` - -Below is a code snippet to configure Redis backplane in an ASP.NET Core application using the AddStackExchangeRedis method +The `RoomName` represents the unique group name for the diagram session, and all users editing the same diagram should join this group to share updates within that session. The `OnConnectedAsync` method is triggered after the client successfully connects to the collaboration hub and receives a unique connection ID, confirming the connection. After that, the `JoinDiagram` method is called to add the client to the specified SignalR group, enabling the client to send and receive real-time updates with other users in the same room. ```csharp -builder.Services.AddSingleton(provider => -{ - var connectionString = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379,abortConnect=false"; - return ConnectionMultiplexer.Connect(connectionString); -}); -builder.Services.AddScoped(); -``` - -## Redis - -Redis is used as a temporary data store to manage real-time collaborative editing operations. It helps queue editing actions and resolve conflicts through versioning mechanisms. - -All diagram editing operations performed during collaboration are cached in Redis. To prevent excessive memory usage, old versioning data is periodically removed from the Redis cache. +@using Microsoft.AspNetCore.SignalR.Client -Redis imposes limits on concurrent connections. Select an appropriate Redis configuration based on your expected user load to maintain optimal performance and avoid connection bottlenecks. - -## How to enable collaborative editing in client side - -### Step 1: Configure SignalR to send and receive changes - -To broadcast the changes made and receive changes from remote users, configure SignalR like below. - -```csharp @code { + HubConnection? connection; + string RoomName = "Syncfusion"; + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) @@ -91,7 +27,6 @@ To broadcast the changes made and receive changes from remote users, configure S await InitializeSignalR(); } } - private async Task InitializeSignalR() { if (connection == null) @@ -104,135 +39,151 @@ To broadcast the changes made and receive changes from remote users, configure S }) .WithAutomaticReconnect() .Build(); - connection.On("OnSaveDiagramState", OnSaveDiagramState); - connection.On("ShowConflict", ShowConflict); - connection.On("RevertCurrentChanges", RevertCurrentChanges); + // Triggered when the connection to the collaboration hub is successfully established connection.On("OnConnectedAsync", OnConnectedAsync); - connection.On("UpdateVersion", UpdateVersion); - connection.On>("CurrentUsers", CurrentUsers); - connection.On("LoadDiagramData", OnLoadDiagramData); - connection.On, long, SelectionEvent>("ReceiveData", async (diagramChanges, serverVersion, selectionEvent) => - { - await InvokeAsync(() => OnReceiveDiagramUpdate(diagramChanges, serverVersion, selectionEvent)); - }); - connection.On("UserJoined", ShowUserJoined); - connection.On("UserLeft", ShowUserLeft); - connection.On("UpdateSelectionHighlighter", SendCurrentSelectorBoundsToOtherClient); - connection.On>("PeerSelectionsBootstrap", async list => - { - foreach (var evt in list) - _peerSelections[evt.ConnectionId] = (evt.UserName ?? "User", evt.ElementIds?.ToHashSet() ?? new(), evt.SelectorBounds); - await InvokeAsync(StateHasChanged); - }); - - connection.On("PeerSelectionChanged", async (evt) => - { - await InvokeAsync(() => - { - PeerSelectionChanged(evt); - StateHasChanged(); - } - ); - }); - - connection.On("PeerSelectionCleared", async evt => - { - if (evt != null) - { - _peerSelections.Remove(evt.ConnectionId); - await InvokeAsync(StateHasChanged); - } - }); await connection.StartAsync(); } } -``` - -### Step 2: Join SignalR room while opening the diagram - -When opening a diagram, we need to generate a unique ID for each diagram. These unique IDs are then used to create rooms using SignalR, which facilitates sending and receiving data from the server. - -```csharp - string diagramId = "diagram"; - string currentUser = string.Empty; - string roomName = "diagram_group"; - private async Task OnConnectedAsync(string connectionId) { if(!string.IsNullOrEmpty(connectionId)) { - this.ConnectionId = connectionId; - currentUser = string.IsNullOrEmpty(currentUser) ? $"{userCount}" : currentUser; // Join the room after connection is established - await connection.SendAsync("JoinDiagram", roomName, diagramId, currentUser); + await connection.SendAsync("JoinDiagram", RoomName); } } +} ``` -### Step 3: Broadcast current editing changes to remote users +### Step 3: Broadcast Current Editing Changes to Remote Users + +To keep all collaborators in sync, changes made on the blazor server side must be sent to the collaboration hub, which then broadcasts them to other connected users. This is done by handling the `HistoryChanged` event of the Blazor Diagram component and using the `GetDiagramUpdates` method to serialize changes into JSON format for transmission. The collaboration hub method `BroadcastToOtherClients` then sends these updates to all clients in the same SignalR group (room). -Changes made on the client-side need to be sent to the server-side to broadcast them to other connected users. To send the changes made to the server, use the method shown below from the diagram using the `HistoryChange` event. +The `HistoryChanged` event is triggered whenever a change occurs in the diagram, such as adding, deleting, or modifying shapes or connectors. The `GetDiagramUpdates` method converts these changes into a JSON format suitable for sending to the collaboration hub, ensuring updates can be easily applied by other clients. Finally, the `BroadcastToOtherClients` method on the collaboration hub broadcasts these updates to other users in the same collaborative session. Each remote user receives the changes through the `ReceiveData` listener and applies them to their diagram using `SetDiagramUpdatesAsync(diagramChanges)`. + +For grouped interactions (e.g., multiple changes in a single operation), enable `EnableGroupActions` in `DiagramHistoryManager`. This ensures `StartGroupAction` and `EndGroupAction` notifications are included in the `HistoryChanged` event, allowing you to broadcast changes only after the group action completes. ```razor - - - + + + @code { + private async Task InitializeSignalR() + { + if (connection == null) + { + connection = new HubConnectionBuilder() + .WithUrl(NavigationManager.ToAbsoluteUri("/diagramHub"), options => + { + options.SkipNegotiation = true; + options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets; + }) + .WithAutomaticReconnect() + .Build(); + // Triggered when the connection to the collaboration hub is successfully established + connection.On("OnConnectedAsync", OnConnectedAsync); + // Apply remote changes to current diagram. + connection.On>("ReceiveData", async (diagramChanges) => + { + await DiagramInstance.SetDiagramUpdatesAsync(diagramChanges); + }); + await connection.StartAsync(); + } + } public async void OnHistoryChange(HistoryChangedEventArgs args) { - if (args != null && DiagramInstance != null && !isLoadDiagram && !isRevertingCurrentChanges) + if (args != null) { - bool isUndo = args.ActionTrigger == HistoryChangedAction.Undo; - bool isStartGroup = args.EntryType == (isUndo ? HistoryEntryType.EndGroup : HistoryEntryType.StartGroup); - bool isEndGroup = args.EntryType == (isUndo ? HistoryEntryType.StartGroup : HistoryEntryType.EndGroup); - - if (isStartGroup) { editedElements = new(); isGroupAction = true; } List parsedData = DiagramInstance.GetDiagramUpdates(args); - editedElements.AddRange(GetEditedElementIds(args).ToList()); if (parsedData.Count > 0) { - var (selectedElementIds, selectorBounds) = await UpdateOtherClientSelectorBounds(); - SelectionEvent currentSelectionDetails = new SelectionEvent() { ElementIds = selectedElementIds, SelectorBounds = selectorBounds }; - if (connection.State != HubConnectionState.Disconnected) - await connection.SendAsync("BroadcastToOtherClients", parsedData, clientVersion, editedElements, currentSelectionDetails, roomName); + // Send changes to the server for broadcasting + await connection.SendAsync("BroadcastToOtherClients", parsedData, roomName); } - if (isEndGroup || !isGroupAction) { editedElements = new(); isGroupAction = false; } } } } ``` -## How to enable collaborative editing in Blazor +## Collaboration Hub Configuration +### Step 1: Register services, Redis backplane, CORS, and map the hub (Program.cs) + +Add these registrations to your collaboration hub Program.cs so clients can connect and scale via Redis. Adjust policies/connection strings to your environment. +* Register Redis for shared state and backplane support. +* Configure SignalR with Redis for distributed messaging. +* Add your application services (like RedisService). +* Map the SignalR hub so clients can connect. + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Redis (shared connection) +builder.Services.AddSingleton(sp => +{ + var cs = builder.Configuration.GetConnectionString("RedisConnectionString"); + return ConnectionMultiplexer.Connect(cs); +}); + +// SignalR + Redis backplane +builder.Services + .AddSignalR() + .AddStackExchangeRedis(builder.Configuration.GetConnectionString("RedisConnectionString")); -### Step 1: Configure SignalR hub to create room for collaborative editing session +// App services +builder.Services.AddScoped(); // your implementation + +var app = builder.Build(); + +app.MapHub("/diagramHub"); + +app.Run(); +``` + +Notes: +- Ensure WebSockets are enabled on the host/proxy, or remove SkipNegotiation on the client to allow fallback transports. +- Use a singleton IConnectionMultiplexer to respect Redis connection limits. + +### Step 2: Configure Redis Cache Connection String at the Application Level +To enable collaborative editing with real-time synchronization, configure Redis as the temporary data store. Redis ensures fast and reliable communication between multiple server instances when scaling the application. + +Add your Redis connection string in the `appsettings.json` file: +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "RedisConnectionString": "<>" + } +} +``` -To manage groups for each diagram, create a folder named “Hubs” and add a file named “DiagramHub.cs” inside it. Add the following code to the file to manage SignalR groups using room names. +### Step 3: Configure SignalR Hub to Create Rooms for Collaborative Editing Sessions +Create a folder named Hubs and add a file DiagramHub.cs. This hub manages SignalR groups (rooms) per diagram and broadcasts updates to connected clients. -Join the group by using unique id of the diagram by using `JoinGroup` method. +The following key methods are implemented on the collaboration hub: +* **OnConnectedAsync:** It will trigger when a new client connects. Sends the generated connection ID to the client so it can be used as a session identifier. +* **JoinDiagram(roomName):** Adds the current connection to a SignalR group (room) identified by roomName. Also records the mapping so the connection can be removed later. +* **BroadcastToOtherClients(payloads, roomName):** Sends updates to other clients in the same room (excludes the sender). +* **OnDisconnectedAsync:** Triggered when a client disconnects. The hub removes the connection from any rooms it had joined. ```csharp using Microsoft.AspNetCore.SignalR; -using System.Collections.Concurrent; namespace DiagramServerApplication.Hubs { public class DiagramHub : Hub { - private readonly IDiagramService _diagramService; - private readonly IRedisService _redisService; private readonly ILogger _logger; - private readonly IHubContext _diagramHubContext; - private static readonly ConcurrentDictionary _diagramUsers = new(); - public DiagramHub( - IDiagramService diagramService, IRedisService redis, - ILogger logger, IHubContext diagramHubContext) + public DiagramHub(ILogger logger) { - _diagramService = diagramService; - _redisService = redis; _logger = logger; - _diagramHubContext = diagramHubContext; } public override Task OnConnectedAsync() @@ -241,349 +192,343 @@ namespace DiagramServerApplication.Hubs Clients.Caller.SendAsync("OnConnectedAsync", Context.ConnectionId); return base.OnConnectedAsync(); } - - public async Task JoinDiagram(string roomName, string diagramId, string userName) + public async Task JoinDiagram(string roomName) { try { string userId = Context.ConnectionId; - if (_diagramUsers.TryGetValue(userId, out var existingUser)) - { - if (existingUser != null) - { - _diagramUsers.TryRemove(userId, out _); - await Groups.RemoveFromGroupAsync(userId, roomName); - _logger.LogInformation("Removed existing connection for user {UserId} before adding new one", userId); - } - } + // Store room name in current context. + Context.Items["roomName"] = roomName; // Add to SignalR group await Groups.AddToGroupAsync(userId, roomName); - - // Store connection context - Context.Items["DiagramId"] = diagramId; - Context.Items["UserId"] = userId; - Context.Items["UserName"] = userName; - Context.Items["RoomName"] = roomName; - - // Track user in diagram - var diagramUser = new DiagramUser - { - ConnectionId = Context.ConnectionId, - UserName = userName, - }; - bool userExists = _diagramUsers?.Count > 0; - if (!userExists) - await ClearConnectionsFromRedis(); - _diagramUsers.AddOrUpdate(userId, diagramUser, - (key, existingValue) => diagramUser - ); - await RequestAndLoadStateAsync(roomName, diagramId, Context.ConnectionId, Context.ConnectionAborted); - - long currentServerVersion = await GetDiagramVersion(); - await Clients.Caller.SendAsync("UpdateVersion", currentServerVersion); - await Clients.OthersInGroup(roomName).SendAsync("UserJoined", userName); - await SendCurrentSelectionsToCaller(); - List activeUsers = GetCurrentUsers(); - await Clients.Group(roomName).SendAsync("CurrentUsers", activeUsers); - _logger.LogInformation("User {UserId} ({UserName}) joined diagram {DiagramId}. Total users: {UserCount}", userId, userName, diagramId, _diagramUsers.Count); } catch (Exception ex) { - _logger.LogError(ex, "Error joining diagram {DiagramId} for user {UserId}", diagramId, Context.ConnectionId); - await Clients.Caller.SendAsync("JoinError", "Failed to join diagram"); + _logger.LogError(ex); } } - - public async Task BroadcastToOtherClients(List payloads, long clientVersion, List? elementIds, SelectionEvent currentSelection, string roomName) + public async Task BroadcastToOtherClients(List payloads, string roomName) { - var connId = Context.ConnectionId; - var gate = GetConnectionLock(connId); - await gate.WaitAsync(); try { - var versionKey = "diagram:version"; - - var (acceptedSingle, serverVersionSingle) = await _redisService.CompareAndIncrementAsync(versionKey, clientVersion); - long serverVersionFinal = serverVersionSingle; - - if (!acceptedSingle) - { - var recentUpdates = await GetUpdatesSinceVersionAsync(clientVersion, maxScan: 200); - var recentlyTouched = new HashSet(StringComparer.Ordinal); - foreach (var upd in recentUpdates) - { - if (upd.ModifiedElementIds == null) continue; - foreach (var id in upd.ModifiedElementIds) - recentlyTouched.Add(id); - } - - var overlaps = elementIds?.Where(id => recentlyTouched.Contains(id)).Distinct().ToList(); - if (overlaps?.Count > 0) - { - await Clients.Caller.SendAsync("RevertCurrentChanges", elementIds); - await Clients.Caller.SendAsync("ShowConflict"); - return; - } - - var (_, newServerVersion) = await _redisService.CompareAndIncrementAsync(versionKey, serverVersionSingle); - serverVersionFinal = newServerVersion; - } - - var update = new DiagramUpdateMessage - { - SourceConnectionId = connId, - Version = serverVersionFinal, - ModifiedElementIds = elementIds - }; - - await StoreUpdateInRedis(update, connId); - SelectionEvent selectionEvent = BuildSelectedElementEvent(currentSelection.ElementIds, currentSelection.SelectorBounds); - await UpdateSelectionBoundsInRedis(selectionEvent, currentSelection.ElementIds, currentSelection.SelectorBounds); - await Clients.OthersInGroup(roomName).SendAsync("ReceiveData", payloads, serverVersionFinal, selectionEvent); - await RemoveOldUpdates(serverVersionFinal); + // Broadcast diagram changes to other connected clients in same room. + await Clients.OthersInGroup(roomName).SendAsync("ReceiveData", payloads); } - finally + catch (Exception ex) { - gate.Release(); + _logger.LogError(ex); } } public override async Task OnDisconnectedAsync(Exception? exception) { try { - string roomName = Context.Items["RoomName"]?.ToString(); - string userName = Context.Items["UserName"]?.ToString(); - - await Clients.OthersInGroup(roomName) - .SendAsync("PeerSelectionCleared", new SelectionEvent - { - ConnectionId = Context.ConnectionId, - ElementIds = new() - }); - await Clients.OthersInGroup(roomName).SendAsync("UserLeft", userName); - + // Get roomName from context + string roomName = Context.Items["roomName"].ToString(); // Remove from SignalR group await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName); - await _redisService.DeleteAsync(SelectionKey(Context.ConnectionId)); - - // Remove from diagram users tracking - if (_diagramUsers.TryGetValue(Context.ConnectionId, out var user)) - { - if (user != null) - _diagramUsers.TryRemove(Context.ConnectionId, out _); - } - List activeUsers = GetCurrentUsers(); - await Clients.Group(roomName).SendAsync("CurrentUsers", activeUsers); - // Clear context - Context.Items.Remove("DiagramId"); - Context.Items.Remove("UserId"); - Context.Items.Remove("UserName"); await base.OnDisconnectedAsync(exception); } catch (Exception ex) { - _logger.LogError(ex, "Error during disconnect cleanup for connection {ConnectionId}", Context.ConnectionId); + _logger.LogError(ex); } - await base.OnDisconnectedAsync(exception); } } } ``` +## Conflict Policy (Optimistic Concurrency) -### Step 2: Register services, Redis backplane, CORS, and map the hub (Program.cs) - -Add these registrations to your server Program.cs so clients can connect and scale via Redis. Adjust policies/connection strings to your environment. - -```csharp -var builder = WebApplication.CreateBuilder(args); - -// Redis (shared connection) -builder.Services.AddSingleton(sp => -{ - var cs = builder.Configuration.GetConnectionString("RedisConnectionString") - ?? "localhost:6379,abortConnect=false"; - return ConnectionMultiplexer.Connect(cs); -}); - -// SignalR + Redis backplane -builder.Services - .AddSignalR() - .AddStackExchangeRedis(builder.Configuration.GetConnectionString("RedisConnectionString") - ?? "localhost:6379,abortConnect=false"); +To handle conflicts during collaborative editing, we use an optimistic concurrency strategy with versioning: -// App services -builder.Services.AddScoped(); -builder.Services.AddScoped(); // your implementation +* **Versioning**: Each update carries the client’s clientVersion and the list of editedElementIds. +* **Blazor Server Update:** The blazor server sends serialized diagram changes, clientVersion, and editedElementIds to the collaboration hub. -var app = builder.Build(); +* **Collaboration Hub Validation:** The collaboration hub compares the incoming clientVersion with the latest version stored in Redis. + * **If stale and overlapping elements exist:** reject the update, instruct the client to revert, and show a conflict notice. + * **If stale but no overlap:** accept the update and increment the version atomically. +* **Blazor server Synchronization:** After acceptance, the blazor server must update its local clientVersion to the server(collaboration hub) version. -app.MapHub("/diagramHub"); +This approach keeps collaborators in sync without locking, while ensuring deterministic conflict handling. -app.Run(); -``` - -Notes: -- Ensure WebSockets are enabled on the host/proxy, or remove SkipNegotiation on the client to allow fallback transports. -- Use a singleton IConnectionMultiplexer to respect Redis connection limits. - -### Step 3: Configure Redis cache connection string in application level +**Blazor server – Send updates & apply remote changes** +```razor +@using Microsoft.AspNetCore.SignalR.Client + + + -Configure the Redis that stores temporary data for the collaborative editing session. Provide the Redis connection string in `appsettings.json` file. +@code { + HubConnection? connection; + private long clientVersion = 0; -```json -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await InitializeSignalR(); + } + } + private async Task InitializeSignalR() + { + if (connection == null) + { + connection = new HubConnectionBuilder() + .WithUrl(NavigationManager.ToAbsoluteUri("/diagramHub"), options => + { + options.SkipNegotiation = true; + options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets; + }) + .WithAutomaticReconnect() + .Build(); + // Show conflict notification + connection.On("ShowConflict", ShowConflict); + // Update the client version to the latest server version. + connection.On("UpdateVersion", UpdateVersion); + // Receive remote changes with the new server version + connection.On>("ReceiveData", async (diagramChanges, serverVersion) => + { + await ApplyRemoteDiagramChanges(diagramChanges); + }); + await connection.StartAsync(); + } + } + private async Task ApplyRemoteDiagramChanges(List diagramChanges, long newVersion) + { + // Apply remote diagram changes to local diagram + await DiagramInstance.SetDiagramUpdatesAsync(diagramChanges); + // Update to latest server version + this.clientVersion = newVersion; + } + public async Task OnHistoryChange(HistoryChangedEventArgs args) + { + if (args != null) + { + List parsedData = DiagramInstance.GetDiagramUpdates(args); + if (parsedData.Count > 0) + { + List editedElements = GetEditedElements(args); + // Send changes to the server for broadcasting + await connection.SendAsync("BroadcastToOtherClients", parsedData, clientVersion, editedElements, roomName); + } + } + } + private void UpdateVersion(long newVersion) + { + this.clientVersion = newVersion; + } + private void ShowConflict() + { + // Show a dialog telling the user their change was rejected due to overlap + } + private List GetEditedElements(HistoryChangedEventArgs args) + { + // TODO: extract IDs from args (nodes/connectors edited) + return new List(); } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "RedisConnectionString": "<>" - } } ``` - - -## Model types used in the sample (minimal) - -Define these models used by the snippets: - +**Collaboration Hub – Validate with Redis and broadcast** ```csharp -public sealed class SelectionEvent -{ - public string ConnectionId { get; set; } = string.Empty; - public string? UserName { get; set; } - public List? ElementIds { get; set; } - public Rect? SelectorBounds { get; set; } // define Rect for your app -} - -public sealed class DiagramUser -{ - public string ConnectionId { get; set; } = string.Empty; - public string UserName { get; set; } = "User"; -} +using Microsoft.AspNetCore.SignalR; -public sealed class DiagramUpdateMessage +public class DiagramUpdateMessage { - public string SourceConnectionId { get; set; } = string.Empty; + public string SourceConnectionId { get; set; } = ""; public long Version { get; set; } public List? ModifiedElementIds { get; set; } - public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; } -public sealed class DiagramData +public class DiagramHub : Hub { - public string DiagramId { get; set; } = string.Empty; - public string? SerializedState { get; set; } - public long Version { get; set; } -} -``` + private readonly IRedisService _redisService; + private readonly ILogger _logger; -## Client essentials (versioning, reconnect, and revert) - -```csharp -long clientVersion = 0; -bool isRevertingCurrentChanges = false; - -private void UpdateVersion(long serverVersion) -{ - clientVersion = serverVersion; -} - -private async Task RevertCurrentChanges(List elementIds) -{ - isRevertingCurrentChanges = true; - try + public DiagramHub(IRedisService redis, ILogger logger) { - await ReloadElementsFromServerOrCache(elementIds); + _redisService = redis; + _logger = logger; } - finally + public async Task BroadcastToOtherClients(List payloads, long clientVersion, List? elementIds, string roomName) { - isRevertingCurrentChanges = false; - } -} + try + { + string versionKey = "diagram:version"; + // Try to accept based on expected version (CAS via Lua) + (bool accepted, long serverVersion) = await _redisService.CompareAndIncrementAsync(versionKey, clientVersion); -// Rejoin the diagram room if connection drops and reconnects -connection.Reconnected += async _ => -{ - await connection.SendAsync("JoinDiagram", roomName, diagramId, currentUser); -}; -``` + if (!accepted) + { + // Check for overlaps since client's version + List recentUpdates = await GetUpdatesSinceVersionAsync(clientVersion, maxScan: 200); + HashSet recentlyTouched = new HashSet(StringComparer.Ordinal); + foreach (DiagramUpdateMessage upd in recentUpdates) + { + if (upd.ModifiedElementIds == null) continue; + foreach (string id in upd.ModifiedElementIds) + recentlyTouched.Add(id); + } -When using HistoryChange, ensure you declare: + List overlaps = elementIds?.Where(id => recentlyTouched.Contains(id)).Distinct().ToList(); + if (overlaps?.Count > 0) + { + // Reject & notify caller of conflict + await Clients.Caller.SendAsync("ShowConflict"); + return; + } -```csharp -List editedElements = new(); -bool isGroupAction = false; + // Accept non-overlapping stale update: increment once more + (bool _, long newServerVersion) = await _redisService.CompareAndIncrementAsync(versionKey, serverVersion); + serverVersion = newServerVersion; + } + // Store update in Redis history + DiagramUpdateMessage update = new DiagramUpdateMessage + { + SourceConnectionId = connId, + Version = serverVersion, + ModifiedElementIds = elementIds + }; + await StoreUpdateInRedis(update); + // Broadcast to others in the same room, including serverVersion + await Clients.OthersInGroup(roomName).SendAsync("ReceiveData", payloads, serverVersion); + } + catch (Exception ex) + { + _logger.LogError(ex); + } + } +} ``` - -## Per-diagram versioning keys (server) - -Avoid a global version key. Use per-diagram keys: - +**Redis Service Interface & Implementation** +* The IRedisService interface defines `CompareAndIncrementAsync(string key, long expectedVersion)`. +This method checks if the current version stored in Redis matches the version we expect. If it matches, it increases the version by 1. +* **Purpose:** This is used in collaborative applications to avoid conflicts when multiple users edit the same element. It ensures only one update happens at a time. ```csharp -private static string VersionKey(string diagramId) => $"diagram:{diagramId}:version"; -private static string UpdateKey(long version, string diagramId) => $"diagram:{diagramId}:update:{version}"; -private static string SelectionKey(string connectionId, string diagramId) => $"diagram:{diagramId}:selection:{connectionId}"; -``` - -Read diagramId from Context.Items["DiagramId"] inside hub methods and use it for all keys. - -## Conflict policy (optimistic concurrency) +using StackExchange.Redis; -- Client sends payload with clientVersion and edited elementIds. -- Server compares with Redis version. If stale and elements overlap, ask client to revert and show conflict. -- If stale but no overlap, server increments and accepts. -- Clients must set clientVersion to the server version after each accepted update. +public interface IRedisService +{ + Task<(bool accepted, long version)> CompareAndIncrementAsync(string key, long expectedVersion); +} +``` +```csharp +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using System.Text.Json; -## Cleanup strategy for Redis + public class RedisService : IRedisService + { + private readonly IDatabase _database; + private readonly ILogger _logger; -- Keep only the last K versions (e.g., 200), or -- Set TTL on update keys to bound memory usage. + public RedisService(IConnectionMultiplexer redis, ILogger logger) + { + _database = redis.GetDatabase(); + _logger = logger; + } -## Hosting, transport, and serialization + public async Task<(bool accepted, long version)> CompareAndIncrementAsync(string key, long expectedVersion) + { + const string lua = @" +local v = redis.call('GET', KEYS[1]) +if not v then + v = 0 +else + v = tonumber(v) or 0 +end + +local expected = tonumber(ARGV[1]) or -1 + +if v == expected then + local newv = redis.call('INCR', KEYS[1]) + return {1, newv} +else + return {0, v} +end +"; + try + { + RedisResult[] result = (StackExchange.Redis.RedisResult[])await _database.ScriptEvaluateAsync( + lua, + keys: new StackExchange.Redis.RedisKey[] { key }, + values: new StackExchange.Redis.RedisValue[] { expectedVersion.ToString() }); -- Enable WebSockets on your host/reverse proxy; consider keep-alives. -- If WebSockets aren’t available, remove SkipNegotiation on the client to allow fallback transports. -- For large payloads, enable MessagePack on server (and client if applicable) and consider sending diffs. + bool accepted = (int)result[0] == 1; -## Security and rooms + long version; + if (result[1].Type == StackExchange.Redis.ResultType.Integer) + version = (long)result[1]; + else + version = long.Parse((string)result[1]); -- Derive roomName from diagramId (e.g., "diagram:" + diagramId) and validate/normalize on server. -- Consider authentication/authorization to join rooms. -- Rate-limit BroadcastToOtherClients if necessary. + return (accepted, version); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in CompareAndIncrementAsync for key {Key}", key); + } + } + } +``` -## App settings example +## Cleanup Strategy for Redis -```json +To prevent unbounded memory growth and maintain optimal performance, implement one or both of the following strategies: +* **Keep Only the Last K Versions** + * Maintain a fixed-size history list (e.g., last 200 updates) by trimming older entries after each push. + * This ensures that only recent updates are retained for conflict resolution. +* **Set TTL (Time-to-Live) on Update Keys** + * Apply an expiration policy to Redis keys storing version and history data. + * This bounds memory usage and automatically cleans up stale sessions. +```csharp +// In IRedisService +Task SetAsync(string key, T value, TimeSpan? expiry = null); +// In RedisService +public async Task SetAsync(string key, T value, TimeSpan? expiry = null) { - "ConnectionStrings": { - "RedisConnectionString": "<>" - } + try + { + string serializedValue = JsonSerializer.Serialize(value); + return await _database.StringSetAsync(key, serializedValue, expiry); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting key {Key}", key); + return false; + } } -``` - -## CollaborationServer helper methods (required in the sample) -Implement or verify these server helpers exist in the Hub or related services; they are invoked in the snippets above: - -- GetConnectionLock(string connectionId): returns a per-connection SemaphoreSlim for serializing updates. -- RequestAndLoadStateAsync(string roomName, string diagramId, string connectionId, CancellationToken abort): loads existing diagram state (from DB/Redis) and sends to caller via LoadDiagramData. -- GetDiagramVersion(): reads current version from Redis for the current diagram (use VersionKey(diagramId)); return 0 if missing. -- GetUpdatesSinceVersionAsync(long sinceVersion, int maxScan): reads recent DiagramUpdateMessage entries from Redis for conflict checks. -- StoreUpdateInRedis(DiagramUpdateMessage update, string connectionId): stores update under UpdateKey(update.Version, diagramId). -- UpdateSelectionBoundsInRedis(SelectionEvent evt, List? elementIds, Rect? selectorBounds): persists the caller’s selection snapshot under SelectionKey(connectionId, diagramId). -- SendCurrentSelectionsToCaller(): gathers SelectionEvent for active peers in this diagram and sends PeerSelectionsBootstrap to the caller. -- GetCurrentUsers(): returns display names of users in _diagramUsers for this diagram/group. -- RemoveOldUpdates(long latestVersion): trims old updates (keep last K versions or apply TTL) for this diagram. -- ClearConnectionsFromRedis(): clears stale selection keys for all users when the first user joins. -- SelectionKey(string connectionId): if you keep this overload, ensure it internally resolves diagramId; otherwise prefer SelectionKey(connectionId, diagramId). -- BuildSelectedElementEvent(IEnumerable? elementIds, Rect? selectorBounds): constructs SelectionEvent with Context.ConnectionId and current user name. - - -The full version of the code discussed can be found in the GitHub location below. +// Applying TTL to the version key +const string versionKey = "diagram:version"; +long version = 5; +await _redisService.SetAsync(versionKey, version, TimeSpan.FromHours(1)); +``` -GitHub Example: Collaborative editing examples +## Hosting, Transport, and Serialization + +To ensure reliable and efficient collaborative editing, consider the following best practices: +* **1. Hosting** + * Enable WebSockets on your hosting environment and reverse proxy (e.g., Nginx, IIS, Azure App Service). + * Configure keep-alives to maintain long-lived connections and prevent timeouts during idle periods. +* **2. Transport** + * **Preferred:** WebSockets for low-latency, full-duplex communication. + * **Fallback:** If WebSockets are unavailable, remove SkipNegotiation on the Blazor server to allow SignalR to fall back to Server-Sent Events (SSE) or Long Polling. +* **3. Serialization** + * For large payloads, enable MessagePack on both collaboration hub and blazor server projects for efficient binary serialization. + * Consider sending diffs (incremental changes) instead of full diagram state to reduce bandwidth usage. + +## Limitations +* **Single-Server Hosting Only** + * The current implementation does not support multiple server instances. SignalR connections and Redis-based versioning are designed for a single-node setup. Scaling out requires configuring a SignalR backplane (e.g., Redis backplane) and ensuring consistent state across nodes. +* **Zoom and Pan Not Collaborative** + * Zoom and pan actions are local to each client and are not synchronized across users. This means collaborators may view different portions of the diagram independently. +* **Unsupported Diagram Settings** + * Changes to properties such as PageSettings, ContextMenu, DiagramHistoryManager, SnapSettings, Rulers, UmlSequenceDiagram, Layout, and ScrollSettings are not propagated to other users and will only apply locally. + +>**Note:** +> * Collaboration is currently supported only for actions that trigger the HistoryChanged event. + +The full version of the code can be found in the GitHub location below. + +GitHub Example: [Collaborative editing examples](https://github.com/syncfusion/blazor-showcase-diagram-collaborative-editing).