Aussom-Server has a built-in scheduler. You write a function on your
app class, mark it with @Scheduled, and the server runs it on the
schedule you describe. The scheduled function shares the same app
instance as your HTTP and WebSocket handlers, so it can read and
update the same state without any extra plumbing.
The scheduler is built on Quartz under the hood, but you do not write any Quartz-specific code. From the developer's seat, you write Aussom and a small block of YAML.
Scheduled jobs are the right tool when work needs to run on a clock, not in response to a user request:
| Workload | Why scheduled |
|---|---|
| Aggregate counters into a metrics table | Run every N minutes |
| Nightly cleanup or archive | Run once at a fixed hour |
| Poll a slow upstream and cache results | Run on an interval |
| Send daily reports | Run at a specific local time |
| Flush an in-memory write buffer to disk | Run every few seconds |
A scheduled job is not an HTTP route. It does not receive a request, does not produce a response, and is not reachable from the outside world. If a client needs to trigger work, build a regular HTTP route. If something needs to happen on the clock, build a scheduled job.
A scheduled job is a method on your AppBase-derived class with
the @Scheduled annotation. The method takes no arguments and
returns nothing.
@Scheduled
function. A function cannot be @Scheduled and @Websocket
at the same time.@Api(version = "1.0.0")
class metricsapp : AppBase {
private hits = 0;
/**
* Bumps the in-memory counter.
*/
public record(req) {
this.hits += 1;
req.send("ok");
}
/**
* Logs and resets the counter every minute.
*/
@Scheduled(every = "1m", desc = "minute roll-up")
public roll() {
c.info("hits in last minute: " + this.hits);
this.hits = 0;
}
}
Start the server, hit /metricsapp/record a few times, wait a
minute, and the per-minute total appears in
logs/metricsapp.log as a [scheduler roll] line.
@Scheduled annotationThe annotation accepts the following named arguments:
| Argument | Required | Type | Notes |
|---|---|---|---|
cron |
one of two | string | Quartz cron expression with six fields (sec min hr dom mon dow). Mutually exclusive with every. |
every |
one of two | string | Fixed interval. Format: <n><unit> where unit is one of s m h d. Examples: 30s, 5m, 1h. |
name |
optional | string | Display name. Used in admin output and the per-job log prefix. Defaults to the function name. |
desc |
optional | string | Free-text description shown in the admin UI. |
enabled |
optional | bool | Default true. Set to false to declare a job that does not fire until enabled. |
logFile |
optional | string | Per-job log filename. See "Per-job log files" below. |
A function with both cron and every, or with neither, is a
load error. The server will refuse to start the app and log a
clear message naming the offending function and source line.
Aussom-Server uses Quartz's six-field cron format:
sec min hr dom mon dow
0 30 2 * * ? # every day at 02:30
0 0 * * * ? # top of every hour
0 */5 * * * ? # every 5 minutes
0 0 9 ? * MON-FRI # 09:00 weekdays
Quartz treats either day-of-month or day-of-week as ? (no
specific value); pick the one you want and put ? in the other.
Cron expressions evaluate in the JVM's default timezone. If you
need a specific timezone, set the JVM's -Duser.timezone=...
flag at startup; per-job timezone overrides are not supported in
this release.
every = "<n><unit>" is the simpler form for "fire every X". The
units are:
s = seconds (e.g. 30s)m = minutes (e.g. 5m)h = hours (e.g. 2h)d = days (e.g. 1d)The first firing happens one interval after startup, not
immediately. A job declared as every = "5m" first fires five
minutes after the server starts, then every five minutes after
that.
applications.yamlThe annotation is the developer's view of the schedule. Operations
gets a parallel knob: a schedules: block under any application
in applications.yaml. Anything an operator sets here wins over
the annotation, so a schedule can be tuned or disabled without
touching code or rebuilding.
applications:
- name: metricsapp
appDirectory: metricsapp
enabled: true
hostResources: false
publicHttpDirectory: public
reloadOnFileChange: false
hostDocEndpoint: false
hostApiEndpoint: false
logLevel: info
schedules:
roll:
every: "30s" # was 1m in code; run twice as often
nightlyExport:
enabled: false # disable while we investigate an issue
report:
cron: "0 0 8 * * ?" # was every="1h"; flip to a daily 08:00 cron
logFile: "report.log" # split this job out to its own file
The merge is per-key. Keys you do not set keep their annotation
value. So you can override only enabled or only every without
having to repeat the rest.
A schedules: entry that names a function the app does not
declare is a load error. This catches typos before they silently
do nothing.
config.yaml)Add a scheduler: block under <env>.server for process-wide
tunables. All four keys are optional with the defaults shown.
local:
server:
name: "Integration Platform Server"
host: "0.0.0.0"
port: 8081
appDir: "test-apps"
logDir: "logs"
logLevel: "info"
scheduler:
threadPoolSize: 4 # default 4
shutdownGraceMs: 30000 # default 30s
historyPerJob: 10 # default 10
historyDbFile: "scheduler-history.db"
| Key | Default | Meaning |
|---|---|---|
threadPoolSize |
4 |
Max concurrent jobs across all apps. A long-running job ties up one worker. |
shutdownGraceMs |
30000 (30s) |
How long the JVM shutdown hook waits for in-flight jobs to finish before exiting. |
historyPerJob |
10 |
How many recent runs are kept in the SQLite history per job. |
historyDbFile |
scheduler-history.db |
Filename under logDir where the history is stored. |
threadPoolSize is intentionally conservative. Most workloads do
not need more than 4. If you have a job that genuinely runs for
minutes at a time, either bump the pool or restructure the job to
checkpoint state and resume on the next firing.
A given scheduled function never runs concurrently with itself.
Each @Scheduled job is marked @DisallowConcurrentExecution
internally. If a previous run is still going when the next firing
is due, Quartz defers the next firing until the previous one
finishes.
Different scheduled functions on the same app can run in
parallel. Different apps' jobs run in parallel as well. The
process-wide thread pool (threadPoolSize, default 4) is the cap
on total concurrent jobs.
The app instance is shared across HTTP, WebSocket, scheduled
jobs, user-spawned Thread objects, and Timer callbacks. Two
of those things can touch the same field at the same time.
Aussom does not currently expose a built-in lock primitive, so
write your shared-state access in patterns that tolerate the
overlap:
@Api(version = "1.0.0")
class counterapp : AppBase {
private count = 0;
public hit(req) {
this.count += 1;
req.send("ok");
}
/**
* Snapshot-and-reset in a single statement so an interleaved
* hit() that arrives between read and reset still records into
* the next bucket rather than getting silently dropped.
*/
@Scheduled(every = "10s")
public flush() {
snapshot = this.count;
this.count = 0;
c.info("counted " + snapshot + " hits in the last 10s");
}
}
Counters that absolutely must not lose increments need a more careful design - typically by writing each increment to a queue or external store rather than mutating a shared field. If the load is small and you can tolerate occasional skew, the snapshot pattern above is usually good enough.
A general lock primitive may land in a future release; until then, factor scheduled jobs and HTTP handlers so they touch shared state in single-statement reads or writes.
app staticThe app static class exposes the runtime lifecycle. Long-running
jobs and threads should poll it so they can exit cleanly when the
app reloads or the JVM is shutting down.
include app;
| Method | Returns | Meaning |
|---|---|---|
app.isReloadPending() |
bool | True while this app is reloading, or when the engine the call is running on has been replaced by a newer engine. |
app.isShutdownPending() |
bool | True after the JVM shutdown hook fires, before the grace timeout expires. |
app.isStopRequested() |
bool | Convenience: true when either of the above is true. Cheap to call in tight loops. |
app.onBeforeReload(callback) |
- | Registers a callback that runs once, before this app reloads. The callback runs against the old engine. |
app.onBeforeShutdown(callback) |
- | Registers a callback that runs once, when the JVM shutdown hook fires. |
Wrap any tight loop with app.isStopRequested() so reload and
shutdown can interrupt it:
@Scheduled(cron = "0 0 2 * * ?")
public nightlyExport() {
rows = db.query("select * from big_table");
for (row : rows) {
if (app.isStopRequested()) {
c.warn("nightlyExport: bailing out, will resume next run");
return;
}
this.export(row);
}
}
This is the most reliable pattern for long-running jobs. Each loop iteration is a clean exit point, and the next firing picks up against the fresh engine.
For one-shot cleanup that has to happen exactly once - close a DB transaction, flush a write buffer, release a resource - register a callback at startup:
@Api(version = "1.0.0")
class txapp : AppBase {
private tx = null;
public hit(req) {
if (this.tx == null) {
this.tx = db.beginTx();
app.onBeforeReload(::cleanup);
app.onBeforeShutdown(::cleanup);
}
// ... use this.tx ...
req.send("ok");
}
private cleanup() {
if (this.tx != null) {
this.tx.rollback();
this.tx = null;
}
}
}
onBeforeReload callbacks fire against the old engine before the
new code is parsed in. onBeforeShutdown callbacks fire when the
JVM is going down, before the grace timer starts counting.
A job stuck inside a blocking call - a slow db.query(), a
synchronous HTTP request, a long file read - does not see the
flag flip until control returns to your code. The flags only
fire on the next time you check them.
The same caveat applies to long iterative work without explicit
checkpoints. Sprinkle if (app.isStopRequested()) return;
between iterations of any loop that might run for more than a
second or two:
@Scheduled(cron = "0 0 2 * * ?")
public process(rows) {
for (row : rows) {
if (app.isStopRequested()) return;
this.handle(row);
}
}
If you genuinely need a delay inside a job, do not block the job
itself - return and let the schedule fire again later, or spawn
a Thread (which has its own sleep method) and return from
the job immediately.
By default a scheduled job logs to the app's regular log file
(logs/<appName>.log) under a [scheduler <jobName>] prefix:
INFO [scheduler roll] start
INFO [scheduler roll] end ok in 2ms
ERROR [scheduler crash] failed in 8ms: <message>
A noisy job can be split out to its own file with the logFile
argument, either on the annotation or in the config override:
@Scheduled(every = "5s", logFile = "metrics-flush.log")
public flush() { ... }
schedules:
flush:
logFile: "metrics-flush.log"
Resolution rules for logFile:
logDir. So "flush.log" writes to <logDir>/flush.log... are rejected at load time.When a per-job log file is configured, the [scheduler <jobName>]
lines for that job land only in the per-job file - they do
not also appear in the app log. Other jobs on the same app
continue to use the app log unless they have their own override.
The admin server exposes two read endpoints for scheduler state.
Both require an X-API-KEY with the list permission (or all).
GET /Admin/schedulesLists every scheduled job across every configured app:
curl -H "X-API-KEY: $KEY" http://127.0.0.1:8091/Admin/schedules
Each entry has the shape of AdminScheduledJob:
{
"appName": "metricsapp",
"functionName": "roll",
"name": "roll",
"description": "minute roll-up",
"scheduleType": "every",
"scheduleExpr": "30000ms",
"enabled": true,
"logFile": "",
"isRunning": false,
"runCount": 124,
"errorCount": 1,
"skipCount": 0,
"lastError": "",
"lastRunStart": "2026-05-04T21:55:24.374Z",
"lastRunEnd": "2026-05-04T21:55:24.375Z",
"lastDurationMs": 1,
"nextFireTime": "2026-05-04T21:55:54.374Z",
"history": [
{
"startTime": "2026-05-04T21:55:24.374Z",
"endTime": "2026-05-04T21:55:24.375Z",
"durationMs": 1,
"status": "ok",
"errorMessage":""
}
/* ... up to historyPerJob (default 10) entries, newest first ... */
]
}
GET /Admin/schedule?appName=&functionName=Returns the same shape for a single job. Returns 400 if either query parameter is missing, 404 if the named job is not registered.
Admin does not have a "change a schedule live" route. To pick up
a schedules: change in applications.yaml:
curl -X POST -H "X-API-KEY: $KEY" \
-H "Content-Type: application/json" \
-d '{"appName":"metricsapp","action":"reload"}' \
http://127.0.0.1:8091/Admin/application
Reload re-runs the app's init(), re-reads the schedules: block,
and rebinds every job. Running jobs are allowed to finish against
the old code; future firings run against the new code.
Every firing - successful, failed, or skipped - writes one row
into <logDir>/<historyDbFile> (default
logs/scheduler-history.db). The table is bounded: after each
insert the runner trims older rows so each job keeps at most
historyPerJob (default 10) most recent entries.
Sample query (any SQLite client works):
SELECT app_name, function_name, status, duration_ms, start_time
FROM run_history
WHERE app_name = 'metricsapp'
ORDER BY id DESC
LIMIT 20;
The same rows surface inline on /Admin/schedules as the
history array, so most operators never need to query the file
directly. The file is mainly useful when:
The history is a debugging convenience, not an event log. Do not
rely on it for audit-grade records of every run; raise the
historyPerJob cap or write your own audit table from inside
the job if you need that.
Aussom-Server reloads an app when its source file changes on disk
(if reloadOnFileChange: true in applications.yaml) or when
POST /Admin/application with action=reload is called.
A reload does the following, in order:
app.onBeforeReload callbacks registered against
the old engine. Exceptions are logged and swallowed; the
reload continues.Timer instances bound to the old
engine. (Timers from the previous code would otherwise keep
firing into a dead environment.)@Scheduled annotations, applies
the schedules: overrides, and re-registers every job with
Quartz.app.isReloadPending() == true and can exit cooperatively.In-flight jobs are not interrupted. They keep running against
the old engine until they finish or until they exit cooperatively
on app.isStopRequested(). The new engine is live for new
firings.
User-spawned Thread objects are not cancelled either; they are
expected to honor app.isStopRequested() on their own loop. If
a Thread does not poll the flag, it lives until it exits on its
own, until the JVM exits, or until the app calls
thread.interrupt() from somewhere.
The JVM shutdown hook does the following:
app.isShutdownPending() flag to true.app.onBeforeShutdown callback.shutdownGraceMs (default 30s) for in-flight jobs
to finish.Long jobs should poll app.isStopRequested() so they can finish
the current iteration and return inside the grace window. A job
that ignores the flag and runs longer than the grace gets killed
mid-execution, possibly leaving partial state.
Thread and TimerA scheduled job can spawn a Thread or a Timer like any other
code. The scheduler considers the job complete when its function
returns, even if the spawned worker is still running.
include thread;
include app;
@Scheduled(every = "10m")
public refreshIndex() {
t = new indexBuilder(this);
t.start();
}
class indexBuilder : Thread {
public parent = null;
public indexBuilder(parentApp) {
this.parent = parentApp;
this.setDaemon(true);
this.setOnRun(::work);
}
public work() {
while (!app.isStopRequested()) {
// ... rebuild a chunk of the index ...
this.sleep(100);
}
}
}
The app.isStopRequested() call works inside the Thread's run
method even though the Thread does not extend AppBase. The
lifecycle resolves the calling app via the engine on the
callback's environment, which is captured at construction time.
For one-shot side work, Timer is fine too:
include thread;
@Scheduled(every = "5m")
public sweep() {
// Do the bulk of the sweep now, then schedule a follow-up
// 30 seconds later for a verify pass.
this.bulkSweep();
t = new Timer(30000, ::verify);
t.start();
}
private verify() {
// ... runs once, 30 seconds after the bulk sweep ...
}
Timers spawned by app code are tracked per-app and cancelled on reload, so you do not have to worry about a stale timer firing against the old engine after a code change.
@Scheduled function from HTTP code. The
HTTP route filter blocks direct hits with 400. If you need
the same logic from both contexts, factor it out into a
private helper that both call.runCount, errorCount, lastRunStart, etc.)
reset to zero on every JVM start. The SQLite history table
persists across restarts; use it if you need long-term run
records.tick, slow, crash, etc. on
multiple apps and assume they collide. Job names are
per-app. Two different apps can both have a @Scheduled tick
function and they fire independently.every triggering an immediate first fire.
every = "5m" first fires five minutes after startup, not
immediately. If you need an at-startup fire, call the
function directly from the app's constructor.@DisallowConcurrentExecution rule defers it). Either bump
the schedule, parallelize internally, or move the work to a
Thread that the job spawns and forgets about.0 0 2 * * ? job
fires at 02:00 server-local, not 02:00 UTC. Set
-Duser.timezone=UTC at startup if you want UTC schedules.historyPerJob rows per job, and may be wiped if it gets
corrupted. Write your application data to a separate
database.app.isStopRequested() as an exception. It
is a function that returns a bool. Treat the return value
the same way you would treat any flag - check it, branch,
and exit cleanly.app, thread, running,
method, HttpReq, WsConn, AppBase, props, api, or
cache. They collide with built-in classes/enums in
aussomserver.aus and thread.aus.Timer with startInterval(). The scheduler is built for
durations measured in seconds and longer.Thread started from the relevant handler. The
scheduler is for "this should run every N" not "this should
run once N seconds from now after a particular thing
happened."