From 635738055664a660fc876359ca62f74d9d667940 Mon Sep 17 00:00:00 2001 From: Jay Choi Date: Sun, 18 Jan 2026 00:27:43 +0900 Subject: [PATCH] Add MongoDB's configuration properties for consistency Closes gh-46261 Signed-off-by: Jay Choi --- .../autoconfigure/MongoConnectionDetails.java | 28 ++++ .../autoconfigure/MongoProperties.java | 126 ++++++++++++++++++ .../PropertiesMongoConnectionDetails.java | 89 +++++++++++++ ...dMongoClientSettingsBuilderCustomizer.java | 16 +++ .../MongoAutoConfigurationTests.java | 41 ++++++ ...PropertiesMongoConnectionDetailsTests.java | 110 +++++++++++++++ 6 files changed, 410 insertions(+) diff --git a/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/MongoConnectionDetails.java b/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/MongoConnectionDetails.java index b766c1b5ede7..fb65f83d98bb 100644 --- a/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/MongoConnectionDetails.java +++ b/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/MongoConnectionDetails.java @@ -17,6 +17,9 @@ package org.springframework.boot.mongodb.autoconfigure; import com.mongodb.ConnectionString; +import com.mongodb.ReadConcern; +import com.mongodb.ReadPreference; +import com.mongodb.WriteConcern; import org.jspecify.annotations.Nullable; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; @@ -28,6 +31,7 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Jay Choi * @since 4.0.0 */ public interface MongoConnectionDetails extends ConnectionDetails { @@ -46,4 +50,28 @@ public interface MongoConnectionDetails extends ConnectionDetails { return null; } + /** + * The {@link ReadConcern} for MongoDB. + * @return the read concern option + */ + default @Nullable ReadConcern getReadConcern() { + return null; + } + + /** + * The {@link WriteConcern} for MongoDB. + * @return the write concern options + */ + default @Nullable WriteConcern getWriteConcern() { + return null; + } + + /** + * The {@link ReadPreference} for MongoDB. + * @return the read preference options + */ + default @Nullable ReadPreference getReadPreference() { + return null; + } + } diff --git a/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/MongoProperties.java b/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/MongoProperties.java index cae688a005aa..b334058f6b1a 100644 --- a/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/MongoProperties.java +++ b/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/MongoProperties.java @@ -16,7 +16,9 @@ package org.springframework.boot.mongodb.autoconfigure; +import java.time.Duration; import java.util.List; +import java.util.Map; import com.mongodb.ConnectionString; import org.bson.UuidRepresentation; @@ -37,6 +39,7 @@ * @author Mark Paluch * @author Artsiom Yudovin * @author Safeer Ansari + * @author Jay Choi * @since 4.0.0 */ @ConfigurationProperties("spring.mongodb") @@ -104,10 +107,20 @@ public class MongoProperties { */ private @Nullable String replicaSetName; + /** + * Read concern level for read operations. Supported values are 'local', 'available', + * 'majority', 'linearizable', and 'snapshot'. Overrides the value in 'uri' if set. + */ + private @Nullable String readConcern; + private final Representation representation = new Representation(); private final Ssl ssl = new Ssl(); + private final WriteConcern writeConcern = new WriteConcern(); + + private final ReadPreference readPreference = new ReadPreference(); + public void setProtocol(String protocol) { this.protocol = protocol; } @@ -199,6 +212,14 @@ public void setAdditionalHosts(@Nullable List additionalHosts) { this.additionalHosts = additionalHosts; } + public @Nullable String getReadConcern() { + return this.readConcern; + } + + public void setReadConcern(@Nullable String readConcern) { + this.readConcern = readConcern; + } + public Representation getRepresentation() { return this.representation; } @@ -207,6 +228,14 @@ public Ssl getSsl() { return this.ssl; } + public WriteConcern getWriteConcern() { + return this.writeConcern; + } + + public ReadPreference getReadPreference() { + return this.readPreference; + } + public static class Representation { /** @@ -255,4 +284,101 @@ public void setBundle(@Nullable String bundle) { } + public static class WriteConcern { + + /** + * The w value. Can be 'majority' or a number representing the number of nodes + * that must acknowledge the write. Overrides the write concern in 'uri' if any + * write-concern property is set. + */ + private @Nullable String w; + + /** + * Whether to block until write operations have been committed to the journal. + * Overrides the write concern in 'uri' if any write-concern property is set. + */ + private @Nullable Boolean journal; + + /** + * Timeout for secondaries to acknowledge the write before failing. Overrides the + * write concern in 'uri' if any write-concern property is set. + */ + private @Nullable Duration wTimeout; + + public @Nullable String getW() { + return this.w; + } + + public void setW(@Nullable String w) { + this.w = w; + } + + public @Nullable Boolean getJournal() { + return this.journal; + } + + public void setJournal(@Nullable Boolean journal) { + this.journal = journal; + } + + public @Nullable Duration getWTimeout() { + return this.wTimeout; + } + + public void setWTimeout(@Nullable Duration wTimeout) { + this.wTimeout = wTimeout; + } + + } + + public static class ReadPreference { + + /** + * Read preference mode for read operations. Supported values are 'primary', + * 'primaryPreferred', 'secondary', 'secondaryPreferred', and 'nearest'. Overrides + * the read preference in 'uri' if any read-preference property is set. + */ + private @Nullable String mode; + + /** + * List of tag sets for read preference. Each entry in the list represents a tag + * set with fallback priority. An empty map represents a wildcard tag set that + * matches any replica set member. Overrides the read preference in 'uri' if any + * read-preference property is set. + */ + private @Nullable List> tags; + + /** + * Maximum staleness duration for read preference. The minimum value is 90 + * seconds. Overrides the read preference in 'uri' if any read-preference property + * is set. + */ + private @Nullable Duration maxStaleness; + + public @Nullable String getMode() { + return this.mode; + } + + public void setMode(@Nullable String mode) { + this.mode = mode; + } + + public @Nullable List> getTags() { + return this.tags; + } + + public void setTags(@Nullable List> tags) { + this.tags = tags; + } + + public @Nullable Duration getMaxStaleness() { + return this.maxStaleness; + } + + public void setMaxStaleness(@Nullable Duration maxStaleness) { + this.maxStaleness = maxStaleness; + } + + } + } diff --git a/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/PropertiesMongoConnectionDetails.java b/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/PropertiesMongoConnectionDetails.java index 2b24c0a0644c..1fb15bca8472 100644 --- a/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/PropertiesMongoConnectionDetails.java +++ b/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/PropertiesMongoConnectionDetails.java @@ -18,12 +18,22 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; import com.mongodb.ConnectionString; +import com.mongodb.ReadConcern; +import com.mongodb.ReadConcernLevel; +import com.mongodb.ReadPreference; +import com.mongodb.Tag; +import com.mongodb.TagSet; +import com.mongodb.WriteConcern; import org.jspecify.annotations.Nullable; +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; import org.springframework.boot.mongodb.autoconfigure.MongoProperties.Ssl; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; @@ -37,6 +47,7 @@ * @author Andy Wilkinson * @author Phillip Webb * @author Scott Frederick + * @author Jay Choi * @since 4.0.0 */ public class PropertiesMongoConnectionDetails implements MongoConnectionDetails { @@ -113,6 +124,67 @@ private char[] encode(char[] input) { return SslBundle.systemDefault(); } + @Override + public @Nullable ReadConcern getReadConcern() { + String readConcernLevel = this.properties.getReadConcern(); + if (readConcernLevel == null) { + return null; + } + return new ReadConcern(ReadConcernLevel.fromString(readConcernLevel)); + } + + @Override + public @Nullable WriteConcern getWriteConcern() { + String w = this.properties.getWriteConcern().getW(); + Duration wTimeout = this.properties.getWriteConcern().getWTimeout(); + Boolean journal = this.properties.getWriteConcern().getJournal(); + WriteConcern writeConcern = null; + if (w != null || wTimeout != null || journal != null) { + if (w == null) { + writeConcern = WriteConcern.ACKNOWLEDGED; + } + else { + try { + writeConcern = new WriteConcern(Integer.parseInt(w)); + } + catch (NumberFormatException ex) { + writeConcern = new WriteConcern(w); + } + } + if (wTimeout != null) { + writeConcern = writeConcern.withWTimeout(wTimeout.toMillis(), TimeUnit.MILLISECONDS); + } + if (journal != null) { + writeConcern = writeConcern.withJournal(journal); + } + } + return writeConcern; + } + + @Override + public @Nullable ReadPreference getReadPreference() { + String mode = this.properties.getReadPreference().getMode(); + List> tags = this.properties.getReadPreference().getTags(); + Duration maxStaleness = this.properties.getReadPreference().getMaxStaleness(); + List tagSetList = getTagSets(tags); + if (mode != null) { + if (tagSetList.isEmpty() && maxStaleness == null) { + return ReadPreference.valueOf(mode); + } + else if (maxStaleness == null) { + return ReadPreference.valueOf(mode, tagSetList); + } + else { + return ReadPreference.valueOf(mode, tagSetList, maxStaleness.toSeconds(), TimeUnit.SECONDS); + } + } + else if (!tagSetList.isEmpty() || maxStaleness != null) { + throw new InvalidConfigurationPropertyValueException("spring.mongodb.read-preference.mode", null, + "Read preference mode must be specified if tags or max-staleness is specified"); + } + return null; + } + private List getOptions() { List options = new ArrayList<>(); if (StringUtils.hasText(this.properties.getReplicaSetName())) { @@ -124,4 +196,21 @@ private List getOptions() { return options; } + private List getTagSets(@Nullable List> tags) { + List tagSetList = new ArrayList<>(); + if (tags != null && !tags.isEmpty()) { + for (Map tag : tags) { + if (tag == null || tag.isEmpty()) { + tagSetList.add(new TagSet()); + } + else { + List tagList = new ArrayList<>(); + tag.forEach((key, value) -> tagList.add(new Tag(key, value))); + tagSetList.add(new TagSet(tagList)); + } + } + } + return tagSetList; + } + } diff --git a/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/StandardMongoClientSettingsBuilderCustomizer.java b/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/StandardMongoClientSettingsBuilderCustomizer.java index 86ab98132e5f..ac1f335b35d6 100644 --- a/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/StandardMongoClientSettingsBuilderCustomizer.java +++ b/module/spring-boot-mongodb/src/main/java/org/springframework/boot/mongodb/autoconfigure/StandardMongoClientSettingsBuilderCustomizer.java @@ -17,6 +17,9 @@ package org.springframework.boot.mongodb.autoconfigure; import com.mongodb.MongoClientSettings; +import com.mongodb.ReadConcern; +import com.mongodb.ReadPreference; +import com.mongodb.WriteConcern; import com.mongodb.connection.SslSettings; import org.bson.UuidRepresentation; @@ -31,6 +34,7 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Jay Choi * @since 4.0.0 */ public class StandardMongoClientSettingsBuilderCustomizer implements MongoClientSettingsBuilderCustomizer, Ordered { @@ -52,6 +56,18 @@ public void customize(MongoClientSettings.Builder settingsBuilder) { settingsBuilder.uuidRepresentation(this.uuidRepresentation); settingsBuilder.applyConnectionString(this.connectionDetails.getConnectionString()); settingsBuilder.applyToSslSettings(this::configureSslIfNeeded); + ReadConcern readConcern = this.connectionDetails.getReadConcern(); + if (readConcern != null) { + settingsBuilder.readConcern(readConcern); + } + WriteConcern writeConcern = this.connectionDetails.getWriteConcern(); + if (writeConcern != null) { + settingsBuilder.writeConcern(writeConcern); + } + ReadPreference readPreference = this.connectionDetails.getReadPreference(); + if (readPreference != null) { + settingsBuilder.readPreference(readPreference); + } } private void configureSslIfNeeded(SslSettings.Builder settings) { diff --git a/module/spring-boot-mongodb/src/test/java/org/springframework/boot/mongodb/autoconfigure/MongoAutoConfigurationTests.java b/module/spring-boot-mongodb/src/test/java/org/springframework/boot/mongodb/autoconfigure/MongoAutoConfigurationTests.java index 75712074fc01..8c03b586f2bd 100644 --- a/module/spring-boot-mongodb/src/test/java/org/springframework/boot/mongodb/autoconfigure/MongoAutoConfigurationTests.java +++ b/module/spring-boot-mongodb/src/test/java/org/springframework/boot/mongodb/autoconfigure/MongoAutoConfigurationTests.java @@ -21,6 +21,10 @@ import com.mongodb.ConnectionString; import com.mongodb.MongoClientSettings; import com.mongodb.MongoCredential; +import com.mongodb.ReadConcern; +import com.mongodb.ReadPreference; +import com.mongodb.TaggableReadPreference; +import com.mongodb.WriteConcern; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; import com.mongodb.client.internal.MongoClientImpl; @@ -45,6 +49,7 @@ * @author Dave Syer * @author Stephane Nicoll * @author Scott Frederick + * @author Jay Choi */ class MongoAutoConfigurationTests { @@ -271,6 +276,42 @@ public ConnectionString getConnectionString() { .doesNotHaveBean(PropertiesMongoConnectionDetails.class)); } + @Test + void configuresReadConcern() { + this.contextRunner.withPropertyValues("spring.mongodb.read-concern=majority") + .run((context) -> assertThat(getSettings(context).getReadConcern()).isEqualTo(ReadConcern.MAJORITY)); + } + + @Test + void configuresWriteConcern() { + this.contextRunner + .withPropertyValues("spring.mongodb.write-concern.w=majority", "spring.mongodb.write-concern.journal=true", + "spring.mongodb.write-concern.w-timeout=5s") + .run((context) -> { + WriteConcern writeConcern = getSettings(context).getWriteConcern(); + assertThat(writeConcern).isNotNull(); + assertThat(writeConcern.getWString()).isEqualTo("majority"); + assertThat(writeConcern.getJournal()).isTrue(); + assertThat(writeConcern.getWTimeout(TimeUnit.SECONDS)).isEqualTo(5); + }); + } + + @Test + void configuresReadPreference() { + this.contextRunner + .withPropertyValues("spring.mongodb.read-preference.mode=secondary", + "spring.mongodb.read-preference.tags[0].region=east", + "spring.mongodb.read-preference.max-staleness=90s") + .run((context) -> { + ReadPreference readPreference = getSettings(context).getReadPreference(); + assertThat(readPreference).isNotNull(); + assertThat(readPreference).isInstanceOf(TaggableReadPreference.class); + assertThat(readPreference.getName()).isEqualTo("secondary"); + assertThat(((TaggableReadPreference) readPreference).getTagSetList()).hasSize(1); + assertThat(((TaggableReadPreference) readPreference).getMaxStaleness(TimeUnit.SECONDS)).isEqualTo(90); + }); + } + private MongoClientSettings getSettings(AssertableApplicationContext context) { assertThat(context).hasSingleBean(MongoClient.class); MongoClientImpl client = (MongoClientImpl) context.getBean(MongoClient.class); diff --git a/module/spring-boot-mongodb/src/test/java/org/springframework/boot/mongodb/autoconfigure/PropertiesMongoConnectionDetailsTests.java b/module/spring-boot-mongodb/src/test/java/org/springframework/boot/mongodb/autoconfigure/PropertiesMongoConnectionDetailsTests.java index ac7e4cb23e9b..050cdc9a117f 100644 --- a/module/spring-boot-mongodb/src/test/java/org/springframework/boot/mongodb/autoconfigure/PropertiesMongoConnectionDetailsTests.java +++ b/module/spring-boot-mongodb/src/test/java/org/springframework/boot/mongodb/autoconfigure/PropertiesMongoConnectionDetailsTests.java @@ -16,17 +16,26 @@ package org.springframework.boot.mongodb.autoconfigure; +import java.time.Duration; import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; import com.mongodb.ConnectionString; import com.mongodb.MongoCredential; +import com.mongodb.ReadConcern; +import com.mongodb.ReadPreference; +import com.mongodb.TaggableReadPreference; +import com.mongodb.WriteConcern; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; import org.springframework.boot.ssl.DefaultSslBundleRegistry; import org.springframework.boot.ssl.SslBundle; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.mock; /** @@ -35,6 +44,7 @@ * @author Christoph Dreis * @author Scott Frederick * @author Moritz Halbritter + * @author Jay Choi */ class PropertiesMongoConnectionDetailsTests { @@ -181,4 +191,104 @@ void shouldReturnNullIfSslIsNotEnabled() { assertThat(sslBundle).isNull(); } + @Test + void readConcernCanBeConfigured() { + this.properties.setReadConcern("majority"); + ReadConcern readConcern = this.connectionDetails.getReadConcern(); + assertThat(readConcern).isEqualTo(ReadConcern.MAJORITY); + } + + @Test + void shouldReturnNullWhenReadConcernNotConfigured() { + ReadConcern readConcern = this.connectionDetails.getReadConcern(); + assertThat(readConcern).isNull(); + } + + @Test + void writeConcernCanBeConfiguredWithWAsString() { + this.properties.getWriteConcern().setW("majority"); + WriteConcern writeConcern = this.connectionDetails.getWriteConcern(); + assertThat(writeConcern).isNotNull(); + assertThat(writeConcern.getWString()).isEqualTo("majority"); + } + + @Test + void writeConcernCanBeConfiguredWithWAsNumber() { + this.properties.getWriteConcern().setW("2"); + WriteConcern writeConcern = this.connectionDetails.getWriteConcern(); + assertThat(writeConcern).isNotNull(); + assertThat(writeConcern.getW()).isEqualTo(2); + } + + @Test + void writeConcernCanBeConfiguredWithJournal() { + this.properties.getWriteConcern().setJournal(true); + WriteConcern writeConcern = this.connectionDetails.getWriteConcern(); + assertThat(writeConcern).isNotNull(); + assertThat(writeConcern.getJournal()).isTrue(); + } + + @Test + void writeConcernCanBeConfiguredWithWTimeout() { + this.properties.getWriteConcern().setWTimeout(Duration.ofSeconds(5)); + WriteConcern writeConcern = this.connectionDetails.getWriteConcern(); + assertThat(writeConcern).isNotNull(); + assertThat(writeConcern.getWTimeout(TimeUnit.SECONDS)).isEqualTo(5); + } + + @Test + void shouldReturnNullWhenWriteConcernNotConfigured() { + WriteConcern writeConcern = this.connectionDetails.getWriteConcern(); + assertThat(writeConcern).isNull(); + } + + @Test + void readPreferenceCanBeConfiguredWithMode() { + this.properties.getReadPreference().setMode("secondary"); + ReadPreference readPreference = this.connectionDetails.getReadPreference(); + assertThat(readPreference).isEqualTo(ReadPreference.secondary()); + } + + @Test + void readPreferenceCanBeConfiguredWithModeAndTags() { + this.properties.getReadPreference().setMode("secondary"); + this.properties.getReadPreference().setTags(List.of(Map.of("region", "east"))); + ReadPreference readPreference = this.connectionDetails.getReadPreference(); + assertThat(readPreference).isNotNull(); + assertThat(readPreference).isInstanceOf(TaggableReadPreference.class); + assertThat(readPreference.getName()).isEqualTo("secondary"); + assertThat(((TaggableReadPreference) readPreference).getTagSetList()).hasSize(1); + } + + @Test + void readPreferenceCanBeConfiguredWithModeAndMaxStaleness() { + this.properties.getReadPreference().setMode("secondary"); + this.properties.getReadPreference().setMaxStaleness(Duration.ofSeconds(90)); + ReadPreference readPreference = this.connectionDetails.getReadPreference(); + assertThat(readPreference).isNotNull(); + assertThat(readPreference).isInstanceOf(TaggableReadPreference.class); + assertThat(readPreference.getName()).isEqualTo("secondary"); + assertThat(((TaggableReadPreference) readPreference).getMaxStaleness(TimeUnit.SECONDS)).isEqualTo(90); + } + + @Test + void shouldReturnNullWhenReadPreferenceNotConfigured() { + ReadPreference readPreference = this.connectionDetails.getReadPreference(); + assertThat(readPreference).isNull(); + } + + @Test + void shouldThrowExceptionWhenTagsConfiguredWithoutMode() { + this.properties.getReadPreference().setTags(List.of(Map.of("region", "east"))); + assertThatExceptionOfType(InvalidConfigurationPropertyValueException.class) + .isThrownBy(() -> this.connectionDetails.getReadPreference()); + } + + @Test + void shouldThrowExceptionWhenMaxStalenessConfiguredWithoutMode() { + this.properties.getReadPreference().setMaxStaleness(Duration.ofSeconds(90)); + assertThatExceptionOfType(InvalidConfigurationPropertyValueException.class) + .isThrownBy(() -> this.connectionDetails.getReadPreference()); + } + }