Skip to content

[FLINK-40019][core][runtime] Support per-job delegation tokens - #28639

Open
Savonitar wants to merge 18 commits into
apache:masterfrom
Savonitar:flip-delegation-token-hooks
Open

Savonitar wants to merge 18 commits into
apache:masterfrom
Savonitar:flip-delegation-token-hooks

Conversation

@Savonitar

@Savonitar Savonitar commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

What is the purpose of the change

Flink's delegation token framework is cluster-scoped: a DelegationTokenProvider obtains one set of tokens for the whole cluster and has no notion of an individual job. This breaks multi-tenant setups where jobs running on a shared cluster (e.g. a session cluster) need to authenticate as different identities against the same external service. This PR implements FLIP-588: it adds per-job awareness to the provider SPI plus the runtime wiring to invoke it. All new SPI methods are default, so existing providers keep working unchanged.

Brief change log

  • Manager lifecycle split into session scope and process scope: stop() (once per ResourceManager leadership session) stops scheduling, releases the listener and unregisters the session's jobs, while the new DelegationTokenManager.close() stops the providers exactly once at process shutdown (wired into ClusterEntrypoint, MiniCluster and YarnClusterDescriptor's one-shot obtain), so provider instances survive leadership changes
  • DelegationTokenProvider gains default methods: an init(Configuration, DelegationTokenManagerCallback) overload, registerJob(JobID, Configuration), unregisterJob(JobID) and stop()
  • New @Experimental interface DelegationTokenManagerCallback with a single reobtainDelegationTokens(), handed to providers at init time so they can request an on-demand obtain-and-broadcast cycle (e.g. right after a new job registers)
  • DelegationTokenManager (@Internal) gains the corresponding methods as no-op defaults, so NoOpDelegationTokenManager and other implementations are unaffected
  • DefaultDelegationTokenManager fans registerJob/unregisterJob out to all providers: a failed first registration is rolled back from all providers and rethrown, a failed re-registration keeps the previous registration so a running job's tokens are not dropped, and per-provider failures during unregistration are caught so one provider cannot abort the cleanup of the others.
  • On-demand re-obtains reuse the existing renewal machinery: concurrent requests are coalesced, throttled by the new security.delegation.tokens.reobtain.cooldown option (default 30s), and only ever bring the next cycle forward, never delaying a scheduled renewal
  • ResourceManager calls registerJob when a JobMaster registers and rejects the registration if it throws, so a job never starts without the tokens it requires; unregisterJob is called when the job is removed
  • ResourceManagerGateway#registerJobMaster is widened to carry the job Configuration (the JobMaster now sends executionPlan.getJobConfiguration(); a backward-compatible overload is kept)
  • Generated configuration docs updated for the new option

Verifying this change

This change added tests and can be verified as follows:

  • Extended DefaultDelegationTokenManagerTest (manually-triggered executors) covering: register/unregister fan-out with rollback on failure, coalescing of concurrent re-obtains, cooldown behavior (first request immediate, deferral within the window, reset on stop()); periodic-renewal vs. on-demand interplay, idempotent registration, and serialized obtain cycles
  • Added ResourceManagerJobMasterTest#testRegisterJobMasterRejectedWhenDelegationTokenRegistrationFails, which drives the widened RPC and asserts the JobMaster registration is rejected when the delegation token manager's registerJob throws
  • Existing delegation token provider implementations are untouched and their tests still pass (the new SPI methods are default, so existing providers compile and run unchanged)
  • Additionally verified end-to-end with a Kafka connector prototype (per-job OAuth tokens against a real SASL/OAUTHBEARER broker with per-principal ACLs); that connector work will be proposed separately to flink-connector-kafka

Does this pull request potentially affect one of the following parts:

  • Dependencies (does it add or upgrade a dependency): no
  • The public API, i.e., is any changed class annotated with @Public(Evolving): SecurityOptions (@PublicEvolving) gains the new security.delegation.tokens.reobtain.cooldown option, the extended/new SPI types (DelegationTokenProvider, DelegationTokenManagerCallback) are @experimental
  • The serializers: no
  • The runtime per-record code paths (performance sensitive): no
  • Anything that affects deployment or recovery: the JobMaster registration path in the ResourceManager: registration is rejected if registerJob throws, and registerJob is invoked again on JobMaster re-registration after failover (the SPI contract requires it to be idempotent)
  • The S3 file system connector: no

Documentation

  • Does this pull request introduce a new feature? yes
  • If yes, how is the feature documented? docs / JavaDocs: the new config option is in the generated configuration reference, the SPI contracts (threading, idempotency, ordering) are documented in the JavaDocs, and the overall design is in FLIP-588

Was generative AI tooling used to co-author this PR?
  • Yes (Claude Opus 4.8, via Claude Code)

@flinkbot

flinkbot commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands The @flinkbot bot supports the following commands:
  • @flinkbot run azure re-run the last Azure build

@Savonitar
Savonitar force-pushed the flip-delegation-token-hooks branch from ed1172e to 84abc1d Compare July 4, 2026 15:25
@Savonitar

Copy link
Copy Markdown
Contributor Author

The PR is currently a draft, but I'm finalizing it. It's mostly ready, just notifying you as discussed. I'll finish the voting soon and move it to 'Ready for Review' next week.
cc: @gaborgsomogyi

@gaborgsomogyi gaborgsomogyi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the contribution! Even if it's a draft I wanted to add my thoughts early.

* <p>Backs {@link
* org.apache.flink.core.security.token.DelegationTokenManagerCallback#reobtainDelegationTokens()}.
*/
default void reobtainDelegationTokens() {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we can have such new functions unimplemented and say that job aware approach is a mush have for all managers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do you suggest to drop default bodies and make all 3 (registerJob, unregisterJob, reobtainDelegationTokens) are abstract on the interface?
CONS:

  1. Downstream forks with custom managers break at compile.
  2. Will it create a precedent to going abstract instead of default which will repeat ^ (breaking compilation) in the future again?
  3. Asymmetry with the provider SPI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've not seen any such hardcode user who has done that but we can keep this to be on the safe side 🙂

this.listener = checkNotNull(listener, "Listener must not be null");
synchronized (tokensUpdateFutureLock) {
checkState(tokensUpdateFuture == null, "Manager is already started");
stopped = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think setting this flag freewillingly is simply wrong. AFIAU the whole point here is to go through the start/stop sequence to keep consistency inside the whole manager ecosystem. Here are my thouhgts:

  • Maybe we can call it started
  • Set started=true at the end of start and early return if already started
  • Set started=fase at the end of stop and early return if not started

If you have something fundamentally different in mind then feel free to challange it.

@Savonitar Savonitar Jul 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on the rename and the early returns, implemented in 69438d6. However, I used the name running because it is used more often in the codebase.

And another part I did differently: I set running = true before the inline first cycle in start(), exactly where stopped = false sat, not at the end of the method (if I understand your suggestion correctly). The first obtain runs inline and checks the flag twice, at the top of startTokensUpdate() and again in maybeScheduleRenewal() when it schedules the periodic renewal. With the flag flipped only at the end of start(), the inline cycle sees not-running, skips the whole first obtain, and never schedules the renewal. startShouldBeIdempotent and startTokensUpdateShouldScheduleRenewal lock that ordering in, both fail if the flag moves after the cycle.

Same reason inverted on the stop() side: running = false flips before the per-job unregistration below it, so a re-obtain racing shutdown cannot schedule a cycle for a session that is shutting down. That cleanup also runs unconditionally (no early return) because ResourceManager calls stop() on the failed-startup path even when start() never ran.

lastReobtainAtMillis = NO_PREVIOUS_REOBTAIN;
}

for (DelegationTokenProvider provider : delegationTokenProviders.values()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The manager is already stopped and start can be called from the other side from another thread. I think that providers will stuck in unkown state under the following circumstances:

  • All providers started
  • Stop called but providers not yet stopped
  • Start called from another thread and start some/all of them
  • Stop continues and stop some/all of them
  • Realize that even if functions called in proper order some of the providers are stopped

I think we must support all situations like this because the manager is the spine of authentication.

@Savonitar Savonitar Jul 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Start called from another thread and start some/all of them

Providers have no start/restart hooks: init() runs once in the manager constructor, and start() only drives the obtain logic, so there is nothing start() could call to re-start a provider mid stop. An obtain cycle overlapping stop() is possible, but that overlap is already covered by the stop() javadoc.

But another issue is possible. The manager instance is reused across leadership sessions, so it is possible to have stop() then start() on the same manager with the same provider instances and no re-init. A provider that closes its resources in stop() (as the javadoc tells it to) is broken in the next leadership term. The SPI javadoc says stop() is "called once during manager shutdown" and the wiring does not honor that.

I see 2 options (taking into account the existing architecture/design):

1.Fan out provider.stop() only at process shutdown and let manager stop() just stop scheduling. Providers keep a single init-to-stop lifecycle, matching their single init.
Or
2.Keep per-session stop() but redefine the contract: stop() may be followed by another start(), so providers must release only per-run resources and re-acquire lazy. Smaller diff, but it changes the agreed "called once" approach and pushes restart-stuff into every provider implementation.

I implemented option 1 in 3e53c90. DelegationTokenManager.close() is the terminal teardown: it ends the session via stop(), then stops the providers exactly once, and a closed manager rejects a later start(). It is called by the component that created the manager: ClusterEntrypoint, MiniCluster, and YarnClusterDescriptor. The per-session stop() keeps the providers usable for the next leadership session. It is a single commit, so easy to rework if you prefer option 2.

WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think previously I've not made distinction between stop and close. Let me explain my current understanding and correct me if I'm wrong. stop (together with start) can happen when HA kicks in during failover and it makes sure that new leader re-obtained tokens. stop has nothing to do with freeing resources allocated by the provider and this function called regularly. close happens during process shutdown and practically a single no way back action which should free resources. At least this is what I see from the manager from naming perspective. If this is true then maybe we can call DelegationTokenProvider.stop as DelegationTokenProvider.close just to have a single convention. If my understanding is correct then this is a one way process shutdown and good as-is and we shouldn't care about any race.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. Renamed stop() to close() in [FLINK-40019][core][runtime] Rename DelegationTokenProvider.stop() to close() . Agree it meets naming API better.

correct me if I'm wrong
stop (together with start) can happen when HA kicks in during failover and it makes sure that new leader re-obtained tokens.
stop has nothing to do with freeing resources allocated by the provider and this function called regularly.

yes, your understanding is correct 👍

@Savonitar

Savonitar commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the contribution! Even if it's a draft I wanted to add my thoughts early.

Hi @gaborgsomogyi , thanks for the early review! Ive kept this PR as a draft because I actually had the similar questions about some items you pointed out.
I'm glad we're on the same page and can discuss them. I'm currently working on some updates on my end to address these points. Thanks again for the feedback!

@Savonitar
Savonitar force-pushed the flip-delegation-token-hooks branch 4 times, most recently from fef300b to dd46acc Compare July 23, 2026 08:55
@Savonitar
Savonitar marked this pull request as ready for review July 23, 2026 12:04
@Savonitar

Copy link
Copy Markdown
Contributor Author

Hi @gaborgsomogyi . Appreciate your pre-review feedback, it was really helpful. Beyond addressing it, I found and fixed a few more gaps in follow-up commits. Moving the PR to "ready for review". I kept the branch as separate logical commits on top of the commit you already reviewed instead of squashing everything, so the increments stay reviewable. Let me know if you prefer a single squashed commit instead.

@Savonitar
Savonitar requested a review from gaborgsomogyi July 27, 2026 16:09

@gaborgsomogyi gaborgsomogyi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the enhancements, I've had another round

// way.
try {
delegationTokenManager.registerJob(jobId, jobConfiguration);
} catch (Exception | LinkageError e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we want to prepare for LinkageError? Giving a meaningful rejection is fine here but as a general saying any provider that throws such should not be considered healthy. To be exact I'm against to treat providers inside the manager which throw such exception to be tracked. Temporary exceptions can happen but this is deployment/compile issue which should just block workloads.

@Savonitar Savonitar Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why do we want to prepare for LinkageError?

The LinkageError catch is there so a failed registration doesn't leave job state in the providers that already registered the job.
It doesn't swallow the error: registerJob rolls back, logs, and rethrows it unchanged. Since 8c56630 the manager also doesn't track a job whose registration failed.
I also conducted experiments with a real JobManager JVM (session cluster, both without HA and with ZooKeeper HA) using a provider whose registerJob() throws NoClassDefFoundError, with and without the catch.

I see three options:

  1. Keep the catch (current branch): a failed registration is rolled back on all providers and logged at ERROR with the job and provider, and the error is rethrown so the registration is rejected.
  2. Remove the catch (the suggestion, if I understand it correctly): the registration is rejected the same way and the JobManager stays up, but nothing rolls back. Providers that already registered the job keep its state until the job ends and the job timeout fires (HA), or until the JobManager shuts down (no HA, or a job that keeps restarting). The manager no longer logs the failure at ERROR. A process-level failure happens only if a provider's token obtain later throws an Error because of that state (a crash loop under HA).
  3. Remove the catch and fail explicitly: escalate a LinkageError from registerJob() with onFatalError, so any broken provider deployment fails the JobManager, at the cost of the other jobs on a session cluster.

Please correct me if I'm missing something and appreciate if you can share your opinion on these tradeoffs.

* @param timeout Timeout for the future to complete
* @return Future registration response
*/
default CompletableFuture<RegistrationResponse> registerJobMaster(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IIUC it's a dead weight that only exists to avoid touching two test files. If that's true maybe we can modify those tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 7c131da and tests now pass the empty configuration explicitly

* <p>Backs {@link
* org.apache.flink.core.security.token.DelegationTokenManagerCallback#reobtainDelegationTokens()}.
*/
default void reobtainDelegationTokens() {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've not seen any such hardcode user who has done that but we can keep this to be on the safe side 🙂

final Throwable reason = ((RegistrationResponse.Failure) response).getReason();
assertThat(reason.getMessage()).contains(jobId.toString());
assertThat(reason.getMessage()).contains("delegation token manager");
assertThat(reason.getCause().getMessage()).contains("registerJob rejected by provider");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice that this locks in the RPC-level failure response, but it doesn't cover what happens to jobLeaderIdService afterward. jobLeaderIdService.addJob(jobId) runs unconditionally before the delegation-token check and nothing removes it on this failure path, so the job stays tracked until either a retry succeeds (containsJob short-circuits addJob and the retry re-attempts registerJob) or the leader-id timeout fires. Could we add a test that retries registration after this failure and asserts it eventually succeeds end-to-end?

// caught so a plugin classpath failure is reported the same
// way.
try {
delegationTokenManager.registerJob(jobId, jobConfiguration);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To clarify my earlier comment on the test: I'm not claiming a prod bug here, this looks self-healing by design (retry skips addJob via containsJob, cleanup happens via removeJob/leader-id timeout otherwise). What's missing is bookkeeping test coverage: a test that fails delegation-token registration once, then retries, and asserts the retry completes end-to-end without leaving jobLeaderIdService in a duplicated or stale state. This path touches token delivery so I would like it locked in by a test rather than relying on inspection.

}
},
delayMs,
TimeUnit.MILLISECONDS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RejectedExecutionException doesn't necessarily mean shutdown, it's also thrown when a bounded queue is saturated while the executor is still alive. scheduledExecutor is a generic ScheduledExecutor, so we can't assume shutdown here. If it's actually saturation, this catch drops the renewal cycle for the rest of the session with no retry. Shouldn't this schedule a retry instead of assuming shutdown?

lastReobtainAtMillis = NO_PREVIOUS_REOBTAIN;
}

for (DelegationTokenProvider provider : delegationTokenProviders.values()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think previously I've not made distinction between stop and close. Let me explain my current understanding and correct me if I'm wrong. stop (together with start) can happen when HA kicks in during failover and it makes sure that new leader re-obtained tokens. stop has nothing to do with freeing resources allocated by the provider and this function called regularly. close happens during process shutdown and practically a single no way back action which should free resources. At least this is what I see from the manager from naming perspective. If this is true then maybe we can call DelegationTokenProvider.stop as DelegationTokenProvider.close just to have a single convention. If my understanding is correct then this is a one way process shutdown and good as-is and we shouldn't care about any race.

}
}

@VisibleForTesting

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

currentRetryBackoff and lastKnownNextRenewal are only ever read/written while obtainLock is held (start(), startTokensUpdate(), here), but neither is annotated. Should both be @GuardedBy("obtainLock") for consistency with the rest of the fields in this class.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

adresed in 90562c7

* wait for an in-flight cycle and the IO executor is multi-threaded, two cycles can never run
* concurrently and broadcast tokens out of order.
*/
private final Object obtainLock = new Object();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two locks here, each protecting a distinct concern: obtainLock serializes one full obtain->broadcast->retry-bookkeeping cycle, tokensUpdateFutureLock guards scheduling/lifecycle state (when the next cycle runs, running, sessionEpoch, listener). tokensUpdateFutureLock doesn't reflect that, it sounds like it only guards one field. Suggest renaming obtainLock to renewalCycleLock and tokensUpdateFutureLock to schedulingLock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed as suggested in 040b178

@Savonitar
Savonitar force-pushed the flip-delegation-token-hooks branch 9 times, most recently from c226b4a to f0d4a1b Compare September 24, 2026 17:05
Rename the stopped flag to running so a never-started manager rejects
work like a stopped one, and make start() idempotent. A retry no longer
delays a pending re-obtain cycle, a scheduler failure no longer blocks
future re-obtains, and a negative renewal delay runs immediately.
…re in job registration and cleanup

A LinkageError from provider plugin code (realistically a
NoClassDefFoundError, the same failure class loadProviders already
special-cases at init) previously slipped past the Exception-only
catches on both job hooks.

The ResourceManager now also wraps the rethrown registration failure
into a FlinkException naming the job before it is sent back in the
RegistrationResponse, so the JobMaster log points at the job and the
delegation token manager instead of a bare provider exception.
…ss re-registrations and sessions

A failed re-registration of an already-registered job no longer rolls the
job back, so a transient provider failure during a JobMaster reconnect
cannot wipe a running job's token state. A manager-held registry tracks
jobs whose provider state may exist: failed unregistrations stay tracked
and are retried by the stop() drain, and stop() releases the listener and
all job registrations so nothing leaks across leadership sessions. start()
resets the retry backoff, the re-obtain cooldown is anchored to cycle
executions (as the config option documents), an in-flight obtain cycle
re-checks the running state before broadcasting, and providers receive a
defensive copy of the job configuration.
…process shutdown

DelegationTokenManager.close() is the terminal teardown: it ends any
active session via stop() and then stops all providers, exactly once, and
a closed manager rejects a later start(). It is called by the component
that created the manager: ClusterEntrypoint, MiniCluster, and
YarnClusterDescriptor's one-shot client-side obtain. stop() stays
session-scoped (cancel scheduling, release the listener, drain job
registrations) and keeps the providers usable for the next ResourceManager
leadership session, so the provider stop() javadoc — called at most once,
at process shutdown, never on leadership changes — holds as written.
…ock and tokensUpdateFutureLock to schedulingLock
…egistrations

Track jobs only after all providers accept registration, and drop them
after unregistration even if cleanup fails.

Document the cleanup policy and cover both failure paths in tests.

Generated-by: Fable 5.1
Generated-by: Codex (GPT-6)
Require the job configuration when registering a JobMaster with the
ResourceManager. Update the existing tests to pass an empty configuration.
…r token failure

A failed delegation token registration must return an RPC failure and leave
no usable JobMaster registration behind. Verify that retrying the same
registration request succeeds and resource declarations are then accepted.

Generated-by: Codex (GPT-6)
…ter token failure

Run a real JobMaster against a ResourceManager whose token manager fails
the first registration. Verify automatic recovery preserves the job ID
and configuration, keeps job leader monitoring active without restarting
it, and allows resource declarations after registration succeeds.

Generated-by: Codex (GPT-6)
@Savonitar
Savonitar force-pushed the flip-delegation-token-hooks branch from f0d4a1b to 8b25efa Compare September 24, 2026 19:39
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.

3 participants