-
Notifications
You must be signed in to change notification settings - Fork 3
Add pg_notify support to reduce polling latency #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -112,6 +112,12 @@ To determine what happens when a job is queued, you can set Que's mode. There ar | |
|
|
||
| **If you're using ActiveRecord to dump your database's schema, [set your schema_format to :sql](http://guides.rubyonrails.org/migrations.html#types-of-schema-dumps) so that Que's table structure is managed correctly.** (You can use schema_format as :ruby if you want but keep in mind this is highly advised against, as some parts of Que will not work.) | ||
|
|
||
| ### Reducing polling latency with pg_notify | ||
|
|
||
| By default, idle workers only notice new jobs on their next poll (`--wake-interval`/`wake_interval`, 5 seconds by default). Running `Que.migrate!` up to the latest schema version adds a trigger that calls `pg_notify` whenever a job becomes available, and passing `--listen` to `bin/que` (or `listen: true` to `Que::WorkerGroup.start`) makes idle workers wake up on that notification instead of waiting out the full interval. Polling itself is unaffected and still runs as normal - notifications are purely a way to shorten the wait, so a missed one just means the job is picked up on the next poll as before. | ||
|
|
||
| This holds one dedicated connection open for the lifetime of each worker group to listen on, in addition to the connections workers use to run queries. If you're using a pooled adapter (ActiveRecord, Sequel, Pond), make sure your pool has capacity for one extra connection per worker group when enabling `--listen`. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An extra connection per worker group could be costly (in some instances - large number of groups and limited connections) - Wondering if it'd help having some guidance on which queues this is useful for vs which not? For eg: if the queue is not heavily busy - then this might be more useful and if the queue is high frequency (busy) - then probably not worth it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah its not ideal is it, I do wonder if it needs to be session or transaction based. My gut feeling is that because its a long running connection it might not even matter. I wonder if we have any findings from the use of goodjob elsewhere |
||
|
|
||
|
|
||
| ## Related Projects | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Que | ||
| # Listens for pg_notify() messages announcing that a job has become | ||
| # available on one of the given queues, and uses them to wake up idle | ||
| # workers early instead of leaving them to wait out their next poll. | ||
| # | ||
| # This is a supplement to polling, not a replacement for it - workers still | ||
| # poll on every cycle regardless of whether they were woken by a | ||
| # notification or by their poll interval elapsing. That means a missed or | ||
| # delayed notification (the listener hasn't (re)connected yet, a | ||
| # notification arrives while we're reconnecting, etc) only costs the | ||
| # latency of the next poll rather than losing the job. | ||
| # | ||
| # Requires a dedicated connection for its entire lifetime (to hold the | ||
| # LISTEN), which for pooled adapters (ActiveRecord/Sequel/Pond) means the | ||
| # pool needs capacity for one extra connection per Listener on top of | ||
| # however many workers are checking connections out to run queries. | ||
| class Listener | ||
| RECONNECT_WAIT = 1.0 # seconds | ||
| POLL_TIMEOUT = 1.0 # seconds; bounds how quickly #stop is noticed | ||
|
|
||
| def self.channel_for_queue(queue) | ||
| "que_job_available_#{queue}" | ||
| end | ||
|
|
||
| def initialize(queues:, wakeup:) | ||
| @queues = queues | ||
| @wakeup = wakeup | ||
| @stop = false | ||
| end | ||
|
|
||
| def start | ||
| @thread = Thread.new { run } | ||
| @thread.name = "que-listener" | ||
| self | ||
| end | ||
|
|
||
| def stop | ||
| @stop = true | ||
| @thread&.join | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def run | ||
| until @stop | ||
| begin | ||
| listen_and_wait | ||
| rescue PG::Error, Adapters::UnavailableConnection => e | ||
| Que.logger&.warn( | ||
| event: "que.listener.connection_error", | ||
| msg: "Listener lost its connection, reconnecting", | ||
| error: e.inspect, | ||
| ) | ||
| sleep(RECONNECT_WAIT) unless @stop | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def listen_and_wait | ||
| Que.adapter.checkout do |conn| | ||
| listen(conn) | ||
|
|
||
| begin | ||
| wait_for_notifications(conn) until @stop | ||
| ensure | ||
| unlisten(conn) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def listen(conn) | ||
| @queues.each do |queue| | ||
| conn.exec(%(LISTEN #{conn.quote_ident(self.class.channel_for_queue(queue))})) | ||
| end | ||
| end | ||
|
|
||
| def unlisten(conn) | ||
| conn.exec("UNLISTEN *") | ||
| nil while conn.notifies | ||
| end | ||
|
|
||
| def wait_for_notifications(conn) | ||
| conn.wait_for_notify(POLL_TIMEOUT) { wake! } | ||
| end | ||
|
|
||
| def wake! | ||
| @wakeup.fetch(:mutex).synchronize { @wakeup.fetch(:condvar).broadcast } | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| BEGIN; | ||
|
|
||
| DROP TRIGGER que_job_notify ON que_jobs; | ||
| DROP FUNCTION que_job_notify(); | ||
|
|
||
| COMMIT; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| BEGIN; | ||
|
|
||
| CREATE FUNCTION que_job_notify() RETURNS trigger AS $$ | ||
| DECLARE | ||
| payload json; | ||
| BEGIN | ||
| -- Don't bother notifying about jobs that aren't ready to run yet - a | ||
| -- worker polling for them later will pick them up regardless. | ||
| IF NEW.run_at IS NOT NULL AND NEW.run_at > now() THEN | ||
| RETURN null; | ||
| END IF; | ||
|
|
||
| -- There's a size limit to what can be broadcast via LISTEN/NOTIFY, so | ||
| -- rather than throw errors when someone enqueues a big job, just | ||
| -- broadcast the most pertinent information, and let the worker query for | ||
| -- the record after it's taken the lock, exactly as it does when polling. | ||
| SELECT row_to_json(t) | ||
| INTO payload | ||
| FROM ( | ||
| SELECT | ||
| NEW.queue AS queue, | ||
| NEW.priority AS priority, | ||
| NEW.job_id AS job_id, | ||
| to_char(NEW.run_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS run_at | ||
| ) t; | ||
|
|
||
| -- One channel per queue, so listeners only subscribe to the queues they | ||
| -- actually work. | ||
| PERFORM pg_notify('que_job_available_' || NEW.queue, payload::text); | ||
|
|
||
| RETURN null; | ||
| END | ||
| $$ | ||
| LANGUAGE plpgsql; | ||
|
|
||
| -- Allow bulk-enqueue code paths to skip this trigger for performance by | ||
| -- running `SET LOCAL que.skip_notify = 'true'` in their transaction. | ||
| CREATE TRIGGER que_job_notify | ||
| AFTER INSERT ON que_jobs | ||
| FOR EACH ROW | ||
| WHEN (NOT coalesce(current_setting('que.skip_notify', true), '') = 'true') | ||
| EXECUTE PROCEDURE que_job_notify(); | ||
|
|
||
| COMMIT; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are the instances that miss might happen (and how) ?