Bluetape4k Leader Part 2: Core APIs and Execution Models

Part 1 established the mental model: one lock name, one winner,
every other node skips. This post goes into the code you actually write. It covers the four elector
interfaces, LeaderElectionOptions, LeaderRunResult, tenant namespaces, and state snapshots — the
complete surface area of leader-core.
The runIfLeader Contract
Section titled “The runIfLeader Contract”Every elector in leader-core exposes at least one method: runIfLeader. The contract is simple:
- Only the elected node runs the action.
- Non-elected nodes return
nullwithout executing the action. - Exceptions thrown by the action propagate to the caller unchanged.
- The backend lock is released when the action returns, or expires according to the configured lease time.
The blocking interface looks like this:
interface LeaderElector { fun <T> runIfLeader( lockName: String, action: () -> T, ): T?}The return type is T?. When the caller gets null, it means another node already held the lock and the action was
not run. This mirrors ShedLock’s contention behavior: contention is not an error, it is the
expected concurrent behavior.
Source: LeaderElector.kt
When null is Ambiguous: LeaderRunResult
Section titled “When null is Ambiguous: LeaderRunResult”The nullable return works until the action itself can legitimately return null. At that point, the caller
cannot distinguish “not elected” from “elected, action returned null”.
LeaderRunResult solves this with a sealed interface:
sealed interface LeaderRunResult<out T> { data class Elected<out T>( val value: T?, val leaderId: String? = null, ) : LeaderRunResult<T>
data object Skipped : LeaderRunResult<Nothing>
data class ActionFailed(val cause: Throwable) : LeaderRunResult<Nothing>}Use runIfLeaderResult when you need the distinction:
when (val result = leaderElector.runIfLeaderResult("batch-job") { processBatch() }) { is LeaderRunResult.Elected -> log.info { "Ran as leader, value=${result.value}" } LeaderRunResult.Skipped -> log.debug { "Another node holds the lock, skipping" } is LeaderRunResult.ActionFailed -> log.error(result.cause) { "Action threw" }}CancellationException is never wrapped in ActionFailed. It propagates directly so coroutine
structured concurrency is not broken.
Source: LeaderRunResult.kt
Execution Model Matrix
Section titled “Execution Model Matrix”Different parts of a JVM service run in different concurrency models. Leader election should not force a model change on the caller.
| Model | Interface | Action type | Return type | Fits |
|---|---|---|---|---|
| Blocking | LeaderElector | () -> T | T? | MVC, batch, plain JVM |
| CompletableFuture | AsyncLeaderElector | () -> CompletableFuture<T> | CompletableFuture<T?> | Java async adapters |
| Coroutine | SuspendLeaderElector | suspend () -> T | T? | Ktor, R2DBC, WebFlux |
| Virtual thread | VirtualThreadLeaderElector | () -> T | VirtualFuture<T?> | Java 21 virtual-thread boundaries |
Blocking
Section titled “Blocking”val result: String? = leaderElector.runIfLeader("report-job") { generateReport()}Use this anywhere the calling thread can block. Spring MVC controllers, Quartz jobs, and plain
@Scheduled methods all fit here.
CompletableFuture
Section titled “CompletableFuture”val future: CompletableFuture<String?> = asyncLeaderElector.runAsyncIfLeader("report-job") { CompletableFuture.supplyAsync { generateReport() }}future.thenAccept { result -> log.info { "result=$result" } }AsyncLeaderElector ties the CompletableFuture completion to the lock lifecycle. The lock is not
released until the returned future completes, so the asynchronous action is not detached from the
leader-election path.
Coroutine
Section titled “Coroutine”val result: String? = suspendLeaderElector.runIfLeader("report-job") { generateReportSuspend()}SuspendLeaderElector accepts a suspend lambda directly. No wrappers, no runBlocking. If the
coroutine is cancelled while holding the lock, CancellationException propagates immediately and the
lock is released. A cancelled leader that keeps holding work blocks shutdown instead of making it
graceful. The action must not swallow cancellation.
Virtual thread
Section titled “Virtual thread”val future: VirtualFuture<String?> = virtualThreadLeaderElector.runAsyncIfLeader("report-job") { generateReport()}
// block the current virtual threadval result = future.await()
// or interoperate with Java APIsval cf: CompletableFuture<String?> = future.toCompletableFuture()VirtualFuture.await() parks the calling virtual thread without blocking a platform thread. Use
toCompletableFuture() when the result needs to integrate with an existing Java async pipeline.

Source: AsyncLeaderElector.kt, SuspendLeaderElector.kt, VirtualThreadLeaderElector.kt
LeaderElectionOptions in Depth
Section titled “LeaderElectionOptions in Depth”All four interfaces accept a LeaderElectionOptions parameter. The same options type works across all
execution models.
| Property | Default | Meaning |
|---|---|---|
waitTime | 5s | How long to wait attempting to acquire the lock before giving up |
leaseTime | 60s | How long the lock is held after the action completes |
nodeId | JVM process id | Stable identifier for the node holding the lock |
minLeaseTime | 0s | Minimum time the lock is held even if action completes early |
autoExtend | false | Automatically extend the lease while a long-running action is executing |
useDbTime | false | Use the database server clock instead of the application node clock |
val options = LeaderElectionOptions( waitTime = 10.seconds, leaseTime = 120.seconds, nodeId = "api-server-1", minLeaseTime = 5.seconds, autoExtend = true,)waitTime vs leaseTime — waitTime controls acquisition. If the lock is held by another node and
waitTime expires, runIfLeader returns null. leaseTime controls how long the lock persists in the
backend store after the action finishes. It prevents a dead node from blocking others indefinitely if it
never released the lock.
minLeaseTime — guarantees the lock is held for at least N seconds even when the action completes
in milliseconds. The backend enforces this via TTL. The calling thread is not parked for that duration;
the lock simply remains visible to competing nodes in the store.
autoExtend — relevant for actions that run longer than leaseTime. When true, a background
mechanism renews the lease at intervals while the action is running. Use this for long batch jobs where
a fixed leaseTime may expire before the job finishes.
useDbTime — meaningful for database-backed implementations. Clock skew across nodes can cause a
recently released lock to appear held depending on which node’s clock is consulted. When useDbTime is
true, lock timestamps are compared against the database server clock, eliminating per-node skew.
Source: LeaderElectionOptions.kt
Tenant Namespaces
Section titled “Tenant Namespaces”forTenant() wraps any elector so that every lockName is automatically prefixed with a tenant scope.
The returned elector has the same interface as the original.
The namespace format is tenant:{tenantId}:{lockName}:
tenant:acme:report-jobtenant:beta-corp:report-jobUsage:
val acmeElector = leaderElector.forTenant("acme")val result = acmeElector.runIfLeader("report-job") { generateReportForTenant("acme")}The lock names stored in the backend carry the full prefixed form. Two tenants running the same job name do not contend with each other.
Two constraints matter for the tenant id and lock name:
- The colon character (
:) is the separator and must not appear in the tenant id or lock name. - The combined prefixed name must not exceed 255 characters to stay within typical backend key limits.
The primary use case is SaaS services where each tenant runs the same scheduled job independently.
Without tenant namespacing, a single report-job lock would allow only one tenant to run at a time
across the entire cluster.
Source: TenantScopedLeaderElectors.kt
State Snapshots
Section titled “State Snapshots”LeaderElectionState.state(lockName) returns a LeaderState value for observability:
val s = leaderElector.state("batch-lock")if (s.isOccupied) { println("current leader: ${s.leader?.leaderId}")}LeaderState carries lockName, status (Empty or Occupied), and an optional leader lease
identifying the current lock holder.
The key rule: state is a best-effort snapshot. Do not use it to decide whether to run an
action. The snapshot can be stale by the time the caller acts on it. Use the atomic runIfLeader path
for all execution decisions.
State snapshots are useful for monitoring dashboards, admin APIs, and diagnostic endpoints where the goal is displaying who currently holds leadership — not making a concurrency decision.
Source: LeaderElectionState.kt, LeaderState.kt
ShedLock Comparison for Kotlin Callers
Section titled “ShedLock Comparison for Kotlin Callers”Part 1 covered the high-level differences. Here is a Kotlin-specific view:
| Concern | ShedLock | bluetape4k-leader |
|---|---|---|
| Suspend function action | Needs a wrapper or adapter | SuspendLeaderElector takes suspend action natively |
CompletableFuture action | Manual care needed to keep lock alive until future completes | AsyncLeaderElector ties future completion to lock lifecycle |
| Virtual thread action | Usually regular blocking code at the callsite | VirtualThreadLeaderElector returns VirtualFuture |
Cancellation / CancellationException | External coroutine scope handles it | Lock is released on cancellation; CancellationException re-propagates |
| Nullable action return value | true/false result or custom wrapper needed | LeaderRunResult.Elected vs Skipped carries the distinction |
| Skip semantics | @SchedulerLock skip-on-lock-fail | runIfLeader returns null; result API returns Skipped |
If the only requirement is one @Scheduled method in a Spring MVC application, ShedLock is a valid and
simpler choice. When the service has coroutine workers, per-tenant pollers, CompletableFuture chains,
or virtual-thread jobs running simultaneously, bluetape4k-leader gives them a shared options type, a
shared result type, and consistent lease semantics across all four execution models.
References
Section titled “References”- Repository: bluetape4k-leader
LeaderElector.ktAsyncLeaderElector.ktSuspendLeaderElector.ktVirtualThreadLeaderElector.ktLeaderElectionOptions.ktLeaderRunResult.kt
Closing
Section titled “Closing”The execution-model choice is usually obvious from where the calling code lives. Blocking services use
LeaderElector. Coroutine services use SuspendLeaderElector. The options and result type are the same
across all four. Part 3 will cover LeaderGroupElector — N-slot multi-leader election — and strategic
election, where the elected node is chosen by rank rather than pure lock contention.
Comments
Leave a note or reaction with your GitHub account.