Skip to content

Commit

Permalink
FW Lite push entry changes to frontend (#1029)
Browse files Browse the repository at this point in the history
* setup a change event bus to allow the signalR hub to notify the client of changes.

* create an event bus in the frontend to push changes to the project view

* add SignalR hub to lexbox to allow listening to changes in a crdt project.

* allow background sync to be triggered by project guid

* setup fw lite backend to connect to the lexbox project change hub.

* eliminate duplicate entry updated notifications

* add logging on project updated listener

* pass client Id to server when adding changes so that project update notifications can be filtered by client id

* push change notifications when Senses or ExampleSentences are changed
  • Loading branch information
hahn-kev authored Aug 29, 2024
1 parent b2ac009 commit 99ff808
Show file tree
Hide file tree
Showing 21 changed files with 398 additions and 75 deletions.
15 changes: 12 additions & 3 deletions backend/FwLite/LcmCrdt/CurrentProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ public async ValueTask<ProjectData> GetProjectData()
var key = CacheKey(Project);
if (!memoryCache.TryGetValue(key, out object? result))
{
using var entry = memoryCache.CreateEntry(key);
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
result = await dbContext.ProjectData.AsNoTracking().FirstAsync();
entry.Value = result;
memoryCache.Set(key, result);
memoryCache.Set(CacheKey(((ProjectData)result).Id), result);
}
if (result is null) throw new InvalidOperationException("Project data not found");

Expand All @@ -32,6 +31,16 @@ private static string CacheKey(CrdtProject project)
return project.Name + "|ProjectData";
}

private static string CacheKey(Guid projectId)
{
return $"ProjectData|{projectId}";
}

public static ProjectData? LookupProjectById(IMemoryCache memoryCache, Guid projectId)
{
return memoryCache.Get<ProjectData>(CacheKey(projectId));
}

public async ValueTask<ProjectData> PopulateProjectDataCache()
{
var projectData = await GetProjectData();
Expand Down
1 change: 1 addition & 0 deletions backend/FwLite/LcmCrdt/ProjectsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ internal static async Task SeedSystemData(DataModel dataModel, Guid clientId)

public AsyncServiceScope CreateProjectScope(CrdtProject crdtProject)
{
//todo make this helper method call `CurrentProjectService.PopulateProjectDataCache`
var serviceScope = provider.CreateAsyncScope();
SetProjectScope(crdtProject);
return serviceScope;
Expand Down
6 changes: 6 additions & 0 deletions backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ public async Task Logout()
return auth?.Account.Username;
}

public async ValueTask<string?> GetCurrentToken()
{
var auth = await GetAuth();
return auth?.AccessToken;
}

/// <summary>
/// will return null if no auth token is available
/// </summary>
Expand Down
42 changes: 34 additions & 8 deletions backend/FwLite/LocalWebApp/BackgroundSyncService.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,50 @@
using System.Threading.Channels;
using SIL.Harmony;
using LcmCrdt;
using LocalWebApp.Auth;
using Microsoft.Extensions.Options;
using MiniLcm;
using Microsoft.Extensions.Caching.Memory;

namespace LocalWebApp;

public class BackgroundSyncService(
IServiceProvider serviceProvider,
ProjectsService projectsService,
IHostApplicationLifetime applicationLifetime,
IOptions<AuthConfig>options,
ProjectContext projectContext) : BackgroundService
ProjectContext projectContext,
ILogger<BackgroundSyncService> logger,
IMemoryCache memoryCache) : BackgroundService
{
private readonly Channel<CrdtProject> _syncResultsChannel = Channel.CreateUnbounded<CrdtProject>();

public void TriggerSync(Guid projectId, Guid? ignoredClientId = null)
{
var projectData = CurrentProjectService.LookupProjectById(memoryCache, projectId);
if (projectData is null)
{
logger.LogWarning("Received project update for unknown project {ProjectId}", projectId);
return;
}
if (ignoredClientId == projectData.ClientId)
{
logger.LogInformation("Received project update for {ProjectId} triggered by my own change, ignoring", projectId);
return;
}

var crdtProject = projectsService.GetProject(projectData.Name);
if (crdtProject is null)
{
logger.LogWarning("Received project update for unknown project {ProjectName}", projectData.Name);
return;
}

TriggerSync(crdtProject);
}
public void TriggerSync()
{
_syncResultsChannel.Writer.TryWrite(projectContext.Project ??
throw new InvalidOperationException("No project selected"));
TriggerSync(projectContext.Project ?? throw new InvalidOperationException("No project selected"));
}

public void TriggerSync(CrdtProject crdtProject)
{
_syncResultsChannel.Writer.TryWrite(crdtProject);
}

private Task StartedAsync()
Expand Down Expand Up @@ -50,6 +75,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
private async Task<SyncResults> SyncProject(CrdtProject crdtProject)
{
await using var serviceScope = projectsService.CreateProjectScope(crdtProject);
await serviceScope.ServiceProvider.GetRequiredService<CurrentProjectService>().PopulateProjectDataCache();
var syncService = serviceScope.ServiceProvider.GetRequiredService<SyncService>();
return await syncService.ExecuteSync();
}
Expand Down
10 changes: 5 additions & 5 deletions backend/FwLite/LocalWebApp/CrdtHttpSyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ public async ValueTask<ISyncable> CreateProjectSyncable(ProjectData project)
var client = await authHelpersFactory.GetHelper(project).CreateClient();
if (client is null)
{
logger.LogWarning("Unable to create http client to sync project, user is not authenticated to {OriginDomain}", project.OriginDomain);
logger.LogWarning("Unable to create http client to sync project {ProjectName}, user is not authenticated to {OriginDomain}", project.Name, project.OriginDomain);
return NullSyncable.Instance;
}

return new CrdtProjectSync(RestService.For<ISyncHttp>(client, refitSettings), project.Id, project.OriginDomain, this);
return new CrdtProjectSync(RestService.For<ISyncHttp>(client, refitSettings), project.Id, project.ClientId , project.OriginDomain, this);
}
}

