Author SHA1 Message Date
SG Command f69cbea49f feat: add prometheus metrics and dashboard
Integration Tests / integration-test (pull_request) Successful in 2m33s
2026-09-19 05:12:04 +02:00
warkanum d3bce39783 ci: update golangci-lint installation path to v2
Integration Tests / integration-test (push) Successful in 1m0s
Release / Build and Release (push) Successful in 1m7s
Build & Release Docker Image / build-and-push (push) Successful in 1m2s
2026-09-18 23:10:59 +02:00
warkanum 7c8d0bdc99 feat: added application_name
Integration Tests / integration-test (push) Failing after 1m0s
2026-09-18 23:08:31 +02:00
warkanumandClaude Sonnet 5 b35017b832 docs(docker): add docker-compose example using the prebuilt image
Integration Tests / integration-test (push) Successful in 17s
Mirrors docker-compose.yml's postgres/migrate/broker stack but pulls
git.warky.dev/wdevs/pgsql-broker:latest instead of building from
source, so it needs no local Go toolchain or Dockerfile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 22:41:01 +02:00
warkanumandClaude Sonnet 5 ebe222784a fix(tests): stop racing a live broker daemon against direct-SQL tests
Integration Tests / integration-test (push) Successful in 51s
Build & Release Docker Image / build-and-push (push) Successful in 1m1s
Release / Build and Release (push) Successful in 1m11s
test-all/test-ci started the standalone pgsql-broker binary via
broker-start before running the Go integration suite, leaving its
background workers polling queue 1/2 against broker_test for the
whole run. TestBrokerWorkflow already starts its own in-process
broker and needs no external daemon, and nothing else in the suite
uses it. The redundant daemon's worker could steal-claim a job that
stage5_test.go's TestJobDependencies or
TestFailedJobRetriesThenCompletesWithoutStranding had just inserted
before the test's own broker_get call ran, intermittently failing
those assertions. Drop broker-start/broker-stop from the test
targets; the standalone targets remain for manual use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 22:04:19 +02:00
warkanumandClaude Sonnet 5 fd9ea30184 fix(ci): install Go 1.26 to match go.mod's toolchain requirement
Integration Tests / integration-test (push) Failing after 1m10s
go.mod declares "go 1.26.0" but both workflows' setup-go pinned 1.25,
causing "compile: version go1.26.0 does not match go tool version
go1.25.13" once the service-network-alias fix let tests actually run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 21:58:06 +02:00
warkanumandClaude Sonnet 5 654504e3fc fix(ci): connect integration tests via service network alias
Integration Tests / integration-test (push) Failing after 1m4s
act_runner runs ubuntu-latest jobs in their own container, so
localhost from inside the job never reaches the postgres service's
published host port (confirmed on a fresh CI run: dynamic port
32768 was assigned with no bind collision, but the connection was
still refused). Drop the host port publish and job.services context
lookup, and connect via the service alias/container port
(postgres:5432) instead, which is reachable on the shared job
network.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 21:55:05 +02:00
warkanum 63a7494982 fix(tests): update database connection strings for CI
Integration Tests / integration-test (push) Failing after 57s
* Use dynamic host and port for Postgres in tests
* Add helper functions for test database host and port
2026-09-18 21:07:28 +02:00
warkanum 21ce966d82 feat(ci): add PostgreSQL service for integration tests
Integration Tests / integration-test (push) Failing after 1s
2026-09-18 20:51:41 +02:00
warkanum 567b1d437c fix(tests): update database host from localhost to 127.0.0.1
Integration Tests / integration-test (push) Failing after 1m15s
* Adjust connection strings in integration tests for consistency
* Ensure compatibility with CI environments that use IPv4
2026-09-18 20:38:28 +02:00
warkanum cca4e1a0ef feat(tests): add PostgreSQL readiness check in test setup
Integration Tests / integration-test (push) Failing after 1m10s
2026-09-18 20:23:56 +02:00
warkanum 4f45e4c9a6 feat(ci): add Docker image build and release workflow
Integration Tests / integration-test (push) Failing after 1m36s
* Implement GitHub Actions workflow for Docker image build and release
* Validate release tags and manage Docker login credentials
* Build and push Docker images with versioning and metadata
* Add docker-build target to Makefile for local image building
2026-09-18 20:17:24 +02:00
warkanum f433c5bdb2 Merge branch 'main' of ssh://git.warky.dev/wdevs/pgsql-broker
Integration Tests / integration-test (push) Failing after 20s
2026-09-17 22:11:05 +02:00
warkanum 4c8e1066d4 feat(broker): migrations-based install, roles, RLS, and job dependency groups
Replace the ad-hoc tables/procedures install layout with versioned,
ordered SQL migrations tracked in broker_schema_migrations. Add
optional least-privilege role provisioning (--with-roles), multi-tenant
row-level security, lease-based job claiming with stale-lease recovery,
and job dependencies -- both by job id and by fan-in job group. Add
Docker/Compose support for running the broker and its test suite.
2026-09-17 22:09:41 +02:00
65 changed files with 3881 additions and 1086 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
bin
broker.log
broker.pid
plan
*.md
+11
View File
@@ -0,0 +1,11 @@
# Copy to .env (gitignored) and fill in real values.
# POSTGRES_PASSWORD is the postgres superuser password (used only by the
# one-shot `migrate` service to run `install --with-roles`).
# The three BROKER_*_PASSWORD values must match the passwords you put in
# broker.docker.yaml (copied from broker.docker.example.yaml) for the
# corresponding role -- BROKER_RUNTIME_PASSWORD in particular must match
# the `password:` field the broker service itself connects with.
POSTGRES_PASSWORD=change_me
BROKER_ADMIN_PASSWORD=change_me
BROKER_RUNTIME_PASSWORD=change_me
BROKER_ENQUEUE_PASSWORD=change_me
+74
View File
@@ -0,0 +1,74 @@
name: Build & Release Docker Image
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to release (e.g. v0.1.0)'
required: true
type: string
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE: git.warky.dev/wdevs/pgsql-broker
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
steps:
- name: Validate release tag
run: |
case "$TAG" in
v*) ;;
*) echo "Release tags must start with v (received: $TAG)" >&2; exit 1 ;;
esac
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- uses: docker/setup-buildx-action@v3
- name: Verify package registry credentials
env:
PACKAGE_REGISTRY_USERNAME: ${{ secrets.PACKAGE_REGISTRY_USERNAME }}
PACKAGE_REGISTRY_TOKEN: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
run: |
test -n "$PACKAGE_REGISTRY_USERNAME" || {
echo 'PACKAGE_REGISTRY_USERNAME is required to publish the Docker image.' >&2
exit 1
}
test -n "$PACKAGE_REGISTRY_TOKEN" || {
echo 'PACKAGE_REGISTRY_TOKEN is required to publish the Docker image.' >&2
exit 1
}
- name: Log in to the Warky container registry
uses: docker/login-action@v3
with:
registry: git.warky.dev
username: ${{ secrets.PACKAGE_REGISTRY_USERNAME }}
password: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
build-args: |
VERSION=${{ env.TAG }}
COMMIT=${{ github.sha }}
BUILD_TIME=${{ github.event.head_commit.timestamp || github.event.repository.updated_at }}
tags: |
${{ env.IMAGE }}:${{ env.TAG }}
${{ env.IMAGE }}:latest
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ env.TAG }}
+33 -8
View File
@@ -12,6 +12,19 @@ jobs:
integration-test: integration-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env:
POSTGRES_DB: broker_test
POSTGRES_USER: user
POSTGRES_PASSWORD: password
options: >-
--health-cmd="pg_isready -U user"
--health-interval=5s
--health-timeout=5s
--health-retries=10
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -19,16 +32,28 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.25' go-version: '1.26'
cache: true cache: true
- name: Set up Python - name: Check formatting
uses: actions/setup-python@v5 run: |
with: make fmt
python-version: '3.12' git diff --exit-code -- '*.go'
- name: Install podman-compose - name: Install golangci-lint
run: pip install podman-compose run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- name: Run lint
run: make lint
- name: Run all tests - name: Run all tests
run: make test-all env:
# act_runner runs this job in its own container alongside the
# postgres service container, both on the job's Docker network.
# "localhost" from inside the job container is the job container
# itself, not the runner host, so the service must be reached by
# its network alias (the services: key) and container-internal
# port -- not a published host port.
TEST_DB_HOST: postgres
TEST_DB_PORT: 5432
run: make test-ci TEST_DB_HOST="$TEST_DB_HOST" TEST_DB_PORT="$TEST_DB_PORT"
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.25' go-version: '1.26'
cache: true cache: true
- name: Get version from tag - name: Get version from tag
+2
View File
@@ -32,7 +32,9 @@ bin/
broker.yaml broker.yaml
broker.yml broker.yml
broker.json broker.json
broker.docker.yaml
!broker.example.yaml !broker.example.yaml
!broker.docker.example.yaml
# IDE # IDE
.vscode/ .vscode/
+40
View File
@@ -0,0 +1,40 @@
# syntax=docker/dockerfile:1
# ---- Build stage ----
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-w -s -X main.Version=${VERSION} -X main.Commit=${COMMIT} -X main.BuildTime=${BUILD_TIME}" \
-o /out/pgsql-broker \
./cmd/broker
# ---- Runtime stage ----
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata && \
addgroup -S broker && adduser -S -G broker -h /home/broker broker && \
mkdir -p /etc/pgsql-broker && \
chown -R broker:broker /etc/pgsql-broker
COPY --from=builder /out/pgsql-broker /usr/local/bin/pgsql-broker
USER broker
WORKDIR /home/broker
# broker.yaml is expected to be mounted at /etc/pgsql-broker/broker.yaml
# (config.LoadConfig's default search path) -- no config is baked into the
# image, since it holds database credentials.
ENTRYPOINT ["pgsql-broker"]
CMD ["start"]
+58 -18
View File
@@ -1,4 +1,4 @@
.PHONY: all build clean test test-all test-integration-go test-unit-go test-connection schema-install broker-start broker-stop install deps docker-up docker-down help .PHONY: all build clean test test-all test-ci test-integration-go test-unit-go test-connection generate-test-config schema-install broker-start broker-stop install deps docker-up docker-down docker-build release help
# Build variables # Build variables
BINARY_NAME=pgsql-broker BINARY_NAME=pgsql-broker
@@ -30,9 +30,16 @@ COMPOSE_CMD := $(shell \
# Test database connection info. Override in CI to point at a dynamically
# assigned Postgres (e.g. a services: block port), avoiding a fixed host port
# that can collide with other jobs on a shared runner.
TEST_DB_HOST ?= 127.0.0.1
TEST_DB_PORT ?= 5433
TEST_CONFIG := $(BIN_DIR)/broker.test.runtime.yaml
# Version information # Version information
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date -u '+2026-01-02_19:58:30') BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
# Inject version info # Inject version info
@@ -63,28 +70,52 @@ test-local-unit: deps ## Run local unit tests
@echo "Running local unit tests..." @echo "Running local unit tests..."
@$(GO) test -v -race -cover $(shell $(GO) list ./... | grep -v /tests/integration) @$(GO) test -v -race -cover $(shell $(GO) list ./... | grep -v /tests/integration)
test-all: test-teardown test-setup test-connection schema-install broker-start test-local-unit test-integration-go broker-stop test-teardown ## Run all unit and integration tests test-all: test-teardown test-setup test-connection schema-install test-local-unit test-integration-go test-teardown ## Run all unit and integration tests (starts its own Postgres via docker-compose)
test-ci: test-connection schema-install test-local-unit test-integration-go ## Run all unit and integration tests against an externally-provided Postgres (CI services: block)
test-connection: deps ## Test database connection with retry test-connection: deps ## Test database connection with retry
@echo "Testing database connection..." @echo "Testing database connection (host=$(TEST_DB_HOST) port=$(TEST_DB_PORT))..."
@$(GO) test -v ./tests/integration/connection_test.go @TEST_DB_HOST=$(TEST_DB_HOST) TEST_DB_PORT=$(TEST_DB_PORT) $(GO) test -v -run '^TestConnection$$' ./tests/integration/...
schema-install: build ## Install database schema using the broker CLI generate-test-config: ## (internal) render broker.test.yaml with TEST_DB_HOST/TEST_DB_PORT
@mkdir -p $(BIN_DIR)
@sed -e "s/^ host: .*/ host: $(TEST_DB_HOST)/" -e "s/^ port: .*/ port: $(TEST_DB_PORT)/" broker.test.yaml > $(TEST_CONFIG)
schema-install: build generate-test-config ## Install database schema using the broker CLI
@echo "Installing database schema..." @echo "Installing database schema..."
@$(BIN_DIR)/$(BINARY_NAME) install --config broker.test.yaml @$(BIN_DIR)/$(BINARY_NAME) install --config $(TEST_CONFIG)
test-setup: build ## Start test environment (docker-compose) test-setup: build ## Start test environment (docker-compose/podman-compose)
@echo "Starting test environment..." @echo "Starting test environment (using $(COMPOSE_CMD))..."
@podman-compose -f tests/docker-compose.yml up -d @if [ "$(CONTAINER_RUNTIME)" = "none" ]; then \
echo "Error: Neither Docker nor Podman is installed"; \
exit 1; \
fi
@$(COMPOSE_CMD) -f tests/docker-compose.yml up -d
@echo "Waiting for PostgreSQL to be ready..."
@for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \
if $(COMPOSE_CMD) -f tests/docker-compose.yml exec -T postgres pg_isready -U user > /dev/null 2>&1; then \
echo "PostgreSQL is ready"; exit 0; \
fi; \
sleep 2; \
done; \
echo "ERROR: PostgreSQL did not become ready in time"; \
$(COMPOSE_CMD) -f tests/docker-compose.yml logs postgres; \
exit 1
test-teardown: ## Stop test environment (docker-compose) test-teardown: ## Stop test environment (docker-compose/podman-compose)
@echo "Stopping test environment..." @echo "Stopping test environment (using $(COMPOSE_CMD))..."
@podman-compose -f tests/docker-compose.yml down -v --rmi all @if [ "$(CONTAINER_RUNTIME)" = "none" ]; then \
@sleep 5 # Give Docker time to release resources echo "Neither Docker nor Podman is installed, skipping teardown"; \
else \
$(COMPOSE_CMD) -f tests/docker-compose.yml down -v --rmi all || true; \
fi
@sleep 5 # Give the container runtime time to release resources
broker-start: build ## Start the broker in the background broker-start: build generate-test-config ## Start the broker in the background
@echo "Starting broker..." @echo "Starting broker..."
@setsid $(BIN_DIR)/$(BINARY_NAME) start --config broker.test.yaml > broker.log 2>&1 < /dev/null & echo $$! > broker.pid @setsid $(BIN_DIR)/$(BINARY_NAME) start --config $(TEST_CONFIG) > broker.log 2>&1 < /dev/null & echo $$! > broker.pid
@sleep 5 # Give the broker a moment to start @sleep 5 # Give the broker a moment to start
broker-stop: ## Stop the broker broker-stop: ## Stop the broker
@@ -97,8 +128,8 @@ broker-stop: ## Stop the broker
fi fi
test-integration-go: ## Run Go integration tests test-integration-go: ## Run Go integration tests
@echo "Running Go integration tests..." @echo "Running Go integration tests (host=$(TEST_DB_HOST) port=$(TEST_DB_PORT))..."
@$(GO) test -v ./tests/integration/... @TEST_DB_HOST=$(TEST_DB_HOST) TEST_DB_PORT=$(TEST_DB_PORT) $(GO) test -v ./tests/integration/...
install: build ## Install the binary to GOPATH/bin install: build ## Install the binary to GOPATH/bin
@echo "Installing to GOPATH/bin..." @echo "Installing to GOPATH/bin..."
@@ -172,6 +203,15 @@ docker-down: ## Stop PostgreSQL test database
fi fi
@echo "PostgreSQL stopped" @echo "PostgreSQL stopped"
docker-build: ## Build the pgsql-broker runtime image
@if [ "$(CONTAINER_RUNTIME)" = "none" ]; then echo "Error: Neither Docker nor Podman is installed"; exit 1; fi
@$(CONTAINER_RUNTIME) build \
--build-arg VERSION=$(VERSION) \
--build-arg COMMIT=$(COMMIT) \
--build-arg BUILD_TIME=$(BUILD_TIME) \
-t pgsql-broker:$(VERSION) -t pgsql-broker:latest \
-f Dockerfile .
release: ## Create and push a new release tag (auto-increments patch version) release: ## Create and push a new release tag (auto-increments patch version)
@echo "Creating new release..." @echo "Creating new release..."
@latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \ @latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
+194 -37
View File
@@ -1,6 +1,14 @@
# PostgreSQL Broker # PostgreSQL Broker
A robust, event-driven job processing system for PostgreSQL that uses LISTEN/NOTIFY for real-time job execution. It supports multiple queues, priority-based scheduling, and can be used both as a standalone service or as a Go library. A robust, event-driven job processing system for PostgreSQL that uses LISTEN/NOTIFY for real-time job execution. It supports multiple queues, priority-based scheduling, multi-tenant row-level security, and can be used both as a standalone service or as a Go library.
## Status
[![Integration Tests](https://git.warky.dev/wdevs/pgsql-broker/actions/workflows/integration.yml/badge.svg?branch=main)](https://git.warky.dev/wdevs/pgsql-broker/actions?workflow=integration.yml)
[![Release](https://git.warky.dev/wdevs/pgsql-broker/actions/workflows/release.yml/badge.svg?branch=main)](https://git.warky.dev/wdevs/pgsql-broker/actions?workflow=release.yml)
[![Build & Release Docker Image](https://git.warky.dev/wdevs/pgsql-broker/actions/workflows/docker-release.yml/badge.svg?branch=main)](https://git.warky.dev/wdevs/pgsql-broker/actions?workflow=docker-release.yml)
## Features ## Features
@@ -8,20 +16,23 @@ A robust, event-driven job processing system for PostgreSQL that uses LISTEN/NOT
- **Event-Driven**: Uses PostgreSQL LISTEN/NOTIFY for instant job notifications - **Event-Driven**: Uses PostgreSQL LISTEN/NOTIFY for instant job notifications
- **Multiple Queues**: Support for concurrent job processing across multiple queues per database - **Multiple Queues**: Support for concurrent job processing across multiple queues per database
- **Priority Scheduling**: Jobs can be prioritized for execution order - **Priority Scheduling**: Jobs can be prioritized for execution order
- **Job Dependencies**: Jobs can depend on other jobs being completed first - **Job Dependencies**: Jobs can depend on other jobs completing first (cycle-checked, gated in `broker_get`)
- **Multi-Tenant Row-Level Security**: Jobs are isolated per `tenant_id` via FORCE RLS policies (`broker.broker_set_tenant`)
- **Lease-Based Claiming**: Jobs are claimed with an expiring lease and recovered automatically if a worker dies mid-job
- **Least-Privilege Roles**: Optional `broker_admin` / `broker_runtime` / `broker_enqueue` role separation
- **Versioned Migrations**: Embedded, ordered SQL migrations with an applied-versions tracking table
- **Adapter Pattern**: Clean interfaces for database and logging (easy to extend) - **Adapter Pattern**: Clean interfaces for database and logging (easy to extend)
- **Standalone or Library**: Use as a CLI tool or integrate into your Go application - **Standalone or Library**: Use as a CLI tool or integrate into your Go application
- **Configuration Management**: Viper-based config with support for YAML, JSON, and environment variables - **Configuration Management**: Viper-based config with support for YAML, JSON, and environment variables
- **Graceful Shutdown**: Proper cleanup and job completion on shutdown - **Graceful Shutdown**: Proper cleanup and job completion on shutdown
- **Instance Tracking**: Monitor active broker instances through the database
- **Single Instance Per Database**: Enforces one broker instance per database to prevent conflicts - **Single Instance Per Database**: Enforces one broker instance per database to prevent conflicts
- **Embedded SQL Installer**: Database schema embedded in binary with built-in install command - **Docker Support**: Production `Dockerfile`/`docker-compose.yml` and a test-runner `Dockerfile.test`/`docker-compose.test.yml`
## Architecture ## Architecture
The broker supports multi-database architecture where a single broker process can manage multiple database connections. Each database has its own instance with dedicated queues, but only ONE broker instance is allowed per database. The broker supports multi-database architecture where a single broker process can manage multiple database connections. Each database has its own instance with dedicated queues, but only ONE broker instance is allowed per database.
``` ```text
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
│ Broker Process │ │ Broker Process │
├─────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────┤
@@ -46,17 +57,20 @@ The broker supports multi-database architecture where a single broker process ca
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ PostgreSQL (DB1) │ │ PostgreSQL (DB2) │ │ PostgreSQL (DB1) │ │ PostgreSQL (DB2) │
│ - broker_jobs │ │ - broker_jobs │ │ - broker_jobs │ │ - broker_jobs │
│ - broker_job_dependency│ │ - broker_job_dependency│
│ - broker_queueinstance│ │ - broker_queueinstance│ │ - broker_queueinstance│ │ - broker_queueinstance│
│ - broker_schedule │ │ - broker_schedule │ │ - broker_schedule │ │ - broker_schedule │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
``` ```
**Key Points**: **Key Points**:
- One broker process can manage multiple databases - One broker process can manage multiple databases
- Each database has exactly ONE active broker instance - Each database has exactly ONE active broker instance
- Each database instance has its own queues and workers - Each database instance has its own queues and workers
- Validation prevents multiple broker processes from connecting to the same database - Validation prevents multiple broker processes from connecting to the same database
- Different databases can have different queue counts - Different databases can have different queue counts
- Jobs are isolated by `tenant_id` via FORCE RLS; `broker_get` only claims jobs with no incomplete dependency
## Installation ## Installation
@@ -76,27 +90,57 @@ The binary will be available in `bin/pgsql-broker`.
go get git.warky.dev/wdevs/pgsql-broker go get git.warky.dev/wdevs/pgsql-broker
``` ```
### With Docker
See [Docker](#docker) below for the production `Dockerfile`/`docker-compose.yml` and the `Dockerfile.test`/`docker-compose.test.yml` test runner.
## Quick Start ## Quick Start
### 1. Setup Database ### 1. Setup Database
Install the required tables and stored procedures: Migrations are embedded in the binary and applied in order, tracked in `broker.broker_schema_migrations`.
```bash ```bash
# Using the CLI (recommended) # Apply all pending migrations, then verify
./bin/pgsql-broker install --config broker.yaml ./bin/pgsql-broker install --config broker.yaml
# Verify only, no changes
./bin/pgsql-broker install --verify-only --config broker.yaml
# Or with make # Or with make
make sql-install make sql-install
# Verify installation
./bin/pgsql-broker install --verify-only --config broker.yaml
# Or manually with psql:
psql -f pkg/broker/install/sql/tables/00_install.sql
psql -f pkg/broker/install/sql/procedures/00_install.sql
``` ```
#### Optional: least-privilege roles
`install --with-roles` additionally creates/rotates three roles (idempotent — safe to re-run):
| Role | Purpose |
| ---- | ------- |
| `broker_admin` | Schema owner, `BYPASSRLS`, runs migrations |
| `broker_runtime` | The role the running broker connects as; table/sequence/function privileges only, no `BYPASSRLS` |
| `broker_enqueue` | Narrow role for enqueuing jobs only (`broker_add_job`, `broker_set_tenant`, insert on `broker_jobs`/`broker_job_dependency`) |
```bash
./bin/pgsql-broker install --with-roles \
--admin-user postgres \
--broker-admin-password ... \
--broker-runtime-password ... \
--broker-enqueue-password ...
```
Credential resolution order for each value: CLI flag → environment variable → interactive masked prompt (TTY only) → error.
| Value | Flag | Env fallback(s) |
| ----- | ---- | ---------------- |
| Admin user | `--admin-user` | `PGUSER`, `PG_USER` |
| Admin password | `--admin-password` | `PGPASSWORD`, `PG_PASS` |
| `broker_admin` password | `--broker-admin-password` | `BROKER_ADMIN_PASSWORD` |
| `broker_runtime` password | `--broker-runtime-password` | `BROKER_RUNTIME_PASSWORD` |
| `broker_enqueue` password | `--broker-enqueue-password` | `BROKER_ENQUEUE_PASSWORD` |
`--with-roles` always connects as the admin login (not the config file's own user) since `broker_admin` must own the schema; it cannot be combined with `--verify-only`.
### 2. Configure ### 2. Configure
Create a configuration file `broker.yaml`: Create a configuration file `broker.yaml`:
@@ -111,6 +155,7 @@ databases:
password: your_password password: your_password
sslmode: disable sslmode: disable
queue_count: 4 queue_count: 4
tenant_id: default # optional; sets broker.tenant_id for RLS
# Optional: add more databases # Optional: add more databases
- name: db2 - name: db2
@@ -125,13 +170,15 @@ databases:
broker: broker:
name: pgsql-broker name: pgsql-broker
enable_debug: false enable_debug: false
lease_seconds: 60 # job lease duration before it's reclaimable
stale_job_recovery_sec: 30 # how often expired leases are recovered
logging: logging:
level: info level: info
format: json format: json
``` ```
**Note**: Each database requires a unique `name` identifier and can have its own `queue_count` configuration. **Note**: Each database requires a unique `name` identifier and can have its own `queue_count` configuration. See [broker.example.yaml](./broker.example.yaml) for the full field set.
### 3. Run the Broker ### 3. Run the Broker
@@ -149,18 +196,47 @@ make run
### 4. Add a Job ### 4. Add a Job
```sql ```sql
SELECT broker_add_job( SELECT * FROM broker.broker_add_job(
'My Job', -- job_name 'My Job', -- p_job_name
'SELECT do_something()', -- execute_str 'SELECT do_something()', -- p_execute_str
1, -- job_queue (default: 1) 1, -- p_job_queue (default: 1)
0, -- job_priority (default: 0) 0, -- p_job_priority (default: 0)
'sql', -- job_language (default: 'sql') 'sql', -- p_job_language (default: 'sql')
NULL, -- run_as NULL, -- p_run_as
NULL, -- user_login NULL, -- p_schedule_id
NULL -- schedule_id ARRAY[123]::BIGINT[], -- p_depends_on_job_ids (NULL = no deps)
NULL, -- p_idempotency_key
1, -- p_max_attempts (default: 1)
NULL, -- p_job_group (default: p_job_name)
ARRAY['extract'] -- p_depends_on_groups (NULL = no deps)
); );
``` ```
Or, for the common case (name, execute string, priority, dependencies), use the shortcut:
```sql
SELECT * FROM broker.broker_add_job_simple('transform', 'SELECT do_something()', 5, ARRAY['extract']);
```
#### Dependencies: job groups vs. job ids
Every job belongs to a **job group**, defaulting to its own `job_name` when `p_job_group` isn't given. A dependency can target either a whole group (`p_depends_on_groups`) or a single job id (`p_depends_on_job_ids`):
- **Group dependency** (fan-in): the dependent job is only claimable once **every** job currently tagged with that group has `complete_status = 2`. Multiple jobs can share a group (e.g. several parallel `extract` jobs feeding one `transform`); already-completed group members simply drop out of the check.
- **Id dependency**: waits on one specific job row, as before.
Both `broker_add_job` and `broker_add_job_simple` reject a job depending on its own group/id, and a one-hop cycle (A depends on B, B already depends on A).
When calling as `broker_enqueue` (or any RLS-scoped role), set the tenant first and wrap in a single transaction so `SET LOCAL` persists across statements:
```sql
BEGIN;
SET ROLE broker_enqueue;
SELECT broker.broker_set_tenant('my_tenant');
SELECT * FROM broker.broker_add_job_simple('extract', 'SELECT 1');
COMMIT;
```
## Usage as a Library ## Usage as a Library
```go ```go
@@ -194,34 +270,92 @@ func main() {
See the [examples](./examples/) directory for complete examples. See the [examples](./examples/) directory for complete examples.
## Docker
### Prebuilt image
Published to `git.warky.dev/wdevs/pgsql-broker` on every `v*.*.*` tag (`.github/workflows/docker-release.yml`), tagged with the version and `latest`.
```bash
docker pull git.warky.dev/wdevs/pgsql-broker:latest
docker run --rm -v $(pwd)/broker.yaml:/etc/pgsql-broker/broker.yaml:ro git.warky.dev/wdevs/pgsql-broker:latest
```
Config must be mounted at `/etc/pgsql-broker/broker.yaml` — no config is baked into the image.
### Prebuilt image: `docker-compose.prebuilt.yml`
Same stack as `docker-compose.yml` below, but pulls `git.warky.dev/wdevs/pgsql-broker:latest` instead of building from source — no local Go toolchain or Dockerfile needed.
```bash
cp broker.docker.example.yaml broker.docker.yaml # set broker_runtime password
cp .env.example .env # set POSTGRES_PASSWORD + 3 BROKER_*_PASSWORD
docker-compose -f docker-compose.prebuilt.yml up -d
```
### Building the image locally
```bash
make docker-build # builds pgsql-broker:<VERSION> and pgsql-broker:latest via Dockerfile
```
### Production: `Dockerfile` + `docker-compose.yml`
Multi-stage build (`golang:1.26-alpine` → `alpine:3.22`, static binary, non-root user). The compose stack runs Postgres, a one-shot `migrate` service (`install --with-roles`), then the `broker` service connecting as `broker_runtime`.
```bash
cp broker.docker.example.yaml broker.docker.yaml # set broker_runtime password
cp .env.example .env # set POSTGRES_PASSWORD + 3 BROKER_*_PASSWORD
docker-compose up --build -d
```
`broker.docker.yaml` and `.env` are gitignored — never commit real credentials. `BROKER_RUNTIME_PASSWORD` in `.env` must match the `password:` field in `broker.docker.yaml`.
### Test runner: `Dockerfile.test` + `docker-compose.test.yml`
Builds the module and runs the full Go integration suite (`tests/integration/...`) against a disposable Postgres container:
```bash
docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from tests
docker-compose -f docker-compose.test.yml down -v
```
## Database Schema ## Database Schema
Schema objects live under `broker.*` and are applied via ordered, embedded migrations (`pkg/broker/install/sql/migrations/`), tracked in `broker.broker_schema_migrations`.
### Tables ### Tables
- **broker_queueinstance**: Tracks active broker queue instances (one per database) - **broker_queueinstance**: Tracks active broker queue instances (one per database)
- **broker_jobs**: Job queue with status tracking - **broker_jobs**: Job queue with status, lease, attempt, tenant, and `job_group` tracking
- **broker_job_dependency**: Dependency edges, each targeting either a `depends_on_job_id` or a `depends_on_group`, enforced at claim time
- **broker_schedule**: Scheduled jobs (cron-like functionality) - **broker_schedule**: Scheduled jobs (cron-like functionality)
### Stored Procedures ### Stored Procedures
- **broker_get**: Fetch the next job from a queue - **broker_get**: Fetch and lease the next claimable job from a queue (skips jobs with an incomplete id- or group-dependency)
- **broker_run**: Execute a job - **broker_run**: Execute a job
- **broker_set**: Set runtime options (user, application_name, etc.) - **broker_set**: Set runtime options (user, application_name, etc.)
- **broker_add_job**: Add a new job to the queue - **broker_set_tenant**: Set the `broker.tenant_id` GUC used by RLS policies
- **broker_register_instance**: Register a broker instance - **broker_add_job**: Add a new job to the queue, with an optional `job_group` and dependencies by id and/or group
- **broker_ping_instance**: Update instance heartbeat - **broker_add_job_simple**: Shortcut for `broker_add_job` — name, execute string, priority, and dependencies by group name
- **broker_shutdown_instance**: Mark instance as shutdown - **broker_register_instance / broker_ping_instance / broker_shutdown_instance**: Instance lifecycle tracking
- **broker_recover_stale_jobs**: Reclaim jobs whose lease has expired
### Roles (optional, via `install --with-roles`)
See `pkg/broker/install/sql/roles/0001_roles.sql` — `broker_admin`, `broker_runtime`, `broker_enqueue` (see table above).
## Configuration Reference ## Configuration Reference
See [broker.example.yaml](./broker.example.yaml) for a complete configuration example. See [broker.example.yaml](./broker.example.yaml) for a complete configuration example, or [broker.docker.example.yaml](./broker.docker.example.yaml) for the Docker Compose variant.
### Database Settings ### Database Settings
The `databases` array can contain multiple database configurations. Each entry supports: The `databases` array can contain multiple database configurations. Each entry supports:
| Setting | Description | Default | | Setting | Description | Default |
|---------|-------------|---------| | ------- | ----------- | ------- |
| `name` | Unique identifier for this database | **Required** | | `name` | Unique identifier for this database | **Required** |
| `host` | PostgreSQL host | `localhost` | | `host` | PostgreSQL host | `localhost` |
| `port` | PostgreSQL port | `5432` | | `port` | PostgreSQL port | `5432` |
@@ -234,13 +368,14 @@ The `databases` array can contain multiple database configurations. Each entry s
| `conn_max_lifetime` | Connection max lifetime | `5m` | | `conn_max_lifetime` | Connection max lifetime | `5m` |
| `conn_max_idle_time` | Connection max idle time | `10m` | | `conn_max_idle_time` | Connection max idle time | `10m` |
| `queue_count` | Number of queues for this database | `4` | | `queue_count` | Number of queues for this database | `4` |
| `tenant_id` | Tenant id set for this connection (RLS) | `default` |
### Broker Settings ### Broker Settings
Global settings applied to all database instances: Global settings applied to all database instances:
| Setting | Description | Default | | Setting | Description | Default |
|---------|-------------|---------| | ------- | ----------- | ------- |
| `name` | Broker instance name | `pgsql-broker` | | `name` | Broker instance name | `pgsql-broker` |
| `fetch_query_que_size` | Jobs per fetch cycle | `100` | | `fetch_query_que_size` | Jobs per fetch cycle | `100` |
| `queue_timer_sec` | Seconds between polls | `10` | | `queue_timer_sec` | Seconds between polls | `10` |
@@ -248,6 +383,12 @@ Global settings applied to all database instances:
| `worker_idle_timeout_sec` | Worker idle timeout | `10` | | `worker_idle_timeout_sec` | Worker idle timeout | `10` |
| `notify_retry_seconds` | NOTIFY retry interval | `30s` | | `notify_retry_seconds` | NOTIFY retry interval | `30s` |
| `enable_debug` | Enable debug logging | `false` | | `enable_debug` | Enable debug logging | `false` |
| `lease_seconds` | Job lease duration before reclaimable | `60` |
| `stale_job_recovery_sec` | Interval for reclaiming expired leases | `30` |
| `metrics_enabled` | Enable Prometheus and the embedded dashboard | `true` |
| `metrics_host` | Metrics HTTP bind host | `0.0.0.0` |
| `metrics_port` | Metrics HTTP bind port | `9469` |
| `queue_depth_poll_sec` | Queue depth collection interval | `15` |
## Development ## Development
@@ -262,9 +403,19 @@ make vet # Run go vet
make test # Run tests make test # Run tests
``` ```
### Testing
```bash
go test ./... # unit tests
go test -v ./tests/integration/... # integration tests (needs local Postgres, see below)
docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from tests
```
Integration tests expect Postgres reachable at `127.0.0.1:5433` (see `tests/integration/`), including `rls_test.go` (multi-tenant isolation) and `stage5_test.go`. `127.0.0.1` is used instead of `localhost` because some CI Docker hosts publish container ports on IPv4 only, and `localhost` can resolve to `::1` first and fail.
### Project Structure ### Project Structure
``` ```text
pgsql-broker/ pgsql-broker/
├── cmd/broker/ # CLI application ├── cmd/broker/ # CLI application
├── pkg/broker/ # Core broker package ├── pkg/broker/ # Core broker package
@@ -274,16 +425,22 @@ pgsql-broker/
│ ├── queue/ # Queue management │ ├── queue/ # Queue management
│ ├── worker/ # Worker implementation │ ├── worker/ # Worker implementation
│ └── install/ # Database installer with embedded SQL │ └── install/ # Database installer with embedded SQL
│ └── sql/ # SQL schema (embedded in binary) │ └── sql/
│ ├── tables/ # Table definitions │ ├── migrations/ # Ordered, versioned schema migrations (embedded in binary)
│ └── procedures/ # Stored procedures │ └── roles/ # Optional least-privilege role DDL (--with-roles)
├── tests/integration/ # Go integration test suite
├── examples/ # Usage examples ├── examples/ # Usage examples
├── Dockerfile # Production broker image
├── Dockerfile.test # Integration test runner image
├── docker-compose.yml # Postgres + migrate + broker
├── docker-compose.test.yml # Postgres + test runner
└── Makefile # Build automation └── Makefile # Build automation
``` ```
## Contributing ## Contributing
Contributions are welcome! Please ensure: Contributions are welcome! Please ensure:
- Code is formatted with `go fmt` - Code is formatted with `go fmt`
- Tests pass with `go test ./...` - Tests pass with `go test ./...`
- Documentation is updated - Documentation is updated
+33
View File
@@ -0,0 +1,33 @@
# Config for the docker-compose.yml stack. Copy to broker.docker.yaml
# (already gitignored), fill in a real password, and it is mounted
# read-only into the broker/migrate containers at
# /etc/pgsql-broker/broker.yaml.
databases:
- name: primary
host: postgres
port: 5432
database: broker
user: broker_runtime
password: change_me
sslmode: disable
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 5m
conn_max_idle_time: 10m
queue_count: 4
broker:
name: pgsql-broker
fetch_query_que_size: 100
queue_timer_sec: 10
queue_buffer_size: 50
worker_idle_timeout_sec: 10
notify_retry_seconds: 30s
enable_debug: false
lease_seconds: 60
stale_job_recovery_sec: 30
logging:
level: info
format: json
+4
View File
@@ -41,6 +41,10 @@ broker:
worker_idle_timeout_sec: 10 # Worker idle timeout worker_idle_timeout_sec: 10 # Worker idle timeout
notify_retry_seconds: 30s # LISTEN/NOTIFY retry interval notify_retry_seconds: 30s # LISTEN/NOTIFY retry interval
enable_debug: false # Enable debug logging enable_debug: false # Enable debug logging
metrics_enabled: true # Expose Prometheus and the embedded dashboard
metrics_host: 0.0.0.0
metrics_port: 9469
queue_depth_poll_sec: 15
# Logging settings # Logging settings
logging: logging:
+1 -1
View File
@@ -1,6 +1,6 @@
databases: databases:
- name: test - name: test
host: localhost host: 127.0.0.1
port: 5433 port: 5433
database: broker_test database: broker_test
user: user user: user
+115 -7
View File
@@ -6,9 +6,12 @@ import (
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
"runtime/debug"
"syscall" "syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker" "git.warky.dev/wdevs/pgsql-broker/pkg/broker"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/config" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/config"
@@ -25,6 +28,13 @@ var (
cfgFile string cfgFile string
logLevel string logLevel string
verifyOnly bool verifyOnly bool
withRoles bool
adminUser string
adminPassword string
brokerAdminPassword string
brokerRuntimePassword string
brokerEnqueuePassword string
) )
func main() { func main() {
@@ -83,9 +93,59 @@ func init() {
// Install command flags // Install command flags
installCmd.Flags().BoolVar(&verifyOnly, "verify-only", false, "only verify installation without installing") installCmd.Flags().BoolVar(&verifyOnly, "verify-only", false, "only verify installation without installing")
installCmd.Flags().BoolVar(&withRoles, "with-roles", false, "also create/update the broker_admin, broker_runtime, and broker_enqueue roles")
installCmd.Flags().StringVar(&adminUser, "admin-user", "", "superuser/CREATEROLE login used only for --with-roles (falls back to PGUSER/PG_USER env)")
installCmd.Flags().StringVar(&adminPassword, "admin-password", "", "password for --admin-user (falls back to PGPASSWORD/PG_PASS env, then an interactive prompt)")
installCmd.Flags().StringVar(&brokerAdminPassword, "broker-admin-password", "", "password to set for broker_admin (falls back to BROKER_ADMIN_PASSWORD env, then an interactive prompt)")
installCmd.Flags().StringVar(&brokerRuntimePassword, "broker-runtime-password", "", "password to set for broker_runtime (falls back to BROKER_RUNTIME_PASSWORD env, then an interactive prompt)")
installCmd.Flags().StringVar(&brokerEnqueuePassword, "broker-enqueue-password", "", "password to set for broker_enqueue (falls back to BROKER_ENQUEUE_PASSWORD env, then an interactive prompt)")
} }
func runBroker() error { // resolveCredential returns the first non-empty value among the flag value,
// the given environment variables (checked in order), and -- if none are
// set -- an interactive masked prompt. It errors rather than prompting when
// stdin is not a terminal, since a hang in a non-interactive context (CI,
// systemd) is worse than a clear failure.
func resolveCredential(flagVal string, envNames []string, promptLabel string) (string, error) {
if flagVal != "" {
return flagVal, nil
}
for _, name := range envNames {
if v := os.Getenv(name); v != "" {
return v, nil
}
}
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
return "", fmt.Errorf(
"%s not provided and stdin is not a terminal to prompt on; pass it via flag or one of %v",
promptLabel, envNames,
)
}
fmt.Fprintf(os.Stderr, "%s: ", promptLabel)
b, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("failed to read %s: %w", promptLabel, err)
}
if len(b) == 0 {
return "", fmt.Errorf("%s must not be empty", promptLabel)
}
return string(b), nil
}
func runBroker() (err error) {
// Top-level safety net: an unrecovered panic anywhere in startup or the
// shutdown wait must not crash the process with a raw trace -- log it
// and return a normal error instead.
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered from panic in runBroker: %v\n%s", r, debug.Stack())
}
}()
// Load configuration // Load configuration
cfg, err := config.LoadConfig(cfgFile) cfg, err := config.LoadConfig(cfgFile)
if err != nil { if err != nil {
@@ -136,12 +196,51 @@ func runInstall() error {
ctx := context.Background() ctx := context.Background()
var rolePasswords install.RolePasswords
var adminUserVal, adminPasswordVal string
if withRoles {
if verifyOnly {
return fmt.Errorf("--with-roles cannot be combined with --verify-only")
}
var err error
adminUserVal, err = resolveCredential(adminUser, []string{"PGUSER", "PG_USER"}, "admin user (superuser/CREATEROLE login for --with-roles)")
if err != nil {
return err
}
adminPasswordVal, err = resolveCredential(adminPassword, []string{"PGPASSWORD", "PG_PASS"}, "admin password")
if err != nil {
return err
}
rolePasswords.AdminPassword, err = resolveCredential(brokerAdminPassword, []string{"BROKER_ADMIN_PASSWORD"}, "broker_admin password")
if err != nil {
return err
}
rolePasswords.RuntimePassword, err = resolveCredential(brokerRuntimePassword, []string{"BROKER_RUNTIME_PASSWORD"}, "broker_runtime password")
if err != nil {
return err
}
rolePasswords.EnqueuePassword, err = resolveCredential(brokerEnqueuePassword, []string{"BROKER_ENQUEUE_PASSWORD"}, "broker_enqueue password")
if err != nil {
return err
}
}
// Install/verify on all configured databases // Install/verify on all configured databases
for i, dbCfg := range cfg.Databases { for i := range cfg.Databases {
dbCfg := &cfg.Databases[i]
logger.Info("processing database", "index", i, "name", dbCfg.Name, "host", dbCfg.Host, "database", dbCfg.Database) logger.Info("processing database", "index", i, "name", dbCfg.Name, "host", dbCfg.Host, "database", dbCfg.Database)
// Create database adapter // Create database adapter. With --with-roles, the config file's own
dbAdapter := adapter.NewPostgresAdapter(dbCfg.ToPostgresConfig(), logger) // user (typically the least-privilege broker_runtime) may not exist
// yet on a fresh cluster -- migrations and role creation both run as
// the admin login instead, since broker_admin must own the schema.
pgCfg := dbCfg.ToPostgresConfig()
if withRoles {
pgCfg.User = adminUserVal
pgCfg.Password = adminPasswordVal
}
dbAdapter := adapter.NewPostgresAdapter(pgCfg, logger)
// Connect to database // Connect to database
if err := dbAdapter.Connect(ctx); err != nil { if err := dbAdapter.Connect(ctx); err != nil {
@@ -161,9 +260,9 @@ func runInstall() error {
} }
logger.Info("database schema verified successfully", "database", dbCfg.Name) logger.Info("database schema verified successfully", "database", dbCfg.Name)
} else { } else {
// Install schema // Apply migrations
logger.Info("installing database schema", "database", dbCfg.Name) logger.Info("applying database migrations", "database", dbCfg.Name)
if err := installer.InstallSchema(ctx); err != nil { if err := installer.ApplyMigrations(ctx); err != nil {
dbAdapter.Close() dbAdapter.Close()
logger.Error("installation failed", "database", dbCfg.Name, "error", err) logger.Error("installation failed", "database", dbCfg.Name, "error", err)
return fmt.Errorf("installation failed for %s: %w", dbCfg.Name, err) return fmt.Errorf("installation failed for %s: %w", dbCfg.Name, err)
@@ -180,6 +279,15 @@ func runInstall() error {
logger.Info("database schema installed and verified successfully", "database", dbCfg.Name) logger.Info("database schema installed and verified successfully", "database", dbCfg.Name)
} }
if withRoles && !verifyOnly {
logger.Info("applying roles", "database", dbCfg.Name)
if err := installer.InstallRoles(ctx, rolePasswords); err != nil {
dbAdapter.Close()
return fmt.Errorf("failed to install roles for %s: %w", dbCfg.Name, err)
}
logger.Info("roles installed successfully", "database", dbCfg.Name)
}
dbAdapter.Close() dbAdapter.Close()
} }
+46
View File
@@ -0,0 +1,46 @@
services:
postgres:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_DB: broker
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d broker"]
interval: 2s
timeout: 3s
retries: 30
restart: unless-stopped
# One-shot: applies migrations and creates/rotates the least-privilege
# broker_admin/broker_runtime/broker_enqueue roles, then exits. The
# broker service below only starts once this completes successfully.
migrate:
image: git.warky.dev/wdevs/pgsql-broker:latest
depends_on:
postgres:
condition: service_healthy
volumes:
- ./broker.docker.yaml:/etc/pgsql-broker/broker.yaml:ro
environment:
PGUSER: postgres
PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
BROKER_ADMIN_PASSWORD: ${BROKER_ADMIN_PASSWORD:?set BROKER_ADMIN_PASSWORD in .env}
BROKER_RUNTIME_PASSWORD: ${BROKER_RUNTIME_PASSWORD:?set BROKER_RUNTIME_PASSWORD in .env}
BROKER_ENQUEUE_PASSWORD: ${BROKER_ENQUEUE_PASSWORD:?set BROKER_ENQUEUE_PASSWORD in .env}
command: ["install", "--with-roles"]
restart: "no"
broker:
image: git.warky.dev/wdevs/pgsql-broker:latest
depends_on:
migrate:
condition: service_completed_successfully
volumes:
- ./broker.docker.yaml:/etc/pgsql-broker/broker.yaml:ro
restart: unless-stopped
volumes:
postgres-data:
+26
View File
@@ -0,0 +1,26 @@
services:
postgres:
image: docker.io/library/postgres:16-alpine
# Tests hardcode host=localhost port=5433, so postgres listens on 5433
# internally and the tests container joins its network namespace below.
command: ["postgres", "-p", "5433"]
environment:
POSTGRES_DB: broker_test
POSTGRES_USER: user
POSTGRES_PASSWORD: password
ports:
- "5434:5433"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d broker_test -p 5433"]
interval: 2s
timeout: 3s
retries: 30
tests:
build:
context: .
dockerfile: Dockerfile.test
network_mode: "service:postgres"
depends_on:
postgres:
condition: service_healthy
+50
View File
@@ -0,0 +1,50 @@
services:
postgres:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_DB: broker
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d broker"]
interval: 2s
timeout: 3s
retries: 30
restart: unless-stopped
# One-shot: applies migrations and creates/rotates the least-privilege
# broker_admin/broker_runtime/broker_enqueue roles, then exits. The
# broker service below only starts once this completes successfully.
migrate:
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
volumes:
- ./broker.docker.yaml:/etc/pgsql-broker/broker.yaml:ro
environment:
PGUSER: postgres
PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
BROKER_ADMIN_PASSWORD: ${BROKER_ADMIN_PASSWORD:?set BROKER_ADMIN_PASSWORD in .env}
BROKER_RUNTIME_PASSWORD: ${BROKER_RUNTIME_PASSWORD:?set BROKER_RUNTIME_PASSWORD in .env}
BROKER_ENQUEUE_PASSWORD: ${BROKER_ENQUEUE_PASSWORD:?set BROKER_ENQUEUE_PASSWORD in .env}
command: ["install", "--with-roles"]
restart: "no"
broker:
build:
context: .
dockerfile: Dockerfile
depends_on:
migrate:
condition: service_completed_successfully
volumes:
- ./broker.docker.yaml:/etc/pgsql-broker/broker.yaml:ro
restart: unless-stopped
volumes:
postgres-data:
+12 -2
View File
@@ -1,21 +1,30 @@
module git.warky.dev/wdevs/pgsql-broker module git.warky.dev/wdevs/pgsql-broker
go 1.25.5 go 1.26.0
require ( require (
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.20.5
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/term v0.46.0
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
@@ -23,7 +32,8 @@ require (
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+28 -6
View File
@@ -1,3 +1,7 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -11,18 +15,32 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
@@ -45,12 +63,16 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+5
View File
@@ -19,6 +19,11 @@ type DBAdapter interface {
// Begin starts a new transaction // Begin starts a new transaction
Begin(ctx context.Context) (DBTransaction, error) Begin(ctx context.Context) (DBTransaction, error)
// Conn returns a single physical connection pinned out of the pool, for
// session-scoped state (e.g. advisory locks) that must survive across
// calls. The caller owns it and must Close() it when done.
Conn(ctx context.Context) (*sql.Conn, error)
// Exec executes a query without returning rows // Exec executes a query without returning rows
Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
+53 -7
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
@@ -22,6 +23,10 @@ type PostgresConfig struct {
MaxIdleConns int MaxIdleConns int
ConnMaxLifetime time.Duration ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration ConnMaxIdleTime time.Duration
// ApplicationName identifies this instance's pool connections in
// pg_stat_activity (e.g. "PGSQL_BROKER_INSTANCE1"). The LISTEN
// connection appends "_LISTENER" to this value.
ApplicationName string
} }
// PostgresAdapter implements DBAdapter for PostgreSQL // PostgresAdapter implements DBAdapter for PostgreSQL
@@ -170,7 +175,7 @@ func (p *PostgresAdapter) Query(ctx context.Context, query string, args ...inter
// Listen starts listening on a PostgreSQL notification channel // Listen starts listening on a PostgreSQL notification channel
func (p *PostgresAdapter) Listen(ctx context.Context, channel string, handler NotificationHandler) error { func (p *PostgresAdapter) Listen(ctx context.Context, channel string, handler NotificationHandler) error {
connStr := p.buildConnectionString() connStr := p.buildConnectionStringWithAppName(p.config.ApplicationName + "_LISTENER")
reportProblem := func(ev pq.ListenerEventType, err error) { reportProblem := func(ev pq.ListenerEventType, err error) {
if err != nil { if err != nil {
@@ -192,8 +197,11 @@ func (p *PostgresAdapter) Listen(ctx context.Context, channel string, handler No
p.logger.Info("listening on channel", "channel", channel) p.logger.Info("listening on channel", "channel", channel)
// Start notification handler in goroutine // Start notification handler in a supervised goroutine: it must keep
go func() { // running for the life of the process, so a panic (e.g. from a
// misbehaving handler) is logged and the loop restarted rather than
// silently dying.
SupervisedGo(p.logger, "listener-"+channel, func() {
for { for {
select { select {
case n := <-listener.Notify: case n := <-listener.Notify:
@@ -208,10 +216,14 @@ func (p *PostgresAdapter) Listen(ctx context.Context, channel string, handler No
p.logger.Info("stopping listener", "channel", channel) p.logger.Info("stopping listener", "channel", channel)
return return
case <-time.After(90 * time.Second): case <-time.After(90 * time.Second):
go listener.Ping() SafeGo(p.logger, "listener-ping-"+channel, func() {
if err := listener.Ping(); err != nil {
p.logger.Error("listener ping failed", "channel", channel, "error", err)
}
})
} }
} }
}() })
return nil return nil
} }
@@ -229,24 +241,58 @@ func (p *PostgresAdapter) Unlisten(ctx context.Context, channel string) error {
return listener.Unlisten(channel) return listener.Unlisten(channel)
} }
// buildConnectionString builds a PostgreSQL connection string // buildConnectionString builds a PostgreSQL connection string for the
// pooled connection, using the adapter's own application name.
func (p *PostgresAdapter) buildConnectionString() string { func (p *PostgresAdapter) buildConnectionString() string {
return p.buildConnectionStringWithAppName(p.config.ApplicationName)
}
// buildConnectionStringWithAppName builds a PostgreSQL connection string
// with the given application_name, so pooled and LISTEN connections can be
// told apart in pg_stat_activity.
func (p *PostgresAdapter) buildConnectionStringWithAppName(appName string) string {
sslMode := p.config.SSLMode sslMode := p.config.SSLMode
if sslMode == "" { if sslMode == "" {
sslMode = "disable" sslMode = "disable"
} }
return fmt.Sprintf( return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s application_name=%s options='-c search_path=broker,public'",
p.config.Host, p.config.Host,
p.config.Port, p.config.Port,
p.config.User, p.config.User,
p.config.Password, p.config.Password,
p.config.Database, p.config.Database,
sslMode, sslMode,
quoteDSNValue(appName),
) )
} }
// quoteDSNValue escapes a value for use in a libpq keyword/value connection
// string, single-quoting it and backslash-escaping embedded backslashes and
// quotes per the libpq connection string format.
func quoteDSNValue(v string) string {
v = strings.ReplaceAll(v, `\`, `\\`)
v = strings.ReplaceAll(v, `'`, `\'`)
return "'" + v + "'"
}
// Conn returns a single physical connection pinned out of the pool, for
// session-scoped operations (e.g. pg_try_advisory_lock) that must survive
// across calls and must not be silently reaped or handed to another caller
// by the pool. The caller owns its lifecycle and must Close() it.
func (p *PostgresAdapter) Conn(ctx context.Context) (*sql.Conn, error) {
p.mu.RLock()
db := p.db
p.mu.RUnlock()
if db == nil {
return nil, fmt.Errorf("database connection not established")
}
return db.Conn(ctx)
}
// postgresTransaction implements DBTransaction // postgresTransaction implements DBTransaction
type postgresTransaction struct { type postgresTransaction struct {
tx *sql.Tx tx *sql.Tx
+56
View File
@@ -0,0 +1,56 @@
package adapter
import (
"runtime/debug"
"time"
)
// RecoverAndLog recovers a panic (if any) and logs it with a stack trace.
// Call it via `defer adapter.RecoverAndLog(logger, "name")` at the top of
// any goroutine body that must never be allowed to crash the process.
func RecoverAndLog(logger Logger, name string) {
if r := recover(); r != nil {
logger.Error("recovered from panic", "component", name, "panic", r, "stack", string(debug.Stack()))
}
}
// SafeGo runs fn in a new goroutine, recovering any panic so it can never
// crash the process. Use for one-shot/fire-and-forget goroutines; a panic is
// logged (with its stack trace) and the goroutine simply ends.
func SafeGo(logger Logger, name string, fn func()) {
go func() {
defer RecoverAndLog(logger, name)
fn()
}()
}
// SupervisedGo runs fn in a new goroutine. If fn panics, the panic is logged
// and fn is restarted (after a short backoff) instead of letting the
// goroutine die permanently. Use for long-running loops (ticker routines,
// notification listeners, worker loops) that must keep running for the life
// of the process. fn must return normally, without panicking, once its own
// shutdown/context-done condition is met -- a clean return is not restarted.
func SupervisedGo(logger Logger, name string, fn func()) {
go func() {
for {
if runSupervised(logger, name, fn) {
return
}
time.Sleep(time.Second)
}
}()
}
// runSupervised runs fn once, recovering a panic if it occurs. It returns
// true if fn returned normally (no restart needed) and false if it panicked
// (caller should restart it).
func runSupervised(logger Logger, name string, fn func()) (clean bool) {
defer func() {
if r := recover(); r != nil {
logger.Error("recovered from panic, restarting", "component", name, "panic", r, "stack", string(debug.Stack()))
clean = false
}
}()
fn()
return true
}
+28 -2
View File
@@ -4,9 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"sync" "sync"
"time"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/config" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/config"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/metrics"
) )
// Broker manages multiple database instances // Broker manages multiple database instances
@@ -17,6 +19,8 @@ type Broker struct {
instances []*DatabaseInstance instances []*DatabaseInstance
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
metrics *metrics.Metrics
server *metrics.Server
shutdown bool shutdown bool
mu sync.RWMutex mu sync.RWMutex
} }
@@ -33,6 +37,16 @@ func New(cfg *config.Config, logger adapter.Logger, version string) (*Broker, er
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
} }
if cfg.Broker.MetricsEnabled {
broker.metrics = metrics.New()
broker.metrics.SetDatabaseCount(len(cfg.Databases))
addr := fmt.Sprintf("%s:%d", cfg.Broker.MetricsHost, cfg.Broker.MetricsPort)
server, err := metrics.NewServer(broker.metrics, addr, broker.logger)
if err != nil {
return nil, err
}
broker.server = server
}
return broker, nil return broker, nil
} }
@@ -42,14 +56,15 @@ func (b *Broker) Start() error {
b.logger.Info("starting broker", "database_count", len(b.config.Databases)) b.logger.Info("starting broker", "database_count", len(b.config.Databases))
// Create and start an instance for each database // Create and start an instance for each database
for i, dbCfg := range b.config.Databases { for i := range b.config.Databases {
dbCfg := &b.config.Databases[i]
b.logger.Info("starting database instance", "name", dbCfg.Name, "host", dbCfg.Host, "database", dbCfg.Database) b.logger.Info("starting database instance", "name", dbCfg.Name, "host", dbCfg.Host, "database", dbCfg.Database)
// Create database adapter // Create database adapter
dbAdapter := adapter.NewPostgresAdapter(dbCfg.ToPostgresConfig(), b.logger) dbAdapter := adapter.NewPostgresAdapter(dbCfg.ToPostgresConfig(), b.logger)
// Create database instance // Create database instance
instance, err := NewDatabaseInstance(b.config, &dbCfg, dbAdapter, b.logger, b.version, b.ctx) instance, err := NewDatabaseInstance(b.config, dbCfg, dbAdapter, b.logger, b.version, b.ctx, b.metrics)
if err != nil { if err != nil {
// Stop any already-started instances // Stop any already-started instances
b.stopInstances() b.stopInstances()
@@ -68,6 +83,9 @@ func (b *Broker) Start() error {
} }
b.logger.Info("broker started successfully", "database_instances", len(b.instances)) b.logger.Info("broker started successfully", "database_instances", len(b.instances))
if b.server != nil {
b.server.Start()
}
return nil return nil
} }
@@ -88,6 +106,13 @@ func (b *Broker) Stop() error {
// Stop all instances // Stop all instances
b.stopInstances() b.stopInstances()
if b.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := b.server.Stop(ctx); err != nil {
b.logger.Error("failed to stop metrics server", "error", err)
}
}
b.logger.Info("broker stopped") b.logger.Info("broker stopped")
return nil return nil
@@ -100,6 +125,7 @@ func (b *Broker) stopInstances() {
wg.Add(1) wg.Add(1)
go func(inst *DatabaseInstance) { go func(inst *DatabaseInstance) {
defer wg.Done() defer wg.Done()
defer adapter.RecoverAndLog(b.logger, "stop-instance-"+inst.DatabaseName)
if err := inst.Stop(); err != nil { if err := inst.Stop(); err != nil {
b.logger.Error("failed to stop instance", "name", inst.DatabaseName, "error", err) b.logger.Error("failed to stop instance", "name", inst.DatabaseName, "error", err)
} }
+39 -3
View File
@@ -2,10 +2,11 @@ package config
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"github.com/spf13/viper"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
"github.com/spf13/viper"
) )
// Config holds all broker configuration // Config holds all broker configuration
@@ -29,6 +30,15 @@ type DatabaseConfig struct {
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"` ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"`
QueueCount int `mapstructure:"queue_count"` QueueCount int `mapstructure:"queue_count"`
// TenantID is the RLS tenant this instance's own workers operate as
// (via broker_set_tenant) when claiming/running jobs. Defaults to
// "default" so single-tenant deployments are unaffected.
TenantID string `mapstructure:"tenant_id"`
// AutoMigrate, when true, applies any pending embedded migrations on
// connect during normal `start`. When false (default), startup fails
// fast if the schema is behind, naming the missing migrations and
// pointing at `pgsql-broker install`.
AutoMigrate bool `mapstructure:"auto_migrate"`
} }
// BrokerConfig holds broker-specific settings // BrokerConfig holds broker-specific settings
@@ -38,8 +48,23 @@ type BrokerConfig struct {
QueueTimerSec int `mapstructure:"queue_timer_sec"` QueueTimerSec int `mapstructure:"queue_timer_sec"`
QueueBufferSize int `mapstructure:"queue_buffer_size"` QueueBufferSize int `mapstructure:"queue_buffer_size"`
WorkerIdleTimeoutSec int `mapstructure:"worker_idle_timeout_sec"` WorkerIdleTimeoutSec int `mapstructure:"worker_idle_timeout_sec"`
NotifyRetrySeconds time.Duration `mapstructure:"notify_retry_seconds"` NotifyRetryInterval time.Duration `mapstructure:"notify_retry_seconds"`
EnableDebug bool `mapstructure:"enable_debug"` EnableDebug bool `mapstructure:"enable_debug"`
// LeaseSeconds is how long a claimed job's lease is valid for before
// broker_recover_stale_jobs considers it abandoned.
LeaseSeconds int `mapstructure:"lease_seconds"`
// StaleJobRecoverySec is the interval between broker_recover_stale_jobs sweeps.
StaleJobRecoverySec int `mapstructure:"stale_job_recovery_sec"`
// MetricsEnabled controls whether the embedded Prometheus metrics HTTP
// server (exposition endpoint + HTML dashboard) is started.
MetricsEnabled bool `mapstructure:"metrics_enabled"`
// MetricsHost is the bind address for the metrics server.
MetricsHost string `mapstructure:"metrics_host"`
// MetricsPort is the bind port for the metrics server.
MetricsPort int `mapstructure:"metrics_port"`
// QueueDepthPollSec is the interval between polls of pending job counts
// used to populate the broker_jobs_queued gauge.
QueueDepthPollSec int `mapstructure:"queue_depth_poll_sec"`
} }
// LoggingConfig holds logging settings // LoggingConfig holds logging settings
@@ -103,6 +128,12 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("broker.worker_idle_timeout_sec", 10) v.SetDefault("broker.worker_idle_timeout_sec", 10)
v.SetDefault("broker.notify_retry_seconds", 30*time.Second) v.SetDefault("broker.notify_retry_seconds", 30*time.Second)
v.SetDefault("broker.enable_debug", false) v.SetDefault("broker.enable_debug", false)
v.SetDefault("broker.lease_seconds", 60)
v.SetDefault("broker.stale_job_recovery_sec", 30)
v.SetDefault("broker.metrics_enabled", true)
v.SetDefault("broker.metrics_host", "0.0.0.0")
v.SetDefault("broker.metrics_port", 9469)
v.SetDefault("broker.queue_depth_poll_sec", 15)
// Logging defaults // Logging defaults
v.SetDefault("logging.level", "info") v.SetDefault("logging.level", "info")
@@ -116,7 +147,8 @@ func validateConfig(config *Config) error {
} }
// Validate each database configuration // Validate each database configuration
for i, db := range config.Databases { for i := range config.Databases {
db := &config.Databases[i]
if db.Name == "" { if db.Name == "" {
return fmt.Errorf("database[%d]: name is required", i) return fmt.Errorf("database[%d]: name is required", i)
} }
@@ -160,6 +192,9 @@ func applyDatabaseDefaults(config *Config) {
if db.QueueCount == 0 { if db.QueueCount == 0 {
db.QueueCount = 4 db.QueueCount = 4
} }
if db.TenantID == "" {
db.TenantID = "default"
}
} }
} }
@@ -176,5 +211,6 @@ func (d *DatabaseConfig) ToPostgresConfig() adapter.PostgresConfig {
MaxIdleConns: d.MaxIdleConns, MaxIdleConns: d.MaxIdleConns,
ConnMaxLifetime: d.ConnMaxLifetime, ConnMaxLifetime: d.ConnMaxLifetime,
ConnMaxIdleTime: d.ConnMaxIdleTime, ConnMaxIdleTime: d.ConnMaxIdleTime,
ApplicationName: fmt.Sprintf("PGSQL_BROKER_%s", strings.ToUpper(d.Name)),
} }
} }
+224 -72
View File
@@ -2,15 +2,18 @@ package broker
import ( import (
"context" "context"
"database/sql" // Import sql package "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/config" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/config"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/install"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/metrics"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/models" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/models"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/queue" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/queue"
) )
@@ -34,15 +37,26 @@ type DatabaseInstance struct {
shutdownMu sync.RWMutex shutdownMu sync.RWMutex
jobsHandled int64 jobsHandled int64
startTime time.Time startTime time.Time
metrics *metrics.Metrics
// sessionConn holds the pg_try_advisory_lock acquired by
// registerInstance. The lock is scoped to this one physical connection,
// so it must be kept open (never returned to the pool) for the life of
// the process and explicitly unlocked on Stop().
sessionConn *sql.Conn
} }
// NewDatabaseInstance creates a new database instance // NewDatabaseInstance creates a new database instance
func NewDatabaseInstance(cfg *config.Config, dbCfg *config.DatabaseConfig, db adapter.DBAdapter, logger adapter.Logger, version string, parentCtx context.Context) (*DatabaseInstance, error) { func NewDatabaseInstance(cfg *config.Config, dbCfg *config.DatabaseConfig, db adapter.DBAdapter, logger adapter.Logger, version string, parentCtx context.Context, brokerMetrics ...*metrics.Metrics) (*DatabaseInstance, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {
hostname = "unknown" hostname = "unknown"
} }
var instanceMetrics *metrics.Metrics
if len(brokerMetrics) > 0 {
instanceMetrics = brokerMetrics[0]
}
instance := &DatabaseInstance{ instance := &DatabaseInstance{
Name: fmt.Sprintf("%s-%s", cfg.Broker.Name, dbCfg.Name), Name: fmt.Sprintf("%s-%s", cfg.Broker.Name, dbCfg.Name),
DatabaseName: dbCfg.Name, DatabaseName: dbCfg.Name,
@@ -56,6 +70,7 @@ func NewDatabaseInstance(cfg *config.Config, dbCfg *config.DatabaseConfig, db ad
queues: make(map[int]*queue.Queue), queues: make(map[int]*queue.Queue),
ctx: parentCtx, ctx: parentCtx,
startTime: time.Now(), startTime: time.Now(),
metrics: instanceMetrics,
} }
return instance, nil return instance, nil
@@ -70,6 +85,11 @@ func (i *DatabaseInstance) Start() error {
return fmt.Errorf("failed to connect to database: %w", err) return fmt.Errorf("failed to connect to database: %w", err)
} }
// Ensure the schema is up to date before touching any broker objects.
if err := i.ensureSchema(); err != nil {
return err
}
// Register instance in database // Register instance in database
if err := i.registerInstance(); err != nil { if err := i.registerInstance(); err != nil {
return fmt.Errorf("failed to register instance: %w", err) return fmt.Errorf("failed to register instance: %w", err)
@@ -87,10 +107,90 @@ func (i *DatabaseInstance) Start() error {
return fmt.Errorf("failed to start listener: %w", err) return fmt.Errorf("failed to start listener: %w", err)
} }
// Start ping routine // Start ping routine (auto-restarted on panic; must run for the life of
go i.pingRoutine() // the process)
adapter.SupervisedGo(i.logger, "ping-routine", i.pingRoutine)
// Start stale/expired-lease job recovery routine (auto-restarted on
// panic; must run for the life of the process)
adapter.SupervisedGo(i.logger, "stale-job-recovery-routine", i.staleJobRecoveryRoutine)
i.logger.Info("database instance started successfully") i.logger.Info("database instance started successfully")
if i.metrics != nil {
adapter.SupervisedGo(i.logger, "metrics-queue-depth-routine", i.queueDepthRoutine)
}
return nil
}
// queueDepthRoutine periodically exports pending jobs grouped by queue.
func (i *DatabaseInstance) queueDepthRoutine() {
interval := time.Duration(i.config.Broker.QueueDepthPollSec) * time.Second
if interval <= 0 {
interval = 15 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
i.updateQueueDepthMetrics()
for {
select {
case <-ticker.C:
i.updateQueueDepthMetrics()
case <-i.ctx.Done():
return
}
}
}
func (i *DatabaseInstance) updateQueueDepthMetrics() {
rows, err := i.db.Query(i.ctx, "SELECT job_queue, COUNT(*) FROM broker.broker_jobs WHERE complete_status = 0 GROUP BY job_queue")
if err != nil {
i.logger.Warn("failed to collect queue depth metrics", "error", err)
return
}
defer rows.Close()
for queueNumber := 1; queueNumber <= i.dbConfig.QueueCount; queueNumber++ {
i.metrics.SetJobsQueued(i.DatabaseName, queueNumber, 0)
}
for rows.Next() {
var queueNumber, count int
if err := rows.Scan(&queueNumber, &count); err != nil {
i.logger.Warn("failed to scan queue depth metric", "error", err)
return
}
i.metrics.SetJobsQueued(i.DatabaseName, queueNumber, float64(count))
}
if err := rows.Err(); err != nil {
i.logger.Warn("failed to read queue depth metrics", "error", err)
}
}
// ensureSchema checks the embedded migration set against the database and,
// depending on dbConfig.AutoMigrate, either applies pending migrations or
// fails startup fast rather than running against a stale/missing schema.
func (i *DatabaseInstance) ensureSchema() error {
installer := install.New(i.db, i.logger)
pending, err := installer.PendingMigrations(i.ctx)
if err != nil {
return fmt.Errorf("failed to check schema migrations: %w", err)
}
if len(pending) == 0 {
return nil
}
if !i.dbConfig.AutoMigrate {
return fmt.Errorf(
"schema is missing or behind: %d migration(s) not applied (%s); either run `pgsql-broker install` or set databases[].auto_migrate: true",
len(pending), strings.Join(pending, ", "),
)
}
i.logger.Info("auto-migrating database schema", "pending", pending)
if err := installer.ApplyMigrations(i.ctx); err != nil {
return fmt.Errorf("auto-migration failed: %w", err)
}
return nil return nil
} }
@@ -116,10 +216,26 @@ func (i *DatabaseInstance) Stop() error {
} }
i.queuesMu.Unlock() i.queuesMu.Unlock()
// Update instance status in database // Update instance status in database. i.ctx may already be canceled by
if err := i.shutdownInstance(); err != nil { // the parent broker's Stop(), so use a fresh short-lived context here.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := i.shutdownInstance(shutdownCtx); err != nil {
i.logger.Error("failed to shutdown instance in database", "error", err) i.logger.Error("failed to shutdown instance in database", "error", err)
} }
cancel()
// Release the advisory lock and close the pinned session connection.
if i.sessionConn != nil {
if _, err := i.sessionConn.ExecContext(context.Background(),
"SELECT pg_advisory_unlock(hashtextextended($1, 0))", "broker:"+i.Name,
); err != nil {
i.logger.Error("failed to release advisory lock", "error", err)
}
if err := i.sessionConn.Close(); err != nil {
i.logger.Error("failed to close session connection", "error", err)
}
i.sessionConn = nil
}
// Close database connection // Close database connection
if err := i.db.Close(); err != nil { if err := i.db.Close(); err != nil {
@@ -130,72 +246,48 @@ func (i *DatabaseInstance) Stop() error {
return nil return nil
} }
// registerInstance registers the instance in the database // registerInstance registers the instance in the database. The advisory
// lock taken by broker_register_instance is session-scoped, so this runs on
// a connection pinned out of the pool (i.sessionConn) that is kept open for
// the life of the process rather than returned after this call.
func (i *DatabaseInstance) registerInstance() error { func (i *DatabaseInstance) registerInstance() error {
conn, err := i.db.Conn(i.ctx)
if err != nil {
return fmt.Errorf("failed to acquire session connection: %w", err)
}
var retval int var retval int
var errmsg string var errmsg string
var nullableInstanceID sql.NullInt64 // Change to nullable type var nullableInstanceID sql.NullInt64
i.logger.Debug("registering instance", "name", i.Name, "hostname", i.Hostname, "pid", i.PID, "version", i.Version, "queue_count", i.dbConfig.QueueCount) i.logger.Debug("registering instance", "name", i.Name, "hostname", i.Hostname, "pid", i.PID, "version", i.Version, "queue_count", i.dbConfig.QueueCount)
err := i.db.QueryRow(i.ctx, err = conn.QueryRowContext(i.ctx,
"SELECT p_retval, p_errmsg, p_instance_id FROM broker_register_instance($1, $2, $3, $4, $5)", "SELECT p_retval, p_errmsg, p_instance_id FROM broker.broker_register_instance($1, $2, $3, $4, $5)",
i.Name, i.Hostname, i.PID, i.Version, i.dbConfig.QueueCount, i.Name, i.Hostname, i.PID, i.Version, i.dbConfig.QueueCount,
).Scan(&retval, &errmsg, &nullableInstanceID) ).Scan(&retval, &errmsg, &nullableInstanceID)
if err != nil { if err != nil {
conn.Close()
i.logger.Error("query error during instance registration", "error", err) i.logger.Error("query error during instance registration", "error", err)
return fmt.Errorf("query error: %w", err) return fmt.Errorf("query error: %w", err)
} }
if retval == 3 { if retval > 0 {
i.logger.Warn("another broker instance is already active, attempting to retrieve ID", "error", errmsg) conn.Close()
// Try to retrieve the ID of the active instance
var activeID int64
err := i.db.QueryRow(i.ctx,
"SELECT id_broker_queueinstance FROM broker_queueinstance WHERE name = $1 AND hostname = $2 AND status = 'active' ORDER BY started_at DESC LIMIT 1",
i.Name, i.Hostname,
).Scan(&activeID)
if err != nil {
i.logger.Error("failed to retrieve ID of active instance", "error", err)
return fmt.Errorf("failed to retrieve ID of active instance: %w", err)
}
i.ID = activeID
i.logger.Info("retrieved active instance ID", "id", i.ID)
return nil
} else if retval > 0 {
i.logger.Error("broker_register_instance error", "retval", retval, "errmsg", errmsg) i.logger.Error("broker_register_instance error", "retval", retval, "errmsg", errmsg)
return fmt.Errorf("broker_register_instance error: %s", errmsg) return fmt.Errorf("broker_register_instance error: %s", errmsg)
} }
// If successfully registered, nullableInstanceID.Valid will be true if !nullableInstanceID.Valid {
if nullableInstanceID.Valid { conn.Close()
i.ID = nullableInstanceID.Int64
i.logger.Info("registered new instance", "id", i.ID)
// Debug logging: Retrieve all entries from broker_queueinstance
rows, err := i.db.Query(i.ctx, "SELECT id_broker_queueinstance, name, hostname, status FROM broker_queueinstance")
if err != nil {
i.logger.Error("debug query failed", "error", err)
} else {
defer rows.Close()
for rows.Next() {
var id int64
var name, hostname, status string
if err := rows.Scan(&id, &name, &hostname, &status); err != nil {
i.logger.Error("debug scan failed", "error", err)
break
}
i.logger.Debug("broker_queueinstance entry", "id", id, "name", name, "hostname", hostname, "status", status)
}
}
} else {
// This case should ideally not happen if retval is 0 (success)
// but if it does, it means p_instance_id was NULL despite success.
// This would be an unexpected scenario.
i.logger.Error("broker_register_instance returned success but no instance ID", "retval", retval, "errmsg", errmsg) i.logger.Error("broker_register_instance returned success but no instance ID", "retval", retval, "errmsg", errmsg)
return fmt.Errorf("broker_register_instance returned success but no instance ID") return fmt.Errorf("broker_register_instance returned success but no instance ID")
} }
i.ID = nullableInstanceID.Int64
i.sessionConn = conn
i.logger.Info("registered new instance", "id", i.ID)
return nil return nil
} }
@@ -204,6 +296,11 @@ func (i *DatabaseInstance) startQueues() error {
i.queuesMu.Lock() i.queuesMu.Lock()
defer i.queuesMu.Unlock() defer i.queuesMu.Unlock()
leaseSeconds := i.config.Broker.LeaseSeconds
if leaseSeconds <= 0 {
leaseSeconds = 60
}
for queueNum := 1; queueNum <= i.dbConfig.QueueCount; queueNum++ { for queueNum := 1; queueNum <= i.dbConfig.QueueCount; queueNum++ {
queueCfg := queue.Config{ queueCfg := queue.Config{
Number: queueNum, Number: queueNum,
@@ -214,6 +311,10 @@ func (i *DatabaseInstance) startQueues() error {
BufferSize: i.config.Broker.QueueBufferSize, BufferSize: i.config.Broker.QueueBufferSize,
TimerSeconds: i.config.Broker.QueueTimerSec, TimerSeconds: i.config.Broker.QueueTimerSec,
FetchSize: i.config.Broker.FetchQueryQueSize, FetchSize: i.config.Broker.FetchQueryQueSize,
TenantID: i.dbConfig.TenantID,
LeaseSeconds: leaseSeconds,
Metrics: i.metrics,
DatabaseName: i.DatabaseName,
} }
q := queue.New(queueCfg) q := queue.New(queueCfg)
@@ -224,6 +325,7 @@ func (i *DatabaseInstance) startQueues() error {
i.queues[queueNum] = q i.queues[queueNum] = q
i.logger.Info("queue started", "number", queueNum) i.logger.Info("queue started", "number", queueNum)
} }
i.metrics.SetQueueCount(i.DatabaseName, len(i.queues))
return nil return nil
} }
@@ -241,42 +343,38 @@ func (i *DatabaseInstance) startListener() error {
return nil return nil
} }
// handleNotification processes incoming job notifications // handleNotification processes incoming wake-up notifications. The payload
// only carries the queue number (plus a job id kept for logging) --
// NOTIFY is wake-only, never a hand-off of a job to execute directly, so the
// woken worker always re-claims via broker_get.
func (i *DatabaseInstance) handleNotification(n *adapter.Notification) { func (i *DatabaseInstance) handleNotification(n *adapter.Notification) {
defer adapter.RecoverAndLog(i.logger, "handle-notification")
if i.config.Broker.EnableDebug { if i.config.Broker.EnableDebug {
i.logger.Debug("received notification", "channel", n.Channel, "payload", n.Payload) i.logger.Debug("received notification", "channel", n.Channel, "payload", n.Payload)
} }
var job models.Job var wake models.WakeNotification
if err := json.Unmarshal([]byte(n.Payload), &job); err != nil { if err := json.Unmarshal([]byte(n.Payload), &wake); err != nil {
i.logger.Error("failed to unmarshal notification", "error", err, "payload", n.Payload) i.logger.Error("failed to unmarshal notification", "error", err, "payload", n.Payload)
return return
} }
if job.ID <= 0 { if wake.Queue <= 0 {
i.logger.Warn("notification missing job ID", "payload", n.Payload) i.logger.Warn("notification missing queue number", "payload", n.Payload)
return return
} }
if job.JobQueue <= 0 {
i.logger.Warn("notification missing queue number", "job_id", job.ID)
return
}
// Get the queue
i.queuesMu.RLock() i.queuesMu.RLock()
q, exists := i.queues[job.JobQueue] q, exists := i.queues[wake.Queue]
i.queuesMu.RUnlock() i.queuesMu.RUnlock()
if !exists { if !exists {
i.logger.Warn("queue not found for job", "job_id", job.ID, "queue", job.JobQueue) i.logger.Warn("queue not found for notification", "queue", wake.Queue, "job_id", wake.JobID)
return return
} }
// Add job to queue q.Wake()
if err := q.AddJob(job); err != nil {
i.logger.Error("failed to add job to queue", "job_id", job.ID, "queue", job.JobQueue, "error", err)
}
} }
// pingRoutine periodically updates the instance status in the database // pingRoutine periodically updates the instance status in the database
@@ -304,13 +402,67 @@ func (i *DatabaseInstance) pingRoutine() {
} }
} }
// staleJobRecoveryRoutine periodically requeues (or dead-letters) jobs whose
// lease has expired while still running.
func (i *DatabaseInstance) staleJobRecoveryRoutine() {
interval := time.Duration(i.config.Broker.StaleJobRecoverySec) * time.Second
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
i.shutdownMu.RLock()
if i.shutdown {
i.shutdownMu.RUnlock()
return
}
i.shutdownMu.RUnlock()
if err := i.recoverStaleJobs(); err != nil {
i.logger.Error("stale job recovery failed", "error", err)
}
case <-i.ctx.Done():
return
}
}
}
// recoverStaleJobs invokes broker_recover_stale_jobs.
func (i *DatabaseInstance) recoverStaleJobs() error {
var retval int
var errmsg string
var recoveredCount int
err := i.db.QueryRow(i.ctx, "SELECT p_retval, p_errmsg, p_recovered_count FROM broker.broker_recover_stale_jobs()").
Scan(&retval, &errmsg, &recoveredCount)
if err != nil {
return fmt.Errorf("query error: %w", err)
}
if retval > 0 {
return fmt.Errorf("broker_recover_stale_jobs error: %s", errmsg)
}
if recoveredCount > 0 {
i.logger.Info("recovered stale jobs", "count", recoveredCount)
}
return nil
}
// ping updates the instance ping timestamp // ping updates the instance ping timestamp
func (i *DatabaseInstance) ping() error { func (i *DatabaseInstance) ping() error {
var retval int var retval int
var errmsg string var errmsg string
err := i.db.QueryRow(i.ctx, err := i.db.QueryRow(i.ctx,
"SELECT p_retval, p_errmsg FROM broker_ping_instance($1, $2)", "SELECT p_retval, p_errmsg FROM broker.broker_ping_instance($1, $2)",
i.ID, i.jobsHandled, i.ID, i.jobsHandled,
).Scan(&retval, &errmsg) ).Scan(&retval, &errmsg)
@@ -326,12 +478,12 @@ func (i *DatabaseInstance) ping() error {
} }
// shutdownInstance marks the instance as shutdown in the database // shutdownInstance marks the instance as shutdown in the database
func (i *DatabaseInstance) shutdownInstance() error { func (i *DatabaseInstance) shutdownInstance(ctx context.Context) error {
var retval int var retval int
var errmsg string var errmsg string
err := i.db.QueryRow(i.ctx, err := i.db.QueryRow(ctx,
"SELECT p_retval, p_errmsg FROM broker_shutdown_instance($1)", "SELECT p_retval, p_errmsg FROM broker.broker_shutdown_instance($1)",
i.ID, i.ID,
).Scan(&retval, &errmsg) ).Scan(&retval, &errmsg)
+381 -145
View File
@@ -4,17 +4,47 @@ import (
"context" "context"
"embed" "embed"
"fmt" "fmt"
"io/fs" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/lib/pq"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
) )
//go:embed all:sql //go:embed all:sql/migrations
var sqlFS embed.FS var migrationsFS embed.FS
// Installer handles database schema installation //go:embed all:sql/roles
var rolesFS embed.FS
const migrationsDir = "sql/migrations"
const rolesDir = "sql/roles"
// migrationsTableSQL creates the version-tracking table itself. It is applied
// unconditionally (idempotently) before any numbered migration file, and is
// not itself a numbered migration.
const migrationsTableSQL = `
CREATE SCHEMA IF NOT EXISTS broker;
CREATE TABLE IF NOT EXISTS broker.broker_schema_migrations (
version BIGINT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`
var migrationFileRe = regexp.MustCompile(`^(\d+)_(.+)\.sql$`)
// migrationFile describes one embedded migration.
type migrationFile struct {
version int64
name string
path string
}
// Installer handles database schema installation via versioned migrations.
type Installer struct { type Installer struct {
db adapter.DBAdapter db adapter.DBAdapter
logger adapter.Logger logger adapter.Logger
@@ -28,217 +58,423 @@ func New(db adapter.DBAdapter, logger adapter.Logger) *Installer {
} }
} }
// InstallSchema installs the complete database schema // loadMigrations reads and sorts every embedded migration file by numeric prefix.
func (i *Installer) InstallSchema(ctx context.Context) error { func loadMigrations() ([]migrationFile, error) {
i.logger.Info("starting schema installation") entries, err := migrationsFS.ReadDir(migrationsDir)
if err != nil {
// Install tables first return nil, fmt.Errorf("failed to read migrations directory: %w", err)
if err := i.installTables(ctx); err != nil {
return fmt.Errorf("failed to install tables: %w", err)
} }
// Then install procedures var migrations []migrationFile
if err := i.installProcedures(ctx); err != nil { for _, e := range entries {
return fmt.Errorf("failed to install procedures: %w", err) if e.IsDir() {
continue
}
m := migrationFileRe.FindStringSubmatch(e.Name())
if m == nil {
continue
}
version, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid migration filename %s: %w", e.Name(), err)
}
migrations = append(migrations, migrationFile{
version: version,
name: m[2],
path: migrationsDir + "/" + e.Name(),
})
} }
i.logger.Info("schema installation completed successfully") sort.Slice(migrations, func(i, j int) bool { return migrations[i].version < migrations[j].version })
return migrations, nil
}
// ensureMigrationsTable creates the broker schema and the migrations
// tracking table if they don't already exist. This is DDL and requires
// CREATE privilege on the database -- only ApplyMigrations (run by an
// admin-privileged connection, e.g. `pgsql-broker install`) calls it.
func (i *Installer) ensureMigrationsTable(ctx context.Context) error {
if _, err := i.db.Exec(ctx, migrationsTableSQL); err != nil {
return fmt.Errorf("failed to ensure migrations table: %w", err)
}
return nil return nil
} }
// installTables installs all table definitions // migrationsTableExists reports whether the migrations tracking table is
func (i *Installer) installTables(ctx context.Context) error { // present, without creating it -- a read-only check safe to run with a
i.logger.Info("installing tables") // least-privilege runtime role (e.g. broker_runtime) that has no CREATE
// privilege on the database.
files, err := sqlFS.ReadDir("sql/tables") func (i *Installer) migrationsTableExists(ctx context.Context) (bool, error) {
var exists bool
err := i.db.QueryRow(ctx, "SELECT to_regclass('broker.broker_schema_migrations') IS NOT NULL").Scan(&exists)
if err != nil { if err != nil {
return fmt.Errorf("failed to read tables directory: %w", err) return false, fmt.Errorf("failed to check migrations table: %w", err)
}
return exists, nil
} }
// Filter and sort SQL files // appliedVersions returns the set of migration versions already recorded.
sqlFiles := filterAndSortSQLFiles(files) func (i *Installer) appliedVersions(ctx context.Context) (map[int64]bool, error) {
rows, err := i.db.Query(ctx, "SELECT version FROM broker.broker_schema_migrations")
if err != nil {
return nil, fmt.Errorf("failed to query applied migrations: %w", err)
}
defer rows.Close()
for _, file := range sqlFiles { applied := make(map[int64]bool)
// Skip install script for rows.Next() {
if file == "00_install.sql" { var v int64
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("failed to scan migration version: %w", err)
}
applied[v] = true
}
return applied, rows.Err()
}
// PendingMigrations returns the names of embedded migrations that have not
// yet been applied to the database, without applying them or creating the
// migrations table -- safe to call with a least-privilege runtime role.
func (i *Installer) PendingMigrations(ctx context.Context) ([]string, error) {
migrations, err := loadMigrations()
if err != nil {
return nil, err
}
exists, err := i.migrationsTableExists(ctx)
if err != nil {
return nil, err
}
if !exists {
pending := make([]string, len(migrations))
for idx, m := range migrations {
pending[idx] = fmt.Sprintf("%04d_%s", m.version, m.name)
}
return pending, nil
}
applied, err := i.appliedVersions(ctx)
if err != nil {
return nil, err
}
var pending []string
for _, m := range migrations {
if !applied[m.version] {
pending = append(pending, fmt.Sprintf("%04d_%s", m.version, m.name))
}
}
return pending, nil
}
// ApplyMigrations applies every embedded migration that has not yet been
// recorded in broker.broker_schema_migrations, each inside its own transaction.
func (i *Installer) ApplyMigrations(ctx context.Context) error {
i.logger.Info("applying migrations")
if err := i.ensureMigrationsTable(ctx); err != nil {
return err
}
migrations, err := loadMigrations()
if err != nil {
return err
}
applied, err := i.appliedVersions(ctx)
if err != nil {
return err
}
appliedCount := 0
for _, m := range migrations {
if applied[m.version] {
continue continue
} }
i.logger.Info("executing table script", "file", file) content, err := migrationsFS.ReadFile(m.path)
content, err := sqlFS.ReadFile("sql/tables/" + file)
if err != nil { if err != nil {
return fmt.Errorf("failed to read file %s: %w", file, err) return fmt.Errorf("failed to read migration %s: %w", m.path, err)
} }
if err := i.executeSQL(ctx, string(content)); err != nil { i.logger.Info("applying migration", "version", m.version, "name", m.name)
return fmt.Errorf("failed to execute %s: %w", file, err)
} tx, err := i.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction for migration %s: %w", m.name, err)
} }
i.logger.Info("tables installed successfully") if err := execStatements(ctx, tx, string(content)); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
i.logger.Error("failed to rollback migration transaction", "version", m.version, "error", rbErr)
}
return fmt.Errorf("failed to apply migration %s: %w", m.name, err)
}
if _, err := tx.Exec(ctx,
"INSERT INTO broker.broker_schema_migrations (version, name) VALUES ($1, $2)",
m.version, m.name,
); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
i.logger.Error("failed to rollback migration transaction", "version", m.version, "error", rbErr)
}
return fmt.Errorf("failed to record migration %s: %w", m.name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration %s: %w", m.name, err)
}
appliedCount++
}
if appliedCount == 0 {
i.logger.Info("no pending migrations")
} else {
i.logger.Info("migrations applied successfully", "count", appliedCount)
}
return nil return nil
} }
// installProcedures installs all stored procedures // RolePasswords holds the login passwords for the reference broker_admin,
func (i *Installer) installProcedures(ctx context.Context) error { // broker_runtime, and broker_enqueue roles created by InstallRoles. All
i.logger.Info("installing procedures") // three are required -- there is no placeholder/default fallback, since
// these roles carry real database privileges.
type RolePasswords struct {
AdminPassword string
RuntimePassword string
EnqueuePassword string
}
files, err := sqlFS.ReadDir("sql/procedures") // InstallRoles applies the embedded role/grant scripts (sql/roles), which
// create (or, if already present, rotate the password of) broker_admin,
// broker_runtime, and broker_enqueue, then grant them the appropriate
// schema/table/function privileges. The caller must connect as a superuser
// or a role with CREATEROLE -- this is intentionally separate from the
// migration-running connection.
func (i *Installer) InstallRoles(ctx context.Context, passwords RolePasswords) error {
if passwords.AdminPassword == "" || passwords.RuntimePassword == "" || passwords.EnqueuePassword == "" {
return fmt.Errorf("all three role passwords (admin, runtime, enqueue) are required")
}
entries, err := rolesFS.ReadDir(rolesDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to read procedures directory: %w", err) return fmt.Errorf("failed to read roles directory: %w", err)
} }
// Filter and sort SQL files var names []string
sqlFiles := filterAndSortSQLFiles(files) for _, e := range entries {
if !e.IsDir() {
for _, file := range sqlFiles { names = append(names, e.Name())
// Skip install script
if file == "00_install.sql" {
continue
} }
}
sort.Strings(names)
i.logger.Info("executing procedure script", "file", file) replacer := strings.NewReplacer(
"__BROKER_ADMIN_PASSWORD__", pq.QuoteLiteral(passwords.AdminPassword),
"__BROKER_RUNTIME_PASSWORD__", pq.QuoteLiteral(passwords.RuntimePassword),
"__BROKER_ENQUEUE_PASSWORD__", pq.QuoteLiteral(passwords.EnqueuePassword),
)
content, err := sqlFS.ReadFile("sql/procedures/" + file) for _, name := range names {
content, err := rolesFS.ReadFile(rolesDir + "/" + name)
if err != nil { if err != nil {
return fmt.Errorf("failed to read file %s: %w", file, err) return fmt.Errorf("failed to read roles script %s: %w", name, err)
} }
if err := i.executeSQL(ctx, string(content)); err != nil { i.logger.Info("applying roles script", "name", name)
return fmt.Errorf("failed to execute %s: %w", file, err)
tx, err := i.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction for roles script %s: %w", name, err)
}
// pq.QuoteLiteral already produces a safely quoted SQL string
// literal (doubling embedded quotes, or switching to E'...' escape
// syntax if the password contains a backslash), so this is a plain
// textual substitution, not string concatenation of untrusted input
// into SQL syntax.
rendered := replacer.Replace(string(content))
if err := execStatements(ctx, tx, rendered); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
i.logger.Error("failed to rollback roles script transaction", "name", name, "error", rbErr)
}
return fmt.Errorf("failed to apply roles script %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit roles script %s: %w", name, err)
} }
} }
i.logger.Info("procedures installed successfully") i.logger.Info("roles installed successfully")
return nil return nil
} }
// executeSQL executes SQL statements // execStatements runs every statement in sql within tx.
func (i *Installer) executeSQL(ctx context.Context, sql string) error { func execStatements(ctx context.Context, tx adapter.DBTransaction, sqlText string) error {
// Remove comments and split by statement statements := splitSQLStatements(sqlText)
statements := splitSQLStatements(sql)
for _, stmt := range statements { for _, stmt := range statements {
stmt = strings.TrimSpace(stmt) stmt = strings.TrimSpace(stmt)
if stmt == "" { if stmt == "" || strings.HasPrefix(stmt, "\\") {
continue continue
} }
if _, err := tx.Exec(ctx, stmt); err != nil {
// Skip psql-specific commands
if strings.HasPrefix(stmt, "\\") {
continue
}
if _, err := i.db.Exec(ctx, stmt); err != nil {
return fmt.Errorf("failed to execute statement: %w\nStatement: %s", err, stmt) return fmt.Errorf("failed to execute statement: %w\nStatement: %s", err, stmt)
} }
} }
return nil return nil
} }
// filterAndSortSQLFiles filters and sorts SQL files // splitSQLStatements splits SQL into individual statements, keeping
func filterAndSortSQLFiles(files []fs.DirEntry) []string { // $$-quoted function bodies intact.
var sqlFiles []string // splitSQLStatements splits a SQL script into individual statements on
for _, file := range files { // top-level semicolons, ignoring semicolons that appear inside single-quoted
if !file.IsDir() && strings.HasSuffix(file.Name(), ".sql") { // strings ('...', with ” as an escaped quote), double-quoted identifiers,
sqlFiles = append(sqlFiles, file.Name()) // line comments (--), and dollar-quoted bodies ($$...$$ or $tag$...$tag$).
} func splitSQLStatements(sqlText string) []string {
}
sort.Strings(sqlFiles)
return sqlFiles
}
// splitSQLStatements splits SQL into individual statements
func splitSQLStatements(sql string) []string {
// Simple split by semicolon
// This doesn't handle all edge cases (strings with semicolons, dollar-quoted strings, etc.)
// but works for our use case
statements := strings.Split(sql, ";")
var result []string var result []string
var buffer string var buffer strings.Builder
for _, stmt := range statements { runes := []rune(sqlText)
stmt = strings.TrimSpace(stmt) n := len(runes)
if stmt == "" { i := 0
for i < n {
c := runes[i]
switch {
case c == '-' && i+1 < n && runes[i+1] == '-':
// Line comment: copy through end of line.
for i < n && runes[i] != '\n' {
buffer.WriteRune(runes[i])
i++
}
continue
case c == '\'':
buffer.WriteRune(c)
i++
for i < n {
buffer.WriteRune(runes[i])
if runes[i] == '\'' {
if i+1 < n && runes[i+1] == '\'' {
buffer.WriteRune(runes[i+1])
i += 2
continue continue
} }
i++
break
}
i++
}
continue
buffer += stmt + ";" case c == '"':
buffer.WriteRune(c)
i++
for i < n {
buffer.WriteRune(runes[i])
if runes[i] == '"' {
i++
break
}
i++
}
continue
// Check if we're inside a function definition ($$) case c == '$':
dollarCount := strings.Count(buffer, "$$") if tag, ok := matchDollarTag(runes, i); ok {
if dollarCount%2 == 0 { closer := tag
// Even number of $$ means we're outside function definitions buffer.WriteString(closer)
result = append(result, buffer) i += len(closer)
buffer = "" end := indexOfRunes(runes, i, closer)
if end == -1 {
buffer.WriteString(string(runes[i:]))
i = n
} else { } else {
// Odd number means we're inside a function, keep accumulating buffer.WriteString(string(runes[i:end]))
buffer += " " buffer.WriteString(closer)
i = end + len(closer)
}
continue
}
buffer.WriteRune(c)
i++
case c == ';':
stmt := strings.TrimSpace(buffer.String())
if stmt != "" {
result = append(result, stmt+";")
}
buffer.Reset()
i++
default:
buffer.WriteRune(c)
i++
} }
} }
// Add any remaining buffered content if stmt := strings.TrimSpace(buffer.String()); stmt != "" {
if buffer != "" { result = append(result, stmt)
result = append(result, buffer)
} }
return result return result
} }
// VerifyInstallation checks if the schema is properly installed // matchDollarTag checks whether runes[pos:] begins a dollar-quote tag
// ($$ or $tag$) and returns that tag if so.
func matchDollarTag(runes []rune, pos int) (string, bool) {
if runes[pos] != '$' {
return "", false
}
j := pos + 1
for j < len(runes) && (runes[j] == '_' || isAlnum(runes[j])) {
j++
}
if j < len(runes) && runes[j] == '$' {
return string(runes[pos : j+1]), true
}
return "", false
}
func isAlnum(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
// indexOfRunes returns the index of the first occurrence of sub in
// runes[from:], or -1 if not found.
func indexOfRunes(runes []rune, from int, sub string) int {
subRunes := []rune(sub)
for i := from; i+len(subRunes) <= len(runes); i++ {
match := true
for j, r := range subRunes {
if runes[i+j] != r {
match = false
break
}
}
if match {
return i
}
}
return -1
}
// VerifyInstallation checks that every embedded migration has been applied.
func (i *Installer) VerifyInstallation(ctx context.Context) error { func (i *Installer) VerifyInstallation(ctx context.Context) error {
i.logger.Info("verifying installation") i.logger.Info("verifying installation")
tables := []string{"broker_queueinstance", "broker_jobs", "broker_schedule"} pending, err := i.PendingMigrations(ctx)
procedures := []string{
"broker_get",
"broker_run",
"broker_set",
"broker_add_job",
"broker_register_instance",
"broker_ping_instance",
"broker_shutdown_instance",
}
// Check tables
for _, table := range tables {
var exists bool
err := i.db.QueryRow(ctx,
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = $1)",
table,
).Scan(&exists)
if err != nil { if err != nil {
return fmt.Errorf("failed to check table %s: %w", table, err) return fmt.Errorf("failed to check pending migrations: %w", err)
} }
if !exists { if len(pending) > 0 {
return fmt.Errorf("table %s does not exist", table) return fmt.Errorf("schema is behind: %d migration(s) not applied: %s", len(pending), strings.Join(pending, ", "))
}
i.logger.Info("table verified", "table", table)
}
// Check procedures
for _, proc := range procedures {
var exists bool
err := i.db.QueryRow(ctx,
"SELECT EXISTS (SELECT FROM pg_proc WHERE proname = $1)",
proc,
).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check procedure %s: %w", proc, err)
}
if !exists {
return fmt.Errorf("procedure %s does not exist", proc)
}
i.logger.Info("procedure verified", "procedure", proc)
} }
i.logger.Info("installation verified successfully") i.logger.Info("installation verified successfully")
@@ -0,0 +1,6 @@
-- Dedicated schema for all broker objects.
CREATE SCHEMA IF NOT EXISTS broker;
REVOKE ALL ON SCHEMA broker FROM PUBLIC;
-- gen_random_uuid() for lease tokens.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
@@ -0,0 +1,25 @@
-- broker.broker_queueinstance
-- Tracks active and historical broker queue instances.
CREATE TABLE IF NOT EXISTS broker.broker_queueinstance (
id_broker_queueinstance BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
hostname VARCHAR(255) NOT NULL,
pid INTEGER NOT NULL,
version VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_ping_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
shutdown_at TIMESTAMP WITH TIME ZONE,
queue_count INTEGER NOT NULL DEFAULT 0,
jobs_handled BIGINT NOT NULL DEFAULT 0,
CONSTRAINT broker_queueinstance_status_check CHECK (status IN ('active', 'inactive', 'shutdown'))
);
CREATE INDEX IF NOT EXISTS idx_broker_queueinstance_status ON broker.broker_queueinstance(status);
CREATE INDEX IF NOT EXISTS idx_broker_queueinstance_hostname ON broker.broker_queueinstance(hostname);
CREATE INDEX IF NOT EXISTS idx_broker_queueinstance_last_ping ON broker.broker_queueinstance(last_ping_at);
COMMENT ON TABLE broker.broker_queueinstance IS 'Tracks broker queue instances (active and historical). Single-active-instance-per-name is enforced via a pg_try_advisory_lock in broker_register_instance, not by this status column, which is observational only.';
COMMENT ON COLUMN broker.broker_queueinstance.status IS 'Observational status: active, inactive, or shutdown. Ownership is enforced via advisory lock, not by reading this column.';
@@ -0,0 +1,41 @@
-- broker.broker_schedule
-- Stores scheduled jobs (cron-like functionality).
CREATE TABLE IF NOT EXISTS broker.broker_schedule (
id_broker_schedule BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
cron_expr VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
job_name VARCHAR(255) NOT NULL,
job_priority INTEGER NOT NULL DEFAULT 0,
job_queue INTEGER NOT NULL DEFAULT 1,
job_language VARCHAR(50) NOT NULL DEFAULT 'sql',
execute_str TEXT NOT NULL,
run_as VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_run_at TIMESTAMP WITH TIME ZONE,
next_run_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT broker_schedule_job_queue_check CHECK (job_queue > 0)
);
CREATE INDEX IF NOT EXISTS idx_broker_schedule_enabled ON broker.broker_schedule(enabled);
CREATE INDEX IF NOT EXISTS idx_broker_schedule_next_run ON broker.broker_schedule(next_run_at) WHERE enabled = true;
CREATE INDEX IF NOT EXISTS idx_broker_schedule_name ON broker.broker_schedule(name);
COMMENT ON TABLE broker.broker_schedule IS 'Scheduled jobs (cron-like functionality)';
CREATE OR REPLACE FUNCTION broker.tf_broker_schedule_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS t_broker_schedule_updated_at ON broker.broker_schedule;
CREATE TRIGGER t_broker_schedule_updated_at
BEFORE UPDATE ON broker.broker_schedule
FOR EACH ROW
EXECUTE FUNCTION broker.tf_broker_schedule_update_timestamp();
@@ -0,0 +1,93 @@
-- broker.broker_jobs
-- Job queue for broker execution.
-- tenant_id / RLS: rows are only visible/writable when tenant_id matches
-- current_setting('broker.tenant_id', true) for the current transaction.
-- Callers must invoke broker.broker_set_tenant(...) before enqueue/claim;
-- if they don't, tenant_id defaults to 'default' and current_setting
-- also defaults to NULL -> broker_add_job coalesces to 'default' so
-- single-tenant use keeps working unmodified.
CREATE TABLE IF NOT EXISTS broker.broker_jobs (
id_broker_jobs BIGSERIAL PRIMARY KEY,
job_name VARCHAR(255) NOT NULL,
job_priority INTEGER NOT NULL DEFAULT 0,
job_queue INTEGER NOT NULL DEFAULT 1,
job_language VARCHAR(50) NOT NULL DEFAULT 'sql',
execute_str TEXT NOT NULL,
execute_result TEXT,
error_msg TEXT,
complete_status INTEGER NOT NULL DEFAULT 0,
run_as VARCHAR(100),
rid_broker_schedule BIGINT,
rid_broker_queueinstance BIGINT,
tenant_id TEXT NOT NULL DEFAULT 'default',
-- Lease / retry / idempotency
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 1,
available_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
leased_at TIMESTAMP WITH TIME ZONE,
lease_expires_at TIMESTAMP WITH TIME ZONE,
lease_token UUID,
idempotency_key TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT broker_jobs_complete_status_check CHECK (complete_status IN (0, 1, 2, 3, 4)),
CONSTRAINT broker_jobs_job_queue_check CHECK (job_queue > 0),
CONSTRAINT fk_schedule FOREIGN KEY (rid_broker_schedule) REFERENCES broker.broker_schedule(id_broker_schedule) ON DELETE SET NULL,
CONSTRAINT fk_instance FOREIGN KEY (rid_broker_queueinstance) REFERENCES broker.broker_queueinstance(id_broker_queueinstance) ON DELETE SET NULL
);
-- General-purpose indexes
CREATE INDEX IF NOT EXISTS idx_broker_jobs_status ON broker.broker_jobs(complete_status);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_schedule ON broker.broker_jobs(rid_broker_schedule);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_instance ON broker.broker_jobs(rid_broker_queueinstance);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_created ON broker.broker_jobs(created_at);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_name ON broker.broker_jobs(job_name, complete_status);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_tenant ON broker.broker_jobs(tenant_id);
-- Claim index: exactly what broker_get's WHERE/ORDER BY needs, partial on pending rows only.
CREATE INDEX IF NOT EXISTS idx_broker_jobs_claim
ON broker.broker_jobs (job_queue, job_priority DESC, created_at, id_broker_jobs)
WHERE complete_status = 0;
-- Idempotency: at most one pending/any job per (queue, key) when a key is supplied.
CREATE UNIQUE INDEX IF NOT EXISTS idx_broker_jobs_idempotency
ON broker.broker_jobs (job_queue, idempotency_key)
WHERE idempotency_key IS NOT NULL;
COMMENT ON TABLE broker.broker_jobs IS 'Job queue for broker execution';
COMMENT ON COLUMN broker.broker_jobs.complete_status IS '0=pending, 1=running, 2=completed, 3=failed (terminal or dead-lettered once attempt_count>=max_attempts), 4=cancelled';
COMMENT ON COLUMN broker.broker_jobs.tenant_id IS 'RLS tenant scaffold; defaults to ''default'' for single-tenant use';
COMMENT ON COLUMN broker.broker_jobs.attempt_count IS 'Number of times this job has been claimed/executed';
COMMENT ON COLUMN broker.broker_jobs.max_attempts IS 'Job is dead-lettered (failed) once attempt_count reaches this value';
COMMENT ON COLUMN broker.broker_jobs.available_at IS 'Job is not claimable until now() >= available_at (used for retry backoff)';
COMMENT ON COLUMN broker.broker_jobs.lease_token IS 'Token handed out by broker_get; broker_run requires a matching token to execute, so an expired/reclaimed lease cannot be double-processed';
COMMENT ON COLUMN broker.broker_jobs.idempotency_key IS 'Optional caller-supplied key; unique per (job_queue, idempotency_key)';
CREATE OR REPLACE FUNCTION broker.tf_broker_jobs_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS t_broker_jobs_updated_at ON broker.broker_jobs;
CREATE TRIGGER t_broker_jobs_updated_at
BEFORE UPDATE ON broker.broker_jobs
FOR EACH ROW
EXECUTE FUNCTION broker.tf_broker_jobs_update_timestamp();
-- Row Level Security: tenant isolation
ALTER TABLE broker.broker_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE broker.broker_jobs FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS broker_jobs_tenant_isolation ON broker.broker_jobs;
CREATE POLICY broker_jobs_tenant_isolation ON broker.broker_jobs
USING (tenant_id = COALESCE(NULLIF(current_setting('broker.tenant_id', true), ''), 'default'))
WITH CHECK (tenant_id = COALESCE(NULLIF(current_setting('broker.tenant_id', true), ''), 'default'));
@@ -0,0 +1,27 @@
-- broker.broker_job_dependency
-- Replaces the old broker_jobs.depends_on text[] column: job_id is only
-- claimable once every row it depends on has complete_status = 2 (completed).
CREATE TABLE IF NOT EXISTS broker.broker_job_dependency (
job_id BIGINT NOT NULL REFERENCES broker.broker_jobs(id_broker_jobs) ON DELETE CASCADE,
depends_on_job_id BIGINT NOT NULL REFERENCES broker.broker_jobs(id_broker_jobs) ON DELETE CASCADE,
tenant_id TEXT NOT NULL DEFAULT 'default',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (job_id, depends_on_job_id),
CHECK (job_id <> depends_on_job_id)
);
CREATE INDEX IF NOT EXISTS idx_broker_job_dependency_reverse
ON broker.broker_job_dependency (depends_on_job_id, job_id);
CREATE INDEX IF NOT EXISTS idx_broker_job_dependency_tenant
ON broker.broker_job_dependency (tenant_id);
COMMENT ON TABLE broker.broker_job_dependency IS 'job_id is not claimable until every depends_on_job_id row has complete_status = 2 (completed)';
ALTER TABLE broker.broker_job_dependency ENABLE ROW LEVEL SECURITY;
ALTER TABLE broker.broker_job_dependency FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS broker_job_dependency_tenant_isolation ON broker.broker_job_dependency;
CREATE POLICY broker_job_dependency_tenant_isolation ON broker.broker_job_dependency
USING (tenant_id = COALESCE(NULLIF(current_setting('broker.tenant_id', true), ''), 'default'))
WITH CHECK (tenant_id = COALESCE(NULLIF(current_setting('broker.tenant_id', true), ''), 'default'));
@@ -0,0 +1,13 @@
-- broker.broker_set_tenant
-- Sets the RLS tenant context for the current transaction (SET LOCAL semantics
-- via set_config(..., true)). Callers must invoke this before enqueue/claim
-- if they are not using the 'default' tenant.
CREATE OR REPLACE FUNCTION broker.broker_set_tenant(p_tenant_id TEXT)
RETURNS VOID
LANGUAGE SQL
AS $$
SELECT set_config('broker.tenant_id', p_tenant_id, true);
$$;
COMMENT ON FUNCTION broker.broker_set_tenant IS 'Sets broker.tenant_id for the current transaction only (SET LOCAL semantics)';
@@ -0,0 +1,83 @@
-- broker.broker_get
-- Claims the next eligible job from a queue: pending, available (backoff
-- elapsed), no incomplete dependency, and visible under the caller's RLS
-- tenant. Grants a lease (lease_token) that broker_run must present back.
-- Returns: p_retval (0=success, >0=infra error), p_errmsg, p_job_id, p_lease_token.
CREATE OR REPLACE FUNCTION broker.broker_get(
p_queue_number INTEGER,
p_instance_id BIGINT DEFAULT NULL,
p_lease_seconds INTEGER DEFAULT 60,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_job_id BIGINT,
OUT p_lease_token UUID
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_job_id BIGINT;
v_lease_token UUID;
BEGIN
p_retval := 0;
p_errmsg := '';
p_job_id := NULL;
p_lease_token := NULL;
IF p_queue_number IS NULL OR p_queue_number <= 0 THEN
p_retval := 1;
p_errmsg := 'Invalid queue number';
RETURN;
END IF;
IF p_lease_seconds IS NULL OR p_lease_seconds <= 0 THEN
p_lease_seconds := 60;
END IF;
SELECT candidate.id_broker_jobs
INTO v_job_id
FROM broker.broker_jobs candidate
WHERE candidate.job_queue = p_queue_number
AND candidate.complete_status = 0
AND candidate.available_at <= NOW()
AND NOT EXISTS (
SELECT 1
FROM broker.broker_job_dependency d
JOIN broker.broker_jobs dep ON dep.id_broker_jobs = d.depends_on_job_id
WHERE d.job_id = candidate.id_broker_jobs
AND dep.complete_status <> 2
)
ORDER BY candidate.job_priority DESC, candidate.created_at ASC, candidate.id_broker_jobs ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
IF NOT FOUND THEN
RETURN;
END IF;
v_lease_token := gen_random_uuid();
UPDATE broker.broker_jobs
SET complete_status = 1, -- running
started_at = NOW(),
rid_broker_queueinstance = p_instance_id,
attempt_count = attempt_count + 1,
lease_token = v_lease_token,
leased_at = NOW(),
lease_expires_at = NOW() + make_interval(secs => p_lease_seconds),
updated_at = NOW()
WHERE id_broker_jobs = v_job_id;
p_job_id := v_job_id;
p_lease_token := v_lease_token;
EXCEPTION
WHEN OTHERS THEN
p_retval := 2;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_get error: %', SQLERRM;
END;
$$;
COMMENT ON FUNCTION broker.broker_get IS 'Claims the next eligible job from a queue and grants a lease';
@@ -0,0 +1,135 @@
-- broker.broker_run
-- Executes a job by its ID, presenting the lease token it was claimed with.
--
-- p_retval is reserved for infra failures (bad job id, job not found, wrong
-- state, lease mismatch/expired, DB error). An executed-and-caught job
-- failure is a *successful* invocation: p_retval stays 0 and the outcome is
-- reported via p_job_status (0=requeued for retry, 2=completed, 3=dead-lettered)
-- so the caller commits the terminal/retry state instead of rolling it back.
--
-- On failure, if attempt_count < max_attempts the job is reset to pending
-- with exponential backoff (base 5s, capped at 300s); otherwise it is
-- dead-lettered as complete_status = 3.
CREATE OR REPLACE FUNCTION broker.broker_run(
p_job_id BIGINT,
p_lease_token UUID,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_job_status INTEGER
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_job_record RECORD;
v_execute_result TEXT;
v_error_occurred BOOLEAN := false;
v_backoff_base CONSTANT INTEGER := 5;
v_backoff_cap CONSTANT INTEGER := 300;
v_backoff_secs INTEGER;
BEGIN
p_retval := 0;
p_errmsg := '';
p_job_status := NULL;
v_execute_result := '';
IF p_job_id IS NULL OR p_job_id <= 0 THEN
p_retval := 1;
p_errmsg := 'Invalid job ID';
RETURN;
END IF;
SELECT id_broker_jobs, execute_str, job_language, complete_status, attempt_count, max_attempts, lease_token
INTO v_job_record
FROM broker.broker_jobs
WHERE id_broker_jobs = p_job_id
FOR UPDATE;
IF NOT FOUND THEN
p_retval := 2;
p_errmsg := 'Job not found';
RETURN;
END IF;
IF v_job_record.complete_status != 1 THEN
p_retval := 3;
p_errmsg := format('Job is not in running state (status: %s)', v_job_record.complete_status);
RETURN;
END IF;
IF v_job_record.lease_token IS DISTINCT FROM p_lease_token THEN
p_retval := 4;
p_errmsg := 'Lease token mismatch or expired; job was reclaimed by another worker';
RETURN;
END IF;
-- Execute the job
BEGIN
IF v_job_record.job_language IN ('sql', 'plpgsql') THEN
EXECUTE v_job_record.execute_str;
v_execute_result := 'Success';
ELSE
v_error_occurred := true;
v_execute_result := format('Unsupported job language: %s', v_job_record.job_language);
END IF;
EXCEPTION
WHEN OTHERS THEN
v_error_occurred := true;
v_execute_result := format('Error: %s', SQLERRM);
END;
IF v_error_occurred THEN
IF v_job_record.attempt_count < v_job_record.max_attempts THEN
v_backoff_secs := LEAST(POWER(2, v_job_record.attempt_count)::INTEGER * v_backoff_base, v_backoff_cap);
UPDATE broker.broker_jobs
SET complete_status = 0, -- pending, retry
available_at = NOW() + make_interval(secs => v_backoff_secs),
error_msg = v_execute_result,
execute_result = v_execute_result,
lease_token = NULL,
leased_at = NULL,
lease_expires_at = NULL,
updated_at = NOW()
WHERE id_broker_jobs = p_job_id;
p_job_status := 0;
ELSE
UPDATE broker.broker_jobs
SET complete_status = 3, -- failed (dead-letter, attempts exhausted)
error_msg = v_execute_result,
execute_result = v_execute_result,
lease_token = NULL,
leased_at = NULL,
lease_expires_at = NULL,
completed_at = NOW(),
updated_at = NOW()
WHERE id_broker_jobs = p_job_id;
p_job_status := 3;
END IF;
ELSE
UPDATE broker.broker_jobs
SET complete_status = 2, -- completed
execute_result = v_execute_result,
error_msg = NULL,
lease_token = NULL,
leased_at = NULL,
lease_expires_at = NULL,
completed_at = NOW(),
updated_at = NOW()
WHERE id_broker_jobs = p_job_id;
p_job_status := 2;
END IF;
EXCEPTION
WHEN OTHERS THEN
p_retval := 6;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_run error: %', SQLERRM;
END;
$$;
COMMENT ON FUNCTION broker.broker_run IS 'Executes a leased job; reports outcome via p_job_status without forcing a rollback of the terminal/retry state';
@@ -0,0 +1,65 @@
-- broker.broker_set
-- Minimal whitelist of session options. The previous SET SESSION AUTHORIZATION
-- and search_path branches were removed: they let a caller assume an arbitrary
-- Postgres role or schema search order from inside a plpgsql function with no
-- identity model behind it, which is unsafe and was unused.
CREATE OR REPLACE FUNCTION broker.broker_set(
p_option_name TEXT,
p_option_value TEXT,
OUT p_retval INTEGER,
OUT p_errmsg TEXT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_sql TEXT;
BEGIN
p_retval := 0;
p_errmsg := '';
IF p_option_name IS NULL OR p_option_name = '' THEN
p_retval := 1;
p_errmsg := 'Option name is required';
RETURN;
END IF;
CASE LOWER(p_option_name)
WHEN 'application_name' THEN
BEGIN
v_sql := format('SET LOCAL application_name TO %L', p_option_value);
EXECUTE v_sql;
EXCEPTION
WHEN OTHERS THEN
p_retval := 3;
p_errmsg := format('Failed to set application_name: %s', SQLERRM);
RETURN;
END;
WHEN 'timezone' THEN
BEGIN
v_sql := format('SET LOCAL timezone TO %L', p_option_value);
EXECUTE v_sql;
EXCEPTION
WHEN OTHERS THEN
p_retval := 5;
p_errmsg := format('Failed to set timezone: %s', SQLERRM);
RETURN;
END;
ELSE
p_retval := 10;
p_errmsg := format('Unknown option: %s', p_option_name);
RETURN;
END CASE;
EXCEPTION
WHEN OTHERS THEN
p_retval := 99;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_set error: %', SQLERRM;
END;
$$;
COMMENT ON FUNCTION broker.broker_set IS 'Sets a whitelisted session-local option (application_name, timezone)';
@@ -0,0 +1,64 @@
-- broker.broker_register_instance
-- Registers a broker instance, using a session-scoped advisory lock keyed by
-- name to guarantee only one active instance per name -- no race window, no
-- "check COUNT(*) then insert" gap. The caller MUST run this on a pinned
-- connection it keeps open for the process lifetime (Go: db.Conn(ctx)) and
-- release the lock (pg_advisory_unlock) itself on shutdown, since the lock
-- lives with the backend session, not with the row.
CREATE OR REPLACE FUNCTION broker.broker_register_instance(
p_name TEXT,
p_hostname TEXT,
p_pid INTEGER,
p_version TEXT,
p_queue_count INTEGER,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_instance_id BIGINT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_lock_key BIGINT;
BEGIN
p_retval := 0;
p_errmsg := '';
p_instance_id := NULL;
IF p_name IS NULL OR p_name = '' THEN
p_retval := 1;
p_errmsg := 'Instance name is required';
RETURN;
END IF;
IF p_hostname IS NULL OR p_hostname = '' THEN
p_retval := 2;
p_errmsg := 'Hostname is required';
RETURN;
END IF;
v_lock_key := hashtextextended('broker:' || p_name, 0);
IF NOT pg_try_advisory_lock(v_lock_key) THEN
p_retval := 3;
p_errmsg := 'Another broker instance is already active for this name (advisory lock held). Only one broker instance per name is allowed.';
RETURN;
END IF;
INSERT INTO broker.broker_queueinstance (
name, hostname, pid, version, status, queue_count, started_at, last_ping_at
) VALUES (
p_name, p_hostname, p_pid, p_version, 'active', p_queue_count, NOW(), NOW()
)
RETURNING id_broker_queueinstance INTO p_instance_id;
EXCEPTION
WHEN OTHERS THEN
p_retval := 99;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_register_instance error: %', SQLERRM;
END;
$$;
COMMENT ON FUNCTION broker.broker_register_instance IS 'Registers a broker instance; caller must hold the connection open for the process lifetime (advisory lock is session-scoped)';
@@ -0,0 +1,137 @@
-- broker.broker_add_job
-- Adds a new job (optionally with dependencies and an idempotency key) and
-- sends a wake-only NOTIFY -- the payload carries only the queue number
-- (job id kept solely for logging); workers re-claim via broker_get rather
-- than executing the notified row directly, so a notification can never
-- hand a job to a worker before it's actually claimable.
CREATE OR REPLACE FUNCTION broker.broker_add_job(
p_job_name TEXT,
p_execute_str TEXT,
p_job_queue INTEGER DEFAULT 1,
p_job_priority INTEGER DEFAULT 0,
p_job_language TEXT DEFAULT 'sql',
p_run_as TEXT DEFAULT NULL,
p_schedule_id BIGINT DEFAULT NULL,
p_depends_on_job_ids BIGINT[] DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL,
p_max_attempts INTEGER DEFAULT 1,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_job_id BIGINT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_notification_payload JSON;
v_tenant_id TEXT;
v_dep_id BIGINT;
v_cycle_exists BOOLEAN;
BEGIN
p_retval := 0;
p_errmsg := '';
p_job_id := NULL;
IF p_job_name IS NULL OR p_job_name = '' THEN
p_retval := 1;
p_errmsg := 'Job name is required';
RETURN;
END IF;
IF p_execute_str IS NULL OR p_execute_str = '' THEN
p_retval := 2;
p_errmsg := 'Execute string is required';
RETURN;
END IF;
IF p_job_queue IS NULL OR p_job_queue <= 0 THEN
p_retval := 3;
p_errmsg := 'Invalid job queue number';
RETURN;
END IF;
IF p_max_attempts IS NULL OR p_max_attempts <= 0 THEN
p_max_attempts := 1;
END IF;
-- Falls back to 'default' when the caller never called broker_set_tenant,
-- so single-tenant use (and the RLS WITH CHECK on insert) keeps working.
v_tenant_id := COALESCE(NULLIF(current_setting('broker.tenant_id', true), ''), 'default');
IF p_idempotency_key IS NOT NULL THEN
SELECT id_broker_jobs INTO p_job_id
FROM broker.broker_jobs
WHERE job_queue = p_job_queue
AND idempotency_key = p_idempotency_key
AND tenant_id = v_tenant_id;
IF FOUND THEN
p_errmsg := 'Job with this idempotency key already exists; returning existing job id';
RETURN;
END IF;
END IF;
INSERT INTO broker.broker_jobs (
job_name, job_priority, job_queue, job_language, execute_str, run_as,
rid_broker_schedule, tenant_id, max_attempts, idempotency_key, complete_status
) VALUES (
p_job_name, p_job_priority, p_job_queue, p_job_language, p_execute_str, p_run_as,
p_schedule_id, v_tenant_id, p_max_attempts, p_idempotency_key, 0
)
RETURNING id_broker_jobs INTO p_job_id;
IF p_depends_on_job_ids IS NOT NULL THEN
FOREACH v_dep_id IN ARRAY p_depends_on_job_ids LOOP
IF v_dep_id IS NULL THEN
CONTINUE;
END IF;
IF v_dep_id = p_job_id THEN
p_retval := 20;
p_errmsg := 'Invalid dependency: a job cannot depend on itself';
RETURN;
END IF;
SELECT EXISTS (
SELECT 1 FROM broker.broker_job_dependency
WHERE job_id = v_dep_id AND depends_on_job_id = p_job_id
) INTO v_cycle_exists;
IF v_cycle_exists THEN
p_retval := 21;
p_errmsg := format('Invalid dependency: job %s already depends on %s (would create a cycle)', v_dep_id, p_job_id);
RETURN;
END IF;
INSERT INTO broker.broker_job_dependency (job_id, depends_on_job_id, tenant_id)
VALUES (p_job_id, v_dep_id, v_tenant_id)
ON CONFLICT (job_id, depends_on_job_id) DO NOTHING;
END LOOP;
END IF;
v_notification_payload := json_build_object(
'queue', p_job_queue,
'job_id', p_job_id
);
PERFORM pg_notify('broker.event', v_notification_payload::text);
EXCEPTION
WHEN unique_violation THEN
-- Concurrent insert raced us to the same idempotency key.
p_retval := 0;
p_errmsg := 'Job with this idempotency key already exists; returning existing job id';
SELECT id_broker_jobs INTO p_job_id
FROM broker.broker_jobs
WHERE job_queue = p_job_queue
AND idempotency_key = p_idempotency_key
AND tenant_id = v_tenant_id;
WHEN OTHERS THEN
p_retval := 99;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_add_job error: %', SQLERRM;
END;
$$;
COMMENT ON FUNCTION broker.broker_add_job IS 'Adds a job (with optional dependencies/idempotency key) and sends a wake-only NOTIFY';
@@ -1,8 +1,6 @@
-- broker_ping_instance function -- broker.broker_ping_instance / broker.broker_shutdown_instance
-- Updates the last_ping_at timestamp for a broker instance
-- Returns: p_retval (0=success, >0=error), p_errmsg (error message)
CREATE OR REPLACE FUNCTION broker_ping_instance( CREATE OR REPLACE FUNCTION broker.broker_ping_instance(
p_instance_id BIGINT, p_instance_id BIGINT,
p_jobs_handled BIGINT DEFAULT NULL, p_jobs_handled BIGINT DEFAULT NULL,
OUT p_retval INTEGER, OUT p_retval INTEGER,
@@ -15,26 +13,22 @@ BEGIN
p_retval := 0; p_retval := 0;
p_errmsg := ''; p_errmsg := '';
-- Validate instance ID
IF p_instance_id IS NULL OR p_instance_id <= 0 THEN IF p_instance_id IS NULL OR p_instance_id <= 0 THEN
p_retval := 1; p_retval := 1;
p_errmsg := 'Invalid instance ID'; p_errmsg := 'Invalid instance ID';
RETURN; RETURN;
END IF; END IF;
-- Update ping timestamp
IF p_jobs_handled IS NOT NULL THEN IF p_jobs_handled IS NOT NULL THEN
UPDATE broker_queueinstance UPDATE broker.broker_queueinstance
SET last_ping_at = NOW(), SET last_ping_at = NOW(), jobs_handled = p_jobs_handled
jobs_handled = p_jobs_handled
WHERE id_broker_queueinstance = p_instance_id; WHERE id_broker_queueinstance = p_instance_id;
ELSE ELSE
UPDATE broker_queueinstance UPDATE broker.broker_queueinstance
SET last_ping_at = NOW() SET last_ping_at = NOW()
WHERE id_broker_queueinstance = p_instance_id; WHERE id_broker_queueinstance = p_instance_id;
END IF; END IF;
-- Check if instance was found
IF NOT FOUND THEN IF NOT FOUND THEN
p_retval := 2; p_retval := 2;
p_errmsg := 'Instance not found'; p_errmsg := 'Instance not found';
@@ -49,11 +43,7 @@ EXCEPTION
END; END;
$$; $$;
-- broker_shutdown_instance function CREATE OR REPLACE FUNCTION broker.broker_shutdown_instance(
-- Marks a broker instance as shutdown
-- Returns: p_retval (0=success, >0=error), p_errmsg (error message)
CREATE OR REPLACE FUNCTION broker_shutdown_instance(
p_instance_id BIGINT, p_instance_id BIGINT,
OUT p_retval INTEGER, OUT p_retval INTEGER,
OUT p_errmsg TEXT OUT p_errmsg TEXT
@@ -65,20 +55,16 @@ BEGIN
p_retval := 0; p_retval := 0;
p_errmsg := ''; p_errmsg := '';
-- Validate instance ID
IF p_instance_id IS NULL OR p_instance_id <= 0 THEN IF p_instance_id IS NULL OR p_instance_id <= 0 THEN
p_retval := 1; p_retval := 1;
p_errmsg := 'Invalid instance ID'; p_errmsg := 'Invalid instance ID';
RETURN; RETURN;
END IF; END IF;
-- Update instance status UPDATE broker.broker_queueinstance
UPDATE broker_queueinstance SET status = 'shutdown', shutdown_at = NOW()
SET status = 'shutdown',
shutdown_at = NOW()
WHERE id_broker_queueinstance = p_instance_id; WHERE id_broker_queueinstance = p_instance_id;
-- Check if instance was found
IF NOT FOUND THEN IF NOT FOUND THEN
p_retval := 2; p_retval := 2;
p_errmsg := 'Instance not found'; p_errmsg := 'Instance not found';
@@ -93,6 +79,5 @@ EXCEPTION
END; END;
$$; $$;
-- Comments COMMENT ON FUNCTION broker.broker_ping_instance IS 'Updates the last ping timestamp for an instance';
COMMENT ON FUNCTION broker_ping_instance IS 'Updates the last ping timestamp for an instance'; COMMENT ON FUNCTION broker.broker_shutdown_instance IS 'Marks an instance as shutdown (does not release the advisory lock -- caller must pg_advisory_unlock on its pinned connection)';
COMMENT ON FUNCTION broker_shutdown_instance IS 'Marks an instance as shutdown';
@@ -0,0 +1,66 @@
-- broker.broker_recover_stale_jobs
-- Recovers jobs whose lease has expired while still 'running' (a worker died
-- or was killed mid-execution without updating status). Applies the same
-- retry/backoff rule as broker_run: retry while attempts remain, otherwise
-- dead-letter. Marked SECURITY DEFINER so the sweep runs across all tenants
-- regardless of caller: this requires the function's owner (whichever role
-- runs the migrations, intended to be broker_admin) to have BYPASSRLS --
-- broker_jobs/broker_job_dependency use FORCE ROW LEVEL SECURITY, so without
-- BYPASSRLS on the owner this would silently only ever see the empty/no
-- tenant context.
CREATE OR REPLACE FUNCTION broker.broker_recover_stale_jobs(
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_recovered_count INTEGER
)
RETURNS RECORD
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = broker, pg_temp
AS $$
DECLARE
v_backoff_base CONSTANT INTEGER := 5;
v_backoff_cap CONSTANT INTEGER := 300;
BEGIN
p_retval := 0;
p_errmsg := '';
p_recovered_count := 0;
WITH stale AS (
SELECT id_broker_jobs, attempt_count, max_attempts
FROM broker.broker_jobs
WHERE complete_status = 1
AND lease_expires_at IS NOT NULL
AND lease_expires_at < NOW()
FOR UPDATE SKIP LOCKED
),
recovered AS (
UPDATE broker.broker_jobs j
SET complete_status = CASE WHEN s.attempt_count < s.max_attempts THEN 0 ELSE 3 END,
available_at = CASE
WHEN s.attempt_count < s.max_attempts
THEN NOW() + make_interval(secs => LEAST(POWER(2, s.attempt_count)::INTEGER * v_backoff_base, v_backoff_cap))
ELSE j.available_at
END,
error_msg = CASE WHEN s.attempt_count >= s.max_attempts THEN COALESCE(j.error_msg, 'Lease expired and max attempts exhausted') ELSE j.error_msg END,
completed_at = CASE WHEN s.attempt_count >= s.max_attempts THEN NOW() ELSE j.completed_at END,
lease_token = NULL,
leased_at = NULL,
lease_expires_at = NULL,
updated_at = NOW()
FROM stale s
WHERE j.id_broker_jobs = s.id_broker_jobs
RETURNING j.id_broker_jobs
)
SELECT COUNT(*) INTO p_recovered_count FROM recovered;
EXCEPTION
WHEN OTHERS THEN
p_retval := 99;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_recover_stale_jobs error: %', SQLERRM;
END;
$$;
COMMENT ON FUNCTION broker.broker_recover_stale_jobs IS 'Requeues (or dead-letters) jobs whose lease expired while still running';
@@ -0,0 +1,41 @@
-- Adds job groups: every job belongs to a job_group (defaults to its own
-- job_name when not given explicitly, set by broker_add_job). A dependency
-- can now target a whole group instead of a single job id -- the dependent
-- job is claimable once every job tagged with that group has completed
-- (complete_status = 2); already-completed group members simply drop out of
-- the gating check, they don't need to have existed at any particular time.
-- The existing id-based dependency (broker_job_dependency.depends_on_job_id)
-- is kept as-is; each dependency row targets exactly one of an id or a group.
ALTER TABLE broker.broker_jobs ADD COLUMN IF NOT EXISTS job_group TEXT;
UPDATE broker.broker_jobs SET job_group = job_name WHERE job_group IS NULL;
ALTER TABLE broker.broker_jobs ALTER COLUMN job_group SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_broker_jobs_group
ON broker.broker_jobs (tenant_id, job_group, complete_status);
ALTER TABLE broker.broker_job_dependency DROP CONSTRAINT IF EXISTS broker_job_dependency_pkey;
ALTER TABLE broker.broker_job_dependency ALTER COLUMN depends_on_job_id DROP NOT NULL;
ALTER TABLE broker.broker_job_dependency ADD COLUMN IF NOT EXISTS depends_on_group TEXT;
ALTER TABLE broker.broker_job_dependency ADD COLUMN IF NOT EXISTS id_broker_job_dependency BIGSERIAL;
ALTER TABLE broker.broker_job_dependency ADD PRIMARY KEY (id_broker_job_dependency);
ALTER TABLE broker.broker_job_dependency DROP CONSTRAINT IF EXISTS broker_job_dependency_one_target;
ALTER TABLE broker.broker_job_dependency ADD CONSTRAINT broker_job_dependency_one_target CHECK (
(depends_on_job_id IS NOT NULL AND depends_on_group IS NULL) OR
(depends_on_job_id IS NULL AND depends_on_group IS NOT NULL)
);
-- Plain (non-partial) unique constraint, matching the old PK's guarantee --
-- NULLs in depends_on_job_id (the group-dependency rows) are never
-- considered equal by a standard unique constraint, so this only constrains
-- id-based rows, and keeps "ON CONFLICT (job_id, depends_on_job_id)" (no
-- predicate needed) working for existing callers.
ALTER TABLE broker.broker_job_dependency DROP CONSTRAINT IF EXISTS broker_job_dependency_unique_id;
ALTER TABLE broker.broker_job_dependency ADD CONSTRAINT broker_job_dependency_unique_id
UNIQUE (job_id, depends_on_job_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_broker_job_dependency_unique_group
ON broker.broker_job_dependency (job_id, depends_on_group) WHERE depends_on_group IS NOT NULL;
COMMENT ON COLUMN broker.broker_jobs.job_group IS 'Group tag for this job; defaults to job_name. Other jobs can depend on the whole group.';
COMMENT ON COLUMN broker.broker_job_dependency.depends_on_group IS 'Alternative to depends_on_job_id: job_id is not claimable until every job with job_group = depends_on_group has completed';
@@ -0,0 +1,184 @@
-- broker.broker_add_job: adds job groups.
-- p_job_group defaults to p_job_name when not given. p_depends_on_groups is
-- the group-based counterpart to the existing p_depends_on_job_ids: the new
-- job is not claimable until every job tagged with each named group has
-- completed. Both dependency kinds can be combined on the same job.
-- New parameters are appended after existing ones with defaults, so every
-- existing positional call (however many args it passes) keeps working
-- unchanged. Appending arguments changes the function's identity though --
-- CREATE OR REPLACE would create a second, ambiguous overload rather than
-- replacing -- so the old 10-arg signature is dropped explicitly first.
DROP FUNCTION IF EXISTS broker.broker_add_job(
TEXT, TEXT, INTEGER, INTEGER, TEXT, TEXT, BIGINT, BIGINT[], TEXT, INTEGER
);
CREATE OR REPLACE FUNCTION broker.broker_add_job(
p_job_name TEXT,
p_execute_str TEXT,
p_job_queue INTEGER DEFAULT 1,
p_job_priority INTEGER DEFAULT 0,
p_job_language TEXT DEFAULT 'sql',
p_run_as TEXT DEFAULT NULL,
p_schedule_id BIGINT DEFAULT NULL,
p_depends_on_job_ids BIGINT[] DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL,
p_max_attempts INTEGER DEFAULT 1,
p_job_group TEXT DEFAULT NULL,
p_depends_on_groups TEXT[] DEFAULT NULL,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_job_id BIGINT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_notification_payload JSON;
v_tenant_id TEXT;
v_job_group TEXT;
v_dep_id BIGINT;
v_dep_group TEXT;
v_cycle_exists BOOLEAN;
BEGIN
p_retval := 0;
p_errmsg := '';
p_job_id := NULL;
IF p_job_name IS NULL OR p_job_name = '' THEN
p_retval := 1;
p_errmsg := 'Job name is required';
RETURN;
END IF;
IF p_execute_str IS NULL OR p_execute_str = '' THEN
p_retval := 2;
p_errmsg := 'Execute string is required';
RETURN;
END IF;
IF p_job_queue IS NULL OR p_job_queue <= 0 THEN
p_retval := 3;
p_errmsg := 'Invalid job queue number';
RETURN;
END IF;
IF p_max_attempts IS NULL OR p_max_attempts <= 0 THEN
p_max_attempts := 1;
END IF;
v_job_group := COALESCE(NULLIF(p_job_group, ''), p_job_name);
-- Falls back to 'default' when the caller never called broker_set_tenant,
-- so single-tenant use (and the RLS WITH CHECK on insert) keeps working.
v_tenant_id := COALESCE(NULLIF(current_setting('broker.tenant_id', true), ''), 'default');
IF p_idempotency_key IS NOT NULL THEN
SELECT id_broker_jobs INTO p_job_id
FROM broker.broker_jobs
WHERE job_queue = p_job_queue
AND idempotency_key = p_idempotency_key
AND tenant_id = v_tenant_id;
IF FOUND THEN
p_errmsg := 'Job with this idempotency key already exists; returning existing job id';
RETURN;
END IF;
END IF;
INSERT INTO broker.broker_jobs (
job_name, job_group, job_priority, job_queue, job_language, execute_str, run_as,
rid_broker_schedule, tenant_id, max_attempts, idempotency_key, complete_status
) VALUES (
p_job_name, v_job_group, p_job_priority, p_job_queue, p_job_language, p_execute_str, p_run_as,
p_schedule_id, v_tenant_id, p_max_attempts, p_idempotency_key, 0
)
RETURNING id_broker_jobs INTO p_job_id;
IF p_depends_on_job_ids IS NOT NULL THEN
FOREACH v_dep_id IN ARRAY p_depends_on_job_ids LOOP
IF v_dep_id IS NULL THEN
CONTINUE;
END IF;
IF v_dep_id = p_job_id THEN
p_retval := 20;
p_errmsg := 'Invalid dependency: a job cannot depend on itself';
RETURN;
END IF;
SELECT EXISTS (
SELECT 1 FROM broker.broker_job_dependency
WHERE job_id = v_dep_id AND depends_on_job_id = p_job_id
) INTO v_cycle_exists;
IF v_cycle_exists THEN
p_retval := 21;
p_errmsg := format('Invalid dependency: job %s already depends on %s (would create a cycle)', v_dep_id, p_job_id);
RETURN;
END IF;
INSERT INTO broker.broker_job_dependency (job_id, depends_on_job_id, tenant_id)
VALUES (p_job_id, v_dep_id, v_tenant_id)
ON CONFLICT (job_id, depends_on_job_id) DO NOTHING;
END LOOP;
END IF;
IF p_depends_on_groups IS NOT NULL THEN
FOREACH v_dep_group IN ARRAY p_depends_on_groups LOOP
IF v_dep_group IS NULL OR v_dep_group = '' THEN
CONTINUE;
END IF;
IF v_dep_group = v_job_group THEN
p_retval := 22;
p_errmsg := format('Invalid dependency: a job cannot depend on its own group (%s)', v_job_group);
RETURN;
END IF;
SELECT EXISTS (
SELECT 1
FROM broker.broker_jobs j
JOIN broker.broker_job_dependency d ON d.job_id = j.id_broker_jobs
WHERE j.tenant_id = v_tenant_id
AND j.job_group = v_dep_group
AND d.depends_on_group = v_job_group
) INTO v_cycle_exists;
IF v_cycle_exists THEN
p_retval := 23;
p_errmsg := format('Invalid dependency: group %s already depends on %s (would create a cycle)', v_dep_group, v_job_group);
RETURN;
END IF;
INSERT INTO broker.broker_job_dependency (job_id, depends_on_group, tenant_id)
VALUES (p_job_id, v_dep_group, v_tenant_id)
ON CONFLICT (job_id, depends_on_group) WHERE depends_on_group IS NOT NULL DO NOTHING;
END LOOP;
END IF;
v_notification_payload := json_build_object(
'queue', p_job_queue,
'job_id', p_job_id
);
PERFORM pg_notify('broker.event', v_notification_payload::text);
EXCEPTION
WHEN unique_violation THEN
-- Concurrent insert raced us to the same idempotency key.
p_retval := 0;
p_errmsg := 'Job with this idempotency key already exists; returning existing job id';
SELECT id_broker_jobs INTO p_job_id
FROM broker.broker_jobs
WHERE job_queue = p_job_queue
AND idempotency_key = p_idempotency_key
AND tenant_id = v_tenant_id;
WHEN OTHERS THEN
p_retval := 99;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_add_job error: %', SQLERRM;
END;
$$;
COMMENT ON FUNCTION broker.broker_add_job IS 'Adds a job (with optional id/group dependencies, job group, and idempotency key) and sends a wake-only NOTIFY';
@@ -0,0 +1,91 @@
-- broker.broker_get: also gates claiming on group-based dependencies
-- (broker_job_dependency.depends_on_group) alongside the existing id-based
-- ones. Signature is unchanged, only the eligibility query grows a second
-- NOT EXISTS clause.
CREATE OR REPLACE FUNCTION broker.broker_get(
p_queue_number INTEGER,
p_instance_id BIGINT DEFAULT NULL,
p_lease_seconds INTEGER DEFAULT 60,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_job_id BIGINT,
OUT p_lease_token UUID
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_job_id BIGINT;
v_lease_token UUID;
BEGIN
p_retval := 0;
p_errmsg := '';
p_job_id := NULL;
p_lease_token := NULL;
IF p_queue_number IS NULL OR p_queue_number <= 0 THEN
p_retval := 1;
p_errmsg := 'Invalid queue number';
RETURN;
END IF;
IF p_lease_seconds IS NULL OR p_lease_seconds <= 0 THEN
p_lease_seconds := 60;
END IF;
SELECT candidate.id_broker_jobs
INTO v_job_id
FROM broker.broker_jobs candidate
WHERE candidate.job_queue = p_queue_number
AND candidate.complete_status = 0
AND candidate.available_at <= NOW()
AND NOT EXISTS (
SELECT 1
FROM broker.broker_job_dependency d
JOIN broker.broker_jobs dep ON dep.id_broker_jobs = d.depends_on_job_id
WHERE d.job_id = candidate.id_broker_jobs
AND dep.complete_status <> 2
)
AND NOT EXISTS (
SELECT 1
FROM broker.broker_job_dependency d
JOIN broker.broker_jobs dep ON dep.tenant_id = candidate.tenant_id
AND dep.job_group = d.depends_on_group
WHERE d.job_id = candidate.id_broker_jobs
AND dep.id_broker_jobs <> candidate.id_broker_jobs
AND dep.complete_status <> 2
)
ORDER BY candidate.job_priority DESC, candidate.created_at ASC, candidate.id_broker_jobs ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
IF NOT FOUND THEN
RETURN;
END IF;
v_lease_token := gen_random_uuid();
UPDATE broker.broker_jobs
SET complete_status = 1, -- running
started_at = NOW(),
rid_broker_queueinstance = p_instance_id,
attempt_count = attempt_count + 1,
lease_token = v_lease_token,
leased_at = NOW(),
lease_expires_at = NOW() + make_interval(secs => p_lease_seconds),
updated_at = NOW()
WHERE id_broker_jobs = v_job_id;
p_job_id := v_job_id;
p_lease_token := v_lease_token;
EXCEPTION
WHEN OTHERS THEN
p_retval := 2;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_get error: %', SQLERRM;
END;
$$;
COMMENT ON FUNCTION broker.broker_get IS 'Claims the next eligible job from a queue (honoring id- and group-based dependencies) and grants a lease';
@@ -0,0 +1,41 @@
-- broker.broker_add_job_simple
-- Convenience wrapper around broker.broker_add_job for the common case: a
-- job named p_job_name running p_execute_str at p_job_priority, depending on
-- other jobs by group name (p_depends_on_groups) -- every job's group
-- defaults to its own job_name, so passing job names here just works.
-- Pure pass-through: no name-to-id resolution needed since dependencies are
-- resolved live, by group, inside broker_get.
CREATE OR REPLACE FUNCTION broker.broker_add_job_simple(
p_job_name TEXT,
p_execute_str TEXT,
p_job_priority INTEGER DEFAULT 0,
p_depends_on_groups TEXT[] DEFAULT NULL,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_job_id BIGINT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
BEGIN
SELECT r.p_retval, r.p_errmsg, r.p_job_id
INTO p_retval, p_errmsg, p_job_id
FROM broker.broker_add_job(
p_job_name,
p_execute_str,
1, -- p_job_queue
p_job_priority,
'sql', -- p_job_language
NULL, -- p_run_as
NULL, -- p_schedule_id
NULL, -- p_depends_on_job_ids
NULL, -- p_idempotency_key
1, -- p_max_attempts
NULL, -- p_job_group (defaults to p_job_name)
p_depends_on_groups
) AS r;
END;
$$;
COMMENT ON FUNCTION broker.broker_add_job_simple IS 'Shortcut for broker_add_job: name, execute string, priority, and dependencies by group name (defaults to job name)';
@@ -1,13 +0,0 @@
-- PostgreSQL Broker Procedures Installation Script
-- Run this script to create all required stored procedures
\echo 'Installing PostgreSQL Broker procedures...'
\i 01_broker_get.sql
\i 02_broker_run.sql
\i 03_broker_set.sql
\i 04_broker_register_instance.sql
\i 05_broker_add_job.sql
\i 06_broker_ping_instance.sql
\echo 'PostgreSQL Broker procedures installed successfully!'
@@ -1,76 +0,0 @@
-- broker_get function
-- Fetches the next job from the queue for a given queue number
-- Returns: p_retval (0=success, >0=error), p_errmsg (error message), p_job_id (job ID if found)
CREATE OR REPLACE FUNCTION broker_get(
p_queue_number INTEGER,
p_instance_id BIGINT DEFAULT NULL,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_job_id BIGINT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_job_record RECORD;
BEGIN
p_retval := 0;
p_errmsg := '';
p_job_id := NULL;
-- Validate queue number
IF p_queue_number IS NULL OR p_queue_number <= 0 THEN
p_retval := 1;
p_errmsg := 'Invalid queue number';
RETURN;
END IF;
-- Find and lock the next pending job for this queue
-- Uses SKIP LOCKED to avoid blocking on jobs being processed by other workers
-- Skip jobs with pending dependencies
SELECT id_broker_jobs, job_name, job_priority, execute_str
INTO v_job_record
FROM broker_jobs
WHERE job_queue = p_queue_number
AND complete_status = 0 -- pending
AND (
depends_on IS NULL -- no dependencies
OR depends_on = '{}' -- empty dependencies
OR NOT EXISTS ( -- all dependencies completed
SELECT 1
FROM broker_jobs dep
WHERE dep.job_name = ANY(broker_jobs.depends_on)
AND dep.complete_status = 0 -- pending dependency
)
)
ORDER BY job_priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- If no job found, return success with NULL job_id
IF NOT FOUND THEN
RETURN;
END IF;
-- Update job status to running
UPDATE broker_jobs
SET complete_status = 1, -- running
started_at = NOW(),
rid_broker_queueinstance = p_instance_id,
updated_at = NOW()
WHERE id_broker_jobs = v_job_record.id_broker_jobs;
-- Return the job ID
p_job_id := v_job_record.id_broker_jobs;
EXCEPTION
WHEN OTHERS THEN
p_retval := 2;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_get error: %', SQLERRM;
END;
$$;
-- Comments
COMMENT ON FUNCTION broker_get IS 'Fetches the next pending job from the specified queue';
@@ -1,113 +0,0 @@
-- broker_run function
-- Executes a job by its ID
-- Returns: p_retval (0=success, >0=error), p_errmsg (error message)
CREATE OR REPLACE FUNCTION broker_run(
p_job_id BIGINT,
OUT p_retval INTEGER,
OUT p_errmsg TEXT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_job_record RECORD;
v_execute_result TEXT;
v_error_occurred BOOLEAN := false;
BEGIN
p_retval := 0;
p_errmsg := '';
v_execute_result := '';
-- Validate job ID
IF p_job_id IS NULL OR p_job_id <= 0 THEN
p_retval := 1;
p_errmsg := 'Invalid job ID';
RETURN;
END IF;
-- Get job details
SELECT id_broker_jobs, execute_str, job_language, run_as, complete_status
INTO v_job_record
FROM broker_jobs
WHERE id_broker_jobs = p_job_id
FOR UPDATE;
-- Check if job exists
IF NOT FOUND THEN
p_retval := 2;
p_errmsg := 'Job not found';
RETURN;
END IF;
-- Check if job is in running state
IF v_job_record.complete_status != 1 THEN
p_retval := 3;
p_errmsg := format('Job is not in running state (status: %s)', v_job_record.complete_status);
RETURN;
END IF;
-- Execute the job
BEGIN
-- For SQL/PLPGSQL jobs, execute directly
IF v_job_record.job_language IN ('sql', 'plpgsql') THEN
EXECUTE v_job_record.execute_str;
v_execute_result := 'Success';
ELSE
-- Other languages would need external execution
p_retval := 4;
p_errmsg := format('Unsupported job language: %s', v_job_record.job_language);
v_error_occurred := true;
END IF;
EXCEPTION
WHEN OTHERS THEN
v_error_occurred := true;
p_retval := 5;
p_errmsg := SQLERRM;
v_execute_result := format('Error: %s', SQLERRM);
END;
-- Update job with results
IF v_error_occurred THEN
UPDATE broker_jobs
SET complete_status = 3, -- failed
error_msg = p_errmsg,
execute_result = v_execute_result,
completed_at = NOW(),
updated_at = NOW()
WHERE id_broker_jobs = p_job_id;
ELSE
UPDATE broker_jobs
SET complete_status = 2, -- completed
execute_result = v_execute_result,
error_msg = NULL,
completed_at = NOW(),
updated_at = NOW()
WHERE id_broker_jobs = p_job_id;
END IF;
EXCEPTION
WHEN OTHERS THEN
p_retval := 6;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_run error: %', SQLERRM;
-- Try to update job status to failed
BEGIN
UPDATE broker_jobs
SET complete_status = 3, -- failed
error_msg = SQLERRM,
completed_at = NOW(),
updated_at = NOW()
WHERE id_broker_jobs = p_job_id;
EXCEPTION
WHEN OTHERS THEN
-- Ignore update errors
NULL;
END;
END;
$$;
-- Comments
COMMENT ON FUNCTION broker_run IS 'Executes a job by its ID and updates the status';
@@ -1,95 +0,0 @@
-- broker_set function
-- Sets broker runtime options and context
-- Supports: user, application_name, and custom settings
-- Returns: p_retval (0=success, >0=error), p_errmsg (error message)
CREATE OR REPLACE FUNCTION broker_set(
p_option_name TEXT,
p_option_value TEXT,
OUT p_retval INTEGER,
OUT p_errmsg TEXT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_sql TEXT;
BEGIN
p_retval := 0;
p_errmsg := '';
-- Validate inputs
IF p_option_name IS NULL OR p_option_name = '' THEN
p_retval := 1;
p_errmsg := 'Option name is required';
RETURN;
END IF;
-- Handle different option types
CASE LOWER(p_option_name)
WHEN 'user' THEN
-- Set session user context
-- This is useful for audit trails and permissions
BEGIN
v_sql := format('SET SESSION AUTHORIZATION %I', p_option_value);
EXECUTE v_sql;
EXCEPTION
WHEN OTHERS THEN
p_retval := 2;
p_errmsg := format('Failed to set user: %s', SQLERRM);
RETURN;
END;
WHEN 'application_name' THEN
-- Set application name (visible in pg_stat_activity)
BEGIN
v_sql := format('SET application_name TO %L', p_option_value);
EXECUTE v_sql;
EXCEPTION
WHEN OTHERS THEN
p_retval := 3;
p_errmsg := format('Failed to set application_name: %s', SQLERRM);
RETURN;
END;
WHEN 'search_path' THEN
-- Set schema search path
BEGIN
v_sql := format('SET search_path TO %s', p_option_value);
EXECUTE v_sql;
EXCEPTION
WHEN OTHERS THEN
p_retval := 4;
p_errmsg := format('Failed to set search_path: %s', SQLERRM);
RETURN;
END;
WHEN 'timezone' THEN
-- Set timezone
BEGIN
v_sql := format('SET timezone TO %L', p_option_value);
EXECUTE v_sql;
EXCEPTION
WHEN OTHERS THEN
p_retval := 5;
p_errmsg := format('Failed to set timezone: %s', SQLERRM);
RETURN;
END;
ELSE
-- Unknown option
p_retval := 10;
p_errmsg := format('Unknown option: %s', p_option_name);
RETURN;
END CASE;
EXCEPTION
WHEN OTHERS THEN
p_retval := 99;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_set error: %', SQLERRM;
END;
$$;
-- Comments
COMMENT ON FUNCTION broker_set IS 'Sets broker runtime options and session context (user, application_name, search_path, timezone)';
@@ -1,82 +0,0 @@
-- broker_register_instance function
-- Registers a new broker instance in the database
-- Returns: p_retval (0=success, >0=error), p_errmsg (error message), p_instance_id (new instance ID)
CREATE OR REPLACE FUNCTION broker_register_instance(
p_name TEXT,
p_hostname TEXT,
p_pid INTEGER,
p_version TEXT,
p_queue_count INTEGER,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_instance_id BIGINT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_active_count INTEGER;
BEGIN
p_retval := 0;
p_errmsg := '';
p_instance_id := NULL;
-- Validate inputs
IF p_name IS NULL OR p_name = '' THEN
p_retval := 1;
p_errmsg := 'Instance name is required';
RETURN;
END IF;
IF p_hostname IS NULL OR p_hostname = '' THEN
p_retval := 2;
p_errmsg := 'Hostname is required';
RETURN;
END IF;
-- Check for existing active instances
-- Only one broker instance should be active per database
SELECT COUNT(*)
INTO v_active_count
FROM broker_queueinstance
WHERE status = 'active';
IF v_active_count > 0 THEN
p_retval := 3;
p_errmsg := 'Another broker instance is already active in this database. Only one broker instance per database is allowed.';
RETURN;
END IF;
-- Insert new instance
INSERT INTO broker_queueinstance (
name,
hostname,
pid,
version,
status,
queue_count,
started_at,
last_ping_at
) VALUES (
p_name,
p_hostname,
p_pid,
p_version,
'active',
p_queue_count,
NOW(),
NOW()
)
RETURNING id_broker_queueinstance INTO p_instance_id;
EXCEPTION
WHEN OTHERS THEN
p_retval := 99;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_register_instance error: %', SQLERRM;
END;
$$;
-- Comments
COMMENT ON FUNCTION broker_register_instance IS 'Registers a new broker instance';
@@ -1,91 +0,0 @@
-- broker_add_job function
-- Adds a new job to the broker queue and sends a notification
-- Returns: p_retval (0=success, >0=error), p_errmsg (error message), p_job_id (new job ID)
CREATE OR REPLACE FUNCTION broker_add_job(
p_job_name TEXT,
p_execute_str TEXT,
p_job_queue INTEGER DEFAULT 1,
p_job_priority INTEGER DEFAULT 0,
p_job_language TEXT DEFAULT 'sql',
p_run_as TEXT DEFAULT NULL,
p_schedule_id BIGINT DEFAULT NULL,
p_depends_on TEXT[] DEFAULT NULL,
OUT p_retval INTEGER,
OUT p_errmsg TEXT,
OUT p_job_id BIGINT
)
RETURNS RECORD
LANGUAGE plpgsql
AS $$
DECLARE
v_notification_payload JSON;
BEGIN
p_retval := 0;
p_errmsg := '';
p_job_id := NULL;
-- Validate inputs
IF p_job_name IS NULL OR p_job_name = '' THEN
p_retval := 1;
p_errmsg := 'Job name is required';
RETURN;
END IF;
IF p_execute_str IS NULL OR p_execute_str = '' THEN
p_retval := 2;
p_errmsg := 'Execute string is required';
RETURN;
END IF;
IF p_job_queue IS NULL OR p_job_queue <= 0 THEN
p_retval := 3;
p_errmsg := 'Invalid job queue number';
RETURN;
END IF;
-- Insert new job
INSERT INTO broker_jobs (
job_name,
job_priority,
job_queue,
job_language,
execute_str,
run_as,
rid_broker_schedule,
depends_on,
complete_status
) VALUES (
p_job_name,
p_job_priority,
p_job_queue,
p_job_language,
p_execute_str,
p_run_as,
p_schedule_id,
p_depends_on,
0 -- pending
)
RETURNING id_broker_jobs INTO p_job_id;
-- Create notification payload
v_notification_payload := json_build_object(
'id', p_job_id,
'job_name', p_job_name,
'job_queue', p_job_queue,
'job_priority', p_job_priority
);
-- Send notification to broker
PERFORM pg_notify('broker.event', v_notification_payload::text);
EXCEPTION
WHEN OTHERS THEN
p_retval := 99;
p_errmsg := SQLERRM;
RAISE WARNING 'broker_add_job error: %', SQLERRM;
END;
$$;
-- Comments
COMMENT ON FUNCTION broker_add_job IS 'Adds a new job to the broker queue and sends a NOTIFY event';
@@ -0,0 +1,60 @@
-- Reference role/grant setup for pgsql-broker.
--
-- Applied via `pgsql-broker install --with-roles` (run once per cluster;
-- the schema/grant statements are safe to re-run per database). The
-- __BROKER_*_PASSWORD__ placeholders are substituted by the installer at
-- render time -- never edit this file to hardcode a real password. Each
-- CREATE ROLE is guarded so re-running this (e.g. against a second
-- configured database) rotates the password via ALTER ROLE instead of
-- failing on an already-existing role.
--
-- Roles:
-- broker_admin -- schema owner, runs migrations (`pgsql-broker install`).
-- Needs BYPASSRLS so broker_recover_stale_jobs (SECURITY
-- DEFINER, owned by this role) can sweep all tenants.
-- broker_runtime -- the role the running broker process connects as.
-- No BYPASSRLS, no ownership, SEARCH_PATH=broker so the
-- broker's unqualified table/function references resolve.
-- broker_enqueue -- narrow role for services that only need to add jobs.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'broker_admin') THEN
CREATE ROLE broker_admin LOGIN PASSWORD __BROKER_ADMIN_PASSWORD__ BYPASSRLS;
ELSE
ALTER ROLE broker_admin WITH LOGIN PASSWORD __BROKER_ADMIN_PASSWORD__ BYPASSRLS;
END IF;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'broker_runtime') THEN
CREATE ROLE broker_runtime LOGIN PASSWORD __BROKER_RUNTIME_PASSWORD__;
ELSE
ALTER ROLE broker_runtime WITH LOGIN PASSWORD __BROKER_RUNTIME_PASSWORD__;
END IF;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'broker_enqueue') THEN
CREATE ROLE broker_enqueue LOGIN PASSWORD __BROKER_ENQUEUE_PASSWORD__;
ELSE
ALTER ROLE broker_enqueue WITH LOGIN PASSWORD __BROKER_ENQUEUE_PASSWORD__;
END IF;
END
$$;
ALTER ROLE broker_runtime SET search_path = broker, public;
ALTER ROLE broker_enqueue SET search_path = broker, public;
-- Run once broker.broker_jobs etc. already exist (i.e. after `pgsql-broker install`
-- as broker_admin), so schema ownership/grants land on the right objects.
ALTER SCHEMA broker OWNER TO broker_admin;
GRANT USAGE ON SCHEMA broker TO broker_runtime, broker_enqueue;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA broker TO broker_runtime;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA broker TO broker_runtime;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA broker TO broker_runtime;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA broker FROM broker_enqueue;
GRANT EXECUTE ON FUNCTION broker.broker_add_job TO broker_enqueue;
GRANT EXECUTE ON FUNCTION broker.broker_add_job_simple TO broker_enqueue;
GRANT EXECUTE ON FUNCTION broker.broker_set_tenant TO broker_enqueue;
GRANT INSERT, SELECT ON broker.broker_jobs, broker.broker_job_dependency TO broker_enqueue;
GRANT USAGE ON broker.broker_jobs_id_broker_jobs_seq TO broker_enqueue;
@@ -1,10 +0,0 @@
-- PostgreSQL Broker Tables Installation Script
-- Run this script to create all required tables
\echo 'Installing PostgreSQL Broker tables...'
\i 01_broker_queueinstance.sql
\i 02_broker_schedule.sql
\i 03_broker_jobs.sql
\echo 'PostgreSQL Broker tables installed successfully!'
@@ -1,31 +0,0 @@
-- broker_queueinstance table
-- Tracks active and historical broker queue instances
CREATE TABLE IF NOT EXISTS broker_queueinstance (
id_broker_queueinstance BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
hostname VARCHAR(255) NOT NULL,
pid INTEGER NOT NULL,
version VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_ping_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
shutdown_at TIMESTAMP WITH TIME ZONE,
queue_count INTEGER NOT NULL DEFAULT 0,
jobs_handled BIGINT NOT NULL DEFAULT 0,
CONSTRAINT broker_queueinstance_status_check CHECK (status IN ('active', 'inactive', 'shutdown'))
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_broker_queueinstance_status ON broker_queueinstance(status);
CREATE INDEX IF NOT EXISTS idx_broker_queueinstance_hostname ON broker_queueinstance(hostname);
CREATE INDEX IF NOT EXISTS idx_broker_queueinstance_last_ping ON broker_queueinstance(last_ping_at);
-- Comments
COMMENT ON TABLE broker_queueinstance IS 'Tracks broker queue instances (active and historical)';
COMMENT ON COLUMN broker_queueinstance.name IS 'Human-readable name of the broker instance';
COMMENT ON COLUMN broker_queueinstance.hostname IS 'Hostname where the broker is running';
COMMENT ON COLUMN broker_queueinstance.pid IS 'Process ID of the broker';
COMMENT ON COLUMN broker_queueinstance.status IS 'Current status: active, inactive, or shutdown';
COMMENT ON COLUMN broker_queueinstance.jobs_handled IS 'Total number of jobs handled by this instance';
@@ -1,50 +0,0 @@
-- broker_schedule table
-- Stores scheduled jobs (cron-like functionality)
CREATE TABLE IF NOT EXISTS broker_schedule (
id_broker_schedule BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
cron_expr VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
job_name VARCHAR(255) NOT NULL,
job_priority INTEGER NOT NULL DEFAULT 0,
job_queue INTEGER NOT NULL DEFAULT 1,
job_language VARCHAR(50) NOT NULL DEFAULT 'sql',
execute_str TEXT NOT NULL,
run_as VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_run_at TIMESTAMP WITH TIME ZONE,
next_run_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT broker_schedule_job_queue_check CHECK (job_queue > 0)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_broker_schedule_enabled ON broker_schedule(enabled);
CREATE INDEX IF NOT EXISTS idx_broker_schedule_next_run ON broker_schedule(next_run_at) WHERE enabled = true;
CREATE INDEX IF NOT EXISTS idx_broker_schedule_name ON broker_schedule(name);
-- Comments
COMMENT ON TABLE broker_schedule IS 'Scheduled jobs (cron-like functionality)';
COMMENT ON COLUMN broker_schedule.name IS 'Unique name for the schedule';
COMMENT ON COLUMN broker_schedule.cron_expr IS 'Cron expression for scheduling';
COMMENT ON COLUMN broker_schedule.enabled IS 'Whether the schedule is active';
COMMENT ON COLUMN broker_schedule.job_name IS 'Name of the job to create';
COMMENT ON COLUMN broker_schedule.execute_str IS 'SQL or code to execute';
COMMENT ON COLUMN broker_schedule.last_run_at IS 'Last time the job was executed';
COMMENT ON COLUMN broker_schedule.next_run_at IS 'Next scheduled execution time';
-- Trigger to update updated_at
CREATE OR REPLACE FUNCTION tf_broker_schedule_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER t_broker_schedule_updated_at
BEFORE UPDATE ON broker_schedule
FOR EACH ROW
EXECUTE FUNCTION tf_broker_schedule_update_timestamp();
@@ -1,62 +0,0 @@
-- broker_jobs table
-- Stores jobs to be executed by the broker
CREATE TABLE IF NOT EXISTS broker_jobs (
id_broker_jobs BIGSERIAL PRIMARY KEY,
job_name VARCHAR(255) NOT NULL,
job_priority INTEGER NOT NULL DEFAULT 0,
job_queue INTEGER NOT NULL DEFAULT 1,
job_language VARCHAR(50) NOT NULL DEFAULT 'sql',
execute_str TEXT NOT NULL,
execute_result TEXT,
error_msg TEXT,
complete_status INTEGER NOT NULL DEFAULT 0,
run_as VARCHAR(100),
rid_broker_schedule BIGINT,
rid_broker_queueinstance BIGINT,
depends_on TEXT[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT broker_jobs_complete_status_check CHECK (complete_status IN (0, 1, 2, 3, 4)),
CONSTRAINT broker_jobs_job_queue_check CHECK (job_queue > 0),
CONSTRAINT fk_schedule FOREIGN KEY (rid_broker_schedule) REFERENCES broker_schedule(id_broker_schedule) ON DELETE SET NULL,
CONSTRAINT fk_instance FOREIGN KEY (rid_broker_queueinstance) REFERENCES broker_queueinstance(id_broker_queueinstance) ON DELETE SET NULL
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_broker_jobs_status ON broker_jobs(complete_status);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_queue ON broker_jobs(job_queue, complete_status, job_priority);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_schedule ON broker_jobs(rid_broker_schedule);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_instance ON broker_jobs(rid_broker_queueinstance);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_created ON broker_jobs(created_at);
CREATE INDEX IF NOT EXISTS idx_broker_jobs_name ON broker_jobs(job_name, complete_status);
-- Comments
COMMENT ON TABLE broker_jobs IS 'Job queue for broker execution';
COMMENT ON COLUMN broker_jobs.job_name IS 'Name/description of the job';
COMMENT ON COLUMN broker_jobs.job_priority IS 'Job priority (higher = more important)';
COMMENT ON COLUMN broker_jobs.job_queue IS 'Queue number (allows parallel processing)';
COMMENT ON COLUMN broker_jobs.job_language IS 'Execution language (sql, plpgsql, etc.)';
COMMENT ON COLUMN broker_jobs.execute_str IS 'SQL or code to execute';
COMMENT ON COLUMN broker_jobs.complete_status IS '0=pending, 1=running, 2=completed, 3=failed, 4=cancelled';
COMMENT ON COLUMN broker_jobs.run_as IS 'User context to run the job as';
COMMENT ON COLUMN broker_jobs.rid_broker_schedule IS 'Reference to schedule if job was scheduled';
COMMENT ON COLUMN broker_jobs.rid_broker_queueinstance IS 'Instance that processed this job';
COMMENT ON COLUMN broker_jobs.depends_on IS 'Array of job names that must be completed before this job can run';
-- Trigger to update updated_at
CREATE OR REPLACE FUNCTION tf_broker_jobs_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER t_broker_jobs_updated_at
BEFORE UPDATE ON broker_jobs
FOR EACH ROW
EXECUTE FUNCTION tf_broker_jobs_update_timestamp();
+130
View File
@@ -0,0 +1,130 @@
// Package metrics defines the broker's Prometheus collectors and a small
// embedded HTTP server that exposes them, both as the standard /metrics
// exposition endpoint and as a single-page HTML dashboard.
package metrics
import (
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
)
// Metrics holds every Prometheus collector the broker exposes. It is safe
// for concurrent use, and a nil *Metrics is safe to call methods on (all
// recording methods become no-ops), so callers that construct a Broker
// without metrics enabled don't need to special-case it.
type Metrics struct {
Registry *prometheus.Registry
jobsCompleted *prometheus.CounterVec
jobsFailed *prometheus.CounterVec
jobsRequeued *prometheus.CounterVec
jobDuration *prometheus.HistogramVec
jobsQueued *prometheus.GaugeVec
databaseCount prometheus.Gauge
queueCount *prometheus.GaugeVec
}
// New creates a Metrics instance with a fresh (non-global) registry, so
// multiple brokers can coexist in the same process -- e.g. in tests --
// without colliding on prometheus.DefaultRegisterer.
func New() *Metrics {
registry := prometheus.NewRegistry()
m := &Metrics{
Registry: registry,
jobsCompleted: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "broker_jobs_completed_total",
Help: "Total number of jobs that completed successfully.",
}, []string{"database", "job_group", "job_name"}),
jobsFailed: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "broker_jobs_failed_total",
Help: "Total number of jobs that were dead-lettered after exhausting retries.",
}, []string{"database", "job_group", "job_name"}),
jobsRequeued: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "broker_jobs_requeued_total",
Help: "Total number of job attempts that failed and were requeued for retry.",
}, []string{"database", "job_group", "job_name"}),
jobDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "broker_job_duration_seconds",
Help: "Job execution duration in seconds, by group and name.",
Buckets: prometheus.DefBuckets,
}, []string{"database", "job_group", "job_name"}),
jobsQueued: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "broker_jobs_queued",
Help: "Current number of pending (not yet claimed) jobs, by database and queue.",
}, []string{"database", "queue"}),
databaseCount: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "broker_databases",
Help: "Number of database instances managed by this broker process.",
}),
queueCount: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "broker_queues",
Help: "Number of queues configured for a database instance.",
}, []string{"database"}),
}
registry.MustRegister(
m.jobsCompleted,
m.jobsFailed,
m.jobsRequeued,
m.jobDuration,
m.jobsQueued,
m.databaseCount,
m.queueCount,
)
return m
}
// RecordJobCompleted records a successfully completed job attempt.
func (m *Metrics) RecordJobCompleted(database, group, name string, duration time.Duration) {
if m == nil {
return
}
m.jobsCompleted.WithLabelValues(database, group, name).Inc()
m.jobDuration.WithLabelValues(database, group, name).Observe(duration.Seconds())
}
// RecordJobFailed records a job attempt that was dead-lettered (attempts exhausted).
func (m *Metrics) RecordJobFailed(database, group, name string, duration time.Duration) {
if m == nil {
return
}
m.jobsFailed.WithLabelValues(database, group, name).Inc()
m.jobDuration.WithLabelValues(database, group, name).Observe(duration.Seconds())
}
// RecordJobRequeued records a job attempt that failed but was requeued for retry.
func (m *Metrics) RecordJobRequeued(database, group, name string, duration time.Duration) {
if m == nil {
return
}
m.jobsRequeued.WithLabelValues(database, group, name).Inc()
m.jobDuration.WithLabelValues(database, group, name).Observe(duration.Seconds())
}
// SetJobsQueued sets the current pending job count for a database/queue pair.
func (m *Metrics) SetJobsQueued(database string, queue int, count float64) {
if m == nil {
return
}
m.jobsQueued.WithLabelValues(database, strconv.Itoa(queue)).Set(count)
}
// SetDatabaseCount sets the number of database instances managed by this process.
func (m *Metrics) SetDatabaseCount(n int) {
if m == nil {
return
}
m.databaseCount.Set(float64(n))
}
// SetQueueCount sets the number of queues configured for a database instance.
func (m *Metrics) SetQueueCount(database string, n int) {
if m == nil {
return
}
m.queueCount.WithLabelValues(database).Set(float64(n))
}
+215
View File
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pgsql-broker metrics</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1115;
--panel: #161a22;
--border: #262b36;
--text: #e6e9ef;
--muted: #8b93a7;
--accent: #5aa8ff;
--good: #4caf7d;
--bad: #e5636b;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2rem;
background: var(--bg);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.25rem;
}
#status {
color: var(--muted);
font-size: 0.8rem;
margin-bottom: 1.5rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 1rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.card h2 {
font-size: 0.9rem;
margin: 0 0 0.75rem;
color: var(--accent);
font-weight: 600;
}
.card .help {
color: var(--muted);
font-size: 0.75rem;
margin: -0.5rem 0 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
td, th {
text-align: left;
padding: 0.2rem 0.4rem 0.2rem 0;
border-bottom: 1px solid var(--border);
}
td.value {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
td.labels {
color: var(--muted);
}
.completed .value { color: var(--good); }
.failed .value { color: var(--bad); }
.empty {
color: var(--muted);
font-size: 0.8rem;
}
</style>
</head>
<body>
<h1>pgsql-broker metrics</h1>
<div id="status">loading&hellip;</div>
<div class="grid" id="grid"></div>
<script>
(function () {
"use strict";
// Metric name -> { help, className } for the cards we render, in order.
var METRICS = [
{ name: "broker_jobs_completed_total", title: "Jobs completed", cls: "completed" },
{ name: "broker_jobs_failed_total", title: "Jobs failed (dead-lettered)", cls: "failed" },
{ name: "broker_jobs_requeued_total", title: "Jobs requeued (retries)", cls: "" },
{ name: "broker_jobs_queued", title: "Jobs currently queued", cls: "" },
{ name: "broker_job_duration_seconds_sum", title: "Job duration, total seconds by group/name", cls: "" },
{ name: "broker_job_duration_seconds_count", title: "Job duration, sample count by group/name", cls: "" },
{ name: "broker_databases", title: "Databases managed", cls: "" },
{ name: "broker_queues", title: "Queues per database", cls: "" }
];
// Parses Prometheus text exposition format into { name: [{labels, value}] }.
function parseMetrics(text) {
var byName = {};
var lines = text.split("\n");
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (!line || line[0] === "#") continue;
var name, labels = {}, rest;
var braceIdx = line.indexOf("{");
var spaceIdx;
if (braceIdx !== -1) {
name = line.slice(0, braceIdx);
var closeIdx = line.indexOf("}", braceIdx);
if (closeIdx === -1) continue;
var labelStr = line.slice(braceIdx + 1, closeIdx);
var labelRe = /(\w+)="((?:[^"\\]|\\.)*)"/g;
var m;
while ((m = labelRe.exec(labelStr)) !== null) {
labels[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
rest = line.slice(closeIdx + 1).trim();
} else {
spaceIdx = line.indexOf(" ");
if (spaceIdx === -1) continue;
name = line.slice(0, spaceIdx);
rest = line.slice(spaceIdx + 1).trim();
}
var value = parseFloat(rest.split(" ")[0]);
if (isNaN(value)) continue;
if (!byName[name]) byName[name] = [];
byName[name].push({ labels: labels, value: value });
}
return byName;
}
function formatLabels(labels) {
var keys = Object.keys(labels).sort();
return keys.map(function (k) { return k + "=" + labels[k]; }).join(", ");
}
function formatValue(v) {
if (Number.isInteger(v)) return String(v);
return v.toFixed(3);
}
function render(byName) {
var grid = document.getElementById("grid");
grid.innerHTML = "";
METRICS.forEach(function (spec) {
var series = byName[spec.name] || [];
var card = document.createElement("div");
card.className = "card " + spec.cls;
var h2 = document.createElement("h2");
h2.textContent = spec.title;
card.appendChild(h2);
if (series.length === 0) {
var empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "no data yet";
card.appendChild(empty);
} else {
var table = document.createElement("table");
series.sort(function (a, b) { return b.value - a.value; });
series.forEach(function (s) {
var tr = document.createElement("tr");
var tdLabels = document.createElement("td");
tdLabels.className = "labels";
tdLabels.textContent = formatLabels(s.labels) || "(none)";
var tdValue = document.createElement("td");
tdValue.className = "value";
tdValue.textContent = formatValue(s.value);
tr.appendChild(tdLabels);
tr.appendChild(tdValue);
table.appendChild(tr);
});
card.appendChild(table);
}
grid.appendChild(card);
});
}
function refresh() {
fetch("metrics", { cache: "no-store" })
.then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.text();
})
.then(function (text) {
render(parseMetrics(text));
document.getElementById("status").textContent =
"last updated " + new Date().toLocaleTimeString();
})
.catch(function (err) {
document.getElementById("status").textContent =
"failed to load metrics: " + err.message;
});
}
refresh();
setInterval(refresh, 5000);
})();
</script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
package metrics
import (
"context"
_ "embed"
"fmt"
"net"
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
)
//go:embed page.html
var dashboardHTML []byte
// Server is the embedded HTTP server exposing /metrics (standard Prometheus
// exposition format) and / (a single-page HTML dashboard that polls
// /metrics).
type Server struct {
httpServer *http.Server
listener net.Listener
logger adapter.Logger
}
// NewServer builds a Server bound to addr (e.g. "127.0.0.1:9469"). Binding
// happens immediately so a port conflict is reported to the caller rather
// than surfacing later in a background goroutine.
func NewServer(m *Metrics, addr string, logger adapter.Logger) (*Server, error) {
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to bind metrics server to %s: %w", addr, err)
}
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{}))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(dashboardHTML)
})
return &Server{
httpServer: &http.Server{Handler: mux},
listener: listener,
logger: logger.With("component", "metrics-server"),
}, nil
}
// Addr returns the actual bound address (useful when addr was given with a
// ":0" port).
func (s *Server) Addr() string {
return s.listener.Addr().String()
}
// Start serves in the background. It returns immediately; Serve errors
// (other than a clean Shutdown) are logged.
func (s *Server) Start() {
s.logger.Info("metrics server listening", "addr", s.Addr())
go func() {
if err := s.httpServer.Serve(s.listener); err != nil && err != http.ErrServerClosed {
s.logger.Error("metrics server stopped unexpectedly", "error", err)
}
}()
}
// Stop gracefully shuts down the server.
func (s *Server) Stop(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
+14
View File
@@ -16,10 +16,24 @@ type Job struct {
RunAs string `json:"run_as"` RunAs string `json:"run_as"`
UserLogin string `json:"user_login"` UserLogin string `json:"user_login"`
ScheduleID int64 `json:"schedule_id"` ScheduleID int64 `json:"schedule_id"`
TenantID string `json:"tenant_id"`
AttemptCount int `json:"attempt_count"`
MaxAttempts int `json:"max_attempts"`
LeaseToken string `json:"lease_token,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// WakeNotification is the payload sent over pg_notify('broker.event', ...).
// It carries only what a worker needs to decide whether to wake: the queue
// number. job_id is included solely for logging -- workers always re-claim
// via broker_get rather than executing the notified id directly.
type WakeNotification struct {
Queue int `json:"queue"`
JobID int64 `json:"job_id,omitempty"`
}
// Instance represents a broker instance // Instance represents a broker instance
type Instance struct { type Instance struct {
ID int64 `json:"id"` ID int64 `json:"id"`
+14 -14
View File
@@ -6,7 +6,7 @@ import (
"sync" "sync"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/models" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/metrics"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/worker" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/worker"
) )
@@ -33,6 +33,10 @@ type Config struct {
BufferSize int BufferSize int
TimerSeconds int TimerSeconds int
FetchSize int FetchSize int
TenantID string
LeaseSeconds int
Metrics *metrics.Metrics
DatabaseName string
} }
// New creates a new queue manager // New creates a new queue manager
@@ -70,6 +74,10 @@ func (q *Queue) Start(cfg Config) error {
BufferSize: cfg.BufferSize, BufferSize: cfg.BufferSize,
TimerSeconds: cfg.TimerSeconds, TimerSeconds: cfg.TimerSeconds,
FetchSize: cfg.FetchSize, FetchSize: cfg.FetchSize,
TenantID: cfg.TenantID,
LeaseSeconds: cfg.LeaseSeconds,
Metrics: cfg.Metrics,
DatabaseName: cfg.DatabaseName,
}) })
if err := w.Start(q.ctx); err != nil { if err := w.Start(q.ctx); err != nil {
@@ -109,26 +117,18 @@ func (q *Queue) Stop() error {
return nil return nil
} }
// AddJob adds a job to the least busy worker // Wake signals every worker in the queue to check for available jobs
func (q *Queue) AddJob(job models.Job) error { // immediately. There is no job hand-off: fetching is always DB-driven via
// broker_get, so waking a worker that finds nothing is harmless.
func (q *Queue) Wake() {
q.mu.RLock() q.mu.RLock()
defer q.mu.RUnlock() defer q.mu.RUnlock()
if len(q.workers) == 0 {
return fmt.Errorf("no workers available")
}
// Simple round-robin: use first available worker
// Could be enhanced with load balancing
for _, w := range q.workers { for _, w := range q.workers {
if err := w.AddJob(job); err == nil { w.Wake()
return nil
} }
} }
return fmt.Errorf("all workers are busy")
}
// GetStats returns statistics for all workers in the queue // GetStats returns statistics for all workers in the queue
func (q *Queue) GetStats() map[int]worker.Stats { func (q *Queue) GetStats() map[int]worker.Stats {
q.mu.RLock() q.mu.RLock()
+179 -60
View File
@@ -2,13 +2,14 @@ package worker
import ( import (
"context" "context"
"database/sql" // Import sql package "database/sql"
"fmt" "fmt"
"runtime/debug"
"sync" "sync"
"time" "time"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/models" "git.warky.dev/wdevs/pgsql-broker/pkg/broker/metrics"
) )
// Worker represents a single job processing worker // Worker represents a single job processing worker
@@ -18,7 +19,7 @@ type Worker struct {
InstanceID int64 InstanceID int64
db adapter.DBAdapter db adapter.DBAdapter
logger adapter.Logger logger adapter.Logger
jobChan chan models.Job wakeChan chan struct{}
shutdown chan struct{} shutdown chan struct{}
wg *sync.WaitGroup wg *sync.WaitGroup
running bool running bool
@@ -27,6 +28,10 @@ type Worker struct {
jobsHandled int64 jobsHandled int64
timerSeconds int timerSeconds int
fetchSize int fetchSize int
tenantID string
leaseSeconds int
metrics *metrics.Metrics
databaseName string
} }
// Stats holds worker statistics // Stats holds worker statistics
@@ -46,21 +51,34 @@ type Config struct {
BufferSize int BufferSize int
TimerSeconds int TimerSeconds int
FetchSize int FetchSize int
TenantID string
LeaseSeconds int
Metrics *metrics.Metrics
DatabaseName string
} }
// New creates a new worker // New creates a new worker
func New(cfg Config) *Worker { func New(cfg Config) *Worker {
leaseSeconds := cfg.LeaseSeconds
if leaseSeconds <= 0 {
leaseSeconds = 60
}
return &Worker{ return &Worker{
ID: cfg.ID, ID: cfg.ID,
QueueNumber: cfg.QueueNumber, QueueNumber: cfg.QueueNumber,
InstanceID: cfg.InstanceID, InstanceID: cfg.InstanceID,
db: cfg.DBAdapter, db: cfg.DBAdapter,
logger: cfg.Logger.With("worker_id", cfg.ID).With("queue", cfg.QueueNumber), logger: cfg.Logger.With("worker_id", cfg.ID).With("queue", cfg.QueueNumber),
jobChan: make(chan models.Job, cfg.BufferSize), wakeChan: make(chan struct{}, 1),
shutdown: make(chan struct{}), shutdown: make(chan struct{}),
wg: &sync.WaitGroup{}, wg: &sync.WaitGroup{},
timerSeconds: cfg.TimerSeconds, timerSeconds: cfg.TimerSeconds,
fetchSize: cfg.FetchSize, fetchSize: cfg.FetchSize,
tenantID: cfg.TenantID,
leaseSeconds: leaseSeconds,
metrics: cfg.Metrics,
databaseName: cfg.DatabaseName,
} }
} }
@@ -77,11 +95,48 @@ func (w *Worker) Start(ctx context.Context) error {
w.logger.Info("worker starting") w.logger.Info("worker starting")
w.wg.Add(1) w.wg.Add(1)
go w.processLoop(ctx) go w.superviseProcessLoop(ctx)
return nil return nil
} }
// superviseProcessLoop runs processLoop for the life of the worker,
// restarting it (after a short backoff) if it ever panics, so a bug in job
// processing can never permanently kill this worker's goroutine.
func (w *Worker) superviseProcessLoop(ctx context.Context) {
defer w.wg.Done()
for {
select {
case <-w.shutdown:
return
case <-ctx.Done():
return
default:
}
if w.runProcessLoopOnce(ctx) {
return
}
time.Sleep(time.Second)
}
}
// runProcessLoopOnce runs processLoop, recovering any panic. It returns true
// if processLoop returned normally (shutdown/context done, no restart
// needed) and false if it panicked (caller should restart it).
func (w *Worker) runProcessLoopOnce(ctx context.Context) (clean bool) {
defer func() {
if r := recover(); r != nil {
w.logger.Error("worker panic recovered, restarting", "panic", r, "stack", string(debug.Stack()))
clean = false
}
}()
w.processLoop(ctx)
return true
}
// Stop gracefully stops the worker // Stop gracefully stops the worker
func (w *Worker) Stop() error { func (w *Worker) Stop() error {
w.mu.Lock() w.mu.Lock()
@@ -103,35 +158,33 @@ func (w *Worker) Stop() error {
return nil return nil
} }
// AddJob adds a job to the worker's queue // Wake signals the worker to check for available jobs immediately, instead
func (w *Worker) AddJob(job models.Job) error { // of waiting for the next timer tick. Fetching is always DB-driven (via
// broker_get with FOR UPDATE SKIP LOCKED), so a redundant or coalesced wake
// is harmless.
func (w *Worker) Wake() {
select { select {
case w.jobChan <- job: case w.wakeChan <- struct{}{}:
return nil
default: default:
return fmt.Errorf("worker %d job channel is full", w.ID)
} }
} }
// processLoop is the main worker processing loop // processLoop is the main worker processing loop
func (w *Worker) processLoop(ctx context.Context) { func (w *Worker) processLoop(ctx context.Context) {
defer w.wg.Done()
defer w.recoverPanic()
timer := time.NewTimer(time.Duration(w.timerSeconds) * time.Second) timer := time.NewTimer(time.Duration(w.timerSeconds) * time.Second)
defer timer.Stop() defer timer.Stop()
for { for {
select { select {
case job := <-w.jobChan: case <-w.wakeChan:
w.updateActivity() w.updateActivity()
w.processJobs(ctx, &job) w.processJobs(ctx)
case <-timer.C: case <-timer.C:
// Timer expired - fetch jobs from database // Timer expired - fetch jobs from database
if w.timerSeconds > 0 { if w.timerSeconds > 0 {
w.updateActivity() w.updateActivity()
w.processJobs(ctx, nil) w.processJobs(ctx)
} }
timer.Reset(time.Duration(w.timerSeconds) * time.Second) timer.Reset(time.Duration(w.timerSeconds) * time.Second)
@@ -147,95 +200,161 @@ func (w *Worker) processLoop(ctx context.Context) {
} }
// processJobs processes jobs from the queue within a transaction // processJobs processes jobs from the queue within a transaction
func (w *Worker) processJobs(ctx context.Context, specificJob *models.Job) { func (w *Worker) processJobs(ctx context.Context) {
defer w.recoverPanic() defer w.recoverPanic()
for i := 0; i < w.fetchSize; i++ { for i := 0; i < w.fetchSize; i++ {
tx, err := w.db.Begin(ctx)
tx, err := w.db.Begin(ctx) // Start transaction
if err != nil { if err != nil {
w.logger.Error("failed to begin transaction", "error", err) w.logger.Error("failed to begin transaction", "error", err)
return return
} }
var jobID int64 if err := w.setTenantTx(ctx, tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
w.logger.Error("failed to rollback transaction", "error", rbErr)
}
w.logger.Error("failed to set tenant", "error", err)
return
}
if specificJob != nil && specificJob.ID > 0 { jobID, leaseToken, err := w.fetchNextJobTx(ctx, tx)
jobID = specificJob.ID
specificJob = nil // Only process once
} else {
jobID, err = w.fetchNextJobTx(ctx, tx) // Use transaction
if err != nil { if err != nil {
tx.Rollback() // Rollback on fetch error if rbErr := tx.Rollback(); rbErr != nil {
w.logger.Error("failed to rollback transaction", "error", rbErr)
}
w.logger.Error("failed to fetch job", "error", err) w.logger.Error("failed to fetch job", "error", err)
return return
} }
}
if jobID <= 0 { if jobID <= 0 {
tx.Rollback() // No job found, rollback // No job found, rollback
if rbErr := tx.Rollback(); rbErr != nil {
w.logger.Error("failed to rollback transaction", "error", rbErr)
}
return // No more jobs return // No more jobs
} }
jobName, jobGroup, err := w.fetchJobLabelsTx(ctx, tx, jobID)
if err != nil {
w.logger.Warn("failed to fetch job labels for metrics", "job_id", jobID, "error", err)
}
// Run the job // Run the job
if err := w.runJobTx(ctx, tx, jobID); err != nil { // Use transaction start := time.Now()
tx.Rollback() // Rollback on job execution error jobStatus, err := w.runJobTx(ctx, tx, jobID, leaseToken)
duration := time.Since(start)
if err == nil {
w.recordJobMetric(jobStatus, jobGroup, jobName, duration)
}
if err != nil {
// Rollback on genuine infra failure
if rbErr := tx.Rollback(); rbErr != nil {
w.logger.Error("failed to rollback transaction", "error", rbErr)
}
w.logger.Error("failed to run job", "job_id", jobID, "error", err) w.logger.Error("failed to run job", "job_id", jobID, "error", err)
} else { } else {
tx.Commit() // Commit if job successful if err := tx.Commit(); err != nil {
w.logger.Error("failed to commit job", "job_id", jobID, "error", err)
continue
}
w.jobsHandled++ w.jobsHandled++
} }
} }
} }
// fetchNextJobTx fetches the next job from the queue within a transaction // setTenantTx applies this worker's RLS tenant for the duration of tx.
func (w *Worker) fetchNextJobTx(ctx context.Context, tx adapter.DBTransaction) (int64, error) { func (w *Worker) setTenantTx(ctx context.Context, tx adapter.DBTransaction) error {
tenantID := w.tenantID
if tenantID == "" {
tenantID = "default"
}
_, err := tx.Exec(ctx, "SELECT broker.broker_set_tenant($1)", tenantID)
return err
}
// fetchNextJobTx fetches the next job from the queue within a transaction,
// claiming it with a lease that must be presented back to broker_run.
func (w *Worker) fetchNextJobTx(ctx context.Context, tx adapter.DBTransaction) (jobID int64, leaseToken string, err error) {
var retval int var retval int
var errmsg string var errmsg string
var nullableJobID sql.NullInt64 var nullableJobID sql.NullInt64
var nullableLeaseToken sql.NullString
err = tx.QueryRow(ctx,
"SELECT p_retval, p_errmsg, p_job_id, p_lease_token FROM broker.broker_get($1, $2, $3)",
w.QueueNumber, w.InstanceID, w.leaseSeconds,
).Scan(&retval, &errmsg, &nullableJobID, &nullableLeaseToken)
if err != nil {
return 0, "", fmt.Errorf("query error: %w", err)
}
if retval > 0 {
return 0, "", fmt.Errorf("broker_get error: %s", errmsg)
}
if !nullableJobID.Valid {
return 0, "", nil
}
return nullableJobID.Int64, nullableLeaseToken.String, nil
}
// runJobTx executes a leased job within a transaction. It only returns an
// error (triggering a rollback of the claim) on a genuine infra failure --
// job outcomes reported via p_job_status (requeued/completed/dead-lettered)
// are always committed.
func (w *Worker) runJobTx(ctx context.Context, tx adapter.DBTransaction, jobID int64, leaseToken string) (int, error) {
w.logger.Debug("running job", "job_id", jobID)
var retval int
var errmsg string
var jobStatus int
err := tx.QueryRow(ctx, err := tx.QueryRow(ctx,
"SELECT p_retval, p_errmsg, p_job_id FROM broker_get($1, $2)", "SELECT p_retval, p_errmsg, p_job_status FROM broker.broker_run($1, $2)",
w.QueueNumber, w.InstanceID, jobID, leaseToken,
).Scan(&retval, &errmsg, &nullableJobID) ).Scan(&retval, &errmsg, &jobStatus)
if err != nil { if err != nil {
return 0, fmt.Errorf("query error: %w", err) return 0, fmt.Errorf("query error: %w", err)
} }
if retval > 0 { if retval > 0 {
return 0, fmt.Errorf("broker_get error: %s", errmsg) return 0, fmt.Errorf("broker_run error: %s", errmsg)
} }
if !nullableJobID.Valid { w.logger.Debug("job finished", "job_id", jobID, "job_status", jobStatus)
return 0, nil return jobStatus, nil
} }
return nullableJobID.Int64, nil // fetchJobLabelsTx looks up the job_name/job_group of jobID for metric
} // labeling. Best-effort: callers log and continue on error rather than
// failing the job over a metrics lookup.
// runJobTx executes a job within a transaction func (w *Worker) fetchJobLabelsTx(ctx context.Context, tx adapter.DBTransaction, jobID int64) (jobName, jobGroup string, err error) {
func (w *Worker) runJobTx(ctx context.Context, tx adapter.DBTransaction, jobID int64) error { err = tx.QueryRow(ctx,
w.logger.Debug("running job", "job_id", jobID) "SELECT job_name, job_group FROM broker.broker_jobs WHERE id_broker_jobs = $1",
var retval int
var errmsg string
err := tx.QueryRow(ctx,
"SELECT p_retval, p_errmsg FROM broker_run($1)",
jobID, jobID,
).Scan(&retval, &errmsg) ).Scan(&jobName, &jobGroup)
if err != nil { if err != nil {
return fmt.Errorf("query error: %w", err) return "", "", fmt.Errorf("query error: %w", err)
}
return jobName, jobGroup, nil
} }
if retval > 0 { // recordJobMetric routes a finished job attempt to the appropriate
return fmt.Errorf("broker_run error: %s", errmsg) // Prometheus counter/histogram based on the p_job_status broker_run
// reported (0=requeued, 2=completed, 3=dead-lettered).
func (w *Worker) recordJobMetric(jobStatus int, jobGroup, jobName string, duration time.Duration) {
switch jobStatus {
case 2:
w.metrics.RecordJobCompleted(w.databaseName, jobGroup, jobName, duration)
case 3:
w.metrics.RecordJobFailed(w.databaseName, jobGroup, jobName, duration)
case 0:
w.metrics.RecordJobRequeued(w.databaseName, jobGroup, jobName, duration)
} }
w.logger.Debug("job completed", "job_id", jobID)
return nil
} }
// updateActivity updates the last activity timestamp // updateActivity updates the last activity timestamp
@@ -255,6 +374,6 @@ func (w *Worker) GetStats() (lastActivity time.Time, jobsHandled int64, running
// recoverPanic recovers from panics in the worker // recoverPanic recovers from panics in the worker
func (w *Worker) recoverPanic() { func (w *Worker) recoverPanic() {
if r := recover(); r != nil { if r := recover(); r != nil {
w.logger.Error("worker panic recovered", "panic", r) w.logger.Error("worker panic recovered", "panic", r, "stack", string(debug.Stack()))
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
# PostgreSQL Schema and Function Recommendations # PostgreSQL Schema and Function Recommendations
/home/warkanum/.claude/plans/binary-puzzling-mountain.md
**Review date:** 2026-09-14 **Review date:** 2026-09-14
**Scope:** `pkg/broker/install/sql` tables and functions, including their interaction with the Go worker. **Scope:** `pkg/broker/install/sql` tables and functions, including their interaction with the Go worker.
+2 -1
View File
@@ -2,6 +2,7 @@ package integration
import ( import (
"database/sql" "database/sql"
"fmt"
"testing" "testing"
"time" "time"
@@ -10,7 +11,7 @@ import (
) )
func TestConnection(t *testing.T) { func TestConnection(t *testing.T) {
connStr := "user=user password=password dbname=broker_test port=5433 sslmode=disable" connStr := fmt.Sprintf("user=user password=password dbname=broker_test host=%s port=%d sslmode=disable", testDBHost(), testDBPort())
var db *sql.DB var db *sql.DB
var err error var err error
+146
View File
@@ -0,0 +1,146 @@
package integration
import (
"context"
"database/sql"
"fmt"
"testing"
_ "github.com/lib/pq"
"github.com/stretchr/testify/require"
)
// TestRLSTenantIsolation verifies that a non-superuser role without
// BYPASSRLS, connected via broker_set_tenant, only ever sees jobs and
// dependency rows for its own tenant -- the core guarantee behind the
// broker_jobs/broker_job_dependency FORCE ROW LEVEL SECURITY policies.
func TestRLSTenantIsolation(t *testing.T) {
ctx := context.Background()
adminDB := setupStage5Schema(t)
// A restricted role, no BYPASSRLS, mirroring sql/roles/0001_roles.sql's
// broker_runtime (superuser test DB roles already bypass RLS entirely,
// so this test would be meaningless against the "user" role).
_, err := adminDB.Exec("DROP ROLE IF EXISTS test_broker_runtime")
require.NoError(t, err)
_, err = adminDB.Exec("CREATE ROLE test_broker_runtime LOGIN PASSWORD 'test-pass' NOSUPERUSER NOBYPASSRLS")
require.NoError(t, err)
t.Cleanup(func() {
if _, err := adminDB.Exec("REASSIGN OWNED BY test_broker_runtime TO CURRENT_USER"); err != nil {
t.Logf("warning: failed to reassign objects owned by test_broker_runtime: %v", err)
}
if _, err := adminDB.Exec("DROP OWNED BY test_broker_runtime"); err != nil {
t.Logf("warning: failed to drop grants owned by test_broker_runtime: %v", err)
}
if _, err := adminDB.Exec("DROP ROLE IF EXISTS test_broker_runtime"); err != nil {
t.Logf("warning: failed to drop role test_broker_runtime: %v", err)
}
})
for _, stmt := range []string{
"GRANT USAGE ON SCHEMA broker TO test_broker_runtime",
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA broker TO test_broker_runtime",
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA broker TO test_broker_runtime",
"GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA broker TO test_broker_runtime",
} {
_, err = adminDB.Exec(stmt)
require.NoError(t, err)
}
runtimeDB, err := sql.Open("postgres",
fmt.Sprintf("user=test_broker_runtime password=test-pass dbname=broker_test host=%s port=%d sslmode=disable options='-c search_path=broker,public'", testDBHost(), testDBPort()))
require.NoError(t, err)
defer runtimeDB.Close()
require.NoError(t, runtimeDB.Ping())
// broker_set_tenant uses SET LOCAL semantics (set_config(..., true)), so
// it only takes effect for the remainder of the transaction it runs in.
// Callers must set the tenant and perform the tenant-scoped operation in
// the same explicit transaction (as worker.go's processJobs does) -- two
// separate autocommitted statements would each run in their own
// transaction and the tenant setting would not carry over.
addJobAsTenant := func(tenant, name string) int64 {
conn, err := runtimeDB.Conn(ctx)
require.NoError(t, err)
defer conn.Close()
tx, err := conn.BeginTx(ctx, nil)
require.NoError(t, err)
_, err = tx.ExecContext(ctx, "SELECT broker.broker_set_tenant($1)", tenant)
require.NoError(t, err)
var retval int
var errmsg string
var jobID int64
err = tx.QueryRowContext(ctx, `
SELECT p_retval, p_errmsg, p_job_id FROM broker.broker_add_job(
$1, 'SELECT 1', 1, 0, 'sql', NULL, NULL, NULL, NULL, 1
)`, name,
).Scan(&retval, &errmsg, &jobID)
require.NoError(t, err)
require.Equal(t, 0, retval, errmsg)
require.NoError(t, tx.Commit())
return jobID
}
tenantAJob := addJobAsTenant("tenant-a", "tenant-a-job")
tenantBJob := addJobAsTenant("tenant-b", "tenant-b-job")
require.NotEqual(t, tenantAJob, tenantBJob)
// As tenant-a, only tenant-a's job must be visible. All of tenant-a's
// checks (including the broker_get claim below) share one explicit
// transaction so the SET LOCAL tenant context stays in effect throughout.
connA, err := runtimeDB.Conn(ctx)
require.NoError(t, err)
defer connA.Close()
txA, err := connA.BeginTx(ctx, nil)
require.NoError(t, err)
defer txA.Rollback()
_, err = txA.ExecContext(ctx, "SELECT broker.broker_set_tenant($1)", "tenant-a")
require.NoError(t, err)
var visibleCount int
err = txA.QueryRowContext(ctx, "SELECT COUNT(*) FROM broker.broker_jobs WHERE id_broker_jobs IN ($1, $2)",
tenantAJob, tenantBJob).Scan(&visibleCount)
require.NoError(t, err)
require.Equal(t, 1, visibleCount, "tenant-a must see only its own job, not tenant-b's")
var visibleName string
err = txA.QueryRowContext(ctx, "SELECT job_name FROM broker.broker_jobs WHERE id_broker_jobs = $1", tenantAJob).Scan(&visibleName)
require.NoError(t, err)
require.Equal(t, "tenant-a-job", visibleName)
// As tenant-b, only tenant-b's job must be visible.
connB, err := runtimeDB.Conn(ctx)
require.NoError(t, err)
defer connB.Close()
txB, err := connB.BeginTx(ctx, nil)
require.NoError(t, err)
defer txB.Rollback()
_, err = txB.ExecContext(ctx, "SELECT broker.broker_set_tenant($1)", "tenant-b")
require.NoError(t, err)
err = txB.QueryRowContext(ctx, "SELECT COUNT(*) FROM broker.broker_jobs WHERE id_broker_jobs IN ($1, $2)",
tenantAJob, tenantBJob).Scan(&visibleCount)
require.NoError(t, err)
require.Equal(t, 1, visibleCount, "tenant-b must see only its own job, not tenant-a's")
require.NoError(t, txB.Commit())
// broker_get run under tenant-a's context must never be able to claim
// tenant-b's job.
var claimedID sql.NullInt64
var getRetval int
var getErrmsg string
err = txA.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_job_id FROM broker.broker_get($1, NULL, $2)", 1, 60,
).Scan(&getRetval, &getErrmsg, &claimedID)
require.NoError(t, err)
require.Equal(t, 0, getRetval, getErrmsg)
require.True(t, claimedID.Valid)
require.Equal(t, tenantAJob, claimedID.Int64, "tenant-a's broker_get must only ever claim tenant-a's own job")
require.NoError(t, txA.Commit())
}
+344
View File
@@ -0,0 +1,344 @@
package integration
import (
"context"
"database/sql"
"fmt"
"log/slog"
"testing"
"time"
_ "github.com/lib/pq"
"github.com/stretchr/testify/require"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/adapter"
"git.warky.dev/wdevs/pgsql-broker/pkg/broker/install"
)
func stage5ConnStr() string {
return fmt.Sprintf("user=user password=password dbname=broker_test host=%s port=%d sslmode=disable", testDBHost(), testDBPort())
}
func newStage5Adapter(logger adapter.Logger) *adapter.PostgresAdapter {
return adapter.NewPostgresAdapter(adapter.PostgresConfig{
Host: testDBHost(), Port: testDBPort(), Database: "broker_test",
User: "user", Password: "password", SSLMode: "disable",
MaxOpenConns: 10, MaxIdleConns: 2,
ConnMaxLifetime: 5 * time.Minute, ConnMaxIdleTime: 10 * time.Minute,
}, logger)
}
// setupStage5Schema drops and re-installs a clean broker schema, returning a
// superuser *sql.DB for direct SQL against it.
func setupStage5Schema(t *testing.T) *sql.DB {
t.Helper()
db, err := connectWithRetry(stage5ConnStr(), 10, 2*time.Second)
require.NoError(t, err)
cleanupSchema(t, db)
logger := adapter.NewSlogLogger(slog.LevelWarn)
dbAdapter := newStage5Adapter(logger)
require.NoError(t, dbAdapter.Connect(context.Background()))
defer dbAdapter.Close()
installer := install.New(dbAdapter, logger)
require.NoError(t, installer.ApplyMigrations(context.Background()))
t.Cleanup(func() { db.Close() })
return db
}
// TestRepeatMigrationRunIsNoOp verifies applying the migration set a second
// time (with nothing pending) makes no changes and reports no error.
func TestRepeatMigrationRunIsNoOp(t *testing.T) {
ctx := context.Background()
setupStage5Schema(t)
logger := adapter.NewSlogLogger(slog.LevelWarn)
dbAdapter := newStage5Adapter(logger)
require.NoError(t, dbAdapter.Connect(ctx))
defer dbAdapter.Close()
installer := install.New(dbAdapter, logger)
pending, err := installer.PendingMigrations(ctx)
require.NoError(t, err)
require.Empty(t, pending, "no migrations should be pending right after install")
require.NoError(t, installer.ApplyMigrations(ctx))
require.NoError(t, installer.VerifyInstallation(ctx))
}
// TestDuplicateInstanceStartFails verifies that broker_register_instance
// rejects a second registration under the same instance name while the
// first holds the advisory lock, and succeeds again once it's released.
func TestDuplicateInstanceStartFails(t *testing.T) {
ctx := context.Background()
db := setupStage5Schema(t)
connA, err := db.Conn(ctx)
require.NoError(t, err)
defer connA.Close()
var retval int
var errmsg string
var instanceID sql.NullInt64
err = connA.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_instance_id FROM broker.broker_register_instance($1,$2,$3,$4,$5)",
"dup-test", "host-a", 111, "test", 2,
).Scan(&retval, &errmsg, &instanceID)
require.NoError(t, err)
require.Equal(t, 0, retval, "first registration should succeed: %s", errmsg)
require.True(t, instanceID.Valid)
// Second registration under the same name, on a different connection,
// must fail while connA still holds the advisory lock.
connB, err := db.Conn(ctx)
require.NoError(t, err)
defer connB.Close()
err = connB.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_instance_id FROM broker.broker_register_instance($1,$2,$3,$4,$5)",
"dup-test", "host-b", 222, "test", 2,
).Scan(&retval, &errmsg, &instanceID)
require.NoError(t, err)
require.Equal(t, 3, retval, "second registration must be rejected by the advisory lock")
require.False(t, instanceID.Valid)
// Release the lock (as registerInstance's caller would on shutdown) and
// confirm a fresh registration then succeeds.
_, err = connA.ExecContext(ctx, "SELECT pg_advisory_unlock(hashtextextended($1, 0))", "broker:dup-test")
require.NoError(t, err)
require.NoError(t, connA.Close())
connC, err := db.Conn(ctx)
require.NoError(t, err)
defer connC.Close()
err = connC.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_instance_id FROM broker.broker_register_instance($1,$2,$3,$4,$5)",
"dup-test", "host-c", 333, "test", 2,
).Scan(&retval, &errmsg, &instanceID)
require.NoError(t, err)
require.Equal(t, 0, retval, "registration should succeed again once the lock is released: %s", errmsg)
}
// TestJobDependencies covers dependency ordering (a job is not claimable
// while an incomplete dependency exists), rejection of a direct
// self-dependency at the table level, and idempotent duplicate-dependency
// inserts.
func TestJobDependencies(t *testing.T) {
ctx := context.Background()
db := setupStage5Schema(t)
addJob := func(name string, deps string) int64 {
var retval int
var errmsg string
var jobID int64
depsArg := sql.NullString{String: deps, Valid: deps != ""}
err := db.QueryRowContext(ctx, `
SELECT p_retval, p_errmsg, p_job_id FROM broker.broker_add_job(
$1, 'SELECT 1', 1, 0, 'sql', NULL, NULL, $2::BIGINT[], NULL, 3
)`, name, depsArg,
).Scan(&retval, &errmsg, &jobID)
require.NoError(t, err)
require.Equal(t, 0, retval, "broker_add_job(%s) failed: %s", name, errmsg)
return jobID
}
base := addJob("base", "")
dependent := addJob("dependent", "{"+sqlItoa(base)+"}")
require.NotZero(t, dependent)
// broker_get must not return "dependent" while "base" is still pending;
// it should return "base" instead.
var jobID sql.NullInt64
var getRetval int
var getErrmsg string
err := db.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_job_id FROM broker.broker_get($1, NULL, $2)", 1, 60,
).Scan(&getRetval, &getErrmsg, &jobID)
require.NoError(t, err)
require.Equal(t, 0, getRetval, getErrmsg)
require.True(t, jobID.Valid)
require.Equal(t, base, jobID.Int64, "dependency-free job must be claimed before its dependent")
// A self-dependency must be rejected by the table's CHECK constraint,
// regardless of caller.
target := addJob("self-target", "")
_, err = db.ExecContext(ctx,
"INSERT INTO broker.broker_job_dependency (job_id, depends_on_job_id) VALUES ($1, $1)", target,
)
require.Error(t, err, "self-dependency must violate the CHECK constraint")
// Re-adding the same dependency pair must be a no-op, not an error
// (ON CONFLICT DO NOTHING on the (job_id, depends_on_job_id) pair).
other := addJob("other-dep-target", "")
dependentTwo := addJob("dependent-two", "{"+sqlItoa(other)+"}")
_, err = db.ExecContext(ctx,
"INSERT INTO broker.broker_job_dependency (job_id, depends_on_job_id) VALUES ($1, $2) ON CONFLICT (job_id, depends_on_job_id) DO NOTHING",
dependentTwo, other,
)
require.NoError(t, err)
var depCount int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM broker.broker_job_dependency WHERE job_id = $1 AND depends_on_job_id = $2",
dependentTwo, other,
).Scan(&depCount)
require.NoError(t, err)
require.Equal(t, 1, depCount, "duplicate dependency insert must not create a second row")
}
// TestStaleLeaseRecovery verifies that a job whose lease has expired while
// still marked running is requeued (attempts remain) by
// broker_recover_stale_jobs, and dead-lettered once attempts are exhausted.
func TestStaleLeaseRecovery(t *testing.T) {
ctx := context.Background()
db := setupStage5Schema(t)
var jobID int64
var retval int
var errmsg string
err := db.QueryRowContext(ctx, `
SELECT p_retval, p_errmsg, p_job_id FROM broker.broker_add_job(
'stale-job', 'SELECT 1', 1, 0, 'sql', NULL, NULL, NULL, NULL, 2
)`,
).Scan(&retval, &errmsg, &jobID)
require.NoError(t, err)
require.Equal(t, 0, retval, errmsg)
// Simulate a worker having claimed the job with a lease that has
// already expired, without ever calling broker_run.
_, err = db.ExecContext(ctx, `
UPDATE broker.broker_jobs
SET complete_status = 1, attempt_count = 1,
lease_token = gen_random_uuid(), leased_at = NOW() - INTERVAL '2 minutes',
lease_expires_at = NOW() - INTERVAL '1 minute'
WHERE id_broker_jobs = $1`, jobID)
require.NoError(t, err)
var recRetval, recovered int
var recErrmsg string
err = db.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_recovered_count FROM broker.broker_recover_stale_jobs()",
).Scan(&recRetval, &recErrmsg, &recovered)
require.NoError(t, err)
require.Equal(t, 0, recRetval, recErrmsg)
require.Equal(t, 1, recovered)
var status int
var leaseToken sql.NullString
err = db.QueryRowContext(ctx,
"SELECT complete_status, lease_token FROM broker.broker_jobs WHERE id_broker_jobs = $1", jobID,
).Scan(&status, &leaseToken)
require.NoError(t, err)
require.Equal(t, 0, status, "job with attempts remaining must be requeued as pending")
require.False(t, leaseToken.Valid, "lease must be cleared on recovery")
// Exhaust attempts (attempt_count already 1, max_attempts 2) then
// simulate one more stale lease -- this time it must dead-letter.
_, err = db.ExecContext(ctx, `
UPDATE broker.broker_jobs
SET complete_status = 1, attempt_count = 2,
lease_token = gen_random_uuid(), leased_at = NOW() - INTERVAL '2 minutes',
lease_expires_at = NOW() - INTERVAL '1 minute'
WHERE id_broker_jobs = $1`, jobID)
require.NoError(t, err)
err = db.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_recovered_count FROM broker.broker_recover_stale_jobs()",
).Scan(&recRetval, &recErrmsg, &recovered)
require.NoError(t, err)
require.Equal(t, 0, recRetval, recErrmsg)
require.Equal(t, 1, recovered)
err = db.QueryRowContext(ctx,
"SELECT complete_status FROM broker.broker_jobs WHERE id_broker_jobs = $1", jobID,
).Scan(&status)
require.NoError(t, err)
require.Equal(t, 3, status, "job with attempts exhausted must be dead-lettered")
}
// TestFailedJobRetriesThenCompletesWithoutStranding verifies the original
// bug fix: a job whose execution fails is requeued for retry (not stranded
// in the running state) and, once it succeeds, ends up completed.
func TestFailedJobRetriesThenCompletesWithoutStranding(t *testing.T) {
ctx := context.Background()
db := setupStage5Schema(t)
var jobID int64
var retval int
var errmsg string
err := db.QueryRowContext(ctx, `
SELECT p_retval, p_errmsg, p_job_id FROM broker.broker_add_job(
'flaky-job', 'SELECT 1/0', 1, 0, 'sql', NULL, NULL, NULL, NULL, 2
)`,
).Scan(&retval, &errmsg, &jobID)
require.NoError(t, err)
require.Equal(t, 0, retval, errmsg)
var claimedID sql.NullInt64
var leaseToken sql.NullString
var getRetval int
var getErrmsg string
err = db.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_job_id, p_lease_token FROM broker.broker_get($1, NULL, $2)", 1, 60,
).Scan(&getRetval, &getErrmsg, &claimedID, &leaseToken)
require.NoError(t, err)
require.Equal(t, 0, getRetval, getErrmsg)
require.True(t, claimedID.Valid)
require.Equal(t, jobID, claimedID.Int64)
var runRetval, jobStatus int
var runErrmsg string
err = db.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_job_status FROM broker.broker_run($1, $2)", jobID, leaseToken.String,
).Scan(&runRetval, &runErrmsg, &jobStatus)
require.NoError(t, err)
require.Equal(t, 0, runRetval, "broker_run must report success (retval=0) even when the job itself failed: %s", runErrmsg)
require.Equal(t, 0, jobStatus, "failing job with attempts remaining must be requeued (job_status=0), not stranded")
var status int
err = db.QueryRowContext(ctx,
"SELECT complete_status FROM broker.broker_jobs WHERE id_broker_jobs = $1", jobID,
).Scan(&status)
require.NoError(t, err)
require.Equal(t, 0, status, "job must be pending again, not stuck at running (1)")
// Presenting a stale lease token after the job was already reset must
// be rejected rather than silently re-running. broker_run returns early
// on the lease mismatch without ever setting p_job_status, so it comes
// back NULL here.
var staleJobStatus sql.NullInt64
err = db.QueryRowContext(ctx,
"SELECT p_retval, p_errmsg, p_job_status FROM broker.broker_run($1, $2)", jobID, leaseToken.String,
).Scan(&runRetval, &runErrmsg, &staleJobStatus)
require.NoError(t, err)
require.NotEqual(t, 0, runRetval, "broker_run must reject a stale/mismatched lease token")
}
func sqlItoa(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+26
View File
@@ -0,0 +1,26 @@
package integration
import (
"os"
"strconv"
)
// testDBHost and testDBPort let CI point the integration suite at a
// dynamically-assigned Postgres (TEST_DB_HOST/TEST_DB_PORT), avoiding a fixed
// host port that can collide with other jobs on a shared runner. Local dev
// keeps working unset, defaulting to the docker-compose test stack.
func testDBHost() string {
if h := os.Getenv("TEST_DB_HOST"); h != "" {
return h
}
return "127.0.0.1"
}
func testDBPort() int {
if p := os.Getenv("TEST_DB_PORT"); p != "" {
if n, err := strconv.Atoi(p); err == nil {
return n
}
}
return 5433
}
+17 -36
View File
@@ -3,6 +3,7 @@ package integration
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"log/slog" "log/slog"
"testing" "testing"
"time" "time"
@@ -27,7 +28,7 @@ func TestBrokerWorkflow(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Database connection string // Database connection string
connStr := "user=user password=password dbname=broker_test host=localhost port=5433 sslmode=disable" connStr := fmt.Sprintf("user=user password=password dbname=broker_test host=%s port=%d sslmode=disable", testDBHost(), testDBPort())
// Connect to database with retry logic // Connect to database with retry logic
db, err := connectWithRetry(connStr, 10, 2*time.Second) db, err := connectWithRetry(connStr, 10, 2*time.Second)
@@ -43,8 +44,8 @@ func TestBrokerWorkflow(t *testing.T) {
// Create database adapter // Create database adapter
postgresConfig := adapter.PostgresConfig{ postgresConfig := adapter.PostgresConfig{
Host: "localhost", Host: testDBHost(),
Port: 5433, Port: testDBPort(),
Database: "broker_test", Database: "broker_test",
User: "user", User: "user",
Password: "password", Password: "password",
@@ -65,8 +66,8 @@ func TestBrokerWorkflow(t *testing.T) {
// Install schema // Install schema
t.Log("Installing database schema...") t.Log("Installing database schema...")
installer := install.New(dbAdapter, logger) installer := install.New(dbAdapter, logger)
err = installer.InstallSchema(ctx) err = installer.ApplyMigrations(ctx)
require.NoError(t, err, "Failed to install schema") require.NoError(t, err, "Failed to apply migrations")
// Verify installation // Verify installation
t.Log("Verifying schema installation...") t.Log("Verifying schema installation...")
@@ -78,8 +79,8 @@ func TestBrokerWorkflow(t *testing.T) {
Databases: []config.DatabaseConfig{ Databases: []config.DatabaseConfig{
{ {
Name: "test_db", Name: "test_db",
Host: "localhost", Host: testDBHost(),
Port: 5433, Port: testDBPort(),
Database: "broker_test", Database: "broker_test",
User: "user", User: "user",
Password: "password", Password: "password",
@@ -93,7 +94,7 @@ func TestBrokerWorkflow(t *testing.T) {
QueueTimerSec: 1, // Short interval for testing QueueTimerSec: 1, // Short interval for testing
QueueBufferSize: 10, QueueBufferSize: 10,
WorkerIdleTimeoutSec: 5, WorkerIdleTimeoutSec: 5,
NotifyRetrySeconds: 5 * time.Second, NotifyRetryInterval: 5 * time.Second,
EnableDebug: true, EnableDebug: true,
}, },
Logging: config.LoggingConfig{ Logging: config.LoggingConfig{
@@ -127,7 +128,7 @@ func TestBrokerWorkflow(t *testing.T) {
var errmsg string var errmsg string
var jobID int64 var jobID int64
err = db.QueryRowContext(ctx, ` err = db.QueryRowContext(ctx, `
SELECT * FROM broker_add_job( SELECT * FROM broker.broker_add_job(
$1, -- job_name $1, -- job_name
$2, -- execute_str $2, -- execute_str
$3, -- job_queue $3, -- job_queue
@@ -135,7 +136,9 @@ func TestBrokerWorkflow(t *testing.T) {
$5, -- job_language $5, -- job_language
NULL, -- run_as NULL, -- run_as
NULL, -- schedule_id NULL, -- schedule_id
NULL -- depends_on NULL, -- depends_on_job_ids
NULL, -- idempotency_key
NULL -- max_attempts
) )
`, `,
"Test Job", "Test Job",
@@ -165,7 +168,7 @@ func TestBrokerWorkflow(t *testing.T) {
SELECT id_broker_jobs, job_name, job_priority, job_queue, job_language, SELECT id_broker_jobs, job_name, job_priority, job_queue, job_language,
execute_str, execute_result, error_msg, complete_status, execute_str, execute_result, error_msg, complete_status,
created_at, updated_at created_at, updated_at
FROM broker_jobs FROM broker.broker_jobs
WHERE id_broker_jobs = $1 WHERE id_broker_jobs = $1
`, jobID).Scan( `, jobID).Scan(
&job.ID, &job.ID,
@@ -243,32 +246,10 @@ func connectWithRetry(connStr string, maxRetries int, retryInterval time.Duratio
return nil, err return nil, err
} }
// cleanupSchema removes all broker tables and functions for a clean test // cleanupSchema drops the entire broker schema for a clean test run.
func cleanupSchema(t *testing.T, db *sql.DB) { func cleanupSchema(t *testing.T, db *sql.DB) {
tables := []string{"broker_jobs", "broker_queueinstance", "broker_schedule"} _, err := db.Exec("DROP SCHEMA IF EXISTS broker CASCADE")
procedures := []string{
"broker_get",
"broker_run",
"broker_set",
"broker_add_job",
"broker_register_instance",
"broker_ping_instance",
"broker_shutdown_instance",
}
// Drop procedures
for _, proc := range procedures {
_, err := db.Exec("DROP FUNCTION IF EXISTS " + proc + " CASCADE")
if err != nil { if err != nil {
t.Logf("Warning: failed to drop procedure %s: %v", proc, err) t.Logf("Warning: failed to drop broker schema: %v", err)
}
}
// Drop tables
for _, table := range tables {
_, err := db.Exec("DROP TABLE IF EXISTS " + table + " CASCADE")
if err != nil {
t.Logf("Warning: failed to drop table %s: %v", table, err)
}
} }
} }