Skip to content

Commit 840fa2e

Browse files
committed
Add CI, README, example, Makefile, and license
GitHub Actions workflow with Postgres service container. README with quick start, job/ticker examples, configuration reference, multi-pod safety docs, and recovery API. Basic example app. MIT license.
1 parent 0f2a283 commit 840fa2e

6 files changed

Lines changed: 404 additions & 0 deletions

File tree

.github/workflows/test.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
on: [push]
2+
3+
name: test
4+
5+
jobs:
6+
7+
test:
8+
name: Test
9+
runs-on: ubuntu-latest
10+
11+
services:
12+
postgres:
13+
image: postgres
14+
env:
15+
POSTGRES_PASSWORD: postgres
16+
options: >-
17+
--health-cmd pg_isready
18+
--health-interval 10s
19+
--health-timeout 5s
20+
--health-retries 5
21+
ports:
22+
- 5432:5432
23+
24+
steps:
25+
- name: Checkout code
26+
uses: actions/checkout@v4
27+
28+
- name: Install Go
29+
uses: actions/setup-go@v5
30+
with:
31+
go-version: 1.24.x
32+
33+
- uses: actions/cache@v4
34+
with:
35+
path: |
36+
~/go/pkg/mod
37+
~/.cache/go-build
38+
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
39+
restore-keys: |
40+
${{ runner.os }}-go-
41+
42+
- name: Test
43+
run: make test-reset

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.DS_Store
2+
.idea/
3+
.vscode/
4+
*.iml
5+
*.tmp
6+
tmp/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 goware
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
SHELL = bash -o pipefail
2+
TEST_FLAGS ?= -p 1 -v -count=1 -timeout 120s
3+
4+
PG_HOST ?= 127.0.0.1
5+
PG_PASSWORD ?= postgres
6+
PG_USER ?= postgres
7+
PG_DATABASE ?= pgqueue_test
8+
9+
all:
10+
@echo "make <cmd>"
11+
@echo ""
12+
@echo "commands:"
13+
@echo " build - Build"
14+
@echo " test - Run tests (requires Postgres)"
15+
@echo " test-reset - Reset DB and run tests"
16+
@echo " lint - Run go vet"
17+
@echo " db-create - Create test database"
18+
@echo " db-drop - Drop test database"
19+
@echo " clean - Clear caches"
20+
21+
build:
22+
go build ./...
23+
24+
lint:
25+
go vet ./...
26+
27+
test:
28+
GOGC=off go test $(TEST_FLAGS) ./...
29+
30+
test-reset: db-reset test
31+
32+
test-clean:
33+
go clean -testcache
34+
35+
clean:
36+
go clean -cache -testcache
37+
38+
db-reset: db-drop db-create
39+
40+
db-create:
41+
@PGPASSWORD=$(PG_PASSWORD) psql -h $(PG_HOST) -U $(PG_USER) -c "CREATE DATABASE $(PG_DATABASE)" 2>/dev/null || true
42+
43+
db-drop:
44+
@PGPASSWORD=$(PG_PASSWORD) psql -h $(PG_HOST) -U $(PG_USER) -c "DROP DATABASE IF EXISTS $(PG_DATABASE)" 2>/dev/null || true
45+
46+
.PHONY: all build lint test test-reset test-clean clean db-reset db-create db-drop

README.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# pgqueue
2+
3+
PostgreSQL-backed job queue for Go, built on [pgkit](https://github.com/goware/pgkit).
4+
5+
Uses `SELECT ... FOR UPDATE SKIP LOCKED` for safe concurrent processing across multiple workers and pods. No Redis, no external broker — just Postgres.
6+
7+
## Features
8+
9+
- **Jobs** — one-shot tasks: enqueue, process, complete or fail
10+
- **Tickers** — recurring tasks: auto-created at startup, reschedule after each run, payload persists state between runs
11+
- **Generic handlers**`Job[P]` with typed payloads, zero boilerplate
12+
- **At-least-once delivery** — fenced finalization with claim tokens prevents stale workers from clobbering results
13+
- **Deduplication** — optional hash-based dedup via `ON CONFLICT DO NOTHING`
14+
- **Lease-based crash recovery** — reaper reclaims tasks from dead workers
15+
- **Graceful shutdown** — drain in-flight work with timeout
16+
17+
## Install
18+
19+
```
20+
go get github.com/goware/pgqueue
21+
```
22+
23+
## Quick Start
24+
25+
### Schema
26+
27+
Run the migration programmatically or use the embedded SQL with goose:
28+
29+
```go
30+
q := pgqueue.New(db)
31+
pgqueue.Migrate(ctx, q)
32+
```
33+
34+
### Define a Job
35+
36+
```go
37+
type SendEmailPayload struct {
38+
To string `json:"to"`
39+
Subject string `json:"subject"`
40+
Body string `json:"body"`
41+
}
42+
43+
var sendEmailSpec = pgqueue.JobSpec[SendEmailPayload]{
44+
Queue: "send-email",
45+
HashFn: func(p SendEmailPayload) *string {
46+
h := p.To + ":" + p.Subject
47+
return &h
48+
},
49+
}
50+
```
51+
52+
### Implement a Handler
53+
54+
```go
55+
type EmailHandler struct {
56+
mailer *smtp.Client
57+
}
58+
59+
func (h *EmailHandler) RunTask(ctx context.Context, job *pgqueue.Job[SendEmailPayload]) pgqueue.Result {
60+
err := h.mailer.Send(job.Payload.To, job.Payload.Subject, job.Payload.Body)
61+
if err != nil {
62+
return pgqueue.Retry(err)
63+
}
64+
return pgqueue.Done()
65+
}
66+
```
67+
68+
### Enqueue and Process
69+
70+
```go
71+
// Enqueue
72+
id, err := pgqueue.Enqueue(ctx, q, sendEmailSpec, SendEmailPayload{
73+
To: "user@example.com",
74+
Subject: "Welcome",
75+
Body: "Hello!",
76+
})
77+
78+
// Start a worker
79+
w := pgqueue.NewWorker(q)
80+
pgqueue.Register(w, sendEmailSpec, &EmailHandler{mailer: mailer})
81+
w.Start(ctx) // blocks until ctx cancelled or Stop called
82+
```
83+
84+
### Define a Ticker
85+
86+
Tickers are recurring tasks. The payload persists between runs — use it for cursors, checkpoints, or state.
87+
88+
```go
89+
type SyncPayload struct {
90+
LastSyncedID int64 `json:"last_synced_id"`
91+
}
92+
93+
var syncSpec = pgqueue.TickerSpec[SyncPayload]{
94+
Queue: "data-sync",
95+
Key: "main-sync",
96+
InitialPayload: SyncPayload{LastSyncedID: 0},
97+
Every: 5 * time.Minute,
98+
}
99+
100+
type SyncHandler struct {
101+
db *sql.DB
102+
}
103+
104+
func (h *SyncHandler) RunTick(ctx context.Context, job *pgqueue.Job[SyncPayload]) pgqueue.Result {
105+
rows, err := h.db.QueryContext(ctx, "SELECT id FROM records WHERE id > $1 LIMIT 100", job.Payload.LastSyncedID)
106+
if err != nil {
107+
return pgqueue.Retry(err)
108+
}
109+
// process rows...
110+
job.Payload.LastSyncedID = lastID // persisted on Done/Skip
111+
return pgqueue.Done()
112+
}
113+
```
114+
115+
## Result Actions
116+
117+
| Action | Jobs | Tickers | Payload persisted? |
118+
|--------|------|---------|-------------------|
119+
| `Done()` | Completed | Reschedule at `Every` | Yes |
120+
| `Retry(err)` | Retry with backoff | Retry with backoff | No |
121+
| `Fail(err)` | Permanent failure | Permanent failure | No |
122+
| `Skip(err)` | Treated as `Fail` | Reschedule at `Every` | Yes |
123+
124+
- **Retry** uses linear backoff: `try * RetryDelay`. After `MaxRetries`, jobs fail permanently; tickers reschedule at `Every`.
125+
- **Skip** is ticker-only: "this run didn't work, but try again next interval."
126+
127+
## Configuration
128+
129+
### JobSpec
130+
131+
| Field | Default | Description |
132+
|-------|---------|-------------|
133+
| `Queue` | required | Queue name |
134+
| `HashFn` | nil | Dedup key function. nil = no dedup |
135+
| `PollInterval` | 5s | How often to check for pending tasks |
136+
| `MaxRetries` | 3 | Max retry attempts. 0 = no retries, negative = use default |
137+
| `RetryDelay` | 30s | Base delay for linear backoff |
138+
| `LeaseDuration` | 5m | Claim lease for crash recovery |
139+
| `FinalizeBuffer` | 10s | Reserved time for finalization after handler |
140+
141+
### TickerSpec
142+
143+
All fields from JobSpec plus:
144+
145+
| Field | Default | Description |
146+
|-------|---------|-------------|
147+
| `Key` | required | Stable identity for upsert (unique per queue) |
148+
| `InitialPayload` | required | Payload for first-ever creation |
149+
| `Every` | required | Reschedule interval |
150+
151+
## Multi-Pod Safety
152+
153+
pgqueue is safe to run across multiple Kubernetes pods:
154+
155+
- **`SKIP LOCKED`** prevents double-processing of the same task
156+
- **Claim tokens** fence finalization — a stale worker cannot overwrite a reclaimed task
157+
- **Lease-based reaper** recovers tasks from crashed workers
158+
- **Ticker upsert** is concurrent-safe (`ON CONFLICT DO NOTHING`)
159+
160+
Handlers must be **idempotent** — at-least-once delivery means a task can be processed more than once if a worker crashes between execution and finalization.
161+
162+
## Recovery
163+
164+
```go
165+
// Re-enable a failed or disabled task
166+
q.Enable(ctx, taskID)
167+
168+
// Re-enable with a specific run time
169+
q.Requeue(ctx, taskID, time.Now().Add(1*time.Hour))
170+
171+
// Fix a poison payload
172+
pgqueue.ReplacePayloadJSON(ctx, q, taskID, NewPayload{Fixed: true})
173+
q.Enable(ctx, taskID)
174+
```
175+
176+
## License
177+
178+
Apache 2.0

0 commit comments

Comments
 (0)