Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Scheduled Fields & the Runner

A mfx:"scheduled" tag on a *time.Time field declares a time-driven transition: when the timestamp falls into the past, the framework applies a configured action to the row. The mechanism is small but covers a surprising number of real workflows — auto-publish, auto-archive, soft-delete after expiry, scheduled status transitions.

This page covers both halves: the tag (declarative, per-model) and the runner (the background goroutine that actually applies transitions).

The tag

mfx:"scheduled" must appear on a *time.Time field (the pointer type is required so “unset” is distinguishable from the zero time). The tag takes one action and any number of qualifiers, separated by semicolons:

type Post struct {
    maniflex.BaseModel
    maniflex.WithDeletedAt

    Title  string `json:"title"`
    Status string `json:"status" mfx:"required,enum:draft|published|archived,default:draft"`

    // Auto-publish: set status=published when publish_at falls in the past.
    PublishAt *time.Time `json:"publish_at" mfx:"scheduled;field=status;from=draft;to=published"`

    // Auto-archive: set status=archived once archive_at falls in the past
    // (no from= — applies regardless of current status).
    ArchiveAt *time.Time `json:"archive_at" mfx:"scheduled;field=status;to=archived"`

    // Auto-soft-delete: requires WithDeletedAt above.
    ExpiresAt *time.Time `json:"expires_at" mfx:"scheduled;soft-delete"`
}

Actions

Exactly one action per scheduled field:

ActionEffect when the timestamp passes
soft-deletesets the soft-delete marker — requires maniflex.WithDeletedAt or WithIsDeleted
hard-deletephysically deletes the row, regardless of soft-delete config
field=NAME;to=VALUEsets the named field to the value

Qualifiers

The field=...;to=... action accepts optional qualifiers:

QualifierEffect
from=VALUEapply only when the named field currently equals this value
to=VALUEthe value to assign (required for field=...)

from= and to= are validated against the field’s enum (if any) at registration time — a typo aborts the boot, not the first sweep.

Validation at registration

Every scheduled tag is resolved when ScanModel runs. Configurations that don’t make sense are reported and the field is dropped from the runner’s scope:

  • Field type must be *time.Time.
  • Exactly one of soft-delete, hard-delete, field= is required.
  • soft-delete requires the model to be soft-deletable.
  • field= requires a to= and references an existing column.
  • from= / to= must be members of the target field’s enum, if it has one.

A scheduled column automatically gets an IndexSpec added to the model so the runner can locate due rows without a full scan.

The runner

The runner lives in maniflex/scheduled (its own satellite-style package). It is opt-in — declaring scheduled tags makes the rows ready to be acted on, but nothing happens until a runner is started.

import "github.com/xaleel/maniflex/scheduled"

runner, err := scheduled.New(server, scheduled.Config{
    Interval:  time.Minute,
    BatchSize: 500,
})
if err != nil {
    log.Fatal(err)
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner.Start(ctx)
defer runner.Stop()

scheduled.New walks the registry, picks up every model that declares a scheduled field, and binds them to the runner. A registry with no scheduled fields produces a usable no-op runner — callers can wire it unconditionally and pay no cost.

Config

FieldDefaultPurpose
Interval1mhow often the loop ticks
BatchSize500maximum rows processed per (model, spec) per tick
Loggerslog.Default()structured log sink
Clocktime.Now().UTCinjectable; tests override
OnDeletenilcallback func(model, id string) after a delete commits
OnSetFieldnilcallback func(model, id, field, to string) after a set-field commits

The two hooks fire once per affected row, after the per-model transaction has committed. They run outside the transaction, so a hook panic does not roll back the write.

What one tick does

For each registered model with scheduled specs, in turn:

  1. Run a SELECT id, <column>, <conditional fields> FROM <table> WHERE <column> <= now() AND ... to find rows due for action. The from= qualifier becomes an additional AND field = 'value' clause.
  2. Open a per-model transaction.
  3. Apply the action to each row in the batch:
    • soft-deleteUPDATE table SET deleted_at = now() WHERE id = ?
    • hard-deleteDELETE FROM table WHERE id = ? (via the adapter’s HardDelete if available)
    • field=NAME;to=VALUEUPDATE table SET name = ? WHERE id = ?
  4. Commit the transaction.
  5. Fire OnDelete / OnSetField hooks for each row, in order.
  6. Move to the next model.

The per-model transaction means a single bad row aborts only that model’s batch, not the whole sweep. Errors are appended to the tick’s Report.Errors and logged.

Sweep for one-shot ticks

runner.Sweep(ctx) runs exactly one tick and returns the Report:

report, err := runner.Sweep(ctx)
log.Printf("deleted %d, updated %d across %d models",
    report.Deleted, report.Updated, len(report.PerModel))

Useful in tests and for cron-driven deployments where the framework’s internal ticker is the wrong fit. Sweep blocks until the pass completes.

Distributed runners

A single runner per cluster is enough for most workloads — the operations it performs (soft-delete, status flip) are idempotent. Two runners processing the same batch simultaneously would do redundant work but no incorrect work.

For at-most-once semantics in a multi-process deployment, run the runner in only one replica (a leader-elected pod, a sidecar, a separate deployment). Or use the scheduled/jobsx adapter, which bridges the runner to a jobs queue so the sweep is enqueued as a durable job and dispatched by the worker pool:

import (
    "github.com/xaleel/maniflex/jobs"
    "github.com/xaleel/maniflex/scheduled"
    "github.com/xaleel/maniflex/scheduled/jobsx"
)

handler := jobsx.JobHandler(runner)
jobsQueue.Register("scheduled.sweep", handler)

cron := cron.New()
cron.Schedule("*/1 * * * *", func() {
    jobsQueue.Enqueue("scheduled.sweep", nil)
})

In this setup the ticker drives the queue, not the runner directly — exactly one worker processes any given tick, even with many app replicas.

Hooks for events and audit

OnDelete and OnSetField are the natural place to emit events for scheduled transitions, so downstream systems learn that a row’s status changed even though no HTTP request caused the change:

runner, _ := scheduled.New(server, scheduled.Config{
    OnSetField: func(model, id, field, to string) {
        bus.Publish(events.Event{
            Kind: "scheduled-transition",
            Data: map[string]any{
                "model": model, "id": id,
                "field": field, "to": to,
            },
        })
    },
})

The hook fires outside the database transaction. For at-least-once delivery semantics, write a row to an outbox table from inside the runner’s transaction (via a custom DB middleware on the affected models) rather than relying on the hook.

Interaction with versioning and audit

A scheduled transition is just an UPDATE (or DELETE) issued by the runner. It flows through the model’s normal middleware:

  • Versioned models get a history row for the transition, with actor_id = NULL (no ctx.Auth exists in the runner).
  • db.AuditLog records the write the same way.

This is intentional — a status change is a status change, regardless of whether a human or the runner triggered it.

When to use scheduled fields

NeedFit
Auto-publish at a fixed timeyes
Auto-archive / auto-expireyes
Soft-delete on retention deadlineyes
Send an email at 9 AM tomorrownot directly — use a job queue; the runner only mutates rows
Run a multi-step workflow at a deadlinenot directly — hook into OnSetField to enqueue the workflow

The runner is deliberately simple: timestamp + row-local change. For side-effecting work outside the database, use it as a trigger and delegate the actual work to a job queue.

Operational checklist

  • One runner per cluster, started once, stopped on shutdown.
  • Set Interval to the desired granularity — 1m is plenty for most workflows; tighten if you have sub-minute deadlines.
  • Set BatchSize to a value the database can absorb in one transaction without blocking writers. 500 is a safe default; for very high-volume tables tune lower so each batch is shorter.
  • Use OnDelete / OnSetField hooks for observability — emit events, increment metrics, log structured records.
  • For deployments with multiple app replicas, gate the runner to one process or use scheduled/jobsx to dispatch sweeps through your job queue.
  • Combine with maniflex.WithDeletedAt for the soft-delete-on-expiry pattern; the indexed deleted_at IS NULL predicate keeps the sweep query cheap as the table grows.