Repository navigation
feat(rbac): launch the task worker manager proxy from app fabric - #16232
shruthi713 wants to merge 12 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the TaskWorkerManagerServiceLauncher, TaskWorkerManagerTwillApplication, and TaskWorkerManagerTwillRunnable to launch and manage the task worker manager netty proxy. It also extends KubeTwillPreparer to support a 'Recreate' deployment strategy, ensuring at-most-one pod runs during updates. Feedback on the changes highlights several critical improvements: propagating service failures in the runnable instead of swallowing them, annotating the launcher with @singleton, declaring twillController as volatile for thread safety, properly restoring the interrupted status when handling InterruptedException, and using DirUtils.deleteDirectory to prevent leaking empty temporary directories.
| try { | ||
| Uninterruptibles.getUninterruptibly(future); | ||
| LOG.debug("Task worker manager stopped"); | ||
| } catch (ExecutionException e) { | ||
| LOG.warn("Task worker manager stopped with exception", e); | ||
| } |
There was a problem hiding this comment.
When the taskManagerService fails, Uninterruptibles.getUninterruptibly(future) throws an ExecutionException. Currently, this exception is caught, logged as a warning, and the run() method exits normally. This causes the Twill container to exit with a success status (code 0), preventing Kubernetes or Twill from detecting the failure and triggering restarts or alerts.
Please propagate the failure by throwing a RuntimeException wrapping the cause of the ExecutionException.
try {
Uninterruptibles.getUninterruptibly(future);
LOG.debug("Task worker manager stopped");
} catch (ExecutionException e) {
LOG.error("Task worker manager stopped with exception", e);
throw new RuntimeException("Task worker manager service failed", e.getCause());
}There was a problem hiding this comment.
Fixed in 77eb49b. The ExecutionException cause is now logged and rethrown, so a crashed proxy fails the runnable instead of looking like a clean stop.
| * proxy only makes sense alongside a running pool, and keeping both on the same control plane | ||
| * avoids a rollout ordering problem between the cdap-operator and App Fabric. | ||
| */ | ||
| public class TaskWorkerManagerServiceLauncher extends AbstractScheduledService { |
There was a problem hiding this comment.
The TaskWorkerManagerServiceLauncher is a scheduled service that manages a singleton Twill application. To prevent accidental multiple instantiations within the Guice injector (since it is currently resolved via JIT binding), it should be annotated with @Singleton.
| public class TaskWorkerManagerServiceLauncher extends AbstractScheduledService { | |
| @com.google.inject.Singleton | |
| public class TaskWorkerManagerServiceLauncher extends AbstractScheduledService { |
| private final Configuration hConf; | ||
| private final TwillRunner twillRunner; | ||
|
|
||
| private TwillController twillController; |
There was a problem hiding this comment.
The twillController field is updated on the scheduled executor thread inside the run() method, but it is read on the service management thread inside the shutDown() method. Without the volatile modifier, there is no guarantee of memory visibility across threads, which could lead to shutDown() seeing a stale or null value and failing to terminate the active controller.
| private TwillController twillController; | |
| private volatile TwillController twillController; |
There was a problem hiding this comment.
volatile isn't needed here. AbstractScheduledService runs runOneIteration() and shutDown() under the same ReentrantLock (checked against Guava 13.0.1, which CDAP ships), so the write in runOneIteration() happens-before the read in shutDown(). TaskWorkerServiceLauncher and SystemWorkerServiceLauncher rely on the same guarantee with a non-volatile field.
| try { | ||
| if (twillController != null) { | ||
| twillController.terminate().get(10, TimeUnit.SECONDS); | ||
| } | ||
| } catch (Exception e) { | ||
| LOG.warn("Failed to terminate TaskWorkerManagerServiceLauncher run", e); | ||
| } |
There was a problem hiding this comment.
In shutDown(), calling twillController.terminate().get(10, TimeUnit.SECONDS) can throw an InterruptedException. If an InterruptedException is caught by the generic catch (Exception e) block, the thread's interrupted status is cleared and lost.
It is a best practice to catch InterruptedException explicitly and restore the interrupted status by calling Thread.currentThread().interrupt().
| try { | |
| if (twillController != null) { | |
| twillController.terminate().get(10, TimeUnit.SECONDS); | |
| } | |
| } catch (Exception e) { | |
| LOG.warn("Failed to terminate TaskWorkerManagerServiceLauncher run", e); | |
| } | |
| try { | |
| if (twillController != null) { | |
| twillController.terminate().get(10, TimeUnit.SECONDS); | |
| } | |
| } catch (InterruptedException e) { | |
| LOG.warn("Interrupted while terminating TaskWorkerManagerServiceLauncher run", e); | |
| Thread.currentThread().interrupt(); | |
| } catch (Exception e) { | |
| LOG.warn("Failed to terminate TaskWorkerManagerServiceLauncher run", e); | |
| } |
There was a problem hiding this comment.
Fixed in 77eb49b; InterruptedException now restores the interrupt flag.
| private void deleteDir(Path dir) { | ||
| try { | ||
| if (Files.isDirectory(dir)) { | ||
| DirUtils.deleteDirectoryContents(dir.toFile()); | ||
| } | ||
| } catch (IOException e) { | ||
| LOG.warn("Failed to cleanup directory {}", dir, e); | ||
| } | ||
| } |
There was a problem hiding this comment.
The deleteDir method uses DirUtils.deleteDirectoryContents(dir.toFile()), which recursively deletes all files and subdirectories inside the specified directory, but leaves the empty directory itself. Since runDir is a temporary directory created via Files.createTempDirectory, this will leak empty directories in the system's temporary folder over time.
Please use DirUtils.deleteDirectory(dir.toFile()) instead, which deletes both the contents and the directory itself.
| private void deleteDir(Path dir) { | |
| try { | |
| if (Files.isDirectory(dir)) { | |
| DirUtils.deleteDirectoryContents(dir.toFile()); | |
| } | |
| } catch (IOException e) { | |
| LOG.warn("Failed to cleanup directory {}", dir, e); | |
| } | |
| } | |
| private void deleteDir(Path dir) { | |
| try { | |
| if (Files.isDirectory(dir)) { | |
| DirUtils.deleteDirectory(dir.toFile()); | |
| } | |
| } catch (IOException e) { | |
| LOG.warn("Failed to cleanup directory {}", dir, e); | |
| } | |
| } |
There was a problem hiding this comment.
This doesn't leak. DirUtils.deleteDirectoryContents(File) delegates to deleteDirectoryContents(path, retain = false), which also deletes the root directory in postVisitDirectory. The sibling launchers use the same call.
6d7b69a to
b088a72
Compare
6e4b4ca to
2c9f8d6
Compare
a07d97f to
3a64b0d
Compare
2c9f8d6 to
e0c92fd
Compare
3a64b0d to
4f707c2
Compare
8875f67 to
e15426a
Compare
50e5925 to
62af1ed
Compare
e15426a to
7ad7a5f
Compare
62af1ed to
77eb49b
Compare
7ad7a5f to
cc05155
Compare
ed70aa4 to
db5f88a
Compare
e056d5d to
c5921e8
Compare
b870939 to
7a79b93
Compare
c5921e8 to
257549d
Compare
257549d to
e4ef9af
Compare
7a79b93 to
8b52682
Compare
e4ef9af to
c2443ae
Compare
8b52682 to
d4de2f9
Compare
c2443ae to
378ffa5
Compare
d4de2f9 to
ecf01d9
Compare
378ffa5 to
e0c00b9
Compare
ecf01d9 to
7d12ce1
Compare
Adds an opt-in for at-most-one-instance workloads. Leaving a Deployment's update strategy unset yields RollingUpdate, whose defaults at a single replica resolve to maxSurge=1 and maxUnavailable=0. The zero unavailability budget is the binding constraint: the controller cannot delete the old pod first, so it starts the replacement and briefly runs two generations of the runnable at once. For a stateless service that overlap is the point of a rolling update. For a workload holding in-memory state that must not be duplicated, it is a correctness bug. withRecreateStrategy() sets strategy type Recreate, which scales the old ReplicaSet to zero before creating the new one, trading a short availability gap for an at-most-one guarantee. Deliberately not a default method: a Twill runtime that cannot honour at-most-one should fail to compile rather than silently no-op. No existing caller opts in, so every other CDAP workload keeps the Kubernetes default.
buildDeployment built the Deployment and then reached back through getSpec() to set the strategy. Hold the nested spec builder instead and set the strategy before endSpec(), so there is no post-build mutation and no dereference that invites a null guard. A null guard there would be actively wrong: the spec is created by withNewSpec() a few lines earlier and cannot be null, and skipping the assignment if it somehow were would silently emit a RollingUpdate Deployment, which is the exact overlap this opt-in exists to prevent. No behaviour change. KubeTwillPreparerTest still asserts an unset strategy by default and Recreate when opted in.
Adds the twill application, runnable and launcher for the proxy, and gates the launch on TaskWorkerManager.isEnabled() in AppFabricProcessorServiceMain. App fabric owns this launch for the same reason it owns the task worker pool launch: the proxy only makes sense alongside a running pool, and keeping both on the same control plane avoids a rollout ordering problem between the cdap-operator and app fabric. The launcher is registered ahead of AppFabricProcessorService on purpose. Once the processor is up it dispatches tasks through the proxy, and callers only have task.worker.retry.policy.max.time.secs worth of retries to absorb the gap before the proxy registers itself in discovery. Instance count is hardcoded to 1 with a Recreate strategy rather than exposed as a config key. The lease table is in memory and shared with nothing, so a second replica would hand the same pod to two namespaces. RollingUpdate would surge to a second pod during an update and reintroduce that split brain, which is why withRecreateStrategy is required here.
TaskWorkerManagerTwillRunnable.run() now rethrows when the proxy service fails. It used to log and return, which Twill records as a successful completion, so a crashed proxy looked the same as a clean stop. TaskWorkerManagerServiceLauncher: - Annotated @singleton. It is resolved through a JIT binding, and each instance runs its own reconciliation schedule; a second instance would race the first to launch a proxy that must never have two replicas. - run() is package-private. Only runOneIteration() and same-package tests call it. - shutDown() restores the interrupt flag if interrupted while waiting for the proxy to terminate. - The launch-failure log uses SLF4J placeholders and states the retry unit.
…orker AppFabric now launches the proxy on TaskWorkerManager.isProxyEnabled(), the same predicate RemoteTaskExecutor routes on. With one task worker clients call it directly, so the proxy pod would only cost memory.
…p-operator AppFabric launches the proxy now, so the old hint about an operator that doesn't deploy it sent people to the wrong place.
… strategy test The single replica comes from the fixture's hard-coded instance count, not from withRecreateStrategy, so the assertion stayed green whatever the preparer did.
The launcher retries every reconcile tick, so a leak here would add one directory per tick for as long as the launch keeps failing.
Without one, Kubernetes marks a new proxy pod ready as soon as its container starts, several seconds before the JVM is listening, so callers are refused during every proxy restart. The launcher now passes task.worker.manager.probe.* to addProbes, the same mechanism the task workers use. The default is a readiness probe on the proxy's own GET /ping (plain HTTP, bind port, every 2s, failure threshold 3 so one slow answer under load doesn't stop routing). There is deliberately no liveness probe: restarting the proxy would cut every in-flight task. Set task.worker.manager.probe.enabled=false to disable.
…stops The proxy now drains in-flight requests on a graceful stop, but two outer limits would cut it short: the JVM's shutdown hook waits at most 30s for any runnable to stop, and Kubernetes kills a pod 30s after asking it to stop. - The shutdown hook's wait is now master.environment.k8s.runnable.stop. timeout.secs, default 30, so every other runnable is unchanged. - ExtendedTwillPreparer gets withTerminationGracePeriod, which sets the pod's terminationGracePeriodSeconds on a Deployment; unset keeps the cluster default. - The proxy launcher sets both from task.worker.manager.drain.timeout. secs: the proxy's own stop wait to drain + 10s and its pod's grace period to drain + 15s (70s and 75s by default).
…uncher tests - Set the proxy readiness probe scheme (k8s.readiness.http.scheme) to HTTPS when ssl.internal.enabled is true and HTTP otherwise so kubelet probes match TaskWorkerManagerService. - Clarify the shutdown warning in TaskWorkerManagerServiceLauncher. - Add unit tests for the HTTPS readiness probe scheme, priority class scheduler queue, and shutDown() terminating the active controller and executor.
e0c00b9 to
189c85e
Compare
7d12ce1 to
185314f
Compare
Summary
Launches the singleton
TaskWorkerManagerNetty proxy as a standalone Twill application from App Fabric whenTaskWorkerManager.isEnabled()is true.TaskWorkerManagerServiceLauncher: Scheduled service that launchesTaskWorkerManagerTwillApplication, manages instance lifecycles, setswithRecreateStrategy(), strips unmounted internal certificates, and passesENDPOINTS_SERVICES = "task.worker"incConfCopyso the proxy container discovers worker pods directly.TaskWorkerManagerTwillApplication: Standalone Twill application specifying the proxy runnable with 1 replica.TaskWorkerManagerTwillRunnable: BootsTaskWorkerManagerServicewith Guice injector in the proxy container.AppFabricProcessorServiceMain: AddsTaskWorkerManagerServiceLauncherto the scheduled services whenTaskWorkerManager.isEnabled(cConf)holds.cdap-role) must grantget,list, andwatchonendpointsso the proxy container's endpoints watcher (feat(k8s): watch V1Endpoints in KubeDiscoveryService #16226) can resolve individualtask.workerpod IPs.TaskWorkerManagerServiceLauncherTest(12 tests) verifying singleton controller adoption, recreate rollout strategy, CConfiguration endpoints discovery setup, the readiness probe (HTTP and HTTPS schemes), priority class setting,shutDown()cleanup, and the drain time limits;KubeTwillPreparerTestcovers the new grace period.Proxy restarts and planned stops
task.worker.manager.probe.*toaddProbes, the mechanism the task workers already use, and setsk8s.readiness.http.schemetoHTTPSwhenssl.internal.enabledistrue(HTTPwhenfalse; kubelet skips certificate verification onHTTPSprobe checks). The default is a readiness probe on the proxy's ownGET /ping(bind port, every 2s, failure threshold 3 so one slow answer under load doesn't stop routing). There is deliberately no liveness probe: restarting the proxy would cut every in-flight task.task.worker.manager.probe.enabled=falseturns it off.MasterEnvironmentMain, and Kubernetes' default termination grace period.master.environment.k8s.runnable.stop.timeout.secs. It defaults to 30, so every other runnable is unchanged.ExtendedTwillPreparergetswithTerminationGracePeriod, which sets the pod'sterminationGracePeriodSecondson a Deployment. Unset keeps the cluster default.task.worker.manager.drain.timeout.secs, for the proxy only: its stop wait is drain + 10s and its grace period drain + 15s (70s and 75s by default).Part of the Task Worker Manager PR stack (merge order: A #16225 and B #16226 first, then C #16229 → D #16230 → E #16231 → F #16232):