Most Java teams reach for a scheduling library the moment a feature needs to run "later" or "again." Then they discover the four things they actually needed - background execution, retries, CLI-triggered jobs, and cron tasks - live in four unrelated tools with four different mental models. Solon takes a different route. It ships one module, solon-scheduling, and exposes four scheduling verbs on top of it, each behind a single enable switch.
Here is the whole surface area:
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-scheduling</artifactId>
</dependency>
One dependency. Four annotations to turn things on: @EnableAsync, @EnableRetry, @EnableCommand, @EnableScheduling. You opt into exactly the verbs you use, and nothing else wakes up.
Async: fire it and move on
Background work is the most common "I'll deal with it later" case. In Solon you mark a method @Async and it runs on the async executor instead of the calling thread.
@EnableAsync
public class DemoApp {
public static void main(String[] args) {
Solon.start(DemoApp.class, args);
}
}
@Component
public class AsyncTask {
@Async
public void test(String hint) {
System.out.println(Thread.currentThread().getName());
}
}
Two honest limitations the docs call out, and I appreciate that they do: async methods can't join a transaction chain, and anything still queued when you restart is gone. So @Async is for work that is genuinely fire-and-forget, not for things that must survive a crash.
When the default executor isn't enough, you replace it by implementing AsyncExecutor:
@Component
public class AsyncExecutorImpl implements AsyncExecutor {
ExecutorService executor = Executors.newFixedThreadPool(8);
@Override
public Future submit(Invocation inv, Async anno) throws Throwable {
if (inv.method().getReturnType().isAssignableFrom(Future.class)) {
return (Future) inv.invoke();
} else {
return executor.submit(() -> {
try {
return inv.invoke();
} catch (Throwable e) {
throw new RuntimeException(e);
}
});
}
}
}
You get the raw Invocation and the Async annotation, so pool sizing, naming, and rejection policy are entirely yours.
Retry: handle the flaky call at the method boundary
The second verb solves the "it works on the second try" problem. Mark a method @Retry and an exception triggers a re-run instead of bubbling straight up.
@Component
public class RetryTask {
@Retry(Throwable.class)
public void test(String hint) {
System.out.println(Thread.currentThread().getName());
}
}
When you need a fallback for the case where every attempt fails, implement Recover and point the annotation at it:
@Component
public class RetryTask implements Recover {
@Retry(include = Throwable.class, recover = RetryTask.class)
public void test(String hint) {
System.out.println(Thread.currentThread().getName());
}
@Override
public Object recover(Callee callee, Throwable e) throws Throwable {
// last-resort handling
return null;
}
}
The nice part is that retry and async compose. The docs explicitly note @Retry pairs with @Async to harden failure handling, so a background task can retry on its own thread without you writing a retry loop by hand.
Command: jobs the operator triggers, not the clock
The third verb is the one most frameworks skip. Sometimes a job shouldn't run on a timer at all - it should run when an operator invokes it, typically as a one-shot from the command line. That's @Command.
@EnableCommand
public class DemoApp {
public static void main(String[] args) {
Solon.start(DemoApp.class, args);
}
}
@Command("cmd:test")
public class Cmd1 implements CommandExecutor {
@Override
public void execute(String command) {
System.out.println("exec: " + command);
}
}
Then:
java -jar demo.jar cmd:test
This is a clean fit for data migrations, one-off backfills, and maintenance scripts - the work you want inside your application context (with all its beans and config) but explicitly not on a schedule.
Scheduled: the cron/rate/delay task
The fourth verb is the classic scheduled job. Turn it on with @EnableScheduling, then annotate either a Runnable class or a component method.
@EnableScheduling
public class JobApp {
public static void main(String[] args) {
Solon.start(JobApp.class, args);
}
}
@Component
public class JobBean {
@Scheduled(fixedRate = 1000 * 3)
public void job11() {
System.out.println("every 3s");
}
}
The @Scheduled attributes are where the real decisions live. The three timing modes are not interchangeable:
| Attribute | Behavior | Concurrency |
|---|---|---|
fixedRate |
fires on a fixed interval; if the previous run isn't done, a new one still starts | parallel runs possible |
fixedDelay |
waits a fixed gap after the previous run finishes | only one run at a time |
cron |
7-field expression (sec, min, hour, day, month, weekday, year) | parallel |
That distinction matters in production. fixedRate and cron can stack overlapping executions if a run runs long. fixedDelay serializes by construction, so it's the safe default for a job that must never overlap itself. cron also takes a zone (like Asia/Shanghai), and initialDelay offsets the first run when paired with rate or delay.
For heavier needs, scheduling has two adapters - solon-scheduling-simple and solon-scheduling-quartz - with the same programming feel and small behavioral differences (Quartz, for instance, doesn't support fixedDelay or initialDelay).
Managing jobs at runtime
Static annotations cover most cases, but sometimes you need to add or stop jobs while the app is running. That's IJobManager:
public interface IJobManager extends Lifecycle {
JobHolder jobAdd(String name, Scheduled scheduled, JobHandler handler);
boolean jobExists(String name);
JobHolder jobGet(String name);
void jobRemove(String name);
void jobStart(String name, Map<String, String> data) throws ScheduledException;
void jobStop(String name) throws ScheduledException;
boolean isStarted();
}
Adding a job programmatically:
IJobManager jobManager = JobManager.getInstance();
if (!jobManager.jobExists("demo")) {
jobManager.jobAdd("demo", new ScheduledAnno().fixedRate(1000), (ctx) -> {
System.out.println("Hello job");
});
}
jobManager.jobRemove("demo");
You can also inject IJobManager directly. And if you want cross-cutting behavior - timing, MDC tagging, error logging - a JobInterceptor wraps every job execution:
@Component
public class JobInterceptorImpl implements JobInterceptor {
@Override
public void doIntercept(Job job, JobHandler handler) throws Throwable {
long start = System.currentTimeMillis();
try {
handler.handle(job.getContext());
} catch (Throwable e) {
TagsMDC.tag0("job");
TagsMDC.tag1(job.getName());
throw e; // don't swallow it
} finally {
long timespan = System.currentTimeMillis() - start;
System.out.println("job=" + job.getName() + " took " + timespan + "ms");
}
}
}
Why one module, four verbs
The design choice worth naming is that these aren't four libraries bolted together. They're four scheduling dimensions of one module, each an opt-in switch, and all of them plug into the same IoC/AOP container. A @Scheduled method can inject its dependencies like any other bean. An @Async method can wear @Retry. A @Command runs inside the full application context.
That means the mental model stays flat: you're not learning four tools, you're learning four verbs on top of one you already know. Pick the verb that matches the intent - run it off-thread, run it again on failure, run it when I say, run it on a clock - enable it, and skip the rest.
Top comments (1)
I think the biggest win isn’t having four annotations—it’s having one mental model. Too many frameworks make you learn different abstractions for async execution, retries, scheduling, and CLI jobs. A consistent programming model usually pays off more than a larger feature set.