postgres

package module
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 6 Imported by: 0

README

BabelQueue idempotency store — PostgreSQL (Go)

Go Reference

A PostgreSQL-backed idempotency.Store for the BabelQueue Go runtime: a shared, persistent record of processed message ids so a fleet of consumers dedupes on the envelope's meta.id (ADR-0022) — instead of every worker keeping its own in-memory set.

The framework-agnostic core stays zero-dependency (GR-7): the dep-free Store interface (and the reference InMemoryStore) live in the core; this module is a separate submodule with its own go.mod, the only place a database driver (pgx) is pulled in.

go get github.com/babelqueue/babelqueue-go/idempotency-postgres
import (
    babelqueue "github.com/babelqueue/babelqueue-go"
    "github.com/babelqueue/babelqueue-go/idempotency"
    postgres "github.com/babelqueue/babelqueue-go/idempotency-postgres"
)

store, err := postgres.New(ctx, "postgres://user:pass@localhost:5432/app?sslmode=disable")
if err != nil { panic(err) }
defer store.Close()

// Create the table once (or apply schema.sql through your own migration tool).
_ = store.Migrate(ctx)

app.Handle("urn:babel:orders:created", idempotency.Wrap(store, handler))

Bring your own pool with postgres.NewWithDB(db) (any *sql.DB on a Postgres-compatible driver). WithTable("…") isolates the table per app; WithTTL(d) expires ids after d so they may be re-processed once the window lapses (default: ids never expire — evict with Forget or an external GC job).

Atomic claim (ACID / parking)

The race-safety comes from the table's primary key on message_id:

INSERT INTO babelqueue_idempotency (message_id, created_at, expires_at)
VALUES ($1, now(), $2)
ON CONFLICT (message_id) DO UPDATE
    SET created_at = now(), expires_at = EXCLUDED.expires_at
    WHERE babelqueue_idempotency.expires_at IS NOT NULL
      AND babelqueue_idempotency.expires_at <= now()
RETURNING message_id;

Under any number of concurrent consumers, exactly one INSERT writes the row (and RETURNING yields it → that caller won the claim); every duplicate hits the conflict and, because the live row has not expired, the DO UPDATE … WHERE matches nothing → RETURNING yields zero rows → that caller is a duplicate / parked delivery. An expired row is re-claimed atomically by the DO UPDATE branch, so an id becomes processable again once its TTL lapses. This is the same "exactly one wins, the rest park" contract the in-memory store models — now durable across processes.

Claim(ctx, id) (bool, error) exposes that race result directly (true = first delivery, false = duplicate). Remember (the idempotency.Store write side) delegates to Claim and discards the bool, so idempotency.Wrap accepts this store unchanged. Seen returns true only for a live (non-expired) row.

Schema

store.Migrate(ctx) creates the table on demand; schema.sql ships the same DDL for migration tools. Periodically reclaim expired rows (optional — expired rows are already ignored by Seen/Claim):

DELETE FROM babelqueue_idempotency WHERE expires_at IS NOT NULL AND expires_at <= now();

Tests

Unit tests run with no database (go-sqlmock over database/sql), covering the claim / duplicate / expiry / Seen / Forget / migrate logic. Integration tests skip cleanly unless a live Postgres is provided:

go test ./...                                   # unit only (no DB) — what CI runs on every push
BABELQUEUE_TEST_PG='postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable' \
  go test ./...                                 # + integration (concurrent claims serialize, duplicate rejected, TTL)

Full spec: babelqueue.com · MIT © Muhammet Şafak

Documentation

Overview

Package postgres is a PostgreSQL-backed idempotency.Store for the BabelQueue Go runtime: a shared, persistent record of processed message ids so a fleet of consumers dedupes on the envelope's meta.id (ADR-0022) instead of every worker keeping its own in-memory set.

store, _ := postgres.New(ctx, "postgres://user:pass@localhost:5432/app?sslmode=disable")
defer store.Close()
app.Handle(urn, idempotency.Wrap(store, handler))

