Skip to content

Controlling execution ​

What happens when runs pile up, when one has to be stopped, and when the same work needs to happen again.

How many run at once ​

Three independent limits: how much the process will do at once (WithWorkers), how much a queue may do at once (WithQueueLimit), and what happens when a job overlaps itself.

go
package main

import (
	"time"

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

// --- Example 3: how many run at once ----------------------------------------
//
// Three independent limits, each answering a different question:
//
//	WithWorkers(n)             how much this *process* will do at once
//	WithQueueLimit("heavy", 1) how much a *queue* may do at once
//	JobDef.MaxConcurrent       how many runs of *this job* may overlap
//
// and one policy that decides what happens when a run hits the limit.

func registerConcurrencyJobs(reg *core.JobRegistry) {
	// Skip — the right choice for anything on a short schedule. If the previous
	// run is still going, this one is dropped as "skipped" and life goes on.
	// With Enqueue here, a job slower than its interval would pile up forever.
	_ = reg.Register(core.JobDef{
		Name:          "poll-inbox",
		Schedule:      core.Every(time.Minute),
		MaxConcurrent: 1,
		Concurrency:   core.ConcurrencySkip,
	}, pollInbox)

	// Enqueue (the default) — nothing is lost. Runs wait their turn, at most two
	// at a time. Good for work that must all happen eventually.
	_ = reg.Register(core.JobDef{
		Name:          "send-invoice",
		MaxConcurrent: 2,
		Concurrency:   core.ConcurrencyEnqueue,
		MaxAttempts:   5,
		Backoff:       core.ExponentialBackoff(2*time.Second, time.Minute),
	}, sendInvoice)

	// Replace — latest wins. Triggering it again cancels the run in progress,
	// which only makes sense when the newer run supersedes the older one.
	_ = reg.Register(core.JobDef{
		Name:          "rebuild-cache",
		Queue:         "heavy",
		MaxConcurrent: 1,
		Concurrency:   core.ConcurrencyReplace,
		StopGrace:     10 * time.Second,
	}, rebuildCache)
}

func pollInbox(c core.ICronjobContext) error {
	c.Log().Debug("polling")
	return nil
}

func sendInvoice(c core.ICronjobContext) error {
	c.Log().Info("sending invoice", "attempt", c.Attempt())
	return nil
}

func rebuildCache(c core.ICronjobContext) error {
	for i := range 10 {
		// a Replace-able job must be stoppable, or "latest wins" cannot work
		if c.IsStopping() {
			c.Log().Info("superseded by a newer run")
			return c.Err()
		}
		c.Progress(i*10, "rebuilding")
		time.Sleep(100 * time.Millisecond)
	}
	return nil
}

Stopping a run ​

Go cannot kill a goroutine, so stopping is always cooperative: the runner cancels the run context and the handler has to notice. This is what "notice it" looks like in practice.

go
package main

import (
	"context"
	"time"

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

// --- Example 4: stopping ----------------------------------------------------
//
// Go cannot kill a goroutine, so stopping is always *cooperative*: the runner
// cancels the run's context and the handler has to notice. Everything that goes
// through the context — DB queries, HTTP calls, cache reads — is cancelled for
// you; only your own loops need the explicit check.
//
// A handler that never checks is not a hung service: after StopGrace the runner
// records the run as canceled and moves on (it logs a warning, and the goroutine
// is left to finish on its own). Still, write stoppable jobs.

func registerStoppableJobs(reg *core.JobRegistry) {
	_ = reg.Register(core.JobDef{
		Name:        "migrate-users",
		Description: "long, resumable batch job",
		Queue:       "heavy",
		Timeout:     2 * time.Hour,
		StopGrace:   30 * time.Second,
	}, migrateUsers)
}

type userRow struct {
	ID string `gorm:"column:id"`
}

func (userRow) TableName() string { return "users" }

func migrateUsers(c core.ICronjobContext) error {
	const batch = 500
	for offset := 0; ; offset += batch {
		// the one line that makes this job stoppable
		if c.IsStopping() {
			c.Log().Info("stopping cleanly", "processed", offset)
			return c.Err()
		}

		rows, err := repository.New[userRow](c).Limit(batch).Offset(offset).FindAll()
		if err != nil {
			return err // the query is cancelled with the run — no orphaned work
		}
		if len(rows) == 0 {
			break
		}
		c.Progress(offset*100/50000, "migrating")
	}
	return nil
}

// stopExamples shows the three levels of "stop", which are *not* the same thing.
func stopExamples(ctx context.Context, runner *core.JobRunner, sc *core.Scheduler, runID string) {
	// 1. stop one run. Queued → it never starts. Running → it is asked to stop.
	_ = runner.Cancel(ctx, runID, "[email protected]", "wrong parameters")

	// 2. pause a whole job: the schedule stops firing and manual triggers are
	//    rejected, without touching anything already queued.
	_ = runner.Pause("migrate-users")
	_ = runner.Resume("migrate-users")

	// 3. stop the process. Runs that do not finish in time go back on the
	//    queue — they are not failures, they never got the chance to fail.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	_ = sc.Stop()
	_ = runner.Stop(shutdownCtx)
}

Replay, retry and idempotency ​

Four different ideas people all call "re-run". Keeping them apart is most of the design.

go
package main

import (
	"context"
	"time"

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

// --- Example 6: running the same thing again --------------------------------
//
// Four different ideas people call "re-run". Keeping them apart is most of the
// design:
//
//	Cancel   stop something that has not finished
//	Retry    the runner's own doing, after a failure, while attempts remain
//	Replay   an operator re-running a *finished* run — a new run, linked back
//	IdemKey  stopping a duplicate run from being created in the first place

func registerReplayJobs(reg *core.JobRegistry) {
	// safe to replay: recomputing a report just recomputes it
	_ = reg.Register(core.JobDef{
		Name:        "recalculate-balances",
		MaxAttempts: 3,
		Backoff:     core.ExponentialBackoff(time.Second, 2*time.Minute),
	}, recalculateBalances)

	// NOT safe to replay: this moves real money. Replay is refused with 403,
	// so nobody can re-send a payout from the admin UI by accident.
	_ = reg.Register(core.JobDef{
		Name:       "payout-vendors",
		Replayable: core.BoolPtr(false),
	}, payoutVendors)
}

func recalculateBalances(c core.ICronjobContext) error {
	c.Log().Info("recalculating", "attempt", c.Attempt(), "trigger", c.Trigger())
	return nil
}

func payoutVendors(c core.ICronjobContext) error {
	// At-least-once delivery: a worker can die mid-run and the run comes back.
	// Handlers must be idempotent — key the side effect on something stable.
	c.Log().Info("paying out", "run_id", c.RunID())
	return nil
}

// replayFailedRuns re-runs everything that failed today — the "fix the bug,
// then replay" flow.
func replayFailedRuns(ctx context.Context, runner *core.JobRunner, by string) core.IError {
	since := time.Now().Add(-24 * time.Hour)
	page, err := runner.Runs(ctx, core.JobRunFilter{
		JobName:  "recalculate-balances",
		Statuses: []core.RunStatus{core.RunFailed},
		From:     &since,
		Page:     &core.PageOptions{Limit: 100},
	})
	if err != nil {
		return err
	}
	for _, old := range page.Items {
		// a new run each time: the original record is never overwritten, and
		// ReplayOf/RootID keep the whole chain traceable
		if _, rerr := runner.Replay(ctx, old.ID, core.ReplayOptions{By: by}); rerr != nil {
			return rerr
		}
	}
	return nil
}

// replayWithDifferentParams fixes the input rather than the code.
func replayWithDifferentParams(ctx context.Context, runner *core.JobRunner, runID string) (*core.JobRun, core.IError) {
	return runner.Replay(ctx, runID, core.ReplayOptions{
		By:     "[email protected]",
		Params: &ReportParams{Date: utils.ToPointer("2026-07-01"), Force: true},
	})
}

// demoManualTriggers is called from main: it is the "operator does things"
// tour — trigger, watch, cancel, replay.
func demoManualTriggers(runner *core.JobRunner) {
	ctx := context.Background()

	// run it now, with parameters, safe against double clicks
	run, err := triggerReport(ctx, runner, "[email protected]", "2026-07-01")
	if err != nil {
		return
	}

	// watch it live (works even though logs are not persisted)
	go watchRun(runner, run.ID)

	// ... and if it turns out to be wrong, stop it
	_ = runner.Cancel(ctx, run.ID, "[email protected]", "wrong date")
}

Maintained by Passakon Puttasuwan & Dev Core Team.