Skip to content

Bluetape4k Leader Part 5: Backends, Operations, and Benchmarks

Robotic builders arranging Redis, etcd, SQL, Kubernetes, and observability props on a leader election storage workbench
Changing the backend changes the operational model first: leases, TTLs, renewal, and observability all move with storage choice.

Part 4 covered Spring Boot annotation/AOP and Ktor application lifecycle boundaries. Part 5 moves one level down. The same runIfLeader call behaves differently operationally depending on whether the lease lives in Redis, etcd, a SQL row, or a Kubernetes Lease object.

That makes backend choice less about API style and more about operations. Leader election should stay quiet during normal contention, but it must be explainable when something fails: who ran, who skipped, who extended the lease, and which backend failed.

Decision map for choosing leader election storage backends by operation shape
The backend-picker is not a performance leaderboard. It starts from the systems your team already knows how to operate.

The first pass is usually this table.

Existing substrateStart withWhy
Redis is the shared cache/lock substrateLettuce or RedissonRedis TTL plus token ownership makes single-leader locks straightforward. Lettuce is a lean client path; Redisson is familiar when lock-oriented Redis operations already exist.
etcd is control-plane truthetcd v3Lease and conditional write semantics fit reconciler/control-plane work.
Database operations own the recovery pathExposed JDBC/R2DBCLock rows, tokens, and expiry timestamps are easy to inspect, migrate, and audit.
The workload runs inside KubernetesKubernetes Leasecoordination.k8s.io/v1 Lease objects match pod/operator lifecycle and API-server observability.
MongoDB, DynamoDB, Consul, Hazelcast, or ZooKeeper is already presentThe matching backendAvoiding a new infrastructure dependency can matter more than micro-benchmark rank.

The reason is simple: backend choice brings lease storage rules with it. Who may extend the lease, how the owner token is checked, how expired ownership is cleaned up, and where operators inspect state all change with the backend.

Redis is the most natural default. If Redis already carries cache, rate-limit, or short-lived coordination traffic, leader locks can stay in the same operational model.

bluetape4k-leader exposes two Redis families.

BackendBest fitWatch
leader-redis-lettuceTeams already operating Lettuce and wanting a lean blocking/suspend pathLease TTL, token validation, and auto-extension are application-level contracts.
leader-redis-redissonTeams already using Redisson and its lock-oriented operational modelElectors pass an explicit leaseTime, so the benchmarked autoExtend path is the shared bluetape4k extender, not Redisson native watchdog renewal.

Long jobs can opt into lease renewal with autoExtend = true. Group election auto-extension is not supported yet because each slot has its own lease and completion boundary.

val options = LeaderElectionOptions(
leaseTime = 30.seconds,
autoExtend = true,
)
val election = RedissonLeaderElector(client, options)
election.runIfLeader("nightly-maintenance") {
LockAssert.assertLocked("nightly-maintenance")
LockExtender.extendActiveLock("nightly-maintenance", 60.seconds)
runLongJob()
}

LockAssert checks that the current code is actually running under a real lock. It does not pass outside a lock or inside a fail-open sentinel. LockExtender extends the active lock. Internally, detailed extension outcomes distinguish not-held, wrong-thread, and backend-failure cases so operators are not left with only a collapsed Boolean.

etcd fits teams that already treat it as control-plane truth. If a reconciler must apply desired state from exactly one node, etcd leases are a natural model.

The selection question is different from Redis. It is not “does the service already connect to Redis?” but “is the authoritative state for this work already in etcd?” When the answer is yes, choosing etcd can make the operational story clearer than adding a separate Redis dependency.

The examples/etcd-reconciler project follows this shape: one node applies desired state while the rest observe or skip.

Part 5 is not a ranking contest. It is a map.

FamilyStorage meaningHow to read it
Exposed JDBC/R2DBCLock row, token, expiry timestamp, optional history rowGood for migration gates, tenant aggregation, and audit-heavy batch work where SQL visibility matters.
MongoDBfindOneAndUpdate plus TTL-index style ownershipUseful when MongoDB is already the operational store for short-lived ownership documents.
DynamoDBConditional item write and TTL-shaped ownershipA candidate when AWS-native operations matter. The benchmark row is noisy enough to require repeat measurement before tuning.
ConsulSession + KVFits maintenance/drain workflows that already use the Consul service model.
Kubernetes Leasecoordination.k8s.io/v1 Lease objectUse when pod/operator lifecycle and Kubernetes API observability are the natural boundary.
HazelcastIMap-based backendMeaningful when a Hazelcast cluster is already runtime substrate. README explicitly says this is IMap-based, not CP Subsystem based.
ZooKeeperCurator lock/semaphoreA candidate when ZooKeeper/Curator remains the existing coordination substrate.

