diff --git a/src/main/java/net/datafaker/service/FakeValuesService.java b/src/main/java/net/datafaker/service/FakeValuesService.java index 4c74b0377..f675ab03f 100644 --- a/src/main/java/net/datafaker/service/FakeValuesService.java +++ b/src/main/java/net/datafaker/service/FakeValuesService.java @@ -66,7 +66,7 @@ public class FakeValuesService { private static final JsonTransformer JSON_TRANSFORMER = JsonTransformer.builder().build(); - private final Map expression2generex = new CopyOnWriteMap<>(WeakHashMap::new); + private static final Map expression2generex = new CopyOnWriteMap<>(WeakHashMap::new); private final CopyOnWriteMap> key2Expression = new CopyOnWriteMap<>(IdentityHashMap::new); private static final Map ARGS_2_SPLITTED_ARGS = new CopyOnWriteMap<>(WeakHashMap::new); @@ -81,7 +81,17 @@ public class FakeValuesService { private static final Map EXPRESSION_2_SPLITTED = new CopyOnWriteMap<>(WeakHashMap::new); - private final Map REGEXP2SUPPLIER_MAP = new CopyOnWriteMap<>(HashMap::new); + /** + * L1: static recipe cache — context-free resolvers shared across all Fakers with same locale. + * Growth is bounded in practice by unique YAML expressions × locales. User-supplied dynamic + * expressions via {@code faker.expression()} carry the same theoretical unbounded-growth exposure + * as the other static string caches in this class (NAME_2_YAML, EXPRESSION_2_SPLITTED, etc.). + */ + private static final Map RECIPE_MAP = new CopyOnWriteMap<>(HashMap::new); + + /** L2: per-instance materialized cache — resolvers pre-bound to this Faker's providers for fast repeated calls. */ + private final Map instanceMap = new CopyOnWriteMap<>(HashMap::new); + public void updateFakeValuesInterfaceMap(List locales) { for (final SingletonLocale l : locales) { fakeValuesInterfaceMap.computeIfAbsent(l, this::getCachedFakeValue); @@ -165,23 +175,22 @@ public String fetchString(String key, FakerContext context) { return (String) fetch(key, context); } - private class SafeFetchResolver implements ValueResolver { + private static class SafeFetchResolver implements ValueResolver { private final String simpleDirective; - private final FakerContext context; - private SafeFetchResolver(String simpleDirective, FakerContext context) { + private SafeFetchResolver(String simpleDirective) { this.simpleDirective = simpleDirective; - this.context = context; } @Override - public Object resolve() { - return safeFetch(simpleDirective, context, null); + public Object resolve(ProviderRegistration root, FakerContext context) { + if (root == null) return null; + return root.fakeValuesService().safeFetch(simpleDirective, context, null); } @Override public String toString() { - return "%s[simpleDirective=%s, context=%s]".formatted(getClass().getSimpleName(), simpleDirective, context); + return "%s[simpleDirective=%s]".formatted(getClass().getSimpleName(), simpleDirective); } } @@ -593,20 +602,35 @@ protected String resolveExpression(String expression, Object current, ProviderRe } continue; } - final RegExpContext regExpContext = new RegExpContext(expr, root, context); - final ValueResolver val = REGEXP2SUPPLIER_MAP.get(regExpContext); final Object resolved; - if (val != null) { - resolved = val.resolve(); + // L2: per-instance hit — fast, provider already bound + final ValueResolver fast = instanceMap.get(expr); + if (fast != null) { + resolved = fast.resolve(root, context); } else { - int j = 0; - final int length = expr.length(); - while (j < length && !Character.isWhitespace(expr.charAt(j))) j++; - String directive = expr.substring(0, j); - while (j < length && Character.isWhitespace(expr.charAt(j))) j++; - final String arguments = j == length ? "" : expr.substring(j); - final String[] args = splitArguments(arguments); - resolved = resExp(directive, args, current, root, context, regExpContext); + final CacheKey cacheKey = new CacheKey(expr, context.getSingletonLocale()); + // L1: static recipe hit — materialize once, store in L2 + final ValueResolver recipe = RECIPE_MAP.get(cacheKey); + if (recipe != null) { + final ValueResolver materialized = root != null ? recipe.materialize(root) : recipe; + if (root != null) instanceMap.put(expr, materialized); + resolved = materialized.resolve(root, context); + } else { + // Both miss: full discovery + int j = 0; + final int length = expr.length(); + while (j < length && !Character.isWhitespace(expr.charAt(j))) j++; + String directive = expr.substring(0, j); + while (j < length && Character.isWhitespace(expr.charAt(j))) j++; + final String arguments = j == length ? "" : expr.substring(j); + final String[] args = splitArguments(arguments); + resolved = resolveExpression(directive, args, current, root, context, cacheKey); + // resolveExpression stored recipe in RECIPE_MAP if cacheable; materialize for L2 + final ValueResolver stored = RECIPE_MAP.get(cacheKey); + if (stored != null && root != null) { + instanceMap.put(expr, stored.materialize(root)); + } + } } if (resolved == null) { throw new RuntimeException("Unable to resolve #{" + expr + "} directive for FakerContext " + context + "."); @@ -681,12 +705,12 @@ private String[] splitExpressions(String expression, int length) { }); } - private Object resExp(String directive, String[] args, Object current, ProviderRegistration root, FakerContext context, RegExpContext regExpContext) { + private Object resolveExpression(String directive, String[] args, Object current, ProviderRegistration root, FakerContext context, CacheKey cacheKey) { Object res = resolveExpression(directive, args, current, root, context); - LOG.fine(() -> "resExp(%s [%s]) current: %s, root: %s, context: %s, regExpContext: %s -> res: %s".formatted(directive, Arrays.toString(args), current, root, context, regExpContext, res)); + LOG.fine(() -> "resolveExpression(%s [%s]) current: %s, root: %s, context: %s, cacheKey: %s -> res: %s".formatted(directive, Arrays.toString(args), current, root, context, cacheKey, res)); if (res instanceof CharSequence) { if (((CharSequence) res).isEmpty()) { - REGEXP2SUPPLIER_MAP.put(regExpContext, EMPTY_STRING); + RECIPE_MAP.put(cacheKey, EMPTY_STRING); } return res; } @@ -696,11 +720,13 @@ private Object resExp(String directive, String[] args, Object current, ProviderR Object valueResolver = it.next(); Object value; if (valueResolver instanceof ValueResolver resolver) { - value = resolver.resolve(); + value = resolver.resolve(root, context); if (value == null) { it.remove(); } else { - REGEXP2SUPPLIER_MAP.put(regExpContext, resolver); + if (resolver.cacheable()) { + RECIPE_MAP.put(cacheKey, resolver); + } return value; } } @@ -733,19 +759,19 @@ private Object resolveExpression(String directive, String[] args, Object current if (current instanceof AbstractProvider) { final Method method = BaseFaker.getMethod((AbstractProvider) current, directive); if (method != null) { - res.add(new MethodResolver(method, current, args)); + res.add(new ProviderMethodResolver(current.getClass().getSimpleName(), method, args)); return res; } } - res.add(resolveFromMethodOn(current, directive, args)); + res.add(resolveFromMethodOn(current, directive, args, root)); } - if (dotIndex > 0) { + if (dotIndex > 0 && root != null) { String providerClassName = directive.substring(0, dotIndex); String methodName = directive.substring(dotIndex + 1); AbstractProvider ap = root.getProvider(providerClassName); Method method = ap == null ? null : ObjectMethods.getMethodByName(ap, methodName); if (method != null) { - res.add(new MethodResolver(method, ap, args)); + res.add(new ProviderMethodResolver(providerClassName, method, args)); return res; } } @@ -758,12 +784,12 @@ private Object resolveExpression(String directive, String[] args, Object current // car.wheel will be looked up in the YAML file. // It's only "simple" if there aren't args if (args.length == 0) { - res.add(new SafeFetchResolver(simpleDirective, context)); + res.add(new SafeFetchResolver(simpleDirective)); } // resolve method references on faker object like #{regexify '[a-z]'} if (dotIndex == -1 && root != null && (current == null || root.getClass() != current.getClass())) { - res.add(resolveFromMethodOn(root, directive, args)); + res.add(resolveFromMethodOn(root, directive, args, root)); } // Resolve Faker Object method references like #{ClassName.method_name} @@ -778,7 +804,7 @@ private Object resolveExpression(String directive, String[] args, Object current // class.method_name (lowercase) if (dotIndex >= 0) { final String key = javaNameToYamlName(simpleDirective); - res.add(new SafeFetchResolver(key, context)); + res.add(new SafeFetchResolver(key)); } return res; @@ -860,12 +886,23 @@ private String javaNameToYamlName(String expression) { * {@link Name} then this method would return {@link Name#firstName()}. Returns null if the directive is nested * (i.e. has a '.') or the method doesn't exist on the obj object. */ - private ValueResolver resolveFromMethodOn(Object obj, String directive, String[] args) { + private ValueResolver resolveFromMethodOn(Object obj, String directive, String[] args, ProviderRegistration root) { if (obj == null) { return null; } final MethodAndCoercedArgs accessor = retrieveMethodAccessor(obj, directive, args); - return accessor == null ? NULL_VALUE : new MethodAndCoercedArgsResolver(accessor, obj); + if (accessor == null) return NULL_VALUE; + if (obj instanceof ProviderRegistration) { + return new RootCoercedResolver(accessor); + } + if (obj instanceof AbstractProvider && root != null) { + String providerName = obj.getClass().getSimpleName(); + Object registered = root.getProvider(providerName); + if (registered != null && registered.getClass() == obj.getClass()) { + return new NamedProviderCoercedResolver(providerName, accessor); + } + } + return new InstanceCoercedResolver(accessor, obj); } /** @@ -896,7 +933,7 @@ private ValueResolver resolveFakerObjectAndMethod(ProviderRegistration faker, St return NULL_VALUE; } - return new MethodAndCoercedArgsResolver(accessor, objectWithMethodToInvoke); + return new ChainedCoercedResolver(fakerAccessor, accessor); } catch (InvocationTargetException | IllegalAccessException e) { throw new RuntimeException("Failed to resolve faker object and method for %s (dotIndex=%s, args=%s)" .formatted(key, dotIndex, Arrays.toString(args)), e); @@ -1148,16 +1185,19 @@ public String toString() { } } - private record RegExpContext(String exp, ProviderRegistration root, FakerContext context) { + private record CacheKey(String exp, SingletonLocale locale) { } private interface ValueResolver { - Object resolve(); + Object resolve(ProviderRegistration root, FakerContext context); + default boolean cacheable() { return true; } + /** Produce a fast per-instance resolver with provider pre-bound. Default: self (already fast or context-dependent). */ + default ValueResolver materialize(ProviderRegistration root) { return this; } } private record ConstantResolver(String value) implements ValueResolver { @Override - public Object resolve() { + public Object resolve(ProviderRegistration root, FakerContext context) { return value; } } @@ -1165,39 +1205,115 @@ public Object resolve() { private static final ConstantResolver EMPTY_STRING = new ConstantResolver(""); private static final ConstantResolver NULL_VALUE = new ConstantResolver(null); - private record MethodResolver(Method method, Object current, Object[] args) implements ValueResolver { + /** L2: fast resolver — method pre-bound to a specific provider instance. Never stored in L1. */ + private record InstanceMethodResolver(Object provider, Method method, Object[] args) implements ValueResolver { @Override - public Object resolve() { + public Object resolve(ProviderRegistration root, FakerContext context) { try { - return method.invoke(current); + return method.invoke(provider); } catch (Exception e) { throw new RuntimeException("Failed to call method %s.%s() on %s (args: %s)".formatted( - method.getDeclaringClass().getName(), method.getName(), current, Arrays.toString(args)), e); + method.getDeclaringClass().getName(), method.getName(), provider, Arrays.toString(args)), e); } } + @Override + public boolean cacheable() { return false; } + } + + /** L1 recipe: resolves a no-arg method on a named provider looked up from root at call time. */ + private record ProviderMethodResolver(String providerName, Method method, Object[] args) implements ValueResolver { + @Override + public Object resolve(ProviderRegistration root, FakerContext context) { + if (root == null) return null; + return new InstanceMethodResolver(root.getProvider(providerName), method, args).resolve(root, context); + } + + @Override + public ValueResolver materialize(ProviderRegistration root) { + if (root == null) return this; + return new InstanceMethodResolver(root.getProvider(providerName), method, args); + } + @Override public String toString() { - return "%s[method=%s.%s(), current=%s, args=%s]".formatted(getClass().getSimpleName(), - method.getDeclaringClass().getSimpleName(), method.getName(), current, Arrays.toString(args)); + return "%s[provider=%s, method=%s.%s(), args=%s]".formatted(getClass().getSimpleName(), + providerName, method.getDeclaringClass().getSimpleName(), method.getName(), Arrays.toString(args)); } } - private record MethodAndCoercedArgsResolver(MethodAndCoercedArgs accessor, Object obj) implements ValueResolver { + /** L1 recipe: resolves a coerced-args method directly on the root ProviderRegistration. */ + private record RootCoercedResolver(MethodAndCoercedArgs accessor) implements ValueResolver { + @Override + public Object resolve(ProviderRegistration root, FakerContext context) { + if (root == null) return null; + return invokeCoerced(accessor, root); + } + @Override - public Object resolve() { - return invokeAndToString(accessor, obj); + public ValueResolver materialize(ProviderRegistration root) { + if (root == null) return this; + return new InstanceCoercedResolver(accessor, root); } + } - private static Object invokeAndToString(MethodAndCoercedArgs accessor, Object objectWithMethodToInvoke) { + /** L1 recipe: resolves a coerced-args method on a named provider looked up from root at call time. */ + private record NamedProviderCoercedResolver(String providerName, MethodAndCoercedArgs accessor) implements ValueResolver { + @Override + public Object resolve(ProviderRegistration root, FakerContext context) { + if (root == null) return null; + return invokeCoerced(accessor, root.getProvider(providerName)); + } + + @Override + public ValueResolver materialize(ProviderRegistration root) { + if (root == null) return this; + return new InstanceCoercedResolver(accessor, root.getProvider(providerName)); + } + } + + /** L1 recipe: two-step chain — invokes fakerAccessor on root to get provider, then accessor on it. */ + private record ChainedCoercedResolver(MethodAndCoercedArgs fakerAccessor, MethodAndCoercedArgs accessor) implements ValueResolver { + @Override + public Object resolve(ProviderRegistration root, FakerContext context) { + if (root == null) return null; + try { + return invokeCoerced(accessor, fakerAccessor.invoke(root)); + } catch (InvocationTargetException | IllegalAccessException e) { + throw new RuntimeException("Failed to invoke chained resolver on %s".formatted(root), unwrap(e)); + } + } + + @Override + public ValueResolver materialize(ProviderRegistration root) { + if (root == null) return this; try { - return accessor.invoke(objectWithMethodToInvoke); + return new InstanceCoercedResolver(accessor, fakerAccessor.invoke(root)); } catch (InvocationTargetException | IllegalAccessException e) { - throw new RuntimeException("Failed to invoke %s on %s".formatted(accessor, objectWithMethodToInvoke), unwrap(e)); + throw new RuntimeException("Failed to materialize chained resolver on %s".formatted(root), unwrap(e)); } } } + /** L2: fast resolver — coerced method pre-bound to a specific instance. Also used for non-registered providers (never in L1). */ + private record InstanceCoercedResolver(MethodAndCoercedArgs accessor, Object instance) implements ValueResolver { + @Override + public Object resolve(ProviderRegistration root, FakerContext context) { + return invokeCoerced(accessor, instance); + } + + @Override + public boolean cacheable() { return false; } + } + + private static Object invokeCoerced(MethodAndCoercedArgs accessor, Object target) { + try { + return accessor.invoke(target); + } catch (InvocationTargetException | IllegalAccessException e) { + throw new RuntimeException("Failed to invoke %s on %s".formatted(accessor, target), unwrap(e)); + } + } + private static Throwable unwrap(Throwable e) { return e instanceof InvocationTargetException reflection ? unwrap(reflection.getTargetException()) : e; } diff --git a/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java b/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java new file mode 100644 index 000000000..301a1fb76 --- /dev/null +++ b/src/test/java/net/datafaker/SharedFakeValuesServiceTest.java @@ -0,0 +1,92 @@ +package net.datafaker; + +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 concurrentFakersProduceNoErrors() throws Exception { + 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 = new Faker(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(); + if (!pool.awaitTermination(120, TimeUnit.SECONDS)) { + pool.shutdownNow(); + throw new AssertionError("Thread pool did not terminate within timeout"); + } + for (Future f : futures) { + f.get(); + } + } + + @Test + void multipleLocalesConcurrentlyProduceNoErrors() throws Exception { + Locale[] locales = {Locale.ENGLISH, Locale.FRENCH, Locale.GERMAN, Locale.forLanguageTag("es")}; + int threads = locales.length * 4; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CyclicBarrier barrier = new CyclicBarrier(threads); + List> futures = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + final Locale locale = locales[i % locales.length]; + futures.add(pool.submit(() -> { + barrier.await(); + Faker faker = new Faker(locale, new Random()); + for (int j = 0; j < 1_000; j++) { + assertThat(faker.name().fullName()).isNotNull(); + assertThat(faker.address().city()).isNotNull(); + } + return null; + })); + } + pool.shutdown(); + if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { + pool.shutdownNow(); + throw new AssertionError("Thread pool did not terminate within timeout"); + } + for (Future f : futures) { + f.get(); + } + } + + @Test + void deterministicOutputUnaffectedByCaching() { + long seed = 12345L; + Faker first = new Faker(Locale.ENGLISH, new Random(seed)); + String firstName = first.name().firstName(); + String city = first.address().city(); + String email = first.internet().emailAddress(); + + // A second Faker with the same seed must produce the same output regardless of cache state + Faker second = new Faker(Locale.ENGLISH, new Random(seed)); + assertThat(second.name().firstName()).isEqualTo(firstName); + assertThat(second.address().city()).isEqualTo(city); + assertThat(second.internet().emailAddress()).isEqualTo(email); + } +}