[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-718":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":10,"language":11,"languages":10,"totalLinesOfCode":10,"stars":12,"forks":13,"watchers":14,"openIssues":15,"contributorsCount":16,"subscribersCount":16,"size":16,"stars1d":16,"stars7d":16,"stars30d":16,"stars90d":16,"forks30d":16,"starsTrendScore":16,"compositeScore":17,"rankGlobal":10,"rankLanguage":10,"license":18,"archived":19,"fork":19,"defaultBranch":20,"hasWiki":21,"hasPages":19,"topics":22,"createdAt":10,"pushedAt":10,"updatedAt":23,"readmeContent":24,"aiSummary":25,"trendingCount":16,"starSnapshotCount":16,"syncStatus":26,"lastSyncTime":27,"discoverSource":28},718,"pgque","NikolayS\u002Fpgque","NikolayS","PgQue – Zero-bloat Postgres queue. One SQL file to install, pg_cron to tick.","",null,"PLpgSQL",1310,27,1,22,0,17.34,"Apache License 2.0",false,"main",true,[],"2026-06-12 02:00:17","\u003Ch1 align=\"center\">PgQue – PgQ, universal edition\u003C\u002Fh1>\n\n\u003Cp align=\"center\">\u003Cstrong>Zero-bloat Postgres queue. One SQL file to install, \u003Ccode>pg_cron\u003C\u002Fcode> or \u003Ccode>pg_timetable\u003C\u002Fcode> to tick.\u003C\u002Fstrong>\u003C\u002Fp>\n\n\u003Cp align=\"center\">\n  \u003Ca href=\"https:\u002F\u002Fgithub.com\u002FNikolayS\u002Fpgque\u002Factions\u002Fworkflows\u002Fci.yml\">\u003Cimg src=\"https:\u002F\u002Fgithub.com\u002FNikolayS\u002Fpgque\u002Factions\u002Fworkflows\u002Fci.yml\u002Fbadge.svg\" alt=\"CI\">\u003C\u002Fa>\n  \u003Ca href=\"https:\u002F\u002Fwww.postgresql.org\u002F\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FPostgreSQL-14--18-336791?logo=postgresql&logoColor=white\" alt=\"PostgreSQL 14-18\">\u003C\u002Fa>\n  \u003Ca href=\"LICENSE\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FLicense-Apache_2.0-blue.svg\" alt=\"License\">\u003C\u002Fa>\n  \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fcitusdata\u002Fpg_cron\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Fpg__cron-optional-336791\" alt=\"pg_cron\">\u003C\u002Fa>\n  \u003Ca href=\"https:\u002F\u002Fgithub.com\u002FNikolayS\u002Fpgque\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Fanti--extension-%5Ci_and_go-orange\" alt=\"Anti-Extension\">\u003C\u002Fa>\n  \u003Ca href=\"https:\u002F\u002Fnews.ycombinator.com\u002Fitem?id=47817349\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FHacker%20News-discussion-ff6600?logo=ycombinator&logoColor=white\" alt=\"Discussion on Hacker News\">\u003C\u002Fa>\n\u003C\u002Fp>\n\n\u003Cp align=\"center\">\u003Cimg src=\"docs\u002Fimages\u002Fdeath_spiral.gif\" alt=\"Death spiral of a SKIP LOCKED queue under sustained load — the failure mode PgQue avoids by construction\" width=\"720\">\u003C\u002Fp>\n\nDiscussion on [Hacker News](https:\u002F\u002Fnews.ycombinator.com\u002Fitem?id=47817349).\n\n*For teams who want a durable event stream inside Postgres. The model is closer to Kafka (log) than to ActiveMQ or RabbitMQ (task message queue). Shared event log, independent per-consumer cursors, zero bloat under sustained load. Pure SQL and PL\u002FpgSQL, any Postgres 14+ — managed or self-hosted, no sidecar daemon. The rest of this README walks the history, comparison, and install paths that back up the claim.*\n\n## Contents\n\n- [Why PgQue](#why-pgque)\n- [Latency trade-off](#latency-trade-off)\n- [Three latencies](#three-latencies)\n- [Comparison](#comparison)\n- [Installation](#installation)\n- [Roles and grants](#roles-and-grants)\n- [Project status](#project-status)\n- [Docs](#docs)\n- [Quick start](#quick-start)\n- [Client libraries](#client-libraries)\n- [Benchmarks](#benchmarks)\n- [Architecture](#architecture)\n- [Roadmap](#roadmap)\n- [Contributing](#contributing)\n- [License](#license)\n\nPgQue brings back [PgQ](https:\u002F\u002Fgithub.com\u002Fpgq\u002Fpgq) — one of the longest-running Postgres queue architectures in production — in a form that runs on any Postgres platform, managed providers included.\n\nPgQ was designed at Skype in 2006 to run messaging for hundreds of millions of users, and it ran on large self-managed Postgres deployments for over a decade. Standard PgQ depends on a C extension (`pgq`) and an external daemon (`pgqd`), neither of which run on most managed Postgres providers.\n\nPgQue rebuilds that battle-tested engine in pure PL\u002FpgSQL, so the zero-bloat queue pattern works anywhere you can run SQL — without adding another distributed system to your stack.\n\nIt is the same engine – PgQ – repackaged for managed Postgres, and provided with client libraries for TypeScript, Python, and Go.\n\n**The anti-extension.** Pure SQL + PL\u002FpgSQL on any Postgres 14+ — including RDS, Aurora, Cloud SQL, AlloyDB, Supabase, Neon, and most other managed providers. No C extension, no `shared_preload_libraries`, no provider approval, no restart.\n\nHistorical context, two decks:\n\n- [Marko Kreen (Skype), PGCon 2009 — PgQ](https:\u002F\u002Fwww.pgcon.org\u002F2009\u002Fschedule\u002Fattachments\u002F91_pgq.pdf)\n- [Alexander Kukushkin (Microsoft), 2026 — Rediscovering PgQ](https:\u002F\u002Fspeakerdeck.com\u002Fcyberdemn\u002Frediscovering-pgq)\n\n## Why PgQue\n\nMost Postgres queues rely on `SKIP LOCKED` plus `DELETE` and\u002For `UPDATE`. That holds up in toy examples and then turns into dead tuples, VACUUM pressure, index bloat, and performance drift under sustained load.\n\nPgQue avoids that whole class of problems. It uses **snapshot-based batching** and **TRUNCATE-based table rotation** instead of per-row deletion. The hot path stays predictable:\n\n- **Zero bloat by design** — no dead tuples in the main queue path\n- **No performance decay** — it does not get slower because it has been running for months\n- **Built for heavy-loaded systems** — the sustained-load regime the original PgQ architecture was designed for\n- **Real Postgres guarantees** — ACID transactions, transactional enqueue\u002Fconsume, WAL, backups, replication, SQL visibility\n- **Works on managed Postgres** — no custom build, no C extension, no separate daemon\n\nPgQue gives you queue semantics **inside** Postgres, with Postgres durability and transactional behavior, without the bloat tax most in-database queues eventually hit.\n\n## Latency trade-off\n\nPgQue is built around **snapshot-based batching**, not row-by-row claiming. That's what gives it zero bloat in the hot path, stable behavior under sustained load, and clean ACID semantics inside Postgres.\n\nThe trade-off is **end-to-end delivery latency** — the gap between `send` and when a consumer can `receive` the event. In the default configuration, end-to-end delivery typically lands around ~50–150 ms: PgQue ticks **10 times per second** (every 100 ms) by default, so the wait for the next tick is ~50 ms on average and at most ~100 ms, plus the consumer's poll interval. Per-call latency (the `send` \u002F `receive` \u002F `ack` functions themselves) stays in the microsecond range.\n\nWays to reduce delivery latency: tune the tick period (for example `pgque.set_tick_period_ms(50)` for 20 ticks\u002Fsec; accepted periods are exact divisors of 1000 ms) and queue thresholds; use `force_next_tick()` for tests and demos or to force an immediate batch. Future versions may add logical-decoding-based wake-ups for sub-millisecond delivery without burning more WAL on ticking.\n\nIf your top priority is single-digit-millisecond dispatch, PgQue is the wrong tool. If your priority is **stability under load without bloat**, that is where PgQue fits.\n\n## Three latencies\n\n\"Queue latency\" is three numbers, not one:\n\n1. **Producer latency** — `send` \u002F `insert_event`. Sub-ms.\n2. **Subscriber latency** — `next_batch` over a pre-built batch. Sub-ms.\n3. **End-to-end delivery** — `send` → consumer visibility. ≈ tick period (default 100 ms). Tunable from 1 ms to 1000 ms via `pgque.set_tick_period_ms(ms)`. Does not grow with load.\n\nSee [docs\u002Fthree-latencies.md](docs\u002Fthree-latencies.md) for the breakdown, tick-cadence trade-off table, and comparison with UPDATE\u002FDELETE-based designs.\n\n## Comparison\n\n| Feature | PgQue | PgQ | PGMQ | River | Que | pg-boss |\n|---|---|---|---|---|---|---|\n| Snapshot-based batching (no row locks) | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |\n| Zero bloat under sustained load | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |\n| No external daemon or worker binary | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |\n| Pure SQL install, managed Postgres ready | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |\n| Language-agnostic SQL API | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |\n| Multiple independent consumers (fan-out) | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |\n| Built-in retry with backoff | ✅ | ✅ | ⚠️ | ✅ | ✅ | ✅ |\n| Built-in dead letter queue | ✅ | ❌ | ⚠️ | ⚠️ | ❌ | ✅ |\n\n**Legend:** ✅ yes · ❌ no · ⚠️ partial \u002F indirect\n\n**Notes:**\n\n- **[PgQ](https:\u002F\u002Fgithub.com\u002Fpgq\u002Fpgq)** is the Skype-era queue engine (~2007) PgQue is derived from. Same snapshot\u002Frotation architecture, but requires C extensions and an external daemon (`pgqd`) — unavailable on managed Postgres. PgQue removes both constraints.\n- **No external daemon:** PgQue uses pg_cron (or your own scheduler) for ticking; PGMQ uses visibility timeouts. River, Que, and pg-boss require a Go \u002F Ruby \u002F Node.js worker binary.\n- **[Que](https:\u002F\u002Fgithub.com\u002Fque-rb\u002Fque)** uses advisory locks (not SKIP LOCKED) — no dead tuples from *claiming*, but completed jobs are still DELETEd. Brandur's [bloat post](https:\u002F\u002Fbrandur.org\u002Fpostgres-queues) was about Que at Heroku. Ruby-only.\n- **PGMQ retry** is visibility-timeout re-delivery (`read_ct` tracking) — no configurable backoff or max attempts.\n- **pg-boss fan-out** is copy-per-queue `publish()`\u002F`subscribe()`, not a shared event log with independent cursors.\n- **Category:** River, Que, and pg-boss (and Oban, graphile-worker, solid_queue, good_job) are **job queue frameworks**. PgQue is an **event\u002Fmessage queue** optimized for high-throughput streaming with fan-out.\n\n### What differentiates PgQue\n\n**1. Zero event-table bloat, by design.** SKIP LOCKED queues (PGMQ, River, pg-boss, Oban, graphile-worker) UPDATE + DELETE rows, creating dead tuples that require VACUUM. Under sustained load this causes documented failures:\n\n- [Brandur\u002FHeroku (2015)](https:\u002F\u002Fbrandur.org\u002Fpostgres-queues) — 60k backlog in one hour.\n- [PlanetScale (2026)](https:\u002F\u002Fplanetscale.com\u002Fblog\u002Fkeeping-a-postgres-queue-healthy) — death spiral at 800 jobs\u002Fsec with OLAP on the side.\n- [River issue #59](https:\u002F\u002Fgithub.com\u002Friverqueue\u002Friver\u002Fissues\u002F59) — autovacuum starvation.\n\nOban Pro shipped table partitioning to mitigate it; PGMQ ships aggressive autovacuum settings. PgQue's TRUNCATE rotation creates zero dead tuples by construction. No tuning. Immune to xmin horizon pinning.\n\n**2. Native fan-out.** Each registered consumer maintains its own cursor on a shared event log and independently receives all events. That is different from competing-consumers (SKIP LOCKED) where each job goes to one worker. pg-boss has fan-out but it is copy-per-queue (one INSERT per subscriber per event). PgQue's model is a position on a shared log — no data duplication, atomic batch boundaries, late subscribers catch up. Closer to Kafka topics than to a job queue.\n\n### When to use PgQue vs. a job queue\n\n- **Choose PgQue** when you want event-driven fan-out, no bloat to tune around, and a language-agnostic SQL API, and you do not need per-job priorities or a worker framework.\n- **Choose a job queue** when you need per-job lifecycle, sub-3ms latency, priority queues, cron scheduling, unique jobs, or deep ecosystem integration (Elixir\u002FGo\u002FNode.js\u002FRuby).\n\n## Installation\n\n**Requirements:** Postgres 14+, and something that calls `pgque.ticker()` periodically. With `pg_cron`, `pgque.start()` schedules a single 1-second `pg_cron` slot that internally re-ticks every **100 ms (10 ticks\u002Fsec)** by default — see [Tick rate](#tick-rate) for tuning. `pg_cron` is pre-installed or one-command available on all major managed Postgres providers (RDS, Aurora, Cloud SQL, AlloyDB, Supabase, Neon); on self-managed Postgres, follow the [pg_cron setup guide](https:\u002F\u002Fgithub.com\u002Fcitusdata\u002Fpg_cron#setting-up-pg_cron). Any external scheduler (system `cron`, systemd, a worker loop in your app) works as an alternative — see below.\n\nGet the source — `\\i sql\u002Fpgque.sql` resolves relative to the cwd, so run psql from the repo root:\n\n```bash\ngit clone https:\u002F\u002Fgithub.com\u002FNikolayS\u002Fpgque\ncd pgque\n```\n\nInside a psql session:\n\n```sql\nbegin;\n\\i sql\u002Fpgque.sql\ncommit;\n```\n\nOr from the shell, same single-transaction guarantee via `psql --single-transaction`:\n\n```bash\nPAGER=cat psql --no-psqlrc --single-transaction -d mydb -f sql\u002Fpgque.sql\n```\n\nWith `pg_cron` available in the same database as PgQue, `pgque.start()` creates the default ticker and maintenance jobs. The ticker uses a one-second pg_cron slot and calls `pgque.ticker_loop()`, which ticks every 100 ms by default (10 ticks\u002Fsec) with a commit between ticks:\n\n```sql\nselect pgque.start();\n```\n\nWith `pg_timetable`, run the external pg_timetable worker against the database where PgQue is installed, then schedule PgQue with the same 10 ticks\u002Fsec default. For example:\n\n```bash\npg_timetable --dbname=mydb --clientname=pgque\n```\n\nThe `--clientname=pgque` flag is required; pg_timetable only executes chains whose `job_client_name` matches the running worker's client name. Keep that worker running; unlike `pg_cron`, pg_timetable is an external scheduler process, not a Postgres extension background worker. See the [pg_timetable docs](https:\u002F\u002Fgithub.com\u002Fcybertec-postgresql\u002Fpg_timetable) for production service setup.\n\n```sql\nselect pgque.start_timetable();      -- default: 10 ticks\u002Fsec\n-- or explicitly:\nselect pgque.start_timetable(10);\n```\n\n`pgque.stop_timetable()` removes the PgQue pg_timetable jobs. `pgque.stop()` also stops whichever PgQue scheduler is active. Calling `pgque.start_timetable()` automatically removes existing PgQue `pg_cron` jobs first; `pgque.start()` does the same for PgQue pg_timetable jobs. `pgque.set_tick_period_ms(ms)` still controls the loop cadence; for example `100` means 10 ticks\u002Fsec, `200` means 5 ticks\u002Fsec, and `1000` means 1 tick\u002Fsec.\n\n### Tick rate\n\nPgQue ticks **10 times per second by default** (every 100 ms), even though `pg_cron`'s minimum schedule is 1 second. `pgque.start()` schedules a single 1-second pg_cron slot that calls `CALL pgque.ticker_loop()`; `pgque.start_timetable()` schedules the same loop through one pg_timetable `@every 1 second` job. The procedure then re-invokes `pgque.ticker()` every `tick_period_ms` ms inside that slot, committing between iterations so each tick gets its own transaction (snapshot semantics; bounded held-xmin so rotation isn't blocked).\n\nTune at runtime — no need to call `start()` again, the change picks up on the next scheduler slot (≤1 s):\n\n```sql\nselect pgque.set_tick_period_ms(50);    -- 20 ticks\u002Fsec, ~25 ms median e2e\nselect pgque.set_tick_period_ms(10);    -- 100 ticks\u002Fsec, ~5 ms median e2e\nselect pgque.set_tick_period_ms(1000);  -- 1 tick\u002Fsec, the pgqd-compatible cadence\n```\n\nAllowed values: exact divisors of `1000` in the `1`..`1000` ms range. Inspect the current rate with `select * from pgque.status();`.\n\nTrade-offs to keep in mind when raising the rate:\n- **Idle queues are cheap.** The 100 ms default is the *check cadence*, not a promise to write 10 ticks\u002Fsec forever. With no producer writes, most ticker calls return `NULL`; PgQue backs off toward `ticker_idle_period` (default 1 minute), so inactive queues produce occasional metadata writes, not hundreds of MiB\u002Fday. The larger WAL numbers apply only to queues that actually materialize ticks continuously. As a planning unit, a forced-tick PG18 measurement isolated about **280 bytes of WAL per materialized tick per queue**; that projects to roughly **240 MiB\u002Fday** at 10 materialized ticks\u002Fsec versus **24 MiB\u002Fday** at 1 materialized tick\u002Fsec. See [docs\u002Ftick-frequency.md](docs\u002Ftick-frequency.md) for caveats and tuning guidance.\n- **NOTIFY rate.** `pgque.ticker()` emits `pg_notify('pgque_\u003Cqueue>', ...)` per tick. Postgres's NOTIFY queue is global (8 GiB SLRU); slow LISTEN consumers can fall behind at very high rates.\n- **Metadata-table dead tuples.** `pgque.tick` and `pgque.subscription` are UPDATEd on every tick. PgQue rotates these tables to keep dead-tuple peaks bounded; at sub-50 ms tick periods, drop the rotation period proportionally.\n- **`pg_cron` background workers.** `pgque.ticker_loop()` holds one pg_cron worker for ~1 s per slot (vs. ~10 ms for the previous 1-second-cadence ticker). With pg_cron's default `cron.max_running_jobs = 32`, that bounds roughly **30 pgque-bearing databases per cluster** before the worker pool saturates. Not a per-database concern; matters if you fan PgQue across many databases on one instance.\n\n**pg_cron in a different database.** `pg_cron` runs jobs in one designated database (`cron.database_name`, typically `postgres`). If your PgQue schema lives in a different database, use the [cross-database pattern](https:\u002F\u002Fgithub.com\u002Fcitusdata\u002Fpg_cron#creating-a-cron-job-in-a-different-database) to call `pgque.ticker_loop()`, `pgque.maint_retry_events()`, and `pgque.maint()` across databases. *Todo: a future release will detect this and emit the correct `cron.schedule_in_database` calls from `pgque.start()` automatically.*\n\n**Scheduler log hygiene.** Every `pg_cron` job execution writes a row to `cron.job_run_details`, with no built-in purge. PgQue's internal sub-second loop does **not** make this worse — there is still only **one** `pg_cron` slot per second per job, regardless of `tick_period_ms`, so the per-second row count is the same as a 1 tick\u002Fsec schedule. Across PgQue's four scheduled jobs (ticker, retry, maint, rotate_step2), that is roughly **5,000 rows per hour** on top of any other pg_cron jobs, growing forever unless you intervene. Prefer a PgQue-specific purge job; disable `cron.log_run` globally only if you do not need successful-run history for any pg_cron jobs. See [the tutorial](docs\u002Ftutorial.md#production-cadence-use-pg_cron) for both recipes. *(Independent issue: `pg_cron` itself has no per-job log toggle as of 1.6.)* If you use pg_timetable, monitor and purge pg_timetable's own execution history tables according to its retention policy; PgQue does not manage those logs.\n\nWithout `pg_cron` or `pg_timetable`, PgQue still installs. Drive ticking and maintenance from your application or an external scheduler:\n\n```bash\nPAGER=cat psql --no-psqlrc -d mydb -c \"select pgque.ticker()\"              # at your chosen tick period\nPAGER=cat psql --no-psqlrc -d mydb -c \"select pgque.maint_retry_events()\"  # every 30 seconds\nPAGER=cat psql --no-psqlrc -d mydb -c \"select pgque.maint()\"               # every 30 seconds\n```\n\nFor sub-second ticking from an external driver, loop `pgque.ticker()` at your target rate; `tick_period_ms` is only consulted by `pgque.ticker_loop()` (the pg_cron path).\n\n**Important:** PgQue does not deliver messages without a working ticker. Enqueueing still works, but consumers will see nothing new because no ticks are created. If you do not use `pg_cron`, run `pgque.ticker()`, `pgque.maint_retry_events()`, and `pgque.maint()` yourself. Skipping `maint_retry_events()` means nack'd events will never be redelivered.\n\nTreat installation as one-way for now — upgrade and reinstall paths are still being tightened. To uninstall: `\\i sql\u002Fpgque_uninstall.sql`.\n\n### Optional: install as a [`pg_tle`](https:\u002F\u002Fgithub.com\u002Faws\u002Fpg_tle) extension\n\nThis is opt-in. The default `\\i sql\u002Fpgque.sql` install stays the recommended path; use pg_tle only if you specifically want PgQue managed as a real Postgres extension.\n\nWhat you get with pg_tle: `pg_extension` membership, `alter extension pgque update` for version upgrades, and `drop extension pgque cascade` for atomic uninstall. What you give up: pg_tle is itself a C extension preloaded via `shared_preload_libraries`, which is the dependency the default install avoids. Available on AWS RDS \u002F Aurora and self-hosted Postgres; check your provider's extension list otherwise.\n\n**Prerequisites.** Run the installer as a role that holds `pgtle_admin` plus `CREATEROLE` (Postgres roles are cluster-global, so the wrapper creates `pgque_reader` \u002F `pgque_writer` \u002F `pgque_admin` outside the TLE body). pg_tle must also be in `shared_preload_libraries`. On managed providers, set this via the parameter group \u002F cluster config UI and reboot. On self-hosted Postgres, **append** `pg_tle` to the existing list — overwriting it disables anything else you preload (e.g. `pg_cron`):\n\n```sql\nshow shared_preload_libraries;                                -- inspect current list first\nalter system set shared_preload_libraries = 'pg_cron,pg_tle'; -- preserve existing entries\n-- restart Postgres, then in the target database:\ncreate extension pg_tle;\n```\n\nOnce pg_tle is loaded, register and create PgQue:\n\n```sql\n\\i sql\u002Fpgque-tle.sql\ncreate extension pgque;\n```\n\nTo uninstall: `\\i sql\u002Fpgque-tle-uninstall.sql`.\n\n## Roles and grants\n\nThe install creates three roles. Application users do not need superuser — grant them whichever role matches their access pattern.\n\nPgQue mirrors upstream PgQ's split: `pgque_reader` (consume) and `pgque_writer` (produce) are **siblings**, not parent\u002Fchild. `pgque_admin` is a member of both. Apps that produce **and** consume must be granted both roles explicitly.\n\n| Role | Purpose | Granted access |\n|---|---|---|\n| `pgque_reader` | Consumers, dashboards, metrics, debugging | Read-only info (`get_queue_info`, `get_consumer_info`, `get_batch_info`, `version`, `select` on all tables) **and** the consume API (`subscribe`, `unsubscribe`, `receive`, `ack`, `nack`) plus the underlying PgQ primitives (`next_batch*`, `get_batch_events`, `finish_batch`, `event_retry`, `register_consumer*`, `unregister_consumer`) and the [experimental cooperative consumer functions](docs\u002Freference.md#cooperative-consumers--subconsumers). |\n| `pgque_writer` | Producers | The produce API (`send`, `send_batch`) and the underlying primitive (`insert_event`). Does **not** inherit `pgque_reader` — a producer-only role cannot ack\u002Ffinish\u002Finspect consumer batches. |\n| `pgque_admin`  | Operators, migrations | Member of both `pgque_reader` and `pgque_writer`, plus full schema\u002Ftable\u002Fsequence access. `uninstall()` is revoked from both `pgque_admin` and PUBLIC (superuser-only — it drops the schema). |\n\nTypical app setup:\n\n```sql\n\\i sql\u002Fpgque.sql\nselect pgque.start();                     -- optional pg_cron ticker + maint\n\n-- Produce + consume in the same app: grant BOTH roles.\ncreate user app_orders with password '...';          -- replace with a real password\ngrant pgque_reader to app_orders;\ngrant pgque_writer to app_orders;\n\n-- Pure producer (e.g. a webhook ingester that only sends).\ncreate user app_webhook with password '...';\ngrant pgque_writer to app_webhook;\n\n-- Pure consumer \u002F dashboard \u002F metrics.\ncreate user metrics with password '...';              -- replace with a real password\ngrant pgque_reader to metrics;\n```\n\nDDL-class operations (`create_queue`, `drop_queue`, `start`, `stop`, `maint`, `maint_retry_events`, `ticker`, `force_next_tick`, `set_queue_config`) are not granted to either `pgque_reader` or `pgque_writer`. The schema-wide blanket `revoke execute … from public` strips PUBLIC, and `pgque_admin` is the only role that retains `execute` on these helpers — perform them as an admin \u002F migration role.\n\n**Roles are global, not per-queue.** A `pgque_reader` granted to an app can ack any consumer's batch and read any other consumer's active batch payloads. Do not grant `pgque_reader` to mutually untrusted applications sharing one database unless you add your own schema-level or database-level isolation. See [docs\u002Freference.md — Roles scope](docs\u002Freference.md#roles-are-global-not-per-queue) for details and recommended isolation patterns.\n\n## Project status\n\nPgQue is **early-stage** as a product and API layer. PgQ itself has run at Skype scale for over a decade. What's new here is the packaging, modernization, managed-Postgres compatibility, and the higher-level PgQue API around that core.\n\nThe default install stays small; additional APIs live under `sql\u002Fexperimental\u002F` until they are worth promoting. See [blueprints\u002FPHASES.md](blueprints\u002FPHASES.md).\n\n## Docs\n\n- [Tutorial](docs\u002Ftutorial.md) — a hands-on walkthrough. Start here if you are new.\n- [Reference](docs\u002Freference.md) — every shipped function and role.\n- [Examples](docs\u002Fexamples.md) — patterns: fan-out, exactly-once, batch loading, recurring jobs.\n- [Benchmarks](docs\u002Fbenchmarks.md) — throughput measurements and methodology.\n- [Tick frequency tuning](docs\u002Ftick-frequency.md) — latency\u002FWAL trade-offs, idle tick behavior, and pg_cron logging caveats.\n- [PgQ concepts](docs\u002Fpgq-concepts.md) — glossary (batch, tick, rotation) for contributors.\n- [PgQ history](docs\u002Fpgq-history.md) — where this engine came from.\n\n## Quick start\n\n```sql\n-- tx 1: create queue + consumer\nselect pgque.create_queue('orders');\nselect pgque.subscribe('orders', 'processor');\n\n-- tx 2: send a message\nselect pgque.send('orders', '{\"order_id\": 42, \"total\": 99.95}'::jsonb);\n\n-- tx 3: advance the queue if you are not using pg_cron\n-- force_next_tick bumps the event-seq threshold; ticker() then inserts the tick.\n-- Each select below is its own implicit transaction in psql autocommit —\n-- do NOT wrap these in begin\u002Fcommit (the tick must see the send committed).\nselect pgque.force_next_tick('orders');\nselect pgque.ticker();\n\n-- tx 4: receive — every returned row carries the same batch_id\nselect * from pgque.receive('orders', 'processor', 100);\n--  msg_id | batch_id |  type   |             payload              | retry_count | ...\n-- --------+----------+---------+----------------------------------+-------------+----\n--       1 |        1 | default | {\"total\": 99.95, \"order_id\": 42} |             |\n-- (jsonb sorts object keys by length then alphabetically, so the input\n--  '{\"order_id\": 42, \"total\": 99.95}' comes back with \"total\" first)\n\n-- tx 5: ack the batch_id from the previous result\nselect pgque.ack(1);\n```\n\nSend, tick, and receive **must** run in separate transactions — that's a hard requirement of PgQ's snapshot-based design, not a recommendation. A `tick` records a snapshot of committed transaction IDs; a `send` in the same xact is still in-progress at that moment and gets excluded from the next batch's visibility window, so the event never surfaces. In normal operation, `pg_cron` or an external scheduler drives `pgque.ticker()`; `force_next_tick()` is mainly for demos, tests, and manual operation. In application code, capture `batch_id` from any returned row and pass it to `ack`.\n\nThe scriptable psql idiom (replaces tx 4 + tx 5 above):\n\n```sql\nselect batch_id from pgque.receive('orders', 'processor', 100) limit 1 \\gset\nselect pgque.ack(:batch_id);\n```\n\nLonger walkthrough in the [tutorial](docs\u002Ftutorial.md); patterns like fan-out, exactly-once, and recurring jobs in [examples](docs\u002Fexamples.md).\n\n## Client libraries\n\nPgQue is SQL-first, so any Postgres driver works. First-party client libraries live in this repo for **Python**, **Go**, and **TypeScript**. The install commands below apply after the first client packages are published.\n\n### Python (`pgque-py`) — psycopg 3\n\n```bash\n# after the first Python client release\npip install pgque-py\n```\n\n```python\nfrom pgque import Consumer, connect\n\nwith connect(\"postgresql:\u002F\u002Flocalhost\u002Fmydb\") as client:\n    client.send(\"orders\", {\"order_id\": 42}, type=\"order.created\")\n    client.conn.commit()\n\nconsumer = Consumer(\n    \"postgresql:\u002F\u002Flocalhost\u002Fmydb\",\n    queue=\"orders\",\n    name=\"processor\",\n)\n\n@consumer.on(\"order.created\")\ndef handle_order(msg):\n    process_order(msg.payload)\n\nconsumer.start()\n```\n\n### Go (`github.com\u002FNikolayS\u002Fpgque-go`) — pgx\u002Fv5\n\n```bash\n# after the first Go client release\ngo get github.com\u002FNikolayS\u002Fpgque-go@latest\n```\n\n```go\nclient, _ := pgque.Connect(ctx, \"postgresql:\u002F\u002Flocalhost\u002Fmydb\")\ndefer client.Close()\n\n_, _ = client.Send(ctx, \"orders\", pgque.Event{\n    Type:    \"order.created\",\n    Payload: map[string]any{\"order_id\": 42},\n})\n\nconsumer := client.NewConsumer(\"orders\", \"processor\")\nconsumer.Handle(\"order.created\", func(ctx context.Context, msg pgque.Message) error {\n    return processOrder(msg)\n})\nconsumer.Start(ctx)\n```\n\n### TypeScript (`pgque`) — node-postgres\n\n```bash\n# after the first TypeScript client release\nnpm install pgque\n```\n\n```ts\nimport { connect } from 'pgque';\n\nconst client = await connect('postgresql:\u002F\u002Flocalhost\u002Fmydb');\ntry {\n  await client.send('orders', {\n    type: 'order.created',\n    payload: { order_id: 42 },\n  });\n  await client.subscribe('orders', 'processor');\n\n  const messages = await client.receive('orders', 'processor', 100);\n  if (messages.length > 0) await client.ack(messages[0]!.batchId);\n} finally {\n  await client.close();\n}\n```\n\n### Any language\n\n```sql\nselect pgque.send('orders', '{\"order_id\": 42}'::jsonb);\n\n-- without pg_cron, advance the queue manually (omit if a ticker is running).\n-- Run as separate transactions — do not wrap in begin\u002Fcommit.\nselect pgque.force_next_tick('orders');\nselect pgque.ticker();\n\n-- receive returns rows; every row carries the same batch_id\nselect * from pgque.receive('orders', 'processor', 100);\n\n-- ack the batch_id from any returned row (capture it in the driver)\nselect pgque.ack(1);  -- replace with the batch_id from above\n```\n\n## Benchmarks\n\nPreliminary laptop numbers: ~86k ev\u002Fs batched PL\u002FpgSQL insert, ~2.4M ev\u002Fs\nprimitive batch read rate (`get_batch_events`), zero dead-tuple growth under a\n30-minute sustained test with a blocked xmin horizon (a concurrent long-running\ntransaction holding an assigned XID — the worst case for SKIP LOCKED queues).\nThe batch read figure reflects raw PgQ primitive throughput, not end-to-end\n`receive()`\u002F`ack()` consumer throughput. See\n[docs\u002Fbenchmarks.md](docs\u002Fbenchmarks.md) for the full table and methodology.\n\nPreliminary cross-system measurements live in [`benchmark\u002F`](benchmark\u002F).\nNumbers there are for reference and exploration, not a final verdict —\nbenchmarking Postgres queues is hard (cf. Brendan Gregg) and the\nmethodology continues to evolve.\n\n## Architecture\n\nPgQue keeps PgQ's proven core architecture — snapshot-based batch isolation, three-table TRUNCATE rotation on the hot path, separate retry \u002F delayed \u002F dead-letter tables, and independent per-consumer cursors — and adds a modern API layer on top. See [blueprints\u002FSPECx.md](blueprints\u002FSPECx.md) for the full specification and [docs\u002Fpgq-concepts.md](docs\u002Fpgq-concepts.md) for the batch\u002Ftick\u002Frotation glossary.\n\n## Roadmap\n\n| Feature | Done |\n|---|---|\n| PgQ core engine | ✅ |\n| Modern Postgres support (14-18, 19devel) | ✅ |\n| Pure SQL \u002F PL\u002FpgSQL install | ✅ |\n| Managed Postgres support | ✅ |\n| No daemon \u002F no C extension | ✅ |\n| `pg_cron`, `pg_timetable`, or external ticking | ✅ |\n| Sub-second ticking with `pg_cron` (default 10 ticks\u002Fsec, tunable) | ✅ |\n| System-table rotation \u002F bloat mitigation |  |\n| Cooperative consumers \u002F subconsumers | 🔬 experimental |\n| Queue splitter |  |\n| Queue mover |  |\n| Modern `send`, `receive`, `ack`, `nack` API | ✅ |\n| `send_batch` API | ✅ |\n| Improved `send_batch` performance |  |\n| Dead-letter queue after retry limit | ✅ |\n| Go library | ✅ |\n| TypeScript library | ✅ |\n| Python library | ✅ |\n| Rust library |  |\n| Java library |  |\n| Ruby library |  |\n| Basic queue\u002Fconsumer info views | ✅ |\n| Advanced observability \u002F health views |  |\n| LISTEN\u002FNOTIFY consumer wakeups on tick |  |\n| Delayed \u002F scheduled delivery (`send_at`) |  |\n| Queue config JSON API |  |\n| Queue pause \u002F resume |  |\n| OpenTelemetry \u002F Prometheus metrics export |  |\n| Admin CLI |  |\n| Cross-database `pg_cron` scheduling |  |\n| Message TTL \u002F expiry |  |\n| Per-tenant isolation \u002F multi-schema installs |  |\n| `pg_tle` extension package | ✅ |\n| Migration guides |  |\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.\n\nSee [blueprints\u002FSPECx.md](blueprints\u002FSPECx.md) for the specification and implementation plan. New code should follow red\u002Fgreen TDD: write the failing test first, then fix it. Agents and AI coding tools should also read [CLAUDE.md](CLAUDE.md).\n\n## License\n\nApache-2.0. See [LICENSE](LICENSE).\n\nPgQue includes code derived from [PgQ](https:\u002F\u002Fgithub.com\u002Fpgq\u002Fpgq) (ISC license, Marko Kreen \u002F Skype Technologies OU). See [NOTICE](NOTICE).\n","PgQue 是一个基于 PostgreSQL 的零膨胀队列系统。其核心功能是通过单一 SQL 文件安装，并使用 `pg_cron` 或 `pg_timetable` 来触发任务，完全采用 PL\u002FpgSQL 编写，不依赖任何扩展或外部守护进程，确保在持续负载下不会出现数据膨胀问题。适用于需要在PostgreSQL数据库内部实现持久化事件流的场景，特别适合那些希望避免额外分布式系统复杂性的团队。PgQue 旨在提供类似Kafka的日志模型而非传统的任务消息队列，支持共享事件日志和每个消费者的独立游标，兼容PostgreSQL 14及以上版本，无论是托管服务还是自托管环境均可使用。",2,"2026-05-06 17:27:22","CREATED_QUERY"]