scobleclaw/pool-manager/examples/agent-dispatcher/main.go
Chief of Staff 19d82d5c41 feat(pool-manager): add pgqueue Go package + dispatcher example
- pgqueue package: Go wrapper around the SQL primitive
  (enqueue, claim, heartbeat, ack, nack, janitor, listener)
- examples/agent-dispatcher: reference worker implementation
  showing end-to-end flow with LISTEN/NOTIFY + heartbeats
- All 5 Go tests pass against live pool_manager DB

Reference: skills/data-stack-conventions.md
Migration: devops/migrations/2026-06-01_pgqueue_primitive.sql
2026-06-02 00:05:30 -07:00

145 lines
3.8 KiB
Go

// Example: a minimal agent_dispatch worker using the pgqueue primitive.
//
// Run as: go run ./examples/agent-dispatcher
//
// This is the "proof" that the new primitive replaces the older poolq package.
// It:
// - Connects to the local pool_manager DB
// - LISTENs on the "pgqueue:agent_dispatch" channel
// - On wakeup, claims the next job
// - Heartbeats every minute for long jobs
// - Acks on success, nacks on failure
//
// This is a reference implementation. Real agents replace the processJob
// function with their actual work.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"scobleclaw.dev/pool-manager/pgqueue"
)
const (
queueName = "agent_dispatch"
leaseFor = 5 * time.Minute
)
func main() {
dsn := os.Getenv("PGQUEUE_DSN")
if dsn == "" {
dsn = "postgres://sscoble@/pool_manager?host=/run/postgresql"
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// SIGINT/SIGTERM
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() { <-sig; log.Println("shutting down..."); cancel() }()
workerID := fmt.Sprintf("dispatcher-%d", os.Getpid())
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("pgxpool: %v", err)
}
defer pool.Close()
// Optional: enqueue a test job on startup
if os.Getenv("ENQUEUE_DEMO") == "1" {
demo, _ := json.Marshal(map[string]any{"demo": true, "ts": time.Now().Format(time.RFC3339)})
res, err := pgqueue.Enqueue(ctx, pool, queueName, demo)
if err != nil {
log.Printf("demo enqueue: %v", err)
} else {
log.Printf("demo enqueued: id=%d", res.ID)
}
}
// LISTEN for wakeups
ln, err := pgqueue.NewListener(ctx, pool, queueName)
if err != nil {
log.Fatalf("listener: %v", err)
}
defer ln.Close()
log.Printf("worker %s: listening on %q (lease %s)", workerID, queueName, leaseFor)
// Fallback poll every 10s in case we miss a NOTIFY
pollTicker := time.NewTicker(10 * time.Second)
defer pollTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-pollTicker.C:
drain(ctx, pool, workerID)
case <-ln.C():
// Drain until empty (a wakeup might mean multiple jobs)
drain(ctx, pool, workerID)
}
}
}
// drain claims and processes jobs until the queue is empty (or ctx cancelled).
func drain(ctx context.Context, pool *pgxpool.Pool, workerID string) {
for ctx.Err() == nil {
job, err := pgqueue.Claim(ctx, pool, queueName, workerID, leaseFor)
if err != nil {
log.Printf("claim: %v", err)
return
}
if job == nil {
return
}
if err := processJob(ctx, pool, job, workerID); err != nil {
log.Printf("job %d: %v", job.ID, err)
res, nackErr := pgqueue.Nack(ctx, pool, job.ID, workerID, err.Error(), 30*time.Second)
if nackErr != nil {
log.Printf("nack %d: %v", job.ID, nackErr)
return
}
log.Printf("job %d: nack → %s", job.ID, res)
continue
}
if err := pgqueue.Ack(ctx, pool, job.ID, workerID); err != nil {
log.Printf("ack %d: %v", job.ID, err)
return
}
log.Printf("job %d: ack", job.ID)
}
}
// processJob is the placeholder. Real agents replace this with actual work.
// For the demo, we just heartbeat a few times to prove the lease works.
func processJob(ctx context.Context, pool *pgxpool.Pool, job *pgqueue.Job, workerID string) error {
log.Printf("job %d: processing %s payload=%s", job.ID, job.Queue, string(job.Payload))
// Simulate work with periodic heartbeats
for i := 0; i < 3; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
ok, err := pgqueue.Heartbeat(ctx, pool, job.ID, workerID, leaseFor)
if err != nil {
return fmt.Errorf("heartbeat: %w", err)
}
if !ok {
return fmt.Errorf("heartbeat lost (job gone or claimed by another)")
}
log.Printf("job %d: heartbeat %d/3", job.ID, i+1)
}
return nil
}