Files
Duc Nguyen 983f6184d9 fix(ui): dynamic searchable timezone picker with validation (#614)
Replace hardcoded 20-entry IANA_TIMEZONES with getAllIanaTimezones()
using Intl.supportedValuesOf (~400 zones). Switch Select dropdowns
to searchable Combobox in cron, heartbeat, and system config.

Add defense-in-depth timezone validation:
- Backend: validate in heartbeat.set handler and SetDefaultTimezone()
- Frontend: isValidIanaTimezone() guard before save in all 3 dialogs

Closes #614
2026-04-01 11:12:35 +07:00

111 lines
2.6 KiB
Go

package pg
import (
"context"
"database/sql"
"log/slog"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/cron"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const defaultCronCacheTTL = 2 * time.Minute
// PGCronStore implements store.CronStore backed by Postgres.
// GetDueJobs() uses an in-memory cache with TTL to reduce DB polling (1s interval).
type PGCronStore struct {
db *sql.DB
mu sync.Mutex
baseCtx context.Context // lifecycle context for background DB operations
cancelCtx context.CancelFunc // cancelled on Stop()
onJob func(job *store.CronJob) (*store.CronJobResult, error)
onEvent func(event store.CronEvent)
running bool
stop chan struct{}
// Job cache: reduces GetDueJobs polling from 86,400 queries/day to ~720/day
jobCache []store.CronJob
cacheLoaded bool
cacheTime time.Time
cacheTTL time.Duration
retryCfg cron.RetryConfig
defaultTZ string // fallback IANA timezone for cron jobs without explicit TZ
}
func NewPGCronStore(db *sql.DB) *PGCronStore {
return &PGCronStore{db: db, cacheTTL: defaultCronCacheTTL, retryCfg: cron.DefaultRetryConfig()}
}
// SetRetryConfig overrides the default retry configuration.
func (s *PGCronStore) SetRetryConfig(cfg cron.RetryConfig) {
s.mu.Lock()
defer s.mu.Unlock()
s.retryCfg = cfg
}
// SetDefaultTimezone sets the fallback IANA timezone for cron expressions
// when a job does not specify its own timezone.
func (s *PGCronStore) SetDefaultTimezone(tz string) {
if tz != "" {
if _, err := time.LoadLocation(tz); err != nil {
slog.Warn("security.invalid_default_timezone", "tz", tz, "err", err)
return
}
}
s.mu.Lock()
defer s.mu.Unlock()
s.defaultTZ = tz
}
func (s *PGCronStore) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return nil
}
s.baseCtx, s.cancelCtx = context.WithCancel(context.Background())
s.stop = make(chan struct{})
s.running = true
s.recomputeStaleJobs()
go s.runLoop()
slog.Info("pg cron service started")
return nil
}
func (s *PGCronStore) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
close(s.stop)
if s.cancelCtx != nil {
s.cancelCtx()
}
s.running = false
}
func (s *PGCronStore) SetOnJob(handler func(job *store.CronJob) (*store.CronJobResult, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onJob = handler
}
func (s *PGCronStore) SetOnEvent(handler func(event store.CronEvent)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onEvent = handler
}
func (s *PGCronStore) emitEvent(event store.CronEvent) {
s.mu.Lock()
fn := s.onEvent
s.mu.Unlock()
if fn != nil {
fn(event)
}
}