Skip to content

AssemblyScanner cannot find concrete serialization types from unobtrusive assembly #6967

@evt-jonny

Description

@evt-jonny

Describe the bug

Description

AssemblyScanner only adds types from assemblies that reference NServiceBus.Core either explicitly or via other referenced assemblies:

if (assembly.GetName().Name == CoreAssemblyName)
{
return processed[assembly.FullName] = true;
}
if (ShouldScanDependencies(assembly))
{
foreach (var referencedAssemblyName in assembly.GetReferencedAssemblies())
{
var referencedAssembly = GetReferencedAssembly(referencedAssemblyName);
var referencesCore = ScanAssembly(referencedAssembly, processed);
if (referencesCore)
{
processed[assembly.FullName] = true;
break;
}
}
}

This means, as far as I can tell, that an assembly containing message contracts but using unobtrusive mode (and therefore not referencing NServiceBus.Core) will not be scanned and types from it will never be added to the MessageMetadaRegistry even if the custom IMessageConvention defines those types as message. The only types from these assemblies that will be added are those that are handled by an IHandleMessages<> (which automatically belongs to a scanned assembly by virtue of referencing NServiceBus.Core in order to implement IHandleMessages<>).

In the case that the handled message contracts from the unobtrusive assembly are simple interfaces, this is perhaps fine because concrete types can be created dynamically in MessageMapper.cs, but any interface that contains--as a property--another interface will not be deserializable.

This is true even when the NServiceBus.EnclosedMessageTypes explicitly defines the concrete/implementation type and when that concrete type lives in the same assembly as the interface:

https://github.com/Particular/NServiceBus/blob/f66e5f74ee49084ae8c74c91890e41d2dce7ecec/src/NServiceBus.Core/Pipeline/Incoming/DeserializeMessageConnector.cs#L70C9-L103C10

Expected behavior

There needs to be some way for a user to ensure that unobtrusive assemblies are scanned for concrete types, especially if the concrete types are in the same assembly as the handled type.

Actual behavior

AssemblyScanner does not scan the unobtrusive assembly and does not find these types, and therefore it is impossible to send messages that are handled by a complex interface contract type.

Versions

8.1.6
SystemJsonSerializer for sure, although I suspect it would also apply to NewtonsoftJsonSerializer.

Steps to reproduce

  1. Create an unobtrusive contracts assembly
  2. Add a complex interface contract type, such as:
public interface IComplexContract
{
    IComplexChild Child { get; }
}

public interface IComplexChild
{
    bool IsComplex { get; }
}
  1. Add concrete implementations for your contract:
public class ConcreteContract : IComplexContract
{
    public ConcreteChild Child { get; set; }
    public IComplexContract.Child => Child;
}

public class ConcreteChild : IComplexChild
{
    public bool IsComplex => true;
}
  1. Add a handler for IConcreteContract:
public class ComplexContractHandler : IHandleMessages<IComplexContract>
{
    public Task Handle(IComplexContract message)
    {
        // unreachable
        return Task.CompletedTask;
    }
}
  1. Send an IComplexContract message. Even if ConcreteContract is in the NServiceBus.EnclosedMessageTypes, the AssemblyScanner will not scan the unobtrusive assembly and will therefore never register ConcreteChild. While a dynamic type will be created to deserialize the IComplexContract, the same will not be down for IComplexChild and MessageDeserializationException will be thrown saying it is unable to deserialize $.Child.

Relevant log output

NServiceBus.MessageDeserializationException: An error occurred while attempting to extract logical messages from incoming physical message {GUID}
 ---> System.NotSupportedException: Deserialization of interface types is not supported. Type 'IComplexChild'. Path: $.Child | LineNumber: 0 | BytePositionInLine: ##.
 ---> System.NotSupportedException: Deserialization of interface types is not supported. Type 'IComplexChild'.
   --- End of inner exception stack trace ---
   at System.Text.Json.ThrowHelper.ThrowNotSupportedException(ReadStack& state, Utf8JsonReader& reader, NotSupportedException ex)
   at System.Text.Json.ThrowHelper.ThrowNotSupportedException_DeserializeNoConstructor(Type type, Utf8JsonReader& reader, ReadStack& state)
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.Metadata.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCoreAsObject(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadCore[TValue](Utf8JsonReader& reader, JsonTypeInfo jsonTypeInfo, ReadStack& state)
   at System.Text.Json.JsonSerializer.ContinueDeserialize[TValue](ReadBufferState& bufferState, JsonReaderState& jsonReaderState, ReadStack& readStack, JsonTypeInfo jsonTypeInfo)
   at System.Text.Json.JsonSerializer.ReadFromStream[TValue](Stream utf8Json, JsonTypeInfo jsonTypeInfo)
   at System.Text.Json.JsonSerializer.Deserialize(Stream utf8Json, Type returnType, JsonSerializerOptions options)
   at NServiceBus.Serializers.SystemJson.JsonMessageSerializer.Deserialize(ReadOnlyMemory`1 body, Type type) in /_/src/NServiceBus.Core/Serializers/SystemJson/JsonMessageSerializer.cs:line 53
   at NServiceBus.Serializers.SystemJson.JsonMessageSerializer.Deserialize(ReadOnlyMemory`1 body, IList`1 messageTypes) in /_/src/NServiceBus.Core/Serializers/SystemJson/JsonMessageSerializer.cs:line 41
   at NServiceBus.DeserializeMessageConnector.Extract(IncomingMessage physicalMessage) in /_/src/NServiceBus.Core/Pipeline/Incoming/DeserializeMessageConnector.cs:line 112
   at NServiceBus.DeserializeMessageConnector.ExtractWithExceptionHandling(IncomingMessage message) in /_/src/NServiceBus.Core/Pipeline/Incoming/DeserializeMessageConnector.cs:line 46
   --- End of inner exception stack trace ---
   at NServiceBus.DeserializeMessageConnector.ExtractWithExceptionHandling(IncomingMessage message) in /_/src/NServiceBus.Core/Pipeline/Incoming/DeserializeMessageConnector.cs:line 50
   at NServiceBus.DeserializeMessageConnector.Invoke(IIncomingPhysicalMessageContext context, Func`2 stage) in /_/src/NServiceBus.Core/Pipeline/Incoming/DeserializeMessageConnector.cs:line 28
   at NServiceBus.ProcessingStatisticsBehavior.Invoke(IIncomingPhysicalMessageContext context, Func`2 next) in /_/src/NServiceBus.Core/Performance/Statistics/ProcessingStatisticsBehavior.cs:line 25
   at NServiceBus.ReceiveDiagnosticsBehavior.Invoke(IIncomingPhysicalMessageContext context, Func`2 next) in /_/src/NServiceBus.Core/OpenTelemetry/Metrics/ReceiveDiagnosticsBehavior.cs:line 33
   at NServiceBus.TransportReceiveToPhysicalMessageConnector.Invoke(ITransportReceiveContext context, Func`2 next) in /_/src/NServiceBus.Core/Pipeline/Incoming/TransportReceiveToPhysicalMessageConnector.cs:line 35
   at NServiceBus.RetryAcknowledgementBehavior.Invoke(ITransportReceiveContext context, Func`2 next) in /_/src/NServiceBus.Core/ServicePlatform/Retries/RetryAcknowledgementBehavior.cs:line 25
   at NServiceBus.MainPipelineExecutor.Invoke(MessageContext messageContext, CancellationToken cancellationToken) in /_/src/NServiceBus.Core/Pipeline/MainPipelineExecutor.cs:line 49
   at NServiceBus.MainPipelineExecutor.Invoke(MessageContext messageContext, CancellationToken cancellationToken) in /_/src/NServiceBus.Core/Pipeline/MainPipelineExecutor.cs:line 68
   at NServiceBus.Transport.AzureServiceBus.MessagePump.ProcessMessage(ServiceBusReceivedMessage message, ProcessMessageEventArgs processMessageEventArgs, String messageId, Dictionary`2 headers, BinaryData body, CancellationToken messageProcessingCancellationToken) in /_/src/Transport/Receiving/MessagePump.cs:line 285

Additional Information

Workarounds

Perhaps adding a concrete type for the complex interface to an assembly that references NServiceBus.Core to ensure it available to the DeserializeMessageConnector ?

Possible solutions

My janky solution just to prove the point was to add a delegate predicate to AssemblyScannerConfiguration to tell it the assembly was an unobtrusive package that should for sure be scanned:

...
        /// <summary>
        /// Defines an additional path for assembly scanning.
        /// </summary>
        public string? AdditionalAssemblyScanningPath { get; set; }

        /// <summary>
        /// Register assembly types based on a predicate.
        /// </summary>
        public Func<string, bool>? AssemblyContainsMessages { get; set; }

        /// <summary>
        /// A list of <see cref="Assembly" />s to ignore in the assembly scanning.
        /// </summary>
        /// <param name="assemblies">The file name of the assembly.</param>
        public void ExcludeAssemblies(params string[] assemblies)
        {
...

And then passing this delegate through to the AssemblyScanner.ScanAssembly():

...
            processed[assembly.FullName] = false;

            var assemblyName = assembly.GetName().Name;
            if (assemblyName == CoreAssemblyName
                || AssemblyContainsMessages?.Invoke(assemblyName) == true)
            {
                return processed[assembly.FullName] = true;
            }

            if (ShouldScanDependencies(assembly))
            {
...

A nicer solution that is essentially the same would be to add something in the same vein as IMessageConvention rather than a raw delegate.

But the better solution, IMHO, would be to automatically scan assemblies that contain any types defined by the IMessageConvention to be messages. A naive solution to this would add a lot of overhead in scanning all the types from all those assemblies, but there are already some inefficiencies there due to loading every referenced assembly just to tell whether it's referencing NServiceBus.Core

if (ShouldScanDependencies(assembly))
{
foreach (var referencedAssemblyName in assembly.GetReferencedAssemblies())
{
var referencedAssembly = GetReferencedAssembly(referencedAssemblyName);
var referencesCore = ScanAssembly(referencedAssembly, processed);
if (referencesCore)
{
processed[assembly.FullName] = true;
break;
}
}
}

Either that, or expose the AssemblyScanningComponent.Configuration.UserProvidedTypes outside of acceptance tests and let the user do the assembly scanning themselves.

Additional information

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions