Skip to content

Memory exhaustion from high cardinatlity BindException FieldError default messages cached in MessageSourceSupport.messageFormatsPerMessage #36609

@msqr

Description

@msqr

I am experiencing a memory exhaustion event in a Spring Boot 4.0.5 web app that I have traced back to the rendering of FieldError default message values after a BindException occurs on a field with high cardinality input values. When messageSource.getMessage(fieldError, locale) is invoked on the exception's errors, the FieldError has a defaultMessage populated like

Failed to convert property value of type 'java.lang.String' to required type
'java.time.LocalDateTime' for property 'dt'; Failed to convert from type
[java.lang.String] to type [java.time.LocalDateTime] for value [asdbcsdfasdfasdf]

and eventually the MessageSourceSupport.renderDefaultMessage() method is invoked with that message and anargs value as a single element array with a DefaultMessageSourceResolvable instance in it. This eventually leads to the formatMessage() method getting called, and the logic then caches an entry in the messageFormatsPerMessage map:

	protected String formatMessage(String msg, Object @Nullable [] args, @Nullable Locale locale) {
		if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
			return msg;
		}
		Map<Locale, MessageFormat> messageFormatsPerLocale = this.messageFormatsPerMessage
				.computeIfAbsent(msg, key -> new ConcurrentHashMap<>());
		MessageFormat messageFormat = messageFormatsPerLocale.computeIfAbsent(locale, key -> {

Because the default message in my case is effectively unique per request (i.e. the current date/time) this map gets filled up with entries, until memory is exhausted.

Image

The following Spring Boot demo app demonstrates this scenario (call http://localhost:8080/req?dt=BADLY_FORMATTED_TIMESTAMP to trigger the effect):

package com.example.demo;

import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.Errors;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;

@SpringBootApplication
@RestController
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}

	@Autowired
	private MessageSource messageSource;

	public static final class Criteria {

		private LocalDateTime dt;

		public final LocalDateTime getDt() {
			return dt;
		}

		public final void setDt(LocalDateTime dt) {
			this.dt = dt;
		}

	}

	// bind the request to a bean with a LocalDateTime field
	@GetMapping("/req")
	public Map<String, Object> req(Criteria criteria) {
		return Map.of("success", true, "dt", criteria.getDt() != null ? criteria.getDt() : "N/A");
	}

	// return a JSON error message based on the bind error
	@ExceptionHandler(BindException.class)
	@ResponseBody
	@ResponseStatus(HttpStatus.UNPROCESSABLE_CONTENT)
	public Map<String, Object> handleBindException(BindException e, WebRequest request, Locale locale) {
		return Map.of("success", false, "message", generateErrorsMessage(e, locale, messageSource));
	}

	public static String generateErrorsMessage(Errors e, Locale locale, MessageSource msgSrc) {
		String msg = "Bind error";
		if ( msgSrc != null && e != null && e.hasErrors() ) {
			StringBuilder buf = new StringBuilder();
			for ( ObjectError error : e.getAllErrors() ) {
				if ( !buf.isEmpty() ) {
					buf.append(" ");
				}
				// the next getMessage() call caches unique default message values based on the request
				// parameter value (in MessageSourceSupport messageFormatsPerMessage field) causing
				// an OOM error eventually when the request parameter cardinality is high
				buf.append(msgSrc.getMessage(error, locale));
			}
			msg = buf.toString();
		}
		return msg;
	}

}

I was looking for a way to avoid having the FieldError default messages not end up in the cache.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions