IdleSerialScheduler is a small Java utility for running short processing ticks strictly one at a time on top of a shared executor pool.
Its main property is that while no work exists, it consumes essentially no execution resources:
- no dedicated thread,
- no sleeping loop,
- no periodic polling task,
- no watchdog ticking in the background.
It becomes active only when external code explicitly signals new work via kick().
It is designed for background loops that:
- must never run in parallel,
- should become active only when new work is explicitly signaled,
- should continue processing while work remains,
- should coalesce repeated wake-up signals.
Typical use cases:
- outbox processing,
- retry queues,
- polling external system statuses,
- draining pending records from a database,
- lightweight background synchronization loops.
A common naive approach looks like this:
while (true) {
if (hasWork()) {
runOnce();
}
Thread.sleep(1000);
}This has obvious drawbacks:
- it permanently occupies a thread,
- it reacts slowly or wastes CPU depending on sleep duration,
- it is awkward to integrate with shared executor pools,
- it often leads to accidental parallel execution once “improved”.
In contrast, IdleSerialScheduler does not keep any background activity alive on its own while idle.
If no work exists, nothing is spinning, sleeping, polling, or waking up periodically.
IdleSerialScheduler follows a different model:
- when work appears, external code calls
kick(), - one processing tick runs,
- if work still remains, the next tick is scheduled after a delay,
- if no work remains, the scheduler becomes idle and does not occupy a thread.
- Strictly sequential execution —
runOnce()is never executed concurrently. - Truly idle when no work exists — no dedicated thread, no timer loop, no watchdog task, no background polling activity.
- Coalesced wake-ups — many
kick()calls collapse into at most one queued immediate tick. - Delayed continuation — if work remains, processing continues automatically after the configured delay.
- Shared-pool friendly — runs on top of a provided backend
Executor.
Execution model:
new work -> kick() -> runOnce()
-> if work remains -> delay -> next runOnce()
-> if no work remains -> idle
Important: when new work arrives, external code must call kick().
ExecutorService backendPool = Executors.newFixedThreadPool(4);
AtomicInteger remaining = new AtomicInteger(10);
IdleSerialScheduler scheduler = new IdleSerialScheduler(
backendPool,
1,
TimeUnit.SECONDS,
() -> {
// process one small batch / one tick
System.out.println("tick");
remaining.decrementAndGet();
},
() -> remaining.get() > 0,
error -> error.printStackTrace()
);
// signal that work is available
scheduler.kick();Main constructor:
public IdleSerialScheduler(
Executor backendPool,
long delay,
TimeUnit unit,
Runnable runOnce,
BooleanSupplier hasWork,
Consumer<Throwable> errorHandler
)Parameters:
backendPool— shared executor used for actual execution,delay/unit— delay between continuation ticks,runOnce— one short processing tick,hasWork— cheap check whether more work remains,errorHandler— receives exceptions thrown by processing tasks.
A low-level utility that guarantees strict sequential execution of submitted tasks on top of a backend executor.
There are two different failure categories.
If user task code throws:
- the error is passed to
errorHandler, - queue progress continues,
- subsequent tasks are still processed.
If the backend executor rejects task submission:
- the exception is propagated to the caller,
- queued head task is not dropped,
- execution can continue later when submission succeeds again.
This utility provides best-effort coalescing.
That means:
- repeated
kick()calls are aggressively collapsed, - no parallel ticks will happen,
- but edge interleavings around an already-running tick and an already-planned delayed continuation are intentionally handled in a practical, not mathematically minimal, way.
This is a deliberate tradeoff for keeping the implementation small, predictable, and robust.
Also note:
hasWorkshould be cheap,runOnceshould perform bounded work and return reasonably quickly,- this is not a cron scheduler or general job framework.
The project includes JUnit 5 tests for:
- coalescing of immediate kicks,
- absence of parallel execution,
- delayed continuation while work remains,
- no self-rescheduling after work is exhausted,
- continued progress after task failure,
- behavior under concurrent kicks,
- backend rejection recovery,
- serial executor guarantees.
MIT