Skip to content

feat(rbac): launch the task worker manager proxy from app fabric - #16232

Open
shruthi713 wants to merge 12 commits into
feature/rbac-twm-E-routingfrom
feature/rbac-twm-F-twill
Open

shruthi713 wants to merge 12 commits into
feature/rbac-twm-E-routingfrom
feature/rbac-twm-F-twill

Conversation

@shruthi713

@shruthi713 shruthi713 commented Sep 23, 2026 •

Copy link
Copy Markdown

Summary

Launches the singleton TaskWorkerManager Netty proxy as a standalone Twill application from App Fabric when TaskWorkerManager.isEnabled() is true.

  • TaskWorkerManagerServiceLauncher: Scheduled service that launches TaskWorkerManagerTwillApplication, manages instance lifecycles, sets withRecreateStrategy(), strips unmounted internal certificates, and passes ENDPOINTS_SERVICES = "task.worker" in cConfCopy so the proxy container discovers worker pods directly.
  • TaskWorkerManagerTwillApplication: Standalone Twill application specifying the proxy runnable with 1 replica.
  • TaskWorkerManagerTwillRunnable: Boots TaskWorkerManagerService with Guice injector in the proxy container.
  • AppFabricProcessorServiceMain: Adds TaskWorkerManagerServiceLauncher to the scheduled services when TaskWorkerManager.isEnabled(cConf) holds.
  • RBAC prerequisite (when enabling the feature flag): The CDAP Kubernetes Role (cdap-role) must grant get, list, and watch on endpoints so the proxy container's endpoints watcher (feat(k8s): watch V1Endpoints in KubeDiscoveryService #16226) can resolve individual task.worker pod IPs.
  • Unit Tests: 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; KubeTwillPreparerTest covers the new grace period.

Proxy restarts and planned stops

  • Readiness probe. Without one, Kubernetes marked a new proxy pod ready as soon as its container started, several seconds before the JVM listened, so callers were refused during every restart. The launcher now passes task.worker.manager.probe.* to addProbes, the mechanism the task workers already use, and sets k8s.readiness.http.scheme to HTTPS when ssl.internal.enabled is true (HTTP when false; kubelet skips certificate verification on HTTPS probe checks). The default is a readiness probe on the proxy's own GET /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=false turns it off.
  • Time to drain. The proxy now drains in-flight requests on a graceful stop (PR D), but two outer limits would cut it short at 30s: the JVM shutdown hook in MasterEnvironmentMain, and Kubernetes' default termination grace period.
    • The hook's wait is now master.environment.k8s.runnable.stop.timeout.secs. It defaults to 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 launcher derives both from 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):

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +146 to +151
try {
Uninterruptibles.getUninterruptibly(future);
LOG.debug("Task worker manager stopped");
} catch (ExecutionException e) {
LOG.warn("Task worker manager stopped with exception", e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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());
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
public class TaskWorkerManagerServiceLauncher extends AbstractScheduledService {
@com.google.inject.Singleton
public class TaskWorkerManagerServiceLauncher extends AbstractScheduledService {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 77eb49b; the launcher is now @Singleton.

private final Configuration hConf;
private final TwillRunner twillRunner;

private TwillController twillController;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
private TwillController twillController;
private volatile TwillController twillController;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +85 to +91
try {
if (twillController != null) {
twillController.terminate().get(10, TimeUnit.SECONDS);
}
} catch (Exception e) {
LOG.warn("Failed to terminate TaskWorkerManagerServiceLauncher run", e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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().

Suggested change
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);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 77eb49b; InterruptedException now restores the interrupt flag.

Comment on lines +221 to +229
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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);
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from 6d7b69a to b088a72 Compare September 24, 2026 04:41
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from 6e4b4ca to 2c9f8d6 Compare September 24, 2026 04:42
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from a07d97f to 3a64b0d Compare September 24, 2026 17:06
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from 2c9f8d6 to e0c92fd Compare September 24, 2026 17:06
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from 3a64b0d to 4f707c2 Compare September 25, 2026 05:24
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch 2 times, most recently from 8875f67 to e15426a Compare September 25, 2026 05:35
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch 2 times, most recently from 50e5925 to 62af1ed Compare September 25, 2026 06:05
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from e15426a to 7ad7a5f Compare September 25, 2026 06:05
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from 62af1ed to 77eb49b Compare September 25, 2026 07:31
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from 7ad7a5f to cc05155 Compare September 25, 2026 07:31
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from ed70aa4 to db5f88a Compare September 25, 2026 10:12
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch 2 times, most recently from e056d5d to c5921e8 Compare September 29, 2026 08:02
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch 2 times, most recently from b870939 to 7a79b93 Compare October 6, 2026 06:53
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from c5921e8 to 257549d Compare October 6, 2026 06:53
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from 257549d to e4ef9af Compare October 8, 2026 05:04
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from 7a79b93 to 8b52682 Compare October 8, 2026 05:04
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from e4ef9af to c2443ae Compare October 8, 2026 07:00
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from 8b52682 to d4de2f9 Compare October 8, 2026 07:00
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from c2443ae to 378ffa5 Compare October 8, 2026 07:07
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from d4de2f9 to ecf01d9 Compare October 8, 2026 07:08
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-E-routing branch from 378ffa5 to e0c00b9 Compare October 13, 2026 11:45
@shruthi713
shruthi713 force-pushed the feature/rbac-twm-F-twill branch from ecf01d9 to 7d12ce1 Compare October 13, 2026 11:45
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant