From 8e478a2f1804bd11f9f44ef29499bf3f1e0b3797 Mon Sep 17 00:00:00 2001 From: Alec Grieser Date: Fri, 21 Aug 2026 17:24:41 +0100 Subject: [PATCH] Expose client knobs through the `FDBDatabaseFactory` This allows FDB client knobs to be set via an API on the `FDBDatabaseFactory`. Knobs can be set on a running network, but we should generally encourage them to be set prior to starting the network, as some of them are not actually safe to mutate. But the FDB API is kind of unclear on which ones those are, so this does its best. There is a warning in the Javadoc discouraging souch recklnessness. The exact set of knobs that are available are pretty vast, and they also change with different FDB versions. This does add a non-exhaustive enum, `FDBClientKnob`. Every value in the enum is a known client knob, and associated with it is the type we expect it to be set to. For any knob in that list, if the user tries to set the knob, we will validate that the value is of a parseable type. There is also an escape hatch, `setKnob(String, String)`, which takes any arbitrary knob key and value. If using that secondary API, if the knob is in our known set, we validate the type. Otherwise, we let it go. As noted in the Javadoc, FDB does not throw an error if the knob fails to pass validation (or at least, it attempts not to), and just ignores the option. We call `setKnob` after calling `setTrace`, so there's a chance that we'll at least get the `TraceEvents` for it if the client is configured for it. This resolves #4479. --- .../record/logging/LogMessageKeys.java | 3 + .../provider/foundationdb/FDBClientKnob.java | 272 ++++++++++++++++++ .../foundationdb/FDBDatabaseFactory.java | 76 +++++ .../foundationdb/FDBDatabaseFactoryImpl.java | 132 +++++++++ .../foundationdb/FDBClientKnobTest.java | 74 +++++ .../FDBDatabaseFactoryImplTest.java | 232 +++++++++++++++ .../foundationdb/FDBDatabaseTest.java | 30 ++ 7 files changed, 819 insertions(+) create mode 100644 fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBClientKnob.java create mode 100644 fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBClientKnobTest.java create mode 100644 fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactoryImplTest.java diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/logging/LogMessageKeys.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/logging/LogMessageKeys.java index ac164196508..95edd553a25 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/logging/LogMessageKeys.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/logging/LogMessageKeys.java @@ -198,6 +198,9 @@ public enum LogMessageKeys { // FDB client configuration API_VERSION, + CLIENT_KNOBS, + CLIENT_KNOB_NAME, + CLIENT_KNOB_VALUE, RUN_LOOP_PROFILING, THREADS_PER_CLIENT_VERSION, TRACE_DIRECTORY, diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBClientKnob.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBClientKnob.java new file mode 100644 index 00000000000..d330d160aa8 --- /dev/null +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBClientKnob.java @@ -0,0 +1,272 @@ +/* + * FDBClientKnob.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2026 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.record.provider.foundationdb; + +import com.apple.foundationdb.annotation.API; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * A curated list of FDB client knobs that adopters may want to tune, along with the type of value that each + * knob expects. The FDB native client is configured through a large number of "knobs," most of which are only + * of interest to FDB developers, but some of which can be useful for client tuning. Under the cover, knobs + * are set via the {@link com.apple.foundationdb.NetworkOptions#setKnob(String)} API, which takes a single + * string-valued argument that should be of the form {@code knob_name=knob_value}. This enum exists to make + * setting the more common knobs less error-prone by allowing the {@link FDBDatabaseFactory#setKnob(FDBClientKnob, String)} + * API to validate that the supplied value is of the type that the knob actually expects. It will then construct + * the appropriate argument value and pass it to the underlying {@code NetworkOptions}. + * + *

