KAFKA-13327, KAFKA-13328, KAFKA-13329: Clean up preflight connector validation#11369
KAFKA-13327, KAFKA-13328, KAFKA-13329: Clean up preflight connector validation#11369C0urante wants to merge 5 commits into
Conversation
…alidation KAFKA-13327: Modify preflight validation logic to prevent 500 responses from being returned instead of valid 200 responses with detailed error messages pertaining to the relevant configuration properties. KAFKA-13328: Add preflight validation logic for per-connector header converters. KAFKA-13329: Add preflight validation logic for per-connector key and value converter classes.
| if (headerConverterClass == null | ||
| || headerConverterConfigValue == null | ||
| || !headerConverterConfigValue.errorMessages().isEmpty() | ||
| ) { | ||
| // Either no custom header converter was specified, or one was specified but there's a problem with it. | ||
| // No need to proceed any further. | ||
| return null; | ||
| } |
There was a problem hiding this comment.
The short-circuiting pattern here where we conditionally exit the method and otherwise proceed normally doesn't seem to be playing very nicely with Checkstyle's NPath complexity metric. I kept this style because it reduces indentation and seems more readable than the alternative (where the portions of code after an if statement such as this one would be put inside an else block), but it comes at the cost that we have to add a Checkstyle suppression.
| // The value will be null if the class couldn't be found; no point in trying to load a ConfigDef for it | ||
| if (value != null) { | ||
| getConfigDefFromConfigProvidingClass(typeConfig, (Class<?>) value); | ||
| } |
There was a problem hiding this comment.
This fixes an issue present in our unit tests where multiple error messages are added for a single invalid transform/predicate property when the class cannot be found. It's best if a single error message is returned stating that the class cannot be found; the second message (something along the lines of "invalid value null for property ...") did not add any value.
| } catch (Exception e) { | ||
| throw new ConfigException(key, String.valueOf(cls), "Error getting config definition from " + baseClass.getSimpleName() + ": " + e.getMessage()); | ||
| // Log the entire exception here in order to provide a stack trace that can be useful for debugging classloading issues | ||
| log.error("Failed to instantiate {} '{}'", baseClass.getSimpleName(), cls, e); |
There was a problem hiding this comment.
Added this error message based on discussion of a similar error-handling situation here.
| aliasKind + " is abstract and cannot be created. Did you mean " + childClassNames + "?"; | ||
| throw new ConfigException(key, String.valueOf(cls), message); | ||
| } | ||
| T transformation; |
There was a problem hiding this comment.
Some basic cleanup to highlight the fact that this class is now agnostic with regards to plugin type and is not specific to SMTs.
| return ConnectorConfig.configDef() | ||
| .define(TOPICS_CONFIG, ConfigDef.Type.LIST, TOPICS_DEFAULT, ConfigDef.Importance.HIGH, TOPICS_DOC, COMMON_GROUP, 4, ConfigDef.Width.LONG, TOPICS_DISPLAY) | ||
| .define(TOPICS_REGEX_CONFIG, ConfigDef.Type.STRING, TOPICS_REGEX_DEFAULT, new RegexValidator(), ConfigDef.Importance.HIGH, TOPICS_REGEX_DOC, COMMON_GROUP, 4, ConfigDef.Width.LONG, TOPICS_REGEX_DISPLAY) | ||
| .define(DLQ_TOPIC_NAME_CONFIG, ConfigDef.Type.STRING, DLQ_TOPIC_DEFAULT, Importance.MEDIUM, DLQ_TOPIC_NAME_DOC, ERROR_GROUP, 6, ConfigDef.Width.MEDIUM, DLQ_TOPIC_DISPLAY) | ||
| .define(DLQ_TOPIC_REPLICATION_FACTOR_CONFIG, ConfigDef.Type.SHORT, DLQ_TOPIC_REPLICATION_FACTOR_CONFIG_DEFAULT, Importance.MEDIUM, DLQ_TOPIC_REPLICATION_FACTOR_CONFIG_DOC, ERROR_GROUP, 7, ConfigDef.Width.MEDIUM, DLQ_TOPIC_REPLICATION_FACTOR_CONFIG_DISPLAY) | ||
| .define(DLQ_CONTEXT_HEADERS_ENABLE_CONFIG, ConfigDef.Type.BOOLEAN, DLQ_CONTEXT_HEADERS_ENABLE_DEFAULT, Importance.MEDIUM, DLQ_CONTEXT_HEADERS_ENABLE_DOC, ERROR_GROUP, 8, ConfigDef.Width.MEDIUM, DLQ_CONTEXT_HEADERS_ENABLE_DISPLAY); |
There was a problem hiding this comment.
It's safer to use a new ConfigDef object every time in order to avoid issues like https://issues.apache.org/jira/browse/KAFKA-9950, which can be caused by reusing a ConfigDef that is then accidentally mutated by some downstream logic.
| throughput = Long.parseLong(props.getOrDefault("throughput", "-1")); | ||
| batchSize = Integer.parseInt(props.getOrDefault("messages.per.poll", "1")); |
There was a problem hiding this comment.
My IDE was nagging me about unnecessary boxing.
| fail("Shouldn't be able to create connector"); | ||
| } catch (ConnectRestException e) { | ||
| assertEquals(e.statusCode(), 400); | ||
| assertEquals(400, e.statusCode()); |
There was a problem hiding this comment.
The expected value should go first, as it's used in error messages that come up with test failures ("expected <first argument>, got <second argument>").
This test did fail once locally for me; I believe it was just some flakiness induced by my laptop being overworked.
gharris1727
left a comment
There was a problem hiding this comment.
It's great to see all of those validation test cases, and even better to see these validations actually making it all the way up to the user. Thanks so much @C0urante!
|
|
||
| Class<?> cls = (Class<?>) value; | ||
| try { | ||
| cls.getDeclaredConstructor().newInstance(); |
There was a problem hiding this comment.
I hope these arbitrary classes that people put in here don't cause memory leaks from doing their own initialization.
There was a problem hiding this comment.
Think it's worth adding a warning about that to the Javadoc for the class?
There was a problem hiding this comment.
No, I don't think that buys us much, as the developer using this validator won't have much more insight into the arbitrary classes' behavior than the developer writing this validator.
Perhaps we could combine these two validators, or make the execution of this one dependent on the subclass validator, to try to narrow down the range of classes that could be instantiated here. For example, if the user is supposed to provide a HeaderConverter class, the typical HeaderConverter class will have a non-leaky default constructor.
...Except i just looked through the API definitions, and I don't think the javadocs for any of our API classes talk about default constructors and expectations, I'd have to dig deep to find out where this expectation is documented, if it exists. Maybe checking the subclass doesn't buy ourselves anything.
If you want to write a validator like this, I think the only justification we can have is that if it causes a memory leak, that's the implementation's fault because the framework never had any guarantees on the number of objects instantiated.
LGTM.
| } | ||
| } | ||
|
|
||
| private static class ConfigError { |
There was a problem hiding this comment.
This has the same contents as a plain ConfigException, but I think the reason you're using this class instead of the plain exception to avoid the cost of instantiating the exception and filling the stacktrace. I think this is a good consideration, but seeing as the existing ConfigDef infrastructure leverages the normal ConfigException in so many cases, I'm not sure this performance impact is substantial.
It's also desirable to return multiple errors from this one method, and that's not possible using an exception. I think passing in Map<String, ConfigValue> and calling the addErrorMessage function as a utility function directly avoids the need for this POJO, while getting you the ability to return multiple errors.
Also, Is there a reason that we can't have this validation return a ConfigInfos object, and merge it in late like the rest of the validations taking place in AbstractHerder::validateConnnectorConfig? That seems like the standard convention.
There was a problem hiding this comment.
I think the reason you're using this class instead of the plain exception to avoid the cost of instantiating the exception and filling the stacktrace.
Not exactly; it's because the ConfigException class isn't really an effective POJO for a property, its value, and an error message. There are no getters for any of those fields, and two of the three constructors available would permit null values for some of them. I chose not to change the ConfigException class to add these getters since I'm not sure it's the best idea for an exception class to be doing double duty as a POJO as well.
I think passing in Map<String, ConfigValue> and calling the addErrorMessage function as a utility function directly avoids the need for this POJO, while getting you the ability to return multiple errors.
This is true, but I'm not sure how we could make that work cleanly with the other current use for SinkConnectorConfig::validate, which is to do a preliminary validation step during WorkerSinkTask initialization.
Also, Is there a reason that we can't have this validation return a ConfigInfos object, and merge it in late like the rest of the validations taking place in AbstractHerder::validateConnnectorConfig? That seems like the standard convention.
It'd make it a little more difficult for subclasses (i.e., DistributedHerder) to override behavior and add their own logic on top.
| Map<String, String> config = defaultSinkConnectorProps(); | ||
| config.remove(TOPICS_CONFIG); | ||
| config.remove(TOPICS_REGEX_CONFIG); | ||
| connect.validateConnectorConfig(config.get(CONNECTOR_CLASS_CONFIG), config); |
There was a problem hiding this comment.
wait where are the assertions? isn't this supposed to fail?
There was a problem hiding this comment.
Pitfalls of TDD. This was failing at the beginning as it is and after it started passing I forgot to go back and add a more fine-grained assertion.
There was a problem hiding this comment.
Wow, good catch. Turns out all three of these tests were broken, including some genuine errors in the new validation logic.
| } | ||
|
|
||
| @Test | ||
| // TODO: Is this actually necessary? Should we permit the same SMT to be applied multiple times? |
There was a problem hiding this comment.
Was this a validation that already takes place, and you're just adding a test for it?
I think this makes sense because you can just work around it by renaming your transform name and copy-pasting the transform configurations.
There was a problem hiding this comment.
Was this a validation that already takes place, and you're just adding a test for it?
Yep, exactly.
I think this makes sense because you can just work around it by renaming your transform name and copy-pasting the transform configurations.
I hear that, but at the same time I wonder if it's doing anybody any good to force them to copy+paste in the first place.
| private static final Logger log = LoggerFactory.getLogger(EmbeddedConnectClusterAssertions.class); | ||
| public static final long WORKER_SETUP_DURATION_MS = TimeUnit.SECONDS.toMillis(60); | ||
| public static final long VALIDATION_DURATION_MS = TimeUnit.SECONDS.toMillis(30); | ||
| public static final long VALIDATION_DURATION_MS = TimeUnit.SECONDS.toMillis(5); |
There was a problem hiding this comment.
Does this reduce test runtime? Might this introduce flakiness in a CI environment?
There was a problem hiding this comment.
Might this introduce flakiness in a CI environment?
I highly doubt it; validations usually take place very quickly.
Does this reduce test runtime?
Sort of. I lowered the value because I used test-driven development for this PR and at the beginning some of my failing test cases were talking 30 seconds each. If we really want to play it safe I think 10 seconds should be fine. Or, alternatively, we could revamp the validation assertions to not use TestUtils::waitForCondition at all and only issue one validation request.
There was a problem hiding this comment.
lowered the value because I used test-driven development for this PR and at the beginning some of my failing test cases were talking 30 seconds each.
Ah, this is a productivity win for everyone using the embedded assertions then. 👍
we could revamp the validation assertions to not use TestUtils::waitForCondition at all and only issue one validation request.
I don't think this is necessary, and any tests that rely on retries for validations would definitely flake out in CI.
…/AbstractHerder.java Co-authored-by: Greg Harris <gharris1727@gmail.com>
gharris1727
left a comment
There was a problem hiding this comment.
LGTM, Thanks @C0urante this is really going to help users get better feedback than a blind 500 error for these odd cases.
|
Downgrading to a draft until I can revisit and fix the merge conflicts. I may also split this into several PRs to make review easier. |
|
A lot of merge conflicts have accrued on this one. Instead of resolving them all at once, I've decided to split this PR out into three smaller PRs, which should also make the review process easier. The first of the three is #14303; others to come soon. |
KAFKA-13327: Modify preflight validation logic to prevent 500 responses from being returned instead of valid 200 responses with detailed error messages pertaining to the relevant configuration properties.
KAFKA-13328: Add preflight validation logic for per-connector header converters.
KAFKA-13329: Add preflight validation logic for per-connector key and value converter classes.
Additionally, a small bug in the logic for KAFKA-3829 introduced by KIP-458 is fixed; the preflight check to ensure that a sink connector's group ID doesn't conflict with the Connect worker's group ID now takes into account overrides made by the
consumer.override.group.idconnector property.A new integration test is added that covers a wide variety of cases for sink connector, key/value converter, and header converter validation.
Committer Checklist (excluded from commit message)