feat: accept a database transaction when queueing jobs

This commit is contained in:
Adriano Caloiaro 2025-09-16 10:11:47 -07:00
parent 0cab9ce7dc
commit 0697e621c3
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
10 changed files with 209 additions and 27 deletions

2
.helix/languages.toml Normal file
View file

@ -0,0 +1,2 @@
[language-server.gopls.config]
buildFlags = ["-tags=testing"]

View file

@ -66,6 +66,11 @@ func Backend(_ context.Context, opts ...neoq.ConfigOption) (backend neoq.Neoq, e
return
}
// Enqueue adds jobs to the specified queue
func (p *MemBackend) EnqueueTx(ctx context.Context, tx neoq.Tx, job *jobs.Job) (jobID string, err error) {
panic("this backend does not implement enqueueing jobs transactionally")
}
// Enqueue queues jobs to be executed asynchronously
func (m *MemBackend) Enqueue(_ context.Context, job *jobs.Job) (jobID string, err error) {
var queueChan chan *jobs.Job

View file

@ -14,6 +14,7 @@ import (
"time"
"github.com/acaloiaro/neoq"
nqpgxtx "github.com/acaloiaro/neoq/backends/postgres/tx/pgx"
"github.com/acaloiaro/neoq/handler"
"github.com/acaloiaro/neoq/internal"
"github.com/acaloiaro/neoq/jobs"
@ -400,8 +401,18 @@ func (p *PgBackend) initializeDB() (err error) {
return nil
}
// Enqueue adds jobs to the specified queue using the provided database transaction
func (p *PgBackend) EnqueueTx(ctx context.Context, tx neoq.Tx, job *jobs.Job) (jobID string, err error) {
return p.enqueue(ctx, tx, job)
}
// Enqueue adds jobs to the specified queue
func (p *PgBackend) Enqueue(ctx context.Context, job *jobs.Job) (jobID string, err error) {
return p.enqueue(ctx, nil, job)
}
// Enqueue adds jobs to the specified queue
func (p *PgBackend) enqueue(ctx context.Context, tx neoq.Tx, job *jobs.Job) (jobID string, err error) {
if job.Queue == "" {
err = jobs.ErrNoQueueSpecified
return
@ -417,11 +428,15 @@ func (p *PgBackend) Enqueue(ctx context.Context, job *jobs.Job) (jobID string, e
}
defer conn.Release()
p.logger.Debug("beginning new transaction to enqueue job", slog.String("queue", job.Queue))
tx, err := conn.Begin(ctx)
if err != nil {
err = fmt.Errorf("error creating transaction: %w", err)
return
if tx == nil {
p.logger.Debug("beginning new transaction to enqueue job", slog.String("queue", job.Queue))
pgxtx, err := p.pool.Begin(ctx)
if err != nil {
err = fmt.Errorf("error creating transaction: %w", err)
return "", err
}
tx = nqpgxtx.FromPgx(pgxtx)
}
// Rollback is safe to call even if the tx is already closed, so if
@ -580,7 +595,7 @@ func (p *PgBackend) Shutdown(ctx context.Context) {
//
// Jobs that are not already fingerprinted are fingerprinted before being added
// Duplicate jobs are not added to the queue. Any two unprocessed jobs with the same fingerprint are duplicates
func (p *PgBackend) enqueueJob(ctx context.Context, tx pgx.Tx, j *jobs.Job) (jobID string, err error) {
func (p *PgBackend) enqueueJob(ctx context.Context, tx neoq.Tx, j *jobs.Job) (jobID string, err error) {
err = jobs.FingerprintJob(j)
if err != nil {
return

View file

@ -14,6 +14,7 @@ import (
"github.com/acaloiaro/neoq"
"github.com/acaloiaro/neoq/backends/postgres"
nqpgxtx "github.com/acaloiaro/neoq/backends/postgres/tx/pgx"
"github.com/acaloiaro/neoq/handler"
"github.com/acaloiaro/neoq/internal"
"github.com/acaloiaro/neoq/jobs"
@ -152,6 +153,84 @@ func TestBasicJobProcessing(t *testing.T) {
}
}
// TestBasicJobProcessingWithTransaction tests that the postgres backend is able to process the most basic jobs with the
// most basic configuration, inside a transaction.
func TestBasicJobProcessingWithTransaction(t *testing.T) {
connString, conn := prepareAndCleanupDB(t)
const queue = "testing"
maxRetries := 5
done := make(chan bool)
defer close(done)
timeoutTimer := time.After(5 * time.Second)
ctx := context.Background()
nq, err := neoq.New(ctx, neoq.WithBackend(postgres.Backend), postgres.WithConnectionString(connString))
if err != nil {
t.Fatal(err)
}
defer nq.Shutdown(ctx)
h := handler.New(queue, func(_ context.Context) (err error) {
done <- true
return
})
err = nq.Start(ctx, h)
if err != nil {
t.Error(err)
}
ptx, err := conn.Begin(ctx)
if err != nil {
t.Error(err)
}
tx := nqpgxtx.FromPgx(ptx)
deadline := time.Now().UTC().Add(5 * time.Second)
jid, e := nq.EnqueueTx(ctx, tx, &jobs.Job{
Queue: queue,
Payload: map[string]any{
"message": "hello world",
},
Deadline: &deadline,
MaxRetries: &maxRetries,
})
if e != nil || jid == jobs.DuplicateJobID {
t.Error(e)
}
select {
case <-timeoutTimer:
err = jobs.ErrJobTimeout
case <-done:
}
if err != nil {
t.Error(err)
}
// ensure job has fields set correctly
var jdl time.Time
var jmxrt int
err = conn.
QueryRow(context.Background(), "SELECT deadline,max_retries FROM neoq_jobs WHERE id = $1", jid).
Scan(&jdl, &jmxrt)
if err != nil {
t.Error(err)
}
jdl = jdl.In(time.UTC)
// dates from postgres come out with only 6 decimal places of millisecond precision, naively format dates as
// strings for comparison reasons. Ref https://www.postgresql.org/docs/current/datatype-datetime.html
if jdl.Format(time.RFC3339) != deadline.Format(time.RFC3339) {
t.Error(fmt.Errorf("job deadline does not match its expected value: %v != %v", jdl, deadline)) // nolint: goerr113
}
if jmxrt != maxRetries {
t.Error(fmt.Errorf("job MaxRetries does not match its expected value: %v != %v", jmxrt, maxRetries)) // nolint: goerr113
}
}
func TestMultipleProcessors(t *testing.T) {
const queue = "testing"
var execCount uint32

View file

@ -0,0 +1,14 @@
package pgx
import (
"github.com/acaloiaro/neoq"
nqptx "github.com/acaloiaro/neoq/backends/postgres/tx"
"github.com/jackc/pgx/v5"
)
// FromStdlib creates a neoq DbTx from a standard library sql.Tx
func FromPgx(tx pgx.Tx) neoq.Tx {
return nqptx.TxWrapper{
Tx: tx,
}
}

View file

@ -0,0 +1,35 @@
package tx
import (
"context"
"github.com/acaloiaro/neoq"
"github.com/jackc/pgx/v5"
)
// TxWrapper wraps a standard library sql.Tx in the neoq Tx interface so that sql.Tx
// may be used as a transaction type for enqueueing jobs.
//
// sql.Tx is _nearly_ compatible with pgx's Tx type, after which neoq's Tx is modeled.
type TxWrapper struct {
Tx pgx.Tx
}
func (t TxWrapper) Commit(ctx context.Context) (err error) {
return t.Tx.Commit(ctx)
}
func (t TxWrapper) Rollback(ctx context.Context) (err error) {
return t.Tx.Rollback(ctx)
}
func (t TxWrapper) QueryRow(ctx context.Context, query string, args ...any) neoq.Row {
return t.Tx.QueryRow(ctx, query, args)
}
// FromStdlib creates a neoq DbTx from a standard library sql.Tx
func FromPgx(tx pgx.Tx) (t neoq.Tx) {
return &TxWrapper{
Tx: tx,
}
}

View file

@ -178,6 +178,11 @@ func WithShutdownTimeout(timeout time.Duration) neoq.ConfigOption {
}
}
// Enqueue adds jobs to the specified queue
func (p *RedisBackend) EnqueueTx(ctx context.Context, tx neoq.Tx, job *jobs.Job) (jobID string, err error) {
panic("this backend does not implement enqueueing jobs transactionally")
}
// Enqueue queues jobs to be executed asynchronously
func (b *RedisBackend) Enqueue(ctx context.Context, job *jobs.Job) (jobID string, err error) {
if job.Queue == "" {

14
go.mod
View file

@ -10,7 +10,7 @@ require (
github.com/hibiken/asynq v0.24.0
github.com/iancoleman/strcase v0.2.0
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
github.com/jackc/pgx/v5 v5.6.0
github.com/jackc/pgx/v5 v5.7.6
github.com/jsuar/go-cron-descriptor v0.1.0
github.com/pkg/errors v0.9.1
github.com/robfig/cron v1.2.0
@ -25,18 +25,18 @@ require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/spf13/cast v1.3.1 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect
google.golang.org/protobuf v1.33.0 // indirect
)

28
go.sum
View file

@ -80,12 +80,12 @@ github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jsuar/go-cron-descriptor v0.1.0 h1:Q97ujk+/xhcz1lA9nmUMq750FZV7RlV23TNyMB74xkQ=
github.com/jsuar/go-cron-descriptor v0.1.0/go.mod h1:PFR+Y6Lr86uYZpwsWRoFMRA3CX4a6q7zfPltp3SLkSU=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@ -154,8 +154,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -185,8 +185,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -200,13 +200,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

27
neoq.go
View file

@ -72,6 +72,9 @@ type Neoq interface {
// Enqueue queues jobs to be executed asynchronously
Enqueue(ctx context.Context, job *jobs.Job) (jobID string, err error)
// Enqueue queues jobs to be executed asynchronously
EnqueueTx(ctx context.Context, tx Tx, job *jobs.Job) (jobID string, err error)
// Start starts processing jobs on the queue specified in the Handler
Start(ctx context.Context, h handler.Handler) (err error)
@ -87,6 +90,30 @@ type Neoq interface {
Shutdown(ctx context.Context)
}
// Tx is neoq's generic transaction interface.
//
// This interface's purpose is to enable interoperability with external library
// transaction types. By being a small (but common) subset of the ususal transaction
// type methods, this interface allows transactions from multiple libraries to become
// become compatible with Tx.
//
// This interface is compatible with the following libraries' transaction types:
// - pgx: This interface is a subset of pgx's Tx type, so it is fully compatible
// - sql: Use helper function tx.FromStdTx to get a Tx from sql.Tx
type Tx interface {
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
QueryRow(ctx context.Context, query string, args ...any) Row
}
// Row interface is the subset of both pgx.Row and sql.Row needed by neoq to enqueue
// jobs useing either's Tx type.
//
// It is the return type of neoq.Tx.QueryRow, to be used as a generic row type
type Row interface {
Scan(dest ...any) error
}
// New creates a new backend instance for job processing.
//
// By default, neoq initializes [memory.Backend] if New() is called without a backend configuration option.