It satisfies the dependency-free idempotency.Store interface from the core (Seen / Remember / Forget) with the SAME contract the reference in-memory store guarantees, and adds atomic-claim semantics on top so concurrent consumers serialize:

  • Remember is an ATOMIC claim — INSERT ... ON CONFLICT DO NOTHING. Exactly one concurrent caller inserts the row (and is the "winner"); a duplicate delivery hits the conflict and is a no-op. This is race-safe under any number of concurrent workers and connections (the unique primary key is the lock).
  • Claim exposes that race result directly: it returns true iff THIS caller won the insert (first delivery), false if the row already existed (duplicate / in-flight "parked" delivery). Remember delegates to Claim and discards the bool, so the idempotency.Store contract is preserved exactly.
  • Seen reports whether a live (non-expired) row exists.
  • TTL/expiry is honored in SQL: an expired row is treated as absent by Seen and re-claimable by Claim/Remember (an expired key may be re-processed once its window lapses, the documented at-least-once behaviour).

The GR-7 zero-dependency core stays dependency-free: this driver lives in its own submodule (its own go.mod) and is the only place a database driver is pulled in. It talks to Postgres through the standard library database/sql over the pgx stdlib driver, so any *sql.DB wired to a Postgres-compatible database also works via NewWithDB.

Index

Constants

View Source
const DefaultTable = "babelqueue_idempotency"

DefaultTable is the table name used when WithTable is not given.

Variables

This section is empty.

Functions

This section is empty.

Types

type Option

type Option func(*Store)

Option customizes a Store.

func WithTTL

func WithTTL(ttl time.Duration) Option

WithTTL sets how long a remembered id stays "seen" before it expires and may be re-processed. Zero (the default) means ids never expire on their own — evict with Forget or an external job. A negative TTL is treated as zero.

func WithTable

func WithTable(table string) Option

WithTable overrides the table name (default DefaultTable). The name is validated as a plain SQL identifier — it is interpolated into DDL/DML, never a query parameter, so it must not come from untrusted input.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is a PostgreSQL-backed idempotency.Store. Construct it with New (owns the pool) or NewWithDB (bring your own *sql.DB).

func New

func New(ctx context.Context, dsn string, opts ...Option) (*Store, error)

New opens a pgx-backed connection pool to dsn (a postgres:// URL or a key=value DSN) and returns a Store. The caller owns the lifetime: call Store.Close to release the pool. It pings once so a bad DSN fails fast.

func NewWithDB

func NewWithDB(db *sql.DB, opts ...Option) *Store

NewWithDB wraps an existing *sql.DB (bring your own pool / driver / config). The caller keeps ownership of db; Store.Close is a no-op for a borrowed pool.

func (*Store) Claim

func (s *Store) Claim(ctx context.Context, messageID string) (bool, error)

Claim atomically records messageID and reports whether THIS call won the race — true on the first delivery (the row was inserted), false when the id was already claimed by a live (non-expired) row (a duplicate / in-flight "parked" delivery).

The race-safety comes from INSERT ... ON CONFLICT DO NOTHING on the primary key: under any number of concurrent consumers exactly one INSERT inserts a row and reports a claim; the rest hit the conflict and report a duplicate. An expired row is reclaimed in the same statement (DO UPDATE when the existing row has lapsed), so a claim succeeds again once the TTL window passes.

This is the atomic primitive [Remember] is built on; the idempotency.Store interface does not expose it, so it is available only on a *Store.

func (*Store) Close

func (s *Store) Close() error

Close releases the connection pool when this Store opened it (New). For a borrowed pool (NewWithDB) it is a no-op, leaving the caller's *sql.DB intact.

func (*Store) Forget

func (s *Store) Forget(ctx context.Context, messageID string) error

Forget drops messageID from the store (manual eviction; the idempotency.Store contract). Removing an absent id is a no-op.

func (*Store) Migrate

func (s *Store) Migrate(ctx context.Context) error

Migrate creates the idempotency table if it does not exist. It is idempotent and safe to run on every boot; production deployments may instead apply the shipped DDL (see schema.sql) through their own migration tool.

func (*Store) Remember

func (s *Store) Remember(ctx context.Context, messageID string) error

Remember records messageID as processed (atomic claim; the idempotency.Store write side). It delegates to Store.Claim and discards whether this caller won the race — the seen-set contract only needs the id to be durable afterwards.

func (*Store) Seen

func (s *Store) Seen(ctx context.Context, messageID string) (bool, error)

Seen reports whether messageID has a live (non-expired) row — the idempotency.Store read side. An expired row is treated as absent (returns false), so the id may be re-processed once its TTL lapses.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL