Skip to content

[BUG] CosmosAsyncContainer.replaceItem bypasses customItemSerializer, serialises POJO with internal ObjectMapper #48527

@lloydmeta

Description

@lloydmeta

Describe the bug

CosmosAsyncContainer.replaceItem does not use the customItemSerializer provided via CosmosClientBuilder.customItemSerializer() for serialising the item POJO. Instead, it eagerly converts the POJO to a Document using InternalObjectNode.fromObject(item), which serialises via the SDK's internal Utils.getSimpleObjectMapper() singleton (with Jackson defaults). The resulting Document extends JsonSerializable, which then short-circuits the custom serialiser's serialize() method.

This means any ObjectMapper configuration applied to the custom serialiser (e.g. WRITE_DATES_AS_TIMESTAMPS = false, custom JsonInclude, naming strategies, additional Jackson modules) is silently ignored for all replaceItem calls.

In contrast, createItem and upsertItem correctly pass the raw POJO through to the custom serialiser, so createItem/upsertItem and replaceItem produce different JSON for the same object.

Related: #45521 (custom serializer also bypassed for queryItems with ORDER BY/GROUP BY and SqlParameter serialization).

Exception or Stack Trace

The exception surfaces on read after a replaceItem has written incorrectly-serialised data. For example, java.time.Instant fields are written as numeric epoch seconds instead of ISO-8601 strings, and the application's deserialiser fails:

java.lang.IllegalStateException: Unable to parse JSON {
  "id": "...",
  "metadata": {"created": 1.774266255081534E9, "last_modified": 1.774266256614417E9},
  "expiration": null,
  ...
}
Caused by: java.time.format.DateTimeParseException: Text '1.774266255081534E9' could not be parsed at index 0

Note "expiration": null is also present -- our mapper uses JsonInclude.Include.NON_ABSENT which would omit null fields, confirming the custom serialiser was not used for this write.

To Reproduce

  1. Create a CosmosClient with a custom serialiser that uses a configured ObjectMapper:

    ObjectMapper mapper = new ObjectMapper();
    mapper.registerModule(new JavaTimeModule());
    mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
    mapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT);
    
    // DefaultCosmosItemSerializer is in the implementation package -- we use it here for
    // brevity 
    CosmosClient client = new CosmosClientBuilder()
        .endpoint(endpoint)
        .key(key)
        .customItemSerializer(new DefaultCosmosItemSerializer(mapper))
        .buildClient();
  2. Create an item containing an Instant field using container.createItem(item, partitionKey, options) -- this works correctly, the Instant is serialised as an ISO-8601 string.

  3. Read the item back and verify the Instant field is a string -- this succeeds.

  4. Modify the item and call container.replaceItem(item, itemId, partitionKey, options).

  5. Read the item back -- the Instant field is now a numeric value (e.g. 1.774266255E9) because replaceItem serialised it with Utils.getSimpleObjectMapper() which has WRITE_DATES_AS_TIMESTAMPS = true (Jackson default).

Code Snippet

The root cause is in CosmosAsyncContainer.replaceItem (L1737-1740):

public <T> Mono<CosmosItemResponse<T>> replaceItem(
    T item, String itemId, PartitionKey partitionKey, CosmosItemRequestOptions options) {
    Document doc = InternalObjectNode.fromObject(item);  // <-- bypasses custom serializer

InternalObjectNode.fromObject uses a static MAPPER field (L21) initialised from Utils.getSimpleObjectMapper():

private static final ObjectMapper MAPPER = Utils.getSimpleObjectMapper();

For a POJO (not byte[], ObjectNode, or InternalObjectNode), it serialises via MAPPER.writeValueAsString (L104):

return new Document(InternalObjectNode.MAPPER.writeValueAsString(cosmosItem));

The resulting Document (which extends JsonSerializable) then short-circuits the custom serialiser in DefaultCosmosItemSerializer.serialize() (L63-64):

if (item instanceof JsonSerializable) {
    return ((JsonSerializable) item).getMap();
}

In contrast, createItemInternal (L551) and upsertItemInternal (L2362) both correctly set the effective item serialiser and pass the raw POJO through:

effectiveOptions.setEffectiveItemSerializer(
    this.database.getClient().getEffectiveItemSerializer(
        effectiveOptions.getEffectiveItemSerializer()));

Expected behavior

replaceItem should use the customItemSerializer (or the effective item serialiser) to serialise the POJO, consistent with how createItem and upsertItem work. The custom serialiser's ObjectMapper configuration should be respected for all point write operations.

Screenshots

N/A

Setup (please complete the following information):

  • OS: macOS 15
  • IDE: IntelliJ
  • Library/Libraries: com.azure:azure-cosmos:4.78.0
  • Java version: 25
  • App Server/Environment: Quarkus application deployed in Kubernetes
  • Frameworks: Quarkus

Additional context

The current workaround is to also apply your ObjectMapper configuration to the SDK's shared singleton (Utils.getSimpleObjectMapper()) so that InternalObjectNode.fromObject() produces compatible output:

ObjectMapper sdkMapper = Utils.getSimpleObjectMapper();
sdkMapper.registerModule(new JavaTimeModule());
sdkMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
sdkMapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT);

This is fragile and mutates an implementation singleton 😬

Information Checklist

  • Bug Description Added
  • Repro Steps Added
  • Setup information Added

Metadata

Metadata

Assignees

Labels

ClientThis issue points to a problem in the data-plane of the library.CosmosService AttentionWorkflow: This issue is responsible by Azure service team.customer-reportedIssues that are reported by GitHub users external to the Azure organization.questionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions