queuebox

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 25, 2026 License: MIT Imports: 15 Imported by: 0

README

queuebox-go

A pull-inbox worker for QueueBox.

QueueBox makes the push path need no code. The pull path needed five SQL statements, a renewal timer and a lease discipline, written by hand in every application. This package holds all of it.

What it guarantees

Your writes and the completion commit together. That is the whole point of the pull path. The handler receives the transaction, and the library runs the completion inside it. If the completion affects no row, the lease was lost, another worker owns the message, and the library rolls your writes back and reports nothing as done.

Requirements

Go 1.24 or later
Database PostgreSQL through pgx, or SQL Server through go-mssqldb
QueueBox The V10 schema or later, which adds the inbox headers column

The library talks to database/sql, so it needs no driver of its own. Your application registers the driver it already uses.

Install

go get github.com/alternayte/queuebox/clients/go

The import path ends in go and the package is named queuebox:

import queuebox "github.com/alternayte/queuebox/clients/go"

PostgreSQL

package main

import (
	"context"
	"database/sql"
	"log"
	"os"
	"os/signal"
	"syscall"

	_ "github.com/jackc/pgx/v5/stdlib"
	queuebox "github.com/alternayte/queuebox/clients/go"
)

func main() {
	db, err := sql.Open("pgx", os.Getenv("QUEUEBOX_DB"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	worker, err := queuebox.NewInboxWorker(db, queuebox.Options{
		Source: "orders", BatchSize: 10, LeaseMS: 30_000,
	})
	if err != nil {
		log.Fatal(err)
	}

	// The worker stops on Ctrl+C. It stops claiming at once, and the handlers already running
	// keep their grace.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	err = worker.Run(ctx, func(ctx context.Context, message queuebox.Message, tx *sql.Tx) error {
		var order struct {
			ID    string `json:"id"`
			Total int    `json:"total"`
		}

		if err := message.UnmarshalPayload(&order); err != nil {
			return err
		}

		_, err := tx.ExecContext(ctx, "INSERT INTO orders (id, total) VALUES ($1, $2)", order.ID, order.Total)

		return err
	})
	if err != nil {
		log.Fatal(err)
	}
}

SQL Server

Register go-mssqldb, open the pool with sql.Open("sqlserver", ...), and set the dialect. Everything else is the same.

worker, err := queuebox.NewInboxWorker(db, queuebox.Options{
	Source:  "orders",
	Dialect: queuebox.DialectSQLServer,
})

Set the context deadline for a claim call, or any wider timeout the caller applies to it, to at least 30 seconds. The SQL Server claim runs sp_getapplock with a 10 second lock timeout of its own, so the server always raises Msg 51000 before a shorter caller-side deadline aborts the call. go-mssqldb inherits the caller's context deadline, so a shorter deadline aborts the connection instead of letting Msg 51000 arrive: the lock is held under @LockOwner = 'Transaction', so the abandoned transaction keeps the per-source claim lock until the connection resets, and every later claim on that source stalls behind it.

Placeholders

Both dialects bind positionally, because the PostgreSQL driver accepts no named parameter. Write the placeholder style of your own database: $1 for PostgreSQL, @p1 for SQL Server.

What the handler receives

Field Meaning
message.ID The inbox row identifier
message.Source The source name
message.IdempotencyKey The deduplication key. The full identity is the source and this key together
message.AggregateID Nullable
message.EventType Nullable
message.Payload The JSON body. UnmarshalPayload parses it
message.Attempt Zero on the first delivery
message.CorrelationID Nullable, for logs
message.Headers The headers the source received, one value per key. An empty map when there are none

The claim token is absent on purpose. The library owns the token, because a handler that could reach it could complete a message out of band.

Rules for a handler

  1. Write every application change through the transaction the handler receives.
  2. Do not commit and do not roll back. The library owns both.
  3. Return an error to fail the message. The library rolls the transaction back, so no partial write stays.
  4. Honour the context. It is cancelled when the lease is lost and when a shutdown runs out of grace.
  5. Do external work, such as an HTTP call, only where a repeat is safe. A transaction cannot roll back a call to another system. Deduplicate on the source and the idempotency key.

Settings

