forked from goffee/goffee
command scheduler
scheduler:processed List processed task executions scheduler:queue List queued scheduler tasks scheduler:semaphore Get or set the scheduler semaphore state scheduler:truncate Empty the scheduler queue and processed logs
This commit is contained in:
parent
06778a5357
commit
b47dbcce70
1 changed files with 308 additions and 0 deletions
308
cmd/scheduler.go
Normal file
308
cmd/scheduler.go
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
// Copyright (c) 2026 Jose Cely <me@jacs.guru>
|
||||
// Use of this source code is governed by MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ─── Helper ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// table names (must match core/scheduler/scheduler.go)
|
||||
const (
|
||||
queueTable = "queue_items"
|
||||
processedTable = "processed_items"
|
||||
schedulerMetaTable = "scheduler_meta"
|
||||
semaphoreKey = "semaphore"
|
||||
semaphoreDefault = "green"
|
||||
)
|
||||
|
||||
// loadEnvAndDB loads the env file for the given mode and returns a connected *gorm.DB.
|
||||
func loadEnvAndDB(envMode string) (*gorm.DB, error) {
|
||||
var envFile string
|
||||
switch envMode {
|
||||
case "dev":
|
||||
envFile = ".env-dev"
|
||||
case "prod":
|
||||
envFile = ".env"
|
||||
default:
|
||||
return nil, fmt.Errorf("environment must be 'dev' or 'prod', got '%s'", envMode)
|
||||
}
|
||||
|
||||
envPath := filepath.Join(".", envFile)
|
||||
if _, err := os.Stat(envPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("environment file '%s' not found in current directory", envFile)
|
||||
}
|
||||
|
||||
if err := godotenv.Load(envPath); err != nil {
|
||||
return nil, fmt.Errorf("error loading '%s': %v", envFile, err)
|
||||
}
|
||||
|
||||
return connectDatabase()
|
||||
}
|
||||
|
||||
// ─── scheduler:queue ─────────────────────────────────────────────────────────
|
||||
|
||||
var SchedulerQueueCmd = &cobra.Command{
|
||||
Use: "scheduler:queue [dev|prod]",
|
||||
Short: "List queued scheduler tasks",
|
||||
Long: `Lists all pending and processing tasks in the scheduler queue.
|
||||
|
||||
Example:
|
||||
goffee scheduler:queue dev
|
||||
goffee scheduler:queue prod
|
||||
`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db, err := loadEnvAndDB(args[0])
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
type QueueItem struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
TaskType string `gorm:"column:task_type"`
|
||||
Payload string `gorm:"column:payload"`
|
||||
Status string `gorm:"column:status"`
|
||||
Priority int `gorm:"column:priority"`
|
||||
MaxRuns int `gorm:"column:max_runs"`
|
||||
Thread int `gorm:"column:thread"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
var items []QueueItem
|
||||
result := db.Table(queueTable).
|
||||
Where("status IN ?", []string{"pending", "processing"}).
|
||||
Order("priority DESC, id ASC").
|
||||
Find(&items)
|
||||
if result.Error != nil {
|
||||
fmt.Printf("Error querying queue: %v\n", result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Println("Queue is empty.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%-6s %-18s %-12s %-8s %-8s %-7s %-6s %s\n",
|
||||
"ID", "TYPE", "STATUS", "PRIORITY", "MAXRUNS", "THREAD", "CREATED", "PAYLOAD")
|
||||
fmt.Println("------ ------------------ ------------ -------- -------- ------- ------ ------------------------------")
|
||||
for _, it := range items {
|
||||
trunc := it.Payload
|
||||
if len(trunc) > 40 {
|
||||
trunc = trunc[:37] + "..."
|
||||
}
|
||||
fmt.Printf("%-6d %-18s %-12s %-8d %-8d %-7d %-6s %s\n",
|
||||
it.ID, it.TaskType, it.Status, it.Priority, it.MaxRuns, it.Thread,
|
||||
it.CreatedAt.Format("15:04:05"), trunc)
|
||||
}
|
||||
fmt.Printf("\nTotal: %d queued task(s)\n", len(items))
|
||||
},
|
||||
}
|
||||
|
||||
// ─── scheduler:processed ─────────────────────────────────────────────────────
|
||||
|
||||
var SchedulerProcessedCmd = &cobra.Command{
|
||||
Use: "scheduler:processed [dev|prod] [limit]",
|
||||
Short: "List processed task executions",
|
||||
Long: `Lists the most recent processed task executions.
|
||||
|
||||
The limit defaults to 10. Pass a number to change it.
|
||||
|
||||
Example:
|
||||
goffee scheduler:processed dev # last 10
|
||||
goffee scheduler:processed prod # last 10
|
||||
goffee scheduler:processed dev 100 # last 100
|
||||
goffee scheduler:processed prod 50 # last 50
|
||||
`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db, err := loadEnvAndDB(args[0])
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if len(args) == 2 {
|
||||
n, err := fmt.Sscanf(args[1], "%d", &limit)
|
||||
if err != nil || n != 1 || limit < 1 {
|
||||
fmt.Printf("Error: invalid limit '%s' — must be a positive integer\n", args[1])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessedItem struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
QueueItemID uint `gorm:"column:queue_item_id"`
|
||||
TaskType string `gorm:"column:task_type"`
|
||||
Status string `gorm:"column:status"`
|
||||
ErrorMsg string `gorm:"column:error_msg"`
|
||||
DurationMs int64 `gorm:"column:duration_ms"`
|
||||
StartedAt time.Time `gorm:"column:started_at"`
|
||||
FinishedAt time.Time `gorm:"column:finished_at"`
|
||||
}
|
||||
|
||||
var items []ProcessedItem
|
||||
result := db.Table(processedTable).
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Find(&items)
|
||||
if result.Error != nil {
|
||||
fmt.Printf("Error querying processed items: %v\n", result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Println("No processed executions found.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%-6s %-14s %-18s %-8s %-10s %-8s %s\n",
|
||||
"ID", "QUEUE_ITEM_ID", "TYPE", "STATUS", "DURATION", "STARTED", "ERROR")
|
||||
fmt.Println("------ -------------- ------------------ -------- ---------- -------- ------------------------------")
|
||||
for _, it := range items {
|
||||
errMsg := it.ErrorMsg
|
||||
if len(errMsg) > 35 {
|
||||
errMsg = errMsg[:32] + "..."
|
||||
}
|
||||
fmt.Printf("%-6d %-14d %-18s %-8s %-10s %-8s %s\n",
|
||||
it.ID, it.QueueItemID, it.TaskType, it.Status,
|
||||
fmt.Sprintf("%dms", it.DurationMs),
|
||||
it.StartedAt.Format("15:04:05"), errMsg)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ─── scheduler:semaphore ─────────────────────────────────────────────────────
|
||||
|
||||
var SchedulerSemaphoreCmd = &cobra.Command{
|
||||
Use: "scheduler:semaphore [dev|prod] [green|red]",
|
||||
Short: "Get or set the scheduler semaphore state",
|
||||
Long: `View or change the scheduler semaphore state.
|
||||
|
||||
Without a state argument, prints the current state.
|
||||
With "green" or "red", sets the semaphore accordingly.
|
||||
|
||||
⚠ WARNING: changing the state only updates the database. The running
|
||||
application keeps the semaphore in memory and will NOT reflect the
|
||||
change until you restart it.
|
||||
|
||||
Example:
|
||||
goffee scheduler:semaphore dev # show current state
|
||||
goffee scheduler:semaphore dev red # block after restart
|
||||
goffee scheduler:semaphore prod green # allow after restart
|
||||
`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db, err := loadEnvAndDB(args[0])
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the meta table exists.
|
||||
_ = db.Table(schedulerMetaTable).AutoMigrate(&struct {
|
||||
Key string `gorm:"primaryKey;size:64"`
|
||||
Value string `gorm:"size:16;not null"`
|
||||
}{})
|
||||
|
||||
if len(args) == 2 {
|
||||
state := args[1]
|
||||
if state != "green" && state != "red" {
|
||||
fmt.Printf("Error: state must be 'green' or 'red', got '%s'\n", state)
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
db.Table(schedulerMetaTable).Where("key = ?", semaphoreKey).Count(&count)
|
||||
if count == 0 {
|
||||
_ = db.Table(schedulerMetaTable).Create(map[string]interface{}{
|
||||
"key": semaphoreKey, "value": state,
|
||||
})
|
||||
} else {
|
||||
_ = db.Table(schedulerMetaTable).
|
||||
Where("key = ?", semaphoreKey).
|
||||
Update("value", state)
|
||||
}
|
||||
fmt.Printf("Semaphore set to %s (restart the application for it to take effect)\n", state)
|
||||
} else {
|
||||
var value string
|
||||
err := db.Table(schedulerMetaTable).
|
||||
Where("key = ?", semaphoreKey).
|
||||
Select("value").
|
||||
Take(&value).Error
|
||||
if err != nil {
|
||||
fmt.Printf("Semaphore: %s (default)\n", semaphoreDefault)
|
||||
} else {
|
||||
fmt.Printf("Semaphore: %s\n", value)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ─── scheduler:truncate ──────────────────────────────────────────────────────
|
||||
|
||||
var SchedulerTruncateCmd = &cobra.Command{
|
||||
Use: "scheduler:truncate [dev|prod]",
|
||||
Short: "Empty the scheduler queue and processed logs",
|
||||
Long: `Deletes all rows from queue_items and processed_items tables.
|
||||
Use with caution — this action cannot be undone.
|
||||
|
||||
Example:
|
||||
goffee scheduler:truncate dev
|
||||
goffee scheduler:truncate prod
|
||||
`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db, err := loadEnvAndDB(args[0])
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all processed items first (child table), then queue items.
|
||||
procResult := db.Exec(fmt.Sprintf("DELETE FROM %s", processedTable))
|
||||
if procResult.Error != nil {
|
||||
fmt.Printf("Error truncating processed_items: %v\n", procResult.Error)
|
||||
return
|
||||
}
|
||||
|
||||
queueResult := db.Exec(fmt.Sprintf("DELETE FROM %s", queueTable))
|
||||
if queueResult.Error != nil {
|
||||
fmt.Printf("Error truncating queue_items: %v\n", queueResult.Error)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset semaphore to green (default).
|
||||
var count int64
|
||||
db.Table(schedulerMetaTable).Where("key = ?", semaphoreKey).Count(&count)
|
||||
if count > 0 {
|
||||
db.Table(schedulerMetaTable).
|
||||
Where("key = ?", semaphoreKey).
|
||||
Update("value", semaphoreDefault)
|
||||
}
|
||||
|
||||
fmt.Printf("Truncated %d processed item(s) and %d queue item(s). Semaphore reset to %s.\n",
|
||||
procResult.RowsAffected, queueResult.RowsAffected, semaphoreDefault)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Registration ────────────────────────────────────────────────────────────
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(SchedulerQueueCmd)
|
||||
rootCmd.AddCommand(SchedulerProcessedCmd)
|
||||
rootCmd.AddCommand(SchedulerSemaphoreCmd)
|
||||
rootCmd.AddCommand(SchedulerTruncateCmd)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue