Build1 publisher3 min readPublished
Cronflower trades Quartz's lock table for a scheduler cluster you run yourself
The posts show a two-process design: your app declares tasks with @Task, and a separate stateful scheduler cluster owns the schedule and enforces retry, timeout and misfire policy from outside your code.
The Engineer · Build desk

What happened
- Cronflower is an open-source distributed, stateful scheduler for Spring Boot with a web console, and its posts say it forms its own cluster and needs no external database, broker or coordinator.
- It splits into two process kinds: a scheduler that keeps task state in a store and decides when each task is due, and an executor, which is your own application declaring task methods.
- The scheduler, not the application, enforces the production attributes on @Task, so every executor behaves the same way on retries, timeouts, misfires and bounded runs.
- A second module, cronflow, declares workflows as Spring beans, where @DagNode methods are the steps, a to list gives the edges, and nodes pass data through named channels read from DagState.
- A DAG can be started from the console with a JSON seed, from a scheduled @Task in the same bean whose return value becomes the input, or from a console task set to Trigger DAG.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost Dropping the Quartz lock table and the self-written dashboard does not take stateful infrastructure out of the deployment. The state moves into scheduler processes your team now patches, backs up and restores.
- constraint A task's whole input surface is one String, so structured arguments have to be encoded into that parameter or fetched inside the method body.
- decision Adoption turns on the durability of the scheduler cluster's own store, which is what a team has to test before a revenue job depends on it firing.
- capability Work that previously needed a lock around a shared counter can be expressed as a declared merge, so the correctness argument moves out of your code and into the channel definition.
A method annotated with @Task is discovered on startup and registered with the scheduler, which then owns its schedule [4]. The annotation sits in your repository; the decision about when it fires happens in another process [2]. Your business code runs in the executor, and the scheduler is separate infrastructure you run alongside it [3].
The executor contract is narrow. A task method takes nothing, or a single String, which is the task's initialParameter, and that parameter can be a SpEL template evaluated on the executor at run time, so a value like today's date is recomputed on every fire [5]. The parser options are the distinctive part. Setting parser = "ycron" reads a year-based expression, and the month-based grammar has no way to write 0 0 12 ? ? 200, which in ycron means noon on the 200th day of the year [6]. And iso = "PT1H30M" covers every 90 minutes, an interval plain cron cannot span [7].
The no-database claim rests on the scheduler being stateful itself. It keeps task state in a store, decides when each task is due, and calls out to run it; run several and they form a cluster with a leader [2]. The posts call that persistence a store and go no further, naming no engine and saying nothing about how it replicates between nodes [25]. For a 02:00 rollup to survive the loss of the node holding its state, that store has to be replicated across the cluster, and I would want that documented before a billing job depended on it.
The reliability attributes are checkable against the console screenshots. With maxRetryCount = 2 and retryInterval = 1000, one fire becomes three rows in the execution history: attempt #0 fails, #1 fails, #2 succeeds [8]. The number therefore caps retries and not total runs, at 1 + maxRetryCount [12]. The same view names the scheduler node that dispatched the run and the executor that ran it [9].
timeout = 2000 on a method that sleeps 5000 ms marks the run as timed-out instead of letting it hang [10]. The run is 3000 ms past its budget at that point [11]. The post's verb is "marks" [10]. Whether the executor thread is interrupted would have to be read out of the source, and a 2 s timeout on a 5 s job that keeps running gives you an accurate dashboard while the worker stays busy.
Misfire policy answers the failure the first post opens with, where the box reboots at 02:00 and the nightly rollup quietly does not happen [14]. On recovery the scheduler can SKIP the missed fire, FIRE_ONCE_NOW, or FIRE_ALL of them, and the sample nightly rollup is set to SKIP [13].
The other half of the product is cronflow, the DAG orchestrator, sitting beside cronsmith, the scheduler [16]. It targets a different habit: one cron charges orders at 02:00, a second ships them at 02:15, and the fifteen-minute gap exists because that is usually long enough for the first to finish [17]. Declared as a graph, the ship step waits on an edge. I would copy the fan-in: three scorer nodes write the score channel in parallel, the channel's SUM_INT reducer merges them to 90, MAX gives maxWeight 40, and JOIN_CSV gives factors as credit,income,collateral [19].
What to watch
- Whether the project documents the scheduler's storage engine and how task state replicates between nodes in the cluster.
- Whether timeout gains documented cancellation semantics, so an overrunning executor thread is ended and not only recorded.
- Whether the executor contract widens past one String parameter, which currently forces structured input to be encoded or fetched in-method.