diff --git a/server/src/main/java/org/elasticsearch/common/settings/Setting.java b/server/src/main/java/org/elasticsearch/common/settings/Setting.java index 1ccaeee626fc9..a5e06e8ace83f 100644 --- a/server/src/main/java/org/elasticsearch/common/settings/Setting.java +++ b/server/src/main/java/org/elasticsearch/common/settings/Setting.java @@ -22,6 +22,7 @@ import org.elasticsearch.common.xcontent.XContentParserUtils; import org.elasticsearch.core.Booleans; import org.elasticsearch.core.Nullable; +import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.core.TimeValue; import org.elasticsearch.core.Tuple; import org.elasticsearch.core.UpdateForV10; @@ -34,6 +35,10 @@ import org.elasticsearch.xcontent.XContentType; import java.io.IOException; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.WrongMethodTypeException; +import java.lang.reflect.RecordComponent; import java.math.BigInteger; import java.time.Instant; import java.util.Arrays; @@ -58,6 +63,9 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static java.lang.invoke.MethodType.methodType; +import static java.util.stream.Collectors.toMap; + /** * A setting. Encapsulates typical stuff like default value, parsing, and scope. * Some (SettingsProperty.Dynamic) can by modified at run time using the API. @@ -1290,12 +1298,207 @@ public void apply(Settings value, Settings current, Settings previous) { @Override public String toString() { - return "Updater for: " + setting.toString(); + return "Updater for: " + setting; } }; } } + private static class RecordSetting extends Setting { + private final String key; + private final Consumer validator; + private final Class recordClass; + private final List> componentParsers; + private final MethodHandle ctor; + + RecordSetting( + String key, + Class recordClass, + MethodHandles.Lookup lookup, + List> relevantSettings, + Property... properties + ) { + super(new GroupKey(keyWithDot(key)), (s) -> "", (s) -> null, properties); + this.key = key; + this.recordClass = recordClass; + this.validator = r -> {}; // TODO: validator + var recordComponents = recordClass.getRecordComponents(); + var ctorArgTypes = Arrays.stream(recordComponents).map(RecordComponent::getType).toArray(Class[]::new); + try { + ctor = lookup.findConstructor(recordClass, methodType(void.class, ctorArgTypes)); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new AssertionError("wtf yo", e); + } + Map> relevantSettingsByKey = relevantSettings.stream().collect(toMap(Setting::getKey, Function.identity())); + this.componentParsers = Arrays.stream(recordComponents) + .map(component -> componentParser(component, lookup, relevantSettingsByKey)) + .toList(); + assert ctorArgTypes.length == componentParsers.size(); + } + + private static String keyWithDot(String key) { + if (key.endsWith(".")) { + throw new IllegalArgumentException("key must not end with a '.'"); + } + return key + "."; + } + + @Override + public boolean isGroupSetting() { + return true; + } + + @Override + public String innerGetRaw(final Settings settings) { + throw new UnsupportedOperationException(); + } + + private Settings getByPrefix(Settings settings) { + return settings.getByPrefix(getKey()); + } + + @Override + public R get(Settings settings) { + // TODO should we be checking for deprecations here? + R result = invokeConstructor(componentParsers.stream().map(p -> p.apply(settings)).toArray()); + validator.accept(result); + return result; + } + + @Override + public void diff(Settings.Builder builder, Settings source, Settings defaultSettings) { + Set leftGroup = getByPrefix(source).keySet(); + Settings defaultGroup = getByPrefix(defaultSettings); + + builder.put( + Settings.builder().put(defaultGroup.filter(k -> leftGroup.contains(k) == false), false).normalizePrefix(getKey()).build(), + false + ); + } + + @Override + public AbstractScopedSettings.SettingUpdater newUpdater(Consumer consumer, Logger logger, Consumer validator) { + return new AbstractScopedSettings.SettingUpdater() { + @Override + public boolean hasChanged(Settings current, Settings previous) { + return false == get(current).equals(get(previous)); + } + + @Override + public R getValue(Settings current, Settings previous) { + // TODO: What am I supposed to do with previous? Javadocs don't say + R result = get(current); + validator.accept(result); + return result; + } + + @Override + public void apply(R value, Settings current, Settings previous) { + consumer.accept(value); + } + + @Override + public String toString() { + return "Updater for record: " + recordClass.getName(); + } + }; + } + + @SuppressForbidden(reason = "To support arbitrary record types, we must invoke the constructor with invokeWithArguments") + private R invokeConstructor(Object[] ctorArgs) { + Object result; + try { + result = ctor.invokeWithArguments(ctorArgs); + } catch (ClassCastException | WrongMethodTypeException e) { + throw new IllegalStateException("Unexpected error invoking constructor for [" + recordClass.getName() + "]", e); + } catch (Throwable e) { + throw new IllegalStateException("Unable to instantiate [" + recordClass.getName() + "]", e); + } + return recordClass.cast(result); + } + + /** + * @return a "compiled executable" form of the given {@link RecordComponent}: + * extracts the component's value from a given {@link Settings}. + */ + private Function componentParser( + RecordComponent component, + MethodHandles.Lookup lookup, + Map> relevantSettingsByKey + ) { + String componentKey = key + "." + snakeCase(component.getName()); + Class type = component.getType(); + + var setting = relevantSettingsByKey.get(componentKey); + if (setting != null) { + return setting::get; + } + + // No relevant Setting provided by the user. + // That means they're ok with the component being considered mandatory, + // and parsed based on its type alone. + + // For scalar types, we can get the setting as a string and then convert + if (type == String.class) { + return settings -> getMandatory(settings, componentKey); + } else if (type == int.class || type == Integer.class) { + return settings -> Integer.parseInt(getMandatory(settings, componentKey)); + } else if (type == long.class || type == Long.class) { + return settings -> Long.parseLong(getMandatory(settings, componentKey)); + } else if (type == float.class || type == Float.class) { + return settings -> Float.parseFloat(getMandatory(settings, componentKey)); + } else if (type == double.class || type == Double.class) { + return settings -> Double.parseDouble(getMandatory(settings, componentKey)); + } + + // For structured types, we're going to need a bigger boat + if (List.class.isAssignableFrom(type)) { + return settings -> settings.getAsList(componentKey); + } else if (Record.class.isAssignableFrom(type)) { + // Recurse + Property[] componentProperties = super.getProperties().toArray(new Property[0]); // TODO: Does it make sense to inherit + // these properties? + return settingsForComponent(component, componentKey, lookup, relevantSettingsByKey, componentProperties)::get; + } else { + // TODO: MOAR + throw new IllegalArgumentException("Not yet supported: " + type); + } + } + + private String snakeCase(String name) { + StringBuilder sb = new StringBuilder(); + name.codePoints().forEachOrdered(cp -> { + if ('A' <= cp && cp <= 'Z') { + sb.append('_'); + sb.appendCodePoint(Character.toLowerCase(cp)); + } else { + sb.appendCodePoint(cp); + } + }); + return sb.toString(); + } + + private static String getMandatory(Settings settings, String componentKey) { + String result = settings.get(componentKey); + if (result == null) { + throw new IllegalStateException("Setting is not present and has no default: [" + componentKey + "]"); + } + return result; + } + + @SuppressWarnings("unchecked") + private static RecordSetting settingsForComponent( + RecordComponent component, + String componentKey, + MethodHandles.Lookup lookup, + Map> relevantSettingsByKey, + Property[] properties1 + ) { + var relevantSettings = relevantSettingsByKey.values().stream().filter(s -> s.getKey().startsWith(componentKey)).toList(); + return new RecordSetting<>(componentKey, (Class) component.getType(), lookup, relevantSettings, properties1); + } + } + private final class Updater implements AbstractScopedSettings.SettingUpdater { private final Consumer consumer; private final Logger logger; @@ -1502,6 +1705,53 @@ public static Setting simpleString(String key, Setting fallback, return new Setting<>(key, fallback, Function.identity(), properties); } + /** + * Allows multiple settings to be accessed collectively as a unit, + * in the form of a {@link Record}. + * + *

+ * The components of the record can be configured by passing in additional + * relevant settings that specify the properties, default value, + * fallback setting, validator, etc. for that specific setting. + * If there is no relevant setting corresponding to some record component, + * its behaviour is inferred automatically from its type according to these + * rules: + * + *

    + *
  • + * There is no default. If the setting is absent, that is a validation error. + *
  • + *
  • + * Parsing uses the corresponding {@code Settings.getAsXXX} method. + *
  • + *
  • + * {@link Property Properties} are the {@code properties} passed into this method. + *
  • + *
  • + * No additional validation is performed. + *
  • + *
+ * + * @param key the common prefix for all the settings accessed by this {@code Setting}. A trailing period will be added automatically. + * @param recordClass the {@link Class} of the value returned by {@link #get}. + * @param lookup is used to access the constructor and component getters, allowing {@code RecordClass} to be non-public. + * @param relevantSettings supply configuration for the components, including (recursively) any components in any + * nested records. + * @param properties apply to this {@link Setting} as a whole, + * and by default to any component settings not specified by {@code relevantSettings}. + * @return the {@link Setting} + * @param the type of {@code recordClass} + */ + public static Setting recordSetting( + String key, + Class recordClass, + MethodHandles.Lookup lookup, + List> relevantSettings, + Property... properties + ) { + return new RecordSetting<>(key, recordClass, lookup, relevantSettings, properties); + } + /** * Creates a new Setting instance with a String value * diff --git a/server/src/test/java/org/elasticsearch/common/settings/SettingTests.java b/server/src/test/java/org/elasticsearch/common/settings/SettingTests.java index 6b7f45f2fc669..97fe765111215 100644 --- a/server/src/test/java/org/elasticsearch/common/settings/SettingTests.java +++ b/server/src/test/java/org/elasticsearch/common/settings/SettingTests.java @@ -24,12 +24,14 @@ import org.elasticsearch.test.MockLog; import org.elasticsearch.test.junit.annotations.TestLogging; +import java.lang.invoke.MethodHandles; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -1605,4 +1607,106 @@ public void testLongSettingBounds() { equalTo("Failed to parse value [-9223372036854775809] for setting [long.setting] must be >= -9223372036854775808") ); } + + public void testRecordSetting() { + record TestSetting(int intSetting) {} + Setting setting = Setting.recordSetting("record.setting", TestSetting.class, MethodHandles.lookup(), List.of()); + Settings settings123 = Settings.builder().put("record.setting.int_setting", 123).build(); // automatic snake-case for names + Settings settings456 = Settings.builder().put("record.setting.int_setting", 456).build(); + + assertEquals(new TestSetting(123), setting.get(settings123)); + assertThrows(IllegalStateException.class, () -> setting.get(Settings.EMPTY)); + + var watcher = new AtomicReference(null); + var updater = setting.newUpdater(watcher::set, logger); + assertNull("Creating an updater does not call set", watcher.get()); + updater.apply(settings456, settings123); + assertEquals("Updater called when value changed", new TestSetting(456), watcher.get()); + assertThrows("Throws when there's no default", IllegalStateException.class, () -> updater.apply(Settings.EMPTY, settings456)); + + watcher.set(null); + updater.apply(settings123, settings123); + assertNull("Updater NOT called when value unchanged", watcher.get()); + updater.apply(settings456, settings456); + assertNull("Updater again not called when value unchanged", watcher.get()); + } + + public void testRecordWithDefaults() { + record TestSetting(int i1, int i2) {} + Setting i1Setting = Setting.intSetting("record.setting.i1", -1); + Setting i2Setting = Setting.intSetting("record.setting.i2", -2); + Setting setting = Setting.recordSetting( + "record.setting", + TestSetting.class, + MethodHandles.lookup(), + List.of(i1Setting, i2Setting) + ); + Settings settings123 = Settings.builder().put("record.setting.i1", 123).build(); + + assertEquals( + new TestSetting(123, 456), + setting.get(Settings.builder().put("record.setting.i1", 123).put("record.setting.i2", 456).build()) + ); + assertEquals(new TestSetting(-1, 456), setting.get(Settings.builder().put("record.setting.i2", 456).build())); + assertEquals(new TestSetting(-1, -2), setting.get(Settings.EMPTY)); + + var watcher = new AtomicReference(null); + var updater = setting.newUpdater(watcher::set, logger); + updater.apply(settings123, Settings.EMPTY); + assertEquals(new TestSetting(123, -2), watcher.get()); + updater.apply(Settings.EMPTY, settings123); + assertEquals(new TestSetting(-1, -2), watcher.get()); + + Settings explicitDefaults = Settings.builder().put("record.setting.i1", -1).put("record.setting.i2", -2).build(); + watcher.set(null); + updater.apply(Settings.EMPTY, explicitDefaults); + assertNull("Updater NOT called when value deleted from the default", watcher.get()); + updater.apply(explicitDefaults, Settings.EMPTY); + assertNull("Updater NOT called when value 'changed' to the default from nothing", watcher.get()); + } + + public void testAffixRecordSetting() { + record TestSetting(int intSetting) {} + var componentSetting = Setting.affixKeySetting("things.", "config.int_setting", (n, key) -> Setting.intSetting(key, -1)); + var setting = Setting.affixKeySetting( + "things.", + "config", + (namespace, key) -> Setting.recordSetting( + key, + TestSetting.class, + MethodHandles.lookup(), + List.of(componentSetting.getConcreteSettingForNamespace(namespace)) + ) + ); + Settings settings = Settings.builder() + .put("things.thing1.config.int_setting", 123) + .put("things.thing2.config.int_setting", 456) + .build(); + assertEquals(new TestSetting(123), setting.getConcreteSetting("things.thing1.config").get(settings)); + assertEquals(new TestSetting(456), setting.getConcreteSetting("things.thing2.config").get(settings)); + + Settings settings1 = Settings.builder().put("things.thing1.config.int_setting", 123).build(); + assertEquals(new TestSetting(123), setting.getConcreteSetting("things.thing1.config").get(settings)); + assertEquals("Default", new TestSetting(-1), setting.getConcreteSetting("things.thing2.config").get(settings1)); + + Map watcher = new ConcurrentHashMap<>(); + var updater = setting.newAffixUpdater(watcher::put, logger, (k, s) -> {}); + + // Test the obvious state transitions + updater.apply(settings1, Settings.EMPTY); + assertEquals(Map.of("thing1", new TestSetting(123)), watcher); + updater.apply(settings, settings1); + assertEquals(Map.of("thing1", new TestSetting(123), "thing2", new TestSetting(456)), watcher); + updater.apply(Settings.EMPTY, settings); + assertEquals("Values set to defaults", Map.of("thing1", new TestSetting(-1), "thing2", new TestSetting(-1)), watcher); + + // Test that only the changed keys are updated + watcher.clear(); + updater.apply(settings, settings1); + assertEquals("Only thing2 updated", Map.of("thing2", new TestSetting(456)), watcher); + watcher.clear(); + updater.apply(Settings.EMPTY, settings1); + assertEquals("thing1 set to default", Map.of("thing1", new TestSetting(-1)), watcher); + } + }