Skip to content

Commit 0d1996d

Browse files
committed
fix: preserve Result outcomes in Orleans JSON storage
1 parent 7777c90 commit 0d1996d

6 files changed

Lines changed: 299 additions & 6 deletions

File tree

‎Directory.Build.props‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
<RepositoryUrl>https://github.com/managedcode/Communication</RepositoryUrl>
2828
<PackageProjectUrl>https://github.com/managedcode/Communication</PackageProjectUrl>
2929
<Product>Managed Code - Communication</Product>
30-
<Version>10.2.11</Version>
31-
<PackageVersion>10.2.11</PackageVersion>
30+
<Version>10.2.12</Version>
31+
<PackageVersion>10.2.12</PackageVersion>
3232

3333
</PropertyGroup>
3434
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using ManagedCode.Communication.Orleans.Surrogates;
4+
using Newtonsoft.Json;
5+
using Newtonsoft.Json.Linq;
6+
7+
namespace ManagedCode.Communication.Orleans.Converters;
8+
9+
/// <summary>Preserves Communication results in the native Orleans JSON storage format.</summary>
10+
public sealed class CommunicationResultJsonConverter : JsonConverter
11+
{
12+
private const string InvalidSuccessFlag = "A stored Result requires an isSuccess boolean.";
13+
private static readonly ConcurrentDictionary<Type, IResultAdapter> Adapters = new();
14+
15+
/// <inheritdoc />
16+
public override bool CanConvert(Type objectType) => objectType == typeof(Result) ||
17+
objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Result<>);
18+
19+
/// <inheritdoc />
20+
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
21+
{
22+
var document = JObject.Load(reader);
23+
return Adapter(objectType).Read(document, serializer);
24+
}
25+
26+
/// <inheritdoc />
27+
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
28+
{
29+
ArgumentNullException.ThrowIfNull(value);
30+
Adapter(value.GetType()).Write(writer, value, serializer);
31+
}
32+
33+
private static IResultAdapter Adapter(Type type) => Adapters.GetOrAdd(type, static type =>
34+
type == typeof(Result) ? new ResultAdapter() :
35+
(IResultAdapter)Activator.CreateInstance(typeof(ResultAdapter<>).MakeGenericType(type.GetGenericArguments()))!);
36+
37+
private static bool Success(JObject document)
38+
{
39+
var token = document.GetValue(CommunicationJsonNames.IsSuccess, StringComparison.OrdinalIgnoreCase);
40+
var failed = document.GetValue(nameof(Result.IsFailed), StringComparison.OrdinalIgnoreCase);
41+
if (token is null && failed?.Type == JTokenType.Boolean && failed.Value<bool>())
42+
{
43+
return false;
44+
}
45+
if (token?.Type != JTokenType.Boolean || failed is not null &&
46+
(failed.Type != JTokenType.Boolean || failed.Value<bool>() == token.Value<bool>()))
47+
{
48+
throw new JsonSerializationException(InvalidSuccessFlag);
49+
}
50+
return token.Value<bool>();
51+
}
52+
53+
private static Problem? ReadProblem(JObject document, JsonSerializer serializer)
54+
{
55+
var token = document.GetValue(CommunicationJsonNames.Problem, StringComparison.OrdinalIgnoreCase);
56+
if (token is null || token.Type == JTokenType.Null)
57+
{
58+
return null;
59+
}
60+
if (token is JObject legacy &&
61+
(legacy.GetValue(nameof(Problem.StatusCode), StringComparison.OrdinalIgnoreCase) is not null ||
62+
legacy.GetValue(nameof(Problem.Extensions), StringComparison.OrdinalIgnoreCase) is not null))
63+
{
64+
return token.ToObject<Problem>(serializer);
65+
}
66+
return System.Text.Json.JsonSerializer.Deserialize<Problem>(token.ToString(Formatting.None));
67+
}
68+
69+
private static void WriteProblem(JsonWriter writer, Problem? problem)
70+
{
71+
if (problem is null)
72+
{
73+
return;
74+
}
75+
writer.WritePropertyName(CommunicationJsonNames.Problem);
76+
JToken.Parse(System.Text.Json.JsonSerializer.Serialize(problem)).WriteTo(writer);
77+
}
78+
79+
private interface IResultAdapter
80+
{
81+
object Read(JObject document, JsonSerializer serializer);
82+
void Write(JsonWriter writer, object value, JsonSerializer serializer);
83+
}
84+
85+
private sealed class ResultAdapter : IResultAdapter
86+
{
87+
public object Read(JObject document, JsonSerializer serializer)
88+
{
89+
var surrogate = new ResultSurrogate(Success(document), ReadProblem(document, serializer));
90+
return new ResultSurrogateConverter().ConvertFromSurrogate(surrogate);
91+
}
92+
93+
public void Write(JsonWriter writer, object value, JsonSerializer serializer)
94+
{
95+
var result = (Result)value;
96+
writer.WriteStartObject();
97+
writer.WritePropertyName(CommunicationJsonNames.IsSuccess);
98+
writer.WriteValue(result.IsSuccess);
99+
WriteProblem(writer, result.Problem);
100+
writer.WriteEndObject();
101+
}
102+
}
103+
104+
private sealed class ResultAdapter<T> : IResultAdapter
105+
{
106+
public object Read(JObject document, JsonSerializer serializer)
107+
{
108+
var token = document.GetValue(CommunicationJsonNames.Value, StringComparison.OrdinalIgnoreCase);
109+
var value = token is null || token.Type == JTokenType.Null ? default : token.ToObject<T>(serializer);
110+
var surrogate = new ResultTSurrogate<T>(Success(document), value, ReadProblem(document, serializer));
111+
return new ResultTSurrogateConverter<T>().ConvertFromSurrogate(surrogate);
112+
}
113+
114+
public void Write(JsonWriter writer, object value, JsonSerializer serializer)
115+
{
116+
var result = (Result<T>)value;
117+
writer.WriteStartObject();
118+
writer.WritePropertyName(CommunicationJsonNames.IsSuccess);
119+
writer.WriteValue(result.IsSuccess);
120+
writer.WritePropertyName(CommunicationJsonNames.Value);
121+
serializer.Serialize(writer, result.Value, typeof(T));
122+
WriteProblem(writer, result.Problem);
123+
writer.WriteEndObject();
124+
}
125+
}
126+
}

‎ManagedCode.Communication.Orleans/Extensions/OrleansExtensions.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ private static void ConfigureRateLimiterOptions(
8888
IServiceCollection services,
8989
Action<OrleansCommandRateLimiterOptions>? configureRateLimiting)
9090
{
91+
services.AddCommunicationOrleansJsonStorage();
9192
services.AddOptions<OrleansCommandRateLimiterOptions>();
9293
if (configureRateLimiting is not null)
9394
{
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using System.Linq;
3+
using ManagedCode.Communication.Orleans.Converters;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Orleans.Serialization;
6+
7+
namespace ManagedCode.Communication.Orleans.Extensions;
8+
9+
/// <summary>Configures the native Orleans JSON storage serializer for Communication results.</summary>
10+
public static class OrleansJsonStorageExtensions
11+
{
12+
/// <summary>Preserves Result flags, typed values and Problems in native Orleans JSON state.</summary>
13+
public static IServiceCollection AddCommunicationOrleansJsonStorage(this IServiceCollection services)
14+
{
15+
ArgumentNullException.ThrowIfNull(services);
16+
services.Configure<OrleansJsonSerializerOptions>(options =>
17+
{
18+
if (!options.JsonSerializerSettings.Converters.OfType<CommunicationResultJsonConverter>().Any())
19+
{
20+
options.JsonSerializerSettings.Converters.Insert(0, new CommunicationResultJsonConverter());
21+
}
22+
});
23+
return services;
24+
}
25+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System;
2+
using System.Linq;
3+
using System.Net;
4+
using System.Text;
5+
using ManagedCode.Communication.Orleans.Converters;
6+
using ManagedCode.Communication.Orleans.Extensions;
7+
using ManagedCode.Communication.Orleans.Surrogates;
8+
using ManagedCode.Communication.Tests.Orleans.Fixtures;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Microsoft.Extensions.Options;
11+
using Newtonsoft.Json;
12+
using Orleans.Serialization;
13+
using Orleans.Storage;
14+
using Orleans.TestingHost;
15+
using Shouldly;
16+
17+
namespace ManagedCode.Communication.Tests.Orleans;
18+
19+
[ClassDataSource<OrleansClusterFixture>(Shared = SharedType.PerClass)]
20+
[NotInParallel(nameof(OrleansJsonStorageSerializationTests))]
21+
public sealed class OrleansJsonStorageSerializationTests(OrleansClusterFixture fixture)
22+
{
23+
private const string FailureTitle = "native-storage-failure";
24+
private const string FailureDetail = "Preserve native stored failure";
25+
private const string ExtensionKey = "owner";
26+
private const string ExtensionValue = "creator";
27+
private const string LegacySuccess = "{\"IsSuccess\":true,\"Value\":42,\"Problem\":null}";
28+
private const string MissingFlag = "{\"Value\":42}";
29+
private const string ConflictingFlags = "{\"IsSuccess\":true,\"IsFailed\":true,\"Value\":42}";
30+
private const string InvalidFlag = "{\"IsSuccess\":\"true\",\"Value\":42}";
31+
32+
private JsonGrainStorageSerializer Serializer() => new(
33+
((InProcessSiloHandle)fixture.Cluster.Primary!).ServiceProvider.GetRequiredService<OrleansJsonSerializer>());
34+
35+
[Test]
36+
[Arguments(0)]
37+
[Arguments(42)]
38+
public void JsonStorageMustPreserveSuccessfulResultFlagAndValue(int value)
39+
{
40+
var serializer = Serializer();
41+
var expected = Result<int>.Succeed(value);
42+
var restored = serializer.Deserialize<Result<int>>(serializer.Serialize(expected));
43+
restored.IsSuccess.ShouldBeTrue();
44+
restored.Value.ShouldBe(expected.Value);
45+
}
46+
47+
[Test]
48+
public void JsonStorageMustPreserveFailedProblemValueAndNestedResults()
49+
{
50+
var problem = Problem.Create(FailureTitle, FailureDetail, (int)HttpStatusCode.Conflict);
51+
problem.Extensions[ExtensionKey] = ExtensionValue;
52+
var expected = new ResultTSurrogateConverter<int>().ConvertFromSurrogate(new ResultTSurrogate<int>(false, 42, problem));
53+
var serializer = Serializer();
54+
var restored = serializer.Deserialize<StoredState>(serializer.Serialize(new StoredState { Update = expected }));
55+
restored!.Update.IsFailed.ShouldBeTrue();
56+
restored.Update.Value.ShouldBe(42);
57+
restored.Update.Problem!.Title.ShouldBe(FailureTitle);
58+
restored.Update.Problem.Detail.ShouldBe(FailureDetail);
59+
restored.Update.Problem.StatusCode.ShouldBe(problem.StatusCode);
60+
restored.Update.Problem.Extensions[ExtensionKey]?.ToString().ShouldBe(ExtensionValue);
61+
}
62+
63+
[Test]
64+
public void JsonStorageMustPreserveUntypedAndDefaultValuedResults()
65+
{
66+
var serializer = Serializer();
67+
serializer.Deserialize<Result>(serializer.Serialize(Result.Succeed())).IsSuccess.ShouldBeTrue();
68+
serializer.Deserialize<Result>(serializer.Serialize(Result.Fail(Problem.Create(FailureTitle, FailureDetail, 409)))).IsFailed.ShouldBeTrue();
69+
serializer.Deserialize<Result<bool>>(serializer.Serialize(Result<bool>.Succeed(false))).IsSuccess.ShouldBeTrue();
70+
}
71+
72+
[Test]
73+
public void NativeLegacyPascalCaseSuccessMustRemainSuccessful()
74+
{
75+
var restored = Serializer().Deserialize<Result<int>>(new BinaryData(Encoding.UTF8.GetBytes(LegacySuccess)));
76+
restored.IsSuccess.ShouldBeTrue();
77+
restored.Value.ShouldBe(42);
78+
}
79+
80+
[Test]
81+
[Arguments(0)]
82+
[Arguments(42)]
83+
public void NativeLegacyFailedProblemMustPreserveStatusAndExtensions(int value)
84+
{
85+
var problem = Problem.Create(FailureTitle, FailureDetail, (int)HttpStatusCode.Conflict);
86+
problem.Extensions[ExtensionKey] = ExtensionValue;
87+
var legacySerializer = new JsonGrainStorageSerializer(new OrleansJsonSerializer(
88+
Options.Create(new OrleansJsonSerializerOptions())));
89+
var expected = new ResultTSurrogateConverter<int>().ConvertFromSurrogate(new ResultTSurrogate<int>(false, value, problem));
90+
var data = legacySerializer.Serialize(expected);
91+
var restored = Serializer().Deserialize<Result<int>>(data);
92+
restored.IsFailed.ShouldBeTrue();
93+
restored.Value.ShouldBe(value);
94+
restored.Problem.Title.ShouldBe(FailureTitle);
95+
restored.Problem.Detail.ShouldBe(FailureDetail);
96+
restored.Problem.StatusCode.ShouldBe(problem.StatusCode);
97+
restored.Problem.Extensions[ExtensionKey]?.ToString().ShouldBe(ExtensionValue);
98+
}
99+
100+
[Test]
101+
[Arguments(MissingFlag)]
102+
[Arguments(InvalidFlag)]
103+
[Arguments(ConflictingFlags)]
104+
public void InvalidStoredFlagsMustFailClosed(string content) =>
105+
Should.Throw<JsonSerializationException>(() => Serializer().Deserialize<Result<int>>(new BinaryData(Encoding.UTF8.GetBytes(content))));
106+
107+
[Test]
108+
public void StandaloneRegistrationMustBeIdempotentAndPreserveNativeJson()
109+
{
110+
using var services = new ServiceCollection().AddCommunicationOrleansJsonStorage().AddCommunicationOrleansJsonStorage().BuildServiceProvider();
111+
var settings = services.GetRequiredService<IOptions<OrleansJsonSerializerOptions>>().Value;
112+
settings.JsonSerializerSettings.Converters.OfType<CommunicationResultJsonConverter>().Count().ShouldBe(1);
113+
var serializer = new JsonGrainStorageSerializer(new OrleansJsonSerializer(Options.Create(settings)));
114+
serializer.Deserialize<Result<int>>(serializer.Serialize(Result<int>.Succeed(42))).IsSuccess.ShouldBeTrue();
115+
}
116+
117+
[global::Orleans.GenerateSerializer]
118+
internal sealed class StoredState
119+
{
120+
[global::Orleans.Id(0)]
121+
public Result<int> Update { get; init; }
122+
}
123+
}

‎README.md‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,10 +299,10 @@ dotnet add package ManagedCode.Communication.Orleans
299299
### PackageReference
300300

301301
```xml
302-
<PackageReference Include="ManagedCode.Communication" Version="10.2.5" />
303-
<PackageReference Include="ManagedCode.Communication.AspNetCore" Version="10.2.5" />
304-
<PackageReference Include="ManagedCode.Communication.Extensions" Version="10.2.5" />
305-
<PackageReference Include="ManagedCode.Communication.Orleans" Version="10.2.5" />
302+
<PackageReference Include="ManagedCode.Communication" Version="10.2.12" />
303+
<PackageReference Include="ManagedCode.Communication.AspNetCore" Version="10.2.12" />
304+
<PackageReference Include="ManagedCode.Communication.Extensions" Version="10.2.12" />
305+
<PackageReference Include="ManagedCode.Communication.Orleans" Version="10.2.12" />
306306
```
307307

308308
## Logging Configuration
@@ -565,6 +565,24 @@ services.AddCommandRateLimiter(limiter);
565565
also accepts a permit-count selector. Wrapping an application-owned `PartitionedRateLimiter<ICommand>` does not transfer
566566
ownership by default; factory-created limiters are owned and disposed by the adapter.
567567

568+
Native Orleans JSON grain storage also preserves `Result` and `Result<T>` success flags, typed values (including
569+
failure payloads), and `Problem` details. `UseOrleansCommunication()` and `UseOrleansCommandExecution()` register the
570+
adapter automatically. A standalone storage reader or migration host can register it without starting a silo:
571+
572+
```csharp
573+
services.AddCommunicationOrleansJsonStorage();
574+
var nativeSerializer = serviceProvider.GetRequiredService<OrleansJsonSerializer>();
575+
var storageSerializer = new JsonGrainStorageSerializer(nativeSerializer);
576+
var restored = storageSerializer.Deserialize<Result<int>>(
577+
storageSerializer.Serialize(Result<int>.Succeed(0)));
578+
// restored.IsSuccess is true and restored.Value is 0.
579+
```
580+
581+
The reader accepts existing native PascalCase result records and their native `Problem` shape. A legacy explicit `IsFailed: true`
582+
is accepted when default-value suppression omitted `IsSuccess: false`; missing, non-boolean or contradictory outcome
583+
flags are rejected rather than inventing an operation outcome. Typed payloads retain
584+
the native Orleans serializer and its type allow-list. Deploy the adapter to every host that reads this JSON state.
585+
568586
`UseOrleansCommunication()` registers only serializers and grain-call filters. Enable Orleans-backed command idempotency and
569587
cluster-wide rate limiting explicitly with `UseOrleansCommandExecution()`. The silo must configure grain storage named
570588
`commandStore`; `ManagedCode.Orleans.RateLimiting` continues to own the distributed algorithms and durable leases. Identity,

0 commit comments

Comments
 (0)