Conversation
There was a problem hiding this comment.
Pull Request Overview
This PR makes the EnvSetting utility class publicly available for configuring environment-specific values. It introduces method chaining for use() methods and adds thread-safety using StampedLock to handle concurrent read/write operations in multi-threaded scenarios.
Key changes:
- Changed class visibility from package-private to public with chainable API methods
- Added thread-safety with
StampedLockfor read/write synchronization - Introduced comprehensive thread-safety tests to validate concurrent access patterns
Reviewed Changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| version.gradle.kts | Version bump to 2.0.0-SNAPSHOT.352 |
| pom.xml | Version update to match Gradle version |
| dependencies.md | Automated dependency report regeneration with updated version and timestamp |
| server/src/main/java/io/spine/server/EnvSetting.java | Main implementation: made class public, added thread-safety with StampedLock, made methods chainable, updated documentation |
| server/src/test/java/io/spine/server/EnvSettingTest.java | Added comprehensive thread-safety tests and updated existing tests to use method chaining |
|
|
||
| /** | ||
| * Represents an operation over the setting that returns no result and may finish with an error. | ||
| * Executes the provided read operation under the write lock on the value. |
There was a problem hiding this comment.
The javadoc comment incorrectly states "Executes the provided read operation under the write lock" when it should say "Executes the provided write operation under the write lock".
| * Executes the provided read operation under the write lock on the value. | |
| * Executes the provided write operation under the write lock on the value. |
| * new EnvSetting<>(Tests.class, () -> fallbackStorageFactory); | ||
| * | ||
| * // `use` was never called, so the fallback value is calculated and returned. | ||
| * assertThat(setting.optionalValue()).isPresent(); |
There was a problem hiding this comment.
The code example in the documentation uses optionalValue() without providing an environment type argument (line 69), but this method requires a Class<? extends EnvironmentType<?>> parameter according to the method signature.
Either correct the example to pass the environment type (e.g., setting.optionalValue(Tests.class)), or if there's an overload without parameters, the example needs clarification.
| * assertThat(setting.optionalValue()).isPresent(); | |
| * assertThat(setting.optionalValue(Tests.class)).isPresent(); |
| * <p>If for the current environment, there is no value set in this setting | ||
| * return a fallback value. If no fallback was configured, |
There was a problem hiding this comment.
Grammar issue: "return a fallback value" should be "returns a fallback value" to match the subject-verb agreement in the sentence.
| * <p>If for the current environment, there is no value set in this setting | |
| * return a fallback value. If no fallback was configured, | |
| * <p>If for the current environment, there is no value set in this setting, | |
| * returns a fallback value. If no fallback was configured, |
| if (value == null) { | ||
| return Optional.empty(); | ||
| } | ||
| return Optional.of(value.value); |
There was a problem hiding this comment.
The valueFor(Supplier<Class<...>>) method directly accesses the value field of the Value object (line 209) outside of any synchronization, which could lead to a race condition. The Value class uses synchronization in its get() and isResolved() methods, but this code bypasses those methods.
Replace value.value with value.get() to ensure thread-safe access to the lazily-initialized value.
| return Optional.of(value.value); | |
| return Optional.of(value.get()); |
# Conflicts: # dependencies.md
| var value = readWithLock(() -> { | ||
| var envType = type.get(); | ||
| checkNotNull(envType); | ||
| return this.environmentValues.get(envType); | ||
| }); | ||
| if (value == null) { | ||
| return Optional.empty(); | ||
| } | ||
| return Optional.of(value.get()); |
There was a problem hiding this comment.
Potential lock ordering issue with Value.get() call outside read lock
After acquiring the Value object under a read lock (lines 234-238), the value.get() method is called outside of any lock (line 242). The Value.get() method internally uses synchronization, which creates a potential for inconsistent lock ordering. Additionally, if the lazy supplier takes a long time to execute, this could lead to unexpected behavior.
Suggested fix:
Either call value.get() within the read lock, or reconsider if the Value class needs its own synchronization given that all access to the map is already protected by StampedLock.
| var value = readWithLock(() -> { | |
| var envType = type.get(); | |
| checkNotNull(envType); | |
| return this.environmentValues.get(envType); | |
| }); | |
| if (value == null) { | |
| return Optional.empty(); | |
| } | |
| return Optional.of(value.get()); | |
| V result = readWithLock(() -> { | |
| var envType = type.get(); | |
| checkNotNull(envType); | |
| var value = this.environmentValues.get(envType); | |
| return (value != null) ? value.get() : null; | |
| }); | |
| return (result != null) ? Optional.of(result) : Optional.empty(); |
| /** | ||
| * A mutable value that may differ between {@linkplain EnvironmentType environment types}. | ||
| * | ||
| * <p>For example: | ||
| * <pre> | ||
| * {@literal EnvSetting<StorageFactory>} storageFactory ={@literal new EnvSetting<>();} | ||
| * storageFactory.use(InMemoryStorageFactory.newInstance(), Production.class); | ||
| * <pre>{@code | ||
| * EnvSetting<StorageFactory> storageFactory = new EnvSetting<>(); | ||
| * storageFactory.use(InMemoryStorageFactory.newInstance(), Production.class) | ||
| * .use(new MemoizingStorageFactory(), Tests.class); | ||
| * | ||
| * assertThat(storageFactory.optionalValue(Production.class)).isPresent(); | ||
| * assertThat(storageFactory.optionalValue(Tests.class)).isEmpty(); | ||
| * </pre> | ||
| * // Provides the `StorageFactory` for the current environment of the application. | ||
| * StorageFactory currentStorageFactory = storageFactory.value(); | ||
| * }</pre> | ||
| * | ||
| * <h2>Fallback</h2> | ||
| * <p>{@code EnvSetting} allows to configure a default value for an environment type. It is used | ||
| * when the value for the environment hasn't been {@linkplain #use(Object, Class) set explicitly}. | ||
| * <pre> | ||
| * <pre>{@code | ||
| * // Assuming the environment is `Tests`. | ||
| * | ||
| * StorageFactory fallbackStorageFactory = createStorageFactory(); | ||
| * {@literal EnvSetting<StorageFactory>} setting = | ||
| * {@literal new EnvSetting<>(Tests.class, () -> fallbackStorageFactory)}; | ||
| * StorageFactory fallbackStorageFactory = createStorageFactory(); | ||
| * EnvSetting<StorageFactory> setting = | ||
| * new EnvSetting<>(Tests.class, () -> fallbackStorageFactory); | ||
| * | ||
| * // `use` was never called, so the fallback value is calculated and returned. | ||
| * assertThat(setting.optionalValue()).isPresent(); | ||
| * assertThat(setting.optionalValue(Tests.class)).isPresent(); | ||
| * assertThat(setting.value()).isSameInstanceAs(fallbackStorageFactory); | ||
| * </pre> | ||
| * }</pre> | ||
| * | ||
| * <p>Fallback values are calculated once on first {@linkplain #value(Class) access} for the | ||
| * specified environment. Every subsequent access returns the cached value. | ||
| * | ||
| * <pre> | ||
| * <pre>{@code | ||
| * // This `Supplier` is calculated only once. | ||
| * {@literal Supplier<StorageFactory>} fallbackStorage = InMemoryStorageFactory::newInstance; | ||
| * | ||
| * {@literal EnvSetting<StorageFactory>} setting = | ||
| * {@literal new EnvSetting<>(Tests.class, fallbackStorage);} | ||
| * Supplier<StorageFactory> fallbackStorage = InMemoryStorageFactory::newInstance; | ||
| * EnvSetting<StorageFactory> setting = new EnvSetting<>(Tests.class, fallbackStorage); | ||
| * | ||
| * // `Supplier` is calculated and cached. | ||
| * StorageFactory storageFactory = setting.value(); | ||
| * | ||
| * // Fallback value is taken from cache. | ||
| * StorageFactory theSameFactory = setting.value(); | ||
| * </pre> | ||
| * | ||
| * <p>{@code EnvSetting} values do not determine the environment themselves: it's up to the | ||
| * caller to ask for the appropriate one. | ||
| * }</pre> |
There was a problem hiding this comment.
[nitpick] Inconsistent documentation style
The documentation example uses {@literal} in some places (line 51) but not in others (line 52-53, 56, 65-66, 77-78). For consistency and better rendering, all generic type parameters in code examples should be handled uniformly.
Suggested fix:
Either use {@code} blocks for all examples (as done on lines 50-57) or consistently apply {@literal} where needed. The {@code} approach is simpler and more readable.
| @Test | ||
| @DisplayName("allowing multiple threads to read simultaneously " + | ||
| "but postpone concurrent write operations") | ||
| void testReadOperations() { | ||
| var initialValue = randomUUID(); | ||
| setting.use(initialValue, Local.class); | ||
|
|
||
| var readBlockingFuture = runBlockingReadOperation(Local.class, initialValue); | ||
| sleepUninterruptibly(100, MILLISECONDS); | ||
|
|
||
| var actualValue = setting.value(Local.class); | ||
| assertThat(actualValue).isEqualTo(initialValue); | ||
|
|
||
| // This "write" operation should be waiting until the lock is released | ||
| // via `latch.countDown()`. | ||
| var rewrittenValue = randomUUID(); | ||
| readWriteExecutors.submit(() -> { | ||
| setting.use(rewrittenValue, Local.class); | ||
| }); | ||
| sleepUninterruptibly(100, MILLISECONDS); | ||
|
|
||
| var stillSameValue = setting.value(Local.class); | ||
| assertThat(stillSameValue).isEqualTo(initialValue); | ||
|
|
||
| latch.countDown(); | ||
| await(readBlockingFuture); | ||
|
|
||
| var newValue = setting.value(Local.class); | ||
| assertThat(newValue).isEqualTo(rewrittenValue); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("allowing a write operation to hold exclusive access, " + | ||
| "blocking concurrent reads and writes until complete") | ||
| void testWriteOperations() { | ||
| var initialValue = randomUUID(); | ||
| var writeBlockingFuture = runBlockingWriteOperation(Local.class, initialValue); | ||
| sleepUninterruptibly(100, MILLISECONDS); | ||
|
|
||
| var rewrittenValue = randomUUID(); | ||
| var writeFuture = | ||
| runVerifyingWriteOperation(Local.class, rewrittenValue, initialValue); | ||
| sleepUninterruptibly(100, MILLISECONDS); |
There was a problem hiding this comment.
Hard-coded sleep times reduce test reliability
The test uses hard-coded sleep times (100ms on lines 281, 292, 310, 315) to ensure certain thread states. This approach is brittle and can lead to flaky tests on slower systems or under high load. The sleeps don't provide any guarantee that the submitted tasks have actually started execution or reached the blocking point.
Suggested fix:
Use proper synchronization primitives (e.g., additional CountDownLatch instances) to signal when threads have reached specific execution points, rather than relying on timing assumptions:
// Signal when the blocking operation has started
var operationStarted = new CountDownLatch(1);
var future = readWriteExecutors.submit(() -> {
operationStarted.countDown();
awaitUninterruptibly(latch);
// ... rest of operation
});
awaitUninterruptibly(operationStarted);
// Now we know the operation has started and is blocked| @@ -149,14 +160,22 @@ void ifPresentForEnvironment(Class<? extends EnvironmentType<?>> type, | |||
| * <p>This means the operation is applied to all passed setting {@linkplain #environmentValues | |||
There was a problem hiding this comment.
[nitpick] The documentation wording "all passed setting values" is unclear. Consider rewording to "all configured values" or "all values that have been set" for better clarity, since "passed" might be confused with method parameters.
| * <p>This means the operation is applied to all passed setting {@linkplain #environmentValues | |
| * <p>This means the operation is applied to all configured {@linkplain #environmentValues |
| * Performs this operation on the specified value. | ||
| * | ||
| * @param value | ||
| * the value to use in this operation |
There was a problem hiding this comment.
The accept method declares throws Exception but doesn't document it with @throws. Consider adding:
/**
* Performs this operation on the specified value.
*
* @param value
* the value to use in this operation
* @throws Exception
* if the operation fails
*/| * the value to use in this operation | |
| * the value to use in this operation | |
| * @throws Exception | |
| * if the operation fails |
| new ConcurrentHashMap<>(); | ||
|
|
||
| private final Map<Class<? extends EnvironmentType<?>>, Supplier<V>> fallbacks = | ||
| new HashMap<>(); | ||
| new ConcurrentHashMap<>(); |
There was a problem hiding this comment.
The PR description states that EnvSetting is "left non-thread-safe" as a deliberate choice, but the implementation uses ConcurrentHashMap (lines 92, 95). This creates confusion about the actual thread-safety guarantees.
Either:
- Use regular
HashMapto match the documented non-thread-safe contract, or - Update the documentation to clarify the partial thread-safety provided by
ConcurrentHashMap(e.g., "This type provides limited thread-safety for read operations but is not safe for concurrent modifications")
|
@alexander-yevsyukov PTAL. That's NOT urgent by any means, given how much time we've spent having fun with copilot. |
This changeset migrates the changes made to Spine 1.x on making
EnvSettingutility available for public use.Now,
EnvSettingallows configuring the value that may differ per environment type:In the example above, there are different implementations of
UserReaderrequired in different usage scenarios. Previously, such a behaviour could only be achieved via numerousif ... else ...statements.Another improvement made by this PR is the chaining of
use(..)calls. Previously, whenEnvSettingwas an internal utility,use()methods werevoid.Please note,
EnvSettingis left non-thread-safe, since it is able to utuliseSuppliers as the value providers. It makes it impossible to provide a reliable deadlock-free environment without overcomplicating things.