javaspring-boottomcatopensearch

“cannot write xcontent for unknown value of type java.time.LocalDate” appears randomly on one Tomcat node


OpenSearch XContentBuilder – “cannot write xcontent for unknown value of type java.time.LocalDate” appears randomly on Tomcat.

Context:

  1. Spring-Boot 3.4.4, packaged as a WAR and deployed external Tomcat 10.1.20 servers (JDK 17.0.11 Temurin).
  2. OpenSearch Java client 2.19.0 (also tested 2.6 → same result).
  3. All dependencies live in WEB-INF/lib — no shading/uber-jar.

What I understood ?

  1. Static initialisation of XContentBuilder
public class XContentBuilder implements Closeable {
    /** Map: Java type ➜ lambda that writes that value */
    private static final Map<Class<?>, Writer> WRITERS = new HashMap<>();

    static {
        /* ── 1 a. built-in primitives ───────────────────────────── */
        WRITERS.put(String.class, (b, v) -> b.value((String) v));
        WRITERS.put(Integer.class, (b, v) -> b.value((Integer) v));
        /* …​ Boolean, BigInteger, Date, byte[]  … */

        /* ── 1 b. ask every SPI extension to extend the map ─────── */
        /*
         * This is the SPI hook.  It is executed ONCE, the first time
         * XContentBuilder is referenced inside a given class-loader.
         */
        for (XContentBuilderExtension ext :
                ServiceLoader.load(XContentBuilderExtension.class)) {
            ext.accept(WRITERS);      // registers extra writers
        }
    }
    …
}

Everything happens once per class-loader, the result is cached in the static WRITERS map.

Somehow, for an obscure reason, ServiceLoader.load(XContentBuilderExtension.class) does not find XContentOpenSearchExtension.class

and when calling this method with a LocalDate object

    private void unknownValue(Object value, boolean ensureNoSelfReferences) throws IOException {
    if (value == null) {
        nullValue();
        return;
    }
    Writer writer = WRITERS.get(value.getClass());
    if (writer != null) {
        writer.write(this, value);
    } else if (value instanceof Path) {
        // Path implements Iterable<Path> and causes endless recursion and a StackOverFlow if treated as an Iterable here
        value((Path) value);
    } else if (value instanceof Map) {
        @SuppressWarnings("unchecked")
        final Map<String, ?> valueMap = (Map<String, ?>) value;
        map(valueMap, ensureNoSelfReferences, true);
    } else if (value instanceof Iterable) {
        value((Iterable<?>) value, ensureNoSelfReferences);
    } else if (value instanceof Object[]) {
        values((Object[]) value, ensureNoSelfReferences);
    } else if (value instanceof ToXContent) {
        value((ToXContent) value);
    } else if (value instanceof Enum<?>) {
        // Write out the Enum toString
        value(Objects.toString(value));
    } else {
        throw new IllegalArgumentException("cannot write xcontent for unknown value of type " + value.getClass());
    }
}

The IllegalArgumentException is throw :

java.lang.IllegalArgumentException: cannot write xcontent for unknown value of type class java.time.LocalDate
    at org.opensearch.core.xcontent.XContentBuilder.unknownValue(XContentBuilder.java:866)
    at org.opensearch.core.xcontent.XContentBuilder.value(XContentBuilder.java:837)
    at org.opensearch.core.xcontent.XContentBuilder.field(XContentBuilder.java:822)
    at org.opensearch.index.query.RangeQueryBuilder.doXContent(RangeQueryBuilder.java:334)

What can lead ServiceLoader.load(XContentBuilderExtension.class) to not find XContentOpenSearchExtension class ?


Solution

  • When using OpenSearch's XContentBuilder to serialize Java date/time objects (like LocalDate, Date, LocalDateTime), I encountered this error:

    java.lang.IllegalArgumentException: cannot write time value xcontent for unknown value of type class java.util.Date

    This happens because the internal WRITERS map in XContentBuilder is not correctly populated for date types. This map is initialized in a static block using:

    for (XContentBuilderExtension ext :ServiceLoader.load(XContentBuilderExtension.class)) {     ..... } 
    

    But in some cases, ServiceLoader.load(...) silently fails to find any implementation, causing OpenSearch client to be unable to serialize common types like LocalDate.

    In my case, this issue occurred only under certain multithreaded conditions:

    Both of these approaches run code in threads from the ForkJoinPool, where the Thread Context ClassLoader (TCCL) is not necessarily the one that has access to application .jars (like the one containing XContentOpenSearchExtension).

    As a result, ServiceLoader.load(...) was using the wrong class loader and failed to find any extensions.

    I resolved the issue using two combined changes:

    1. Replace stream().parallel() and CompletableFuture

    I replaced:

    list.stream().parallel().map(...).collect(...) 
    

    with:

    list.stream().map(...).collect(...) 
    

    and:

    CompletableFuture.supplyAsync(() -> callOpenSearch()) 
    

    with a Spring-managed ThreadPoolTaskExecutor, ensuring that threads used for async work inherit the correct class loader:

    @Autowired 
    private ThreadPoolTaskExecutor openSearchExecutor;  
    
    
    public void callOpensearchAsync() {
        openSearchExecutor.submit(() -> { callOpenSearch(); }); 
    }
    

    This ensures the context classloader is consistent with the application and has access to XContentBuilderExtension implementations.

    Note: This only happens on Tomcat, Works fine on Embedded Tomcat.