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  }