Leader Work and Partitions
Work started through Simba is bound to one term: it receives that Term, runs only while the term lasts, and is interrupted when the term ends. It never reads "the current leadership state", so a run that started in one term can never pick up the token of a later one.
Run While Leader
val task: LeaderTask = simba.election("ingest").runWhileLeader { term ->
val cursor = cursors.load()
while (term.isValid) {
val batch = source.poll(cursor) ?: continue
sink.write(batch, fencingToken = term.token)
}
}- The election starts at once. The block runs once per term, each time on a new virtual thread of the client's work executor (or on the
Executorpassed as the first argument). - When the term is revoked, its thread is interrupted and
term.revokedcompletes. Write the block so that it stops on interruption or whenterm.isValidturnsfalse. - A block that returns early keeps the term until it ends; the block does not run again until the next term.
- Exceptions are logged and published as
SimbaEvent.WorkCompletedwith outcomeFAILED(orCANCELLEDonce the term has ended); they never affect the election. task.stop()stops the election and completes once the current work exited (or ran out of time) and the lease is released;task.close()does the same and waits. See Handover Order.
runWhileLeader and schedule throw IllegalStateException when the election is already started on this client.
Schedules
schedule is runWhileLeader plus a loop: one loop per term, on one virtual thread, calling term.ensureValid() before every run.
simba.election("report").schedule(
Schedule.fixedRate(Duration.ofMinutes(1)).withInitialDelay(Duration.ofSeconds(10))
) { term ->
reportService.generate(fencingToken = term.token)
}| Factory | Period measured |
|---|---|
Schedule.fixedDelay(delay, initialDelay = 0) | From the end of one run to the start of the next |
Schedule.fixedRate(period, initialDelay = 0) | Between scheduled starts; a run that overruns skips the missed starts instead of bursting |
initialDelay counts from the start of each term. A failed run (an Error included) is logged and published, and the loop goes on; a VirtualMachineError ends the loop and steps the election down for one lease duration; the loop ends with its term. There is no thread pool per schedule.
Partition Groups
A partition group spreads partitions partitions over the live nodes of the group, each partition on exactly one node at a time:
val task: GroupTask = simba.group("billing", partitions = 16)
.schedule(Schedule.fixedDelay(Duration.ofMinutes(1))) { term, partition ->
billing.run(shard = partition, fencingToken = term.token)
}
task.partitions() // partitions this node runs in its latest viewHow it works:
- The client's session joins the group on the backend; members are the group's live sessions, so membership needs no heartbeat of its own and disappears with the session.
- Every node computes the same assignment from the member list with rendezvous hashing (
PartitionAssignment, over session ids), so adding or removing a node moves only the partitions it gains or loses. - Each partition
pis an ordinary election of the lease"{group}.{p}". A node contends only for the partitions assigned to it, and hands over (work first, then the lease) the ones it no longer owns. - The member view is refreshed on backend membership hints (Zookeeper) and every renew interval.
The lease guarantees that a partition never has two valid terms at once, even while nodes disagree about the member list during a change; the assignment only decides who contends. Every member must use the same partitions. The group name follows the lease name rules, and "{group}.{partitions - 1}" must fit in 128 characters.
runWhileLeader { term, partition -> } is available too. GroupTask.stop() leaves the group and stops every partition, completing once each one handed over, including partitions an earlier view moved away that are still handing over.
Spring Annotations
@SimbaScheduled
@Service
public class ReportJobs {
@SimbaScheduled(name = "report", fixedDelay = "1m", initialDelay = "10s")
public void generate(Term term) {
reportService.generate(term.getToken());
}
}| Attribute | Description |
|---|---|
name | Election (lease name) whose leader runs the method; unique per application |
fixedDelay | Delay between the end of one run and the start of the next |
fixedRate | Period between the starts of consecutive runs |
initialDelay | Delay before the first run of each term (default 0s) |
Set exactly one of fixedDelay and fixedRate. The method takes no parameter or a single Term.
@SimbaPartitioned
@Service
public class Billing {
@SimbaPartitioned(group = "billing", partitions = 16, fixedDelay = "1m")
public void bill(Term term, int partition) {
billing.run(partition, term.getToken());
}
}| Attribute | Description |
|---|---|
group | Group name, unique per application; partition p is elected as "{group}.{p}" |
partitions | Number of partitions; every member must use the same value |
fixedDelay / fixedRate / initialDelay | As for @SimbaScheduled, per partition |
The method takes (Term, int).
String attributes of both annotations accept ${...} placeholders, and durations use the Spring Boot formats (10s, 500ms, PT1M). Methods start after the context refreshed and stop on shutdown before the client closes; see Shutdown Order. Disable them with simba.scheduling.enabled=false.
Writing Good Leader Work
- Pass
term.tokento every write the work makes to a shared resource. A paused node can still land a write after its term ended; only a resource that checks the fencing token rejects it. - Stop on interruption. Blocking I/O that ignores interruption delays the handover up to the term's remaining time.
- Check before long steps:
term.ensureValid(expectedStepDuration)throwsTermExpiredExceptioninstead of starting a step that would outlive the term. - Make work idempotent where you can: after a failover the next leader may redo the last step.