Hosted Services
MasterController implements the ASP.NET Core generic host: hosted services start at master.start() and stop in reverse on shutdown, BackgroundService hosts long-running loops, a PeriodicTimer-style service runs a job on an interval with a fresh DI scope per run, a background task queue takes fire-and-forget work out of request handlers, and master.lifetime + useGracefulShutdown() handle SIGTERM/SIGINT.
| ASP.NET Core | MasterController |
|---|---|
services.AddHostedService<T>() | master.addHostedService(Class | instance, { name }) |
IHostedService.StartAsync/StopAsync | start(signal) / stop() — started in registration order, stopped in reverse |
BackgroundService.ExecuteAsync(token) | class Worker extends BackgroundService { async executeAsync(signal) {…} } |
BackgroundServiceExceptionBehavior.StopHost | default — an unhandled exception stops the host; this.exceptionBehavior = 'ignore' to opt out |
PeriodicTimer loop + CreateScope() per run | master.addPeriodicService(name, intervalMs, run(scope, signal), opts) |
IBackgroundTaskQueue + queued hosted service | await master.backgroundQueue.enqueue(job(scope, signal)), master.backgroundQueueConcurrency |
IHostApplicationLifetime | master.lifetime.onStarted / onStopping / onStopped, stoppingSignal, isStopping |
HostOptions.ShutdownTimeout | master.shutdownTimeoutMs (30 s) |
IHost.StopAsync / SIGTERM | await master.stop(); master.useGracefulShutdown() |
A complete host#
import master, { BackgroundService } from 'mastercontroller';
import AppContext from './app/models/AppContext.js';
class OutboxDispatcher extends BackgroundService {
async executeAsync(signal) {
while (!signal.aborted) {
await this.useScope(async (scope) => { // scoped MasterRecord context per batch (like EF's DbContext)
const batch = await scope.db.Outbox.where('o => o.sentAt == $$', null).take(50).toList();
for (const msg of batch) { await send(msg); msg.sentAt = new Date().toISOString(); }
await scope.db.saveChanges();
});
await this.delay(2000, signal); // wakes immediately on shutdown
}
}
}
const server = master.setupServer('http');
master.addScoped('db', AppContext);
master.addHostedService(OutboxDispatcher);
master.addPeriodicService('session-cleanup', 15 * 60_000, async (scope) => { await scope.db.Session.where('s => s.expiresAt < $$', Date.now()).executeDelete(); }, { runOnStart: true });
master.lifetime.onStopping(() => console.log('draining…'));
master.useGracefulShutdown(); // SIGTERM/SIGINT -> master.stop() -> exit 0
await master.start(server); // options validated, hosted services started (a failing start aborts the boot)
server.listen(3001); // ApplicationStarted fires when listening
// anywhere in a request:
await master.backgroundQueue.enqueue(async (scope) => { await scope.mailer.sendWelcome(userId); });Hosted services#
Anything with start(signal) and stop() qualifies. Pass a class (constructed for you) or an instance. Services registered after start() are started immediately; a failing start() aborts the boot.
// Any object with start(signal) / stop() — IHostedService
class WarmCache {
async start(signal) { await master.cache.getOrCreate('categories', loadCategories, { ttlMs: 60_000 }); }
async stop() { /* flush, close connections… */ }
}
master.addHostedService(WarmCache, { name: 'warm-cache' }); // a class (constructed for you) or an instanceBackgroundService#
import { BackgroundService } from 'mastercontroller' and implement executeAsync(signal). As in ASP.NET, start() does not await executeAsync — the host keeps booting — so honour the signal to stop promptly. Helpers on the instance: this.delay(ms, signal) (wakes on shutdown), this.useScope(fn) / this.createScope() for scoped services, and this.exceptionBehavior ('stopHost' default, 'ignore').
Periodic services#
master.addPeriodicService(name, intervalMs, run, { runOnStart, jitterMs, exceptionBehavior }) runs run(scope, signal) every interval with a fresh DI scope — a scoped MasterRecord context is built and disposed per tick. A failing tick is logged and the loop continues (exceptionBehavior: 'stopHost' to fail the host instead).
master.addPeriodicService('cleanup', 60_000, async (scope, signal) => {
await scope.db.Session.where('s => s.expiresAt < $$', Date.now()).executeDelete();
}, { runOnStart: true, jitterMs: 5_000, exceptionBehavior: 'ignore' });Background task queue#
master.backgroundQueue.enqueue(job) hands job(scope, signal) to a queued hosted worker. Each job gets its own scope; enqueue()resolves with the job’s result or rejects with its error, and a failing job never stops the host.
// in a controller action — respond now, do the work later
async create() {
const user = await this.db.User.add(this.model);
await this.db.saveChanges();
master.backgroundQueue.enqueue(async (scope, signal) => { // own DI scope; resolves with the job's result
await scope.mailer.sendWelcome(user.id);
}).catch((err) => this.logger.error('welcome mail failed', err));
this.created(`/users/${user.id}`, user);
}
master.backgroundQueueConcurrency = 4; // parallel workers (default 1)Host lifetime & graceful shutdown#
master.lifetime.onStarted(() => console.log('listening')); // after server.listen
master.lifetime.onStopping(() => console.log('draining…')); // master.stop() began
master.lifetime.onStopped(() => console.log('bye')); // everything stopped
master.lifetime.stoppingSignal; // AbortSignal — pass it to long-running work
master.lifetime.isStopping; // boolean
master.shutdownTimeoutMs = 30_000; // HostOptions.ShutdownTimeout (default 30 s)
master.useGracefulShutdown(); // SIGTERM / SIGINT -> await master.stop() -> process.exit(0)
master.useGracefulShutdown({ signals: ['SIGTERM'], exit: false }); // keep the process alive after stop()
await master.stop(); // programmatic, idempotentmaster.stop() runs the shutdown sequence: onStopping handlers → lifetime.stoppingSignal aborts → server.close() (stop accepting; idle keep-alives closed) → hosted services stop() in reverse order, each bounded by the remaining shutdownTimeoutMs → onStopped. It is idempotent.
/health/ready reports Unhealthy as soon as stop() begins, so load balancers drain traffic before the server closes.