Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright 2010-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.mongodb.core;

import static org.springframework.data.mongodb.core.query.Criteria.*;
import static org.springframework.data.mongodb.core.query.Query.*;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.util.Assert;

/**
* Internal helper for MongoDB-backed atomic counters using findAndModify.
* <p>
* <strong>This class is for internal use only and subject to change.</strong>
* It is not part of the public API and may be refactored in future releases.
* <p>
* This class provides support for server-side counter management using MongoDB's
* atomic findAndModify operation with $inc, upsert, and returnNew options.
* Counter values start at 1 on first access.
*
* @author Jeongkyun An
* @since 4.5
* @see <a href="https://github.com/spring-projects/spring-data-mongodb/issues/4823">GH-4823</a>
*/
class MongoCounterSupport {

private final MongoOperations mongoOperations;

/**
* Creates a new {@link MongoCounterSupport} instance.
*
* @param mongoOperations must not be {@literal null}.
*/
MongoCounterSupport(MongoOperations mongoOperations) {

Assert.notNull(mongoOperations, "MongoOperations must not be null");
this.mongoOperations = mongoOperations;
}

/**
* Get the next counter value atomically. Counter starts at 1 on first call.
*
* @param counterName the name of the counter, must not be {@literal null}.
* @param collectionName the collection to store counter documents, must not be {@literal null}.
* @return the next counter value.
*/
long getNextSequenceValue(String counterName, String collectionName) {

Assert.notNull(counterName, "Counter name must not be null");
Assert.notNull(collectionName, "Collection name must not be null");

Update update = new Update().inc("count", 1L);
FindAndModifyOptions options = FindAndModifyOptions.options().returnNew(true).upsert(true);

CounterDocument result = mongoOperations.findAndModify(query(where("_id").is(counterName)), update, options,
CounterDocument.class, collectionName);

return result != null ? result.getCount() : 1L;
}

/**
* Internal document structure for counter storage.
* <p>
* Document format: {@code {_id: "counterName", count: 123}}
*/
static class CounterDocument {

@Id
private String id;

private Long count;

public String getId() {
return id;
}

public void setId(String id) {
this.id = id;
}

public Long getCount() {
return count;
}

public void setCount(Long count) {
this.count = count;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,20 @@ default <T> List<T> findDistinct(Query query, String field, String collection, C
<T> @Nullable T findAndModify(Query query, UpdateDefinition update, FindAndModifyOptions options,
Class<T> entityClass, String collectionName);

/**
* Get the next counter value atomically using MongoDB's findAndModify operation.
* <p>
* Counter starts at 1 on first call and increments by 1 on each subsequent call.
* Uses $inc with upsert to ensure atomic behavior even under concurrent access.
*
* @param counterName the name of the counter, must not be {@literal null}.
* @param collectionName the collection to store counter documents, must not be {@literal null}.
* @return the next counter value.
* @since 4.5
* @see <a href="https://github.com/spring-projects/spring-data-mongodb/issues/4823">GH-4823</a>
*/
long getNextCounterValue(String counterName, String collectionName);

/**
* Triggers
* <a href="https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndReplace/">findOneAndReplace</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware,
private final PropertyOperations propertyOperations;
private final QueryOperations queryOperations;
private final EntityLifecycleEventDelegate eventDelegate;
private final MongoCounterSupport counterSupport;

private @Nullable WriteConcern writeConcern;
private WriteConcernResolver writeConcernResolver = DefaultWriteConcernResolver.INSTANCE;
Expand Down Expand Up @@ -270,6 +271,7 @@ public MongoTemplate(MongoDatabaseFactory mongoDbFactory, @Nullable MongoConvert
this.queryOperations = new QueryOperations(queryMapper, updateMapper, operations, propertyOperations,
mongoDbFactory);
this.eventDelegate = new EntityLifecycleEventDelegate();
this.counterSupport = new MongoCounterSupport(this);

// We always have a mapping context in the converter, whether it's a simple one or not
mappingContext = this.mongoConverter.getMappingContext();
Expand Down Expand Up @@ -307,6 +309,7 @@ private MongoTemplate(MongoDatabaseFactory dbFactory, MongoTemplate that) {
this.propertyOperations = that.propertyOperations;
this.queryOperations = that.queryOperations;
this.eventDelegate = that.eventDelegate;
this.counterSupport = that.counterSupport;
}

/**
Expand Down Expand Up @@ -1177,6 +1180,11 @@ <T, R> GeoResults<R> doGeoNear(NearQuery near, Class<?> domainType, String colle
getMappedSortObject(query, entityClass), entityClass, update, optionsToUse, resultConverter);
}

@Override
public long getNextCounterValue(String counterName, String collectionName) {
return counterSupport.getNextSequenceValue(counterName, collectionName);
}

@Override
public <S, T> @Nullable T findAndReplace(Query query, S replacement, FindAndReplaceOptions options,
Class<S> entityType, String collectionName, Class<T> resultType) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.mongodb.core;

import static org.assertj.core.api.Assertions.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import org.springframework.data.mongodb.test.util.MongoTestTemplate;
import org.springframework.data.mongodb.test.util.Template;

/**
* Tests for {@link MongoCounterSupport}.
*
* @author Jeongkyun An
*/
class MongoCounterSupportTests {

@Template
static MongoTestTemplate template;

private static final String COUNTER_COLLECTION = "counters";

private MongoCounterSupport counterSupport;

@BeforeEach
void setUp() {
template.dropCollection(COUNTER_COLLECTION);
counterSupport = new MongoCounterSupport(template);
}

@Test // GH-4823
void shouldIncrementCounterAtomically() {

long value1 = counterSupport.getNextSequenceValue("test-counter", COUNTER_COLLECTION);
long value2 = counterSupport.getNextSequenceValue("test-counter", COUNTER_COLLECTION);
long value3 = counterSupport.getNextSequenceValue("test-counter", COUNTER_COLLECTION);
long value4 = counterSupport.getNextSequenceValue("test-counter", COUNTER_COLLECTION);
long value5 = counterSupport.getNextSequenceValue("test-counter", COUNTER_COLLECTION);

assertThat(value1).isEqualTo(1L);
assertThat(value2).isEqualTo(2L);
assertThat(value3).isEqualTo(3L);
assertThat(value4).isEqualTo(4L);
assertThat(value5).isEqualTo(5L);
}

@Test // GH-4823
void shouldCreateCounterDocumentOnFirstCall() {

long firstValue = counterSupport.getNextSequenceValue("new-counter", COUNTER_COLLECTION);

assertThat(firstValue).isEqualTo(1L);

long secondValue = counterSupport.getNextSequenceValue("new-counter", COUNTER_COLLECTION);

assertThat(secondValue).isEqualTo(2L);
}

@Test // GH-4823
void shouldMaintainIndependentCounters() {

long counter1Value1 = counterSupport.getNextSequenceValue("counter-1", COUNTER_COLLECTION);
long counter2Value1 = counterSupport.getNextSequenceValue("counter-2", COUNTER_COLLECTION);
long counter1Value2 = counterSupport.getNextSequenceValue("counter-1", COUNTER_COLLECTION);
long counter2Value2 = counterSupport.getNextSequenceValue("counter-2", COUNTER_COLLECTION);

assertThat(counter1Value1).isEqualTo(1L);
assertThat(counter2Value1).isEqualTo(1L);
assertThat(counter1Value2).isEqualTo(2L);
assertThat(counter2Value2).isEqualTo(2L);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4099,6 +4099,39 @@ void readsMapWithDotInKey() {
assertThat(loaded.mapValue).isEqualTo(sourceMap);
}

@Test // GH-4823
void shouldGetNextCounterValue() {

String countersCollection = "counters";
MongoTestUtils.flushCollection(DB_NAME, countersCollection, client);

long value1 = template.getNextCounterValue("order-id", countersCollection);
long value2 = template.getNextCounterValue("order-id", countersCollection);
long value3 = template.getNextCounterValue("order-id", countersCollection);

assertThat(value1).isEqualTo(1L);
assertThat(value2).isEqualTo(2L);
assertThat(value3).isEqualTo(3L);
}

@Test // GH-4823
void shouldMaintainIndependentCountersInDifferentCollections() {

String collectionA = "collection-a";
String collectionB = "collection-b";

MongoTestUtils.flushCollection(DB_NAME, collectionA, client);
MongoTestUtils.flushCollection(DB_NAME, collectionB, client);

long counter1 = template.getNextCounterValue("counter-1", collectionA);
long counter2 = template.getNextCounterValue("counter-1", collectionB);
long counter1Again = template.getNextCounterValue("counter-1", collectionA);

assertThat(counter1).isEqualTo(1L);
assertThat(counter2).isEqualTo(1L);
assertThat(counter1Again).isEqualTo(2L);
}

private AtomicReference<ImmutableVersioned> createAfterSaveReference() {

AtomicReference<ImmutableVersioned> saved = new AtomicReference<>();
Expand Down