Skip to content

(#358) Default Offline conflict resolver. #382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion docs/in-depth/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,29 @@ This example shows all of the options that can be configured for an entity:
* The `Endpoint` can be relative or absolute. If relative, it is relative to the `BaseAddress` of the `HttpClient` that is used.
* The `Query` limits which entities are requested from the remote service.

### Configuring automatic conflict resolution

By default, the library does not do conflict resolution automatically. You can set an automated conflict resolver by writing an `IConflictResolver` or `IConflictResolver<T>` implementation. The library provides two by default:

* `ClientWinsConflictResolver` will force-write the client version to the server.
* `ServerWinsConflictResolver` will replace the client version with the server version.

You can set the conflict resolver in two ways - per-entity or as a fallback default:

```csharp
protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder builder)
{
// A fallback default for cases when you did not set one per entity
builder.UseDefaultConflictResolver(new ClientWinsConflictResolver());

// Set a specific conflict resolver for an entity.
builder.Entity<Movie>(cfg => {
cfg.ConflictResolver = new ServerWinsConflictResolver();
// Along with any other settings you want to use
})
}
```

## Local only entities

You can specify that a dataset is not to be synchronized by using the `[DoNotSynchronize]` attribute:
Expand Down Expand Up @@ -165,10 +188,61 @@ When the push result is complete, the `PushResult` is returned. This has the fo

* `CompletedOperations` - the number of operations that were completed successfully.
* `IsSuccessful` - a boolean to indicate that the push was completed with no errors.
* `FailedRequests` - a `Dictionary<Uri, ServiceResponse>` that indicates which requests failed.
* `FailedRequests` - a `Dictionary<string, ServiceResponse>` that indicates which requests failed.

In addition, the operations queue is updated. Completed operations are removed and failed operations are marked as failed. You can use the `FailedRequests` property to see the exact error that was returned by the service.

### Conflict resolution

When a conflict resolver is configured, that will be used before a queued change is marked as failed. In the case of a failed request, you can process the failed requests as follows:

```csharp
foreach (var failedRequest in result.FailedRequests)
{
var operationId = failedRequest.Key;
var serviceResponse = failedRequest.Value;

DatasyncOperation operation = context.DatasyncOperationsQueue.Single(x => x.Id == operationId);
// operation.EntityType is the type of entity being transferred
// operation.Item is the JSON-serialized client-side entity
// operation.EntityVersion is the version of the entity that should be overwritten
// serviceResponse.ContentStream is the JSON-serialized server-side entity
}
```

Handling conflicts is complex and involves modifying the queue entity and/or client-side entity to match requirements. Use conflict resolvers in preference of these manual techniques. A conflict resolver is an implementation of `IConflictResolver` or `IConflictResolver<T>` that is attached to the push operation. The main method is `ResolveConflictAsync()`. For example, let's look at the "client-wins" conflict resolver:

```csharp
public class ClientWinsConflictResolver : IConflictResolver
{
/// <inheritdoc />
public async Task<ConflictResolution> ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default)
{
return new ConflictResolution { Result = ConflictResolutionResult.Client, Entity = clientObject };
}
}
```

The `IConflictResolver<T>` is the same as `IConflictResolver` with the notable exception that the `clientObject` and `serverObject` are typed instead of objects. The `ConflictResolution` result model consists of two parts:

* `Result` is either `ConflictResolutionResult.Client` (indicating that the client wins and the server entity should be overwritten) or `ConflictResolutionResult.Server` (indicating that the server wins and the client entity should be overwritten).
* `Entity` is the entity that should be written.

To provide another example, let's say you want to allow updates from the client for all columns except for a `Title` column. You can do this as follows:

```csharp
public class CustomConflictResolver : IConflictResolver<Movie>
{
public async Task<ConflictResolution> ResolverConflictAsync(Movie? clientObject, Movie? serverObject, CancellationToken cancellationToken = default)
{
clientObject.Movie = serverObject.Movie;
return new ConflictResolution { Result = ConflictResolutionResult.Client, Entity = clientObject };
}
}
```

Here, we copy the server value of the movie title to the client before returning so that the title is preserved.

## Pulling data from the service

As with push operations, there are many ways of pulling data from the service. For most situations, you can specify a single filter when configuring the datasync service in `OnDatasyncInitialization` and then use one of the following methods:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace CommunityToolkit.Datasync.Client.Offline;
public class DatasyncOfflineOptionsBuilder
{
internal IHttpClientFactory? _httpClientFactory;
internal IConflictResolver? _defaultConflictResolver;
internal readonly Dictionary<string, EntityOfflineOptions> _entities;

/// <summary>
Expand Down Expand Up @@ -78,6 +79,19 @@ public DatasyncOfflineOptionsBuilder UseHttpClientOptions(HttpClientOptions clie
return this;
}

/// <summary>
/// Sets the default conflict resolver to use for all entities that do not have a specific
/// conflict resolver set.
/// </summary>
/// <param name="conflictResolver">The default conflict resolver.</param>
/// <returns>The current builder for chaining.</returns>
public DatasyncOfflineOptionsBuilder UseDefaultConflictResolver(IConflictResolver conflictResolver)
{
ArgumentNullException.ThrowIfNull(conflictResolver);
this._defaultConflictResolver = conflictResolver;
return this;
}

/// <summary>
/// Configures the specified entity type for offline operations.
/// </summary>
Expand Down Expand Up @@ -133,7 +147,8 @@ internal OfflineOptions Build()

OfflineOptions result = new()
{
HttpClientFactory = this._httpClientFactory
HttpClientFactory = this._httpClientFactory,
DefaultConflictResolver = this._defaultConflictResolver
};

foreach (EntityOfflineOptions entity in this._entities.Values)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ internal class OfflineOptions()
/// </summary>
public required IHttpClientFactory HttpClientFactory { get; init; }

/// <summary>
/// The default <see cref="IConflictResolver"/> to use for this request.
/// </summary>
public IConflictResolver? DefaultConflictResolver { get; set; }

/// <summary>
/// Adds an entity to the mapping of options.
/// </summary>
Expand Down Expand Up @@ -50,7 +55,7 @@ public EntityDatasyncOptions GetOptions(Type entityType)
{
return new()
{
ConflictResolver = options.ConflictResolver,
ConflictResolver = options.ConflictResolver ?? DefaultConflictResolver,
Endpoint = options.Endpoint,
HttpClient = HttpClientFactory.CreateClient(options.ClientName),
QueryDescription = options.QueryDescription ?? new QueryDescription()
Expand All @@ -60,7 +65,7 @@ public EntityDatasyncOptions GetOptions(Type entityType)
{
return new()
{
ConflictResolver = null,
ConflictResolver = DefaultConflictResolver,
Endpoint = new Uri($"tables/{entityType.Name.ToLowerInvariant()}", UriKind.Relative),
HttpClient = HttpClientFactory.CreateClient(),
QueryDescription = new QueryDescription()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,71 @@ public async Task GenericConflictResolver_BothNull_ShouldReturnDefault()

#region Integration with OperationsQueueManager Tests

[Fact]
public async Task PushAsync_WithDefaultClientWinsResolver_ShouldResolveConflictAndRetry()
{
// Arrange
var context = CreateContext();

// Configure context to use client wins resolver
context.Configurator = builder =>
{
builder.UseDefaultConflictResolver(new ClientWinsConflictResolver());
builder.Entity<ClientMovie>(c =>
{
c.ClientName = "movies";
c.Endpoint = new Uri("/tables/movies", UriKind.Relative);
});
};

// Create a client movie and save it to generate operation
var clientMovie = new ClientMovie(TestData.Movies.BlackPanther)
{
Id = Guid.NewGuid().ToString("N"),
Title = "Client Title"
};
context.Movies.Add(clientMovie);
context.SaveChanges();

// Setup response for conflict followed by success
var serverMovie = new ClientMovie(TestData.Movies.BlackPanther)
{
Id = clientMovie.Id,
Title = "Server Title",
UpdatedAt = DateTimeOffset.UtcNow,
Version = Guid.NewGuid().ToString()
};
string serverJson = DatasyncSerializer.Serialize(serverMovie);

// First response is a conflict
context.Handler.AddResponseContent(serverJson, HttpStatusCode.Conflict);

// Second response (after resolution) is success
var finalMovie = new ClientMovie(TestData.Movies.BlackPanther)
{
Id = clientMovie.Id,
Title = "Client Title", // This should match the client version after resolution
UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1),
Version = Guid.NewGuid().ToString()
};
string finalJson = DatasyncSerializer.Serialize(finalMovie);
context.Handler.AddResponseContent(finalJson, HttpStatusCode.OK);

// Act
var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions());

// Assert
result.IsSuccessful.Should().BeTrue();
result.CompletedOperations.Should().Be(1);
result.FailedRequests.Should().BeEmpty();

// Verify the database has the right value
var savedMovie = context.Movies.Find(clientMovie.Id);
savedMovie.Should().NotBeNull();
savedMovie!.Title.Should().Be("Client Title");
savedMovie.Version.Should().Be(finalMovie.Version);
}

[Fact]
public async Task PushAsync_WithClientWinsResolver_ShouldResolveConflictAndRetry()
{
Expand Down