-
Notifications
You must be signed in to change notification settings - Fork 124
[controller] Add generic controller plugin framework #2668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.linkedin.venice.controller; | ||
|
|
||
| import java.io.Closeable; | ||
|
|
||
|
|
||
| /** | ||
| * A pluggable service that runs on the parent Venice controller. | ||
| * Implementations are provided externally (e.g., by downstream projects) and registered one of two ways: | ||
| * <ul> | ||
| * <li>programmatically, via {@link VeniceControllerContext.Builder#setControllerPluginFactories}; or</li> | ||
| * <li>by class name, via the {@code controller.plugin.class.names} config, instantiated by reflection | ||
| * (the class must expose a public constructor taking {@code VeniceParentHelixAdmin}, | ||
| * {@code AuthorizerService}, {@code VeniceControllerMultiClusterConfig}).</li> | ||
| * </ul> | ||
| * | ||
| * <p>The lifecycle is: | ||
| * <ol> | ||
| * <li>{@link ControllerPluginFactory#create} — called during controller construction</li> | ||
| * <li>{@link #start()} — called when the controller starts</li> | ||
| * <li>{@link #close()} — called when the controller stops</li> | ||
| * </ol> | ||
| */ | ||
| public interface ControllerPlugin extends Closeable { | ||
| /** | ||
| * Called once during controller startup. May start background threads. | ||
| */ | ||
| void start(); | ||
|
|
||
| /** | ||
| * Returns a human-readable name for logging. | ||
| */ | ||
| String getName(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package com.linkedin.venice.controller; | ||
|
|
||
| import com.linkedin.venice.authorization.AuthorizerService; | ||
|
|
||
|
|
||
| /** | ||
| * Factory to create a {@link ControllerPlugin}. Receives all controller context needed to | ||
| * initialize the plugin: the parent admin for store enumeration and leadership checks, | ||
| * the authorizer service, and the multi-cluster config for reading plugin-specific properties. | ||
| * | ||
| * <p>Factories are registered via {@link VeniceControllerContext.Builder#setControllerPluginFactories} | ||
| * and invoked during controller construction. A factory may return {@code null} to indicate | ||
| * the plugin should not be created (e.g., when disabled by config). | ||
| */ | ||
| @FunctionalInterface | ||
| public interface ControllerPluginFactory { | ||
| /** | ||
| * @param admin the parent controller admin | ||
| * @param authorizerService the controller's authorizer, or {@code null} when controller authorization is | ||
| * disabled. Implementations that require it must null-check and return {@code null} | ||
| * (skip the plugin) rather than dereferencing it. | ||
| * @param config the multi-cluster config, for reading plugin-specific properties | ||
| * @return the plugin to run, or {@code null} to skip creation | ||
| */ | ||
| ControllerPlugin create( | ||
| VeniceParentHelixAdmin admin, | ||
| AuthorizerService authorizerService, | ||
| VeniceControllerMultiClusterConfig config); | ||
|
Comment on lines
+16
to
+28
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| package com.linkedin.venice.controller; | ||
|
|
||
| import static com.linkedin.venice.ConfigKeys.CONTROLLER_PLUGIN_CLASS_NAMES; | ||
| import static com.linkedin.venice.ConfigKeys.ZOOKEEPER_ADDRESS; | ||
|
|
||
| import com.linkedin.d2.balancer.D2Client; | ||
|
|
@@ -55,6 +56,7 @@ | |
| import com.linkedin.venice.system.store.ControllerClientBackedSystemSchemaInitializer; | ||
| import com.linkedin.venice.utils.LogContext; | ||
| import com.linkedin.venice.utils.PropertyBuilder; | ||
| import com.linkedin.venice.utils.ReflectUtils; | ||
| import com.linkedin.venice.utils.RegionUtils; | ||
| import com.linkedin.venice.utils.SslUtils; | ||
| import com.linkedin.venice.utils.Utils; | ||
|
|
@@ -150,6 +152,7 @@ public static List<Class<? extends ModuleMetricEntityInterface>> getMetricEntity | |
| private final PubSubPositionTypeRegistry pubSubPositionTypeRegistry; | ||
| private final Optional<List<VeniceVersionLifecycleEventListener>> versionLifecycleEventListeners; | ||
| private final Optional<ExternalETLService> externalETLService; | ||
| private final List<ControllerPlugin> controllerPlugins; | ||
|
|
||
| /** | ||
| * Allocates a new {@code VeniceController} object. | ||
|
|
@@ -220,6 +223,7 @@ public VeniceController(VeniceControllerContext ctx) { | |
| this.versionLifecycleEventListeners = Optional.ofNullable(ctx.getVersionLifecycleEventListeners()); | ||
| this.externalETLService = Optional.ofNullable(ctx.getExternalETLService()); | ||
| this.controllerService = createControllerService(); | ||
| this.controllerPlugins = createControllerPlugins(ctx); | ||
| this.adminServer = createAdminServer(false); | ||
| this.secureAdminServer = sslEnabled ? createAdminServer(true) : null; | ||
| this.topicCleanupService = createTopicCleanupService(); | ||
|
|
@@ -267,6 +271,77 @@ private VeniceControllerService createControllerService() { | |
| return veniceControllerService; | ||
| } | ||
|
|
||
| private List<ControllerPlugin> createControllerPlugins(VeniceControllerContext ctx) { | ||
| if (!multiClusterConfigs.isParent()) { | ||
| return Collections.emptyList(); | ||
| } | ||
| Admin admin = controllerService.getVeniceHelixAdmin(); | ||
| if (!(admin instanceof VeniceParentHelixAdmin)) { | ||
| return Collections.emptyList(); | ||
| } | ||
| VeniceParentHelixAdmin parentAdmin = (VeniceParentHelixAdmin) admin; | ||
| AuthorizerService authService = ctx.getAuthorizerService(); | ||
| List<ControllerPlugin> plugins = new ArrayList<>(); | ||
|
|
||
| // Path 1: Programmatic factories from VeniceControllerContext | ||
| List<ControllerPluginFactory> factories = ctx.getControllerPluginFactories(); | ||
| if (factories != null) { | ||
| for (ControllerPluginFactory factory: factories) { | ||
| try { | ||
| ControllerPlugin plugin = factory.create(parentAdmin, authService, multiClusterConfigs); | ||
| if (plugin != null) { | ||
| plugins.add(plugin); | ||
| LOGGER.info("Created controller plugin from factory: {}", plugin.getName()); | ||
| } | ||
| } catch (Exception e) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium][logic_error] Plugin creation failures are silently caught and logged at ERROR level in both paths. For the reflection-based path (Path 2), where an operator has explicitly configured a class name in config, silent failure means the controller appears healthy while the intended plugin is not running. Consider logging at WARN level with a metric, or failing fast for the reflection path where operator intent is explicit. (codex review) |
||
| // Fail fast: a registered factory that throws is a wiring bug. Surface it at startup rather | ||
| // than letting the controller come up with the plugin silently absent. | ||
| throw new VeniceException("Failed to create controller plugin from factory.", e); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Path 2: Reflection-based discovery from config | ||
| String pluginClassNames = | ||
| multiClusterConfigs.getCommonConfig().getProps().getString(CONTROLLER_PLUGIN_CLASS_NAMES, ""); | ||
| if (!pluginClassNames.isEmpty()) { | ||
| for (String className: pluginClassNames.split(",")) { | ||
| className = className.trim(); | ||
| if (className.isEmpty()) { | ||
| continue; | ||
| } | ||
| try { | ||
| Class<?> loadedClass = ReflectUtils.loadClass(className); | ||
| if (!ControllerPlugin.class.isAssignableFrom(loadedClass)) { | ||
| throw new VeniceException( | ||
| "Configured controller plugin class does not implement " + ControllerPlugin.class.getName() + ": " | ||
| + className); | ||
| } | ||
| Class<? extends ControllerPlugin> pluginClass = loadedClass.asSubclass(ControllerPlugin.class); | ||
| ControllerPlugin plugin = ReflectUtils.callConstructor( | ||
| pluginClass, | ||
| new Class[] { VeniceParentHelixAdmin.class, AuthorizerService.class, | ||
| VeniceControllerMultiClusterConfig.class }, | ||
| new Object[] { parentAdmin, authService, multiClusterConfigs }); | ||
| plugins.add(plugin); | ||
| LOGGER.info("Created controller plugin from config: {} ({})", plugin.getName(), className); | ||
| } catch (VeniceException e) { | ||
| // Already specific (e.g. wrong type); surface as-is without re-wrapping. | ||
| throw e; | ||
| } catch (Exception e) { | ||
| // Fail fast: this class name was set explicitly by an operator. A typo, missing class, or | ||
| // missing constructor must abort startup rather than silently no-op the configured plugin. | ||
| throw new VeniceException( | ||
| "Failed to create controller plugin from configured class: " + className + ". Check the " | ||
| + CONTROLLER_PLUGIN_CLASS_NAMES + " config.", | ||
| e); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return plugins; | ||
| } | ||
|
|
||
| AdminSparkServer createAdminServer(boolean secure) { | ||
| return new AdminSparkServer( | ||
| secure ? multiClusterConfigs.getAdminSecurePort() : multiClusterConfigs.getAdminPort(), | ||
|
|
@@ -463,6 +538,17 @@ public void start() { | |
| systemStoreRepairService.ifPresent(AbstractVeniceService::start); | ||
| disabledPartitionEnablerService.ifPresent(AbstractVeniceService::start); | ||
| deferredVersionSwapService.ifPresent(AbstractVeniceService::start); | ||
| for (ControllerPlugin plugin: controllerPlugins) { | ||
| LOGGER.info("Starting controller plugin: {}", plugin.getName()); | ||
| try { | ||
| plugin.start(); | ||
| } catch (Exception e) { | ||
| // Fail fast: a configured plugin that cannot start is a deployment problem and must surface | ||
| // immediately. Abort controller startup (before service discovery registration below) rather | ||
| // than letting the controller come up healthy while the plugin is silently not running. | ||
| throw new VeniceException("Failed to start controller plugin: " + plugin.getName(), e); | ||
| } | ||
|
Comment on lines
+545
to
+550
|
||
| } | ||
| // register with service discovery at the end | ||
| asyncRetryingServiceDiscoveryAnnouncer.register(); | ||
| if (adminGrpcServer != null) { | ||
|
|
@@ -531,6 +617,9 @@ public void stop() { | |
| storeBackupVersionCleanupService.ifPresent(Utils::closeQuietlyWithErrorLogged); | ||
| disabledPartitionEnablerService.ifPresent(Utils::closeQuietlyWithErrorLogged); | ||
| deferredVersionSwapService.ifPresent(Utils::closeQuietlyWithErrorLogged); | ||
| for (ControllerPlugin plugin: controllerPlugins) { | ||
| Utils.closeQuietlyWithErrorLogged(plugin); | ||
| } | ||
| if (adminGrpcServer != null) { | ||
| adminGrpcServer.stop(); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,7 @@ public class VeniceControllerContext { | |
| private PubSubClientsFactory pubSubClientsFactory; | ||
| private List<VeniceVersionLifecycleEventListener> versionLifecycleEventListeners; | ||
| private ExternalETLService externalETLService; | ||
| private List<ControllerPluginFactory> controllerPluginFactories; | ||
|
|
||
| public List<VeniceProperties> getPropertiesList() { | ||
| return propertiesList; | ||
|
|
@@ -91,6 +92,10 @@ public ExternalETLService getExternalETLService() { | |
| return externalETLService; | ||
| } | ||
|
|
||
| public List<ControllerPluginFactory> getControllerPluginFactories() { | ||
| return controllerPluginFactories; | ||
| } | ||
|
|
||
| public VeniceControllerContext(Builder builder) { | ||
| this.propertiesList = builder.propertiesList; | ||
| this.metricsRepository = builder.metricsRepository; | ||
|
|
@@ -105,6 +110,7 @@ public VeniceControllerContext(Builder builder) { | |
| this.d2Clients = builder.d2Clients; | ||
| this.versionLifecycleEventListeners = builder.versionLifecycleEventListeners; | ||
| this.externalETLService = builder.externalETLService; | ||
| this.controllerPluginFactories = builder.controllerPluginFactories; | ||
| } | ||
|
|
||
| public static class Builder { | ||
|
|
@@ -124,6 +130,7 @@ public static class Builder { | |
| private boolean isServiceDiscoveryAnnouncerSet; | ||
| private List<VeniceVersionLifecycleEventListener> versionLifecycleEventListeners; | ||
| private ExternalETLService externalETLService; | ||
| private List<ControllerPluginFactory> controllerPluginFactories = Collections.emptyList(); | ||
|
|
||
| public Builder setPropertiesList(List<VeniceProperties> propertiesList) { | ||
| this.propertiesList = propertiesList; | ||
|
|
@@ -194,6 +201,11 @@ public Builder setExternalETLService(ExternalETLService externalETLService) { | |
| return this; | ||
| } | ||
|
|
||
| public Builder setControllerPluginFactories(List<ControllerPluginFactory> controllerPluginFactories) { | ||
| this.controllerPluginFactories = controllerPluginFactories; | ||
| return this; | ||
| } | ||
|
Comment on lines
133
to
+207
|
||
|
|
||
| private void addDefaultValues() { | ||
| if (metricsRepository == null && !isMetricsRepositorySet) { | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ControllerPluginJavadoc only mentions registration viaVeniceControllerContext.Builder#setControllerPluginFactories, but this PR also adds a config-based registration path (controller.plugin.class.names). Please update the interface documentation to reflect both supported registration mechanisms so downstream implementers know about the reflection option.