diff --git a/Directory.Packages.props b/Directory.Packages.props
index a6f4d7cb..f52f1e0e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,10 +8,12 @@
+
+
diff --git a/Speckle.Sdk.sln b/Speckle.Sdk.sln
index bb0e5bbd..48e0047c 100644
--- a/Speckle.Sdk.sln
+++ b/Speckle.Sdk.sln
@@ -42,6 +42,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Sdk.Tests.Performance", "tests\Speckle.Sdk.Tests.Performance\Speckle.Sdk.Tests.Performance.csproj", "{870E3396-E6F7-43AE-B120-E651FA4F46BD}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Sdk.Serialization.Testing", "tests\Speckle.Sdk.Serialization.Testing\Speckle.Sdk.Serialization.Testing.csproj", "{FF922B6D-D416-4348-8CB8-0C8B28691070}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -84,6 +86,10 @@ Global
{870E3396-E6F7-43AE-B120-E651FA4F46BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{870E3396-E6F7-43AE-B120-E651FA4F46BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{870E3396-E6F7-43AE-B120-E651FA4F46BD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FF922B6D-D416-4348-8CB8-0C8B28691070}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FF922B6D-D416-4348-8CB8-0C8B28691070}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FF922B6D-D416-4348-8CB8-0C8B28691070}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FF922B6D-D416-4348-8CB8-0C8B28691070}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{A413E196-3696-4F48-B635-04B5F76BF9C9} = {5CB96C27-FC5B-4A41-86B6-951AF99B8116}
@@ -95,5 +101,6 @@ Global
{4FB41A6D-D139-4111-8115-E3F9F6BEAF24} = {35047EE7-AD1D-4741-80A7-8F0E874718E9}
{B623BD21-5CAA-43F9-A539-1835276C220E} = {DA2AED52-58F9-471E-8AD8-102FD36129E3}
{870E3396-E6F7-43AE-B120-E651FA4F46BD} = {35047EE7-AD1D-4741-80A7-8F0E874718E9}
+ {FF922B6D-D416-4348-8CB8-0C8B28691070} = {35047EE7-AD1D-4741-80A7-8F0E874718E9}
EndGlobalSection
EndGlobal
diff --git a/src/Speckle.Objects/packages.lock.json b/src/Speckle.Objects/packages.lock.json
index bf429349..e5c32636 100644
--- a/src/Speckle.Objects/packages.lock.json
+++ b/src/Speckle.Objects/packages.lock.json
@@ -234,10 +234,12 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
+ "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.7, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
+ "Microsoft.Extensions.ObjectPool": "[8.0.10, )",
"Polly": "[7.2.3, )",
"Polly.Contrib.WaitAndRetry": "[1.1.1, )",
"Polly.Extensions.Http": "[3.0.0, )",
@@ -256,6 +258,15 @@
"System.Reactive": "5.0.0"
}
},
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.0, )",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==",
+ "dependencies": {
+ "System.Threading.Tasks.Extensions": "4.5.4"
+ }
+ },
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
@@ -290,6 +301,12 @@
"Microsoft.Extensions.Options": "2.2.0"
}
},
+ "Microsoft.Extensions.ObjectPool": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.10, )",
+ "resolved": "8.0.10",
+ "contentHash": "u7gAG7JgxF8VSJUGPSudAcPxOt+ymJKQCSxNRxiuKV+klCQbHljQR75SilpedCTfhPWDhtUwIJpnDVtspr9nMg=="
+ },
"Polly": {
"type": "CentralTransitive",
"requested": "[7.2.3, )",
@@ -494,10 +511,12 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
+ "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.7, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
+ "Microsoft.Extensions.ObjectPool": "[8.0.10, )",
"Polly": "[7.2.3, )",
"Polly.Contrib.WaitAndRetry": "[1.1.1, )",
"Polly.Extensions.Http": "[3.0.0, )",
@@ -516,6 +535,12 @@
"System.Reactive": "5.0.0"
}
},
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.0, )",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
@@ -550,6 +575,12 @@
"Microsoft.Extensions.Options": "2.2.0"
}
},
+ "Microsoft.Extensions.ObjectPool": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.10, )",
+ "resolved": "8.0.10",
+ "contentHash": "u7gAG7JgxF8VSJUGPSudAcPxOt+ymJKQCSxNRxiuKV+klCQbHljQR75SilpedCTfhPWDhtUwIJpnDVtspr9nMg=="
+ },
"Polly": {
"type": "CentralTransitive",
"requested": "[7.2.3, )",
diff --git a/src/Speckle.Sdk/Api/Operations/Operations.Receive.cs b/src/Speckle.Sdk/Api/Operations/Operations.Receive.cs
index 27e3da7a..60d0c296 100644
--- a/src/Speckle.Sdk/Api/Operations/Operations.Receive.cs
+++ b/src/Speckle.Sdk/Api/Operations/Operations.Receive.cs
@@ -2,12 +2,46 @@ using Microsoft.Extensions.Logging;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Models;
using Speckle.Sdk.Serialisation;
+using Speckle.Sdk.Serialisation.V2;
+using Speckle.Sdk.Serialisation.V2.Receive;
using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Api;
public partial class Operations
{
+ public async Task Receive2(
+ Uri url,
+ string streamId,
+ string objectId,
+ string? authorizationToken = null,
+ IProgress? onProgressAction = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ using var receiveActivity = activityFactory.Start("Operations.Receive");
+ metricsFactory.CreateCounter("Receive").Add(1);
+
+ receiveActivity?.SetTag("objectId", objectId);
+
+ try
+ {
+ var sqliteTransport = new SQLiteCacheManager(streamId);
+ var serverObjects = new ServerObjectManager(speckleHttp, activityFactory, url, authorizationToken);
+ var o = new ObjectLoader(sqliteTransport, serverObjects, streamId, onProgressAction);
+ var process = new DeserializeProcess(onProgressAction, o);
+ var result = await process.Deserialize(objectId, cancellationToken).ConfigureAwait(false);
+ receiveActivity?.SetStatus(SdkActivityStatusCode.Ok);
+ return result;
+ }
+ catch (Exception ex)
+ {
+ receiveActivity?.SetStatus(SdkActivityStatusCode.Error);
+ receiveActivity?.RecordException(ex);
+ throw;
+ }
+ }
+
///
/// Receives an object (and all its sub-children) from the two provided s.
///
diff --git a/src/Speckle.Sdk/Api/Operations/Operations.cs b/src/Speckle.Sdk/Api/Operations/Operations.cs
index dd050250..b3bf342c 100644
--- a/src/Speckle.Sdk/Api/Operations/Operations.cs
+++ b/src/Speckle.Sdk/Api/Operations/Operations.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
+using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Api;
@@ -12,6 +13,7 @@ namespace Speckle.Sdk.Api;
[GenerateAutoInterface]
public partial class Operations(
ILogger logger,
+ ISpeckleHttp speckleHttp,
ISdkActivityFactory activityFactory,
ISdkMetricsFactory metricsFactory
) : IOperations;
diff --git a/src/Speckle.Sdk/Helpers/SpeckleHttpClientHandler.cs b/src/Speckle.Sdk/Helpers/SpeckleHttpClientHandler.cs
index 58959fc6..f2fbea93 100644
--- a/src/Speckle.Sdk/Helpers/SpeckleHttpClientHandler.cs
+++ b/src/Speckle.Sdk/Helpers/SpeckleHttpClientHandler.cs
@@ -66,8 +66,14 @@ public sealed class SpeckleHttpClientHandler : DelegatingHandler
if (policyResult.Outcome == OutcomeType.Successful)
{
+ activity?.SetStatus(SdkActivityStatusCode.Ok);
return policyResult.Result.NotNull();
}
+ activity?.SetStatus(SdkActivityStatusCode.Error);
+ if (policyResult.FinalException != null)
+ {
+ activity?.RecordException(policyResult.FinalException);
+ }
// if the policy failed due to a cancellation, AND it was our cancellation token, then don't wrap the exception, and rethrow an new cancellation
if (policyResult.FinalException is OperationCanceledException)
@@ -75,7 +81,10 @@ public sealed class SpeckleHttpClientHandler : DelegatingHandler
cancellationToken.ThrowIfCancellationRequested();
}
- throw new HttpRequestException("Policy Failed", policyResult.FinalException);
+ throw new HttpRequestException(
+ "Policy Failed: " + policyResult.FinalHandledResult?.StatusCode ?? "Unknown",
+ policyResult.FinalException
+ );
}
}
}
diff --git a/src/Speckle.Sdk/Serialisation/SpeckleObjectSerializerPool.cs b/src/Speckle.Sdk/Serialisation/SpeckleObjectSerializerPool.cs
index cedba016..0f881065 100644
--- a/src/Speckle.Sdk/Serialisation/SpeckleObjectSerializerPool.cs
+++ b/src/Speckle.Sdk/Serialisation/SpeckleObjectSerializerPool.cs
@@ -1,4 +1,5 @@
using System.Buffers;
+using Microsoft.Extensions.ObjectPool;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Common;
@@ -22,4 +23,31 @@ public class SpeckleObjectSerializerPool
public void Return(T[]? array) => pool.Return(array.NotNull());
}
+
+ public ObjectPool> ObjectDictionaries { get; } =
+ ObjectPool.Create(new ObjectDictionaryPolicy());
+
+ private class ObjectDictionaryPolicy : IPooledObjectPolicy>
+ {
+ public Dictionary Create() => new(50, StringComparer.OrdinalIgnoreCase);
+
+ public bool Return(Dictionary obj)
+ {
+ obj.Clear();
+ return true;
+ }
+ }
+
+ public ObjectPool> ListString { get; } = ObjectPool.Create(new ListStringPolicy());
+
+ private class ListStringPolicy : IPooledObjectPolicy>
+ {
+ public List Create() => new(20);
+
+ public bool Return(List obj)
+ {
+ obj.Clear();
+ return true;
+ }
+ }
}
diff --git a/src/Speckle.Sdk/Serialisation/V2/AsyncExtensions.cs b/src/Speckle.Sdk/Serialisation/V2/AsyncExtensions.cs
new file mode 100644
index 00000000..849ef5af
--- /dev/null
+++ b/src/Speckle.Sdk/Serialisation/V2/AsyncExtensions.cs
@@ -0,0 +1,212 @@
+using System.Diagnostics;
+
+namespace Speckle.Sdk.Serialisation.V2;
+
+public static class AsyncExtensions
+{
+ public static async ValueTask FirstAsync(this IAsyncEnumerable source)
+ {
+ var e = source.GetAsyncEnumerator();
+ if (await e.MoveNextAsync().ConfigureAwait(false))
+ {
+ return e.Current;
+ }
+ throw new InvalidOperationException("Sequence contains no elements");
+ }
+
+ public static async IAsyncEnumerable SelectManyAsync(this IEnumerable> source)
+ {
+ // get enumerators from all inner IAsyncEnumerable
+ var enumerators = source.Select(x => x.GetAsyncEnumerator()).ToList();
+
+ List, bool)>> runningTasks = new();
+
+ // start all inner IAsyncEnumerable
+ foreach (var asyncEnumerator in enumerators)
+ {
+ runningTasks.Add(MoveNextWrapped(asyncEnumerator));
+ }
+
+ // while there are any running tasks
+ while (runningTasks.Count != 0)
+ {
+ // get next finished task and remove it from list
+ var finishedTask = await Task.WhenAny(runningTasks).ConfigureAwait(false);
+ runningTasks.Remove(finishedTask);
+
+ // get result from finished IAsyncEnumerable
+ var result = await finishedTask.ConfigureAwait(false);
+ var asyncEnumerator = result.Item1;
+ var hasItem = result.Item2;
+
+ // if IAsyncEnumerable has item, return it and put it back as running for next item
+ if (hasItem)
+ {
+ yield return asyncEnumerator.Current;
+
+ runningTasks.Add(MoveNextWrapped(asyncEnumerator));
+ }
+ }
+
+ // don't forget to dispose, should be in finally
+ foreach (var asyncEnumerator in enumerators)
+ {
+ await asyncEnumerator.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Helper method that returns Task with tuple of IAsyncEnumerable and it's result of MoveNextAsync.
+ ///
+ private static async Task<(IAsyncEnumerator, bool)> MoveNextWrapped(
+ IAsyncEnumerator asyncEnumerator
+ )
+ {
+ var res = await asyncEnumerator.MoveNextAsync().ConfigureAwait(false);
+ return (asyncEnumerator, res);
+ }
+
+ public static IAsyncEnumerable BatchAsync(this IAsyncEnumerable source, int size) =>
+ AsyncEnumerableChunkIterator(source, size);
+
+ private static async IAsyncEnumerable AsyncEnumerableChunkIterator(
+ IAsyncEnumerable source,
+ int size
+ )
+ {
+#pragma warning disable CA2007
+ await using IAsyncEnumerator e = source.GetAsyncEnumerator();
+#pragma warning restore CA2007
+
+ // Before allocating anything, make sure there's at least one element.
+ if (await e.MoveNextAsync().ConfigureAwait(false))
+ {
+ // Now that we know we have at least one item, allocate an initial storage array. This is not
+ // the array we'll yield. It starts out small in order to avoid significantly overallocating
+ // when the source has many fewer elements than the chunk size.
+ int arraySize = Math.Min(size, 4);
+ int i;
+ do
+ {
+ var array = new TSource[arraySize];
+
+ // Store the first item.
+ array[0] = e.Current;
+ i = 1;
+
+ if (size != array.Length)
+ {
+ // This is the first chunk. As we fill the array, grow it as needed.
+ for (; i < size && await e.MoveNextAsync().ConfigureAwait(false); i++)
+ {
+ if (i >= array.Length)
+ {
+ arraySize = (int)Math.Min((uint)size, 2 * (uint)array.Length);
+ Array.Resize(ref array, arraySize);
+ }
+
+ array[i] = e.Current;
+ }
+ }
+ else
+ {
+ // For all but the first chunk, the array will already be correctly sized.
+ // We can just store into it until either it's full or MoveNext returns false.
+ TSource[] local = array; // avoid bounds checks by using cached local (`array` is lifted to iterator object as a field)
+ Debug.Assert(local.Length == size);
+ for (; (uint)i < (uint)local.Length && await e.MoveNextAsync().ConfigureAwait(false); i++)
+ {
+ local[i] = e.Current;
+ }
+ }
+
+ if (i != array.Length)
+ {
+ Array.Resize(ref array, i);
+ }
+
+ yield return array;
+ } while (i >= size && await e.MoveNextAsync().ConfigureAwait(false));
+ }
+ }
+
+ public static IEnumerable Batch(this IEnumerable source, int size)
+ {
+ if (source is TSource[] array)
+ {
+ // Special-case arrays, which have an immutable length. This enables us to not only do an
+ // empty check and avoid allocating an iterator object when empty, it enables us to have a
+ // much more efficient (and simpler) implementation for chunking up the array.
+ return array.Length != 0 ? ArrayChunkIterator(array, size) : [];
+ }
+
+ return EnumerableChunkIterator(source, size);
+ }
+
+ private static IEnumerable ArrayChunkIterator(TSource[] source, int size)
+ {
+ int index = 0;
+ while (index < source.Length)
+ {
+ TSource[] chunk = new ReadOnlySpan(source, index, Math.Min(size, source.Length - index)).ToArray();
+ index += chunk.Length;
+ yield return chunk;
+ }
+ }
+
+ private static IEnumerable EnumerableChunkIterator(IEnumerable source, int size)
+ {
+ using IEnumerator e = source.GetEnumerator();
+
+ // Before allocating anything, make sure there's at least one element.
+ if (e.MoveNext())
+ {
+ // Now that we know we have at least one item, allocate an initial storage array. This is not
+ // the array we'll yield. It starts out small in order to avoid significantly overallocating
+ // when the source has many fewer elements than the chunk size.
+ int arraySize = Math.Min(size, 4);
+ int i;
+ do
+ {
+ var array = new TSource[arraySize];
+
+ // Store the first item.
+ array[0] = e.Current;
+ i = 1;
+
+ if (size != array.Length)
+ {
+ // This is the first chunk. As we fill the array, grow it as needed.
+ for (; i < size && e.MoveNext(); i++)
+ {
+ if (i >= array.Length)
+ {
+ arraySize = (int)Math.Min((uint)size, 2 * (uint)array.Length);
+ Array.Resize(ref array, arraySize);
+ }
+
+ array[i] = e.Current;
+ }
+ }
+ else
+ {
+ // For all but the first chunk, the array will already be correctly sized.
+ // We can just store into it until either it's full or MoveNext returns false.
+ TSource[] local = array; // avoid bounds checks by using cached local (`array` is lifted to iterator object as a field)
+ Debug.Assert(local.Length == size);
+ for (; (uint)i < (uint)local.Length && e.MoveNext(); i++)
+ {
+ local[i] = e.Current;
+ }
+ }
+
+ if (i != array.Length)
+ {
+ Array.Resize(ref array, i);
+ }
+
+ yield return array;
+ } while (i >= size && e.MoveNext());
+ }
+ }
+}
diff --git a/src/Speckle.Sdk/Serialisation/V2/Receive/DeserializeProcess.cs b/src/Speckle.Sdk/Serialisation/V2/Receive/DeserializeProcess.cs
new file mode 100644
index 00000000..997343db
--- /dev/null
+++ b/src/Speckle.Sdk/Serialisation/V2/Receive/DeserializeProcess.cs
@@ -0,0 +1,116 @@
+using System.Collections.Concurrent;
+using Speckle.Sdk.Models;
+using Speckle.Sdk.Serialisation.Utilities;
+using Speckle.Sdk.Transports;
+
+namespace Speckle.Sdk.Serialisation.V2.Receive;
+
+public record DeserializeOptions(bool? SkipCacheCheck = null);
+
+public sealed class DeserializeProcess(IProgress? progress, IObjectLoader objectLoader)
+{
+ private readonly ConcurrentDictionary)> _closures = new();
+ private long _total;
+
+ public ConcurrentDictionary BaseCache { get; } = new();
+
+ public async Task Deserialize(
+ string rootId,
+ CancellationToken cancellationToken,
+ DeserializeOptions? options = null
+ )
+ {
+ var (rootJson, childrenIds) = await objectLoader
+ .GetAndCache(rootId, cancellationToken, options)
+ .ConfigureAwait(false);
+ _total = childrenIds.Count;
+ _closures.TryAdd(rootId, (rootJson, childrenIds));
+ progress?.Report(new(ProgressEvent.DeserializeObject, BaseCache.Count, childrenIds.Count));
+ await Traverse(rootId, cancellationToken).ConfigureAwait(false);
+ return BaseCache[rootId];
+ }
+
+ private async Task Traverse(string id, CancellationToken cancellationToken)
+ {
+ if (BaseCache.ContainsKey(id))
+ {
+ return;
+ }
+ var (_, childIds) = GetClosures(id);
+ var tasks = new List();
+ foreach (var childId in childIds)
+ {
+ lock (BaseCache)
+ {
+ if (BaseCache.ContainsKey(childId))
+ {
+ continue;
+ }
+
+ // tmp is necessary because of the way closures close over loop variables
+ var tmpId = childId;
+ Task t = Task
+ .Factory.StartNew(
+ () => Traverse(tmpId, cancellationToken),
+ cancellationToken,
+ TaskCreationOptions.AttachedToParent,
+ TaskScheduler.Default
+ )
+ .Unwrap();
+ tasks.Add(t);
+ }
+ }
+
+ if (tasks.Count > 0)
+ {
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+
+ //don't redo things if the id is decoded already in the cache
+ if (!BaseCache.ContainsKey(id))
+ {
+ DecodeOrEnqueueChildren(id);
+ progress?.Report(new(ProgressEvent.DeserializeObject, BaseCache.Count, _total));
+ }
+ }
+
+ private (string, IReadOnlyList) GetClosures(string id)
+ {
+ if (!_closures.TryGetValue(id, out var closures))
+ {
+ var json = objectLoader.LoadId(id);
+ if (json == null)
+ {
+ throw new InvalidOperationException();
+ }
+ var childrenIds = ClosureParser.GetClosures(json).OrderByDescending(x => x.Item2).Select(x => x.Item1).ToList();
+ closures = (json, childrenIds);
+ _closures.TryAdd(id, closures);
+ }
+
+ return closures;
+ }
+
+ public void DecodeOrEnqueueChildren(string id)
+ {
+ if (BaseCache.ContainsKey(id))
+ {
+ return;
+ }
+ (string json, _) = GetClosures(id);
+ var @base = Deserialise(id, json);
+ BaseCache.TryAdd(id, @base);
+ //remove from JSON cache because we've finally made the Base
+ _closures.TryRemove(id, out _);
+ }
+
+ private Base Deserialise(string id, string json)
+ {
+ if (BaseCache.TryGetValue(id, out var baseObject))
+ {
+ return baseObject;
+ }
+ SpeckleObjectDeserializer2 deserializer = new(BaseCache, SpeckleObjectSerializerPool.Instance);
+ return deserializer.Deserialize(json);
+ }
+}
diff --git a/src/Speckle.Sdk/Serialisation/V2/Receive/DictionaryConverter.cs b/src/Speckle.Sdk/Serialisation/V2/Receive/DictionaryConverter.cs
new file mode 100644
index 00000000..f1b1e07d
--- /dev/null
+++ b/src/Speckle.Sdk/Serialisation/V2/Receive/DictionaryConverter.cs
@@ -0,0 +1,83 @@
+using System.Reflection;
+using Speckle.Newtonsoft.Json;
+using Speckle.Sdk.Common;
+using Speckle.Sdk.Host;
+using Speckle.Sdk.Models;
+using Speckle.Sdk.Serialisation.Utilities;
+
+namespace Speckle.Sdk.Serialisation.V2.Receive;
+
+public static class DictionaryConverter
+{
+ ///
+ /// Property that describes the type of the object.
+ ///
+ public const string TYPE_DISCRIMINATOR = nameof(Base.speckle_type);
+ private static readonly object?[] s_invokeNull = [null];
+
+ public static string? BlobStorageFolder { get; set; }
+
+ public static Base Dict2Base(Dictionary dictObj, bool skipInvalidConverts)
+ {
+ string typeName = (string)dictObj[TYPE_DISCRIMINATOR].NotNull();
+ Type type = TypeLoader.GetType(typeName);
+ Base baseObj = (Base)Activator.CreateInstance(type).NotNull();
+
+ dictObj.Remove(TYPE_DISCRIMINATOR);
+ dictObj.Remove("__closure");
+
+ var staticProperties = TypeCache.GetTypeProperties(typeName);
+ foreach (var entry in dictObj)
+ {
+ if (staticProperties.TryGetValue(entry.Key, out PropertyInfo? value) && value.CanWrite)
+ {
+ if (entry.Value == null)
+ {
+ // Check for JsonProperty(NullValueHandling = NullValueHandling.Ignore) attribute
+ JsonPropertyAttribute? attr = TypeLoader.GetJsonPropertyAttribute(value);
+ if (attr is { NullValueHandling: NullValueHandling.Ignore })
+ {
+ continue;
+ }
+ }
+
+ Type targetValueType = value.PropertyType;
+ bool conversionOk = ValueConverter.ConvertValue(
+ targetValueType,
+ entry.Value,
+ skipInvalidConverts,
+ out object? convertedValue
+ );
+ if (conversionOk)
+ {
+ value.SetValue(baseObj, convertedValue);
+ }
+ else
+ {
+ // Cannot convert the value in the json to the static property type
+ throw new SpeckleDeserializeException(
+ $"Cannot deserialize {entry.Value?.GetType().FullName} to {targetValueType.FullName}"
+ );
+ }
+ }
+ else
+ {
+ // No writable property with this name
+ CallSiteCache.SetValue(entry.Key, baseObj, entry.Value);
+ }
+ }
+
+ if (baseObj is Blob bb && BlobStorageFolder != null)
+ {
+ bb.filePath = bb.GetLocalDestinationPath(BlobStorageFolder);
+ }
+
+ var onDeserializedCallbacks = TypeCache.GetOnDeserializedCallbacks(typeName);
+ foreach (MethodInfo onDeserialized in onDeserializedCallbacks)
+ {
+ onDeserialized.Invoke(baseObj, s_invokeNull);
+ }
+
+ return baseObj;
+ }
+}
diff --git a/src/Speckle.Sdk/Serialisation/V2/Receive/ObjectLoader.cs b/src/Speckle.Sdk/Serialisation/V2/Receive/ObjectLoader.cs
new file mode 100644
index 00000000..3c0916da
--- /dev/null
+++ b/src/Speckle.Sdk/Serialisation/V2/Receive/ObjectLoader.cs
@@ -0,0 +1,124 @@
+using System.Collections.Concurrent;
+using Speckle.InterfaceGenerator;
+using Speckle.Sdk.Common;
+using Speckle.Sdk.Serialisation.Utilities;
+using Speckle.Sdk.Transports;
+
+namespace Speckle.Sdk.Serialisation.V2.Receive;
+
+[GenerateAutoInterface]
+public sealed class ObjectLoader(
+ ISQLiteCacheManager sqLiteCacheManager,
+ IServerObjectManager serverObjectManager,
+ string streamId,
+ IProgress? progress
+) : IObjectLoader
+{
+ private const int HTTP_ID_CHUNK_SIZE = 500;
+ private const int CACHE_CHUNK_SIZE = 3000;
+ private const int MAX_PARALLELISM_HTTP = 4;
+
+ public async Task<(string, IReadOnlyList)> GetAndCache(
+ string rootId,
+ CancellationToken cancellationToken,
+ DeserializeOptions? options = null
+ )
+ {
+ var rootJson = sqLiteCacheManager.GetObject(rootId);
+ if (rootJson != null)
+ {
+ //assume everything exists as the root is there.
+ var allChildren = ClosureParser.GetChildrenIds(rootJson).ToList();
+ return (rootJson, allChildren);
+ }
+ rootJson = await serverObjectManager
+ .DownloadSingleObject(streamId, rootId, progress, cancellationToken)
+ .NotNull()
+ .ConfigureAwait(false);
+ var allChildrenIds = ClosureParser
+ .GetClosures(rootJson)
+ .OrderByDescending(x => x.Item2)
+ .Select(x => x.Item1)
+ .Where(x => !x.StartsWith("blob", StringComparison.Ordinal))
+ .ToList();
+ if (!(options?.SkipCacheCheck ?? false))
+ {
+ var idsToDownload = CheckCache(allChildrenIds);
+ await DownloadAndCache(idsToDownload, cancellationToken).ConfigureAwait(false);
+ }
+ //save the root last to shortcut later
+ sqLiteCacheManager.SaveObjectSync(rootId, rootJson);
+ return (rootJson, allChildrenIds);
+ }
+
+ private async IAsyncEnumerable CheckCache(IReadOnlyList childrenIds)
+ {
+ var count = 0L;
+ progress?.Report(new(ProgressEvent.CacheCheck, count, childrenIds.Count));
+ await foreach (
+ var (id, result) in childrenIds
+ .Batch(CACHE_CHUNK_SIZE)
+ .Select(x => sqLiteCacheManager.HasObjects2(x)) // there needs to be a Task somewhere here
+ .SelectManyAsync()
+ )
+ {
+ count++;
+ progress?.Report(new(ProgressEvent.CacheCheck, count, childrenIds.Count));
+ if (!result)
+ {
+ yield return id;
+ }
+ }
+ }
+
+ private async Task DownloadAndCache(IAsyncEnumerable ids, CancellationToken cancellationToken)
+ {
+ var count = 0L;
+ progress?.Report(new(ProgressEvent.DownloadObject, count, null));
+ var toCache = new List<(string, string)>();
+ var tasks = new ConcurrentBag();
+ using SemaphoreSlim ss = new(MAX_PARALLELISM_HTTP, MAX_PARALLELISM_HTTP);
+ await foreach (var idBatch in ids.BatchAsync(HTTP_ID_CHUNK_SIZE).WithCancellation(cancellationToken))
+ {
+ await ss.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await foreach (
+ var (id, json) in serverObjectManager.DownloadObjects(streamId, idBatch, progress, cancellationToken)
+ )
+ {
+ count++;
+ progress?.Report(new(ProgressEvent.DownloadObject, count, null));
+ toCache.Add((id, json));
+ if (toCache.Count >= CACHE_CHUNK_SIZE)
+ {
+ var toSave = toCache;
+ toCache = new List<(string, string)>();
+#pragma warning disable CA2008
+ tasks.Add(
+ Task.Factory.StartNew(() => sqLiteCacheManager.SaveObjects(toSave, cancellationToken), cancellationToken)
+ );
+#pragma warning restore CA2008
+ }
+ }
+ }
+ finally
+ {
+ ss.Release();
+ }
+ }
+
+ if (toCache.Count > 0)
+ {
+#pragma warning disable CA2008
+ tasks.Add(
+ Task.Factory.StartNew(() => sqLiteCacheManager.SaveObjects(toCache, cancellationToken), cancellationToken)
+ );
+#pragma warning restore CA2008
+ }
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+
+ public string? LoadId(string id) => sqLiteCacheManager.GetObject(id);
+}
diff --git a/src/Speckle.Sdk/Serialisation/V2/Receive/SpeckleObjectDeserializer2.cs b/src/Speckle.Sdk/Serialisation/V2/Receive/SpeckleObjectDeserializer2.cs
new file mode 100644
index 00000000..fb137d84
--- /dev/null
+++ b/src/Speckle.Sdk/Serialisation/V2/Receive/SpeckleObjectDeserializer2.cs
@@ -0,0 +1,160 @@
+using System.Numerics;
+using Speckle.Newtonsoft.Json;
+using Speckle.Sdk.Common;
+using Speckle.Sdk.Models;
+
+namespace Speckle.Sdk.Serialisation.V2.Receive;
+
+public record DeserializedOptions(bool ThrowOnMissingReferences = true, bool SkipInvalidConverts = false);
+
+public sealed class SpeckleObjectDeserializer2(
+ IReadOnlyDictionary references,
+ SpeckleObjectSerializerPool pool,
+ DeserializedOptions? options = null
+)
+{
+ /// The JSON string of the object to be deserialized
+ /// A typed object deserialized from the
+ /// was null
+ /// cannot be deserialised to type
+ // /// did not contain the required json objects (closures)
+ public Base Deserialize(string objectJson)
+ {
+ if (objectJson is null)
+ {
+ throw new ArgumentNullException(nameof(objectJson), $"Cannot deserialize {nameof(objectJson)}, value was null");
+ }
+ // Apparently this automatically parses DateTimes in strings if it matches the format:
+ // JObject doc1 = JObject.Parse(objectJson);
+
+ // This is equivalent code that doesn't parse datetimes:
+ using var stringReader = new StringReader(objectJson);
+ using JsonTextReader reader = pool.GetJsonTextReader(stringReader);
+
+ reader.DateParseHandling = DateParseHandling.None;
+
+ Base? converted;
+ try
+ {
+ reader.Read();
+ converted = (Base)ReadObject(reader).NotNull();
+ }
+ catch (Exception ex) when (!ex.IsFatal() && ex is not OperationCanceledException)
+ {
+ throw new SpeckleDeserializeException("Failed to deserialize", ex);
+ }
+
+ return converted;
+ }
+
+ private List
[MemoryDiagnoser]
-[SimpleJob(RunStrategy.Monitoring)]
+[SimpleJob(RunStrategy.Monitoring, 0, 0, 2)]
public class GeneralDeserializer : IDisposable
{
+ private const bool skipCache = true;
+
+ /*
+ private const string url = "https://latest.speckle.systems/projects/a3ac1b2706/models/59d3b0f3c6"; //small?
+ private const string streamId = "a3ac1b2706";
+ private const string rootId = "7d53bcf28c6696ecac8781684a0aa006";*/
+
+
+ private const string url = "https://latest.speckle.systems/projects/2099ac4b5f/models/da511c4d1e"; //perf?
+ private const string streamId = "2099ac4b5f";
+ private const string rootId = "30fb4cbe6eb2202b9e7b4a4fcc3dd2b6";
private TestDataHelper _dataSource;
[GlobalSetup]
@@ -24,33 +39,36 @@ public class GeneralDeserializer : IDisposable
TypeLoader.Initialize(typeof(Base).Assembly, typeof(Point).Assembly);
_dataSource = new TestDataHelper();
await _dataSource
- .SeedTransport(
- new Account()
- {
- serverInfo = new() { url = "https://latest.speckle.systems/projects/2099ac4b5f/models/da511c4d1e" },
- },
- "2099ac4b5f",
- "30fb4cbe6eb2202b9e7b4a4fcc3dd2b6"
- )
+ .SeedTransport(new Account() { serverInfo = new() { url = url } }, streamId, rootId, skipCache)
.ConfigureAwait(false);
}
[Benchmark]
- public async Task RunTest()
+ public async Task RunTest_New()
{
- SpeckleObjectDeserializer sut = new() { ReadTransport = _dataSource.Transport };
- string data = await _dataSource.Transport.GetObject(_dataSource.ObjectId)!;
- return await sut.DeserializeAsync(data);
+ var sqlite = new SQLiteCacheManager(streamId);
+ var serverObjects = new ServerObjectManager(
+ TestDataHelper.ServiceProvider.GetRequiredService(),
+ TestDataHelper.ServiceProvider.GetRequiredService(),
+ new Uri(url),
+ null
+ );
+ var o = new ObjectLoader(sqlite, serverObjects, streamId, null);
+ var process = new DeserializeProcess(null, o);
+ return await process.Deserialize(rootId, default, new(skipCache)).ConfigureAwait(false);
}
+ /*
+ [Benchmark]
+ public async Task RunTest_Old()
+ {
+ SpeckleObjectDeserializer sut = new() { ReadTransport = _dataSource.Transport };
+ string data = await _dataSource.Transport.GetObject(_dataSource.ObjectId)!;
+ return await sut.DeserializeAsync(data);
+ }
+ */
[GlobalCleanup]
- public void Cleanup()
- {
- Dispose();
- }
+ public void Cleanup() => Dispose();
- public void Dispose()
- {
- _dataSource.Dispose();
- }
+ public void Dispose() => _dataSource.Dispose();
}
diff --git a/tests/Speckle.Sdk.Tests.Performance/Benchmarks/GeneralReceiveTest.cs b/tests/Speckle.Sdk.Tests.Performance/Benchmarks/GeneralReceiveTest.cs
new file mode 100644
index 00000000..60c2ee39
--- /dev/null
+++ b/tests/Speckle.Sdk.Tests.Performance/Benchmarks/GeneralReceiveTest.cs
@@ -0,0 +1,70 @@
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Engines;
+using Microsoft.Extensions.DependencyInjection;
+using Speckle.Objects.Geometry;
+using Speckle.Sdk.Api;
+using Speckle.Sdk.Credentials;
+using Speckle.Sdk.Helpers;
+using Speckle.Sdk.Host;
+using Speckle.Sdk.Logging;
+using Speckle.Sdk.Models;
+using Speckle.Sdk.Serialisation;
+using Speckle.Sdk.Serialisation.V2;
+using Speckle.Sdk.Serialisation.V2.Receive;
+using Speckle.Sdk.Transports;
+
+namespace Speckle.Sdk.Tests.Performance.Benchmarks;
+
+///
+/// How many threads on our Deserializer is optimal
+///
+[MemoryDiagnoser]
+[SimpleJob(RunStrategy.Monitoring, 0, 0, 1)]
+public class GeneralReceiveTest : IDisposable
+{
+ /*
+ private const string url = "https://latest.speckle.systems/projects/a3ac1b2706/models/59d3b0f3c6"; //small?
+ private const string streamId = "a3ac1b2706";S
+ private const string rootId = "7d53bcf28c6696ecac8781684a0aa006";*/
+
+
+ private const string url = "https://latest.speckle.systems/projects/2099ac4b5f/models/da511c4d1e"; //perf?
+ private readonly Uri _baseUrl = new("https://latest.speckle.systems");
+ private const string streamId = "2099ac4b5f";
+ private const string rootId = "30fb4cbe6eb2202b9e7b4a4fcc3dd2b6";
+ private TestDataHelper _dataSource;
+ private IOperations _operations;
+ private ITransport remoteTransport;
+
+ [GlobalSetup]
+ public async Task Setup()
+ {
+ TypeLoader.Initialize(typeof(Base).Assembly, typeof(Point).Assembly);
+ _dataSource = new TestDataHelper();
+ var acc = new Account() { serverInfo = new() { url = url } };
+ await _dataSource.SeedTransport(acc, streamId, rootId, true).ConfigureAwait(false);
+ _operations = TestDataHelper.ServiceProvider.GetRequiredService();
+ // await _operations.Receive2(_baseUrl, streamId, rootId, null);
+
+ remoteTransport = TestDataHelper
+ .ServiceProvider.GetRequiredService()
+ .Create(acc, streamId);
+ }
+
+ [Benchmark]
+ public async Task RunTest_Receive()
+ {
+ return await _operations.Receive(rootId, remoteTransport, _dataSource.Transport);
+ }
+
+ [Benchmark]
+ public async Task RunTest_Receive2()
+ {
+ return await _operations.Receive2(_baseUrl, streamId, rootId, null);
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose() => _dataSource.Dispose();
+}
diff --git a/tests/Speckle.Sdk.Tests.Performance/Benchmarks/GeneralSerializerTest.cs b/tests/Speckle.Sdk.Tests.Performance/Benchmarks/GeneralSerializerTest.cs
index d18c6d2e..deb8a27d 100644
--- a/tests/Speckle.Sdk.Tests.Performance/Benchmarks/GeneralSerializerTest.cs
+++ b/tests/Speckle.Sdk.Tests.Performance/Benchmarks/GeneralSerializerTest.cs
@@ -32,7 +32,8 @@ public class GeneralSerializerTest
serverInfo = new() { url = "https://latest.speckle.systems/projects/2099ac4b5f/models/da511c4d1e" },
},
"2099ac4b5f",
- "30fb4cbe6eb2202b9e7b4a4fcc3dd2b6"
+ "30fb4cbe6eb2202b9e7b4a4fcc3dd2b6",
+ false
)
.ConfigureAwait(false);
diff --git a/tests/Speckle.Sdk.Tests.Performance/TestDataHelper.cs b/tests/Speckle.Sdk.Tests.Performance/TestDataHelper.cs
index 2420efdd..9ca4a397 100644
--- a/tests/Speckle.Sdk.Tests.Performance/TestDataHelper.cs
+++ b/tests/Speckle.Sdk.Tests.Performance/TestDataHelper.cs
@@ -23,24 +23,33 @@ public sealed class TestDataHelper : IDisposable
ServiceProvider = serviceCollection.BuildServiceProvider();
}
- public async Task SeedTransport(Account account, string streamId, string objectId)
+ public async Task SeedTransport(Account account, string streamId, string objectId, bool skipCache)
{
// Transport = new SQLiteTransport(s_basePath, APPLICATION_NAME);
Transport = new SQLiteTransport();
//seed SQLite transport with test data
- ObjectId = await SeedTransport(account, streamId, objectId, Transport).ConfigureAwait(false);
+ ObjectId = await SeedTransport(account, streamId, objectId, Transport, skipCache).ConfigureAwait(false);
}
- public async Task SeedTransport(Account account, string streamId, string objectId, ITransport transport)
+ public async Task SeedTransport(
+ Account account,
+ string streamId,
+ string objectId,
+ ITransport transport,
+ bool skipCache
+ )
{
- using ServerTransport remoteTransport = ServiceProvider
- .GetRequiredService()
- .Create(account, streamId);
- transport.BeginWrite();
- await remoteTransport.CopyObjectAndChildren(objectId, transport).ConfigureAwait(false);
- transport.EndWrite();
- await transport.WriteComplete().ConfigureAwait(false);
+ if (!skipCache)
+ {
+ using ServerTransport remoteTransport = ServiceProvider
+ .GetRequiredService()
+ .Create(account, streamId);
+ transport.BeginWrite();
+ await remoteTransport.CopyObjectAndChildren(objectId, transport).ConfigureAwait(false);
+ transport.EndWrite();
+ await transport.WriteComplete().ConfigureAwait(false);
+ }
return objectId;
}
diff --git a/tests/Speckle.Sdk.Tests.Performance/packages.lock.json b/tests/Speckle.Sdk.Tests.Performance/packages.lock.json
index e9528b66..703abca5 100644
--- a/tests/Speckle.Sdk.Tests.Performance/packages.lock.json
+++ b/tests/Speckle.Sdk.Tests.Performance/packages.lock.json
@@ -98,11 +98,6 @@
"resolved": "1.17.0",
"contentHash": "8x+HCVTl/HHTGpscH3vMBhV8sknN/muZFw9s3TsI8SA6+c43cOTCi2+jE4KsU8pNLbJ++iF2ZFcpcXHXtDglnw=="
},
- "Microsoft.Bcl.AsyncInterfaces": {
- "type": "Transitive",
- "resolved": "1.1.0",
- "contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
- },
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -361,10 +356,12 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
+ "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.7, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
+ "Microsoft.Extensions.ObjectPool": "[8.0.10, )",
"Polly": "[7.2.3, )",
"Polly.Contrib.WaitAndRetry": "[1.1.1, )",
"Polly.Extensions.Http": "[3.0.0, )",
@@ -383,6 +380,12 @@
"System.Reactive": "5.0.0"
}
},
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.0, )",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
@@ -417,6 +420,12 @@
"Microsoft.Extensions.Options": "2.2.0"
}
},
+ "Microsoft.Extensions.ObjectPool": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.10, )",
+ "resolved": "8.0.10",
+ "contentHash": "u7gAG7JgxF8VSJUGPSudAcPxOt+ymJKQCSxNRxiuKV+klCQbHljQR75SilpedCTfhPWDhtUwIJpnDVtspr9nMg=="
+ },
"Polly": {
"type": "CentralTransitive",
"requested": "[7.2.3, )",
diff --git a/tests/Speckle.Sdk.Tests.Unit/packages.lock.json b/tests/Speckle.Sdk.Tests.Unit/packages.lock.json
index 68ea50dd..ca08e1f8 100644
--- a/tests/Speckle.Sdk.Tests.Unit/packages.lock.json
+++ b/tests/Speckle.Sdk.Tests.Unit/packages.lock.json
@@ -283,10 +283,12 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
+ "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.7, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
+ "Microsoft.Extensions.ObjectPool": "[8.0.10, )",
"Polly": "[7.2.3, )",
"Polly.Contrib.WaitAndRetry": "[1.1.1, )",
"Polly.Extensions.Http": "[3.0.0, )",
@@ -305,6 +307,12 @@
"System.Reactive": "5.0.0"
}
},
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.0, )",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
@@ -339,6 +347,12 @@
"Microsoft.Extensions.Options": "2.2.0"
}
},
+ "Microsoft.Extensions.ObjectPool": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.10, )",
+ "resolved": "8.0.10",
+ "contentHash": "u7gAG7JgxF8VSJUGPSudAcPxOt+ymJKQCSxNRxiuKV+klCQbHljQR75SilpedCTfhPWDhtUwIJpnDVtspr9nMg=="
+ },
"Polly": {
"type": "CentralTransitive",
"requested": "[7.2.3, )",