Skip to content
Shiny.NET
Commercial SupportGuaranteed response times on Shiny bug fixes, straight from the maintainers. Plans from US$1,500 a year.See the plans

Conflict Resolution

When the server returns 409 Conflict or 412 Precondition Failed for an outbox operation, Shiny.Data.Sync consults the endpoint’s DefaultConflictPolicy to decide what to do.

opts.RegisterEndpoint<Project>(url, ep =>
{
ep.DefaultConflictPolicy = ConflictPolicy.AskDelegate; // default
});
Policy Behavior
AskDelegate (default) Calls IDataSyncDelegate.OnConflict(op, remotePayload) on each registered delegate until one decides. See More than one delegate.
ServerWins Drops the local op, dispatches the remote payload through OnReceived as an Update.
ClientWins Re-queues the local op as-is. The server should ultimately accept it (e.g. drop the ETag check).

OnConflict receives the local SyncOperation and the current remote payload as the server returned it. Decide what to apply by returning a ConflictResolution:

public Task<ConflictResolution> OnConflict(SyncOperation op, string remotePayload)
{
// Discard the local op and accept the remote state. Same as ConflictPolicy.ServerWins.
if (alwaysTrustServer)
return Task.FromResult(ConflictResolution.AcceptRemote);
// Force the local op through. Same as ConflictPolicy.ClientWins.
if (op.Verb == SyncVerb.Delete)
return Task.FromResult(ConflictResolution.KeepLocal);
// Merge: parse both sides, build a combined payload, retry with that.
var local = JsonSerializer.Deserialize<Project>(op.Payload!, AppJsonContext.Default.Project)!;
var remote = JsonSerializer.Deserialize<Project>(remotePayload, AppJsonContext.Default.Project)!;
var merged = local with
{
Title = remote.Title, // server-wins for title
Tags = local.Tags.Union(remote.Tags).ToArray(), // union of tags
UpdatedAt = DateTimeOffset.UtcNow
};
var mergedJson = JsonSerializer.Serialize(merged, AppJsonContext.Default.Project);
return Task.FromResult(ConflictResolution.UseMerged(mergedJson));
}

ConflictResolution factory methods:

Factory Result
ConflictResolution.AcceptRemote Same as ServerWins — the remote payload is dispatched as an Update and the local op is removed.
ConflictResolution.KeepLocal Re-queues the local op as-is.
ConflictResolution.UseMerged(string mergedPayload) Replaces the op’s Payload with mergedPayload and re-queues.
ConflictResolution.Abstain No opinion. The next registered delegate is asked. If every delegate abstains, the server’s copy wins, as with AcceptRemote.

You can register more than one IDataSyncDelegate. For a conflict, they are asked in registration order, and the first real answer wins: once a delegate returns AcceptRemote, KeepLocal or UseMerged, that resolution is applied and later delegates are not called.

A delegate that has no opinion returns ConflictResolution.Abstain, and the next delegate is asked. Returning null counts as abstaining too. (Before 6.5.3 a null result threw inside the engine and the op went down the error and retry path.) A delegate that throws is logged and skipped the same way. If every delegate abstains, the server’s copy wins.

// A logging delegate that never decides. It only sees the conflict if it is
// registered before the delegate that does decide.
public Task<ConflictResolution> OnConflict(SyncOperation op, string remotePayload)
{
logger.LogInformation("Conflict on {Entity}", op.EntityIdentifier);
return Task.FromResult(ConflictResolution.Abstain);
}

Because the first real answer stops the loop, an abstaining delegate registered after the deciding one is never called. To watch conflicts without depending on registration order, use the Activity events below.

To see conflicts without deciding them (for logging, telemetry or a status bar), subscribe to the Activity stream. Two events cover a conflict:

Event When Carries
OutboxConflict The server answered 409 / 412. Operation, StatusCode, RemotePayload (the server’s copy sent with the conflict).
OutboxConflictResolved The endpoint’s ConflictPolicy or a delegate has decided, before the op’s next event. The same, plus Resolution (the ConflictResolution that was applied).
sync.Activity += (s, evt) =>
{
switch (evt.Type)
{
case SyncEventType.OutboxConflict:
Log.Warn($"{evt.EndpointKey} conflict for {evt.Operation?.EntityIdentifier} (HTTP {evt.StatusCode}). Server has: {evt.RemotePayload}");
break;
case SyncEventType.OutboxConflictResolved:
Log.Info($"{evt.Operation?.EntityIdentifier} resolved with {evt.Resolution?.Action}");
break;
}
};

OutboxConflictResolved fires under every policy, so a ServerWins endpoint reports AcceptRemote and a ClientWins endpoint reports KeepLocal. This works however many delegates you register and in whatever order.

The typed UpdateReceived event carries the same information on SyncOperationResult.RemotePayload and SyncOperationResult.Resolution. The update that applies the resolution has both: Completed for AcceptRemote, Pending when the local change is sent again. On the HttpClient engine (Android, Windows, Linux, macOS, Blazor) the operation also fires as SyncOperationState.ConflictPending, with RemotePayload set, so a status bar can flag it.

Situation Recommended policy
Reference data the user can’t edit on the client ServerWins — the server is the source of truth.
Mobile-first workflow where the user expects their edit to win ClientWins (or KeepLocal from the delegate).
Real conflicts (two editors, both with local changes) AskDelegate with a merge implementation.
You can’t merge but want to log and skip AskDelegate returning AcceptRemote.
A library or diagnostics delegate that should never decide Return Abstain, or better, listen to the OutboxConflict / OutboxConflictResolved events.

Shiny.Data.Sync does not attach If-Match headers automatically. If your server uses optimistic concurrency, attach the ETag either:

  • in the per-endpoint OnBeforeSend hook (read it off the op’s payload or your local store), or
  • in a global ISyncInterceptor.BeforePush for cross-cutting precondition handling.