mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-16 22:54:12 +00:00
- Fix log cross-contamination by introducing per-invocation LogBuffers (bugboard #108) - Enforce a 100ms minimum for CronPollInterval to prevent scheduler starvation (bugboard #109) - Add comprehensive validation tests for cron interval constraints
238 lines
7.9 KiB
Go
238 lines
7.9 KiB
Go
package serverless
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Config holds configuration for the serverless engine.
|
|
type Config struct {
|
|
// Memory limits
|
|
DefaultMemoryLimitMB int `yaml:"default_memory_limit_mb"`
|
|
MaxMemoryLimitMB int `yaml:"max_memory_limit_mb"`
|
|
|
|
// Execution limits
|
|
DefaultTimeoutSeconds int `yaml:"default_timeout_seconds"`
|
|
MaxTimeoutSeconds int `yaml:"max_timeout_seconds"`
|
|
|
|
// Retry configuration
|
|
DefaultRetryCount int `yaml:"default_retry_count"`
|
|
MaxRetryCount int `yaml:"max_retry_count"`
|
|
DefaultRetryDelaySeconds int `yaml:"default_retry_delay_seconds"`
|
|
|
|
// Rate limiting (global)
|
|
GlobalRateLimitPerMinute int `yaml:"global_rate_limit_per_minute"`
|
|
|
|
// Background job configuration
|
|
JobWorkers int `yaml:"job_workers"`
|
|
JobPollInterval time.Duration `yaml:"job_poll_interval"`
|
|
JobMaxQueueSize int `yaml:"job_max_queue_size"`
|
|
JobMaxPayloadSize int `yaml:"job_max_payload_size"` // bytes
|
|
|
|
// Scheduler configuration.
|
|
//
|
|
// CronPollInterval is the cadence at which the cron scheduler scans
|
|
// `function_cron_triggers` for due rows. Lower = finer dispatch
|
|
// granularity (useful for sub-second cron expressions like
|
|
// `*/1 * * * * *` — the 6-field grammar accepted by ParseCron),
|
|
// higher = less rqlite/CPU spend.
|
|
//
|
|
// Hard floor: MinCronPollInterval (rejected at Validate). Below the
|
|
// floor the scheduler can't keep up — each tick costs ~1 rqlite
|
|
// ListDue + N MarkRun writes, ~340-450ms per call on a
|
|
// cross-region anchat-test-style cluster. Polling faster than the
|
|
// per-tick cost queues ticks indefinitely and starves the namespace.
|
|
//
|
|
// Default: 1 minute. Set to 1s for typing/presence-style ephemeral
|
|
// state prune workloads (bugboard #109).
|
|
CronPollInterval time.Duration `yaml:"cron_poll_interval"`
|
|
TimerPollInterval time.Duration `yaml:"timer_poll_interval"`
|
|
DBPollInterval time.Duration `yaml:"db_poll_interval"`
|
|
|
|
// WASM execution limits
|
|
MaxConcurrentExecutions int `yaml:"max_concurrent_executions"` // Max concurrent WASM module instantiations
|
|
|
|
// WASM compilation cache
|
|
ModuleCacheSize int `yaml:"module_cache_size"` // Number of compiled modules to cache
|
|
EnablePrewarm bool `yaml:"enable_prewarm"` // Pre-compile frequently used functions
|
|
|
|
// Secrets encryption
|
|
SecretsEncryptionKey string `yaml:"secrets_encryption_key"` // AES-256 key (32 bytes, hex-encoded)
|
|
|
|
// Logging
|
|
LogInvocations bool `yaml:"log_invocations"` // Log all invocations to database
|
|
LogRetention int `yaml:"log_retention"` // Days to retain logs
|
|
}
|
|
|
|
// MinCronPollInterval is the hard floor on CronPollInterval. Below
|
|
// this the cron scheduler can't keep up with itself — each tick costs
|
|
// at minimum one rqlite ListDue (a network round-trip + query), so
|
|
// polling much faster than the per-tick cost would queue ticks
|
|
// indefinitely and starve the namespace gateway. 100ms is generous
|
|
// (it allows ~10 ticks/sec) while still preventing the runaway
|
|
// configuration that would cripple the gateway.
|
|
//
|
|
// Operators wanting sub-second cron dispatch (e.g. typing/presence
|
|
// ephemeral state prune jobs per bugboard #109) should set 1s — this
|
|
// gives comfortable headroom over per-tick rqlite latency even on
|
|
// cross-region clusters and allows 6-field cron expressions like
|
|
// `*/1 * * * * *` to fire on every-second cadence.
|
|
const MinCronPollInterval = 100 * time.Millisecond
|
|
|
|
// DefaultConfig returns a configuration with sensible defaults.
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
// Memory limits
|
|
DefaultMemoryLimitMB: 64,
|
|
MaxMemoryLimitMB: 256,
|
|
|
|
// Execution limits
|
|
DefaultTimeoutSeconds: 30,
|
|
MaxTimeoutSeconds: 300, // 5 minutes max
|
|
|
|
// Retry configuration
|
|
DefaultRetryCount: 0,
|
|
MaxRetryCount: 5,
|
|
DefaultRetryDelaySeconds: 5,
|
|
|
|
// Rate limiting
|
|
GlobalRateLimitPerMinute: 250000, // 250k requests/minute globally
|
|
|
|
// Background jobs
|
|
JobWorkers: 4,
|
|
JobPollInterval: time.Second,
|
|
JobMaxQueueSize: 10000,
|
|
JobMaxPayloadSize: 1024 * 1024, // 1MB
|
|
|
|
// Scheduler
|
|
CronPollInterval: time.Minute,
|
|
TimerPollInterval: time.Second,
|
|
DBPollInterval: time.Second * 5,
|
|
|
|
// WASM execution
|
|
MaxConcurrentExecutions: 10,
|
|
|
|
// WASM cache
|
|
ModuleCacheSize: 100,
|
|
EnablePrewarm: true,
|
|
|
|
// Logging
|
|
LogInvocations: true,
|
|
LogRetention: 7, // 7 days
|
|
}
|
|
}
|
|
|
|
// Validate checks the configuration for errors.
|
|
func (c *Config) Validate() []error {
|
|
var errs []error
|
|
|
|
if c.DefaultMemoryLimitMB <= 0 {
|
|
errs = append(errs, &ConfigError{Field: "DefaultMemoryLimitMB", Message: "must be positive"})
|
|
}
|
|
if c.MaxMemoryLimitMB < c.DefaultMemoryLimitMB {
|
|
errs = append(errs, &ConfigError{Field: "MaxMemoryLimitMB", Message: "must be >= DefaultMemoryLimitMB"})
|
|
}
|
|
if c.DefaultTimeoutSeconds <= 0 {
|
|
errs = append(errs, &ConfigError{Field: "DefaultTimeoutSeconds", Message: "must be positive"})
|
|
}
|
|
if c.MaxTimeoutSeconds < c.DefaultTimeoutSeconds {
|
|
errs = append(errs, &ConfigError{Field: "MaxTimeoutSeconds", Message: "must be >= DefaultTimeoutSeconds"})
|
|
}
|
|
if c.GlobalRateLimitPerMinute <= 0 {
|
|
errs = append(errs, &ConfigError{Field: "GlobalRateLimitPerMinute", Message: "must be positive"})
|
|
}
|
|
if c.JobWorkers <= 0 {
|
|
errs = append(errs, &ConfigError{Field: "JobWorkers", Message: "must be positive"})
|
|
}
|
|
if c.ModuleCacheSize <= 0 {
|
|
errs = append(errs, &ConfigError{Field: "ModuleCacheSize", Message: "must be positive"})
|
|
}
|
|
// CronPollInterval floor — see MinCronPollInterval doc. Zero means
|
|
// "use the default" (ApplyDefaults handles it); a non-zero value
|
|
// below the floor would silently let the operator paint themselves
|
|
// into a runaway-scheduler corner.
|
|
if c.CronPollInterval != 0 && c.CronPollInterval < MinCronPollInterval {
|
|
errs = append(errs, &ConfigError{
|
|
Field: "CronPollInterval",
|
|
Message: fmt.Sprintf("must be >= %s (current=%s); see bugboard #109 — below this the scheduler can't keep up with per-tick rqlite cost and queues ticks indefinitely",
|
|
MinCronPollInterval, c.CronPollInterval),
|
|
})
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// ApplyDefaults fills in zero values with defaults.
|
|
func (c *Config) ApplyDefaults() {
|
|
defaults := DefaultConfig()
|
|
|
|
if c.DefaultMemoryLimitMB == 0 {
|
|
c.DefaultMemoryLimitMB = defaults.DefaultMemoryLimitMB
|
|
}
|
|
if c.MaxMemoryLimitMB == 0 {
|
|
c.MaxMemoryLimitMB = defaults.MaxMemoryLimitMB
|
|
}
|
|
if c.DefaultTimeoutSeconds == 0 {
|
|
c.DefaultTimeoutSeconds = defaults.DefaultTimeoutSeconds
|
|
}
|
|
if c.MaxTimeoutSeconds == 0 {
|
|
c.MaxTimeoutSeconds = defaults.MaxTimeoutSeconds
|
|
}
|
|
if c.GlobalRateLimitPerMinute == 0 {
|
|
c.GlobalRateLimitPerMinute = defaults.GlobalRateLimitPerMinute
|
|
}
|
|
if c.JobWorkers == 0 {
|
|
c.JobWorkers = defaults.JobWorkers
|
|
}
|
|
if c.JobPollInterval == 0 {
|
|
c.JobPollInterval = defaults.JobPollInterval
|
|
}
|
|
if c.JobMaxQueueSize == 0 {
|
|
c.JobMaxQueueSize = defaults.JobMaxQueueSize
|
|
}
|
|
if c.JobMaxPayloadSize == 0 {
|
|
c.JobMaxPayloadSize = defaults.JobMaxPayloadSize
|
|
}
|
|
if c.CronPollInterval == 0 {
|
|
c.CronPollInterval = defaults.CronPollInterval
|
|
}
|
|
if c.TimerPollInterval == 0 {
|
|
c.TimerPollInterval = defaults.TimerPollInterval
|
|
}
|
|
if c.DBPollInterval == 0 {
|
|
c.DBPollInterval = defaults.DBPollInterval
|
|
}
|
|
if c.MaxConcurrentExecutions == 0 {
|
|
c.MaxConcurrentExecutions = defaults.MaxConcurrentExecutions
|
|
}
|
|
if c.ModuleCacheSize == 0 {
|
|
c.ModuleCacheSize = defaults.ModuleCacheSize
|
|
}
|
|
if c.LogRetention == 0 {
|
|
c.LogRetention = defaults.LogRetention
|
|
}
|
|
}
|
|
|
|
// WithMemoryLimit returns a copy with the memory limit set.
|
|
func (c *Config) WithMemoryLimit(defaultMB, maxMB int) *Config {
|
|
copy := *c
|
|
copy.DefaultMemoryLimitMB = defaultMB
|
|
copy.MaxMemoryLimitMB = maxMB
|
|
return ©
|
|
}
|
|
|
|
// WithTimeout returns a copy with the timeout set.
|
|
func (c *Config) WithTimeout(defaultSec, maxSec int) *Config {
|
|
copy := *c
|
|
copy.DefaultTimeoutSeconds = defaultSec
|
|
copy.MaxTimeoutSeconds = maxSec
|
|
return ©
|
|
}
|
|
|
|
// WithRateLimit returns a copy with the rate limit set.
|
|
func (c *Config) WithRateLimit(perMinute int) *Config {
|
|
copy := *c
|
|
copy.GlobalRateLimitPerMinute = perMinute
|
|
return ©
|
|
}
|