Skip to content

Commit 0511bb1

Browse files
adrianhallAdrian Hall
andauthored
(#383) Capture the local exceptions in a pull operation. (#398)
Co-authored-by: Adrian Hall <[email protected]>
1 parent d5d7941 commit 0511bb1

File tree

4 files changed

+137
-60
lines changed

4 files changed

+137
-60
lines changed

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using CommunityToolkit.Datasync.Client.Serialization;
56
using System.Collections.Concurrent;
67
using System.Collections.Immutable;
78

@@ -14,11 +15,12 @@ public class PullResult
1415
{
1516
private int _additions, _deletions, _replacements;
1617
private readonly ConcurrentDictionary<Uri, ServiceResponse> _failedRequests = new();
18+
private readonly ConcurrentDictionary<string, Exception> _localExceptions = new();
1719

1820
/// <summary>
1921
/// Determines if the pull result was completely successful.
2022
/// </summary>
21-
public bool IsSuccessful { get => this._failedRequests.IsEmpty; }
23+
public bool IsSuccessful { get => this._failedRequests.IsEmpty && this._localExceptions.IsEmpty; }
2224

2325
/// <summary>
2426
/// The total count of operations performed on this pull operation.
@@ -41,13 +43,36 @@ public class PullResult
4143
public int Replacements { get => this._replacements; }
4244

4345
/// <summary>
44-
/// The list of failed requests.
46+
/// The list of failed requests. The key is the request URI, and the value is
47+
/// the <see cref="ServiceResponse"/> for that request.
4548
/// </summary>
4649
public IReadOnlyDictionary<Uri, ServiceResponse> FailedRequests { get => this._failedRequests.ToImmutableDictionary(); }
4750

51+
/// <summary>
52+
/// The list of local exceptions. The key is the GUID of the entity that caused the exception,
53+
/// and the value is the exception itself.
54+
/// </summary>
55+
public IReadOnlyDictionary<string, Exception> LocalExceptions { get => this._localExceptions.ToImmutableDictionary(); }
56+
57+
/// <summary>
58+
/// Adds a failed request to the list of failed requests.
59+
/// </summary>
60+
/// <param name="requestUri">The request URI causing the failure.</param>
61+
/// <param name="response">The response for the request.</param>
4862
internal void AddFailedRequest(Uri requestUri, ServiceResponse response)
4963
=> _ = this._failedRequests.TryAdd(requestUri, response);
5064

65+
/// <summary>
66+
/// Adds a local exception to the list of local exceptions.
67+
/// </summary>
68+
/// <param name="entityMetadata">The entity metadata, or null if not available.</param>
69+
/// <param name="exception">The exception that was thrown.</param>
70+
internal void AddLocalException(EntityMetadata? entityMetadata, Exception exception)
71+
{
72+
string entityId = entityMetadata?.Id ?? $"NULL:{Guid.NewGuid():N}";
73+
_ = this._localExceptions.TryAdd(entityId, exception);
74+
}
75+
5176
internal void IncrementAdditions()
5277
=> Interlocked.Increment(ref this._additions);
5378

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

Lines changed: 93 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -54,86 +54,108 @@ public async Task<PullResult> ExecuteAsync(IEnumerable<PullRequest> requests, Pu
5454

5555
QueueHandler<PullResponse> databaseUpdateQueue = new(1, async pullResponse =>
5656
{
57-
if (pullResponse.Items.Any())
57+
EntityMetadata? currentMetadata = null;
58+
59+
try
5860
{
59-
DateTimeOffset lastSynchronization = await DeltaTokenStore.GetDeltaTokenAsync(pullResponse.QueryId, cancellationToken).ConfigureAwait(false);
60-
foreach (object item in pullResponse.Items)
61+
if (pullResponse.Items.Any())
6162
{
62-
EntityMetadata metadata = EntityResolver.GetEntityMetadata(item, pullResponse.EntityType);
63-
object? originalEntity = await context.FindAsync(pullResponse.EntityType, [metadata.Id], cancellationToken).ConfigureAwait(false);
64-
65-
if (originalEntity is null && !metadata.Deleted)
66-
{
67-
_ = context.Add(item);
68-
result.IncrementAdditions();
69-
}
70-
else if (originalEntity is not null && metadata.Deleted)
63+
DateTimeOffset lastSynchronization = await DeltaTokenStore.GetDeltaTokenAsync(pullResponse.QueryId, cancellationToken).ConfigureAwait(false);
64+
foreach (object item in pullResponse.Items)
7165
{
72-
_ = context.Remove(originalEntity);
73-
result.IncrementDeletions();
74-
}
75-
else if (originalEntity is not null && !metadata.Deleted)
76-
{
77-
// Gather properties marked with [JsonIgnore]
78-
HashSet<string> ignoredProps = pullResponse.EntityType
79-
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
80-
.Where(p => p.IsDefined(typeof(JsonIgnoreAttribute), inherit: true))
81-
.Select(p => p.Name)
82-
.ToHashSet();
83-
84-
EntityEntry originalEntry = context.Entry(originalEntity);
85-
EntityEntry newEntry = context.Entry(item);
66+
EntityMetadata metadata = EntityResolver.GetEntityMetadata(item, pullResponse.EntityType);
67+
currentMetadata = metadata;
68+
object? originalEntity = await context.FindAsync(pullResponse.EntityType, [metadata.Id], cancellationToken).ConfigureAwait(false);
8669

87-
// Only copy properties that are not marked with [JsonIgnore]
88-
foreach (IProperty property in originalEntry.Metadata.GetProperties())
70+
if (originalEntity is null && !metadata.Deleted)
71+
{
72+
_ = context.Add(item);
73+
result.IncrementAdditions();
74+
}
75+
else if (originalEntity is not null && metadata.Deleted)
8976
{
90-
if (!ignoredProps.Contains(property.Name))
77+
_ = context.Remove(originalEntity);
78+
result.IncrementDeletions();
79+
}
80+
else if (originalEntity is not null && !metadata.Deleted)
81+
{
82+
// Gather properties marked with [JsonIgnore]
83+
HashSet<string> ignoredProps = pullResponse.EntityType
84+
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
85+
.Where(p => p.IsDefined(typeof(JsonIgnoreAttribute), inherit: true))
86+
.Select(p => p.Name)
87+
.ToHashSet();
88+
89+
EntityEntry originalEntry = context.Entry(originalEntity);
90+
EntityEntry newEntry = context.Entry(item);
91+
92+
// Only copy properties that are not marked with [JsonIgnore]
93+
foreach (IProperty property in originalEntry.Metadata.GetProperties())
9194
{
92-
originalEntry.Property(property.Name).CurrentValue = newEntry.Property(property.Name).CurrentValue;
95+
if (!ignoredProps.Contains(property.Name))
96+
{
97+
originalEntry.Property(property.Name).CurrentValue = newEntry.Property(property.Name).CurrentValue;
98+
}
9399
}
100+
101+
result.IncrementReplacements();
94102
}
95103

96-
result.IncrementReplacements();
104+
if (metadata.UpdatedAt > lastSynchronization)
105+
{
106+
lastSynchronization = metadata.UpdatedAt.Value;
107+
bool isAdded = await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false);
108+
if (isAdded)
109+
{
110+
// Sqlite oddity - you can't add then update; it changes the change type to UPDATE, which then fails.
111+
_ = await context.SaveChangesAsync(true, false, cancellationToken).ConfigureAwait(false);
112+
}
113+
}
114+
currentMetadata = null;
97115
}
98116

99-
if (metadata.UpdatedAt > lastSynchronization)
117+
if (pullOptions.SaveAfterEveryServiceRequest)
100118
{
101-
lastSynchronization = metadata.UpdatedAt.Value;
102-
bool isAdded = await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false);
103-
if (isAdded)
104-
{
105-
// Sqlite oddity - you can't add then update; it changes the change type to UPDATE, which then fails.
106-
_ = await context.SaveChangesAsync(true, false, cancellationToken).ConfigureAwait(false);
107-
}
119+
_ = await context.SaveChangesAsync(true, false, cancellationToken).ConfigureAwait(false);
108120
}
109-
}
110121

111-
if (pullOptions.SaveAfterEveryServiceRequest)
112-
{
113-
_ = await context.SaveChangesAsync(true, false, cancellationToken).ConfigureAwait(false);
122+
context.SendSynchronizationEvent(new SynchronizationEventArgs()
123+
{
124+
EventType = SynchronizationEventType.ItemsCommitted,
125+
EntityType = pullResponse.EntityType,
126+
ItemsProcessed = pullResponse.TotalItemsProcessed,
127+
ItemsTotal = pullResponse.TotalRequestItems,
128+
QueryId = pullResponse.QueryId
129+
});
114130
}
115131

116-
context.SendSynchronizationEvent(new SynchronizationEventArgs()
132+
if (pullResponse.Completed)
117133
{
118-
EventType = SynchronizationEventType.ItemsCommitted,
119-
EntityType = pullResponse.EntityType,
120-
ItemsProcessed = pullResponse.TotalItemsProcessed,
121-
ItemsTotal = pullResponse.TotalRequestItems,
122-
QueryId = pullResponse.QueryId
123-
});
134+
context.SendSynchronizationEvent(new SynchronizationEventArgs()
135+
{
136+
EventType = SynchronizationEventType.PullEnded,
137+
EntityType = pullResponse.EntityType,
138+
ItemsProcessed = pullResponse.TotalItemsProcessed,
139+
ItemsTotal = pullResponse.TotalRequestItems,
140+
QueryId = pullResponse.QueryId,
141+
Exception = pullResponse.Exception,
142+
ServiceResponse = pullResponse.Exception is DatasyncPullException ex ? ex.ServiceResponse : null
143+
});
144+
}
124145
}
125-
126-
if (pullResponse.Completed)
146+
catch (Exception ex)
127147
{
148+
// An exception is thrown in the local processing section of the pull operation. We can't
149+
// handle it properly, so we add it to the result and send a synchronization event to allow
150+
// the developer to capture the exception.
151+
result.AddLocalException(currentMetadata, ex);
128152
context.SendSynchronizationEvent(new SynchronizationEventArgs()
129153
{
130-
EventType = SynchronizationEventType.PullEnded,
154+
EventType = SynchronizationEventType.LocalException,
131155
EntityType = pullResponse.EntityType,
132-
ItemsProcessed = pullResponse.TotalItemsProcessed,
133-
ItemsTotal = pullResponse.TotalRequestItems,
134156
QueryId = pullResponse.QueryId,
135-
Exception = pullResponse.Exception,
136-
ServiceResponse = pullResponse.Exception is DatasyncPullException ex ? ex.ServiceResponse : null
157+
Exception = ex,
158+
EntityMetadata = currentMetadata
137159
});
138160
}
139161
});
@@ -189,6 +211,20 @@ public async Task<PullResult> ExecuteAsync(IEnumerable<PullRequest> requests, Pu
189211
result.AddFailedRequest(requestUri, ex.ServiceResponse);
190212
databaseUpdateQueue.Enqueue(new PullResponse(pullRequest.EntityType, pullRequest.QueryId, [], totalCount, itemsProcessed, true, ex));
191213
}
214+
catch (Exception localex)
215+
{
216+
// An exception is thrown that is locally generated. We can't handle it properly, so we
217+
// add it to the result and send a synchronization event to allow the developer to capture
218+
// the exception.
219+
result.AddLocalException(null, localex);
220+
context.SendSynchronizationEvent(new SynchronizationEventArgs()
221+
{
222+
EventType = SynchronizationEventType.LocalException,
223+
EntityType = pullRequest.EntityType,
224+
QueryId = pullRequest.QueryId,
225+
Exception = localex
226+
});
227+
}
192228
});
193229

194230
// Get requests we need to enqueue. Note : do not enqueue them yet. Context only supports one outstanding query at a time and we don't want a query from a background task being run concurrently with GetDeltaTokenAsync.

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using CommunityToolkit.Datasync.Client.Serialization;
6+
57
namespace CommunityToolkit.Datasync.Client.Offline;
68

79
/// <summary>
@@ -14,6 +16,7 @@ public enum SynchronizationEventType
1416
/// </summary>
1517
/// <remarks><see cref="SynchronizationEventArgs.ItemsProcessed"/> is not yet known here</remarks>
1618
PullStarted,
19+
1720
/// <summary>
1821
/// Occurs when items have been successfully fetched from the server.
1922
/// </summary>
@@ -30,18 +33,26 @@ public enum SynchronizationEventType
3033
/// Pull for the given entity ended.
3134
/// </summary>
3235
PullEnded,
36+
3337
/// <summary>
3438
/// Push operation started.
3539
/// </summary>
3640
PushStarted,
41+
3742
/// <summary>
3843
/// An item was pushed to the server
3944
/// </summary>
4045
PushItem,
46+
4147
/// <summary>
4248
/// Push operation ended.
4349
/// </summary>
4450
PushEnded,
51+
52+
/// <summary>
53+
/// A local exception was thrown during pull operations.
54+
/// </summary>
55+
LocalException,
4556
}
4657

4758
/// <summary>
@@ -94,4 +105,9 @@ public class SynchronizationEventArgs
94105
/// The operation that was executed. Not used on pull events.
95106
/// </summary>
96107
public DatasyncOperation? PushOperation { get; init; }
108+
109+
/// <summary>
110+
/// The local metadata of the entity being modified during local exceptions.
111+
/// </summary>
112+
public EntityMetadata? EntityMetadata { get; init; }
97113
}

src/CommunityToolkit.Datasync.Client/Serialization/EntityMetadata.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace CommunityToolkit.Datasync.Client.Serialization;
77
/// <summary>
88
/// A representation of just the metadata for an entity.
99
/// </summary>
10-
internal class EntityMetadata
10+
public class EntityMetadata
1111
{
1212
/// <summary>
1313
/// The globally unique ID of the entity.

0 commit comments

Comments
 (0)