Setting Default Meaning
Source none, it is mandatory The source whose messages this worker takes
BatchSize 10 The largest number of messages one claim takes
LeaseMS 30000 The lease duration. The renewal runs every third of it
MaxConcurrency 1 The largest number of handlers that run at one time
PollInterval 1 second The wait after a claim that returned nothing
ShutdownGrace 30 seconds How long a stop waits for the handlers already running
Dialect DialectPostgreSQL The database dialect
Schema the QueueBox names The table and column names, when an operator mapped them
RetryPolicy five attempts, exponential What happens to a message whose handler failed
Logger none The caller's logger. The library prints nothing without one

The concurrency default is one. A handler meets no sibling message unless the caller raises it. Raise it only when the handler is safe against a sibling message of another aggregate:

options := queuebox.Options{Source: "orders", MaxConcurrency: 10}

Failure, retry and the dead letter

The default policy retries while the attempt is below the ceiling, with an exponential backoff and jitter, and dead-letters after that. Only your application knows that a validation error must never be retried while a timeout must, so write your own:

policy := queuebox.RetryPolicyFunc(func(message queuebox.Message, failure error) queuebox.FailureAction {
	// A bad payload never becomes good. Do not spend five attempts on it.
	var syntax *json.SyntaxError
	if errors.As(failure, &syntax) {
		return queuebox.DeadLetter()
	}

	if message.Attempt >= 5 {
		return queuebox.DeadLetter()
	}

	return queuebox.RetryAfter(time.Duration(1<<message.Attempt) * time.Second)
})

Shutdown

Cancel the context you passed to Run. The worker stops claiming at once. The handlers that already run keep ShutdownGrace, and a handler that does not finish inside it is cancelled. Its message is abandoned: the library completes nothing, spends no attempt on it, and lets the lease expire so another worker takes it.

Logging

Set Logger. Without one the library prints nothing. Every message and every error passes through the same redaction QueueBox itself uses, so a connection string password reaches neither a log line nor the last_error column.

Mapped table and column names

QueueBox lets an operator rename the inbox table and its columns. Name them here as well:

schema := queuebox.DefaultSchema()
schema.Table = "qb_messages"
schema.State = "row_state"

worker, err := queuebox.NewInboxWorker(db, queuebox.Options{Source: "orders", Schema: &schema})

Every name is quoted and checked before it reaches the database, so a mapping cannot carry SQL.

Tests

go test ./...            # the unit tests and the shared redaction corpus
cd contract && go test ./...   # the contract, against real databases in containers

The contract tests live in their own module. They need Testcontainers and both database drivers, and those require a newer Go than this library does. A test dependency must never raise the floor of a published library, so the heavy dependencies stay out of the module you install.

Releases

Go ties the tag of a module in a subdirectory to that subdirectory, so the tag carries the path:

git tag clients/go/v0.1.1 && git push origin clients/go/v0.1.1

License

MIT. See LICENSE.

Documentation

Overview

Package queuebox is a pull-inbox worker for QueueBox.

QueueBox makes the push path need no code. The pull path needed five SQL statements, a renewal timer and a lease discipline, written by hand in every application. This package holds all of it.

The guarantee

The application's writes and the completion commit together. The handler receives the transaction, and the library runs the completion inside it. If the completion affects no row, the lease was lost, another worker owns the message, and the library rolls the writes back and reports nothing as done.

Use

worker, err := queuebox.NewInboxWorker(db, queuebox.Options{Source: "orders"})
if err != nil {
	return err
}

err = worker.Run(ctx, func(ctx context.Context, message queuebox.Message, tx *sql.Tx) error {
	_, err := tx.ExecContext(ctx, "INSERT INTO orders (id) VALUES ($1)", message.IdempotencyKey)
	return err
})

Write every application change through the transaction the handler receives. Do not commit and do not roll back, because the library owns both. Return an error to fail the message. Honour the context: it is cancelled when the lease is lost and when a shutdown runs out of grace.

Databases

PostgreSQL through pgx, and SQL Server through go-mssqldb. The library talks to database/sql and imports no driver, so the application registers the one it already uses. Both dialects bind positionally: PostgreSQL writes $1 and SQL Server writes @p1.

