Skip to content

Commit c3b3467

Browse files
hahn-kevmyieye
andauthored
Test snapshots & take them from CRDT projects during merge (#1913)
* pull snapshots out into their own helper * compare projects via snapshot in tests * take the project snapshot test from the crdt api to ensure we don't store data in the snapshot which isn't in the crdt yet * add comment * only exclude properties which are `.Id` not `.ComplexFormId` for example * correct sync test for complex forms * move all merge related apis into their own routes file * create new regenerate project snapshot api * delete unused snapshot tests * correct return on success * update some comments * Ensure snapshot API is the CRDT API --------- Co-authored-by: Tim Haasdyk <[email protected]>
1 parent 3e7530b commit c3b3467

File tree

10 files changed

+313
-171
lines changed

10 files changed

+313
-171
lines changed

backend/FwHeadless/FwHeadlessConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ public string GetCrdtFile(string projectCode, Guid projectId)
5959
return Path.Join(GetProjectFolder(projectCode, projectId), "crdt.sqlite");
6060
}
6161

62+
public FwDataProject GetFwDataProject(Guid projectId)
63+
{
64+
return new FwDataProject(FwDataSubFolder, GetProjectFolder(projectId));
65+
}
66+
6267
public FwDataProject GetFwDataProject(string projectCode, Guid projectId)
6368
{
6469
return new FwDataProject(FwDataSubFolder, GetProjectFolder(projectCode, projectId));

backend/FwHeadless/Program.cs

Lines changed: 1 addition & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,7 @@
7878

7979
app.MapDefaultEndpoints();
8080
app.MapMediaFileRoutes();
81-
82-
app.MapPost("/api/crdt-sync", ExecuteMergeRequest);
83-
app.MapGet("/api/crdt-sync-status", GetMergeStatus);
84-
app.MapGet("/api/await-sync-finished", AwaitSyncFinished);
81+
app.MapMergeRoutes();
8582

8683
// DELETE endpoint to remove a project if it exists
8784
app.MapDelete("/api/manage/repo/{projectId}", async (Guid projectId,
@@ -115,124 +112,3 @@
115112
});
116113

117114
app.Run();
118-
119-
static async Task<Results<Ok, NotFound, ProblemHttpResult>> ExecuteMergeRequest(
120-
SyncHostedService syncHostedService,
121-
ProjectLookupService projectLookupService,
122-
ILogger<Program> logger,
123-
CrdtHttpSyncService crdtHttpSyncService,
124-
IHttpClientFactory httpClientFactory,
125-
Guid projectId)
126-
{
127-
var projectCode = await projectLookupService.GetProjectCode(projectId);
128-
if (projectCode is null)
129-
{
130-
logger.LogError("Project ID {projectId} not found", projectId);
131-
return TypedResults.NotFound();
132-
}
133-
134-
logger.LogInformation("Project code is {projectCode}", projectCode);
135-
//if we can't sync with lexbox fail fast
136-
if (!await crdtHttpSyncService.TestAuth(httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName)))
137-
{
138-
logger.LogError("Unable to authenticate with Lexbox");
139-
return TypedResults.Problem("Unable to authenticate with Lexbox");
140-
}
141-
syncHostedService.QueueJob(projectId);
142-
return TypedResults.Ok();
143-
}
144-
145-
static async Task<Results<Ok<ProjectSyncStatus>, NotFound>> GetMergeStatus(
146-
CurrentProjectService projectContext,
147-
ProjectLookupService projectLookupService,
148-
SendReceiveService srService,
149-
IOptions<FwHeadlessConfig> config,
150-
SyncJobStatusService syncJobStatusService,
151-
IServiceProvider services,
152-
LexBoxDbContext lexBoxDb,
153-
SyncHostedService syncHostedService,
154-
Guid projectId)
155-
{
156-
using var activity = FwHeadlessActivitySource.Value.StartActivity();
157-
activity?.SetTag("app.project_id", projectId);
158-
var jobStatus = syncJobStatusService.SyncStatus(projectId);
159-
if (jobStatus == SyncJobStatus.Running) return TypedResults.Ok(ProjectSyncStatus.Syncing);
160-
if (syncHostedService.IsJobQueuedOrRunning(projectId)) return TypedResults.Ok(ProjectSyncStatus.QueuedToSync);
161-
var project = projectContext.MaybeProject;
162-
if (project is null)
163-
{
164-
// 404 only means "project doesn't exist"; if we don't know the status, then it hasn't synced before and is therefore ready to sync
165-
if (await projectLookupService.ProjectExists(projectId))
166-
{
167-
activity?.SetStatus(ActivityStatusCode.Unset, "Project never synced");
168-
return TypedResults.Ok(ProjectSyncStatus.NeverSynced);
169-
}
170-
activity?.SetStatus(ActivityStatusCode.Error, "Project not found");
171-
return TypedResults.NotFound();
172-
}
173-
var lexboxProject = await lexBoxDb.Projects.Include(p => p.FlexProjectMetadata).FirstOrDefaultAsync(p => p.Id == projectId);
174-
if (lexboxProject is null)
175-
{
176-
// Can't sync if lexbox doesn't have this project
177-
activity?.SetStatus(ActivityStatusCode.Error, "Lexbox project not found");
178-
return TypedResults.NotFound();
179-
}
180-
activity?.SetTag("app.project_code", lexboxProject.Code);
181-
var projectFolder = config.Value.GetProjectFolder(lexboxProject.Code, projectId);
182-
if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder);
183-
var fwDataProject = config.Value.GetFwDataProject(lexboxProject.Code, projectId);
184-
var pendingHgCommits = srService.PendingCommitCount(fwDataProject, lexboxProject.Code); // NOT awaited here so that this long-running task can run in parallel with others
185-
186-
var crdtCommitsOnServer = await lexBoxDb.Set<ServerCommit>().CountAsync(c => c.ProjectId == projectId);
187-
await using var lcmCrdtDbContext = await services.GetRequiredService<IDbContextFactory<LcmCrdtDbContext>>().CreateDbContextAsync();
188-
var localCrdtCommits = await lcmCrdtDbContext.Set<Commit>().CountAsync();
189-
var pendingCrdtCommits = crdtCommitsOnServer - localCrdtCommits;
190-
191-
var lastCrdtCommitDate = await lcmCrdtDbContext.Set<Commit>().MaxAsync(commit => commit.HybridDateTime.DateTime);
192-
var lastHgCommitDate = lexboxProject.LastCommit;
193-
194-
return TypedResults.Ok(ProjectSyncStatus.ReadyToSync(pendingCrdtCommits, await pendingHgCommits, lastCrdtCommitDate, lastHgCommitDate));
195-
}
196-
197-
static async Task<SyncJobResult> AwaitSyncFinished(
198-
SyncHostedService syncHostedService,
199-
SyncJobStatusService syncJobStatusService,
200-
CancellationToken cancellationToken,
201-
Guid projectId)
202-
{
203-
using var activity = FwHeadlessActivitySource.Value.StartActivity();
204-
try
205-
{
206-
var result = await syncHostedService.AwaitSyncFinished(projectId, cancellationToken);
207-
if (result is null)
208-
{
209-
activity?.SetStatus(ActivityStatusCode.Error, "Sync job not found");
210-
return new(SyncJobStatusEnum.SyncJobNotFound, "Sync job not found");
211-
}
212-
213-
activity?.SetStatus(ActivityStatusCode.Ok, "Sync finished");
214-
return result;
215-
}
216-
catch (OperationCanceledException e)
217-
{
218-
if (e.CancellationToken == cancellationToken)
219-
{
220-
// The AwaitSyncFinished call was canceled, but the sync job was not (necessarily) canceled
221-
activity?.SetStatus(ActivityStatusCode.Unset, "Timed out awaiting sync status");
222-
return new SyncJobResult(SyncJobStatusEnum.TimedOutAwaitingSyncStatus, "Timed out awaiting sync status");
223-
}
224-
else
225-
{
226-
activity?.SetStatus(ActivityStatusCode.Error, "Sync job timed out");
227-
return new SyncJobResult(SyncJobStatusEnum.SyncJobTimedOut, "Sync job timed out");
228-
}
229-
}
230-
catch (Exception e)
231-
{
232-
activity?.AddException(e);
233-
var error = e.ToString();
234-
// TODO: Consider only returning exception error for certain users (admins, devs, managers)?
235-
// Note 200 OK returned here; getting the status is a successful HTTP request even if the status is "the job failed and here's why"
236-
return new SyncJobResult(SyncJobStatusEnum.CrdtSyncFailed, error);
237-
}
238-
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
using System.Diagnostics;
2+
using FwHeadless.Services;
3+
using FwLiteProjectSync;
4+
using LcmCrdt;
5+
using LcmCrdt.RemoteSync;
6+
using LexCore.Sync;
7+
using LexData;
8+
using Microsoft.AspNetCore.Http.HttpResults;
9+
using Microsoft.EntityFrameworkCore;
10+
using Microsoft.Extensions.Options;
11+
using MiniLcm;
12+
using SIL.Harmony;
13+
using SIL.Harmony.Core;
14+
15+
namespace FwHeadless.Routes;
16+
17+
public static class MergeRoutes
18+
{
19+
public static IEndpointConventionBuilder MapMergeRoutes(this WebApplication app)
20+
{
21+
var group = app.MapGroup("/api/merge").WithOpenApi();
22+
23+
group.MapPost("/execute", ExecuteMergeRequest);
24+
group.MapPost("/regenerate-snapshot", RegenerateProjectSnapshot);
25+
group.MapGet("/status", GetMergeStatus);
26+
group.MapGet("/await-finished", AwaitSyncFinished);
27+
return group;
28+
}
29+
30+
31+
static async Task<Results<Ok, NotFound, ProblemHttpResult>> ExecuteMergeRequest(
32+
SyncHostedService syncHostedService,
33+
ProjectLookupService projectLookupService,
34+
ILogger<Program> logger,
35+
CrdtHttpSyncService crdtHttpSyncService,
36+
IHttpClientFactory httpClientFactory,
37+
Guid projectId)
38+
{
39+
var projectCode = await projectLookupService.GetProjectCode(projectId);
40+
if (projectCode is null)
41+
{
42+
logger.LogError("Project ID {projectId} not found", projectId);
43+
return TypedResults.NotFound();
44+
}
45+
46+
logger.LogInformation("Project code is {projectCode}", projectCode);
47+
//if we can't sync with lexbox fail fast
48+
if (!await crdtHttpSyncService.TestAuth(httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName)))
49+
{
50+
logger.LogError("Unable to authenticate with Lexbox");
51+
return TypedResults.Problem("Unable to authenticate with Lexbox");
52+
}
53+
syncHostedService.QueueJob(projectId);
54+
return TypedResults.Ok();
55+
}
56+
57+
static async Task<Results<Ok, NotFound<string>>> RegenerateProjectSnapshot(
58+
Guid projectId,
59+
CurrentProjectService projectContext,
60+
ProjectLookupService projectLookupService,
61+
CrdtFwdataProjectSyncService syncService,
62+
IOptions<FwHeadlessConfig> config,
63+
HttpContext context
64+
)
65+
{
66+
using var activity = FwHeadlessActivitySource.Value.StartActivity();
67+
activity?.SetTag("app.project_id", projectId);
68+
var project = projectContext.MaybeProject;
69+
if (project is null)
70+
{
71+
// 404 only means "project doesn't exist"; if we don't know the status, then it hasn't synced before and is therefore ready to sync
72+
if (await projectLookupService.ProjectExists(projectId))
73+
{
74+
activity?.SetStatus(ActivityStatusCode.Unset, "Project never synced");
75+
return TypedResults.NotFound("Project never synced");
76+
}
77+
78+
activity?.SetStatus(ActivityStatusCode.Error, "Project not found");
79+
return TypedResults.NotFound("Project not found");
80+
}
81+
82+
var miniLcmApi = context.RequestServices.GetRequiredService<IMiniLcmApi>();
83+
await syncService.RegenerateProjectSnapshot(miniLcmApi, config.Value.GetFwDataProject(projectId));
84+
return TypedResults.Ok();
85+
}
86+
87+
static async Task<Results<Ok<ProjectSyncStatus>, NotFound>> GetMergeStatus(
88+
CurrentProjectService projectContext,
89+
ProjectLookupService projectLookupService,
90+
SendReceiveService srService,
91+
IOptions<FwHeadlessConfig> config,
92+
SyncJobStatusService syncJobStatusService,
93+
IServiceProvider services,
94+
LexBoxDbContext lexBoxDb,
95+
SyncHostedService syncHostedService,
96+
Guid projectId)
97+
{
98+
using var activity = FwHeadlessActivitySource.Value.StartActivity();
99+
activity?.SetTag("app.project_id", projectId);
100+
var jobStatus = syncJobStatusService.SyncStatus(projectId);
101+
if (jobStatus == SyncJobStatus.Running) return TypedResults.Ok(ProjectSyncStatus.Syncing);
102+
if (syncHostedService.IsJobQueuedOrRunning(projectId)) return TypedResults.Ok(ProjectSyncStatus.QueuedToSync);
103+
var project = projectContext.MaybeProject;
104+
if (project is null)
105+
{
106+
// 404 only means "project doesn't exist"; if we don't know the status, then it hasn't synced before and is therefore ready to sync
107+
if (await projectLookupService.ProjectExists(projectId))
108+
{
109+
activity?.SetStatus(ActivityStatusCode.Unset, "Project never synced");
110+
return TypedResults.Ok(ProjectSyncStatus.NeverSynced);
111+
}
112+
activity?.SetStatus(ActivityStatusCode.Error, "Project not found");
113+
return TypedResults.NotFound();
114+
}
115+
var lexboxProject = await lexBoxDb.Projects.Include(p => p.FlexProjectMetadata).FirstOrDefaultAsync(p => p.Id == projectId);
116+
if (lexboxProject is null)
117+
{
118+
// Can't sync if lexbox doesn't have this project
119+
activity?.SetStatus(ActivityStatusCode.Error, "Lexbox project not found");
120+
return TypedResults.NotFound();
121+
}
122+
activity?.SetTag("app.project_code", lexboxProject.Code);
123+
var projectFolder = config.Value.GetProjectFolder(lexboxProject.Code, projectId);
124+
if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder);
125+
var fwDataProject = config.Value.GetFwDataProject(lexboxProject.Code, projectId);
126+
var pendingHgCommits = srService.PendingCommitCount(fwDataProject, lexboxProject.Code); // NOT awaited here so that this long-running task can run in parallel with others
127+
128+
var crdtCommitsOnServer = await lexBoxDb.Set<ServerCommit>().CountAsync(c => c.ProjectId == projectId);
129+
await using var lcmCrdtDbContext = await services.GetRequiredService<IDbContextFactory<LcmCrdtDbContext>>().CreateDbContextAsync();
130+
var localCrdtCommits = await lcmCrdtDbContext.Set<Commit>().CountAsync();
131+
var pendingCrdtCommits = crdtCommitsOnServer - localCrdtCommits;
132+
133+
var lastCrdtCommitDate = await lcmCrdtDbContext.Set<Commit>().MaxAsync(commit => commit.HybridDateTime.DateTime);
134+
var lastHgCommitDate = lexboxProject.LastCommit;
135+
136+
return TypedResults.Ok(ProjectSyncStatus.ReadyToSync(pendingCrdtCommits, await pendingHgCommits, lastCrdtCommitDate, lastHgCommitDate));
137+
}
138+
139+
static async Task<SyncJobResult> AwaitSyncFinished(
140+
SyncHostedService syncHostedService,
141+
SyncJobStatusService syncJobStatusService,
142+
CancellationToken cancellationToken,
143+
Guid projectId)
144+
{
145+
using var activity = FwHeadlessActivitySource.Value.StartActivity();
146+
try
147+
{
148+
var result = await syncHostedService.AwaitSyncFinished(projectId, cancellationToken);
149+
if (result is null)
150+
{
151+
activity?.SetStatus(ActivityStatusCode.Error, "Sync job not found");
152+
return new(SyncJobStatusEnum.SyncJobNotFound, "Sync job not found");
153+
}
154+
155+
activity?.SetStatus(ActivityStatusCode.Ok, "Sync finished");
156+
return result;
157+
}
158+
catch (OperationCanceledException e)
159+
{
160+
if (e.CancellationToken == cancellationToken)
161+
{
162+
// The AwaitSyncFinished call was canceled, but the sync job was not (necessarily) canceled
163+
activity?.SetStatus(ActivityStatusCode.Unset, "Timed out awaiting sync status");
164+
return new SyncJobResult(SyncJobStatusEnum.TimedOutAwaitingSyncStatus, "Timed out awaiting sync status");
165+
}
166+
else
167+
{
168+
activity?.SetStatus(ActivityStatusCode.Error, "Sync job timed out");
169+
return new SyncJobResult(SyncJobStatusEnum.SyncJobTimedOut, "Sync job timed out");
170+
}
171+
}
172+
catch (Exception e)
173+
{
174+
activity?.AddException(e);
175+
var error = e.ToString();
176+
// TODO: Consider only returning exception error for certain users (admins, devs, managers)?
177+
// Note 200 OK returned here; getting the status is a successful HTTP request even if the status is "the job failed and here's why"
178+
return new SyncJobResult(SyncJobStatusEnum.CrdtSyncFailed, error);
179+
}
180+
}
181+
182+
}

backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ private void ShouldAllBeEquivalentTo(Dictionary<Guid, Entry> crdtEntries, Dictio
6161
//by default the first sync is an import, this will skip that so that the sync will actually sync data
6262
private async Task BypassImport(bool wsImported = false)
6363
{
64-
var snapshot = CrdtFwdataProjectSyncService.ProjectSnapshot.Empty;
64+
var snapshot = ProjectSnapshot.Empty;
6565
if (wsImported) snapshot = snapshot with { WritingSystems = await _fwDataApi.GetWritingSystems() };
6666
await _syncService.SaveProjectSnapshot(_fwDataApi.Project, snapshot);
6767
}
@@ -137,6 +137,10 @@ public async Task FirstSena3SyncJustDoesAnSync()
137137
var fwdataEntries = await _fwDataApi.GetAllEntries().ToDictionaryAsync(e => e.Id);
138138
fwdataEntries.Count.Should().Be(_fwDataApi.EntryCount);
139139
ShouldAllBeEquivalentTo(crdtEntries, fwdataEntries);
140+
SyncTests.AssertSnapshotsAreEquivalent(
141+
await _fwDataApi.TakeProjectSnapshot(),
142+
await _crdtApi.TakeProjectSnapshot()
143+
);
140144
}
141145

142146
[Fact]
@@ -154,6 +158,10 @@ public async Task SyncWithoutImport_CrdtShouldMatchFwdata()
154158
var fwdataEntries = await _fwDataApi.GetAllEntries().ToDictionaryAsync(e => e.Id);
155159
fwdataEntries.Count.Should().Be(_fwDataApi.EntryCount);
156160
ShouldAllBeEquivalentTo(crdtEntries, fwdataEntries);
161+
SyncTests.AssertSnapshotsAreEquivalent(
162+
await _fwDataApi.TakeProjectSnapshot(),
163+
await _crdtApi.TakeProjectSnapshot()
164+
);
157165
}
158166

159167
[Fact]

0 commit comments

Comments
 (0)