Skip to content

Commit 92a2936

Browse files
authored
(#90) Fixed threading issue in PushAsync (#91)
1 parent 4892839 commit 92a2936

File tree

5 files changed

+123
-15
lines changed

5 files changed

+123
-15
lines changed

src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,12 @@ internal async Task<Page<object>> GetPageAsync(HttpClient client, Uri requestUri
161161
object? result = await JsonSerializer.DeserializeAsync(response.ContentStream, pageType, context.JsonSerializerOptions, cancellationToken).ConfigureAwait(false)
162162
?? throw new DatasyncPullException("JSON result is null") { ServiceResponse = response };
163163

164-
return new Page<object>()
164+
Page<object> page = new Page<object>()
165165
{
166166
Items = (IEnumerable<object>)itemsPropInfo.GetValue(result)!,
167167
NextLink = (string?)nextLinkPropInfo.GetValue(result)
168168
};
169+
return page;
169170
}
170171
catch (JsonException ex)
171172
{

src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ namespace CommunityToolkit.Datasync.Client.Offline.OperationsQueue;
1919
/// </summary>
2020
internal class OperationsQueueManager : IOperationsQueueManager
2121
{
22+
/// <summary>
23+
/// A lock object for locking against concurrent changes to the queue.
24+
/// </summary>
25+
private readonly object pushlock = new();
26+
2227
/// <summary>
2328
/// The map of valid entities that can be synchronized to the service.
2429
/// </summary>
@@ -296,10 +301,14 @@ internal async Task<PushResult> PushAsync(IEnumerable<Type> entityTypes, PushOpt
296301

297302
if (!response.IsSuccessful)
298303
{
299-
operation.LastAttempt = DateTimeOffset.UtcNow;
300-
operation.HttpStatusCode = response.StatusCode;
301-
operation.State = OperationState.Failed;
302-
_ = this._context.Update(operation);
304+
lock (this.pushlock)
305+
{
306+
operation.LastAttempt = DateTimeOffset.UtcNow;
307+
operation.HttpStatusCode = response.StatusCode;
308+
operation.State = OperationState.Failed;
309+
_ = this._context.Update(operation);
310+
}
311+
303312
return response;
304313
}
305314

@@ -311,7 +320,11 @@ internal async Task<PushResult> PushAsync(IEnumerable<Type> entityTypes, PushOpt
311320
ReplaceDatabaseValue(oldValue, newValue);
312321
}
313322

314-
_ = this._context.DatasyncOperationsQueue.Remove(operation);
323+
lock (this.pushlock)
324+
{
325+
_ = this._context.DatasyncOperationsQueue.Remove(operation);
326+
}
327+
315328
return null;
316329
}
317330

@@ -327,8 +340,11 @@ internal void ReplaceDatabaseValue(object? oldValue, object? newValue)
327340
throw new DatasyncException("Internal Datasync Error: invalid values for replacement.");
328341
}
329342

330-
EntityEntry tracker = this._context.Entry(oldValue);
331-
tracker.CurrentValues.SetValues(newValue);
343+
lock (this.pushlock)
344+
{
345+
EntityEntry tracker = this._context.Entry(oldValue);
346+
tracker.CurrentValues.SetValues(newValue);
347+
}
332348
}
333349

334350
/// <summary>

tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceApplicationFactory.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ internal static bool IsValid(IMovie movie)
7070
&& movie.Duration >= 60 && movie.Duration <= 360;
7171
}
7272

73+
internal void ResetInMemoryMovies()
74+
{
75+
using IServiceScope scope = Services.CreateScope();
76+
InMemoryRepository<InMemoryMovie> repository = scope.ServiceProvider.GetRequiredService<IRepository<InMemoryMovie>>() as InMemoryRepository<InMemoryMovie>;
77+
List<InMemoryMovie> sourceData = TestCommon.TestData.Movies.OfType<InMemoryMovie>();
78+
repository.Clear();
79+
sourceData.ForEach(movie => repository.StoreEntity(movie));
80+
}
81+
7382
internal void RunWithRepository<TEntity>(Action<InMemoryRepository<TEntity>> action) where TEntity : InMemoryTableData
7483
{
7584
using IServiceScope scope = Services.CreateScope();

tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceTest.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,18 @@ public abstract class ServiceTest(ServiceApplicationFactory factory)
2020

2121
protected DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow;
2222

23-
internal IntegrationDbContext GetOfflineContext(bool useRealFile = false)
23+
internal IntegrationDbContext GetOfflineContext(bool useRealFile = false, bool enableLogging = false)
2424
{
2525
string filename = null;
2626
string connectionString = "Data Source=:memory:";
2727
if (useRealFile)
2828
{
2929
filename = Path.GetTempFileName();
30-
SqliteConnectionStringBuilder builder = new();
31-
builder.DataSource = filename;
32-
builder.Mode = SqliteOpenMode.ReadWriteCreate;
30+
SqliteConnectionStringBuilder builder = new()
31+
{
32+
DataSource = filename,
33+
Mode = SqliteOpenMode.ReadWriteCreate
34+
};
3335
connectionString = builder.ConnectionString;
3436
}
3537

@@ -38,9 +40,12 @@ internal IntegrationDbContext GetOfflineContext(bool useRealFile = false)
3840

3941
DbContextOptionsBuilder<IntegrationDbContext> optionsBuilder = new();
4042
optionsBuilder.UseSqlite(connection);
41-
optionsBuilder.LogTo(Console.WriteLine);
42-
optionsBuilder.EnableSensitiveDataLogging();
43-
optionsBuilder.EnableDetailedErrors();
43+
if (enableLogging)
44+
{
45+
optionsBuilder.LogTo(Console.WriteLine);
46+
optionsBuilder.EnableSensitiveDataLogging();
47+
optionsBuilder.EnableDetailedErrors();
48+
}
4449

4550
IntegrationDbContext context = new(optionsBuilder.Options)
4651
{
@@ -71,6 +76,9 @@ internal InMemoryMovie GetRandomMovie()
7176
internal TEntity GetServerEntityById<TEntity>(string id) where TEntity : InMemoryTableData
7277
=> factory.GetServerEntityById<TEntity>(id);
7378

79+
internal void ResetInMemoryMovies()
80+
=> factory.ResetInMemoryMovies();
81+
7482
protected void SeedKitchenSinkWithCountryData()
7583
{
7684
factory.RunWithRepository<InMemoryKitchenSink>(repository =>

tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Push_Tests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public void Dispose()
4343
[Fact]
4444
public async Task PushAsync_Complex_Situation()
4545
{
46+
ResetInMemoryMovies();
47+
4648
PullResult initialPullResults = await this.context.Movies.PullAsync();
4749
initialPullResults.IsSuccessful.Should().BeTrue();
4850
initialPullResults.Additions.Should().Be(248);
@@ -109,4 +111,76 @@ public async Task PushAsync_Complex_Situation()
109111
// The service always replaces additions and replacements - updating the last updatedAt.
110112
pullResults.Replacements.Should().Be(moviesToReplace.Count + 1);
111113
}
114+
115+
[Fact]
116+
public async Task PushAsync_Multithreaded()
117+
{
118+
ResetInMemoryMovies();
119+
120+
PullResult initialPullResults = await this.context.Movies.PullAsync();
121+
initialPullResults.IsSuccessful.Should().BeTrue();
122+
initialPullResults.Additions.Should().Be(248);
123+
initialPullResults.Deletions.Should().Be(0);
124+
initialPullResults.Replacements.Should().Be(0);
125+
126+
// Let's add some new movies
127+
ClientMovie blackPanther = new(TestCommon.TestData.Movies.BlackPanther) { Id = Guid.NewGuid().ToString("N") };
128+
this.context.Movies.Add(blackPanther);
129+
await this.context.SaveChangesAsync();
130+
131+
// And remove any movie that matches some criteria
132+
List<ClientMovie> moviesToDelete = await this.context.Movies.Where(x => x.Duration > 180).ToListAsync();
133+
this.context.Movies.RemoveRange(moviesToDelete);
134+
await this.context.SaveChangesAsync();
135+
136+
// Then replace all the Unrated movies with a rating of NC17
137+
List<ClientMovie> moviesToReplace = await this.context.Movies.Where(x => x.Rating == MovieRating.Unrated).ToListAsync();
138+
moviesToReplace.ForEach(r =>
139+
{
140+
r.Rating = MovieRating.NC17;
141+
r.Title = r.Title.PadLeft('-');
142+
this.context.Movies.Update(r);
143+
});
144+
await this.context.SaveChangesAsync();
145+
146+
// Check the queue.
147+
List<DatasyncOperation> operations = await this.context.DatasyncOperationsQueue.ToListAsync();
148+
operations.Count.Should().Be(1 + moviesToDelete.Count + moviesToReplace.Count);
149+
operations.Count(x => x.Kind is OperationKind.Add).Should().Be(1);
150+
operations.Count(x => x.Kind is OperationKind.Delete).Should().Be(moviesToDelete.Count);
151+
operations.Count(x => x.Kind is OperationKind.Replace).Should().Be(moviesToReplace.Count);
152+
153+
// Now push the results and check what we did
154+
PushResult pushResults = await this.context.Movies.PushAsync(new PushOptions { ParallelOperations = 8 });
155+
156+
// This little snippet of code is to aid debugging if this test fails
157+
if (!pushResults.IsSuccessful)
158+
{
159+
foreach (KeyValuePair<string, ServiceResponse> failedRequest in pushResults.FailedRequests)
160+
{
161+
string id = failedRequest.Key;
162+
ServiceResponse response = failedRequest.Value;
163+
string jsonContent = string.Empty;
164+
if (response.HasContent)
165+
{
166+
using StreamReader reader = new(response.ContentStream);
167+
jsonContent = reader.ReadToEnd();
168+
}
169+
170+
Console.WriteLine($"FAILED REQUEST FOR ID: {id}: {response.StatusCode}\n{jsonContent}");
171+
}
172+
}
173+
174+
pushResults.IsSuccessful.Should().BeTrue();
175+
pushResults.CompletedOperations.Should().Be(1 + moviesToDelete.Count + moviesToReplace.Count);
176+
this.context.DatasyncOperationsQueue.Should().BeEmpty();
177+
178+
// Now use PullAsync() again - these should all be pulled down again
179+
PullResult pullResults = await this.context.PullAsync();
180+
pullResults.IsSuccessful.Should().BeTrue();
181+
pullResults.Additions.Should().Be(0);
182+
pullResults.Deletions.Should().Be(0);
183+
// The service always replaces additions and replacements - updating the last updatedAt.
184+
pullResults.Replacements.Should().Be(moviesToReplace.Count + 1);
185+
}
112186
}

0 commit comments

Comments
 (0)