Skip to main content

Recurring jobs

A Serverpod future call fires once. Making one recurring — surviving restarts without stacking duplicates, reporting failures, and re-arming even after a failed run — is the same fiddly boilerplate in every project, and it is easy to get the last part wrong: forget to reschedule inside a finally and one bad run silently kills the schedule forever.

DwRecurringFutureCall owns that whole lifecycle. A job declares only what runs and how often:

class SessionClosureFutureCall extends DwRecurringFutureCall {

String get name => 'sessionClosure';


Duration get interval => const Duration(minutes: 1);


Future<void> run(Session session) => closeTimedOutSessions(session);
}

Then, once at server startup (after pod.start()), hand the jobs over. This registers each handler and arms its first run:

await DwRecurringJobs.startAll(pod, [
SessionClosureFutureCall(),
DatabaseRetentionFutureCall(),
]);

That is the entire contract. You never touch Serverpod's future-call plumbing — registerFutureCall, futureCallWithDelay, cancelFutureCall are all handled behind the base class.

What the base class guarantees

  • Re-arms after every run, including a failed one — the schedule survives a bad run instead of dying on it.
  • No duplicate schedules on restart — the pending run is cancelled and re-queued under the job's name, so a redeploy doesn't stack a second copy.
  • Failures are reported, not swallowed — a throw from run goes to dw.alerts (with the exception and stack trace) and is logged; the next run is still queued.

First run timing

By default the first run happens one interval after startup. Override initialDelay to change that — for example, run once immediately on boot:


Duration get initialDelay => Duration.zero;

The one job the framework ships

DwOrphanedAuthKeyCleanup deletes auth keys whose user profile is gone. Register it with your own:

await DwRecurringJobs.startAll(pod, [
DwOrphanedAuthKeyCleanup(), // hourly by default; pass `interval:` to change it
SessionClosureFutureCall(),
]);

It exists because an auth key has no expiry and DwAuthKey.userId cannot carry a foreign key — the user profile table belongs to the app, so nothing cascades from it. An account deleted without dw.auth!.revokeAuthKeys therefore leaves a token that works forever.

Which makes this the honest home for an aliveness check. Until 0.3.0 one ran by accident on the hot path: reading the caller's id fetched the whole profile row, so a deleted profile read back as "not signed in" — correct, and paid for with a database round trip on every request. Here the same fact costs one statement per interval, and the price is a window: up to interval during which a forgotten revocation still authenticates. Shorten it if that trade reads wrong for your app.

The job only sees hard deletion. A ban or a soft delete leaves the row in place, and that session ends only when you call revokeAuthKeys yourself.