+ * Not every knob supported by the native client has a constant here. For knobs that are not (yet) enumerated, + * {@link FDBDatabaseFactory#setKnob(String, String)} can be used instead. Note that no validation is performed + * on the value being set for any knob not in this enumeration. However, the fact that this enumeration is + * not exhaustive means that the client does not need to wait for us to release a new Record Layer version every + * time a new client knob is added to FDB. Any knob that is supported by the linked client library can be configured + * by the library consumer. + *

+ * + *

+ * This class is {@link API.Status#UNSTABLE}. It may add new values at any time in response to new knob values + * that are determined to be useful to configure. The name of each enum constant is the upper-case + * version of the associated knob's name. This mapping is relied on to derive the knob name, so this + * convention must be preserved for any new constants that are added. + *

+ * + *

+ * For the full list of available knobs and their default values, consult the FDB source files: + *

+ * + * + * + *

+ * When consulting those files, be sure to validate that the knob is present in tag corresponding to the + * FDB client version. + *

+ * + * @see FDBDatabaseFactory#setKnob(FDBClientKnob, String) + * @see FDBDatabaseFactory#setKnob(String, String) + * @see com.apple.foundationdb.NetworkOptions#setKnob(String) + */ +@API(API.Status.UNSTABLE) +public enum FDBClientKnob { + /** + * Maximum size in bytes of a single network packet. If a single packet exceeds this size, the + * network request will throw a {@link com.apple.foundationdb.FDBError#PLATFORM_ERROR}. + * + * @see #PACKET_WARNING + */ + PACKET_LIMIT(KnobValueType.LONG), + /** + * Warning threshold in bytes for network packets. If a single packet exceeds this size, it + * will log a {@code LargePacketSent} trace event. + * + * @see #PACKET_LIMIT + */ + PACKET_WARNING(KnobValueType.LONG), + /** + * The number of dedicated threads used by the client to perform TLS handshakes. If set to {@code 0}, TLS + * handshakes are instead performed on the main network thread. + */ + TLS_CLIENT_HANDSHAKE_THREADS(KnobValueType.INT), + /** + * If {@code true}, TLS handshakes are never performed on the main network thread, even if + * {@link #TLS_CLIENT_HANDSHAKE_THREADS} is set to {@code 0}. + */ + DISABLE_MAINTHREAD_TLS_HANDSHAKE(KnobValueType.BOOLEAN), + /** + * The number of times a TLS connection to a given peer is allowed to fail within + * {@link #TLS_CLIENT_CONNECTION_THROTTLE_TIMEOUT} seconds before further connection attempts to that peer + * are throttled. + */ + TLS_CLIENT_CONNECTION_THROTTLE_ATTEMPTS(KnobValueType.INT), + /** + * The time window, in seconds, over which failed TLS connection attempts to a given peer are counted for + * the purposes of {@link #TLS_CLIENT_CONNECTION_THROTTLE_ATTEMPTS}. + */ + TLS_CLIENT_CONNECTION_THROTTLE_TIMEOUT(KnobValueType.DOUBLE), + /** + * The maximum number of commit proxies that the client will maintain connections to at once. + */ + MAX_COMMIT_PROXY_CONNECTIONS(KnobValueType.INT), + /** + * The maximum number of GRV (get read version) proxies that the client will maintain connections to at once. + */ + MAX_GRV_PROXY_CONNECTIONS(KnobValueType.INT), + /** + * The number of seconds to wait after failing to reach an endpoint before the client's location cache will + * retry that endpoint again. + */ + LOCATION_CACHE_FAILED_ENDPOINT_RETRY_INTERVAL(KnobValueType.DOUBLE), + /** + * Whether the client should log detailed information about connection attempts. If enabled, logs are + * written to the directory specified by {@link #CONNECTION_LOG_DIRECTORY}. + */ + LOG_CONNECTION_ATTEMPTS_ENABLED(KnobValueType.BOOLEAN), + /** + * The directory to which connection attempt logs are written, if {@link #LOG_CONNECTION_ATTEMPTS_ENABLED} + * is set to {@code true}. + */ + CONNECTION_LOG_DIRECTORY(KnobValueType.STRING), + /** + * The maximum time, in seconds, that the client will wait between attempts to reconnect to a peer, + * following exponential backoff. + */ + MAX_RECONNECTION_TIME(KnobValueType.DOUBLE), + /** + * The rate at which the delay between successive reconnection attempts to an unreachable peer grows. + */ + RECONNECTION_TIME_GROWTH_RATE(KnobValueType.DOUBLE), + /** + * The amount of time, in seconds, since the last connection attempt to a peer after which the reconnection + * delay is reset back to its initial value rather than continuing to grow. + */ + RECONNECTION_RESET_TIME(KnobValueType.DOUBLE), + /** + * Whether the client should proactively evict a storage server address from its location cache once that + * address's persistent connection failures cross {@link #LOCATION_CACHE_PEER_EVICTOR_FAILED_THRESHOLD}. + * The location cache maintains a list of storage servers that are believed to serve data for different + * ranges of data. When this knob is enabled, it allows the client to clean up any connections to + * instances that it considers to be down as it has been unable to make a healthy connection. + * + * @see #LOCATION_CACHE_PEER_EVICTOR_FAILED_THRESHOLD + * @see #LOCATION_CACHE_PEER_EVICTOR_ENABLED + * @see #LOCATION_CACHE_PEER_EVICTOR_SCAN_CHUNK + */ + LOCATION_CACHE_PEER_EVICTOR_ENABLED(KnobValueType.BOOLEAN), + /** + * Time in seconds between sweeps of the location cache peer eviction sweep. If location + * cache peer eviction is not enabled, this has no effect. Otherwise, the client will run a background + * process to clean up its internal state, with the period determined by this knob. + * + * @see #LOCATION_CACHE_PEER_EVICTOR_ENABLED + */ + LOCATION_CACHE_PEER_EVICTOR_DELAY(KnobValueType.DOUBLE), + /** + * The number of unsuccessful peer connection attempts to require before it is eligible to evicted + * from the location cache. If set to zero, then any failure makes the entry in the location cache + * eligible for clean up. + * + * @see #LOCATION_CACHE_PEER_EVICTOR_ENABLED + */ + LOCATION_CACHE_PEER_EVICTOR_FAILED_THRESHOLD(KnobValueType.INT), + /** + * The number of location cache ranges that the address-based invalidation scan performed by the + * location-cache peer evictor processes before yielding to the main event loop. This has no + * effect if location cache peer eviction is not enabled. It is present to prevent slow tasks + * affecting other operations during the peer eviction. + * + * @see #LOCATION_CACHE_PEER_EVICTOR_ENABLED + */ + LOCATION_CACHE_PEER_EVICTOR_SCAN_CHUNK(KnobValueType.INT), + /** + * Whether the client should clear its cached sampled subset of commit/GRV proxies whenever the recruited + * proxy count drops below {@link #MAX_COMMIT_PROXY_CONNECTIONS} or {@link #MAX_GRV_PROXY_CONNECTIONS}, so + * that the cache cleans up connections to unresponsive proxies. + */ + SHRINK_PROXY_LIST_CLEAR_CACHE_BELOW_THRESHOLD(KnobValueType.BOOLEAN), + ; + + @Nonnull + private static final Map BY_KNOB_NAME = Arrays.stream(values()) + .collect(Collectors.toMap(FDBClientKnob::getKnobName, Function.identity())); + + @Nonnull + private final KnobValueType valueType; + @Nonnull + private final String knobName; + + FDBClientKnob(@Nonnull KnobValueType valueType) { + this.valueType = valueType; + this.knobName = name().toLowerCase(Locale.ROOT); + } + + /** + * Get the type of value that this knob expects. + * + * @return the type of value that this knob expects + */ + @Nonnull + public KnobValueType getValueType() { + return valueType; + } + + /** + * Get the name of the knob, as it should be supplied to the native client. This is just the name of the + * enum constant, lower-cased. + * + * @return the name of the knob + */ + @Nonnull + public String getKnobName() { + return knobName; + } + + /** + * Look up the {@link FDBClientKnob} constant with the given (lower-case) knob name, if one exists. + * + * @param knobName the (lower-case) name of the knob to look up + * @return the {@link FDBClientKnob} constant associated with {@code knobName}, or {@code null} if + * {@code knobName} is not one of the knobs enumerated by this class + */ + @Nullable + public static FDBClientKnob fromKnobName(@Nonnull String knobName) { + return BY_KNOB_NAME.get(knobName); + } + + /** + * The type of value that a given {@link FDBClientKnob} expects to be set to. This is used to validate that + * a value supplied to one of the {@link FDBDatabaseFactory#setKnob(FDBClientKnob, String)}-style setters + * matches what the underlying native client knob actually expects. The following table describes what + * string values are considered legal for each type: + * + * + * + * + * + * + * + * + *
Legal values by knob value type
TypeLegal values
{@link #INT}, {@link #LONG}Any value accepted by {@link Integer#decode(String)} (for + * {@link #INT}) or {@link Long#decode(String)} (for {@link #LONG}), except for the {@code #}-prefixed hex + * notation that those methods accept but the native client does not. In particular, this includes ordinary + * base-10 values (e.g., {@code "123"}, {@code "-1"}), hex values with a {@code 0x} or {@code 0X} prefix + * (e.g., {@code "0x7B"}), and octal values with a leading {@code 0} (e.g., {@code "0173"}, interpreted as + * {@code 123}), matching the parsing performed by the native client.
{@link #DOUBLE}Any value accepted by {@link Double#parseDouble(String)}.
{@link #BOOLEAN}{@code "true"} or {@code "false"} (either accepted case-insensitively), + * or any value that can be parsed as an integer (as with {@link #INT}), with a non-zero value interpreted + * as {@code true} and zero interpreted as {@code false}.
{@link #STRING}Any value is legal.
+ */ + public enum KnobValueType { + INT, + LONG, + DOUBLE, + BOOLEAN, + STRING, + } +} diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactory.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactory.java index c3d390762b3..c34af008653 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactory.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactory.java @@ -700,6 +700,82 @@ public TransactionListener getTransactionListener() { */ public abstract boolean isShutdownHookDisabled(); + /** + * Set a client knob to the given value. Knobs are how the FDB native client exposes lower-level tuning + * parameters. This method should be preferred over {@link #setKnob(String, String)} when the knob being + * configured has a corresponding {@link FDBClientKnob} constant. + * + *

+ * This method can be called before or after {@link FDB} initialization. If it is called before the client + * is initialized, the knob will be applied when the client starts. If it is called after the client has + * already started, the knob will be applied to the running client immediately. Warning: + * not every knob will actually have the desired effect if applied after client start-up. Therefore, though + * this is allowed by the API, it is generally not advisable. + *

+ * + * @param knob the knob to set + * @param value the value to set the knob to + * + * @throws RecordCoreArgumentException if {@code value} is not of the type that {@code knob} expects + * + * @see #setKnob(String, String) + * @see FDBClientKnob + */ + public abstract void setKnob(@Nonnull FDBClientKnob knob, @Nonnull String value); + + /** + * Set a client knob to the given value. Knobs are how the FDB native client exposes lower-level tuning + * parameters. Unlike {@link #setKnob(FDBClientKnob, String)}, this method allows any knob name to be + * supplied, including ones that do not (yet) have an associated {@link FDBClientKnob} constant. If + * {@code knobName} does happen to match a known {@link FDBClientKnob}, then {@code value} is validated + * against that knob's expected type just as it would be by {@link #setKnob(FDBClientKnob, String)}. + * Otherwise, because the type of value that the knob expects is not known to the Record Layer, this method + * is not able to validate that {@code value} is of the correct type; an invalid value will instead be + * silently ignored by the native client (with a warning written to its trace logs, if enabled). + * + *

+ * This method can be called before or after {@link FDB} initialization. See {@link #setKnob(FDBClientKnob, String)} + * for details. + *

+ * + * @param knobName the (lower-case) name of the knob to set + * @param value the value to set the knob to + * + * @throws RecordCoreArgumentException if {@code knobName} is blank or contains an {@code =} character, or + * if {@code knobName} matches a known {@link FDBClientKnob} and {@code value} is not of the type that knob + * expects + * + * @see #setKnob(FDBClientKnob, String) + * @see com.apple.foundationdb.NetworkOptions#setKnob(String) + */ + public abstract void setKnob(@Nonnull String knobName, @Nonnull String value); + + /** + * Get the client knobs that have been configured on this factory, along with the values that they have + * been set to. This does not include any knobs that may have been configured outside of this factory, + * for example, by setting the {@code FDB_NETWORK_OPTION_KNOB} environment variable. + * + * @return an unmodifiable view of the client knobs configured on this factory + * + * @see #setKnob(FDBClientKnob, String) + * @see #setKnob(String, String) + */ + @Nonnull + public abstract Map getKnobs(); + + /** + * Remove any previously configured client knobs. This does not affect knobs that may have been configured + * outside of this factory, for example, by setting the {@code FDB_NETWORK_OPTION_KNOB} environment variable. Because + * there is no way to reset a knob that has already been applied to a running client back to its default, + * this method may only be called before {@link FDB} initialization. + * + * @throws RecordCoreException if the client has already been initialized + * + * @see #setKnob(FDBClientKnob, String) + * @see #setKnob(String, String) + */ + public abstract void clearKnobs(); + /** * Set whether additional run-loop profiling of the FDB client is enabled. This can be useful for debugging * certain performance problems, but the profiling is also fairly heavy-weight, and so it is not generally diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactoryImpl.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactoryImpl.java index f1c9b7ad981..0a7fc01450d 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactoryImpl.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactoryImpl.java @@ -25,6 +25,7 @@ import com.apple.foundationdb.NetworkOptions; import com.apple.foundationdb.annotation.API; import com.apple.foundationdb.annotation.SpotBugsSuppressWarnings; +import com.apple.foundationdb.record.RecordCoreArgumentException; import com.apple.foundationdb.record.RecordCoreException; import com.apple.foundationdb.record.logging.KeyValueLogMessage; import com.apple.foundationdb.record.logging.LogMessageKeys; @@ -34,7 +35,11 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Objects; +import java.util.function.Function; import java.util.function.Supplier; /** @@ -89,6 +94,9 @@ public class FDBDatabaseFactoryImpl extends FDBDatabaseFactory { private boolean runLoopProfilingEnabled = false; + @Nonnull + private final Map knobs = new LinkedHashMap<>(); + /** * The default is a log-based predicate, which can also be used to enable tracing on a more granular level * (such as by request) using {@link #setTransactionIsTracedSupplier(Supplier)}. @@ -123,6 +131,7 @@ protected synchronized FDB initFDB() { .addKeyAndValue(LogMessageKeys.TRACE_LOG_GROUP, traceLogGroup) .addKeyAndValue(LogMessageKeys.RUN_LOOP_PROFILING, runLoopProfilingEnabled) .addKeyAndValue(LogMessageKeys.THREADS_PER_CLIENT_VERSION, threadsPerClientVersion) + .addKeyAndValue(LogMessageKeys.CLIENT_KNOBS, knobs.keySet()) .getMessageWithKeys()); } fdb = FDB.selectAPIVersion(apiVersion.getVersionNumber()); @@ -141,6 +150,11 @@ protected synchronized FDB initFDB() { if (runLoopProfilingEnabled) { options.setEnableRunLoopProfiling(); } + for (Map.Entry knob : knobs.entrySet()) { + // Knob name and value validation is done during setKnob below, so + // no validation is done here + options.setKnob(formatKnob(knob.getKey(), knob.getValue())); + } if (shutdownHookDisabled) { fdb.disableShutdownHook(); } @@ -242,6 +256,124 @@ public boolean isShutdownHookDisabled() { return shutdownHookDisabled; } + @Override + public void setKnob(@Nonnull FDBClientKnob knob, @Nonnull String value) { + validateKnobValue(knob, value); + setKnobInternal(knob.getKnobName(), value); + } + + @Override + public void setKnob(@Nonnull String knobName, @Nonnull String value) { + // If the name happens to match one of our known knobs, validate the value against its expected type, + // call setKnob(FDBClientKnob, String) so that the knob value is validated + final FDBClientKnob knownKnob = FDBClientKnob.fromKnobName(knobName); + if (knownKnob != null) { + setKnob(knownKnob, value); + return; + } + // Otherwise, all we can do is validate that the name is reasonable + if (knobName.isBlank() || knobName.indexOf('=') != -1) { + throw new RecordCoreArgumentException("invalid client knob name") + .addLogInfo(LogMessageKeys.CLIENT_KNOB_NAME, knobName); + } + setKnobInternal(knobName, value); + } + + private synchronized void setKnobInternal(@Nonnull String knobName, @Nonnull String value) { + knobs.put(knobName, value); + if (inited) { + Objects.requireNonNull(fdb).options().setKnob(formatKnob(knobName, value)); + } + } + + @Nonnull + @Override + public synchronized Map getKnobs() { + return Collections.unmodifiableMap(knobs); + } + + @Override + public synchronized void clearKnobs() { + if (inited) { + throw new RecordCoreException("client knobs cannot be cleared as the client has already started"); + } + knobs.clear(); + } + + @Nonnull + private static String formatKnob(@Nonnull String knobName, @Nonnull String value) { + return knobName + "=" + value; + } + + /** + * Validate that {@code value} can be parsed as the type of value that {@code knob} expects. This is only a + * sanity check performed before handing the value off to the native client; it does not guarantee that the + * native client will actually accept the value (for example, it does not know the valid range for a given + * knob). + * + * @param knob the knob being set + * @param value the value that the knob is being set to + */ + private static void validateKnobValue(@Nonnull FDBClientKnob knob, @Nonnull String value) { + final boolean valid = switch (knob.getValueType()) { + case INT -> isValidInteger(value, Integer::decode); + case LONG -> isValidInteger(value, Long::decode); + case DOUBLE -> tryParse(() -> Double.parseDouble(value)); + case BOOLEAN -> isValidBoolean(value); + case STRING -> true; + }; + if (!valid) { + throw new RecordCoreArgumentException("client knob value does not match the knob's expected type") + .addLogInfo(LogMessageKeys.CLIENT_KNOB_NAME, knob.getKnobName()) + .addLogInfo(LogMessageKeys.CLIENT_KNOB_VALUE, value) + .addLogInfo(LogMessageKeys.EXPECTED, knob.getValueType()); + } + } + + private static boolean tryParse(@Nonnull Runnable parse) { + try { + parse.run(); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Determine whether {@code value} is a valid integer knob value, matching the FDB native client's own + * parsing logic ({@code strtol}-style base-0 parsing): ordinary base-10 values are accepted, as are + * hex values with a {@code 0x}/{@code 0X} prefix and octal values with a leading {@code 0}. This rejects + * the {@code #}-prefixed hex notation that {@code decode} otherwise accepts, since that notation is + * specific to {@link Integer#decode(String)}/{@link Long#decode(String)} and is not recognized by the + * native client, which would otherwise silently ignore a value that this validation had let through. + * + * @param value the value to validate + * @param decode {@link Integer#decode(String)} or {@link Long#decode(String)}, depending on the knob's type + * @return whether {@code value} is a valid integer knob value + */ + private static boolean isValidInteger(@Nonnull String value, @Nonnull Function decode) { + if (value.indexOf('#') != -1) { + return false; + } + return tryParse(() -> decode.apply(value)); + } + + /** + * Determine whether {@code value} is a valid boolean knob value, matching the FDB native client's own + * parsing logic: {@code "true"} and {@code "false"} are accepted case-insensitively, and otherwise the + * value is accepted so long as it can be parsed as an integer (a non-zero integer is interpreted as + * {@code true}, and zero as {@code false}). + * + * @param value the value to validate + * @return whether {@code value} is a valid boolean knob value + */ + private static boolean isValidBoolean(@Nonnull String value) { + if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) { + return true; + } + return isValidInteger(value, Integer::decode); + } + // TODO: Demote these to UNSTABLE and deprecate at some point. @Override diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBClientKnobTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBClientKnobTest.java new file mode 100644 index 00000000000..367a2b4705d --- /dev/null +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBClientKnobTest.java @@ -0,0 +1,74 @@ +/* + * FDBClientKnobTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2026 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.record.provider.foundationdb; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +import javax.annotation.Nonnull; +import java.util.Arrays; +import java.util.Locale; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests of {@link FDBClientKnob} itself, independent of {@link FDBDatabaseFactory}. + */ +@Execution(ExecutionMode.CONCURRENT) +class FDBClientKnobTest { + @Nonnull + private static final Pattern KNOB_NAME_PATTERN = Pattern.compile("[a-z_]+"); + + @Test + void knobNameIsLowerCasedEnumNameWithNoEquals() { + for (FDBClientKnob knob : FDBClientKnob.values()) { + assertThat(knob.getKnobName()) + .matches(KNOB_NAME_PATTERN) + .doesNotContain("=") + .isEqualTo(knob.name().toLowerCase(Locale.ROOT)); + } + } + + @Test + void fromKnobNameFindsEveryKnownKnob() { + for (FDBClientKnob knob : FDBClientKnob.values()) { + assertThat(FDBClientKnob.fromKnobName(knob.getKnobName())) + .isEqualTo(knob); + } + } + + @Test + void fromKnobNameReturnsNullForUnknownKnob() { + assertThat(FDBClientKnob.fromKnobName("some_unknown_knob")) + .isNull(); + } + + @Test + void allKnobNamesAreDistinct() { + final long distinctNameCount = Arrays.stream(FDBClientKnob.values()) + .map(FDBClientKnob::getKnobName) + .distinct() + .count(); + assertThat(distinctNameCount).isEqualTo(FDBClientKnob.values().length); + } +} diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactoryImplTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactoryImplTest.java new file mode 100644 index 00000000000..9bc4f545ab8 --- /dev/null +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseFactoryImplTest.java @@ -0,0 +1,232 @@ +/* + * FDBDatabaseFactoryImplTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2026 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.record.provider.foundationdb; + +import com.apple.foundationdb.record.RecordCoreArgumentException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests of the client knob-related methods on {@link FDBDatabaseFactory}, exercised against a fresh, + * un-initialized {@link FDBDatabaseFactoryImpl} so that they can run without a running FDB instance. + */ +@Execution(ExecutionMode.CONCURRENT) +class FDBDatabaseFactoryImplTest { + private FDBDatabaseFactory factory; + + @BeforeEach + void createFactory() { + // Deliberately create a new factory rather than using the singleton. That way, setting + // the knobs in these tests does not actually attempt to set the knobs on any concurrently + // running FDB client. + factory = new FDBDatabaseFactoryImpl(); + } + + @Test + void setKnobByNameRecordsValue() { + factory.setKnob("some_unknown_knob", "42"); + assertThat(factory.getKnobs()).containsExactly(Map.entry("some_unknown_knob", "42")); + } + + @Test + void setKnobByEnumUsesLowerCasedNameAsKnobName() { + factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, "3"); + assertThat(factory.getKnobs()).containsExactly(Map.entry("tls_client_handshake_threads", "3")); + } + + @Test + void setKnobOverwritesPreviousValue() { + factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, "3"); + factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, "5"); + assertThat(factory.getKnobs()).containsExactly(Map.entry("tls_client_handshake_threads", "5")); + } + + @Test + void clearKnobsRemovesAllConfiguredKnobs() { + factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, "3"); + factory.setKnob("some_unknown_knob", "42"); + factory.clearKnobs(); + assertThat(factory.getKnobs()).isEmpty(); + } + + @Test + void getKnobsReturnsUnmodifiableView() { + factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, "3"); + assertThatThrownBy(() -> factory.getKnobs().put("some_unknown_knob", "1")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void getKnobsReturnsLiveViewOfLaterChanges() { + // getKnobs() is documented as returning a view of the current knobs, so it should reflect subsequent + // setKnob()/clearKnobs() calls made through the same map instance rather than a point-in-time snapshot. + final Map knobsView = factory.getKnobs(); + factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, "3"); + assertThat(knobsView).containsExactly(Map.entry("tls_client_handshake_threads", "3")); + factory.clearKnobs(); + assertThat(knobsView).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "bad=name"}) + void setKnobByNameRejectsInvalidNames(String knobName) { + assertThatThrownBy(() -> factory.setKnob(knobName, "3")) + .isInstanceOf(RecordCoreArgumentException.class); + assertThat(factory.getKnobs()).isEmpty(); + } + + @Test + void setKnobByNameAcceptsValidValueForKnownKnob() { + assertThatCode(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS.getKnobName(), "3")) + .doesNotThrowAnyException(); + assertThat(factory.getKnobs()).containsExactly(Map.entry("tls_client_handshake_threads", "3")); + } + + @Test + void setKnobByNameRejectsInvalidValueForKnownKnob() { + // "tls_client_handshake_threads" is a known INT knob, so setting it via the raw-string overload should + // still be validated as if it had been set through the FDBClientKnob-typed overload. + assertThatThrownBy(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS.getKnobName(), "not_an_int")) + .isInstanceOf(RecordCoreArgumentException.class); + assertThat(factory.getKnobs()).isEmpty(); + } + + @Test + void setKnobByNameRejectsInvalidValueForKnownLongKnob() { + assertThatThrownBy(() -> factory.setKnob(FDBClientKnob.PACKET_LIMIT.getKnobName(), "not_a_long")) + .isInstanceOf(RecordCoreArgumentException.class); + assertThat(factory.getKnobs()).isEmpty(); + } + + @Test + void setKnobByNameRejectsInvalidValueForKnownDoubleKnob() { + assertThatThrownBy(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_CONNECTION_THROTTLE_TIMEOUT.getKnobName(), "not_a_double")) + .isInstanceOf(RecordCoreArgumentException.class); + assertThat(factory.getKnobs()).isEmpty(); + } + + @Test + void setKnobByNameRejectsInvalidValueForKnownBooleanKnob() { + assertThatThrownBy(() -> factory.setKnob(FDBClientKnob.LOG_CONNECTION_ATTEMPTS_ENABLED.getKnobName(), "not_a_bool")) + .isInstanceOf(RecordCoreArgumentException.class); + assertThat(factory.getKnobs()).isEmpty(); + } + + @Test + void setKnobByNameAcceptsAnyValueForKnownStringKnob() { + assertThatCode(() -> factory.setKnob(FDBClientKnob.CONNECTION_LOG_DIRECTORY.getKnobName(), "anything at all")) + .doesNotThrowAnyException(); + assertThat(factory.getKnobs()).containsExactly(Map.entry("connection_log_directory", "anything at all")); + } + + @Test + void setKnobByNameDoesNotValidateUnknownKnob() { + assertThatCode(() -> factory.setKnob("some_unknown_knob", "not_a_number_but_who_knows")) + .doesNotThrowAnyException(); + assertThat(factory.getKnobs()).containsExactly(Map.entry("some_unknown_knob", "not_a_number_but_who_knows")); + } + + @ParameterizedTest + @ValueSource(strings = {"3", "-1", "0x10", "2147483647", "-2147483648"}) + void setKnobByEnumAcceptsValidIntValues(String value) { + assertThatCode(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, value)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = {"not_an_int", "3.5", "", "#10"}) + void setKnobByEnumRejectsInvalidIntValues(String value) { + assertThatThrownBy(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, value)) + .isInstanceOf(RecordCoreArgumentException.class); + assertThat(factory.getKnobs()).isEmpty(); + } + + @Test + void setKnobByEnumAcceptsOctalIntValueMatchingNativeClientSemantics() { + // "010" is octal in the native client's base-0 stoi parsing, just as it is for Integer.decode, and both + // interpret it as the decimal value 8 (not 10). + assertThatCode(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, "010")) + .doesNotThrowAnyException(); + assertThat(Integer.decode("010")).isEqualTo(8); + } + + @ParameterizedTest + @ValueSource(strings = {"11.0", "0", "-1.5", "NaN", "Infinity", "-Infinity"}) + void setKnobByEnumAcceptsValidDoubleValues(String value) { + assertThatCode(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_CONNECTION_THROTTLE_TIMEOUT, value)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = {"not_a_double", ""}) + void setKnobByEnumRejectsInvalidDoubleValues(String value) { + assertThatThrownBy(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_CONNECTION_THROTTLE_TIMEOUT, value)) + .isInstanceOf(RecordCoreArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "TRUE", "false", "FALSE", "1", "0", "-1", "0x10", "010"}) + void setKnobByEnumAcceptsValidBooleanValues(String value) { + assertThatCode(() -> factory.setKnob(FDBClientKnob.LOG_CONNECTION_ATTEMPTS_ENABLED, value)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = {"not_a_bool", "yes", ""}) + void setKnobByEnumRejectsInvalidBooleanValues(String value) { + assertThatThrownBy(() -> factory.setKnob(FDBClientKnob.LOG_CONNECTION_ATTEMPTS_ENABLED, value)) + .isInstanceOf(RecordCoreArgumentException.class); + } + + @Test + void setKnobByEnumAcceptsAnyStringValueForStringKnobs() { + assertThatCode(() -> factory.setKnob(FDBClientKnob.CONNECTION_LOG_DIRECTORY, "/tmp/fdb-connections")) + .doesNotThrowAnyException(); + assertThat(factory.getKnobs()).containsExactly(Map.entry("connection_log_directory", "/tmp/fdb-connections")); + } + + @ParameterizedTest + @ValueSource(strings = {"1", "9223372036854775807", "-9223372036854775808"}) + void setKnobByEnumAcceptsValidLongValues(String value) { + assertThatCode(() -> factory.setKnob(FDBClientKnob.PACKET_WARNING, value)) + .doesNotThrowAnyException(); + } + + @Test + void setKnobByEnumRejectsLongValuesThatOverflowInt() { + // sanity check that the LONG knob type is checked against long parsing, not int parsing + final String tooBigForInt = Long.toString((long) Integer.MAX_VALUE + 1); + assertThatCode(() -> factory.setKnob(FDBClientKnob.PACKET_WARNING, tooBigForInt)) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, tooBigForInt)) + .isInstanceOf(RecordCoreArgumentException.class); + } +} diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseTest.java index 2e0a108c040..6b999315553 100644 --- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseTest.java +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/FDBDatabaseTest.java @@ -617,6 +617,36 @@ void cannotChangeAPIVersionAfterInit() { } } + @Test + void canSetKnobOnRunningClient() { + // Get a running client (initialized in dbExtension) + final FDBDatabase database = dbExtension.getDatabase(); + final FDBDatabaseFactory factory = database.getFactory(); + + // A knob that the native client recognizes: setting it should not throw, whether or not the client + // actually respects a change to it after having already started. + assertDoesNotThrow(() -> factory.setKnob(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS, "3")); + assertEquals("3", factory.getKnobs().get(FDBClientKnob.TLS_CLIENT_HANDSHAKE_THREADS.getKnobName())); + + // The native client swallows unrecognized knob names (logging a warning) rather than throwing, both + // before and after the client has started. Setting one through the factory should not throw either, + // but it is still recorded so that, e.g., a subsequent factory targeting a fresh client would apply it. + assertDoesNotThrow(() -> factory.setKnob("this_knob_does_not_exist", "1")); + assertEquals("1", factory.getKnobs().get("this_knob_does_not_exist")); + + } + + @Test + void cannotClearKnobsOnRunningClient() { + // Get a running client (initialized in dbExtension) + final FDBDatabase database = dbExtension.getDatabase(); + final FDBDatabaseFactory factory = database.getFactory(); + + // Since there is no way to reset a knob that has already been applied to a running client, clearKnobs() + // is only allowed before the client has started. + assertThrows(RecordCoreException.class, factory::clearKnobs); + } + @Test void canAccessMultipleClusters() { FDBTestEnvironment.assumeClusterCount(2);