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 inModeCron. They never share request state.
Shutdown order matters
e.Shutdown(ctx)— stop taking new requests, let in-flight ones finish.sc.Stop()— stop scheduling; running jobs are allowed to complete.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
Appwiring; 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 therun()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.