Skip to content

Execution Modes

The mode parameter controls what happens when a recurring job's next tick becomes due while a prior invocation is still running. Four modes are available, matching the bus's execution modes.

await self.scheduler.run_every(
    self.sync_data,
    minutes=5,
    name="sync_data",
    mode="single",  # or "restart", "queued", "parallel"
)

mode= is accepted by schedule() and all seven convenience methods: run_in, run_once, run_every, run_minutely, run_hourly, run_daily, and run_cron.

On-Grid Firing

Recurring jobs reschedule the next occurrence at dispatch time — before the current invocation runs. This means an overrunning job fires on its grid, not interval-after-completion.

A job scheduled every 30 seconds that takes 45 seconds fires at T+0, T+30 (held by the mode), T+60, T+90 — not T+0, T+45, T+75. A job that completes within its interval fires at identical times to before.

The mode governs what happens when that next-tick entry becomes due while the current run is still in flight.

The Four Modes

single — drop while running (app default)

single is the default for app jobs. When the next tick becomes due while the prior invocation is still running, the scheduler drops the re-fire. The running invocation continues uninterrupted.

class PollApp(App[AppConfig]):
    async def on_initialize(self):
        # App jobs default to single — no mode= needed.
        await self.scheduler.run_every(
            self.sync_data,
            seconds=30,
            name="sync_data",
        )

    async def sync_data(self):
        # Only one copy of this job runs at a time.
        # A re-fire that arrives while this is running is dropped.
        self.logger.info("Syncing data")
        await self.api.call_service(
            "homeassistant", "update_entity",
            entity_id="sensor.outdoor_temperature",
        )

The dropped re-fire is logged at DEBUG. No WARNING is emitted — this is expected behavior, not an error.

single is the right choice for jobs that mutate shared state, call a slow service, or hold a resource. One invocation at a time prevents duplicate side effects.

restart — cancel and replace

restart cancels the running invocation when the next tick becomes due, then starts a fresh one.

class RefreshApp(App[AppConfig]):
    async def on_initialize(self):
        await self.scheduler.run_every(
            self.refresh_report,
            minutes=5,
            name="refresh_report",
            mode="restart",
        )

    async def refresh_report(self):
        # If the next tick arrives before this finishes,
        # the in-flight run is cancelled and a fresh one starts.
        self.logger.info("Refreshing report")
        await self.api.call_service(
            "script", "turn_on",
            entity_id="script.generate_report",
        )

The cancelled invocation receives CancelledError at its next await. Making jobs cancellation-safe is the author's responsibility. The framework cancels the task; try/finally runs cleanup as the cancellation propagates. Suppressing the CancelledError keeps the task alive and defeats the restart.

restart is the right choice for "latest wins" patterns: a report refresh where only the most recent run matters, or a cache reload where a stale in-flight run should give way to a fresher one.

Cancelled invocations have side effects

A job cancelled mid-run may have already mutated state or called a service. The framework provides no automatic rollback. Jobs that mutate state mid-run need cancellation handling with try/finally. single or queued avoid partial execution entirely.

queued — serialize in arrival order

queued runs every tick, one at a time, in the order ticks arrived. Ticks that become due while an invocation is running are held and dispatched sequentially after the current invocation completes.

class AuditApp(App[AppConfig]):
    async def on_initialize(self):
        await self.scheduler.run_every(
            self.run_audit,
            minutes=2,
            name="run_audit",
            mode="queued",
        )

    async def run_audit(self):
        # Every tick runs in arrival order, one at a time.
        # If the queue reaches 10 pending ticks, the newest is dropped.
        self.logger.info("Starting audit run")
        await self.api.call_service(
            "script", "turn_on",
            entity_id="script.run_audit",
        )

The queue holds at most 10 pending ticks. When the queue is full, the newest tick is dropped and logged at DEBUG. Already-queued ticks are unaffected. This cap prevents unbounded memory growth when a slow job falls behind a fast schedule.

queued is the right choice when every tick must be processed and order matters: sequential audit runs, command dispatch, or anything where skipping an execution would leave the system in an incorrect state.

parallel — concurrent (framework default)

parallel imposes no overlap guard. Multiple invocations of the same job run concurrently. This is the framework default for internal jobs.

class MetricApp(App[AppConfig]):
    async def on_initialize(self):
        await self.scheduler.run_every(
            self.record_reading,
            seconds=10,
            name="record_reading",
            mode="parallel",
        )

    async def record_reading(self):
        # Each tick spawns an independent recording task.
        # Multiple readings can run concurrently.
        self.logger.info("Recording sensor reading")
        await self.api.call_service(
            "homeassistant", "update_entity",
            entity_id="sensor.outdoor_temperature",
        )

parallel is the right choice for stateless, idempotent work where each invocation manages its own isolated resources.

Default Mode: Tier-Aware

The default mode depends on the tier — who registered the job. App jobs and framework-internal jobs get different defaults.

Framework-internal jobs are the scheduler entries Hassette registers for itself — not through self.scheduler.* in an app — to run its own services.

Registration tier Default mode Why
App job (self.scheduler.*) single Prevents a job from running twice at once in user automations
Framework-internal job parallel Preserves concurrent behavior required by the framework

An explicit mode= always overrides the tier default.

One-Shot Jobs

mode= is accepted on one-shot schedules (run_in and run_once) for API uniformity. It has no overlap effect — a one-shot fires once and is removed, so no overlap is possible.

class CleanupApp(App[AppConfig]):
    async def on_initialize(self):
        # mode= is accepted on run_in for API uniformity.
        # It has no overlap effect — a one-shot never re-fires.
        await self.scheduler.run_in(
            self.cleanup,
            delay=60,
            name="delayed_cleanup",
            mode="single",
        )

    async def cleanup(self):
        self.logger.info("Running cleanup")

The value passed is stored on the job and appears in the jobs API response, but it never triggers guard logic.

Observability

Suppressed and dropped counts

Each job with a non-parallel mode tracks two live counters:

  • Suppressed — ticks dropped by single while the job was running.
  • Dropped — ticks discarded by queued when the queue was at its cap.

These counts appear in the monitoring UI's Jobs view when non-zero, alongside the job's mode. They are live-only diagnostics — held in memory, reset to zero when the process restarts, never persisted to the database.

A non-zero suppressed count on a single job indicates ticks arrive faster than the job completes. If that represents lost work, consider queued. If it represents expected deduplication, single is correct.

Cancelled counts

restart cancels the running invocation on every re-fire, so a busy restart job accumulates cancelled executions. Cancelled is a persisted execution status, not a live counter. The telemetry database records each one, and the monitoring UI surfaces a cancelled count separate from failures.

A restart job working as designed shows a high cancelled count and a 100% success rate. Cancellation is the intended outcome, so it never counts against the success rate. The cancelled column appears in the Handlers table for both jobs and event handlers.

Cancelled column in the monitoring UI handlers and jobs table

Stall detection

A job that holds its execution-mode guard — any mode except parallel — longer than 60 seconds without completing emits a WARNING. This is the only WARNING the execution mode feature generates. Suppressed and dropped ticks always log at DEBUG.

The per-job timeout still applies and ultimately releases the guard when it fires. The stall WARNING is an early signal, independent of the timeout.

Mode in the monitoring UI

The mode is persisted for each job and displayed in the monitoring UI's Jobs view. The mode appears alongside the job's next run time, suppressed count, and dropped count.

Run hassette job on a running instance to see each job's mode from the command line. The live suppressed and dropped counts are not in the default table — use hassette job --json to read them alongside every other field.

See Also