Documentation
¶
Overview ¶
Package workqueue is a leased work queue over Postgres: the SELECT … FOR UPDATE SKIP LOCKED claim/complete/expire pattern, generic over the key that names a unit of work.
distributedlock scopes job queues out, and rightly — a lock is not a queue. This is the queue. It is the piece every distributed-systems consumer writes next, and the piece they usually write badly, because the two things that make it survive production are not the parts that look hard.
What it is, and is not ¶
An item is a key and nothing else. There is no payload column: the consumer already knows how to turn a key into work, and a queue that also stores the work has to answer questions about encoding, size, and schema evolution that a key does not raise. If you want to move payloads to a broker, that is outbox.
Scheduling policy stays with the consumer too. This package knows how to hand out leases fairly and take them back when they lapse; it does not know what "stale" means for your domain, or which work is urgent. You express both by enqueueing: Entry.Delay says "not before", Entry.Priority says "ahead of the rest".
The clock ¶
The database's now() is the only clock. Every timestamp that governs scheduling — lease expiry, availability, completion, retention — is written and compared server-side, and no timestamp is ever bound from or returned to a caller's process. Durations cross the seam instead: a lease is "this many microseconds from now()", an age comes back as a duration measured against now().
That is why this package has no clock.Clock option, alone among the platform's scheduling components. Process clocks never have to agree, which is the whole reason a fleet can coordinate through one table.
Failure recovery is expiry, and only expiry ¶
There are no fencing tokens and no heartbeats. A worker that dies simply lets its lease lapse, and the item is handed to somebody else. Nothing detects the death; nothing has to.
The price of that simplicity is that work must be idempotent. Two workers can briefly hold the same key — a lease lapses while its holder is merely slow, not dead — and a straggler's late Complete lands on an item somebody else has already finished. Both are waste, not corruption, as long as doing the work twice is the same as doing it once. Item.Reclaimed marks a claim that took over a lapsed lease, so the duplicate window is at least visible.
The two details that cost real incidents ¶
Both of these came out of a production system running this pattern, and neither is obvious until it bites. They are the reason this package exists rather than the twenty lines of SQL underneath it.
*Every writer takes its row locks in primary-key order.* Enqueue sorts its rows before building the statement, and Complete, Release, and Remove reach their rows through a CTE that orders and locks them explicitly. With one total order, contention between concurrent batch writers degrades into a queue; without it, two batches that overlap in opposite orders deadlock (SQLSTATE 40P01) the moment they meet. Claim is exempt and safe: SKIP LOCKED never waits, and a writer that never waits cannot be in a lock cycle.
*Enqueue group-commits.* One statement per caller does not survive contact with a read path. Every in-flight Enqueue on a process is merged into a single upsert, so however many callers are enqueueing, exactly one statement is ever in flight — and overlapping key sets collapse into one row apiece instead of contending for the same row. Callers still block until their own keys have landed, so read-your-write holds: enqueue, then claim, and the key you enqueued is there. The unmerged version of this once wedged a service by parking thirty pool connections in deadlocking upserts, starving every unrelated endpoint of a connection.
The batcher owns a goroutine, which is why a Queue has to be Closed.
Driving it ¶
Claim, work, Complete. Release hands work back early with a delay and a reason; otherwise the lease lapses on its own and the item returns anyway.
items, err := queue.Claim(ctx, 100, 30*time.Second)
// ...
for _, item := range items {
if err := do(ctx, item.Key); err != nil {
_ = queue.Release(ctx, time.Minute, err, item.Key)
continue
}
done = append(done, item.Key)
}
err = queue.Complete(ctx, done...)
A claim's limit counts the items it actually leased, not the rows it looked at: Postgres applies the LIMIT above the lock, so rows a concurrent claimer holds are skipped and replaced rather than subtracted. A fleet of claimers all get full batches while work remains, and a short batch means the queue really is nearly drained. That depends on the shape of the claim statement — a LIMIT pushed into a subquery below the lock would silently start returning short batches — so there is a test pinning it.
The loop around that is yours, and Wait is the only part of it this package supplies: it blocks until a wakeup arrives, until the poll elapses, or until the context is done. Given a wakeup it turns an idle worker from one claim query per tick into none, and turns the latency of a fresh enqueue from a poll interval into a millisecond:
listener, err := pgnotify.NewListener(ctx, &pgnotify.Config{
ConnectionString: dsn,
Channel: "work",
})
// ...
go listener.Run()
queue, err := workqueue.New[string](ctx, cfg, client,
workqueue.WithWakeup(listener.Signal()))
with Config.NotifyChannel set to the same channel on whatever enqueues, so Enqueue emits a payload-free pg_notify once the rows have landed.
None of the queue's guarantees rest on that. The notification carries no information, the poll stays exactly as it was, and a wake that is never delivered costs latency and nothing else — which matters, because NOTIFY is at-most-once and connection-scoped, so a reconnecting listener misses everything sent while it was away. Config.MinWakeInterval floors how often a wake can return, so a burst of enqueues costs one extra claim rather than one per enqueue.
Reap and Stats are methods rather than a loop this package runs, because you already have a scheduler — see the jobs package. Reap deletes completed items past their retention; Stats is the health read. Nothing here fails loudly, so Stats.OldestReadyAge is the number that tells you the fleet has stopped draining: depth alone cannot distinguish a queue that is deep and moving from one that is deep and stuck.
Keys ¶
K is comparable, which is most of what makes an encoding safe: maps and slices are already excluded, so the JSON rendering of a struct key is stable across processes and releases as long as its field order is. Strings and string-like types are stored as themselves rather than JSON-quoted, so the table stays legible. Anything else — a key that has to sort a particular way, or that already has a canonical string form — supplies WithKeyCodec.
The encoded key is the table's primary key and is bounded by MaxKeyLength; an over-long key is rejected at Enqueue rather than silently truncated.
Creating the table ¶
workqueue/migrations renders the DDL for a table prefix. If you already run database/migrate, hand migrations.SQL to WithGeneratedMigration and the table is created by your normal migration run at a version you choose.
One table serves any number of logical queues: Config.Name partitions it, and is the leading column of the primary key. Two Queue values with different names share nothing but storage.
Postgres only ¶
Deliberately. The contract above is "the database's now() is the only clock, and SKIP LOCKED is the arbiter", and the SQL that delivers it — a lock-ordering CTE, a single-statement claim with RETURNING, interval arithmetic on the server — is written against Postgres rather than reduced to a portable subset.
SKIP LOCKED is not the part that binds. MySQL 8.0 has it, and CTEs too; what it has no form of is RETURNING. The claim is one statement that selects due rows, locks them, increments attempts, extends the lease, and hands back the keys, and without RETURNING those become a SELECT … FOR UPDATE SKIP LOCKED and a separate UPDATE inside a transaction held across both round trips. That is a different concurrency shape with a different failure model — a second implementation rather than a dialect switch. SQLite is a harder no: it is single-writer, with no row-level locking to skip.
So New returns dialect.ErrUnsupported for anything but Postgres, rather than degrading to a lease-only claim that would look like it worked. If a second backend is ever wanted, the shape to reach for is this package as the interface with a workqueue/postgres beneath it, the way cache and cache/redis sit — nothing here forecloses that.
Example ¶
Enqueue, claim, work, complete — the whole loop a worker runs.
package main
import (
"context"
"log"
"time"
"github.com/primandproper/platform-go/v10/database"
"github.com/primandproper/platform-go/v10/workqueue"
)
// A key is whatever names a unit of work in the consumer's domain. It is
// comparable, so its JSON rendering is stable — see workqueue.DefaultKeyCodec.
type tileKey struct {
Layer string `json:"layer"`
X int `json:"x"`
Y int `json:"y"`
}
func main() {
ctx := context.Background()
var client database.Client // built through database/config, speaking Postgres
queue, err := workqueue.New[tileKey](ctx, &workqueue.Config{Name: "tiles"}, client)
if err != nil {
log.Fatal(err)
}
defer func() { _ = queue.Close(ctx) }()
// Anything can offer work, including a request handler: concurrent enqueues
// on one process merge into a single statement.
if err = queue.EnqueueKeys(ctx, tileKey{Layer: "roads", X: 1, Y: 2}); err != nil {
log.Print(err)
return
}
items, err := queue.Claim(ctx, 100, 30*time.Second)
if err != nil {
log.Print(err)
return
}
done := make([]tileKey, 0, len(items))
for _, item := range items {
if err = render(ctx, item.Key); err != nil {
// Hand it back with a delay and a reason. Skipping this is safe
// too — the lease lapses and the item returns anyway, just later
// and without the recorded cause.
_ = queue.Release(ctx, time.Minute, err, item.Key)
continue
}
done = append(done, item.Key)
}
if err = queue.Complete(ctx, done...); err != nil {
log.Print(err)
}
}
func render(context.Context, tileKey) error { return nil }
Output:
Index ¶
- Constants
- Variables
- type Config
- type Entry
- type Item
- type KeyCodec
- type Option
- type Queue
- func (q *Queue[K]) Claim(ctx context.Context, limit int, lease time.Duration) ([]Item[K], error)
- func (q *Queue[K]) Close(ctx context.Context) error
- func (q *Queue[K]) Complete(ctx context.Context, keys ...K) error
- func (q *Queue[K]) Enqueue(ctx context.Context, entries ...Entry[K]) error
- func (q *Queue[K]) EnqueueKeys(ctx context.Context, keys ...K) error
- func (q *Queue[K]) Name() string
- func (q *Queue[K]) Reap(ctx context.Context) (int64, error)
- func (q *Queue[K]) Release(ctx context.Context, delay time.Duration, cause error, keys ...K) error
- func (q *Queue[K]) Remove(ctx context.Context, keys ...K) error
- func (q *Queue[K]) Stats(ctx context.Context) (Stats, error)
- func (q *Queue[K]) Wait(ctx context.Context, poll time.Duration) error
- type Stats
Examples ¶
Constants ¶
const ( // DefaultTablePrefix is the namespace the queue table carries when none is // configured, which is none — rendering work_queue_items. // // The work_queue_ segment is the schema's, not the caller's: a table always // says which package created it. Setting a namespace of "ddb" renders // ddb_work_queue_items, for a database shared between applications. DefaultTablePrefix = "" // DefaultMaxClaimBatch caps a single Claim. It is a guard, not a target: an // unbounded claim on a deep queue leases more work than the caller can // finish inside the lease, so the excess is reclaimed by somebody else and // done twice. DefaultMaxClaimBatch = 500 // DefaultRetention is how long a completed item is kept before reaping. DefaultRetention = 24 * time.Hour // DefaultReapBatchSize caps one reap, so a long-neglected queue is drained // over several passes instead of one long-running DELETE. DefaultReapBatchSize = 1000 // DefaultWriteAttempts is how many times a writer re-runs a statement // Postgres asked it to retry. Ordered locking makes a deadlock between two // of this package's writers impossible; this covers the residual case where // something else in the consumer's schema touches these rows. DefaultWriteAttempts = 3 // DefaultMinWakeInterval is the floor between two wake-driven returns from // Wait. It only bites during a burst: a wake arriving when the last one is // already older than this is served immediately, which is the ordinary case // and the whole point of a wakeup. DefaultMinWakeInterval = 100 * time.Millisecond )
const MaxKeyLength = 512
MaxKeyLength bounds an encoded key.
The encoded key is half of the table's primary key, and a primary key has to be indexable. The limit is enforced in Go rather than by the column, because the failure it prevents is not a rejected write: two keys that differ only past the limit encode to the same row, and the second unit of work vanishes into the first with nothing to detect it.
Variables ¶
var ( // ErrNilDatabaseClient indicates a nil database.Client was passed to New. It // wraps errors.ErrNilInputParameter, so a caller may check either. ErrNilDatabaseClient = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil database client") // ErrNilConfig indicates a nil Config was passed to New. It wraps // errors.ErrNilInputParameter, so a caller may check either. ErrNilConfig = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil work queue config") // ErrEmptyQueueName indicates a Config with no Name. There is no default: // one table holds every logical queue, and an unnamed queue would silently // share rows with every other unnamed queue in the database. ErrEmptyQueueName = platformerrors.New("empty work queue name") // ErrInvalidLease indicates a non-positive lease was supplied to Claim. A // zero lease would be handed out already expired, so every concurrent // claimer would take the same item. ErrInvalidLease = platformerrors.New("invalid work queue lease") // ErrInvalidPollInterval indicates a non-positive poll was supplied to Wait. // The poll is the backstop that makes a lost wakeup survivable, so a loop // without one would stop forever the first time a notification went // missing — which is a normal event, not an exceptional one. ErrInvalidPollInterval = platformerrors.New("invalid work queue poll interval") // ErrKeyTooLong indicates a key whose encoded form exceeds MaxKeyLength. It // is reported rather than truncated: two keys that differ only past the // limit would become one row, and the second unit of work would silently // disappear into the first. ErrKeyTooLong = platformerrors.New("encoded work queue key is too long") // ErrEmptyKey indicates a key whose encoded form is empty. An empty primary // key is legal SQL and always a mistake — it is what a zero-valued key // encodes to, so admitting it would let every unset key collapse onto one // row. ErrEmptyKey = platformerrors.New("empty work queue key") // ErrKeyContainsControlCharacter indicates a key whose encoded form contains // a NUL, a newline, or a carriage return. Postgres accepts all three in a // primary key, and every one of them is a key built by concatenating // unvalidated input — which makes every log line and every psql session that // touches the row unreadable. // // It is separate from ErrEmptyKey rather than a shade of it. A caller that // branches on "the key came out empty" and gets this instead reaches for the // wrong fix: a key with a newline in it is not missing, it is malformed, and // nothing about defaulting an unset key addresses it. ErrKeyContainsControlCharacter = platformerrors.New("work queue key contains a control character") // ErrKeyCodecTypeMismatch indicates WithKeyCodec was given a codec for a // type other than the Queue's. Option carries no type parameter, so the // compiler cannot catch this; New reports it instead, at construction. ErrKeyCodecTypeMismatch = platformerrors.New("key codec type does not match queue key type") // ErrClosed indicates an Enqueue that arrived after Close. It is returned // rather than parking the caller on a batch nothing will ever flush. // // It wraps batching.ErrClosed, which is where the refusal actually // originates, so a caller may check either — but the queue is what the // caller closed, and the queue is what the error should name. ErrClosed = platformerrors.Wrap(batching.ErrClosed, "work queue is closed") )
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
// Name is the logical queue. It is required and has no default: one table
// holds every queue in the database, partitioned by this column, so an
// unnamed queue would quietly share rows with every other unnamed one.
//
// It is bound as a parameter, never interpolated, so it is free-form text
// rather than an SQL identifier.
Name string `env:"NAME" json:"name,omitempty" yaml:"name,omitempty"`
// TablePrefix is the namespace the queue table carries. Empty renders
// work_queue_items; "ddb" renders ddb_work_queue_items. It must match the
// namespace the migrations were rendered with.
TablePrefix string `env:"TABLE_PREFIX" json:"tablePrefix,omitempty" yaml:"tablePrefix,omitempty"`
// NotifyChannel makes Enqueue emit a payload-free pg_notify on this channel
// after the rows land, so a claim loop listening on it wakes at once
// instead of on its next poll.
//
// Empty — the default — emits nothing at all, and the enqueue path runs
// exactly the statements it always did. It must be a plain SQL identifier:
// it is bound as text here, but a listener has to render it into a LISTEN,
// which takes no parameters.
//
// Nothing in this package listens. A wakeup arrives as a bare channel
// through WithWakeup, which database/postgres/pgnotify is one way to fill.
NotifyChannel string `env:"NOTIFY_CHANNEL" json:"notifyChannel,omitempty" yaml:"notifyChannel,omitempty"`
// Retention is how long a completed item is kept before Reap may delete it.
Retention time.Duration `env:"RETENTION" json:"retention,omitempty" yaml:"retention,omitempty"`
// MaxAttempts is how many times an item may be claimed before the queue
// stops handing it out. Zero — the default — means unlimited, which is the
// right answer when the work is idempotent and a failure is transient.
//
// Set it when an item can be poisonous. Without a ceiling, one key that
// reliably kills its worker is claimed, half-processed, and reclaimed
// forever, and because it sorts to the front on every pass it takes the
// whole queue's throughput with it. Stalled items are counted by
// Stats.Stalled and excluded from every claim; they are not deleted, so the
// keys remain available for inspection and a Release resets nothing on its
// own — an operator re-enqueues them once the cause is fixed.
MaxAttempts uint `env:"MAX_ATTEMPTS" json:"maxAttempts,omitempty" yaml:"maxAttempts,omitempty"`
// MaxClaimBatch caps how many items one Claim may lease. A larger limit is
// clamped to it rather than rejected, and a non-positive limit means "as
// many as allowed".
MaxClaimBatch int `env:"MAX_CLAIM_BATCH" json:"maxClaimBatch,omitempty" yaml:"maxClaimBatch,omitempty"`
// ReapBatchSize caps how many completed items one Reap deletes.
ReapBatchSize int `env:"REAP_BATCH_SIZE" json:"reapBatchSize,omitempty" yaml:"reapBatchSize,omitempty"`
// WriteAttempts is how many times a writer re-runs a statement that failed
// with a serialization failure or a deadlock — the two conditions Postgres
// resolves by asking the caller to try the whole thing again. Anything else
// is returned on the first failure.
WriteAttempts uint `env:"WRITE_ATTEMPTS" json:"writeAttempts,omitempty" yaml:"writeAttempts,omitempty"`
// MinWakeInterval floors the rate at which Wait returns on a wakeup, so a
// queue taking thousands of enqueues a second cannot drive thousands of
// claim round trips a second. It is inert without WithWakeup.
MinWakeInterval time.Duration `env:"MIN_WAKE_INTERVAL" json:"minWakeInterval,omitempty" yaml:"minWakeInterval,omitempty"`
// contains filtered or unexported fields
}
Config configures a Queue.
There is deliberately no Dialect field, and no clock. The SQL has to match the database it runs against, so New reads the dialect off the database.Client — the one thing that cannot be wrong about its own dialect — and every timestamp that governs scheduling comes from that database's now().
func (*Config) EnsureDefaults ¶
func (cfg *Config) EnsureDefaults()
EnsureDefaults fills unset knobs with the package defaults.
MaxAttempts is not among them: zero is a meaningful value there, not an unset one.
type Entry ¶
type Entry[K comparable] struct { // Key names the work. It is the row's identity: enqueueing the same key // twice updates one row rather than creating two. Key K // Priority orders the queue ahead of waiting time. Higher goes first, and // re-enqueueing an item can only raise it — an enqueue is a claim on // attention, so the loudest caller wins and a later, quieter one cannot // demote work somebody else already flagged as urgent. // // It is the generic form of a demand signal. A read path that discovers it // needs a key computed sooner enqueues it with a higher priority; that is // the whole mechanism, and it is why Enqueue has to be cheap enough to call // from a request handler. Priority int // Delay holds the item back for this long, measured from the database's // now() at the moment the row lands. // // It is a duration rather than a timestamp for the reason the package // documentation opens with: an absolute time would be the caller's clock, // and the whole point is that no two processes have to agree on one. // // Re-enqueueing an outstanding item can only move its availability earlier, // mirroring Priority. A completed item is being restarted rather than // hurried, so it takes the new delay outright. Delay time.Duration }
Entry is one unit of work being offered to the queue.
type Item ¶
type Item[K comparable] struct { // Key names the work. It is the key that was enqueued, decoded back through // the queue's codec. Key K // Priority is the item's current priority, which may be higher than the one // it was first enqueued with — re-enqueueing raises it. Priority int // Attempts counts claims of this item, including this one. It is 1 on a // first claim, so a worker can tell a fresh item from a retried one. Attempts int // Reclaimed reports that this claim took over a lease that lapsed rather // than one that was released or completed. // // It is the only visible trace of the package's failure-recovery mechanism. // A steady trickle is healthy — workers do die. A rate that tracks the claim // rate means leases are shorter than the work, and every item is being done // at least twice. Reclaimed bool }
Item is one leased unit of work.
It carries no timestamp, deliberately. The lease was just stamped from the server's now(), so a deadline expressed against the caller's clock would be exactly the process-clock dependency this package exists to remove; if a worker needs to know whether it still holds the lease, the answer is to finish and let Complete match nothing rather than to compare clocks.
type KeyCodec ¶
type KeyCodec[K comparable] interface { // EncodeKey renders key as the text stored in the primary key column. EncodeKey(key K) (string, error) // DecodeKey is EncodeKey's inverse, applied to keys read back from a claim. DecodeKey(encoded string) (K, error) }
KeyCodec translates a payload key to and from the text stored in the queue's primary key column.
The default handles the two shapes that cover nearly everything — see DefaultKeyCodec — so this exists for keys that need a specific rendering: one that has to sort a particular way, one with a canonical string form already, or one whose Go type is about to change in a way JSON would notice.
Whatever a codec produces has to be stable forever. It is the identity of a row: a rendering that changes between releases does not migrate the queue, it silently forks it, and the old rows stay claimable under keys nothing will ever complete.
func DefaultKeyCodec ¶
func DefaultKeyCodec[K comparable]() KeyCodec[K]
DefaultKeyCodec is the codec a Queue uses when none is supplied.
A string, or any type whose underlying type is string, is stored as itself. That is not only shorter than the JSON rendering, it keeps the table legible: an operator reading item_key sees the key, not a quoted one.
Everything else is JSON. K is comparable, which is most of what makes that safe — maps and slices cannot be keys in the first place, so the only remaining source of instability is struct field order, and Go's encoder emits fields in declaration order. Reordering the fields of a key struct therefore forks the queue, exactly as changing a custom codec would; treat the key type as part of the schema.
type Option ¶
type Option func(*queueOptions)
Option configures a Queue at construction.
It is deliberately not parameterized on the Queue's K. None of these settings depend on it, and Go cannot infer a type argument from a call's result type — so an Option would force every call site to spell the Queue's key type out by hand, forever.
WithKeyCodec is the one setting that does depend on K. It stays generic but still needs no annotation, because K is inferable from the codec it is handed; see its documentation for how a mismatch is reported.
There is no clock option, alone among this module's scheduling components. The database's now() is the only clock a Queue consults, and offering a seam to replace it would offer a way to break the one property the whole design rests on.
func WithKeyCodec ¶
func WithKeyCodec[K comparable](codec KeyCodec[K]) Option
WithKeyCodec overrides how keys are rendered into the table's primary key. The default is DefaultKeyCodec, which stores string-like keys as themselves and everything else as JSON.
K is inferred from the codec, so this needs no type argument:
workqueue.WithKeyCodec(myCodec{})
It must match the Queue it configures. Because Option carries no type parameter, a codec for the wrong key type cannot be rejected by the compiler; New returns ErrKeyCodecTypeMismatch instead, at construction, before a single key has been written under the wrong rendering.
func WithLogger ¶
WithLogger attaches a logger. Nothing in this package fails loudly — a deadlock retried into success, a Release that could not be recorded — so without one those events are visible only in metrics.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider. An absent provider records nothing.
func WithTracerProvider ¶
WithTracerProvider attaches a tracer provider. A claim that leases nothing is not traced: a root span per empty poll is noise, and an idle worker polls far more often than a busy one.
func WithWakeup ¶
func WithWakeup(wakeup <-chan struct{}) Option
WithWakeup gives Wait a channel to return on, beside its poll interval. A receive means "there may be work now"; the caller runs the same Claim it would have run on its next poll, so nothing about the queue's guarantees changes and a wake that never arrives costs only latency.
It is a bare channel because the queue must not learn where the wake came from. database/postgres/pgnotify fills it from LISTEN/NOTIFY — pair it with Config.NotifyChannel on the enqueueing side — but a test fills it by hand.
The channel should coalesce — capacity one, non-blocking sends, as pgnotify.Listener.Signal does. Config.MinWakeInterval floors the rate regardless.
Without one, Wait is a plain sleep and every claim loop keeps the behavior it has today.
type Queue ¶
type Queue[K comparable] struct { // contains filtered or unexported fields }
Queue is a leased work queue over one Postgres table.
It is safe for concurrent use, and is meant to be shared: one Queue per process per logical queue, handed to every goroutine that enqueues or claims. Enqueue's group commit is what makes that sharing pay — a Queue per caller would merge nothing.
A Queue owns a goroutine and must be Closed.
Example (Migrations) ¶
The table is created by the consumer's own migration run, at a version they choose — the platform ships no numbered migration, since the number would collide with theirs.
package main
import (
"log"
"github.com/primandproper/platform-go/v10/database/dialect"
"github.com/primandproper/platform-go/v10/database/migrate"
"github.com/primandproper/platform-go/v10/workqueue"
"github.com/primandproper/platform-go/v10/workqueue/migrations"
)
func main() {
body, err := migrations.SQL(dialect.Postgres, workqueue.DefaultTablePrefix)
if err != nil {
log.Print(err)
return
}
m, err := migrate.New(dialect.Postgres, nil,
migrate.WithGeneratedMigration(41, "create_work_queue_tables", body),
)
if err != nil {
log.Print(err)
return
}
_ = m
}
Output:
func New ¶
func New[K comparable]( ctx context.Context, cfg *Config, client database.Client, opts ...Option, ) (*Queue[K], error)
New builds a Queue over client, which must speak Postgres and must be the database holding the queue table.
ctx is used to validate the config and is not retained; every method takes its own.
func (*Queue[K]) Claim ¶
Claim leases up to limit of the queue's due items for the given lease duration, in one statement: nothing is selected without also being leased, so two claimers can never see the same item.
Due means unfinished, unleased, past its delay, and — when Config.MaxAttempts is set — not yet out of attempts. Ties are broken by priority first and by waiting time second, so the loudest and then the oldest work goes first.
The limit counts items actually leased, so a full batch comes back whenever that many items are due — a row another claimer holds is skipped and replaced, not subtracted. A short batch therefore means the queue is nearly drained, and an empty one means it is.
The lease is what the caller promises to finish inside. There is no heartbeat and no way to extend one — a lease that lapses mid-work hands the item to somebody else, and the original worker's eventual Complete lands on an item that is already done. That is waste, not corruption, provided the work is idempotent; if it is not, this package is the wrong tool.
Example ¶
A worker loop is just claim, work, complete, repeat. Competing claimers do not shrink each other's batches — a locked row is skipped and replaced rather than counted — so an empty claim is the only signal that there is nothing to do.
package main
import (
"context"
"log"
"time"
"github.com/primandproper/platform-go/v10/workqueue"
)
// A key is whatever names a unit of work in the consumer's domain. It is
// comparable, so its JSON rendering is stable — see workqueue.DefaultKeyCodec.
type tileKey struct {
Layer string `json:"layer"`
X int `json:"x"`
Y int `json:"y"`
}
func main() {
ctx := context.Background()
var queue *workqueue.Queue[tileKey]
for {
items, err := queue.Claim(ctx, 100, 30*time.Second)
if err != nil {
log.Print(err)
return
}
if len(items) == 0 {
time.Sleep(time.Second)
continue
}
for _, item := range items {
// A reclaimed item is one whose previous holder's lease lapsed. The
// work may already have been done once, which is why it has to be
// idempotent.
if item.Reclaimed {
log.Printf("retrying %v (attempt %d)", item.Key, item.Attempts)
}
}
}
}
Output:
func (*Queue[K]) Close ¶
Close stops the enqueue batcher, writing whatever it still holds so that a caller blocked in Enqueue during shutdown gets a real answer instead of hanging until its own context expires.
Safe to call more than once. Enqueue after Close returns ErrClosed; every other method keeps working, because they hold no state of their own — a worker draining its last claimed batch does not need the batcher.
func (*Queue[K]) Complete ¶
Complete retires finished items: the lease is dropped and the item stops being claimable. Rows are marked rather than deleted so a duplicate or a gap can be investigated afterwards; Reap removes them once they age past Config.Retention.
Keys the queue does not hold are ignored rather than reported. A straggler whose lease lapsed, and whose item was completed by somebody else or removed outright, has nothing useful to do with an error.
Completing is idempotent, and re-enqueueing a completed key restarts it with a fresh attempt count.
func (*Queue[K]) Enqueue ¶
Enqueue offers work to the queue, and returns once those keys are durably in it.
Every in-flight Enqueue on this process is merged into a single upsert. The caller still blocks until its own keys have landed, so read-your-write holds — enqueue, then claim, and the key is there — but however many callers are enqueueing at once, exactly one statement is ever in flight. That is what makes this safe to call from a request handler: the busier the process gets, the larger the batches become and the fewer connections the write path holds, which is the opposite of how one-statement-per-caller behaves under the same load.
A caller whose context expires stops waiting but does not cancel the flush: the batch is shared, and its other waiters still need it. Those keys are therefore likely to land anyway, which is the right outcome — the work was still worth doing.
Keys are validated before they join a batch, so a malformed key fails its own Enqueue rather than poisoning everybody else's.
Example ¶
Priority and delay are how a consumer expresses scheduling policy; the queue itself has no opinion about what is urgent or what is stale.
package main
import (
"context"
"log"
"time"
"github.com/primandproper/platform-go/v10/workqueue"
)
// A key is whatever names a unit of work in the consumer's domain. It is
// comparable, so its JSON rendering is stable — see workqueue.DefaultKeyCodec.
type tileKey struct {
Layer string `json:"layer"`
X int `json:"x"`
Y int `json:"y"`
}
func main() {
ctx := context.Background()
var queue *workqueue.Queue[tileKey]
err := queue.Enqueue(ctx,
// Somebody asked for this tile and got a stale one, so it jumps the
// line. Re-enqueueing an item can only raise its priority, so a later
// quieter caller cannot undo this.
workqueue.Entry[tileKey]{Key: tileKey{Layer: "roads", X: 1, Y: 2}, Priority: 10},
// Not worth doing until the upstream feed lands. The delay is measured
// from the database's clock, not this process's.
workqueue.Entry[tileKey]{Key: tileKey{Layer: "traffic", X: 1, Y: 2}, Delay: 15 * time.Minute},
)
if err != nil {
log.Print(err)
}
}
Output:
func (*Queue[K]) EnqueueKeys ¶
EnqueueKeys is Enqueue for the ordinary case: work with no priority and no delay, wanted as soon as a worker is free.
func (*Queue[K]) Reap ¶
Reap deletes completed items that have aged past Config.Retention, up to Config.ReapBatchSize of them, and reports how many it removed.
It is a method rather than a loop this package runs, because a consumer already has a scheduler — see the jobs package — and a component that starts its own timers is a component that has to be told when to stop. Call it on a period comfortably shorter than the time it takes ReapBatchSize completions to accumulate; a return value equal to the batch size means the queue is falling behind on retention and the period is too long.
func (*Queue[K]) Release ¶
Release hands claimed items back to the queue before their leases lapse, holding each for delay before it becomes claimable again and recording cause as the item's last error.
A zero delay and a nil cause is the plain hand-back — "I am not going to get to this" — and needs no ceremony. A non-zero delay is how a caller backs off a failing item without a scheduler of its own; retry/config's DelayFor computes the same schedule the rest of this module retries on.
Releasing is optional. An unreleased lease lapses and the item returns anyway, just later, which is why nothing here treats a failed Release as fatal. What it buys is the delay and the recorded reason: without it, a failing item comes straight back and spins against whatever it failed on.
Items that have already been completed are skipped, so a late Release arriving after somebody else finished the work cannot resurrect it.
func (*Queue[K]) Remove ¶
Remove drops items from the working set entirely, completed or not.
It is how a queue shrinks: a key whose subject no longer exists should stop being scheduled, not be completed as though the work had been done. Removing an item somebody currently holds a lease on is allowed — their Complete simply matches nothing, exactly as it would after a lapsed lease.
func (*Queue[K]) Stats ¶
Stats reads the queue's shape and records it to the gauges.
It is the health signal: nothing in this package fails loudly, so a queue that has stopped draining looks exactly like an idle one until somebody counts what is waiting. Sample it on a timer rather than per claim — every field is an aggregate over the queue, and at claim cadence the read costs more than the work it reports on.
Example ¶
Reap and Stats are called on a schedule the consumer owns — the jobs package is the obvious place — because a component that starts its own timers is one that has to be told when to stop.
package main
import (
"context"
"log"
"time"
"github.com/primandproper/platform-go/v10/workqueue"
)
// A key is whatever names a unit of work in the consumer's domain. It is
// comparable, so its JSON rendering is stable — see workqueue.DefaultKeyCodec.
type tileKey struct {
Layer string `json:"layer"`
X int `json:"x"`
Y int `json:"y"`
}
func main() {
ctx := context.Background()
var queue *workqueue.Queue[tileKey]
stats, err := queue.Stats(ctx)
if err != nil {
log.Print(err)
return
}
// Depth alone cannot tell a queue that is deep and moving from one that is
// deep and stuck. The age can.
if stats.OldestReadyAge > time.Hour {
log.Printf("queue is %d deep and falling behind: oldest ready item is %s old",
stats.Pending, stats.OldestReadyAge)
}
if _, err = queue.Reap(ctx); err != nil {
log.Print(err)
}
}
Output:
func (*Queue[K]) Wait ¶
Wait paces a claim loop: it blocks until a wakeup arrives, until poll elapses, or until ctx is done, whichever comes first.
It is the one piece of the loop this package supplies, and it exists because the loop is otherwise the caller's:
for {
items, err := queue.Claim(ctx, 10, time.Minute)
// ...
if len(items) == 0 {
if err = queue.Wait(ctx, time.Second); err != nil {
return err
}
}
}
Without WithWakeup it is a sleep, and a loop written around it behaves exactly as one written around time.Sleep. With one, an enqueue that lands a millisecond after a poll is claimed a millisecond later instead of a poll interval later, and an idle worker stops issuing a claim per tick — which is the larger win, because idle is what a work queue mostly is.
poll must be positive. It is the backstop that makes the wakeup safe to lose, and losing wakes is normal: the signal is at-most-once, and a listener that reconnects misses whatever arrived while it was away. A loop with no backstop would stop forever the first time that happened.
Config.MinWakeInterval floors how often a wake can return, so a burst of enqueues costs one extra claim rather than one per enqueue. Wait holds the wake for the remainder of the interval rather than discarding it, so the last enqueue of a burst is still claimed promptly.
Call it from one loop per Queue. A wake goes to a single receiver, so several loops sharing a Queue would divide the wakes between them arbitrarily and run on their poll intervals the rest of the time — correct, but pointless. Give each loop its own Queue and its own Listener.
Wait is not traced. A span per poll would be a root span per idle tick, which is the same noise Claim declines to emit when it leases nothing.
type Stats ¶
type Stats struct {
// OldestReadyAge is how long the oldest claimable item has been waiting,
// measured on the database's clock. Zero when nothing is claimable.
//
// This is the number to alert on. Every other field is a level, and no level
// distinguishes a queue that is deep because it is busy from one that is
// deep because it has stopped.
OldestReadyAge time.Duration
// Pending counts items that have not been completed, whether or not they are
// currently claimable.
Pending int64
// Ready counts items a Claim would hand out right now.
Ready int64
// Leased counts items currently held by a worker.
Leased int64
// Stalled counts pending items that have exhausted Config.MaxAttempts and
// will never be claimed again. Always zero when MaxAttempts is unlimited.
Stalled int64
// Completed counts finished items still inside the retention window.
Completed int64
}
Stats is the queue's shape, read in one round trip.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package workqueuecfg assembles a work queue from environment configuration.
|
Package workqueuecfg assembles a work queue from environment configuration. |
|
Package migrations supplies the work queue table's DDL, rendered for a table prefix.
|
Package migrations supplies the work queue table's DDL, rendered for a table prefix. |