Search code examples
c#json.netazure-cosmosdb

Possible mutation of static readonly record through Cosmos SDK and Newtonsoft


This is a very awkward error and it took me a while to tackle it down to these few lines of code. The full code can be grabbed as fiddle, but you have to provide your Cosmos Url and Key to let it run. The problem is, we defined a public static readonly Author Unknown field and the class Author is a record, that is defined as follows:

public record Author(
    [Required] string Id,
    [Required] string DisplayName,
    [Required] string MailAddress)
{
    // This is going to be mutated! 👇🏻
    public static readonly Author Unknown = Create(nameof(Unknown));

    public static Author Create(string name)
    {
        return new Author(
            name.ToLowerInvariant(),
            name,
            $"{name}@test.internal");
    }
}

In a next step we have a class, that we want to read/write from/to Azure Cosmos database and the class can be as simple as

public class DocumentBase
{
    [Newtonsoft.Json.JsonProperty("id")]
    public Guid Id { get; set; } = default!;
    public string PartitionKey { get; set; } = "PartitionKey";

    // Apply static readonly record in default ctor of class
    public Author Modified { get; set; } = Author.Unknown;
}

The next thing we need in our setup is a Cosmos DB container we can use to read/write our documents:

private static async Task<Container> CreateContainer()
{
    var endPoint = "https://{yourCosmosDatabase}.documents.azure.com:443/";
    var key = "{yourCosmosKey}";

    var client = new CosmosClient(endPoint, key);
    var containerId = $"{DateTime.UtcNow:s}-{Guid.NewGuid()}";
    var partitionKey = "/PartitionKey";

    var database = client.GetDatabase("Testing");
    var response = await database.CreateContainerAsync(new ContainerProperties
    {
        Id = containerId,
        PartitionKeyPath = partitionKey,
        DefaultTimeToLive = -1
    });

    return response.Container;
}

Here here is the code, that changes the values in Author.Unknown:

public static async Task Main()
{
    // Create a container to access Cosmos
    var container = await CreateContainer();

    // Create an instance with individual values
    var defaultValue = new DocumentBase
    {
        Id = Guid.NewGuid(),
        Modified = Author.Create("Modified"),
    };

    // Create a copy of the static readonly record for comparison
    var copyOfUnknown = Author.Unknown with { };
    // Check if the copy and the original values are equal
    // with value comparison thanks to the help of record
    Author.Unknown.ShouldBe(copyOfUnknown);

    // Let Cosmos write that instance down to the database
    // and read back the fresh created value.
    // It will be returned by the function, but we ignore it.
    await container.CreateItemAsync(defaultValue, new PartitionKey(defaultValue.PartitionKey));

    // Make another comparison that fails, because in Author.Unknown
    // you find now Author.Create("Modified")!
    Author.Unknown.ShouldBe(copyOfUnknown);
}

So what is going on here?? We have a public static readonly field that stores a record and after deserializing the response from Cosmos that field is mutated? How can that be?

To work around this problem we have multiple options, maybe showing what are the needed ingredients to run into this problem:

  • Replace the readonly field with a property getter that returns a new instance every time being called.
  • Do not set the Modified property in the default ctor of DocumentBase
  • Set the Modified property in the default ctor of DocumentBase by using the copy ctor = Author.Unknown with { };

Nevertheless this behaviour was really unexpected and I would be glad if anyone could explain this behaviour. And by the way, we used the latest NuGet package of Microsoft.Azure.Cosmos 3.38.1.


Solution

  • This is a known issue with Json.NET and immutable records. There is a possibility that Json.NET may mutate an (apparently) immutable property when populating a preallocated instance such as your Author.Unknown. For confirmation, see the Json.NET issue Deserialization appears to write over default record properties rather than create new ones #2451 [1].

    As a workaround, you could tell Json.NET to ignore the compiler-generated properties and serialize using private readonly properties instead:

    public record Author(
        [property: Newtonsoft.Json.JsonIgnore][Required] string Id,
        [property: Newtonsoft.Json.JsonIgnore][Required] string DisplayName,
        [property: Newtonsoft.Json.JsonIgnore][Required] string MailAddress)
    {
        [Newtonsoft.Json.JsonProperty("Id")] string JsonId => Id;
        [Newtonsoft.Json.JsonProperty("DisplayName")] string JsonDisplayName => Id;
        [Newtonsoft.Json.JsonProperty("MailAddress")] string JsonMailAddress => MailAddress;
        
        // Remainder unchanged
    

    Working demo fiddle #1 here.

    Another workaround suggested by JamesNK is to mark all Author-valued properties with ObjectCreationHandling.Replace:

    public class DocumentBase
    {
        // Apply static readonly record in default ctor of class
        [Newtonsoft.Json.JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)]
        public Author Modified { get; set; } = Author.Unknown;
    
        // Remainder unchanged
    

    Working demo fiddle #2 here.

    There might be options to do this automatically using a custom contract resolver, however I am not sure whether the Microsoft.Azure.Cosmos client provides access to settings in order to modify the contract resolver used.


    [1] FYI, the reason that Json.NET is able to modify an (apparently) immutable record is that, for positional properties in records, the compiler generates init-only properties. From the docs:

    When you use the positional syntax for property definition, the compiler creates:

    • A public autoimplemented property for each positional parameter provided in the record declaration.

      • For record types and readonly record struct types: An init-only property.
      • For record struct types: A read-write property.

    I.e. your Author class actually looks something like:

    public class Author
    {
       public string Id { get; init; }
       public string DisplayName { get; init; }
       public string MailAddress { get; init; }
    }
    

    And, init-only properties can be set by reflection. For confirmation see Init Only Setters:

    [init property accessors] will not protect against the following circumstances:

    • Reflection over public members
    • The use of dynamic
    • ...

    Since Json.NET (and other serializers) use reflection over public members to create their serialization contracts, init-only properties look like settable properties, and so can get populated during deserialization.