What it is
A job queue without an external broker: jobs live in PostgreSQL, workers claim them concurrently (FOR UPDATE SKIP LOCKED), and the outcome is written into the same row. One binary, one VPS, observability from day one.
brooq is a library: it compiles into the consumer's process and shares its transaction and connection pool with it. A separate worker process is a consequence, not the essence: cmd/brooq uses the same library.
Three promises of different strength:
- claim — at most once at any moment;
- execution — at least once (the handler must be idempotent);
- outcome write — exactly once; the fate of a job is read with a single
SELECT.
Why
Notifications and background actions in my personal project went through an "api → notification-service → transport" push circuit: the job state lived in PostgreSQL while execution lived in process memory, which made delivery poorly traceable and hard to maintain. brooq moves the queue into a separate project: Postgres is the source of truth, workers claim rows themselves, everything is visible through a SQL query and metrics.
Quick start
make bin-deps # go mod download + pinned tool versions
make test-db-up # local PostgreSQL for integration tests (port 55433)
TEST_DATABASE_URL='postgres://brooq:brooq@localhost:55433/brooq_test?sslmode=disable' make test
make build # bin/brooq
make lint
make spec-validate # openspec validate --all --strict
A green make test without TEST_DATABASE_URL does not prove that migrations are applied: integration tests and e2e are skipped in that case.
Running the unit locally: copy .env.example to .env, set DATABASE_URL, then make migrate-up && make run. The service surface is 127.0.0.1:8090 (/healthz, /metrics).
As a library
import (
"github.com/thefish/brooq/pkg/brooq" // engine
"github.com/thefish/brooq/pkg/postgres" // SQL adapter
"github.com/thefish/brooq/pkg/queue" // vocabulary: Job, Claim, Handler
)
store := postgres.New(pool) // the consumer opens the pool
engine := brooq.New(store, log, brooq.Config{Workers: 4, Lease: 30 * time.Second})
_ = engine.RegisterKind(queue.Topic("email"), handler{}, queue.KindOptions{
MaxAttempts: 5, AttemptTimeout: time.Minute, MaxExecutionTimeout: 5 * time.Minute,
BackoffBase: time.Second, BackoffMax: time.Minute,
})
engine.Start()
defer engine.Close()
// Enqueue through the pool: the id and a flag saying the row was created by this call.
id, created, err := engine.Enqueue(ctx, queue.Job{Topic: "email", Payload: payload, IdempotencyKey: "order-42"})
if err != nil {
log.Error("enqueueing job", "err", err)
}
// created == false means a job with this key already existed; id is its identifier.
log.Info("job enqueued", "id", id, "created", created)
// Enqueue in your own transaction: your write and the job commit together.
tx, err := pool.Begin(ctx) // the consumer's pgx.Tx
defer func() { _ = tx.Rollback(ctx) }()
if _, _, err := store.EnqueueTx(ctx, postgres.WrapTx(tx), queue.Job{
Topic: "email", Payload: payload, IdempotencyKey: "order-42",
}); err != nil {
log.Error("enqueueing job", "err", err)
}
// ... your own writes with the same tx ...
_ = tx.Commit(ctx)
// The reverse direction: the queue transaction → your code.
qtx, err := engine.Begin(ctx) // queue.Tx
pgxTx, ok := postgres.AsPgx(qtx) // false if this is not *postgres.Tx
postgres.WrapTx wraps the consumer's transaction in the queue.Tx port, postgres.AsPgx does the reverse — it hands the driver transaction out of the port. Both sides are the same transaction, not a copy.
A detailed guide with examples is in queue-engine.md.
Documents
| File | What is inside |
|---|---|
architecture.md |
architecture: structure, guarantees, limitations |
queue-engine.md |
engine guide: examples, states, operations |
deploy/README.md |
deployment: systemd unit, variables, migrations |
Status
The engine is fully functional: enqueue (with the job identifier and the created flag), claim, outcome write, retry with backoff, Reaper, heartbeat, in-flight cancel, watchdog, and nine brooq_* metrics. Enqueue works both through the pool and inside the consumer's transaction — in both directions (postgres.WrapTx / postgres.AsPgx).
What is not there yet:
- an HTTP API for the queue — cancel exists (
Queue.Cancel), there is no endpoint; - priorities — ordering is strictly FIFO by
available_at, id; - partitioning — at very large volumes, splitting by time instead of deleting rows will be needed.
Terminal jobs (done, dead, canceled) are deleted by TTL — 30 days by default (QUEUE_RETENTION_TTL), in batches, with autovacuum tuned for it. The idempotency key lives as long as the row: the TTL sets the window after which a retry with the same key creates a new job.
Package layout and import rules are in architecture.md:
pkg/queue vocabulary (stdlib only)
pkg/brooq engine: workers, Reaper, heartbeat, metrics
pkg/postgres SQL adapter (the only place where PostgreSQL lives)
cmd/brooq composition: wire dependencies, start
Schema changes go only through the migrations/ migrations, applied as a deployment step, never from the binary at startup.
Status: the vocabulary was fixed by the first domain modelling session. The words here are used by specs, ADRs and code identifiers (ADR-0005). It is edited only in a domain modelling session.
brooq is a job queue library: it is compiled into the consumer's process and shares its transaction and connection pool. A separate worker process is a consequence, not the essence: it uses the same library.
Goal (the end scenario)
The producer put a job → a worker claimed it → the outcome is recorded and visible. Behind this stand three promises of different strength:
- Claim — "no more than once at any moment": at no moment is a job pinned to two workers.
- Execution — "at least once": the handler may be run again, including without anyone's decision (the lease expired — the job returned to circulation). The handler must be idempotent. "Exactly once" is achievable only for effects inside the same database, not for external ones (email, HTTP) — see ADR-0010.
- Outcome recording — "exactly once": terminal states are not overwritten, a job's fate is read with a single SQL query.
Everything else in the project exists so that this scenario is reliable, observable and maintainable: a job is not lost when a worker crashes, is not executed twice at the same moment, and its fate is read with a single SQL query and metrics.
Vocabulary
| Term | Meaning | Identifier |
|---|---|---|
| Job | a unit of work in the queue: topic, payload, state, attempts | Job |
| Topic | the kind of job; it selects the handler (routing) | Topic |
| Payload | the job's useful load; only the consumer knows its schema | Payload |
| State | the job's position in its lifecycle | State |
| Outcome | how a single attempt ended: success, retry, exhausted attempts, cancel; the outcome determines the job's new state | Outcome |
| Claim | an atomic capture of a job by a worker (FOR UPDATE SKIP LOCKED) |
Claim |
| Lease | the period for which a job is pinned to a worker | Lease |
| Worker | the executing side: claims jobs and runs them with a handler | Worker |
| Handler | consumer code that executes a job of one topic | Handler |
| Reaper | returns jobs with an expired lease to circulation | Reaper |
| Attempt | one cycle "claimed → executed → outcome recorded"; a retry is normal, not an emergency | Attempt |
| Producer | whoever puts a job into the queue | Producer |
| Idempotency key | the key of a specific delivery attempt: re-enqueueing with the same key does not create a second job, a new key is a new job | IdempotencyKey |
| Max attempts | the number of attempts after which a job becomes dead | MaxAttempts |
| Backoff | the interval until the next attempt; it grows with the attempt number and is bounded from above | Backoff |
| Cancel | a terminal outcome: the job is no longer relevant, it need not be executed | Cancel |
Spelling we avoid: cancelled (two l) — we write canceled, as in context.Canceled. state — not status (a state has no separate "status"; it is the same word). kind — not a synonym for topic. payload — not data and not body.
States and events
States are lowercase, events are past-tense PascalCase (ADR-0005). The meaning of the states:
| State | What it means |
|---|---|
pending |
waiting to be claimed; possibly until available_at |
processing |
claimed and pinned by a lease |
failed |
waiting for a retry: the failure is recorded, attempts are not exhausted, waiting for available_at |
done |
executed successfully; terminal |
dead |
attempts exhausted; terminal |
canceled |
canceled by the handler; terminal |
Events: JobClaimed, JobDone, JobFailed, JobDead, JobCanceled, JobRequeued (returned to circulation by the reaper).
failed is not "failed forever" but "waiting for the next attempt": a job is claimed both from pending and from failed, so this is a waiting state, not a deferred verdict.
Invariants
- A job in
processingalways has a lease: the worker identifier and the lease deadline are filled in at the same time. - A job cannot be pinned to two workers at one moment: the capture is a single SQL statement with
FOR UPDATE SKIP LOCKED(ADR-0001, ADR-0010). This is a promise about pinning, not about execution. - Terminal states —
done,dead,canceled— are not overwritten. - A job's outcome is written into the same row as the job itself: there is no separate "delivery log".
- The queue does not know the consumer's domain: the job payload is opaque JSON.
- An attempt is charged at claim time:
attemptsis how many times the job has already been claimed. deadis reached only through a recorded failure: a successful execution writesdoneunconditionally. A lease expiry does not count as an attempt and does not bring a job closer todead— it is a sign of a process crash, not of an execution failure.- Idempotency is on the pair "topic + key": at most one job per pair for the whole lifetime of the row, including terminal states. The key describes a specific delivery attempt, so a re-enqueue with a different key is a new job. Jobs without a key are not deduplicated.
- The queue does not own the process: it does not start goroutines and does not open connections without an explicit call. Everything it does, it does inside the caller's transaction or on the caller's command.
- The execution order is by readiness time, and on a tie by enqueue order. Between processes the order is not guaranteed.
- The guarantees are of different strength and are not mixed: claim — no more than once at any moment; execution — at least once; outcome recording — exactly once. Repeated execution is normal, not an emergency: it happens without anyone's decision when the lease has expired or the outcome recording did not succeed.
How the queue is built and what you can rely on without reading the code. Examples, configuration and operational practices are in queue-engine.md.
Overview
brooq is a job queue that lives entirely in PostgreSQL. Jobs sit in the job table in the same database as the consumer's data; workers claim rows concurrently; the outcome of an attempt is written into the same row that holds the job itself. There is no external broker, no second state store, no separate service for observation.
It ships as a library plus a binary. The library is compiled into the consumer's process and shares its transaction and connection pool: that is the main thing it offers beyond "put some JSON in a table". The cmd/brooq binary is built from the same packages and runs a worker as a separate process.
The end scenario: the producer put a job → a worker claimed it → the outcome is recorded and visible.
Jobs and states
A job is a row of the job table: topic, payload, state, attempt counter, availability time, lease. The payload is opaque JSONB: only the consumer knows its schema, the queue never looks inside.
Six states:
| State | Meaning |
|---|---|
pending |
waiting to be claimed; possibly until available_at |
processing |
claimed and pinned by a lease |
failed |
waiting for a retry: the failure is recorded, attempts are not exhausted, waiting for available_at |
done |
completed successfully; terminal |
dead |
attempts exhausted; terminal |
canceled |
canceled; terminal |
failed is not a verdict but a wait: a job is claimed from pending and from failed alike. There are exactly three terminal states — done, dead, canceled; they are never overwritten.
Cancel comes from two places: the handler's signal that "the job is no longer relevant" and an external cancel by an operator, including a job in flight. A job in flight is stopped through loss of the lease, without LISTEN/NOTIFY: the worker renews the lease every Lease/3 with a query conditioned on "the row is still processing and I am the one holding it"; after the cancel the condition does not hold, the update returns 0 rows, and the worker shuts the handler down without writing anything into the row — the outcome has already been recorded by the canceler. Reaction latency is up to Lease/3.
Guarantees
Three promises of different strength, and they must not be mixed:
- claim — at most once at any moment: a job is never pinned to two workers simultaneously;
- execution — at least once: the handler may be run again, including without anyone's decision (the lease expired, the process crashed, the outcome write failed);
- outcome write — exactly once: the fate of a job is read with a single
SELECT.
Hence the main requirement on the consumer: the handler must be idempotent. "Exactly once" is achievable only for effects inside the same database; for external effects — an email, an HTTP call, a payment — only "at least once". Two windows of repeated execution are unavoidable by design:
- the effect was made but the
donewrite failed — the job comes back into circulation and the effect repeats; - the lease expired while the handler was still running — a second worker claimed the job, two handlers run simultaneously.
That is why the guarantee is stated per place of effect:
| Where the effect happens | Guarantee |
|---|---|
| in the same database (row write, enqueueing the next job) | exactly once — one transaction |
| outside the database (email, HTTP, payment) | at least once only |
Storage
Jobs live in the job table in the consumer's database. Workers claim rows with a single SELECT … FOR UPDATE SKIP LOCKED, so a taken row does not block the others: the second worker claims the next job instead of waiting.
A job's state exists only in that row. There are no second state stores — no Redis, no in-memory maps, no files. It follows that a worker remembers nothing between runs: a crash at any moment means "the lease will expire and the job will come back into circulation".
A cache is acceptable only as an accelerator and never as the source of an answer: if the cache and the database disagree, the truth is in the database.
Why not a broker: the fate of a job is read with ordinary SQL rather than a dedicated UI; one backup and one set of migrations instead of two; "write the order and enqueue the job" is one transaction instead of a dual write with an outbox table. A volume of tens of thousands of jobs per day is far from the limits where a Postgres queue starts to get in the way.
Schema and migrations
The schema is described only by migrations/*.sql files (goose). There is no second truth: neither CREATE TABLE IF NOT EXISTS in the code at startup, nor a schema assembled by hand in production.
An applied file is never edited — a mistake is cured by a new migration. Migrations are applied by a deployment step and never by the binary at startup: otherwise a restart of several instances turns into a DDL race. Tests apply the same directory through goose, so the divergence "the schema in tests is one thing, in production another" does not happen. A rollback (+goose Down) is mandatory and is verified on a clean database.
Retention of terminal jobs
Terminal jobs (done, dead, canceled) are deleted by TTL: by default 30 days after the transition to a terminal state. Without cleanup the table and its indexes grow without bound, and the idempotency key is never released.
The age is computed from updated_at: it is set by every outcome write operator, so "older than the TTL" and "terminal for longer than the TTL" are the same thing. Deletion goes in batches rather than in one query: a large DELETE holds locks and produces a spike of dead rows, whereas a batch is committed in its own transaction and repeated while it comes back full. The subquery carries FOR UPDATE SKIP LOCKED — several processes or replicas remove different rows instead of waiting for each other.
Active states (pending, processing, failed) are never deleted, even if their updated_at is very old: only terminal ones are in the selection. A zero TTL means "cleanup disabled" and never "delete everything".
For cleanup there is a partial index on updated_at for the three terminal states: it does not grow with the work queue and does not get in the way of the hot path. For the table itself the autovacuum threshold is lowered: at the default 20 % dead rows a queue with a constant flow of inserts and deletes gets rare and very long passes, while frequent short ones are cheaper.
Consequence for idempotency: the TTL sets the deduplication window. While the row is alive, a repeat with the same key does not create a job; after cleanup it does. The key describes a delivery attempt, not an event in the consumer's domain, so this is expected behaviour rather than a defect.
Partitioning by time is the next step if the volume becomes large: then old partitions are dropped wholesale, without DELETE.
Packages and dependencies
cmd/brooq composition: assemble dependencies, start up
pkg/brooq engine: workers, Reaper, heartbeat, metrics, backoff
pkg/queue vocabulary: Job, State, Claim, Lease, Tx, Handler
pkg/postgres SQL adapter: the only place where PostgreSQL lives
(and the transaction bridge WrapTx / Pgx / AsPgx)
pkg/{config,db,logger} reusable packages with no domain knowledge
internal/{archgate,health,loopback,observability} infrastructure of the unit
Imports go downwards only, and this is checked by a test against the actual dependencies rather than by agreement:
pkg/*does not importinternal/*;pkg/queueknows neither SQL nor transport: stdlib only. The producer's transaction is passed as the structural interfacequeue.Tx, which the driver's transaction satisfies without importing the driver;pkg/brooqdoes not know PostgreSQL: it works through its ownRepoport, so it is testable without a database and the storage is replaced by a fake;- SQL lives only in
pkg/postgres.
The engine and the adapter are public because the internal/ directory cannot be imported from another module: with the packages in internal/ this would not be a library. Only the unit's own infrastructure stays in internal/.
Transactions
Every write goes through a transaction: "claim the job and record the attempt", "confirm and schedule a retry" are atomic operations. Reads — lists, statistics, service queries — go straight through the pool: a transaction for the sake of one SELECT holds a snapshot and gets in the way of vacuum.
A write through the bare pool is a defect. If a method does not accept a transaction, that is visible in review and must be justified.
Transaction bridge
The queue.Tx port abstracts the transaction, so at the junction of the two worlds both sides are needed: the consumer with its pgx layer and the queue with its port. The bridge lives in the adapter, because only the adapter is allowed to know the driver:
func WrapTx(tx pgx.Tx) *Tx // pgx.Tx → queue.Tx (the same object)
func (t *Tx) Pgx() pgx.Tx // *Tx → pgx.Tx
func AsPgx(tx queue.Tx) (pgx.Tx, bool) // queue.Tx → pgx.Tx, safely
WrapTx does not open a new transaction and does not copy it: enqueueing through the wrapper goes into the consumer's very transaction, so a rollback leaves no job behind, and a commit makes it visible to workers. Returning the concrete *Tx rather than the port — so that the reverse direction stays available without a type assertion: *Tx already satisfies queue.Tx.
AsPgx is needed where the port came from the engine — from Queue.Begin or from Claim.OnSucceeded: on a port value the Pgx method cannot be called. The second result is false for a foreign implementation of the port, nil and an empty wrapper: the adapter does not take it upon itself to unwrap foreign transactions.
Whoever opened the transaction closes it. The wrapper merely translates it between representations: Commit or Rollback through either side closes it for everyone who shares it.
Service contours
The service surface (/healthz, /metrics) and any internal endpoints listen on 127.0.0.1 only. Internal endpoints additionally require a service bearer token, compared in constant time. A public surface appears only through nginx and only deliberately. /healthz is deliberately not behind authentication: otherwise monitoring goes blind exactly when it is needed.
Secrets
Secrets — a DSN with a password, provider keys, bearer tokens — do not live in the repository, in the code, in migrations or in the database in plain text. Locally that is .env (gitignored) or environment variables; on the server — EnvironmentFile for non-secret settings and LoadCredential= for secrets, so that the value does not end up in /proc/<pid>/environ or in systemctl show. An empty variable value counts as unset: KEY= must not turn a default into an empty string.
Language and naming
Comments, doc comments, commit messages, error texts and logs are in Russian. Identifiers are English; a Russian term in the code is acceptable only as a data value, not as a name. User-facing texts are not written inline: they come from a message catalogue by a symbolic key.
States and other enumeration values are lowercase (pending). Events are past-tense PascalCase (JobClaimed). Job topics are lowercase with a hyphen or a dot (email.send). Tables are singular (job), columns are snake_case (available_at), Go fields are PascalCase, JSON tags are snake_case.
A state is where a thing stands, an event is a fact. When they are written the same way, the query "what happened" and the query "where is it now" start to blur together. The engine currently produces no events: a cancel can be observed through state and metrics.
Why a custom engine
Ready-made queue libraries on the same principle would have to be taken together with their schema and their migrator. For a project where the schema is described only by goose migrations and is applied by a single deployment step, a second migration stream breaks the single process of deployment and testing on a clean database.
The second constraint is the lease model. Some ready-made implementations have no explicit "claimed" state: the lease is encoded in a deferred availability time, and the question "what is in flight right now and who holds it" is inexpressible, while cancel creates a race — the worker's success overwrites the cancel.
Instead of a dependency, techniques were taken rather than code: a backoff formula with overflow protection, catching a panic in the handler, a watchdog for a hung handler, limiting the fetch by the number of free workers, enqueueing the next job in the transaction that closes the current one. Renewing the lease at short intervals with an ownership check absorbs the need for LISTEN/NOTIFY.
Operations and limits
One host, one systemd unit, PostgreSQL alongside. The binary is static, configuration comes from .env, secrets through LoadCredential, migrations are a deployment step. The service surface listens on loopback. Such a deployment is maintained by one person: a rollback is restoring the previous binary, diagnostics are systemctl status and journalctl. Scaling beyond the limits of a host requires no changes in the queue: it is enough to run workers on several hosts.
What is worth knowing before production:
- The TTL sets the idempotency window. Terminal rows are deleted after
QUEUE_RETENTION_TTL(30 days by default); after deletion the key is released, and a repeat with the same key will create a new job. If repeats must be filtered out for longer, increase the TTL or keep the window in the consumer. Cleanup can be disabled entirely:QUEUE_RETENTION_TTL=0; nothing is released, the table grows. Retention and partitioning are separate work that will be needed as volume grows; - a handler that ignores the context cannot be killed. Lease renewal stops after a cancel, so the reaper will return the job into circulation after
Lease, while the hung goroutine remains a memory leak until the process restarts. The leak is visible inbrooq_hung_workers_total; - there is no absolute ceiling without a hang. A handler that kills the process writes no failure, and
deadis not reached: the job will be claimed again. An external detector is needed — growth ofattemptswith an emptylast_error— plus a manual transition of the row todead; - there is no HTTP API for the queue. Cancel is available from the consumer's code and from
psql; there are no priorities, the order is strictly FIFO by availability time.
A practical guide to the engine. How it is built, guarantees and limitations — in architecture.md.
1. Quick start
Five steps from an empty database to a claimed job.
Step 1. Apply the migrations
make test-db-up # local PostgreSQL for experiments
export DATABASE_URL='postgres://brooq:brooq@localhost:55433/brooq_test?sslmode=disable'
make migrate-up
make migrate-status
Expected output: 00001_job and 00002_job_idempotency applied. A repeated make migrate-up is a no-op. Migrations are applied as a deployment step, never from the binary at startup.
Step 2. Set the settings
Everything through the environment or .env; the defaults already work:
QUEUE_WORKERS=2
QUEUE_POLL_INTERVAL=1s
QUEUE_LEASE=2m
QUEUE_ATTEMPT_TIMEOUT=1m
QUEUE_MAX_EXECUTION_TIMEOUT=5m
QUEUE_BACKOFF_BASE=1s
QUEUE_BACKOFF_MAX=1m
QUEUE_MAX_ATTEMPTS=5
Invalid values (zero, negative, AttemptTimeout > Lease) are rejected at startup with all the problems listed at once.
Step 3. Write a handler
package main
import (
"context"
"encoding/json"
"log/slog"
"github.com/thefish/brooq/pkg/queue"
)
// notification — the job payload; only the consumer knows the schema.
type notification struct {
UserID string `json:"user_id"`
Text string `json:"text"`
}
type notifyHandler struct {
log *slog.Logger
}
// Handle executes one job. The handler must be idempotent:
// execution may repeat (the lease expired, the outcome write did not go through).
func (h notifyHandler) Handle(ctx context.Context, job queue.Claim) error {
var msg notification
if err := json.Unmarshal(job.Payload, &msg); err != nil {
// Permanent error: a retry will not help, but the job cannot be
// canceled either — let the attempts run out and the job become dead.
return err
}
if err := send(ctx, msg); err != nil {
// Temporary error of an external API: retry later without spending
// an attempt (the attempt was already charged at claim — give it back).
return queue.ErrMustPreserveAttempt
}
h.log.Info("уведомление отправлено", "job", job.ID, "attempt", job.Attempt)
return nil
}
Step 4. Assemble the engine
// The store is the public SQL adapter; the consumer opens the pool
// (pkg/db is a convenient helper, but any *pgxpool.Pool will do).
store := postgres.New(pool)
// The engine is assembled from the store, the logger and the settings.
engine := brooq.New(store, log, brooq.Config{
Workers: 2,
PollInterval: time.Second,
Lease: 2 * time.Minute,
})
// Topic registration — before Start: settings errors are visible at startup.
if err := engine.RegisterKind(queue.Topic("notification"), notifyHandler{log}, queue.KindOptions{
MaxAttempts: 5,
AttemptTimeout: time.Minute,
MaxExecutionTimeout: 5 * time.Minute,
BackoffBase: time.Second,
BackoffMax: time.Minute,
}); err != nil {
return err
}
engine.Start()
defer engine.Close()
Step 5. Put a job and see the outcome
// A job is enqueued through the facade — the SQL adapter needs no direct access.
// id is known immediately, created == false — a job with this key already existed.
id, created, err := engine.Enqueue(ctx, queue.Job{
Topic: queue.Topic("notification"),
Payload: []byte(`{"user_id":"u-42","text":"привет"}`),
// The idempotency key is optional: with it a repeated enqueue
// will not create a second job.
IdempotencyKey: "notify-u-42-2026-10-20",
})
if err != nil {
return err
}
log.Info("задание поставлено", "id", id, "created", created)
A worker will claim the job and record the outcome in the same row; attempts will become 1, state — done.
Consumer imports — public packages only:
import (
"github.com/thefish/brooq/pkg/brooq" // engine: New, Config, Queue
"github.com/thefish/brooq/pkg/postgres" // SQL adapter for PostgreSQL
"github.com/thefish/brooq/pkg/queue" // vocabulary: Job, Claim, Handler, signals
)
Not a single internal/* is needed: that is the point of the public facade.
A complete ready-made example is cmd/brooq/main.go (it has the demo topic and a demonstration handler).
Checking it live:
make build
DATABASE_URL='postgres://brooq:brooq@localhost:55433/brooq_test?sslmode=disable' ./bin/brooq &
sleep 2
psql "$DATABASE_URL" -c "INSERT INTO job (id, topic, payload, idempotency_key)
VALUES (gen_random_uuid(), 'demo', '{\"msg\":\"привет\"}'::jsonb, 'demo-1')"
sleep 2
psql "$DATABASE_URL" -c "SELECT state, attempts FROM job WHERE idempotency_key='demo-1'"
# state | attempts
# -------+----------
# done | 1
2. Public surface
Three public packages — and the consumer needs nothing more:
| Package | What it gives | Imports |
|---|---|---|
pkg/queue |
vocabulary: Job, Claim, Handler, Tx, State, signals |
stdlib only |
pkg/brooq |
engine: New, Config, Queue, RegisterKind, Start/Stop/Close |
pkg/queue |
pkg/postgres |
SQL adapter: New(pool), Repository, transaction bridge WrapTx/AsPgx |
pkg/queue, pgx |
The direction of imports is downwards only: pkg/brooq does not know PostgreSQL (it works through the Repo port), pkg/queue knows neither. That is why the engine is tested without a database, and the store is swappable.
The engine facade:
type Queue struct{ /* ... */ }
func New(repo Repo, log *slog.Logger, cfg Config) *Queue
func (q *Queue) RegisterKind(topic queue.Topic, h queue.Handler, opts queue.KindOptions) error
// Enqueue — no need to touch the SQL adapter directly.
func (q *Queue) Enqueue(ctx context.Context, job queue.Job) (id string, created bool, err error)
func (q *Queue) EnqueueTx(ctx context.Context, tx queue.Tx, job queue.Job) (id string, created bool, err error)
func (q *Queue) Begin(ctx context.Context) (queue.Tx, error)
// Administrative cancel (D9, D14).
func (q *Queue) Cancel(ctx context.Context, id string) error
func (q *Queue) Start()
func (q *Queue) Stop()
func (q *Queue) Close() error
The vocabulary (pkg/queue) — below.
// A job to enqueue.
type Job struct {
Topic Topic
Payload Payload // []byte, opaque JSON
IdempotencyKey string // "" = no key
AvailableAt time.Time // zero = now
}
// A claimed job as the handler sees it.
type Claim struct {
ID string
Topic Topic
Payload Payload
Attempt int // the number of the completed attempt, from 1
Lease Lease // who holds it and until when
// OnSucceeded — a callback for enqueuing the next job in the same
// transaction that closes the current one. The engine fills it in before
// calling the handler, so in Handle it is never nil; tx is the
// queue.Tx port, the driver is obtained from it with postgres.AsPgx.
OnSucceeded func(ctx context.Context, tx Tx) error
}
type Handler interface {
Handle(ctx context.Context, job Claim) error
}
// Topic settings: set in code, not as a table column.
type KindOptions struct {
MaxAttempts int
AttemptTimeout time.Duration
MaxExecutionTimeout time.Duration
BackoffBase time.Duration
BackoffMax time.Duration
}
3. States
| State | What it means |
|---|---|
pending |
waiting to be claimed; possibly until available_at |
processing |
claimed and pinned by the lease |
failed |
waiting for a retry: the failure is recorded, attempts are not exhausted |
done |
executed successfully; terminal |
dead |
attempts exhausted; terminal |
canceled |
canceled; terminal |
┌──────────── done (success, unconditionally)
│
pending ──claim───▶ processing ──┬──────── dead (failure on the last attempt)
▲ │ │
│ │ └──────── canceled (cancel signal / external cancel)
│ │
└── Reaper ◀────────┤ lease expired → pending (§ 10)
│
└── failed ──(available_at)──▶ claimed again
The key point: failed is a waiting queue, not a verdict. A job is claimed from pending and failed once available_at has come.
Terminal states are not overwritten: all outcome writes are conditional UPDATEs with the guard WHERE state = 'processing' AND locked_by = $me, and "zero rows affected" is a normal result, not an error.
4. Retry policy
The attempt is charged at claim
attempts is incremented in the same UPDATE that pins the lease. So Claim.Attempt is already the number of the completed attempt, starting at 1.
Backoff
d = min(BackoffBase · 2^(attempts-1), BackoffMax)
actual backoff = uniformly from [d/2, d]
The jitter spreads out workers that would otherwise hit an external API simultaneously. The overflow guard sits before the exponentiation: for a large attempt number the result equals the ceiling, without Inf.
Example with BackoffBase=1s, BackoffMax=1m:
| Attempt | d |
Actual |
|---|---|---|
| 1 | 1s | 0.5s … 1s |
| 2 | 2s | 1s … 2s |
| 3 | 4s | 2s … 4s |
| 7 | 64s → ceiling 1m | 30s … 1m |
Three handler signals
import "github.com/thefish/brooq/pkg/queue"
// 1. "Retry later, in this much time" — does not spend an attempt beyond
// the one already charged and does not decrement the counter. The backoff
// is clamped to BackoffMax. On the last attempt the job becomes dead anyway.
return queue.MustDelay(30 * time.Second)
// 2. "The failure must not spend an attempt" — the job returns to failed,
// attempts is decremented back (but not below zero), dead is unreachable.
return queue.ErrMustPreserveAttempt
// 3. "The job has become irrelevant" — canceled regardless of the remaining
// attempts, processing stops.
return queue.ErrMustCancelTask
Errors are wrappable — fmt.Errorf("отправка: %w", queue.ErrMustCancelTask) works the same. To check the backoff in your own code:
if d, ok := queue.DelayOf(err); ok {
log.Info("обработчик попросил отсрочку", "delay", d)
}
An ordinary error
Returning any other error is a failed attempt: failed + backoff, or dead if attempts are exhausted. The reason is written to last_error (no secrets — that is your responsibility).
Panic
A handler panic is recovered: the job gets the failure outcome, the worker keeps running, the process does not exit. The stack is written to the log at ERROR level.
5. Reasons for context interruption
The engine distinguishes a timeout from a cancel via context.WithCancelCause (D15). The handler does not need to parse the cause — returning ctx.Err() is enough.
| Cause | What the engine does |
|---|---|
context.DeadlineExceeded (AttemptTimeout expired) |
failed + backoff |
queue.ErrMaxExecutionExceeded (the topic ceiling) |
failed + backoff |
queue.ErrLeaseLost (lease lost) |
writes nothing — the canceling side has already changed the row |
func (h handler) Handle(ctx context.Context, job queue.Claim) error {
if err := h.doWork(ctx); err != nil {
if ctx.Err() != nil {
// Cancel or timeout: the engine determines the outcome from context.Cause.
return ctx.Err()
}
return err
}
return nil
}
ErrMaxExecutionExceeded does not make the job dead: dead is reached only by exhausting attempts.
6. Producer
An ordinary enqueue
id, created, err := repo.Enqueue(ctx, queue.Job{
Topic: queue.Topic("notification"),
Payload: payload,
})
if err != nil {
return err
}
log.Info("задание поставлено", "id", id, "created", created)
In the producer's transaction (the main point of a "library")
The job is committed together with your changes — atomically:
tx, err := repo.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) // safe: a no-op after Commit
// 1. Change our own state.
if _, err := tx.Exec(ctx, `UPDATE account SET notified = true WHERE id = $1`, id); err != nil {
return err
}
// 2. Put the job in the same transaction.
// The identifier is known before the commit, but is valid only after Commit.
jobID, created, err := repo.EnqueueTx(ctx, tx, queue.Job{
Topic: queue.Topic("notification"),
Payload: payload,
IdempotencyKey: "welcome-" + id,
})
if err != nil {
return err
}
// 3. Always write the meta, idempotently by job_id: created == false may
// refer to an already finished job — then the meta is simply appended.
if _, err := tx.Exec(ctx,
`INSERT INTO outbox (order_id, job_id) VALUES ($1, $2) ON CONFLICT (job_id) DO NOTHING`,
id, jobID); err != nil {
return err
}
log.Info("задание поставлено в транзакции", "id", jobID, "created", created)
// 4. Commit: either both changes are visible or neither.
return tx.Commit(ctx)
If the transaction rolls back, there is no job. If it commits, the worker will see it.
Both sides of one transaction
The boundary between consumer code and the queue is crossed in both directions. postgres.WrapTx wraps the driver transaction into the queue.Tx port, and (*Tx).Pgx and postgres.AsPgx return the driver transaction back.
// own transaction → queue
tx, err := pool.Begin(ctx) // the consumer's pgx.Tx
defer func() { _ = tx.Rollback(ctx) }()
if _, _, err := repo.EnqueueTx(ctx, postgres.WrapTx(tx), job); err != nil { ... }
// ... own writes with the same tx ...
err = tx.Commit(ctx)
// queue transaction → own code
qtx, err := engine.Begin(ctx) // queue.Tx
pgxTx, ok := postgres.AsPgx(qtx) // false if it is not *postgres.Tx
WrapTx returns a concrete *postgres.Tx — which is also a queue.Tx, so it fits everywhere the engine expects the port. Pgx and AsPgx give the very same transaction, not a copy: a Commit or Rollback through it closes it for everyone who shares it. Its owner closes it — the one who opened it. The same technique works in Claim.OnSucceeded(ctx, tx): the engine hands out a queue.Tx, and AsPgx turns it into the driver transaction for your own writes in the same transaction.
Idempotency
// A repeat with the same key → no second job (ON CONFLICT DO NOTHING).
id, created, err := repo.Enqueue(ctx, queue.Job{Topic: "notification", Payload: p, IdempotencyKey: "order-42-paid"})
// One row in the table; the first call: created == true, id — the new row.
id2, created2, err := repo.Enqueue(ctx, queue.Job{Topic: "notification", Payload: p, IdempotencyKey: "order-42-paid"})
// The second call: the same id (id2 == id) and created2 == false — no job appeared.
The created flag answers "is this a new row", not "is this a new effect": created == false may refer to an already finished job (the producer enqueued the job and crashed before writing the meta, the worker brought it to done, the producer retried). That is why the consumer's meta is written always, made idempotent by job_id — in the same transaction as EnqueueTx:
INSERT INTO outbox (order_id, job_id) VALUES ($1, $2)
ON CONFLICT (job_id) DO NOTHING
Then a repeat with created == false appends the missing meta and does not duplicate the existing one. The reverse order ("meta first, then enqueue") does not work: id is not known yet.
Rules:
- the key is unique within a topic: the same key in different topics means two different jobs;
- the empty string
""means "no key" (it becomesNULL), and such jobs are not deduplicated; - a whitespace-only key
" "is a real key; - the key is not released after a terminal state: a repeat in a month with the same key will not create a job. The key describes a specific delivery attempt — a new delivery needs a new key.
7. Canceling a job
// Cancels both a waiting and a running job.
err := engine.Cancel(ctx, jobID)
From psql — the same thing:
UPDATE job SET state = 'canceled', updated_at = now()
WHERE id = '...' AND state IN ('pending', 'failed', 'processing');
How a cancel reaches a running handler
The mechanism is lease loss, without LISTEN/NOTIFY (D14):
- the canceling side moves the row to
canceled; - the worker's next heartbeat does not pass the
state='processing'guard and returns 0 rows; - the worker calls
cancelCause(ErrLeaseLost), the handler getsctx.Done(), the worker writes nothing to the row — the outcome has already been recorded by the canceling side.
The reaction delay is no longer than Lease/3 (the renewal period).
cancel ──▶ row canceled ──▶ heartbeat: 0 rows ──▶ ErrLeaseLost ──▶ ctx.Done()
(≤ Lease/3) │
▼
the worker does NOT write the outcome
A handler that respects ctx will finish on its own. A handler that ignores ctx keeps running (a goroutine in Go cannot be killed) — then the watchdog fires: ERROR in the log and brooq_hung_workers_total. Its outcome write gets 0 rows and does not overwrite the cancel.
8. Settings
| Variable | Field | Default | Meaning |
|---|---|---|---|
QUEUE_WORKERS |
QueueWorkers |
1 |
goroutines (free slots) per topic |
QUEUE_POLL_INTERVAL |
QueuePollInterval |
1s |
base polling period, the actual one ±25 % |
QUEUE_LEASE |
QueueLease |
2m |
lease duration of a claimed job |
QUEUE_ATTEMPT_TIMEOUT |
QueueAttemptTimeout |
1m |
how much time one attempt gets |
QUEUE_MAX_EXECUTION_TIMEOUT |
QueueMaxExecutionTimeout |
5m |
hard ceiling of the topic |
QUEUE_BACKOFF_BASE |
QueueBackoffBase |
1s |
backoff base |
QUEUE_BACKOFF_MAX |
QueueBackoffMax |
1m |
backoff ceiling |
QUEUE_MAX_ATTEMPTS |
QueueMaxAttempts |
5 |
max attempts |
QUEUE_REAPER_PERIOD |
QueueReaperPeriod |
0 (computed) |
reaper period; 0 = max(5s, Lease/4) |
QUEUE_WATCHDOG_GRACE |
QueueWatchdogGrace |
0 (= Lease) |
watchdog grace after a cancel |
QUEUE_STATS_PERIOD |
QueueStatsPeriod |
0 (= 30s) |
statistics collection period for the gauges |
QUEUE_RETENTION_TTL |
QueueRetentionTTL |
720h (30 days) |
how long a terminal job lives after moving to a terminal state; 0 — cleanup disabled |
QUEUE_RETENTION_BATCH |
QueueRetentionBatch |
1000 |
delete batch size |
QUEUE_RETENTION_PERIOD |
QueueRetentionPeriod |
0 (computed) |
cleanup period; 0 = one day, clamped to [1h, 24h] |
Startup validation rejects:
QUEUE_WORKERS < 1,QUEUE_MAX_ATTEMPTS < 1,QUEUE_RETENTION_BATCH < 1;- any period
<= 0— exceptQUEUE_RETENTION_PERIOD, where0means "computed"; - a negative
QUEUE_RETENTION_TTL; zero is allowed and means "cleanup disabled"; AttemptTimeout > MaxExecutionTimeout;- any period
<= 0; AttemptTimeout > MaxExecutionTimeout;— the restriction is lifted: the lease is renewed by the heartbeat, so long work no longer loses the lease. Consequence for operations: the shorter theAttemptTimeout > LeaseLease, the faster theReaperreturns a crashed worker's job, but the more frequent the renewals.
Bare numbers without units (QUEUE_LEASE=120) are rejected: time.Duration requires a suffix (120s).
How to choose Lease
Lease no longer limits the duration of work: while the handler is alive, the worker renews the lease every Lease/3. So Lease is the recovery speed:
- a short
Lease(10–30s) — a crashed worker's job comes back quickly, but renewals happen more often (more database queries); - a long
Lease(2–5m) — less load, but a crash costs up toLeaseof job downtime.
WatchdogGrace — how long to wait for the handler to exit after a cancel before recording a hang. By default it equals Lease.
9. Schema and observability
Columns
| Column | Meaning |
|---|---|
id |
the job's UUID |
topic |
the topic (routing) |
payload |
the payload, JSONB, opaque to the queue |
state |
one of the six states, CHECK job_state_check |
attempts |
how many times the job has already been claimed |
available_at |
do not claim before this time |
locked_by |
who holds the lease (the worker id) |
locked_until |
the end of the lease |
last_error |
the reason of the last failure (no secrets) |
idempotency_key |
the idempotency key, NULL = none |
created_at / updated_at |
the time of enqueue / of the last change |
Indexes
| Index | Why |
|---|---|
job_ready_idx |
selecting ready jobs without a topic filter |
job_ready_topic_idx |
(topic, available_at, id) WHERE state IN ('pending','failed') — the worker's hot path |
job_lease_idx |
expired leases (WHERE state='processing') |
job_topic_idx |
per-topic counters |
job_idempotency_idx |
uniqueness of (topic, idempotency_key) |
Useful queries
-- A job's fate in one query — the main promise of the queue.
SELECT state, attempts, last_error, available_at, locked_by, locked_until
FROM job WHERE id = '...';
-- What is in flight right now and who holds it.
SELECT id, topic, locked_by, locked_until, attempts,
locked_until < now() AS lease_expired
FROM job WHERE state = 'processing' ORDER BY locked_until;
-- Queue depth by topic.
SELECT topic, state, count(*) FROM job GROUP BY topic, state ORDER BY 1, 2;
-- Age of the oldest ready job (queue lag).
SELECT topic, now() - min(available_at) AS oldest_ready
FROM job WHERE state IN ('pending','failed') AND available_at <= now()
GROUP BY topic;
-- Dead jobs: to be sorted out by hand.
SELECT id, topic, attempts, last_error, updated_at
FROM job WHERE state = 'dead' ORDER BY updated_at DESC;
Reaper: what happens to stuck jobs
The Reaper runs inside every process: one goroutine, period max(5s, Lease/4), overridden by QUEUE_REAPER_PERIOD. It does exactly what D8 describes:
UPDATE job
SET state = 'pending', locked_by = NULL, locked_until = NULL,
available_at = now(), updated_at = now()
WHERE state = 'processing' AND locked_until < now()
RETURNING id, topic;
Why exactly this way:
attemptsdoes not change: an expired lease is a sign of a crashed process, not of a recorded failure (invariant 7). So a crash does not bring the job closer todead;available_at = now(), without an extra backoff: the lease itself was already the wait — the job could not come back earlier than its term. A second delay would be a double one;- the
state = 'processing'guard makes the return idempotent and does not allow resurrecting terminal rows (they also keep alocked_untilfrom their past lease); - several replicas are safe: no mutex table is needed, a second concurrent reaper will see the row already in
pendingand will not return it again.
To see what exactly came back: an Info log entry with count and topics. How many jobs are executing right now and who holds them — the "what is in flight" query above.
If you still need to return a job by hand (for example, the reaper is disabled):
-- WARNING: safe only if the lease has really expired.
-- attempts does not change — an expired lease is not counted as an attempt.
UPDATE job
SET state = 'pending', locked_by = NULL, locked_until = NULL, updated_at = now()
WHERE state = 'processing' AND locked_until < now();
Do not run this in a loop with a short period: a job whose lease has just expired may be running in a live but slow worker. Its outcome write after the return to circulation will get 0 rows (the guard on locked_by), that is, the work will not be counted — the job will be executed again. That is exactly the window the heartbeat exists to eliminate (4.6).
Cleaning up terminal rows (retention)
Terminal jobs (done, dead, canceled) are deleted by TTL: by default 30 days after moving to a terminal state. Why: without cleanup the table and its indexes grow without bound, and the idempotency key is never released.
DELETE FROM job
WHERE id IN (
SELECT id
FROM job
WHERE state IN ('done', 'dead', 'canceled')
AND updated_at < now() - $1::interval -- TTL
ORDER BY updated_at
LIMIT $2 -- batch size
FOR UPDATE SKIP LOCKED
)
RETURNING id, topic;
Why this way:
updated_atas the age. Every outcome statement sets it, so "older than the TTL" and "terminal for longer than the TTL" are the same thing;- in batches, not all at once. One big
DELETEholds locks and produces a spike of dead rows. The goroutine deletes up toQUEUE_RETENTION_BATCHrows in one transaction and repeats while a batch comes back full; FOR UPDATE SKIP LOCKED. Several processes or replicas clean different rows instead of waiting for each other;ORDER BY updated_at. Oldest first, so that a lagging cleanup does not accumulate a tail;- active rows are untouched — the selection contains only terminal states, and
pending/processing/failedare never deleted, even with a very oldupdated_at.
Disabling. QUEUE_RETENTION_TTL=0 disables cleanup entirely: the goroutine does not start. This is protection against an accidental DELETE of the terminal part of the table — a zero TTL never means "delete everything".
The loop is bounded to a hundred batches per tick. If the accumulation is larger, the remainder goes to the next tick, and a Warn appears in the log — better than one endless pass that holds the load unnoticed.
Autovacuum. Migration 00003 lowers the threshold for the job table (autovacuum_vacuum_scale_factor = 0.02, threshold = 100): with the default 20 % dead rows, a queue with a constant stream of inserts and deletes gets rare and very long passes. Frequent short passes are cheaper.
How much was deleted: the brooq_retention_deleted_total{topic} metric and an Info log entry when the cleanup was non-empty.
To check that exactly the right rows are being deleted:
-- Deletion candidates: how many rows the cleanup will take on the next tick.
SELECT state, count(*), min(updated_at) AS oldest
FROM job
WHERE state IN ('done', 'dead', 'canceled')
AND updated_at < now() - interval '30 days'
GROUP BY state;
Delete by hand (for example, when cleanup is disabled):
DELETE FROM job
WHERE state IN ('done', 'dead', 'canceled')
AND updated_at < now() - interval '30 days';
Remember: the idempotency key is released together with the row. If the consumer relied on a repeat with the same key not creating a job, then after the cleanup such a repeat will create a new one — that is the expected behaviour of the TTL, not a defect.
Metrics
/metrics serves ten engine metrics (plus the surface's HTTP metrics):
| Metric | Type | What it shows |
|---|---|---|
brooq_jobs{topic,state} |
gauge | how many jobs are in each state |
brooq_ready_oldest_age_seconds{topic} |
gauge | age of the oldest ready job — the main SLO |
brooq_claim_total{topic,outcome} |
counter | claims: claimed / empty |
brooq_settle_total{topic,outcome} |
counter | outcomes: done / retry / dead / canceled |
brooq_attempt_duration_seconds_sum{topic} |
counter | total handler duration |
brooq_attempt_duration_seconds_count{topic} |
counter | number of executions |
brooq_lease_expired_total{topic} |
counter | returns of expired leases (Reaper) |
brooq_lease_lost_total{topic} |
counter | lease loss (cancel/interception) |
brooq_hung_workers_total{topic} |
counter | hung handlers (watchdog) |
brooq_worker_busy{topic} |
gauge | busy slots |
brooq_retention_deleted_total{topic} |
counter | how many terminal rows were deleted by the cleanup |
Average attempt duration = _sum / _count. There are no histograms on purpose: the registry has no buckets, and the sum/count pair covers the average and the rate.
brooq_ready_oldest_age_seconds resets to zero when there are no ready jobs — so "growing" unambiguously means "the queue cannot keep up", without false alarms after a topic is drained.
Typical alerts:
# The queue is stuck: the oldest ready job has been waiting for more than 5 minutes.
brooq_ready_oldest_age_seconds > 300
# Jobs are dying: dead outcomes are growing.
rate(brooq_settle_total{outcome="dead"}[5m]) > 0
# Handlers are hanging.
increase(brooq_hung_workers_total[15m]) > 0
# All slots are busy — the queue is not being drained by the workers.
brooq_worker_busy == on(topic) brooq_jobs{state="processing"}
Job chain (Chain)
A handler can enqueue the next job in the same transaction that closes the current one (D9). This is the main point of the "library" shape: if the write of the next job fails, the current one is not considered closed — both changes roll back together.
func (h handler) Handle(ctx context.Context, job queue.Claim) error {
if err := h.step(ctx, job); err != nil {
return err
}
// The next step is committed together with the closing of the current one.
job.Chain(queue.Job{
Topic: queue.Topic("email"),
Payload: nextPayload,
IdempotencyKey: "step-2-" + job.ID,
})
return nil
}
The chain fires only on success: on a failure or a cancel the next job is not enqueued.
10. Guarantees
Three promises of different strength — it is important not to mix them:
- Claim — "no more than once at any moment." At no moment is a job pinned to two workers. Ensured by one SQL statement with
FOR NO KEY UPDATE SKIP LOCKEDand thelocked_byguard on the outcome write. - Execution — "at least once." The handler may be started again — including without anyone's decision (the lease expired, the outcome write did not go through). The handler must be idempotent. "Exactly once" is achievable only for effects inside the same database, but not for external ones (email, HTTP).
- Outcome recording — "exactly once." Terminal states are not overwritten; a job's fate is read with one SQL query.
What the queue does not do
- does not guarantee ordering between processes (within a topic — FIFO by
available_at, thenid); - does not provide priorities;
- does not retry internal database queries: a failed query is a failed operation;
- does not delete terminal rows: retention is a separate change, so the idempotency key is not released and the table grows;
- does not start goroutines or open connections without an explicit call.
11. Using it as a library
brooq is imported from another module: the engine and the adapter are public (pkg/brooq, pkg/postgres), only the unit's own infrastructure remains in internal/. The proof is a test in the brooq_test package that assembles the queue from the outside and drives a job to done without importing a single internal/*.
Minimal consumer:
package main
import (
"context"
"log/slog"
"github.com/thefish/brooq/pkg/brooq"
"github.com/thefish/brooq/pkg/db"
"github.com/thefish/brooq/pkg/postgres"
"github.com/thefish/brooq/pkg/queue"
)
func main() {
ctx := context.Background()
pool, err := db.Connect(ctx, dsn, db.DefaultParams())
if err != nil {
panic(err)
}
defer pool.Close()
engine := brooq.New(postgres.New(pool), slog.Default(), brooq.Config{
Workers: 4, Lease: 2 * time.Minute,
})
if err := engine.RegisterKind(queue.Topic("email"), handler{}, queue.KindOptions{
MaxAttempts: 5, AttemptTimeout: time.Minute,
MaxExecutionTimeout: 5 * time.Minute,
BackoffBase: time.Second, BackoffMax: time.Minute,
}); err != nil {
panic(err)
}
engine.Start()
defer engine.Close()
select {} // or your own loop/signal.NotifyContext
}
What the consumer must provide
- its own
jobtable: migrations are a deployment step, the binary does not apply them. Allmigrations/*.sqlfiles are part of the schema contract, not an implementation detail; - an idempotent handler: "at least once" execution is not a slogan but a consequence of the lease and retries (§ 4);
- its own PostgreSQL connection: the pool is opened by the consumer, the engine neither creates nor closes it;
- its own transaction or port: its own pgx transaction is brought into the queue via
postgres.WrapTx, and back viapostgres.AsPgx(§ 6).
What the facade does not have
- the queue's HTTP API — cancel exists (
Queue.Cancel), but there is no endpoint; - priorities, sharding, isolation by application version;
- schema management: retention and partitioning are separate tasks.
pkg/queue remains a frozen contract: renaming a field in Job or Claim breaks the consumer.
12. Tests
make test # without a database: integration and e2e — SKIP
make test-db-up
TEST_DATABASE_URL='postgres://brooq:brooq@localhost:55433/brooq_test?sslmode=disable' make test
A green make test without TEST_DATABASE_URL does not prove that the migrations are applied — pkg/postgres and pkg/brooq are skipped. With a database the tests create an isolated schema, apply goose and remove it.
What is covered:
| Package | What is checked |
|---|---|
pkg/queue |
the vocabulary and states, errors.Is/As of the signals, context.Cause parsing |
pkg/brooq |
outcome mapping (16 cases), panic, OnSucceeded in one transaction, an empty queue, idempotent Stop/Close, no goroutine leak, no overlap of iterations, backoff, the facade (Enqueue/EnqueueTx/Begin/Cancel), the reaper at the engine level (return while the engine runs, a live lease, two engines, repeated expiry) |
pkg/postgres |
schema and indexes, deduplication, the terminality and ownership guard, the claim contract (topic, available_at, failed, ordering, limit, lease, SKIP LOCKED), transaction rollback, Reaper, enqueue returns id/created (including the concurrent case — one key, two goroutines), the transaction bridge: WrapTx does not copy the transaction, AsPgx does not unwrap a foreign one |
pkg/brooq (e2e) |
race: two engines for one job → exactly one call; happy path: enqueue in a transaction → claim → outcome in one SELECT; the library test from brooq_test — assembling the queue without internal/* |
Two e2e tests — by the owner's decision: the claim race and the happy path. The rest are unit tests in table style, without a database.
The full gate before completion:
make build && make test && make lint && make spec-validate
13. What's next
The engine is closed: all tasks of the 01-queue-engine and 02-enqueue-returns-id changes are done and archived, and so is the retention of terminal rows. What remains is consciously deferred:
- The queue's HTTP API — cancel exists (
Queue.Cancel), but there is no endpoint and no authorization; cancel frompsqlor from the consumer's code. - Priorities — the order is strictly FIFO by
available_at, id. - Table partitioning — TTL cleanup solves the growth problem, but at very large volumes splitting by time will be needed: then old partitions are dropped whole, without
DELETE. - Sharding and isolation by application version — not needed at the current scale.
What is worth knowing about operations:
Leasenow defines not "how long work may last" (the lease is renewed) but the recovery speed after a worker crash: a crashed process's job comes back afterLease;- a hung handler that ignores
ctxholds its slot until the process restarts — a goroutine in Go cannot be killed. Observed viabrooq_hung_workers_total; brooq_ready_oldest_age_secondsis the main SLO: growth means that the queue cannot keep up. The value resets to zero when there are no ready jobs;- the TTL defines the idempotency window: after the row is deleted the key is released, and a repeated enqueue with the same key will create a new job. If a repeat must be cut off for longer, increase
QUEUE_RETENTION_TTLor keep the window in the consumer itself.