solid_queue-listen_notify 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +59 -0
- data/MIT-LICENSE +21 -0
- data/README.md +93 -0
- data/docs/REFERENCE.md +217 -0
- data/lib/generators/solid_queue/listen_notify/install/USAGE +22 -0
- data/lib/generators/solid_queue/listen_notify/install/install_generator.rb +45 -0
- data/lib/generators/solid_queue/listen_notify/install/templates/install_solid_queue_listen_notify_trigger.rb.erb +38 -0
- data/lib/solid_queue/listen_notify/connection_provider.rb +140 -0
- data/lib/solid_queue/listen_notify/listener.rb +368 -0
- data/lib/solid_queue/listen_notify/log_subscriber.rb +95 -0
- data/lib/solid_queue/listen_notify/preflight.rb +388 -0
- data/lib/solid_queue/listen_notify/queue_matcher.rb +66 -0
- data/lib/solid_queue/listen_notify/railtie.rb +45 -0
- data/lib/solid_queue/listen_notify/registry.rb +303 -0
- data/lib/solid_queue/listen_notify/trigger_installer.rb +166 -0
- data/lib/solid_queue/listen_notify/version.rb +7 -0
- data/lib/solid_queue/listen_notify.rb +376 -0
- data/lib/solid_queue-listen_notify.rb +3 -0
- data/prompts/install.md +103 -0
- data/prompts/uninstall.md +107 -0
- data/solid_queue-listen_notify.gemspec +41 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d8a439406bd1b7fa1cc8433b7397f24806657ac4930bac1470ffec2265a8ac60
|
|
4
|
+
data.tar.gz: e9e9fdebf590d423e528fc70d38cba87c6acf4e4e6b21f1b91725d1b7d7f3697
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f1b86d7da131403275b52571cf975f76987cf8f912ac685d5848717b681a8823ed0668954b44022befb82ed9c4d304ae2e2fa9e5a6d7385d031db84b3a0a5c04
|
|
7
|
+
data.tar.gz: 1c69cb4d0691584d1765312e9f835bb9ce9761b218b0172973bf969d0f2bf6299a775eac3be3056c45b84a4d77be080f4e215a8c548acd04423734b4418f5620
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.5.0] - 2026-07-27
|
|
11
|
+
|
|
12
|
+
### First official release
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
[Unreleased]: https://github.com/cmer/solid_queue-listen_notify/compare/v0.5.0...HEAD
|
|
17
|
+
[0.5.0]: https://github.com/cmer/solid_queue-listen_notify/compare/v0.1.0...v0.5.0
|
|
18
|
+
[0.1.0]: https://github.com/cmer/solid_queue-listen_notify/releases/tag/v0.1.0
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this gem is
|
|
6
|
+
|
|
7
|
+
Optional Postgres LISTEN/NOTIFY wake-ups for Solid Queue: a database trigger on `solid_queue_ready_executions` fires `pg_notify(channel, queue_name)` on commit, and one listener thread per worker process wakes matching workers via Solid Queue's public `wake_up`. Polling remains the correctness backstop — notifications are purely a latency optimization, and **every failure mode must degrade loudly to stock Solid Queue behavior** (banner in the log, gem inactive, polling intervals untouched or restored).
|
|
8
|
+
|
|
9
|
+
Two hard constraints shape everything:
|
|
10
|
+
|
|
11
|
+
1. **Zero monkey patches.** Solid Queue is integrated only through documented lifecycle hooks (`on_worker_start`/`on_worker_stop`, wired in the Railtie inside `ActiveSupport.on_load(:solid_queue)`) and public methods (`wake_up`, `queues`, `polling_interval=`, `pool.idle?`, `alive?`). Some of these are public-by-visibility rather than documented contract — that is why CI has an allowed-to-fail cell against `solid_queue@main`.
|
|
12
|
+
2. **The gem must load and pass its unit tier with no `pg`, `rails`, or `solid_queue` loaded.** Every reference to those is lazy (inside method bodies, `defined?`-guarded, error classes resolved by name at rescue time in `Listener.connection_errors`). Related trap: inside `module SolidQueue`, bare `Process` resolves to the `SolidQueue::Process` AR model once solid_queue is loaded — always write `::Process`.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
Postgres is required for integration tests (LISTEN can't be faked). Either `docker compose up -d` (publishes 127.0.0.1:55432, user `postgres` — what CI uses) or point env vars at a local server:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
POSTGRES_PORT=5432 POSTGRES_USER=$(whoami) bundle exec rake test # full suite (unit + integration)
|
|
20
|
+
bundle exec rake test:unit # no DB, no Rails — runs anywhere
|
|
21
|
+
POSTGRES_PORT=5432 POSTGRES_USER=$(whoami) bundle exec rake test:integration
|
|
22
|
+
bundle exec rubocop # rubocop-rails-omakase
|
|
23
|
+
|
|
24
|
+
# Single file / single test:
|
|
25
|
+
bundle exec ruby -Itest test/unit/queue_matcher_test.rb
|
|
26
|
+
POSTGRES_PORT=5432 POSTGRES_USER=$(whoami) bundle exec ruby -Itest test/integration/wake_latency_test.rb -n /latency/
|
|
27
|
+
|
|
28
|
+
# Other Rails versions (Appraisals: rails_7_1, rails_8_1, solid_queue_main):
|
|
29
|
+
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test
|
|
30
|
+
bundle exec appraisal generate # after editing Appraisals
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`test/integration_helper.rb` creates and schema-loads both dummy databases itself on every run — there is no separate db:setup step, and a stale test DB can never explain a failure.
|
|
34
|
+
|
|
35
|
+
## Architecture
|
|
36
|
+
|
|
37
|
+
`lib/solid_queue/listen_notify.rb` is the facade: config (`mattr_accessor`, mirroring solid_queue's style; copied from `config.solid_queue_listen_notify` by the Railtie), `register`/`deregister` (called from the lifecycle hooks; fully rescued — this gem must never make a worker look broken), the per-pid memoized `operational?`, and `after_fork`.
|
|
38
|
+
|
|
39
|
+
Flow at worker boot: `register(worker)` → `operational?` runs **Preflight** once per process → if operational, raise the worker's `polling_interval` to `fallback_polling_interval` (only ever raise, record the original) → `Registry.instance.register(worker)` starts the **Listener** on first registration.
|
|
40
|
+
|
|
41
|
+
- **`Preflight`** (`preflight.rb`) — decides operational-or-not; never raises; each failure prints an actionable WARN/ERROR banner (loud failure is a product requirement, not a nicety). Checks in order: enabled → channel 1–63 bytes → adapter is PG → trigger installed (auto-installed by default when missing) → trigger function body contains the exact `pg_notify('<channel>', ...)` call → **cross-connection self-test**: LISTEN on the listener's connection path, NOTIFY from a pooled queue-DB connection. Cross-connection is what detects both PgBouncer transaction pooling and a `listen_database` pointing at the wrong database.
|
|
42
|
+
- **`Registry`** (`registry.rb`) — per-process singleton multiplexing one listener to N workers (async mode runs N workers as threads in one process). Locking discipline is documented in its header comment and must be preserved: the mutex is never held across `wake_up`, `listener.stop`, or anything blocking; listeners are detached under the lock and stopped outside it. Cleanup is crash-tolerant, never hook-dependent (`on_worker_stop` is skipped on SIGQUIT `exit!`): dead workers are reaped on register and on the keepalive tick.
|
|
43
|
+
- **`Listener`** (`listener.rb`) — one thread, one dedicated connection produced by **`ConnectionProvider`** (GoodJob-style `pool.checkout` + `pool.remove`, pinned to the writing role; `listen_database` gets a private handler). Loop: `wait_for_notify(1s)` slices → `registry.dispatch(payload)`; `SELECT 1` keepalive every 10s; reconnect with backoff on connection errors, reporting to `SolidQueue.on_thread_error` only past a consecutive-failure threshold. On a *fatal* (non-connection) error it calls `registry.listener_crashed` so every polling interval this gem raised is restored and the workers woken — a worker left at 10s with nothing to wake it is the one failure this gem must never cause.
|
|
44
|
+
- **`QueueMatcher`** (`queue_matcher.rb`) — pure-Ruby replica of `QueueSelector` semantics, DB-free, with one deliberate asymmetry: it may over-wake but must never under-wake. Wildcard entries anchor on the literal text before the first `*` **or LIKE metacharacter** (`_`, `%`, `\`) because `QueueSelector` compiles `user_*` to `LIKE 'user_%'` where `_` matches any character. A differential test against a Ruby LIKE oracle asserts zero misses over thousands of pairs.
|
|
45
|
+
- **`TriggerInstaller`** (`trigger_installer.rb`) — DDL, injected connection, no pool management. `installed?` resolves the table with `to_regclass` (search_path-aware). The generator's migration template contains literal SQL that must stay in sync with the installer — a unit test renders the template and compares.
|
|
46
|
+
|
|
47
|
+
### Fork safety (Puma clustered + async mode)
|
|
48
|
+
|
|
49
|
+
The listener's connection is invisible to ActiveRecord's post-fork `discard_pools!` because it was removed from its pool. `ListenNotify.after_fork` (ForkTracker) plus pid checks at registry entry points handle it: in the child, the inherited adapter is **`discard!`ed, never `disconnect!`ed** — disconnect sends a termination packet down the socket shared with the parent and kills the parent's session. The hook also replaces every mutex in the gem and makes the provider forget memoized `listen_database` pools (AR discarded them behind our back). The fork integration test forces GC in the child specifically to catch the passive failure mode (ruby-pg's finalizer calls `PQfinish` unconditionally); it was validated by mutation — sabotaging `discard!` makes it fail.
|
|
50
|
+
|
|
51
|
+
### Testing rules
|
|
52
|
+
|
|
53
|
+
- Integration tests extend `IntegrationTestCase` and are **non-transactional by necessity** — Postgres delivers NOTIFY on commit, so a transactional test never sees one. Cleanup is `destroy_records` in setup and teardown, unique queue names per test, `wait_for`-style polling helpers (copied from solid_queue's harness) instead of fixed sleeps.
|
|
54
|
+
- Instrumentation events use the `*.solid_queue_listen_notify` namespace. The listener-start event is `start_listener`, not `start` — `ActiveSupport::Subscriber` silently refuses to subscribe methods named `start`/`finish`.
|
|
55
|
+
- `test/dummy` is a minimal PG-only Rails app (primary + `queue` databases, `connects_to` on the queue DB) modeled on solid_queue's own dummy.
|
|
56
|
+
|
|
57
|
+
## Reference
|
|
58
|
+
|
|
59
|
+
`docs/REFERENCE.md` documents every config option, log line, instrumentation payload, and failure mode — keep it (and the README's small config table) in sync with code when changing defaults or behavior. Maintain `CHANGELOG.md` for user-visible changes — brief and simple: one line per change, no essays. `prompts/install.md` and `prompts/uninstall.md` are agent-executable instructions fetched via raw GitHub URLs from the README; update them when the install/uninstall flow or defaults change. A local read-only checkout of solid_queue v1.5.0 typically lives at `../solid_queue` for cross-referencing upstream internals.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carl Mercier
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# LISTEN/NOTIFY for Solid Queue
|
|
2
|
+
|
|
3
|
+
Instant job pickup for [Solid Queue](https://github.com/rails/solid_queue) on PostgreSQL — without the polling tax.
|
|
4
|
+
|
|
5
|
+
Out of the box, every Solid Queue worker polls the database for work every 0.1 seconds, forever, whether or not there is anything to do. This gem replaces the waiting with Postgres's native LISTEN/NOTIFY: a database trigger announces the moment a job becomes ready, and workers wake instantly instead of polling for it.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
- **Near-instant pickup.** Enqueue-to-start drops to ~50–60 ms (measured locally) even with workers polling once a minute.
|
|
10
|
+
- **Orders of magnitude less database load.** Polling can be raised from 10 queries/second per worker to once every 10–60 seconds, because it no longer has to be fast — just present.
|
|
11
|
+
- **Safe by design.** Polling stays as the correctness backstop. If anything is wrong — trigger missing, database down, a pooler eating the `LISTEN`, connection dropped — the gem says so loudly in the log, deactivates itself, and you are running stock Solid Queue.
|
|
12
|
+
- **No patches.** Solid Queue is untouched: the gem uses only its documented lifecycle hooks and public APIs, and it can be [removed in five minutes](#uninstalling).
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
PostgreSQL 12+ (for the queue database only), Solid Queue 1.5+, Rails 7.1+, Ruby 3.2+.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
**With an AI agent** — paste this into Claude Code, Cursor, or any coding agent, from your application's root. It checks the prerequisites (Solid Queue installed, Postgres), installs, and verifies notifications actually flow:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Fetch https://raw.githubusercontent.com/cmer/solid_queue-listen_notify/main/prompts/install.md
|
|
24
|
+
and follow its instructions to install solid_queue-listen_notify in this application.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**By hand:**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle add solid_queue-listen_notify
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's it. Run `bin/jobs` as before: the gem installs its database trigger automatically at first boot, verifies that notifications actually get delivered, and only then raises the workers' polling interval (0.1 s → 10 s by default). If your workers' database user can't `CREATE FUNCTION`/`CREATE TRIGGER`, install the trigger with a migration instead: `bin/rails generate solid_queue:listen_notify:install --database queue && bin/rails db:migrate`, and set `auto_install_trigger = false`.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
An `AFTER INSERT` trigger on `solid_queue_ready_executions` calls `pg_notify` with the queue name — covering every path that makes a job ready, since they all end in an insert on that table. Postgres delivers notifications on commit and de-duplicates within a transaction, so a bulk enqueue of 500 jobs sends one notification and a rolled-back enqueue sends none.
|
|
38
|
+
|
|
39
|
+
In each worker process, one listener thread on one dedicated connection receives the notifications and wakes the workers whose queues match, using Solid Queue's own public `wake_up`. If the connection drops, the listener reconnects with backoff while polling covers the gap.
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
The defaults are right for most apps. The options you're most likely to touch:
|
|
44
|
+
|
|
45
|
+
| Option | Default | What it does |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `fallback_polling_interval` | `10.seconds` | What workers' `polling_interval` is raised to once notifications are verified. Only ever raises; `nil` to leave intervals alone. |
|
|
48
|
+
| `listen_database` | `nil` | A `database.yml` entry that connects **directly** to Postgres, for apps behind PgBouncer in transaction mode (which silently breaks `LISTEN` — the gem detects this at boot and tells you). |
|
|
49
|
+
| `enabled` | `true` | Kill switch; also `SOLID_QUEUE_LISTEN_NOTIFY_ENABLED=false` in the environment, no deploy needed. |
|
|
50
|
+
|
|
51
|
+
Set them in an initializer as above, or via `config.solid_queue_listen_notify.*` in your Rails config. The full list of options, log output, instrumentation events, and the complete failure-mode reference live in [docs/REFERENCE.md](docs/REFERENCE.md).
|
|
52
|
+
|
|
53
|
+
## Good to know
|
|
54
|
+
|
|
55
|
+
- **Scheduled and recurring jobs** still wait on Solid Queue's dispatcher (1 s polling by default) to become ready; only the ready-to-running hop is accelerated. Leave the dispatcher's interval alone.
|
|
56
|
+
- **One extra Postgres connection** per worker process, held by the listener. Budget for it in `max_connections`.
|
|
57
|
+
- **Puma in clustered mode with `solid_queue_mode :async`** is discouraged (a forked child can inherit the listener's connection — the gem guards against it, but prefer fork mode or standalone `bin/jobs`).
|
|
58
|
+
- Workers on paused or non-matching queues, saturated pools, replicas, `SIGQUIT` teardown — all handled; see the [failure-mode reference](docs/REFERENCE.md#failure-modes).
|
|
59
|
+
|
|
60
|
+
## Uninstalling
|
|
61
|
+
|
|
62
|
+
**With an AI agent:**
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
Fetch https://raw.githubusercontent.com/cmer/solid_queue-listen_notify/main/prompts/uninstall.md
|
|
66
|
+
and follow its instructions to cleanly remove solid_queue-listen_notify from this application,
|
|
67
|
+
including a migration that drops the database trigger.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**By hand:** remove the gem, lower `polling_interval` in `config/queue.yml` back to your liking, and (optionally) drop the trigger:
|
|
71
|
+
|
|
72
|
+
```sql
|
|
73
|
+
DROP TRIGGER IF EXISTS solid_queue_listen_notify ON solid_queue_ready_executions;
|
|
74
|
+
DROP FUNCTION IF EXISTS solid_queue_listen_notify_ready();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A leftover trigger notifying a channel nobody listens on is harmless.
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
The suite needs a real Postgres (`LISTEN` can't be faked): `docker compose up -d && bin/setup && bundle exec rake test`, or point `POSTGRES_HOST`/`POSTGRES_PORT`/`POSTGRES_USER`/`POSTGRES_PASSWORD` at your own server. `rake test:unit` runs without any database. See [docs/REFERENCE.md](docs/REFERENCE.md#development-and-testing) for details.
|
|
82
|
+
|
|
83
|
+
## Credits
|
|
84
|
+
|
|
85
|
+
The listener's design — a dedicated connection checked out of the Active Record pool and removed from it, short `wait_for_notify` slices, keepalives, and patient reconnection — is closely inspired by [GoodJob](https://github.com/bensheldon/good_job)'s notifier, which pioneered LISTEN/NOTIFY for Postgres-backed Active Job backends. Thank you, Ben Sheldon and GoodJob contributors.
|
|
86
|
+
|
|
87
|
+
## Contributing
|
|
88
|
+
|
|
89
|
+
Bug reports and pull requests are welcome at https://github.com/cmer/solid_queue-listen_notify — please include a failing test where it makes sense.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
Open source under the terms of the [MIT License](MIT-LICENSE).
|
data/docs/REFERENCE.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# solid_queue-listen_notify — Reference
|
|
2
|
+
|
|
3
|
+
The [README](../README.md) covers what the gem does and how to install it. This document is the complete reference: every configuration option, what appears in the logs, the instrumentation events, the full failure-mode table, and the details behind the caveats.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [How it works, in detail](#how-it-works-in-detail)
|
|
8
|
+
- [Configuration](#configuration)
|
|
9
|
+
- [What you'll see in the logs](#what-youll-see-in-the-logs)
|
|
10
|
+
- [Instrumentation events](#instrumentation-events)
|
|
11
|
+
- [Failure modes](#failure-modes)
|
|
12
|
+
- [Caveats in detail](#caveats-in-detail)
|
|
13
|
+
- [Development and testing](#development-and-testing)
|
|
14
|
+
|
|
15
|
+
## How it works, in detail
|
|
16
|
+
|
|
17
|
+
An `AFTER INSERT` trigger on `solid_queue_ready_executions` calls `pg_notify(channel, NEW.queue_name)`. Every path that makes a job ready — the bulk `insert_all` the dispatcher and `enqueue_all` use, the `create_or_find_by!` behind an immediate enqueue, a blocked execution being promoted when its concurrency lock frees up — ends in an insert on that table, so all of them are covered without the gem touching a line of Solid Queue's code.
|
|
18
|
+
|
|
19
|
+
Postgres delivers notifications **on commit**, and it collapses duplicate `(channel, payload)` pairs within a transaction, so a bulk enqueue of 500 jobs on one queue sends one notification, and a job enqueued inside a transaction that rolls back sends none.
|
|
20
|
+
|
|
21
|
+
On the receiving side there is **one listener thread with one dedicated connection per worker process**, not per worker. In `async` mode a single process runs N workers in N threads; a per-worker connection would be N connections doing the same thing. Instead a registry keeps track of the workers in the process and fans each notification out to the ones whose queue list matches the payload, using a pure-Ruby copy of Solid Queue's `QueueSelector` string semantics (`*`, exact names, `prefix*`) so that routing costs no database queries. Waking a worker calls its public `wake_up`, which writes a byte to the worker's self-pipe — the same mechanism Solid Queue's own thread pool uses.
|
|
22
|
+
|
|
23
|
+
Matching is deliberately asymmetric: waking a worker that turns out to have nothing to do costs one wasted poll, while failing to wake one costs latency, so wherever exact parity with `QueueSelector` would need a query the matcher over-matches instead. That is also why a glob is matched on the literal text before its first `*` **or** its first SQL `LIKE` metacharacter: `QueueSelector` turns `user_*` into `LIKE 'user_%'`, where the `_` matches any single character, so a worker on `user_*` really does claim the queue `users` — and the matcher wakes it, by anchoring on `user`. The differential test in `test/unit/queue_matcher_test.rb` checks this against an independent oracle over a few thousand entry/queue-name pairs, and asserts zero misses.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
enqueue ──► solid_queue_ready_executions ──► trigger ──► pg_notify("solid_queue_ready", queue_name)
|
|
27
|
+
│ (on COMMIT)
|
|
28
|
+
▼
|
|
29
|
+
┌───────────── worker process ─────────────┐
|
|
30
|
+
│ listener thread (1 dedicated connection) │
|
|
31
|
+
│ │ registry.dispatch │
|
|
32
|
+
│ ┌───────────┼───────────┐ │
|
|
33
|
+
│ worker A worker B worker C │
|
|
34
|
+
│ wake_up wake_up (queues │
|
|
35
|
+
│ don't match) │
|
|
36
|
+
└───────────────────────────────────────────┘
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The listener's connection is checked out of the pool and then **removed** from it, so it is never handed to application code while it sits in `wait_for_notify`. It is pinned to the writing role: a `LISTEN` on a replica hears nothing.
|
|
40
|
+
|
|
41
|
+
On Postgres 14 and up the trigger is installed with `CREATE OR REPLACE TRIGGER`; below that, with a `DROP` followed by a `CREATE`. The trigger uses `EXECUTE FUNCTION`, which needs Postgres 11; 12 is the documented floor because that is the oldest release still worth supporting.
|
|
42
|
+
|
|
43
|
+
The generated migration contains literal SQL with the channel name baked in. It doesn't reference the gem at all, so it keeps replaying (and reverting) after the gem is removed. If you change `channel` from its default, configure it **before** running the generator.
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
Set options either through the Railtie, in `config/application.rb` or an environment file:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
config.solid_queue_listen_notify.fallback_polling_interval = 30.seconds
|
|
51
|
+
config.solid_queue_listen_notify.keepalive_interval = 15.seconds
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
...or directly on the module, from an initializer:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# config/initializers/solid_queue_listen_notify.rb
|
|
58
|
+
SolidQueue::ListenNotify.fallback_polling_interval = 30.seconds
|
|
59
|
+
SolidQueue::ListenNotify.keepalive_interval = 15.seconds
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The two are equivalent — `config.solid_queue_listen_notify` is copied onto the module by an initializer. An unknown option name is logged as a warning and ignored rather than raising; a typo in an optional optimization shouldn't stop an app from booting.
|
|
63
|
+
|
|
64
|
+
| Option | Default | What it does |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `enabled` | `true`, unless `SOLID_QUEUE_LISTEN_NOTIFY_ENABLED` is set to the literal string `"false"` | Master switch. When off, the preflight returns immediately with reason `:disabled`, nothing is registered, no connection is opened, and polling intervals are untouched. The environment variable is read once, when the gem is loaded, and **only the exact value `"false"` disables it** — `"0"`, `"no"` and `"off"` all leave the gem enabled. |
|
|
67
|
+
| `channel` | `"solid_queue_ready"` | The `pg_notify` channel. Must be **1 to 63 bytes** — Postgres's identifier limit — and must match what the installed trigger notifies on. The preflight checks the length before it opens a connection, and looks in the trigger's function body for the exact `pg_notify('<channel>', NEW.queue_name)` call to detect drift. |
|
|
68
|
+
| `fallback_polling_interval` | `10.seconds` | The polling interval workers are raised to once notifications are proven to work. **Only ever raises**: a worker already configured to poll every 30 seconds keeps its 30 seconds, and a worker at 0.1 goes to 10. Set to `nil` to never touch any worker's interval. Applied only when the gem is operational, and put back if registration then fails. |
|
|
69
|
+
| `listen_database` | `nil` | Name of a `config/database.yml` entry to open the listener connection against, instead of Solid Queue's own pool. This is the PgBouncer escape hatch: point it at an entry that connects **directly** to Postgres. It must name **the same database** as the queue database — only the connection route may differ — and the preflight's self-test fails if it doesn't. It gets a private connection handler, so it never mixes with your application's pools. |
|
|
70
|
+
| `auto_install_trigger` | `true` | When the preflight finds the trigger missing, install it there and then. Idempotent, and the cure for the [schema.rb trap](#schemarb-does-not-dump-triggers), which is why it is on by default. Needs a database user allowed to create functions and triggers; a failure is treated as "still missing" (you get the install instructions, not a stack trace). Set to `false` if your worker's database user must not have DDL privileges — then run the generator's migration instead. |
|
|
71
|
+
| `wake_saturated_workers` | `false` | By default a worker whose thread pool has no idle thread is skipped: it has nothing to do with the wake-up, and its pool's own `on_idle` hook plus polling cover the race. Set to `true` to wake it anyway. |
|
|
72
|
+
| `wait_timeout` | `1.second` | How long each `wait_for_notify` call blocks before looping. Also the granularity at which the listener notices it has been asked to stop, and (times two, plus one) the join timeout on shutdown — which is what keeps it inside Solid Queue's 5-second shutdown timeout. |
|
|
73
|
+
| `keepalive_interval` | `10.seconds` | How often the listener runs `SELECT 1` on its connection, to notice a silently dropped connection and to keep idle-connection reapers away. Each tick also sweeps workers that went away without a stop hook. |
|
|
74
|
+
| `reconnect_wait` | `5.seconds` | Backoff between reconnection attempts after a connection error. Slept in `wait_timeout`-sized slices so that a shutdown is never delayed by a full backoff. |
|
|
75
|
+
| `connection_errors_reporting_threshold` | `6` | Consecutive connection failures before errors start being reported to `SolidQueue.on_thread_error` (and logged at `ERROR` rather than `WARN`). With the defaults that is roughly 30 seconds of failing before your exception tracker hears about it, which keeps a rolling database restart from paging anyone. The counter resets on every **successful connect**, as well as on the first notification or keepalive after one — so a connection that flaps faster than `keepalive_interval` cannot climb past the threshold once and then report forever. |
|
|
76
|
+
| `application_name` | `"solid_queue-listen_notify [<pid>]"` | The `application_name` the listener sets on its connection, so you can find it in `pg_stat_activity`. The default is resolved on every call, so a process that forks after boot reports its own pid. |
|
|
77
|
+
| `connection_provider` | `SolidQueue::ListenNotify::ConnectionProvider.new` | **Advanced.** Anything responding to `#call` that returns a pool-removed Postgres connection. Replace it only if you need to hand the listener a connection nothing else could produce; the default already handles the writing-role pin and `listen_database`. |
|
|
78
|
+
|
|
79
|
+
## What you'll see in the logs
|
|
80
|
+
|
|
81
|
+
Everything goes through `SolidQueue.logger` (with a `$stdout` fallback, so the warnings can't be lost when Solid Queue isn't loaded yet), formatted like Solid Queue's own log subscriber so a single log reads as one stream:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
SolidQueue-ListenNotify-0.1.0 Preflight passed (32.7ms) channel: "solid_queue_ready", operational: true, reason: nil
|
|
85
|
+
SolidQueue-ListenNotify-0.1.0 Raised polling interval (0.0ms) worker_name: "worker-9a1f0c6b3e8d7c2a5b41", from: 0.1, to: 10.0
|
|
86
|
+
SolidQueue-ListenNotify-0.1.0 Started listener (1.2ms) pid: 51234, channel: "solid_queue_ready"
|
|
87
|
+
SolidQueue-ListenNotify-0.1.0 Notification (0.3ms) queue_name: "background", woken: 1, skipped_saturated: 0
|
|
88
|
+
SolidQueue-ListenNotify-0.1.0 Keepalive (0.4ms) pid: 51234
|
|
89
|
+
SolidQueue-ListenNotify-0.1.0 Stopped listener (0.1ms) pid: 51234
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`Notification` and `Keepalive` are logged at `DEBUG`, because they happen constantly. At `INFO` you see the preflight verdict, the listener starting and stopping, any polling interval that was raised, and the trigger being installed. Anything that means "this is degraded" is at least a warning:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
SolidQueue-ListenNotify-0.1.0 Preflight failed – the gem is inactive (18.4ms) channel: "solid_queue_ready", operational: false, reason: :trigger_missing
|
|
96
|
+
SolidQueue-ListenNotify-0.1.0 Listener connection lost – reconnecting (0.2ms) error: "PG::ConnectionBad", message: "PQconsumeInput() server closed the connection unexpectedly", consecutive_failures: 1, wait: 5 seconds
|
|
97
|
+
SolidQueue-ListenNotify-0.1.0 Fork detected – discarding the inherited listener (0.1ms) parent_pid: 51234, pid: 51299
|
|
98
|
+
SolidQueue-ListenNotify-0.1.0 Listener died (0.1ms) pid: 51234, error: "NotImplementedError", message: "No connection provider configured..."
|
|
99
|
+
SolidQueue-ListenNotify listener died permanently (NotImplementedError: No connection provider configured...); 2 worker(s) restored to their original polling intervals. Notifications are no longer being delivered in this process — it is back to plain polling.
|
|
100
|
+
SolidQueue-ListenNotify-0.1.0 Restored polling interval (0.0ms) worker_name: "worker-9a1f0c6b3e8d7c2a5b41", from: 10.0, to: 0.1
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A failing preflight also prints a multi-line banner explaining what is wrong and how to fix it. That banner is written by the preflight itself, not by the log subscriber, so it comes out even in a process where no subscriber was ever attached.
|
|
104
|
+
|
|
105
|
+
## Instrumentation events
|
|
106
|
+
|
|
107
|
+
All events are on `ActiveSupport::Notifications` with the suffix `.solid_queue_listen_notify`, so `preflight.solid_queue_listen_notify`, `notify.solid_queue_listen_notify`, and so on.
|
|
108
|
+
|
|
109
|
+
| Event | Payload | When |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `preflight` | `channel:`, `operational:`, `reason:`, `details:` (only when there are any) | Once per process, the first time `operational?` is asked. `reason` is `nil` on success, otherwise one of `:disabled`, `:invalid_channel`, `:unsupported_adapter`, `:trigger_missing`, `:channel_mismatch`, `:self_test_failed`, `:error`. |
|
|
112
|
+
| `start_listener` | `pid:`, `channel:` | The listener connected and issued its `LISTEN`. Also fires on every reconnect. (It is not called `start`: `ActiveSupport::Subscriber` reserves that name for the notifier protocol.) |
|
|
113
|
+
| `notify` | `queue_name:`, `woken:`, `skipped_saturated:` | A notification arrived and was fanned out. `queue_name` is the payload, i.e. the queue the job was enqueued on. |
|
|
114
|
+
| `keepalive` | `pid:` | A `SELECT 1` tick, every `keepalive_interval`. |
|
|
115
|
+
| `reconnect` | `error:`, `message:`, `consecutive_failures:`, `reported:`, `wait:` | A connection error was caught. `reported: true` means the failure count exceeded `connection_errors_reporting_threshold` and the error was handed to `SolidQueue.on_thread_error`. |
|
|
116
|
+
| `install_trigger` | `channel:`, `database_version:` | The trigger was installed — by `auto_install_trigger`, or by anything else calling `TriggerInstaller#install!`. `database_version` is Postgres's integer version (e.g. `150001`). |
|
|
117
|
+
| `fork_detected` | `parent_pid:`, `pid:` | A registry entry point ran in a process that inherited a listener from its parent. Logged at `WARN`: see [the fork caveat](#puma-clustered--async-is-discouraged). |
|
|
118
|
+
| `shutdown` | `pid:`, plus `error:` and `message:` when it died | The listener thread finished, whether cleanly or not. |
|
|
119
|
+
| `override_polling_interval` | `worker_name:`, `from:`, `to:`, `restored:` (only on the way back) | A worker's polling interval was raised to `fallback_polling_interval`. Never emitted when the gem is not operational. The same event with `restored: true` means the listener died for good and the worker was put back on its original interval. |
|
|
120
|
+
|
|
121
|
+
## Failure modes
|
|
122
|
+
|
|
123
|
+
The table below is the whole safety story. In every row the outcome is "stock Solid Queue" — polling at the interval you configured — and in every row you are told about it.
|
|
124
|
+
|
|
125
|
+
| Situation | What the gem does |
|
|
126
|
+
|---|---|
|
|
127
|
+
| **Trigger missing** | The preflight installs it on the spot (`auto_install_trigger` is on by default) and carries on. Only when that fails — no DDL privilege — or when auto-install was turned off does it fail with `:trigger_missing` and print a banner with the exact generator command (and the install failure, when there was one). Not operational in that case, polling intervals untouched. |
|
|
128
|
+
| **Non-Postgres adapter** | Detected from the queue pool's configuration, without opening a connection at all. An adapter that *is* named Postgres but whose raw connection can't `wait_for_notify` is caught right after. Either way, a prominent banner names the adapter it found and tells you to remove the gem or move the queue database to Postgres. Not operational. |
|
|
129
|
+
| **Postgres down at boot** | Every branch of the preflight is rescued: the exception becomes an `:error` verdict with its class and message in the payload. `bin/jobs` starts normally and workers poll. The verdict is per-process and memoized, so a database that comes back later is picked up by the next process, not this one. |
|
|
130
|
+
| **Connection drops mid-run** | The listener catches `PG::Error`, `ActiveRecord::ConnectionNotEstablished`, `ActiveRecord::ConnectionFailed`, `ActiveRecord::StatementInvalid`, `IOError`, `EOFError`, `Errno::EPIPE` and `Errno::ECONNRESET`, disconnects, waits `reconnect_wait`, and reconnects — indefinitely. The first failures are `WARN`-level only; after `connection_errors_reporting_threshold` consecutive ones they escalate to `ERROR` and go to `SolidQueue.on_thread_error`. Meanwhile polling still runs the jobs. |
|
|
131
|
+
| **Listener dies for good** | A fatal, non-connection error (API drift, a connection provider that cannot be configured) kills the listener thread, and no reconnect will fix it. The gem raised those workers' polling intervals on the strength of a promise it can no longer keep, so it withdraws the promise: the registry detaches the dead listener, every interval this gem raised is put back, each worker is `wake_up`ped so it re-reads it instead of finishing its 10-second sleep, and one `ERROR` line says the listener died permanently and how many workers were restored. Back to stock Solid Queue at the interval you configured. |
|
|
132
|
+
| **A subscriber of ours raises** | Your `ActiveSupport::Notifications` subscribers run on the listener thread. A raising subscriber is caught, reported once per exception class, and the loop carries on — the notification it was subscribed to has already been fanned out. An exception from the gem's own work inside an instrumented block is *not* swallowed: a connection error in a keepalive still reaches the reconnect path. |
|
|
133
|
+
| **PgBouncer / transaction pooling** | Transaction-mode pooling silently discards `LISTEN`, and cannot be detected by asking. So the preflight does the only reliable thing: it `LISTEN`s on the very connection the listener would use, sends a `NOTIFY` on the real channel **from a pooled connection to the queue database** — the connection the trigger itself would fire on — and waits 2 seconds for it to arrive. Failure prints an `ERROR` banner naming PgBouncer explicitly and telling you to set `listen_database`. Because the two halves run on different connections, the same test also catches a `listen_database` pointing at a reachable but *wrong* database, and the banner says so. It additionally runs `SELECT pg_backend_pid()` twice on one connection; if the answers differ, the banner adds that you are almost certainly behind a transaction-mode pooler. Not operational. |
|
|
134
|
+
| **Channel Postgres can't accept** | Notification channels are identifiers, capped at 63 bytes. `LISTEN`/`NOTIFY` truncate a longer one *silently*, but the trigger's `pg_notify()` takes it as text and raises `channel name too long` — on every insert into `solid_queue_ready_executions`, which is to say on every enqueue in your application. So the length is checked in three places: the generator refuses to write the migration, `TriggerInstaller#install!` raises rather than installing, and the preflight fails with `:invalid_channel` and a banner before it opens a single connection. |
|
|
135
|
+
| **`SIGQUIT` / `exit!`** | `on_worker_stop` is not guaranteed to run — a supervisor past its shutdown timeout calls `exit!` and skips every callback. Nothing here depends on it. The listener's connection dies with the process, and inside a surviving process the registry reaps workers that are no longer `alive?` on every register and every keepalive tick — and drops any worker whose `wake_up` raises while a notification is being fanned out. The listener stops itself when the last worker goes, and that stop does **not** join the listener thread: a worker gets five seconds to shut down in total, and the thread unwinds and closes its own connection within one `wait_timeout` without being waited on. |
|
|
136
|
+
| **`fork` after the listener started** | The listener's connection was removed from its pool, so Active Record's own `discard_pools!` cannot see it. A `ForkTracker.after_fork` hook plus a pid check at every registry entry point catches this: in the child, the inherited connection is `discard!`ed (which drops our end of the file descriptor — never `disconnect!`, which would send a termination packet down the **parent's** session), the registry is emptied, and `fork_detected` is logged at `WARN`. The child gets a clean listener of its own the first time a worker registers. |
|
|
137
|
+
| **`fork` while a lock was held** | `fork()` copies only the calling thread, so any lock another thread was holding is inherited with nobody left to release it. CRuby abandons those mutexes itself, so this is not a live hang today — but the exposure is asymmetric (the widest window is `operational?`, which holds a lock across connecting to Postgres and a two-second self-test, and a child that inherited it would hang the first worker to register, silently, inside a lifecycle hook), so the `after_fork` hook **replaces** every lock in the gem while the child is still single-threaded rather than relying on an interpreter detail. The same hook makes the connection provider forget any pool it memoized for `listen_database`, because Active Record discarded that pool in the child behind our back and the first checkout from it would raise. |
|
|
138
|
+
| **Saturated workers** | A worker with no idle thread is skipped and counted in the `notify` event's `skipped_saturated`. Its pool wakes it itself when a thread frees up, and polling is still there. `wake_saturated_workers = true` opts out. |
|
|
139
|
+
| **Paused queues** | Notifications are routed by queue name only, with no database lookup, so a worker on a paused queue is woken. It claims nothing and goes back to sleep — a wasted wake-up, not a bug. |
|
|
140
|
+
| **Replicas** | The listener connection is checked out inside `connected_to(role: :writing)`. A `LISTEN` on a replica hears nothing, so this is not optional. |
|
|
141
|
+
| **Gem removed, trigger left behind** | The trigger keeps firing `pg_notify` with nobody listening, which costs a function call per inserted row and nothing else. Run the migration's `down` when you want it gone. |
|
|
142
|
+
|
|
143
|
+
## Caveats in detail
|
|
144
|
+
|
|
145
|
+
### schema.rb does not dump triggers
|
|
146
|
+
|
|
147
|
+
`ActiveRecord::Base.schema_format = :ruby` — the default — dumps tables, indexes and columns, and **not** triggers. So a database created with `db:schema:load` (a fresh CI database, a new developer's machine, a restored staging environment) has the table but not the trigger.
|
|
148
|
+
|
|
149
|
+
This is why `auto_install_trigger` is **on by default**: the preflight simply puts the trigger back whenever it is missing, and the trap never fires. It needs a database user allowed to `CREATE FUNCTION` and `CREATE TRIGGER`. If yours must not have DDL privileges, set it to `false` and either use `schema_format = :sql` for the queue database so `structure.sql` carries the trigger (Rails 8.0+ accepts a per-entry `schema_format: sql` in `config/database.yml`; on 7.1/7.2 it is `ActiveRecord.schema_format` for the whole application or nothing), or run migrations rather than loading the schema on fresh environments.
|
|
150
|
+
|
|
151
|
+
Either way, the failure is loud: a database without the trigger and no way to install it produces the `:trigger_missing` banner when a worker process starts, not silence.
|
|
152
|
+
|
|
153
|
+
### PgBouncer and other transaction-mode poolers
|
|
154
|
+
|
|
155
|
+
`LISTEN` is a session-level statement. A transaction-mode pooler hands your session back to the pool at the end of every transaction, so a `LISTEN` issued through it applies to whichever backend happened to serve that statement, and notifications go nowhere. Session-mode pooling is fine; transaction mode is not.
|
|
156
|
+
|
|
157
|
+
The preflight's self-test detects this and refuses to activate, so you get an error banner instead of a silent latency regression. The fix is `listen_database`: add an entry to `config/database.yml` that connects straight to Postgres, bypassing the pooler, and name it there. That entry is used for the listener connection only — one connection per worker process — while everything else keeps going through the pooler.
|
|
158
|
+
|
|
159
|
+
It has to point at **the same database**, with only the route differing. An entry naming a different database connects and `LISTEN`s perfectly happily and then never hears a thing, which is exactly the kind of silent failure this gem exists not to have — so the self-test spans both connections and catches it, and the banner tells you to check for it.
|
|
160
|
+
|
|
161
|
+
### Puma clustered + `async` is discouraged
|
|
162
|
+
|
|
163
|
+
Running Solid Queue inside Puma with `solid_queue_mode :async` **and** Puma in clustered mode (`workers > 0`) means the listener's dedicated connection can be inherited across `fork`. The gem handles this — see the fork rows in the table above, and there is an integration test that forks a live listener and asserts the parent's `pg_stat_activity` session survives the child exiting — but you are relying on a guard rather than on the problem not existing.
|
|
164
|
+
|
|
165
|
+
Solid Queue's own advice applies here too: the recommended and default mode is `fork`. Prefer the Puma plugin in its default fork mode, or run `bin/jobs` as its own process. If you must run clustered Puma with `solid_queue_mode :async`, watch for `Fork detected` warnings in the log — they are at `WARN` precisely so you can.
|
|
166
|
+
|
|
167
|
+
### Scheduled and recurring jobs are still bound by the dispatcher
|
|
168
|
+
|
|
169
|
+
This gem accelerates one thing: the moment a row appears in `solid_queue_ready_executions`. Jobs enqueued with `set(wait:)` or `wait_until:`, and recurring tasks, sit in `solid_queue_scheduled_executions` until the **dispatcher** polls, finds them due, and moves them across — at which point the trigger fires and the pickup is instant. The dispatcher's own `polling_interval` (1 second by default) is therefore the floor for delayed jobs, and raising it slows them down. Raise worker polling intervals freely; leave the dispatcher's alone.
|
|
170
|
+
|
|
171
|
+
### The preflight sends one real notification at boot
|
|
172
|
+
|
|
173
|
+
The self-test `LISTEN`s on the connection the listener will use, then sends a `NOTIFY` on the configured channel, with the payload `"preflight"`, **from a pooled connection to the queue database** — the same connection route the trigger's own notifications take. Testing the real channel over the real path is the point; anything narrower would test a different code path.
|
|
174
|
+
|
|
175
|
+
It does mean every other worker process listening on that channel receives it too. Those processes fan out a notification for a queue named `preflight`: workers listening on `*` (or on a prefix that happens to match) wake up, find nothing, and go back to sleep. It costs one spurious wake-up per running worker process per new process started, and nothing else.
|
|
176
|
+
|
|
177
|
+
### One extra connection per worker process
|
|
178
|
+
|
|
179
|
+
The listener holds a direct Postgres connection for the lifetime of the process, outside the Active Record pool it came from. In `fork` mode that is one connection per worker process; in `async` mode, one for the whole supervisor. Budget for it in `max_connections` — and remember that the preflight briefly opens (and closes) one of its own before the listener does.
|
|
180
|
+
|
|
181
|
+
### Uninstalling, in full
|
|
182
|
+
|
|
183
|
+
1. Remove `solid_queue-listen_notify` from your `Gemfile`.
|
|
184
|
+
2. Lower `polling_interval` in `config/queue.yml` back to something you're happy with as the *only* pickup path.
|
|
185
|
+
3. Drop the trigger, either by reverting the generated migration (`bin/rails db:migrate:down:queue VERSION=20260727120000`, using the name you passed to `--database`) or with two statements:
|
|
186
|
+
|
|
187
|
+
```sql
|
|
188
|
+
DROP TRIGGER IF EXISTS solid_queue_listen_notify ON solid_queue_ready_executions;
|
|
189
|
+
DROP FUNCTION IF EXISTS solid_queue_listen_notify_ready();
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Step 3 is optional and can wait: a trigger notifying a channel nobody listens on is harmless. And if you only want to turn the gem off without deploying, set `SOLID_QUEUE_LISTEN_NOTIFY_ENABLED=false` and restart your workers.
|
|
193
|
+
|
|
194
|
+
## Development and testing
|
|
195
|
+
|
|
196
|
+
The test suite needs a Postgres it can `LISTEN` on. There's a compose file for one:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
docker compose up -d
|
|
200
|
+
bin/setup
|
|
201
|
+
bundle exec rake test
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
If you already run Postgres locally, skip Docker entirely — the dummy app reads `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER` and `POSTGRES_PASSWORD`, and defaults to what the compose file publishes (`127.0.0.1:55432`, user `postgres`):
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
POSTGRES_PORT=5432 POSTGRES_USER=$(whoami) bin/setup
|
|
208
|
+
POSTGRES_PORT=5432 POSTGRES_USER=$(whoami) bundle exec rake test
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Three tasks:
|
|
212
|
+
|
|
213
|
+
- `rake test:unit` — no database, no Rails application. Everything in the gem that can be tested without Postgres is tested here.
|
|
214
|
+
- `rake test:integration` — boots `test/dummy` against a real Postgres and runs real workers. Non-transactional by necessity: Postgres delivers notifications on commit, and a transactional test never commits.
|
|
215
|
+
- `rake test` — both.
|
|
216
|
+
|
|
217
|
+
`bundle exec rubocop` for style ([rubocop-rails-omakase](https://github.com/rails/rubocop-rails-omakase)). CI tests the edges rather than a full grid: Ruby 3.2 × Rails 7.1 (oldest supported), Ruby 3.4 and 4.0 × Rails 8.1 (newest), plus an allowed-to-fail cell against `solid_queue@main` for early warning on upstream drift. `bundle exec appraisal generate` regenerates `gemfiles/` after a change to `Appraisals`, and `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test` runs a single combination locally.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generates a migration that installs the Postgres trigger used by
|
|
3
|
+
solid_queue-listen_notify. The trigger fires AFTER INSERT on
|
|
4
|
+
solid_queue_ready_executions and calls pg_notify with the queue name, which
|
|
5
|
+
wakes the workers listening on the configured channel.
|
|
6
|
+
|
|
7
|
+
The channel is baked into the migration when it is generated, from
|
|
8
|
+
SolidQueue::ListenNotify.channel (default "solid_queue_ready"). Configure the
|
|
9
|
+
channel before running the generator if you don't want the default. The
|
|
10
|
+
generated SQL is self-contained: it doesn't reference the gem, so the
|
|
11
|
+
migration keeps replaying after the gem is removed.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
bin/rails generate solid_queue:listen_notify:install
|
|
15
|
+
|
|
16
|
+
Writes db/queue_migrate/<timestamp>_install_solid_queue_listen_notify_trigger.rb
|
|
17
|
+
|
|
18
|
+
The migration goes to the migrations path of the `queue` database. Pass
|
|
19
|
+
--database (or --db) to target a different entry in config/database.yml, for
|
|
20
|
+
instance when Solid Queue shares the primary database:
|
|
21
|
+
|
|
22
|
+
bin/rails generate solid_queue:listen_notify:install --database primary
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record"
|
|
4
|
+
require "solid_queue/listen_notify"
|
|
5
|
+
|
|
6
|
+
module SolidQueue
|
|
7
|
+
module ListenNotify
|
|
8
|
+
module Generators
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
|
|
12
|
+
namespace "solid_queue:listen_notify:install"
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
class_option :database, type: :string, aliases: %i[ --db ], default: "queue",
|
|
16
|
+
desc: "The database that Solid Queue uses. Defaults to `queue`"
|
|
17
|
+
|
|
18
|
+
# Generating a migration that cannot run is worse than refusing to
|
|
19
|
+
# generate one: the failure would land on whoever runs db:migrate, or —
|
|
20
|
+
# if the trigger installs at all — on every enqueue in production.
|
|
21
|
+
def validate_channel
|
|
22
|
+
return if SolidQueue::ListenNotify::TriggerInstaller.channel_valid?(channel)
|
|
23
|
+
|
|
24
|
+
raise Thor::Error, SolidQueue::ListenNotify::TriggerInstaller.channel_error_message(channel)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_migration_file
|
|
28
|
+
migration_template "install_solid_queue_listen_notify_trigger.rb.erb",
|
|
29
|
+
File.join(db_migrate_path, "install_solid_queue_listen_notify_trigger.rb")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
def channel
|
|
34
|
+
SolidQueue::ListenNotify.channel.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Baked into the migration at generation time so that the migration keeps
|
|
38
|
+
# working after the gem is removed.
|
|
39
|
+
def quoted_channel
|
|
40
|
+
"'#{channel.gsub("'", "''")}'"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by solid_queue-listen_notify. The SQL is literal and self-contained
|
|
4
|
+
# on purpose: this migration must keep replaying after the gem is removed.
|
|
5
|
+
class InstallSolidQueueListenNotifyTrigger < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
6
|
+
def up
|
|
7
|
+
execute <<~SQL
|
|
8
|
+
CREATE OR REPLACE FUNCTION solid_queue_listen_notify_ready() RETURNS trigger AS $$
|
|
9
|
+
BEGIN
|
|
10
|
+
PERFORM pg_notify(<%= quoted_channel %>, NEW.queue_name);
|
|
11
|
+
RETURN NULL;
|
|
12
|
+
END;
|
|
13
|
+
$$ LANGUAGE plpgsql;
|
|
14
|
+
SQL
|
|
15
|
+
|
|
16
|
+
# CREATE OR REPLACE TRIGGER needs Postgres 14.
|
|
17
|
+
if connection.database_version >= 14_00_00
|
|
18
|
+
execute <<~SQL
|
|
19
|
+
CREATE OR REPLACE TRIGGER solid_queue_listen_notify
|
|
20
|
+
AFTER INSERT ON solid_queue_ready_executions
|
|
21
|
+
FOR EACH ROW EXECUTE FUNCTION solid_queue_listen_notify_ready();
|
|
22
|
+
SQL
|
|
23
|
+
else
|
|
24
|
+
execute "DROP TRIGGER IF EXISTS solid_queue_listen_notify ON solid_queue_ready_executions;"
|
|
25
|
+
|
|
26
|
+
execute <<~SQL
|
|
27
|
+
CREATE TRIGGER solid_queue_listen_notify
|
|
28
|
+
AFTER INSERT ON solid_queue_ready_executions
|
|
29
|
+
FOR EACH ROW EXECUTE FUNCTION solid_queue_listen_notify_ready();
|
|
30
|
+
SQL
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def down
|
|
35
|
+
execute "DROP TRIGGER IF EXISTS solid_queue_listen_notify ON solid_queue_ready_executions;"
|
|
36
|
+
execute "DROP FUNCTION IF EXISTS solid_queue_listen_notify_ready();"
|
|
37
|
+
end
|
|
38
|
+
end
|