Home > other >  Memory increases with each request when using AutoMapper's ProjectTo with expand option
Memory increases with each request when using AutoMapper's ProjectTo with expand option

Time:12-21

I'm building a .NET 5 REST API based on CQRS/MediatR and I noticed a linear memory increase when stress testing my application. I did some profiling and saw a large number of object instances from the namespace System.Linq.Expression was taking up all the space. All these instances are related to the MapperConfiguration of AM.

I use AutoMapper to map my entities to the DTOs, to do so I mainly use the following ProjectTo extension method :

public static IQueryable<TDestination> ProjectTo<TDestination>(this IQueryable source, IConfigurationProvider configuration, object parameters, params Expression<Func<TDestination, object>>[] membersToExpand);

After some testing, I noticed that the memory problem is only happening when providing membersToExpand inside the ProjectTo method.

When providing an "expand" and calling the related endpoint, it's like the expression of the mapping is created each time but never freed. As they stay, they accumulates in memory and add as the query is called.

Here is a screenshot of the memory state comparing two snapshots :

  • The first one after one call to the endpoint
  • The second one after five more call to the same endpoint (without changing anything)

Memory comparison between first and sixth call

I wouldn't mind the memory usage thinking it may be some kind of cache used by AM to have better response time but the problem is that the more memory is used the slower it gets for my API to respond to the request (4ms at the start to 90ms after the stress test). I guess AM has a hard time searching inside so many stored expressions. As soon as I stop using the "expand" system, the responses are again instantaneous (4ms).

Last point is that when the application pool is recycled (and the memory cleared), the API responds again in full speed at 4ms (with the expand).

I searched online for hours to see if I had missed a specific AM configuration related to caches or something, but found nothing.

Does anyone has an idea, similar experience or more information about this behaviour ?

PS : As you can see, I use AM with DI (using the package for DI and .AddAutoMapper method)

Below, some code samples from my application :

RoleByProfileDto (RoleDto has nothing particular and ReverseMap is only here for "translation" purpose) :

    public class RoleByProfileDto : RoleDto
    {
        public ProfileRoleDto ProfileRole { get; set; }

        public void Mapping(AM.Profile profile)
        {
            int profileId = default;

            profile.CreateMap<Role, RoleByProfileDto>()
                .ForMember(dto =>
                    dto.ProfileRole, opts =>
                        opts.MapFrom(r => r.ProfileRoles.FirstOrDefault(pr => pr.ProfileId == profileId))
                )
                .IncludeBase<Role, RoleDto>()
                .ReverseMap();
        }
    }

Handler of the said request :

    public class GetRoleByProfileAndIdQueryHandler : IRequestHandler<GetRoleByProfileAndIdQuery, RoleByProfileDto>
    {
        private readonly IApplicationDbContext _context;
        private readonly AM.IMapper _mapper;

        public GetRoleByProfileAndIdQueryHandler(IApplicationDbContext context, AM.IMapper mapper)
        {
            _context = context;
            _mapper = mapper;
        }

        public async Task<RoleByProfileDto> Handle(GetRoleByProfileAndIdQuery request, CancellationToken cancellationToken)
        {
            var dto = await _context.Roles
                .Where(r =>
                    r.Id == request.RoleId
                    && r.ProfileRoles.Any(pr =>
                        pr.ProfileId == request.ProfileId
                    )
                )
                .ProjectTo(
                    _mapper.ConfigurationProvider,
                    new { profileId = request.ProfileId },
                    request.Expand.GetExpandMemberList(ExpandMappings)
                )
                .FirstOrDefaultAsync(cancellationToken);

            if (dto == null)
            {
                throw new NotFoundException(
                    nameof(Role),
                    new List<string>() { nameof(Profile), nameof(Role) },
                    new List<object>() { request.ProfileId, request.RoleId }
                );
            }

            return dto;
        }

        private static readonly IReadOnlyDictionary<RoleByProfileAndIdExpand, Expression<Func<RoleByProfileDto, object>>> ExpandMappings =
            new Dictionary<RoleByProfileAndIdExpand, Expression<Func<RoleByProfileDto, object>>>
            {
                { RoleByProfileAndIdExpand.ProfileRole, d => d.ProfileRole }
            };
    }

The method used to build the expand array that is provided to the ProjectTo :

        public static Expression<Func<TDto, object>>[] GetExpandMemberList<TEnum, TDto>(
            this IList<TEnum> selectedExpands,
            IReadOnlyDictionary<TEnum, Expression<Func<TDto, object>>> mappings
        )
            where TEnum : Enum
            where TDto : BaseDto
        {
            if (selectedExpands != null && selectedExpands.Any())
                return selectedExpands.Select(e => mappings[e]).ToArray();
            else
                return Array.Empty<Expression<Func<TDto, object>>>();
        }

CodePudding user response:

This leak is fixed in the MyGet build. Details here.

  • Related