Skip to content

Bluetape4k Leader Part 2: Core APIs and Execution Models

Robotic election judges comparing candidate execution models on a Kotlin JVM workbench
The execution model should match the caller, not force the caller to change.

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.

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 null without 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

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

Different parts of a JVM service run in different concurrency models. Leader election should not force a model change on the caller.

ModelInterfaceAction typeReturn typeFits
BlockingLeaderElector() -> TT?MVC, batch, plain JVM
CompletableFutureAsyncLeaderElector() -> CompletableFuture<T>CompletableFuture<T?>Java async adapters
CoroutineSuspendLeaderElectorsuspend () -> TT?Ktor, R2DBC, WebFlux
Virtual threadVirtualThreadLeaderElector() -> TVirtualFuture<T?>Java 21 virtual-thread boundaries
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.

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.

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.

val future: VirtualFuture<String?> = virtualThreadLeaderElector.runAsyncIfLeader("report-job") {
generateReport()
}
// block the current virtual thread
val result = future.await()
// or interoperate with Java APIs
val 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.

runIfLeader success and skip sequence diagram
Leader acquired: action runs and result is returned. Lock contention: null is returned without an error.

Source: AsyncLeaderElector.kt, SuspendLeaderElector.kt, VirtualThreadLeaderElector.kt

All four interfaces accept a LeaderElectionOptions parameter. The same options type works across all execution models.

PropertyDefaultMeaning
waitTime5sHow long to wait attempting to acquire the lock before giving up
leaseTime60sHow long the lock is held after the action completes
nodeIdJVM process idStable identifier for the node holding the lock
minLeaseTime0sMinimum time the lock is held even if action completes early
autoExtendfalseAutomatically extend the lease while a long-running action is executing
useDbTimefalseUse 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 leaseTimewaitTime 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

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-job
tenant:beta-corp:report-job

Usage:

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

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

Part 1 covered the high-level differences. Here is a Kotlin-specific view:

ConcernShedLockbluetape4k-leader
Suspend function actionNeeds a wrapper or adapterSuspendLeaderElector takes suspend action natively
CompletableFuture actionManual care needed to keep lock alive until future completesAsyncLeaderElector ties future completion to lock lifecycle
Virtual thread actionUsually regular blocking code at the callsiteVirtualThreadLeaderElector returns VirtualFuture
Cancellation / CancellationExceptionExternal coroutine scope handles itLock is released on cancellation; CancellationException re-propagates
Nullable action return valuetrue/false result or custom wrapper neededLeaderRunResult.Elected vs Skipped carries the distinction
Skip semantics@SchedulerLock skip-on-lock-failrunIfLeader 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.

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.