Skip to content

Running API + Cron Jobs together

A single service can serve HTTP and run scheduled jobs in one process. Both share one App (one set of connection pools), and shutdown is coordinated so in-flight requests and jobs finish cleanly.

Full example (main.go)

go
package main

import (
	"context"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

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

func main() {
	env, err := core.NewEnv()
	if err != nil {
		panic(err)
	}

	// shared resources (one pool set for both HTTP handlers and jobs)
	db, err := core.NewDatabase(env)
	if err != nil {
		panic(err)
	}
	redis, err := core.NewCache(env)
	if err != nil {
		panic(err)
	}
	app, err := core.NewApp(env,
		core.WithSQL("default", db),
		core.WithCache("default", redis),
	)
	if err != nil {
		panic(err)
	}

	// --- HTTP ---
	e := core.NewHTTPServer(app, nil)
	e.GET("/health", func(c core.IHTTPContext) error { return c.JSON(200, "ok") })
	// ... more routes ...

	// --- Cron ---
	sc, err := core.NewScheduler(app)
	if err != nil {
		panic(err)
	}
	_ = sc.AddByCron("nightly-report", "0 2 * * *", NightlyReport)
	_ = sc.AddByDuration("heartbeat", 30*time.Second, Heartbeat)

	run(app, e, sc)
}

// run starts the scheduler and HTTP server, then blocks until SIGINT/SIGTERM and
// shuts everything down gracefully (drain HTTP → stop jobs → close pools).
func run(app *core.App, e *core.Server, sc *core.Scheduler) {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	sc.Start() // non-blocking

	addr := app.Config().Host
	if addr == "" {
		addr = ":8080"
	}
	go func() {
		if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
			app.Log().Error("http server stopped", "err", err)
			stop() // trigger shutdown if the server dies
		}
	}()
	app.Log().Info("service started", "addr", addr)

	<-ctx.Done() // wait for a signal
	app.Log().Info("shutting down…")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	_ = e.Shutdown(shutdownCtx)   // 1. stop accepting requests, drain in-flight
	_ = sc.Stop()                 // 2. stop the scheduler (waits for running jobs)
	_ = app.Shutdown(shutdownCtx) // 3. close pools last
}

Jobs are ordinary core.JobFuncs with the full context (see scheduler.md):

go
func NightlyReport(c core.ICronjobContext) error {
	users, err := repository.New[User](c).Where("active = ?", true).FindAll()
	if err != nil {
		return err
	}
	// c.DB(), c.Cache(), c.Requester(), c.MQ() — same capabilities as HTTP handlers
	return nil
}

Why one App

  • One pool set. HTTP handlers and jobs use the same DB/cache/MQ pools — no double the connections, no lifecycle surprises.
  • Correct shutdown. Pools are closed once (app.Shutdown), after both the HTTP server and the scheduler have stopped.
  • Independent contexts. Each request runs in ModeHTTP; each job run gets a fresh context in ModeCron. They never share request state.

Shutdown order matters

  1. e.Shutdown(ctx) — stop taking new requests, let in-flight ones finish.
  2. sc.Stop() — stop scheduling; running jobs are allowed to complete.
  3. app.Shutdown(ctx) — close DB/cache/MQ pools last, once nothing uses them.

The 15-second timeout bounds the whole drain; tune it to your longest expected request/job.

Alternative: separate API and worker processes

For independent scaling (many API replicas, few workers) run the same binary in two modes and deploy them separately:

go
func main() {
	app := bootstrap() // NewEnv + NewDatabase + NewApp ...

	switch os.Getenv("APP_ROLE") {
	case "worker":
		runWorker(app) // scheduler only, blocks until signal
	default:
		runAPI(app)    // HTTP only
	}
}
  • APP_ROLE=worker → only the scheduler runs (no HTTP port).
  • default / APP_ROLE=api → only HTTP.
  • Same codebase and App wiring; pick per deployment.

Use the combined process (first example) for small services; split into API + worker roles when you need to scale or isolate them.

Notes

  • core.StartHTTPServer(e, env) is the simple HTTP-only starter. For a combined service use the run() loop above so the scheduler and pools shut down with the server.
  • A panicking job never crashes the scheduler — it becomes a logged error (see scheduler.md). A panicking HTTP handler becomes a 500.
  • Want only one instance in a cluster to run a job? Guard it inside the job with a Redis lock via c.Cache() (SetNX) before doing the work.

Maintained by Passakon Puttasuwan & Dev Core Team.