MasterController

Hosted Services

Background work that starts with the app and stops cleanly with it.

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 CoreMasterController
services.AddHostedService<T>()master.addHostedService(Class | instance, { name })
IHostedService.StartAsync/StopAsyncstart(signal) / stop() — started in registration order, stopped in reverse
BackgroundService.ExecuteAsync(token)class Worker extends BackgroundService { async executeAsync(signal) {…} }
BackgroundServiceExceptionBehavior.StopHostdefault — an unhandled exception stops the host; this.exceptionBehavior = 'ignore' to opt out
PeriodicTimer loop + CreateScope() per runmaster.addPeriodicService(name, intervalMs, run(scope, signal), opts)
IBackgroundTaskQueue + queued hosted serviceawait master.backgroundQueue.enqueue(job(scope, signal)), master.backgroundQueueConcurrency
IHostApplicationLifetimemaster.lifetime.onStarted / onStopping / onStopped, stoppingSignal, isStopping
HostOptions.ShutdownTimeoutmaster.shutdownTimeoutMs (30 s)
IHost.StopAsync / SIGTERMawait master.stop(); master.useGracefulShutdown()

A complete host#

server.js
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.

javascript
// 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 instance

BackgroundService#

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

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

javascript
// 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#

javascript
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, idempotent

master.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 shutdownTimeoutMsonStopped. It is idempotent.

Readiness during shutdown
With health checks enabled, /health/ready reports Unhealthy as soon as stop() begins, so load balancers drain traffic before the server closes.