public class CrdtProjectSync(ISyncHttp restSyncClient, Guid projectId, string originDomain, CrdtHttpSyncService httpSyncService) : ISyncable
public class CrdtProjectSync(ISyncHttp restSyncClient, Guid projectId, Guid clientId, string originDomain, CrdtHttpSyncService httpSyncService) : ISyncable

Check warning on line 64 in backend/FwLite/LocalWebApp/CrdtHttpSyncService.cs

View workflow job for this annotation

GitHub Actions / Publish FW Lite app for Mac

Parameter 'originDomain' is unread.
{
public ValueTask<bool> ShouldSync()
{
Expand All @@ -70,7 +70,7 @@ public ValueTask<bool> ShouldSync()

async Task ISyncable.AddRangeFromSync(IEnumerable<Commit> commits)
{
await restSyncClient.AddRange(projectId, commits);
await restSyncClient.AddRange(projectId, commits, clientId);
}

async Task<ChangesResult<Commit>> ISyncable.GetChanges(SyncState otherHeads)
Expand Down Expand Up @@ -104,7 +104,7 @@ public interface ISyncHttp
Task<HttpResponseMessage> HealthCheck();

[Post("/api/crdt/{id}/add")]
internal Task AddRange(Guid id, IEnumerable<Commit> commits);
internal Task AddRange(Guid id, IEnumerable<Commit> commits, Guid? clientId);

[Get("/api/crdt/{id}/get")]
internal Task<SyncState> GetSyncState(Guid id);
Expand Down
33 changes: 16 additions & 17 deletions backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.SignalR;
using LcmCrdt;
using LocalWebApp.Services;
using Microsoft.AspNetCore.SignalR.Client;
using MiniLcm;
using SystemTextJsonPatch;

Expand All @@ -7,12 +9,21 @@ namespace LocalWebApp.Hubs;
public class CrdtMiniLcmApiHub(
ILexboxApi lexboxApi,
BackgroundSyncService backgroundSyncService,
SyncService syncService) : MiniLcmApiHubBase(lexboxApi)
SyncService syncService,
ChangeEventBus changeEventBus,
CurrentProjectService projectContext,
LexboxProjectService lexboxProjectService) : MiniLcmApiHubBase(lexboxApi)
{
public const string ProjectRouteKey = "project";
public static string ProjectGroup(string projectName) => "crdt-" + projectName;

public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, ProjectGroup(projectContext.Project.Name));
await syncService.ExecuteSync();
changeEventBus.SetupGlobalSignalRSubscription();

await lexboxProjectService.ListenForProjectChanges(projectContext.ProjectData, Context.ConnectionAborted);
}

public override async Task<WritingSystem> CreateWritingSystem(WritingSystemType type, WritingSystem writingSystem)
Expand All @@ -22,27 +33,15 @@ public override async Task<WritingSystem> CreateWritingSystem(WritingSystemType
return newWritingSystem;
}

public override async Task<WritingSystem> UpdateWritingSystem(WritingSystemId id, WritingSystemType type, JsonPatchDocument<WritingSystem> update)
public override async Task<WritingSystem> UpdateWritingSystem(WritingSystemId id,
WritingSystemType type,
JsonPatchDocument<WritingSystem> update)
{
var writingSystem = await base.UpdateWritingSystem(id, type, update);
backgroundSyncService.TriggerSync();
return writingSystem;
}

public override async Task<Entry> CreateEntry(Entry entry)
{
var newEntry = await base.CreateEntry(entry);
await NotifyEntryUpdated(newEntry);
return newEntry;
}

public override async Task<Entry> UpdateEntry(Guid id, JsonPatchDocument<Entry> update)
{
var entry = await base.UpdateEntry(id, update);
await NotifyEntryUpdated(entry);
return entry;
}

public override async Task<Sense> CreateSense(Guid entryId, Sense sense)
{
var createdSense = await base.CreateSense(entryId, sense);
Expand Down
1 change: 1 addition & 0 deletions backend/FwLite/LocalWebApp/LocalAppKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public static IServiceCollection AddLocalAppServices(this IServiceCollection ser
services.AddSingleton<UrlContext>();
services.AddScoped<SyncService>();
services.AddScoped<LexboxProjectService>();
services.AddSingleton<ChangeEventBus>();
services.AddSingleton<ImportFwdataService>();
services.AddSingleton<BackgroundSyncService>();
services.AddSingleton<IHostedService>(s => s.GetRequiredService<BackgroundSyncService>());
Expand Down
4 changes: 3 additions & 1 deletion backend/FwLite/LocalWebApp/LocalWebApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="icu.net" Version="2.10.1-beta.5" GeneratePathProperty="true" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.8" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.4" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.61.0" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.63.0" />
<PackageReference Include="Refit" Version="7.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
<PackageReference Include="System.Reactive" Version="6.0.1" />
</ItemGroup>

<ItemGroup>
Expand Down
12 changes: 8 additions & 4 deletions backend/FwLite/LocalWebApp/Routes/TestRoutes.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using SIL.Harmony.Core;
using SIL.Harmony.Db;
using LocalWebApp.Hubs;
using Microsoft.EntityFrameworkCore;
using LocalWebApp.Hubs;
using LocalWebApp.Services;
using Microsoft.OpenApi.Models;
using MiniLcm;
using Entry = LcmCrdt.Objects.Entry;
Expand All @@ -27,6 +25,12 @@ public static IEndpointConventionBuilder MapTest(this WebApplication app)
{
return api.GetEntries();
});
group.MapPost("/set-entry-note", async (ILexboxApi api, ChangeEventBus eventBus, Guid entryId, string ws, string note) =>
{
var entry = await api.UpdateEntry(entryId, api.CreateUpdateBuilder<MiniLcm.Entry>().Set(e => e.Note[ws], note).Build());
if (entry is Entry crdtEntry)
eventBus.NotifyEntryUpdated(crdtEntry);
});
return group;
}
}
50 changes: 50 additions & 0 deletions backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using LcmCrdt;
using LcmCrdt.Objects;
using LocalWebApp.Hubs;
using Microsoft.AspNetCore.SignalR;

namespace LocalWebApp.Services;

public class ChangeEventBus(ProjectContext projectContext, IHubContext<CrdtMiniLcmApiHub, ILexboxHubClient> hubContext, ILogger<ChangeEventBus> logger)
: IDisposable
{
private IDisposable? _subscription;

public void SetupGlobalSignalRSubscription()
{
if (_subscription is not null) return;
_subscription = _entryUpdated.Subscribe(notification =>
{
logger.LogInformation("Sending notification for {EntryId} to {ProjectName}", notification.Entry.Id, notification.ProjectName);
_ = hubContext.Clients.Group(CrdtMiniLcmApiHub.ProjectGroup(notification.ProjectName)).OnEntryUpdated(notification.Entry);
});
}

private record struct ChangeNotification(Entry Entry, string ProjectName);

private readonly Subject<ChangeNotification> _entryUpdated = new();

public IObservable<Entry> OnEntryUpdated
{
get
{
var projectName = projectContext.Project?.Name ?? throw new InvalidOperationException("Not in a project");
return _entryUpdated
.Where(n => n.ProjectName == projectName)
.Select(n => n.Entry);
}
}

public void NotifyEntryUpdated(Entry entry)
{
_entryUpdated.OnNext(new ChangeNotification(entry,
projectContext.Project?.Name ?? throw new InvalidOperationException("Not in a project")));
}

public void Dispose()
{
_subscription?.Dispose();
}
}
Loading

0 comments on commit 99ff808

Please sign in to comment.