Skip to content

Commit 9dbe6ed

Browse files
committed
(CommunityToolkit#298) Conflict resolver.
1 parent 55a1712 commit 9dbe6ed

File tree

7 files changed

+644
-0
lines changed

7 files changed

+644
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace CommunityToolkit.Datasync.Client.Offline;
6+
7+
/// <summary>
8+
/// An abstract class that provides a mechanism for resolving conflicts between client and server objects of a specified
9+
/// type asynchronously. The object edition of the conflict resolver just calls the typed version.
10+
/// </summary>
11+
/// <typeparam name="TEntity">The type of entity being resolved.</typeparam>
12+
public abstract class AbstractConflictResolver<TEntity> : IConflictResolver<TEntity>
13+
{
14+
/// <inheritdoc />
15+
public abstract Task<TEntity?> ResolveConflictAsync(TEntity? clientObject, TEntity? serverObject, CancellationToken cancellationToken = default);
16+
17+
/// <summary>
18+
/// The object version of the resolver calls the typed version.
19+
/// </summary>
20+
/// <param name="clientObject"></param>
21+
/// <param name="serverObject"></param>
22+
/// <param name="cancellationToken"></param>
23+
/// <returns></returns>
24+
public virtual async Task<object?> ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default)
25+
=> await ResolveConflictAsync((TEntity?)clientObject, (TEntity?)serverObject, cancellationToken);
26+
}
27+
28+
/// <summary>
29+
/// A conflict resolver where the client object always wins.
30+
/// </summary>
31+
public class ClientWinsConflictResolver : IConflictResolver
32+
{
33+
/// <inheritdoc />
34+
public Task<object?> ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default)
35+
=> Task.FromResult(clientObject);
36+
}
37+
38+
/// <summary>
39+
/// A conflict resolver where the server object always wins.
40+
/// </summary>
41+
public class ServerWinsConflictResolver : IConflictResolver
42+
{
43+
/// <inheritdoc />
44+
public Task<object?> ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default)
45+
=> Task.FromResult(serverObject);
46+
}
47+

src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs

+10
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ public class EntityOfflineOptions(Type entityType)
164164
/// </summary>
165165
public Uri Endpoint { get; set; } = new Uri($"/tables/{entityType.Name.ToLowerInvariant()}", UriKind.Relative);
166166

167+
/// <summary>
168+
/// The conflict resolver for this entity.
169+
/// </summary>
170+
public IConflictResolver? ConflictResolver { get; set; }
171+
167172
/// <summary>
168173
/// The query description for the entity type - may be null (to mean "pull everything").
169174
/// </summary>
@@ -186,6 +191,11 @@ public class EntityOfflineOptions<TEntity>() where TEntity : class
186191
/// </summary>
187192
public string ClientName { get; set; } = string.Empty;
188193

194+
/// <summary>
195+
/// The conflict resolver for this entity.
196+
/// </summary>
197+
public IConflictResolver? ConflictResolver { get; set; }
198+
189199
/// <summary>
190200
/// The endpoint for the entity type.
191201
/// </summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace CommunityToolkit.Datasync.Client.Offline;
6+
7+
/// <summary>
8+
/// Definition of a conflict resolver. This is used in push situations where
9+
/// the server returns a 409 or 412 status code indicating that the client is
10+
/// out of step with the server.
11+
/// </summary>
12+
public interface IConflictResolver
13+
{
14+
/// <summary>
15+
/// Resolves the conflict between two objects - client side and server side.
16+
/// </summary>
17+
/// <param name="clientObject">The client object.</param>
18+
/// <param name="serverObject">The server object.</param>
19+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
20+
/// <returns>The conflict resolution.</returns>
21+
Task<object?> ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default);
22+
}
23+
24+
/// <summary>
25+
/// Definition of a conflict resolver. This is used in push situations where
26+
/// the server returns a 409 or 412 status code indicating that the client is
27+
/// out of step with the server.
28+
/// </summary>
29+
/// <typeparam name="TEntity">The type of the entity.</typeparam>
30+
public interface IConflictResolver<TEntity> : IConflictResolver
31+
{
32+
/// <summary>
33+
/// Resolves the conflict between two objects - client side and server side.
34+
/// </summary>
35+
/// <param name="clientObject">The client object.</param>
36+
/// <param name="serverObject">The server object.</param>
37+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
38+
/// <returns>The conflict resolution.</returns>
39+
Task<TEntity?> ResolveConflictAsync(TEntity? clientObject, TEntity? serverObject, CancellationToken cancellationToken = default);
40+
}

src/CommunityToolkit.Datasync.Client/Offline/Models/EntityDatasyncOptions.cs

+5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ namespace CommunityToolkit.Datasync.Client.Offline.Models;
1111
/// </summary>
1212
internal class EntityDatasyncOptions
1313
{
14+
/// <summary>
15+
/// The conflict resolver for the entity.
16+
/// </summary>
17+
internal IConflictResolver? ConflictResolver { get; init; }
18+
1419
/// <summary>
1520
/// The endpoint for the entity type.
1621
/// </summary>

src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs

+7
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public EntityDatasyncOptions GetOptions(Type entityType)
4343
{
4444
return new()
4545
{
46+
ConflictResolver = options.ConflictResolver,
4647
Endpoint = options.Endpoint,
4748
HttpClient = HttpClientFactory.CreateClient(options.ClientName),
4849
QueryDescription = options.QueryDescription ?? new QueryDescription()
@@ -52,6 +53,7 @@ public EntityDatasyncOptions GetOptions(Type entityType)
5253
{
5354
return new()
5455
{
56+
ConflictResolver = null,
5557
Endpoint = new Uri($"tables/{entityType.Name.ToLowerInvariant()}", UriKind.Relative),
5658
HttpClient = HttpClientFactory.CreateClient(),
5759
QueryDescription = new QueryDescription()
@@ -69,6 +71,11 @@ internal class EntityOptions
6971
/// </summary>
7072
public required string ClientName { get; set; }
7173

74+
/// <summary>
75+
/// The conflict resolver for the entity options.
76+
/// </summary>
77+
internal IConflictResolver? ConflictResolver { get; set; }
78+
7279
/// <summary>
7380
/// The endpoint for the entity type.
7481
/// </summary>

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

+23
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,29 @@ internal async Task<PushResult> PushAsync(IEnumerable<Type> entityTypes, PushOpt
299299
ExecutableOperation op = await ExecutableOperation.CreateAsync(operation, cancellationToken).ConfigureAwait(false);
300300
ServiceResponse response = await op.ExecuteAsync(options, cancellationToken).ConfigureAwait(false);
301301

302+
if (response.IsConflictStatusCode && options.ConflictResolver is not null)
303+
{
304+
object? serverEntity = JsonSerializer.Deserialize(response.ContentStream, entityType, DatasyncSerializer.JsonSerializerOptions);
305+
object? clientEntity = JsonSerializer.Deserialize(operation.Item, entityType, DatasyncSerializer.JsonSerializerOptions);
306+
object? resolvedEntity = await options.ConflictResolver.ResolveConflictAsync(clientEntity, serverEntity, cancellationToken).ConfigureAwait(false);
307+
if (resolvedEntity is not null)
308+
{
309+
lock (this.pushlock)
310+
{
311+
operation.Item = JsonSerializer.Serialize(resolvedEntity, entityType, DatasyncSerializer.JsonSerializerOptions);
312+
operation.State = OperationState.Pending;
313+
operation.LastAttempt = DateTimeOffset.UtcNow;
314+
operation.HttpStatusCode = response.StatusCode;
315+
operation.EntityVersion = string.Empty; // Force the push
316+
operation.Version++;
317+
_ = this._context.Update(operation);
318+
}
319+
320+
ExecutableOperation resolveOperation = await ExecutableOperation.CreateAsync(operation, cancellationToken).ConfigureAwait(false);
321+
response = await resolveOperation.ExecuteAsync(options, cancellationToken).ConfigureAwait(false);
322+
}
323+
}
324+
302325
if (!response.IsSuccessful)
303326
{
304327
lock (this.pushlock)

0 commit comments

Comments
 (0)