See the README for the settings, the retry policy, the shutdown behaviour and the schema mapping.

Index

Constants

View Source
const MaxErrorLength = 2000

MaxErrorLength is the longest text that Sanitize returns.

Variables

This section is empty.

Functions

func Sanitize

func Sanitize(text string) string

Sanitize redacts every secret value in the text and truncates the result. It prepares an error text for the last_error column and for a log line.

func SanitizeError

func SanitizeError(err error) string

SanitizeError names the failure and every cause below it, then redacts the whole text.

A driver puts the connection string in the message of the cause, not of the wrapper. The chain therefore reaches the redaction, and no cause message escapes it.

Types

type Dialect

type Dialect string

Dialect is the database dialect that the inbox table lives in.

const (
	// DialectPostgreSQL selects the PostgreSQL statements.
	DialectPostgreSQL Dialect = "postgresql"
	// DialectSQLServer selects the SQL Server statements.
	DialectSQLServer Dialect = "sqlserver"
)

type FailureAction

type FailureAction struct {
	// Retry is true to run the retry statement, and false to run the dead-letter statement.
	Retry bool
	// Delay is how long the retry waits before the message can be claimed again.
	Delay time.Duration
}

FailureAction states what the library does with a message whose handler returned an error.

func DeadLetter

func DeadLetter() FailureAction

DeadLetter moves the message to the dead letter and stops.

func RetryAfter

func RetryAfter(delay time.Duration) FailureAction

RetryAfter returns the message to pending after a delay.

type Handler

type Handler func(ctx context.Context, message Message, tx *sql.Tx) error

Handler handles one message.

The transaction is the point of the pull path. Write every application change through it. The library runs the completion in the same transaction after the handler returns, so the writes and the completion commit together or neither of them does.

Do not commit and do not roll back. The library owns both.

The context is cancelled when the lease is lost and when a shutdown runs out of grace.

type InboxWorker

type InboxWorker struct {
	// contains filtered or unexported fields
}

InboxWorker claims QueueBox pull messages, hands each one to a handler inside a transaction, and completes, retries or dead-letters it.

func NewInboxWorker

func NewInboxWorker(db *sql.DB, options Options) (*InboxWorker, error)

NewInboxWorker builds a worker.

database/sql is the abstraction, so the library needs no driver of its own and one worker serves the PostgreSQL and the SQL Server drivers alike.

func (*InboxWorker) Run

func (w *InboxWorker) Run(ctx context.Context, handler Handler) error

Run works until the context is cancelled.

A cancellation stops the claiming at once. The handlers that already run keep their grace, and a handler that does not finish inside it is cancelled and its message is abandoned. An abandoned message is never completed; its lease expires and another worker takes it.

type Logger

type Logger interface {
	Info(message string, fields map[string]any)
	Warn(message string, fields map[string]any)
	Error(message string, fields map[string]any)
}

Logger is the logger the library writes to. It prints nothing without one.

type Message

type Message struct {
	// ID is the inbox row identifier.
	ID string
	// Source is the source name.
	Source string
	// IdempotencyKey is the deduplication key. The full identity is the source and this key
	// together, because two sources can send the same event ID and mean different events.
	IdempotencyKey string
	// AggregateID is nullable.
	AggregateID *string
	// EventType is nullable.
	EventType *string
	// Payload is the JSON body, as it was stored.
	Payload json.RawMessage
	// Attempt is the delivery counter. It is zero on the first delivery.
	Attempt int
	// CorrelationID is nullable, and it is for logs.
	CorrelationID *string
	// Headers holds the headers the source received, one value per key. It is never nil.
	Headers map[string]string
}

Message is one claimed inbox row, as the handler sees it.

The claim token is deliberately absent. The library owns the token, because a handler that could reach it could complete a message out of band.

func (Message) UnmarshalPayload

func (m Message) UnmarshalPayload(target any) error

UnmarshalPayload parses the JSON body into the value that target points at.

type Options

