Menu

Scheduler - Usage Guide

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.


What the scheduler is good for

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.


How it fits into an Aussom-Server app

A scheduled job is a method on your AppBase-derived class with the @Scheduled annotation. The method takes no arguments and returns nothing.

  • Each scheduled function name maps to one job inside that app's Quartz job group. Two apps can have a function with the same name without colliding.
  • Scheduled functions are not reachable as HTTP routes. Aussom-Server returns 400 if a regular GET hits a @Scheduled function. A function cannot be @Scheduled and @Websocket at the same time.
  • A scheduled function shares its app instance with HTTP and WebSocket handlers. State you write in a job is visible to request handlers and vice versa.

Quick start: a tick every minute

@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.


The @Scheduled annotation

The 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.

Cron expressions

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.

Interval expressions

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.


Operator overrides in applications.yaml

The 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.

When to use which

  • Annotation is the developer-time default. It documents the intent and ships with the code.
  • Config override is the operator-time tunable. It exists for ops to disable a chatty job, swap in a more aggressive cron in production, or split a noisy job out to its own log file.

Server-wide scheduler config (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.


Concurrency model

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.


Cooperative cancellation: the app static

The 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.

Polling: the loop pattern

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.

Hooks: register-and-forget cleanup

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.

Important: the lifecycle is not preemptive

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.


Per-job log files

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:

  • Bare filenames and relative paths resolve under the server's logDir. So "flush.log" writes to <logDir>/flush.log.
  • Absolute paths are honored as-is.
  • Paths containing .. 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.


Admin endpoints

The admin server exposes two read endpoints for scheduler state. Both require an X-API-KEY with the list permission (or all).

GET /Admin/schedules

Lists 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.

Reloading after a config change

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.


Persistent run history

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:

  • You want to inspect runs across a server restart (the live counters reset; the history persists).
  • You want to script a query across many apps.

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.


Hot reload semantics

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:

  1. Fires any app.onBeforeReload callbacks registered against the old engine. Exceptions are logged and swallowed; the reload continues.
  2. Cancels any user-started Timer instances bound to the old engine. (Timers from the previous code would otherwise keep firing into a dead environment.)
  3. Parses the new source, builds a new engine, and instantiates the new app.
  4. Walks the new source for @Scheduled annotations, applies the schedules: overrides, and re-registers every job with Quartz.
  5. Marks the old engine as superseded so any in-flight jobs see 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.


Graceful shutdown

The JVM shutdown hook does the following:

  1. Sets the process-wide app.isShutdownPending() flag to true.
  2. Fires every registered app.onBeforeShutdown callback.
  3. Tells Quartz to stop dispatching new firings.
  4. Waits up to shutdownGraceMs (default 30s) for in-flight jobs to finish.
  5. Lets the JVM exit; any job still running gets killed at process termination.

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.


Combining with Thread and Timer

A 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.


What not to do

  • Do not call a @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.
  • Do not block forever in a scheduled job. A job that never returns ties up one worker thread and prevents the same job from ever running again (concurrent execution is disallowed). The shutdown hook will kill the process when the grace expires; you will lose any partial work.
  • Do not assume the live counters survive a restart. Live counters (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.
  • Do not name a public method 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.
  • Do not rely on 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.
  • Do not put long-running blocking I/O outside a polling loop. Even on a fast schedule, a 30-second blocking call delays the next firing by 30 seconds (the @DisallowConcurrentExecution rule defers it). Either bump the schedule, parallelize internally, or move the work to a Thread that the job spawns and forgets about.
  • Do not assume cron expressions evaluate in UTC. Cron is evaluated in the JVM's default timezone. A 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.
  • Do not use the SQLite history file as your application's data store. It is for the scheduler's bookkeeping, capped at historyPerJob rows per job, and may be wiped if it gets corrupted. Write your application data to a separate database.
  • Do not catch 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.
  • Do not name a local variable app, thread, running, method, HttpReq, WsConn, AppBase, props, api, or cache. They collide with built-in classes/enums in aussomserver.aus and thread.aus.

When to reach for something else

  • One-shot work triggered by a request - regular HTTP route. The scheduler fires on the clock, not on demand.
  • Sub-second tight loops or fast-firing programmatic delays
    • Timer with startInterval(). The scheduler is built for durations measured in seconds and longer.
  • Multi-machine fan-out or distributed work queues - a real queue (NATS, Kafka, Redis Streams). Aussom-Server's scheduler is single-node only and does not coordinate across processes.
  • Deferred work triggered by a specific event - a user-spawned 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."