From 78552fb6722fd0e3e05a74efb0262232621673a9 Mon Sep 17 00:00:00 2001 From: Marco Ferretti Date: Mon, 18 May 2026 22:47:44 +0200 Subject: [PATCH 1/2] Add shared FakeValuesService for multi-threaded Faker instances Closes #1814 This allows multiple `Faker` instances to share a single, pre-initialized `FakeValuesService` per locale. Each `Faker` can then supply its own `Random` instance, avoiding redundant YAML loading and improving performance in concurrent scenarios. --- src/main/java/net/datafaker/Faker.java | 13 ++++ .../datafaker/service/FakeValuesService.java | 12 +++ .../SharedFakeValuesServiceTest.java | 78 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/test/java/net/datafaker/SharedFakeValuesServiceTest.java diff --git a/src/main/java/net/datafaker/Faker.java b/src/main/java/net/datafaker/Faker.java index 5902f007c..4900d5996 100644 --- a/src/main/java/net/datafaker/Faker.java +++ b/src/main/java/net/datafaker/Faker.java @@ -42,4 +42,17 @@ public Faker(FakeValuesService fakeValuesService, FakerContext context) { public Faker(FakeValuesService fakeValuesService, FakerContext context, Predicate> whiteListPredicate) { super(fakeValuesService, context, whiteListPredicate); } + + /** + * Creates a {@link Faker} backed by a shared {@link FakeValuesService}. + * Use in multi-threaded scenarios where all threads share one service per locale + * and each supplies its own {@link Random} to avoid redundant YAML loading. + * + * @param sharedService a pre-initialized service, e.g. from {@link FakeValuesService#getShared(Locale)} + * @param locale locale for this Faker instance + * @param random per-thread random source + */ + public static Faker withSharedService(FakeValuesService sharedService, Locale locale, Random random) { + return new Faker(sharedService, new FakerContext(locale, new RandomService(random))); + } } diff --git a/src/main/java/net/datafaker/service/FakeValuesService.java b/src/main/java/net/datafaker/service/FakeValuesService.java index 506daa85a..dce9fc7d1 100644 --- a/src/main/java/net/datafaker/service/FakeValuesService.java +++ b/src/main/java/net/datafaker/service/FakeValuesService.java @@ -38,6 +38,7 @@ import java.util.Map; import java.util.Objects; import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import java.util.logging.Level; import java.util.logging.Logger; @@ -83,7 +84,18 @@ public class FakeValuesService { private static final Map EXPRESSION_2_SPLITTED = new CopyOnWriteMap<>(WeakHashMap::new); + private static final ConcurrentHashMap SHARED_INSTANCES = new ConcurrentHashMap<>(); + private final Map REGEXP2SUPPLIER_MAP = new CopyOnWriteMap<>(HashMap::new); + + /** + * Returns a lazily-initialized per-locale singleton. Safe to share across threads: + * all mutable instance state uses idempotent copy-on-write caches. + */ + public static FakeValuesService getShared(Locale locale) { + return SHARED_INSTANCES.computeIfAbsent(locale, l -> new FakeValuesService()); + } + public void updateFakeValuesInterfaceMap(List locales) { for (final SingletonLocale l : locales) { fakeValuesInterfaceMap.computeIfAbsent(l, this::getCachedFakeValue); diff --git a/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java b/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java new file mode 100644 index 000000000..a99784ac1 --- /dev/null +++ b/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java @@ -0,0 +1,78 @@ +package net.datafaker; + +import net.datafaker.service.FakeValuesService; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class SharedFakeValuesServiceTest { + + @Test + void getSharedReturnsSameInstanceUnderConcurrency() throws Exception { + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CyclicBarrier barrier = new CyclicBarrier(threads); + List> futures = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + futures.add(pool.submit(() -> { + barrier.await(); + return FakeValuesService.getShared(Locale.ENGLISH); + })); + } + pool.shutdown(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + FakeValuesService expected = futures.get(0).get(); + for (Future f : futures) { + assertThat(f.get()).isSameAs(expected); + } + } + + @Test + void concurrentFakersWithSharedServiceProduceNoErrors() throws Exception { + FakeValuesService shared = FakeValuesService.getShared(Locale.ENGLISH); + int threads = 16; + int iterations = 10_000; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CyclicBarrier barrier = new CyclicBarrier(threads); + List> futures = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + final long seed = i; + futures.add(pool.submit(() -> { + barrier.await(); + Faker faker = Faker.withSharedService(shared, Locale.ENGLISH, new Random(seed)); + for (int j = 0; j < iterations; j++) { + assertThat(faker.name().fullName()).isNotNull(); + assertThat(faker.address().city()).isNotNull(); + assertThat(faker.internet().emailAddress()).isNotNull(); + } + return null; + })); + } + pool.shutdown(); + assertThat(pool.awaitTermination(120, TimeUnit.SECONDS)).isTrue(); + for (Future f : futures) { + f.get(); + } + } + + @Test + void withSharedServiceOutputMatchesNormalFaker() { + long seed = 12345L; + Faker normal = new Faker(Locale.ENGLISH, new Random(seed)); + Faker shared = Faker.withSharedService( + FakeValuesService.getShared(Locale.ENGLISH), Locale.ENGLISH, new Random(seed)); + assertThat(shared.name().firstName()).isEqualTo(normal.name().firstName()); + assertThat(shared.address().city()).isEqualTo(normal.address().city()); + assertThat(shared.internet().emailAddress()).isEqualTo(normal.internet().emailAddress()); + } +} From d3f174e06c2b6f5607458709f0b9207aa8cdd513 Mon Sep 17 00:00:00 2001 From: Marco Ferretti Date: Tue, 19 May 2026 19:09:00 +0200 Subject: [PATCH 2/2] Enforce read-only contract on shared FakeValuesService instances addPath and addUrl are non-idempotent mutators: calling them on a shared instance would silently affect every consumer of that singleton. Address this by adding a volatile boolean `shared` flag set by getShared(), and guarding both methods with an UnsupportedOperationException fail-fast. Also rewrites the getShared Javadoc to accurately describe the design: the locale parameter is a cache-partition key (not a constructor arg), YAML is loaded lazily by BaseFaker after construction, and mixing locales between getShared and withSharedService is unsupported. Adds sharedInstanceRejectsAddPathAndAddUrl test to cover both guards. --- .../datafaker/service/FakeValuesService.java | 28 +++++++++++++++---- .../SharedFakeValuesServiceTest.java | 11 ++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/datafaker/service/FakeValuesService.java b/src/main/java/net/datafaker/service/FakeValuesService.java index dce9fc7d1..312069a3c 100644 --- a/src/main/java/net/datafaker/service/FakeValuesService.java +++ b/src/main/java/net/datafaker/service/FakeValuesService.java @@ -86,14 +86,28 @@ public class FakeValuesService { private static final ConcurrentHashMap SHARED_INSTANCES = new ConcurrentHashMap<>(); + private volatile boolean shared = false; + private final Map REGEXP2SUPPLIER_MAP = new CopyOnWriteMap<>(HashMap::new); /** - * Returns a lazily-initialized per-locale singleton. Safe to share across threads: - * all mutable instance state uses idempotent copy-on-write caches. + * Returns a lazily-initialized per-locale singleton safe to share across threads. + *

+ * The {@code locale} is used as a cache-partition key: all callers passing the same + * locale receive the same instance. Locale-specific YAML data is loaded lazily the + * first time a Faker backed by this service is constructed, so there is no need to + * pre-warm the instance. + *

+ * Shared instances are read-only: {@link #addPath} and {@link #addUrl} will throw + * {@link UnsupportedOperationException}. Mixing locales — e.g. passing + * {@code getShared(Locale.ENGLISH)} to + * {@link net.datafaker.Faker#withSharedService(FakeValuesService, Locale, Random)} + * with a different locale — is unsupported. */ public static FakeValuesService getShared(Locale locale) { - return SHARED_INSTANCES.computeIfAbsent(locale, l -> new FakeValuesService()); + FakeValuesService svc = SHARED_INSTANCES.computeIfAbsent(locale, l -> new FakeValuesService()); + svc.shared = true; + return svc; } public void updateFakeValuesInterfaceMap(List locales) { @@ -115,9 +129,11 @@ private FakeValuesInterface getCachedFakeValue(SingletonLocale locale) { * * @param locale the locale for which a path is going to be added. * @param path path to a file with YAML structure - * @throws IllegalArgumentException in case of invalid path + * @throws IllegalArgumentException in case of invalid path + * @throws UnsupportedOperationException if called on a shared instance obtained via {@link #getShared(Locale)} */ public void addPath(Locale locale, Path path) { + if (shared) throw new UnsupportedOperationException("addPath cannot be called on a shared FakeValuesService"); requireNonNull(locale); if (path == null || Files.notExists(path) || Files.isDirectory(path) || !Files.isReadable(path)) { throw new IllegalArgumentException("Path should be an existing readable file: \"%s\"".formatted(path)); @@ -134,9 +150,11 @@ public void addPath(Locale locale, Path path) { * * @param locale the locale for which an url is going to be added. * @param url url of a file with YAML structure - * @throws IllegalArgumentException in case of invalid url + * @throws IllegalArgumentException in case of invalid url + * @throws UnsupportedOperationException if called on a shared instance obtained via {@link #getShared(Locale)} */ public void addUrl(Locale locale, URL url) { + if (shared) throw new UnsupportedOperationException("addUrl cannot be called on a shared FakeValuesService"); requireNonNull(locale); if (url == null) { throw new IllegalArgumentException("url should be an existing readable file"); diff --git a/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java b/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java index a99784ac1..326b92d9f 100644 --- a/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java +++ b/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java @@ -14,6 +14,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class SharedFakeValuesServiceTest { @@ -65,6 +66,16 @@ void concurrentFakersWithSharedServiceProduceNoErrors() throws Exception { } } + @Test + void sharedInstanceRejectsAddPathAndAddUrl() throws Exception { + FakeValuesService shared = FakeValuesService.getShared(Locale.GERMAN); + assertThatThrownBy(() -> shared.addPath(Locale.GERMAN, java.nio.file.Path.of("nonexistent.yml"))) + .isInstanceOf(UnsupportedOperationException.class); + java.net.URL url = new java.net.URI("file:///nonexistent.yml").toURL(); + assertThatThrownBy(() -> shared.addUrl(Locale.GERMAN, url)) + .isInstanceOf(UnsupportedOperationException.class); + } + @Test void withSharedServiceOutputMatchesNormalFaker() { long seed = 12345L;