type Options struct {
	// Source is the source whose messages this worker takes. It is mandatory.
	Source string
	// BatchSize is the largest number of messages one claim takes. The default is ten.
	BatchSize int
	// LeaseMS is the lease duration in milliseconds. The renewal runs every third of it.
	// The default is thirty thousand.
	LeaseMS int
	// MaxConcurrency is the largest number of handlers that run at one time. It never passes the
	// batch size. The default is one. A handler meets no sibling message unless the caller asks
	// for parallelism.
	MaxConcurrency int
	// PollInterval is how long the worker waits after a claim that returned nothing.
	// The default is one second.
	PollInterval time.Duration
	// ShutdownGrace is how long a stop waits for the handlers that already run.
	// The default is thirty seconds.
	ShutdownGrace time.Duration
	// Dialect is the database dialect. The default is PostgreSQL.
	Dialect Dialect
	// Schema names the table and the columns. The default is the QueueBox V6 names.
	Schema *Schema
	// RetryPolicy decides what happens to a message whose handler returned an error.
	RetryPolicy RetryPolicy
	// Logger is the caller's logger.
	Logger Logger
}

Options holds the settings of one worker.

type RetryPolicy

type RetryPolicy interface {
	Decide(message Message, failure error) FailureAction
}

RetryPolicy decides what happens to a message whose handler returned an error.

The library exposes the decision, because only the application knows that a validation error must never be retried while a timeout must.

func NewDefaultRetryPolicy

func NewDefaultRetryPolicy(maxAttempts int, baseDelay, maxDelay time.Duration, jitter float64) RetryPolicy

NewDefaultRetryPolicy retries while the attempt is below the ceiling, with an exponential backoff and jitter, and dead-letters after that.

The row starts at attempt zero, so the ceiling allows maxAttempts plus one deliveries.

type RetryPolicyFunc

type RetryPolicyFunc func(message Message, failure error) FailureAction

RetryPolicyFunc lets a plain function be a RetryPolicy.

func (RetryPolicyFunc) Decide

func (f RetryPolicyFunc) Decide(message Message, failure error) FailureAction

Decide calls the function.

type Schema

type Schema struct {
	Table          string
	ID             string
	Consumption    string
	Source         string
	State          string
	ScheduledAt    string
	CreatedAt      string
	ClaimToken     string
	ClaimedAt      string
	LeaseExpiresAt string
	ProcessedAt    string
	Attempt        string
	LastError      string
	IdempotencyKey string
	AggregateID    string
	EventType      string
	Payload        string
	CorrelationID  string
	Headers        string
}

Schema names the inbox table and every column the library reads or writes. QueueBox lets an operator map these names, so the library must not assume the defaults.

func DefaultSchema

func DefaultSchema() Schema

DefaultSchema returns the QueueBox V10 names.

type Statements

type Statements struct {
	// Claim takes rows. Parameters: source, batch, leaseMS, candLimit.
	//
	// The SQL Server text carries its own transaction control (BEGIN TRANSACTION and COMMIT
	// TRANSACTION). The claim must be alone in that transaction: put no other application work
	// in it, and commit it before starting any handler. A wider transaction around the claim
	// changes three things silently: the claimed rows stay uncommitted until the wider
	// transaction commits, a lock failure rolls back that whole wider transaction instead of
	// only the claim, and the per-source applock stays held for as long as the wider transaction
	// stays open instead of releasing at the claim's own commit.
	Claim string
	// Renew extends the lease. Parameters: leaseMS, id, token.
	Renew string
	// Complete marks the row processed. Parameters: id, token.
	Complete string
	// Retry returns the row to pending. Parameters: delayMS, error, id, token.
	Retry string
	// Dead marks the row dead. Parameters: error, id, token.
	Dead string
}

Statements holds the five statements of the pull contract, rendered for one dialect and one schema.

Both dialects bind POSITIONALLY, because the PostgreSQL driver accepts no named parameter. PostgreSQL writes $1 and SQL Server writes @p1, so one database/sql call site serves both.

func NewStatements

func NewStatements(dialect Dialect, schema Schema) (Statements, error)

NewStatements renders the five statements of the pull contract.

The library uses it, and an application that wants to run one statement by hand can use it too. Every identifier is quoted and checked, so a schema mapping cannot carry SQL.

A caller who runs the SQL Server Claim by hand must run it alone, never nested inside a wider transaction: see the Claim field for the three consequences of nesting it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL