git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/queue/postgresql/postgresql.go (about) 1 package postgresql 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "strings" 9 "sync/atomic" 10 "time" 11 12 "log/slog" 13 14 "git.sr.ht/~pingoo/stdx/db" 15 "git.sr.ht/~pingoo/stdx/guid" 16 "git.sr.ht/~pingoo/stdx/queue" 17 ) 18 19 var ( 20 ErrJobTypeIsNotValid = errors.New("queue.postgresql: job type is not valid") 21 ErrJobDataIsNotValid = errors.New("queue.postgresql: job data is not valid") 22 ErrJobRetryMaxIsNotValid = errors.New("queue.postgresql: retry_max is not valid") 23 ErrJobRetryDelayIsNotValid = errors.New("queue.postgresql: retry_delay is not valid") 24 ErrJobRetryStrategyIsNotValid = errors.New("queue.postgresql: retry_strategy is not valid") 25 ErrJobTimeoutIsNotValid = errors.New("queue.postgresql: timeout is not valid") 26 ) 27 28 type PostgreSQLQueue struct { 29 db db.DB 30 shuttingDown atomic.Bool 31 logger *slog.Logger 32 } 33 34 func NewPostgreSQLQueue(db db.DB, logger *slog.Logger) *PostgreSQLQueue { 35 var shuttingDown atomic.Bool 36 shuttingDown.Store(false) 37 38 queue := PostgreSQLQueue{ 39 db: db, 40 shuttingDown: shuttingDown, 41 logger: logger, 42 } 43 44 // TODO: improve? 45 ctx := context.Background() 46 47 go func() { 48 for { 49 if shuttingDown.Load() { 50 break 51 } 52 queue.failTimedOutJobs(ctx) 53 time.Sleep(time.Second) 54 } 55 }() 56 57 return &queue 58 } 59 60 func (pgqueue *PostgreSQLQueue) Push(ctx context.Context, tx db.Queryer, newJob queue.NewJobInput) (err error) { 61 now := time.Now().UTC() 62 var db db.Queryer 63 64 db = pgqueue.db 65 if tx != nil { 66 db = tx 67 } 68 69 scheduledFor := now 70 if newJob.ScheduledFor != nil { 71 scheduledFor = (*newJob.ScheduledFor).UTC() 72 } 73 74 jobType := strings.TrimSpace(newJob.Type) 75 if jobType == "" { 76 err = ErrJobTypeIsNotValid 77 return 78 } 79 80 if newJob.Data == nil { 81 err = ErrJobDataIsNotValid 82 return 83 } 84 85 rawData, err := json.Marshal(newJob.Data) 86 if err != nil { 87 err = fmt.Errorf("queue.postgresql: marshalling job data to JSON") 88 return 89 } 90 91 retryMax := queue.DefaultRetryMax 92 if newJob.RetryMax != nil { 93 retryMax = *newJob.RetryMax 94 } 95 if retryMax < queue.MinRetryMax || retryMax > queue.MaxRetryMax { 96 err = ErrJobRetryMaxIsNotValid 97 return 98 } 99 100 retryDelay := queue.DefaultRetryDelay 101 if newJob.RetryDelay != nil { 102 retryDelay = *newJob.RetryDelay 103 } 104 if retryDelay < queue.MinRetryDelay || retryDelay > queue.MaxRetryDelay { 105 err = ErrJobRetryDelayIsNotValid 106 return 107 } 108 109 retryStrategy := queue.DefaultRetryStrategy 110 if newJob.RetryStrategy != queue.DefaultRetryStrategy { 111 retryStrategy = newJob.RetryStrategy 112 } 113 if retryStrategy != queue.RetryStrategyConstant && retryStrategy != queue.RetryStrategyExponential { 114 err = ErrJobRetryStrategyIsNotValid 115 return 116 } 117 118 jobTimeout := queue.DefaultTimeout 119 if newJob.Timeout != nil { 120 jobTimeout = *newJob.Timeout 121 } 122 if jobTimeout < queue.MinTimeout || jobTimeout > queue.MaxTimeout { 123 err = ErrJobTimeoutIsNotValid 124 return 125 } 126 127 // we use time-based GUIDs to avoid index fragmentation and increase insert performance 128 // see https://www.cybertec-postgresql.com/en/unexpected-downsides-of-uuid-keys-in-postgresql 129 // https://news.ycombinator.com/item?id=36429986 130 job := queue.Job{ 131 ID: guid.NewTimeBased(), 132 CreatedAt: now, 133 UpdatedAt: now, 134 ScheduledFor: scheduledFor, 135 FailedAttempts: 0, 136 Status: queue.JobStatusQueued, 137 Type: jobType, 138 RawData: rawData, 139 RetryMax: retryMax, 140 RetryDelay: retryDelay, 141 RetryStrategy: retryStrategy, 142 Timeout: jobTimeout, 143 } 144 query := `INSERT INTO queue 145 (id, created_at, updated_at, scheduled_for, failed_attempts, status, type, data, retry_max, retry_delay, retry_strategy, timeout) 146 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)` 147 148 _, err = db.Exec(ctx, query, job.ID, job.CreatedAt, job.UpdatedAt, job.ScheduledFor, job.FailedAttempts, 149 job.Status, job.Type, job.RawData, job.RetryMax, job.RetryDelay, job.RetryStrategy, job.Timeout) 150 if err != nil { 151 return 152 } 153 154 return 155 } 156 157 // pull fetches at most `number_of_jobs` from the queue. 158 func (pgqueue *PostgreSQLQueue) Pull(ctx context.Context, numberOfJobs int64) ([]queue.Job, error) { 159 if numberOfJobs > 200 || numberOfJobs < 0 { 160 numberOfJobs = 200 161 } 162 163 now := time.Now().UTC() 164 query := `UPDATE queue 165 SET status = $1, updated_at = $2 166 WHERE id IN ( 167 SELECT id 168 FROM queue 169 WHERE status = $3 AND scheduled_for <= $4 AND failed_attempts <= queue.retry_max 170 ORDER BY scheduled_for 171 FOR UPDATE SKIP LOCKED 172 LIMIT $5 173 ) 174 RETURNING *` 175 ret := []queue.Job{} 176 177 err := pgqueue.db.Select(ctx, &ret, query, queue.JobStatusRunning, now, queue.JobStatusQueued, now, numberOfJobs) 178 if err != nil { 179 return ret, err 180 } 181 return ret, nil 182 } 183 184 func (pgqueue *PostgreSQLQueue) DeleteJob(ctx context.Context, jobID guid.GUID) error { 185 query := "DELETE FROM queue WHERE id = $1" 186 187 _, err := pgqueue.db.Exec(ctx, query, jobID) 188 if err != nil { 189 return err 190 } 191 return nil 192 } 193 194 func (pgqueue *PostgreSQLQueue) FailJob(ctx context.Context, job queue.Job) error { 195 query := `UPDATE queue 196 SET status = $1, updated_at = $2, scheduled_for = $3, failed_attempts = $4 197 WHERE id = $5` 198 199 now := time.Now().UTC() 200 status := queue.JobStatusQueued 201 failedAttempt := job.FailedAttempts + 1 202 203 if failedAttempt >= job.RetryMax { 204 status = queue.JobStatusFailed 205 } 206 207 var factor int64 = 1 208 if job.RetryStrategy == queue.RetryStrategyExponential { 209 factor = failedAttempt 210 } 211 scheduledFor := now.Add(time.Second * time.Duration(job.RetryDelay) * time.Duration(factor)) 212 213 _, err := pgqueue.db.Exec(ctx, query, status, now, scheduledFor, failedAttempt, job.ID) 214 if err != nil { 215 return err 216 } 217 return nil 218 } 219 220 func (pgqueue *PostgreSQLQueue) Clear(ctx context.Context) error { 221 query := "DELETE FROM queue" 222 223 _, err := pgqueue.db.Exec(ctx, query) 224 if err != nil { 225 return err 226 } 227 return nil 228 } 229 230 // TODO 231 func (pgqueue *PostgreSQLQueue) failTimedOutJobs(ctx context.Context) { 232 // query := `UPDATE queue 233 // SET status = $1, updated_at = $2 234 // WHERE id IN ( 235 // SELECT id 236 // FROM queue 237 // WHERE status = $3 AND scheduled_for <= $4 AND failed_attempts < queue.retry_max 238 // ORDER BY scheduled_for 239 // FOR UPDATE SKIP LOCKED 240 // LIMIT $5 241 // ) 242 // RETURNING *` 243 244 // now := time.Now().UTC() 245 246 // _, err := queue.db.Exec(ctx, query, queue.JobStatusQueued, now, 247 // queue.JobStatusRunning, now, scheduledFor, failedAttempt, job.ID) 248 249 // if err != nil { 250 // queue.logger.Error("queue.failTimedOutJobs: updating timedout jobs", slogx.Err(err)) 251 // return 252 // } 253 254 // query := `UPDATE queue 255 // SET status = $1, updated_at = $2, scheduled_for = $3, failed_attempts = $4 256 // WHERE id = $5` 257 258 // now := time.Now().UTC() 259 // failedAttempt := job.FailedAttempts + 1 260 // var factor int64 = 1 261 // if job.RetryStrategy == queue.RetryStrategyExponential { 262 // factor = failedAttempt 263 // } 264 // scheduledFor := now.Add(time.Second * time.Duration(job.RetryDelay) * time.Duration(factor)) 265 266 // _, err = queue.db.Exec(ctx, query, queue.JobStatusFailed, now, scheduledFor, failedAttempt, job.ID) 267 // if err != nil { 268 // return err 269 // } 270 271 return 272 } 273 274 func (pgqueue *PostgreSQLQueue) Stop(ctx context.Context) { 275 pgqueue.shuttingDown.Store(true) 276 }