Skip to content

Basics

Defining a job and giving it typed input — the two things every job needs before anything else applies.

The smallest thing that works

A job is a plain function of ICronjobContext, exactly as in v1. What changed is what happens around it: every tick creates a JobRun you can inspect, instead of an anonymous call that leaves no trace.

go
package main

import (
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
)

// --- Example 1: the smallest thing that works -------------------------------
//
// A job is a plain function of ICronjobContext, exactly as in v1. What changed
// is what happens around it: every tick now creates a JobRun you can inspect,
// instead of an anonymous function call that leaves no trace.

func registerBasicJobs(reg *core.JobRegistry) {
	// scheduled, no parameters
	_ = reg.Register(core.JobDef{
		Name:        "heartbeat",
		Description: "prove the worker is alive",
		Schedule:    core.Every(30 * time.Second),
		// a job that fires this often must never queue up behind itself
		MaxConcurrent: 1,
		Concurrency:   core.ConcurrencySkip,
	}, heartbeat)

	// scheduled with a cron expression, retried a few times
	_ = reg.Register(core.JobDef{
		Name:        "nightly-report",
		Description: "build yesterday's report",
		Schedule:    core.Cron("0 2 * * *"),
		Timeout:     10 * time.Minute,
		MaxAttempts: 3,
	}, nightlyReport)

	// no Schedule at all: the job exists only to be triggered by hand
	_ = reg.Register(core.JobDef{
		Name:        "rebuild-search-index",
		Description: "manual-only maintenance task",
		Queue:       "heavy",
	}, rebuildSearchIndex)
}

func heartbeat(c core.ICronjobContext) error {
	c.Log().Debug("still alive", "job", c.JobName())
	return nil
}

func nightlyReport(c core.ICronjobContext) error {
	// the full context is available, exactly like in an HTTP handler:
	// c.DB(), c.Cache(), c.Requester(), c.MQ() — all bound to this run, so a
	// cancelled run cancels its queries too.
	c.Log().Info("building report", "run_id", c.RunID(), "attempt", c.Attempt())
	c.SetResult(map[string]any{"rows": 0})
	return nil
}

func rebuildSearchIndex(c core.ICronjobContext) error {
	c.Progress(0, "starting")
	time.Sleep(50 * time.Millisecond)
	c.Progress(100, "done")
	return nil
}

Typed, validated parameters

Parameters are declared, validated on the way in, and published as a schema — so an admin UI can build a "run now" form without hardcoding anything. See Jobs → Parameters.

go
package main

import (
	"context"
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/utils"
	"gitlab.finema.co/finema/idin-core/v2/valid"
)

// --- Example 2: parameters --------------------------------------------------
//
// Parameters are typed and validated on the way in, exactly like an HTTP
// payload. RegisterJob decodes the stored JSON into P and validates it before
// your handler ever sees it, so a bad trigger fails the run up front instead of
// blowing up halfway through — and the per-field violations are stored on
// JobRun.Error.Fields.

// ReportParams is the input of the "sales-report" job. Fields are pointers, as
// with request payloads, so "absent" and "empty" stay distinguishable. The
// struct stays a plain data type — nothing about the form lives in it.
type ReportParams struct {
	Date   *string  `json:"date"`
	Status *string  `json:"status,omitempty"`
	Email  *string  `json:"email,omitempty"`
	Limit  *int64   `json:"limit,omitempty"`
	Emails []string `json:"emails,omitempty"`
	Force  bool     `json:"force,omitempty"`
}

// reportParamsSchema is what the admin API publishes so a UI can build the "run
// now" form: the name, the shape, whether it is required, the allowed values and
// what each field means — declared in one place you can read top to bottom.
//
// Registration rejects a name that the struct does not have, so this cannot
// quietly drift out of sync with ReportParams.
func reportParamsSchema() []core.ParamField {
	return core.Params(
		core.DateParam("date").
			Label("Report date").
			Desc("Day to report on (defaults to yesterday)").
			Example("2026-07-01"),

		// an enum whose display text differs from the value sent
		core.StringParam("status").
			Options(
				core.Opt("draft", "ฉบับร่าง"),
				core.Opt("sent", "ส่งแล้ว"),
				core.Opt("paid", "ชำระแล้ว"),
			).
			Default("sent").
			Desc("Which invoices to include"),

		core.StringParam("email").
			Pattern(`^[^@]+@[^@]+$`).
			Desc("Notify this address when it is done").
			Example("[email protected]"),

		// constraints the form can enforce before anything is submitted
		core.IntParam("limit").
			Between(1, 1000).
			Desc("Maximum rows").
			Example("500"),

		core.ArrayParam("emails", core.StringParam("")).
			Max(20).
			Desc("Extra recipients"),

		core.BoolParam("force").
			Desc("Rebuild even if the report already exists"),
	)
}

// Anything this framework does not model goes in Meta, and a kind of your own
// goes in CustomParam — the UI decides what to do with them. Wrap house
// conventions in a helper with With() so every job declares them the same way.
func tenantParam() *core.ParamSpec {
	return core.StringParam("tenant").
		Required().
		With(func(f *core.ParamField) {
			f.Pattern = "^t_[a-z0-9]+$"
			f.Description = "Tenant to run for"
		})
}

// PayoutParams describes itself, which is the only way to build a schema out of
// runtime state — allowed currencies from the database, a feature flag, whatever
// is not known at compile time.
type PayoutParams struct {
	Tenant *string `json:"tenant"`
	Amount *int64  `json:"amount"`
	Note   *string `json:"note,omitempty"`
}

// ParamSchema implements core.IParamsDescriber.
func (PayoutParams) ParamSchema() []core.ParamField {
	return core.Params(
		tenantParam(),
		core.CustomParam("amount", "currency").
			Meta("currency", "THB").
			Meta("widget", "money-input").
			Between(1, 1_000_000),
		core.StringParam("note").
			Multiline().
			Max(500).
			Meta("group", "audit"),
	)
}

func payoutRun(c core.ICronjobContext, p PayoutParams) error {
	c.Log().Info("paying out", "tenant", utils.ToNonPointerOr(p.Tenant, ""))
	return nil
}

// Valid implements core.IValidateContext. The run context *is* a core.IContext,
// so the whole valid package works here — including the DB-backed rules, which
// query through the same connections an HTTP handler would use.
func (p *ReportParams) Valid(ctx core.IContext) core.IError {
	v := valid.New(ctx)
	v.Str("date", p.Date).Required().Date("2006-01-02")
	v.Str("email", p.Email).Email().Exists("users", "email")
	v.Int("limit", p.Limit).Between(1, 1000)
	v.Arr("emails", p.Emails).Max(20)
	return v.Error()
}

func registerParamJobs(reg *core.JobRegistry) {
	// RegisterJob (not Register) gives the handler its params already decoded
	_ = core.RegisterJob(reg, core.JobDef{
		Name:        "sales-report",
		Description: "build the sales report for one day",
		Schedule:    core.Cron("30 1 * * *"),
		Timeout:     5 * time.Minute,
		MaxAttempts: 3,
		Params:      reportParamsSchema(),
	}, salesReport)

	// A parameter type can also describe itself — the only way to build a schema
	// from runtime state (values loaded from the database, a feature flag).
	_ = core.RegisterJob(reg, core.JobDef{
		Name:        "payout-run",
		Description: "schema declared by the parameter type",
	}, payoutRun)

	// A job that declares nothing still lists its parameters: the names and
	// kinds are read off the type. That is enough for a quick form, but only a
	// declared schema can say "required", "enum" or what a field means.
	_ = core.RegisterJob(reg, core.JobDef{
		Name:        "quick-report",
		Description: "same input, schema inferred from the type",
	}, salesReport)
}

func salesReport(c core.ICronjobContext, p *ReportParams) error {
	// A scheduled tick passes no parameters, so p holds its zero values — hence
	// "date" is validated but not Required: a job that is both scheduled *and*
	// parameterised has to have a sensible default for every field, otherwise
	// every scheduled run fails validation.
	date := utils.ToNonPointerOr(p.Date, time.Now().AddDate(0, 0, -1).Format("2006-01-02"))

	c.Log().Info("building sales report", "date", date, "force", p.Force)
	c.SetResult(map[string]any{"date": date, "rows": 42})
	return nil
}

// triggerReport is what a handler, a CLI command or an admin endpoint calls to
// run the job right now with specific parameters.
func triggerReport(ctx context.Context, runner *core.JobRunner, userID, date string) (*core.JobRun, core.IError) {
	return runner.Trigger(ctx, "sales-report",
		&ReportParams{Date: utils.ToPointer(date), Force: true},
		core.TriggerOptions{
			By: userID,
			// one run per day, however many times the button is clicked
			IdemKey: "sales-report:" + date,
		})
}

// triggerReportAndWait is the variant for a UI that shows the outcome
// immediately. Everything else about the run is identical — same queue, same
// status, same logs.
func triggerReportAndWait(ctx context.Context, runner *core.JobRunner, date string) (*core.JobRun, core.IError) {
	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	return runner.TriggerAndWait(ctx, "sales-report", &ReportParams{Date: utils.ToPointer(date)})
}

// invalidParams shows what a caller gets back when validation fails: the run is
// created, fails immediately, and JobRun.Error carries the per-field violations
// in the same shape the HTTP layer returns:
//
//	{
//	  "code": "INVALID_PARAMS",
//	  "fields": {
//	    "date":  {"code": "INVALID_DATE",  "message": "The date field must be a valid date"},
//	    "email": {"code": "NOT_EXISTS",    "message": "The email field does not exist"}
//	  }
//	}
func invalidParams(ctx context.Context, runner *core.JobRunner) {
	run, _ := runner.TriggerAndWait(ctx, "sales-report",
		&ReportParams{Date: utils.ToPointer("01-07-2026")})
	if run != nil && run.Error != nil {
		_ = run.Error.Fields // ready to hand straight back to the caller
	}
}

Maintained by Passakon Puttasuwan & Dev Core Team.