Operations layer: extend, assert, record, measure

Section titled “Operations layer: extend, assert, record, measure”

After the backend is chosen, the operational questions remain.

QuestionTool
Can the task outlive the initial lease?autoExtend, LeaderLeaseAutoExtender, LockExtender
Is this code really inside a lock scope?LockAssert.assertLocked() / assertLockedSuspend()
Who led, completed, or failed?Leader history recorder and backend-specific history sinks
How many AOP calls attempted, acquired, skipped, or failed?leader-micrometer and MicrometerLeaderAopMetricsRecorder

The history recorder is best-effort around sink failures: recording should not destroy the protected task. Cancellation is still rethrown. The same distinction showed up in Part 4’s lifecycle discussion: cancellation is cancellation; ordinary failure can be logged and recorded.

Micrometer records attempts, acquisition, not-acquired reasons, execution duration, task failures, and active gauges around the AOP boundary. The operational rule is to keep normal contention separate from backend failure. Contention is an expected skip. Backend failure is a reliability event.

The benchmark README starts with a boundary: these are comparable kotlinx-benchmark suites for same-machine before/after comparison, not release-grade performance claims. The 2026-05-21 baseline used one fork, one thread, two warmup iterations, and three one-second measurement iterations. PostgreSQL/MySQL rows were added on 2026-05-29, and focused Redis lease-extension rows were added on 2026-06-01.

Distributed backend throughput chart for bluetape4k-leader
Distributed backend throughput. Higher is better. Local and H2 shape rows are intentionally excluded from this chart.
Distributed backend latency chart for bluetape4k-leader
Distributed backend average time. Lower is better. PostgreSQL/MySQL exposed rows are much slower in this same-machine benchmark shape.

Representative rows:

RowThroughput ops/sAverage time us/opReading
Lettuce Redis blocking1,454.7699.4Redis backends sit in the upper distributed-backend band.
Redisson Redis blocking1,415.8699.7Similar band; operational client preference matters.
Hazelcast blocking1,460.9766.3Same upper band in this benchmark.
MongoDB blocking843.71,131.0Competitive document-store row.
ZooKeeper blocking804.31,372.2Curator-backed coordination candidate.
Consul blocking593.61,900.6Read it in Consul session/KV operational context.
etcd suspend467.52,239.4Control-plane fit can matter more than raw rank.
PostgreSQL/MySQL exposed rows50-80 range13,000-17,000 rangeDistributed SQL rows are slower here. Do not compare them with local/H2 structure checks.

Kubernetes Lease runs in a separate benchmark source set with K3s Testcontainers. README representative rows are 171.5 ops/s for blocking and 164.7 ops/s for suspend. That separate target matters because the Fabric8 client runtime differs from the default etcd/Vert.x runtime.

The Redis lease-extension benchmark answers a narrower question. Normal runIfLeader and autoExtend rows are within the JMH error range. runIfLeaderWithRenewalWindow uses a 90 ms lease and 45 ms action dwell to force the extension path. If a row reads 50,000 us/op, treat it as 50 ms/op, not 50 microseconds, and do not read that row as the everyday cost of leader election.

If you want to jump to code, start with examples.

ExampleBackendWhat to inspect
examples/batch-schedulerRedis/SpringCluster-wide scheduled batch
examples/migration-gateExposed JDBCRun schema/data migration on one node
examples/webhook-pollerRedisAvoid duplicate polling
examples/cache-warmerRedisCoordinate cache warming
examples/tenant-aggregatorExposed R2DBCIndependent leader per tenant
examples/ktor-appKtor + LettuceLeaderElectionPlugin and leaderScheduled
examples/prometheus-dashboardSpring + LettuceAOP metrics, Prometheus, Grafana
examples/etcd-reconcileretcdDesired-state reconciler
examples/consul-maintenanceConsulMaintenance/drain workflow
examples/k8s-leaseKubernetes LeaseLow-level Lease acquire/release/reacquire
examples/k8s-operatorKubernetes Lease + SpringThree-replica operator pattern
examples/rate-limiterLettuce + Bucket4jLeader-dispatched external API probes
examples/redisson-watchdogRedissonLong-running job and shared lease extender

Comments

Leave a note or reaction with your GitHub account.