├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ ├── codeql.yml │ └── dependency-review.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-gemset ├── .tool-versions ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── activerecord-postgres_pub_sub.gemspec ├── bin ├── console ├── rubocop └── setup ├── gemfiles ├── activerecord_6.1.gemfile ├── activerecord_7.0.gemfile ├── activerecord_7.1.gemfile ├── activerecord_7.2.gemfile └── activerecord_8.0.gemfile ├── lib ├── activerecord-postgres_pub_sub.rb ├── activerecord │ └── postgres_pub_sub │ │ ├── listener.rb │ │ └── version.rb └── generators │ └── active_record │ └── postgres_pub_sub │ ├── notify_on_insert_generator.rb │ └── templates │ └── create_notify_on_insert_trigger.rb.erb └── spec ├── active_record └── postgres_pub_sub │ └── listener_spec.rb └── spec_helper.rb /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ezcater/monolith-experience 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What did we change? 2 | 3 | ## Why are we doing this? 4 | 5 | ## How was it tested? 6 | - [ ] Specs 7 | - [ ] Locally 8 | - [ ] Staging 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: [ main ] 6 | 7 | env: 8 | branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }} 9 | 10 | jobs: 11 | lint: 12 | name: Lint (Ruby ${{ matrix.ruby }}) 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby: [ '3.0' ] 18 | 19 | steps: 20 | - name: Checkout the code 21 | uses: actions/checkout@v4 22 | 23 | - name: Install Ruby ${{ matrix.ruby }} 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | 28 | - name: Install dependencies 29 | run: bundle install 30 | 31 | - name: Run Rubocop 32 | run: bundle exec rubocop 33 | 34 | test: 35 | name: Test (Ruby ${{ matrix.ruby }}, activerecord ${{ matrix.activerecord }}) 36 | runs-on: ubuntu-latest 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | activerecord: [ '6.1', '7.0', '7.1', '7.2', '8.0' ] 41 | ruby: [ '3.0', '3.1', '3.2', '3.3', '3.4' ] 42 | exclude: 43 | - activerecord: '6.1' 44 | ruby: '3.4' 45 | - activerecord: '7.0' 46 | ruby: '3.4' 47 | - activerecord: '7.2' 48 | ruby: '3.0' 49 | - activerecord: '8.0' 50 | ruby: '3.0' 51 | - activerecord: '8.0' 52 | ruby: '3.1' 53 | timeout-minutes: 10 54 | needs: 55 | - lint 56 | 57 | env: 58 | PGUSER: postgres 59 | 60 | services: 61 | postgres: 62 | image: postgres:16.1 63 | env: 64 | POSTGRES_USER: postgres 65 | POSTGRES_DB: postgres_pub_sub_test 66 | POSTGRES_HOST_AUTH_METHOD: trust 67 | options: >- 68 | --health-cmd pg_isready 69 | --health-interval 10s 70 | --health-timeout 5s 71 | --health-retries 5 72 | ports: 73 | - 5432:5432 74 | 75 | steps: 76 | - name: Checkout the code 77 | uses: actions/checkout@v4 78 | 79 | - name: Install Ruby ${{ matrix.ruby }} 80 | uses: ruby/setup-ruby@v1 81 | with: 82 | ruby-version: ${{ matrix.ruby }} 83 | 84 | - name: Install postgresql-client 85 | run: sudo apt update && sudo apt install postgresql-client 86 | 87 | - name: Install activerecord_${{ matrix.activerecord }} dependencies 88 | run: BUNDLE_GEMFILE=gemfiles/activerecord_${{ matrix.activerecord }}.gemfile bundle install 89 | 90 | - name: Appraise activerecord_${{ matrix.activerecord }} 91 | run: bundle exec appraisal activerecord_${{ matrix.activerecord }} rspec 92 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "main" ] 9 | # https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/customizing-code-scanning#avoiding-unnecessary-scans-of-pull-requests 10 | paths-ignore: 11 | - '**/*.md' 12 | - '**/*.txt' 13 | schedule: 14 | - cron: '41 22 * * 2' 15 | 16 | jobs: 17 | scan: 18 | name: Scan 19 | runs-on: [ ubuntu-latest ] 20 | permissions: 21 | actions: read 22 | contents: read 23 | security-events: write 24 | 25 | strategy: 26 | # Setting fail-fast to false to prevent a failed scan in 27 | # any of the matrix.language's from stopping the other scans 28 | # If there are multiple offenses, better to find/report them 29 | # all at once 30 | fail-fast: false 31 | matrix: 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 33 | # https://aka.ms/codeql-docs/language-support 34 | language: [ 'ruby' ] 35 | 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v3 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v2 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | 50 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 51 | # queries: security-extended,security-and-quality 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 60 | 61 | # If the Autobuild fails above, remove it and uncomment the following three lines. 62 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 63 | 64 | # - run: | 65 | # echo "Run, Build Application using script" 66 | # ./location_of_script_within_repo/buildscript.sh 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@v2 70 | with: 71 | category: "/language:${{matrix.language}}" -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: [pull_request] 3 | permissions: 4 | contents: read 5 | jobs: 6 | dependency-review: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: 'Checkout Repository' 10 | uses: actions/checkout@v3 11 | - name: Dependency Review 12 | uses: actions/dependency-review-action@v3 13 | with: 14 | # Possible values: "critical", "high", "moderate", "low" 15 | fail-on-severity: high 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /gemfiles/*.lock 11 | /gemfiles/.bundle 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | ezcater_rubocop: conf/rubocop_gem.yml 3 | 4 | AllCops: 5 | TargetRubyVersion: 3.0 6 | SuggestExtensions: false 7 | 8 | Naming/FileName: 9 | Exclude: 10 | - "lib/activerecord-postgres_pub_sub.rb" 11 | 12 | Naming/MemoizedInstanceVariableName: 13 | Exclude: 14 | - "lib/generators/active_record/postgres_pub_sub/notify_on_insert_generator.rb" 15 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | activerecord-postgres_pub_sub 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.4.1 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise "activerecord_6.1" do 4 | gem "activerecord", ">= 6.1.0", "< 6.2" 5 | gem "concurrent-ruby", "1.3.4" 6 | end 7 | 8 | appraise "activerecord_7.0" do 9 | gem "activerecord", ">= 7.0.0", "< 7.1" 10 | gem "concurrent-ruby", "1.3.4" 11 | end 12 | 13 | appraise "activerecord_7.1" do 14 | gem "activerecord", ">= 7.1.0", "< 7.2" 15 | end 16 | 17 | appraise "activerecord_7.2" do 18 | gem "activerecord", ">= 7.2.0", "< 7.3" 19 | end 20 | 21 | appraise "activerecord_8.0" do 22 | gem "activerecord", ">= 8.0.0", "< 8.1" 23 | end 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # activerecord-postgres_pub_sub 2 | 3 | ## v3.2.0 4 | - Add support for ActiveRecord 8.0 5 | - Add support for Ruby 3.4 6 | 7 | ## v3.1.0 8 | - Add support for ActiveRecord 7.2 9 | - Add support for Ruby 3.3 10 | 11 | ## v3.0.0 12 | - Add support for multiple databases by allowing injection of the base Active Record class. 13 | - BREAKING: Drop support for ActiveRecord 5.2, 6.0 14 | - BREAKING: Drop support for ruby < 3.0 15 | 16 | ## v2.3.0 17 | - Add support for Rails 7.1 18 | 19 | ## v2.2.0 20 | - Add support for listening to multiple channels. 21 | 22 | ## v2.1.0 23 | - Set required ruby version to 2.7.0 24 | - Add support for Rails 7.0 25 | 26 | ## v2.0.1 27 | - Fix version constraint on pg gem. 28 | - Drop support for rails 5.1 as a result of pg constraint change. 29 | 30 | ## v2.0.0 31 | - Add support for Rails 6.1. 32 | - Drop support for pg 0.18 as support has been [dropped in activerecord 6.1](https://github.com/rails/rails/commit/592358e182effecebe8c6a4645bd4431f5a73654). 33 | 34 | ## v1.2.0 35 | - Extend pg support to all of the `1.x` major version. 36 | 37 | ## v1.1.0 38 | - Add support for Rails 6.0. 39 | 40 | ## v1.0.0 41 | - No change. 42 | 43 | ## v0.5.0 44 | - Add support for pg 1.1.3 45 | 46 | ## v0.4.0 47 | - Add safe instance variable if StrongMigrations is used. 48 | 49 | ## v0.3.0 50 | - Add support for Rails 5.2. 51 | 52 | ## v0.2.0 53 | - Public release 54 | 55 | ## v0.1.0 56 | - Initial version 57 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # override the :github shortcut to be secure by using HTTPS 6 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" } 7 | 8 | # Specify your gem's dependencies in activerecord-postgres_pub_sub.gemspec 9 | gemspec 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 ezCater, Inc 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # activerecord-postgres_pub_sub 2 | 3 | This gem contains support for PostgreSQL LISTEN and NOTIFY functionality: 4 | [doc](https://www.postgresql.org/docs/9.6/static/libpq-notify.html). 5 | 6 | ## Installation 7 | 8 | Add this line to your application's Gemfile: 9 | 10 | ```ruby 11 | gem "activerecord-postgres_pub_sub" 12 | ``` 13 | 14 | And then execute: 15 | 16 | $ bundle 17 | 18 | Or install it yourself as: 19 | 20 | $ gem install activerecord-postgres_pub_sub 21 | 22 | ## Usage 23 | 24 | ### Listener 25 | 26 | The `Listener` class is used to handle notification messages on one or more 27 | channels. 28 | 29 | The listener can be configured with three blocks: 30 | 31 | * **on_notify**: called whenever a notification is received. 32 | * **on_start**: called before receiving any notifications. 33 | * **on_timeout**: called based on a configurable timeout, when no notifications 34 | have been received. 35 | 36 | When creating a listener, the following configuration is supported: 37 | 38 | * **listen_timeout**: If set, the `on_timeout` block will be called if 39 | no notifications are received within this period. (Default `nil`). 40 | * **notify_only**: A payload string can be included in notifications. By default 41 | the listener ignores the payload and coalesces multiple notifications into a 42 | single call. When this option is `false`, the `on_notify` block is called with 43 | the payload for each notification. (Default `true`). 44 | * **base_class**: An Active Record class should you need to use a different base 45 | class (e.g. for multiple database support). (Default `ActiveRecord::Base`). 46 | * **exclusive_lock**: Acquire a lock using 47 | [with_advisory_lock](https://github.com/ClosureTree/with_advisory_lock) prior to listening. 48 | This option ensures that a process as a singleton listener. (Default `true`). 49 | 50 | Example: 51 | 52 | ```ruby 53 | ActiveRecord::PostgresPubSub::Listener.listen("notify_channel", listen_timeout: 30) do |listener| 54 | listener.on_start do 55 | # when starting assume we missed something and perform regular activity 56 | handle_notification 57 | end 58 | 59 | listener.on_notify do 60 | handle_notification 61 | end 62 | 63 | listener.on_timeout do 64 | perform_regular_maintenance 65 | end 66 | end 67 | ``` 68 | 69 | ### Generator 70 | 71 | This gem contains a Rails generator for a migration to add a trigger to notify on insert to a table. 72 | 73 | The generator must be run with a model name corresponding to the table. 74 | 75 | ```bash 76 | rails generate active_record:postgres_pub_sub:notify_on_insert --model_name NameSpace::Entity 77 | ``` 78 | 79 | In this example, notification events would be generated for the channel named `"name_space_entity"` based 80 | on inserts to the `name_space_entities` table. 81 | 82 | ## Supported dependencies 83 | 84 | This gem will not support versions of ruby and activerecord which no 85 | longer receive security updates. It will support the latest major 86 | version of the pg gem and it will be tested against the latest minor / 87 | patch version of that gem. 88 | 89 | ## Development 90 | 91 | After checking out the repo, run `bin/setup` to install dependencies. Then, 92 | run `rake spec` to run the tests. You can also run `bin/console` for an 93 | interactive prompt that will allow you to experiment. 94 | 95 | To install this gem onto your local machine, run `bundle exec rake install`. 96 | 97 | To release a new version, update the version number in `version.rb`, and then 98 | run `bundle exec rake release`, which will create a git tag for the version, 99 | push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 100 | 101 | ## Contributing 102 | 103 | Bug reports and pull requests are welcome on GitHub at 104 | https://github.com/ezcater/activerecord-postgres_pub_sub. 105 | 106 | ## License 107 | 108 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 109 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /activerecord-postgres_pub_sub.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "activerecord/postgres_pub_sub/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "activerecord-postgres_pub_sub" 9 | spec.version = ActiveRecord::PostgresPubSub::VERSION 10 | spec.authors = ["ezCater, Inc"] 11 | spec.email = ["engineering@ezcater.com"] 12 | spec.summary = "Support for Postgres Notify/Listen" 13 | spec.description = spec.summary 14 | spec.homepage = "https://github.com/ezcater/activerecord-postgres_pub_sub" 15 | spec.license = "MIT" 16 | 17 | # Set "allowed_push_post" to control where this gem can be published. 18 | if spec.respond_to?(:metadata) 19 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 20 | else 21 | raise "RubyGems 2.0 or newer is required to protect against public gem pushes." 22 | end 23 | 24 | excluded_files = %w(.gitignore 25 | .rspec 26 | .rubocop.yml 27 | .ruby-gemset 28 | .tool-versions 29 | Rakefile) 30 | 31 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 32 | f.match(/^(bin|test|spec|features|.github)\//) 33 | end - excluded_files 34 | spec.bindir = "bin" 35 | spec.executables = [] 36 | spec.require_paths = ["lib"] 37 | spec.required_ruby_version = ">= 3.0.0" 38 | 39 | spec.add_runtime_dependency "activerecord", "> 6.0", "< 8.1" 40 | spec.add_runtime_dependency "pg", "~> 1.1" 41 | spec.add_runtime_dependency "private_attr" 42 | spec.add_runtime_dependency "with_advisory_lock" 43 | 44 | spec.add_development_dependency "appraisal" 45 | spec.add_development_dependency "bundler", "~> 2.2" 46 | spec.add_development_dependency "database_cleaner" 47 | spec.add_development_dependency "ezcater_matchers" 48 | spec.add_development_dependency "ezcater_rubocop", "~> 6.1.0" 49 | spec.add_development_dependency "overcommit" 50 | spec.add_development_dependency "rake", "~> 13.1" 51 | spec.add_development_dependency "rspec", "~> 3.4" 52 | spec.add_development_dependency "simplecov" 53 | end 54 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "activerecord-postgres_pub_sub" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don"t forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start 16 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("bundle", __dir__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rubocop", "rubocop") 30 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -v 5 | 6 | bundle update 7 | -------------------------------------------------------------------------------- /gemfiles/activerecord_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", ">= 6.1.0", "< 6.2" 6 | gem "concurrent-ruby", "1.3.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", ">= 7.0.0", "< 7.1" 6 | gem "concurrent-ruby", "1.3.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", ">= 7.1.0", "< 7.2" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", ">= 7.2.0", "< 7.3" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", ">= 8.0.0", "< 8.1" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/activerecord-postgres_pub_sub.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | require "activerecord/postgres_pub_sub/version" 5 | require "activerecord/postgres_pub_sub/listener" 6 | -------------------------------------------------------------------------------- /lib/activerecord/postgres_pub_sub/listener.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "private_attr" 4 | require "with_advisory_lock" 5 | 6 | module ActiveRecord 7 | module PostgresPubSub 8 | class Listener 9 | extend PrivateAttr 10 | 11 | private_attr_reader :on_notify_blk, :on_start_blk, :on_timeout_blk, 12 | :channels, :listen_timeout, :exclusive_lock, :notify_only, :base_class 13 | 14 | def self.listen( 15 | *channels, 16 | listen_timeout: nil, 17 | exclusive_lock: true, 18 | notify_only: true, 19 | base_class: ActiveRecord::Base 20 | ) 21 | listener = new(*channels, 22 | listen_timeout: listen_timeout, 23 | exclusive_lock: exclusive_lock, 24 | notify_only: notify_only, 25 | base_class: base_class) 26 | yield(listener) if block_given? 27 | listener.listen 28 | end 29 | 30 | def initialize( 31 | *channels, 32 | listen_timeout: nil, 33 | exclusive_lock: true, 34 | notify_only: true, 35 | base_class: ActiveRecord::Base 36 | ) 37 | @channels = channels 38 | @listen_timeout = listen_timeout 39 | @exclusive_lock = exclusive_lock 40 | @notify_only = notify_only 41 | @base_class = base_class 42 | end 43 | 44 | def on_notify(&blk) 45 | @on_notify_blk = blk 46 | end 47 | 48 | def on_start(&blk) 49 | @on_start_blk = blk 50 | end 51 | 52 | def on_timeout(&blk) 53 | @on_timeout_blk = blk 54 | end 55 | 56 | def listen 57 | with_connection do |connection| 58 | on_start_blk&.call 59 | 60 | loop do 61 | wait_for_notify(connection) do |payload, channel| 62 | notify_only ? on_notify_blk.call : on_notify_blk.call(payload, channel) 63 | end 64 | end 65 | end 66 | end 67 | 68 | private 69 | 70 | def with_connection 71 | base_class.connection_pool.with_connection do |connection| 72 | with_optional_lock do 73 | channels.each do |channel| 74 | connection.execute("LISTEN #{channel};") 75 | end 76 | 77 | begin 78 | yield(connection) 79 | ensure 80 | channels.each do |channel| 81 | connection.execute("UNLISTEN #{channel}") 82 | end 83 | end 84 | end 85 | end 86 | end 87 | 88 | def with_optional_lock(&block) 89 | if exclusive_lock 90 | base_class.with_advisory_lock(lock_name, &block) 91 | else 92 | yield 93 | end 94 | end 95 | 96 | def lock_name 97 | "#{channels.join('-')}-listener" 98 | end 99 | 100 | def empty_channel(connection) 101 | while connection.wait_for_notify(0) 102 | # call until nil is returned 103 | end 104 | end 105 | 106 | def wait_for_notify(connection) 107 | connection_pid = connection.raw_connection.backend_pid 108 | event_result = connection.raw_connection.wait_for_notify(listen_timeout) do |notify_channel, pid, payload| 109 | if pid != connection_pid 110 | empty_channel(connection.raw_connection) if notify_only 111 | 112 | yield(payload, notify_channel) 113 | end 114 | end 115 | 116 | on_timeout_blk&.call if event_result.nil? 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/activerecord/postgres_pub_sub/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresPubSub 5 | VERSION = "3.2.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/generators/active_record/postgres_pub_sub/notify_on_insert_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "rails/generators/migration" 5 | require "rails/generators/active_record" 6 | 7 | module ActiveRecord 8 | module PostgresPubSub 9 | class NotifyOnInsertGenerator < Rails::Generators::Base 10 | include ActiveRecord::Generators::Migration 11 | 12 | source_root File.join(File.dirname(__FILE__), "templates") 13 | 14 | class_option :model_name, type: :string 15 | 16 | def create_migration_file 17 | migration_template("create_notify_on_insert_trigger.rb.erb", 18 | "db/migrate/create_notify_on_#{table_name}_insert_trigger.rb") 19 | end 20 | 21 | private 22 | 23 | def model_name 24 | @model_name ||= options.fetch(:model_name) 25 | end 26 | 27 | def table_name 28 | @table_name ||= model_name.tableize.tr("/", "_") 29 | end 30 | 31 | def notification_name 32 | @notification_name || table_name.singularize 33 | end 34 | 35 | def table_module 36 | @module_name ||= model_name.deconstantize.underscore.tr("/", "_") 37 | end 38 | 39 | def model_title 40 | @model_title ||= table_name.camelize 41 | end 42 | 43 | def strong_migrations 44 | defined?(StrongMigrations) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/generators/active_record/postgres_pub_sub/templates/create_notify_on_insert_trigger.rb.erb: -------------------------------------------------------------------------------- 1 | class CreateNotifyOn<%= model_title %>InsertTrigger < ActiveRecord::Migration[5.1] 2 | TABLE_NAME = "<%= table_name %>".freeze 3 | NOTIFICATION_NAME = "<%= notification_name %>".freeze 4 | TABLE_MODULE = "<%= table_module %>".freeze 5 | 6 | def up 7 | <% if strong_migrations %>@safe = true<% end %> 8 | execute <<-SQL 9 | CREATE OR REPLACE FUNCTION notify_#{TABLE_MODULE}_listeners() RETURNS TRIGGER AS $$ 10 | DECLARE 11 | BEGIN 12 | PERFORM pg_notify('#{NOTIFICATION_NAME}', null); 13 | RETURN NEW; 14 | END; 15 | $$ LANGUAGE plpgsql 16 | SQL 17 | 18 | execute <<-SQL 19 | CREATE TRIGGER #{TABLE_MODULE}_trigger 20 | AFTER INSERT 21 | ON #{TABLE_NAME} 22 | FOR EACH STATEMENT 23 | EXECUTE PROCEDURE notify_#{TABLE_MODULE}_listeners() 24 | SQL 25 | end 26 | 27 | def down 28 | <% if strong_migrations %>@safe = true<% end %> 29 | execute <<-SQL 30 | DROP FUNCTION notify_#{TABLE_MODULE}_listeners() CASCADE 31 | SQL 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/active_record/postgres_pub_sub/listener_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | 5 | RSpec.describe ActiveRecord::PostgresPubSub::Listener, cleaner_strategy: :truncation do 6 | let(:channel) { "pub_sub_test" } 7 | let(:base_class) { ActiveRecord::Base } 8 | 9 | describe ".listen" do 10 | let(:listener_options) { Hash.new } 11 | let!(:state) do 12 | OpenStruct.new(started: 0, 13 | count: 0, 14 | timeout_count: 0, 15 | payloads: [], 16 | received_channels: []) 17 | end 18 | let!(:listener_thread) do 19 | Thread.new do 20 | listener_loop(**listener_options) 21 | ensure 22 | base_class.connection_handler.clear_active_connections! 23 | end 24 | end 25 | 26 | after do 27 | listener_thread.terminate 28 | listener_thread.join 29 | end 30 | 31 | it "invokes the notify block when it receives a notification" do 32 | wait_for_started 33 | 34 | ActiveRecord::Base.transaction do 35 | 3.times { |i| notify(i) } 36 | end 37 | 38 | wait_for("notification received") { state.count > 0 } 39 | expect(state.payloads).to match_ordered_array([nil]) 40 | expect(state.count).to eq(1) 41 | end 42 | 43 | context "when using 1-arg version of #on_notify" do 44 | let!(:listener_thread) do 45 | Thread.new do 46 | listener_loop(**listener_options) do |listener| 47 | listener.on_notify do |payload| 48 | state.count += 1 49 | state.payloads << payload 50 | end 51 | end 52 | ensure 53 | ActiveRecord::Base.connection_handler.clear_active_connections! 54 | end 55 | end 56 | 57 | it "invokes the single arg. notify block when it receives a notification" do 58 | wait_for_started 59 | 60 | ActiveRecord::Base.transaction do 61 | 3.times { |i| notify(i) } 62 | end 63 | 64 | wait_for("notification received") { state.count > 0 } 65 | expect(state.payloads).to match_ordered_array([nil]) 66 | expect(state.count).to eq(1) 67 | end 68 | end 69 | 70 | context "when using notify_only=false" do 71 | let(:listener_options) do 72 | Hash[notify_only: false] 73 | end 74 | 75 | it "invokes the notify block with the payload of each notification" do 76 | wait_for_started 77 | 78 | ActiveRecord::Base.transaction do 79 | 3.times { |i| notify(i) } 80 | end 81 | 82 | wait_for("notification received") { state.count == 3 } 83 | expect(state.payloads).to match_ordered_array(%w(0 1 2)) 84 | end 85 | end 86 | 87 | context "when a timeout is set" do 88 | let(:listener_options) do 89 | Hash[listen_timeout: 0.001] 90 | end 91 | 92 | it "invokes the timeout block if a notification is not received" do 93 | wait_for_started 94 | 95 | wait_for("listener timeout") { state.timeout_count > 0 } 96 | end 97 | end 98 | 99 | context "when listen to multiple channels" do # rubocop:disable RSpec/MultipleMemoizedHelpers 100 | let(:channels) { %w(pub_sub_test1 pub_sub_test2) } 101 | let(:listener_options) { Hash[listen_to: channels, notify_only: false] } 102 | 103 | it "invokes the notify multiple channels block with notification notify to diffrent channels" do 104 | wait_for_started 105 | 106 | ActiveRecord::Base.transaction do 107 | channels.each { |c| notify(c, notify_to: c) } 108 | end 109 | 110 | wait_for("notification received") { state.received_channels.count > 0 } 111 | expect(state.payloads).to match_ordered_array(channels) 112 | expect(state.received_channels).to match_ordered_array(channels) 113 | end 114 | end 115 | 116 | context "when using a custom base class for multiple databases" do 117 | let(:base_class) { OtherApplicationRecord } 118 | let(:listener_options) { Hash[base_class: base_class] } 119 | 120 | it "invokes the notify block when it receives a notification" do 121 | wait_for_started 122 | 123 | OtherApplicationRecord.transaction do 124 | 3.times { |i| notify(i) } 125 | end 126 | 127 | wait_for("notification received") { state.count > 0 } 128 | expect(state.payloads).to match_ordered_array([nil]) 129 | expect(state.count).to eq(1) 130 | end 131 | end 132 | 133 | def notify(payload, notify_to: channel) 134 | base_class.connection.execute("NOTIFY #{notify_to}, '#{payload}'") 135 | end 136 | 137 | def wait_for_started 138 | wait_for("listener started") { state.started > 0 } 139 | end 140 | 141 | def wait_for(message, timeout: 5, poll_interval: 0.001) 142 | expires_at = Time.now + timeout 143 | loop do 144 | return if yield 145 | raise "Timed out waiting for #{message}" if Time.now > expires_at 146 | 147 | sleep(poll_interval) 148 | end 149 | end 150 | 151 | def listener_loop( 152 | listen_to: [channel], 153 | listen_timeout: nil, 154 | exclusive_lock: true, 155 | notify_only: true, 156 | base_class: ActiveRecord::Base 157 | ) 158 | described_class.listen(*listen_to, 159 | listen_timeout: listen_timeout, 160 | exclusive_lock: exclusive_lock, 161 | notify_only: notify_only, 162 | base_class: base_class) do |listener| 163 | listener.on_start do 164 | state.started += 1 165 | end 166 | 167 | listener.on_notify do |payload, channel| 168 | state.count += 1 169 | state.payloads << payload 170 | state.received_channels << channel 171 | end 172 | 173 | listener.on_timeout do 174 | state.timeout_count += 1 175 | end 176 | 177 | yield(listener) if block_given? 178 | end 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "simplecov" 5 | SimpleCov.start do 6 | add_filter("/spec/") 7 | end 8 | 9 | require "activerecord-postgres_pub_sub" 10 | require "database_cleaner" 11 | require "ezcater_matchers" 12 | 13 | class OtherApplicationRecord < ActiveRecord::Base 14 | self.abstract_class = true 15 | end 16 | 17 | RSpec.configure do |config| 18 | host = ENV.fetch("PGHOST", "localhost") 19 | port = ENV.fetch("PGPORT", 5432) 20 | 21 | databases = { 22 | "postgres_pub_sub_test" => ActiveRecord::Base, 23 | "postgres_pub_sub_other_test" => OtherApplicationRecord, 24 | } 25 | 26 | config.expect_with :rspec do |expectations| 27 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 28 | end 29 | 30 | config.mock_with :rspec do |mocks| 31 | mocks.verify_partial_doubles = true 32 | end 33 | 34 | config.shared_context_metadata_behavior = :apply_to_host_groups 35 | config.disable_monkey_patching! 36 | config.default_formatter = "doc" if config.files_to_run.one? 37 | config.order = :random 38 | Kernel.srand config.seed 39 | 40 | config.before(:suite) do 41 | pg_version = `psql -h #{host} -p #{port} -t -c "select version()";`.strip 42 | puts "Testing with Postgres version: #{pg_version}" 43 | puts "Testing with ActiveRecord #{ActiveRecord::VERSION::STRING}" 44 | 45 | databases.each do |name, base_class| 46 | `dropdb -h #{host} -p #{port} --if-exists #{name} 2> /dev/null` 47 | `createdb -h #{host} -p #{port} #{name}` 48 | 49 | database_url = "postgres://#{host}:#{port}/#{name}" 50 | 51 | puts "Using database #{database_url}" 52 | 53 | base_class.establish_connection(database_url) 54 | end 55 | 56 | DatabaseCleaner.clean_with(:truncation) 57 | end 58 | 59 | config.after(:suite) do 60 | databases.each do |name, base_class| 61 | base_class.connection_pool.disconnect! 62 | `dropdb -h #{host} -p #{port} --if-exists #{name}` 63 | end 64 | end 65 | 66 | config.before do |example| 67 | DatabaseCleaner.strategy = example.metadata[:cleaner_strategy] || :transaction 68 | end 69 | 70 | config.before do 71 | DatabaseCleaner.start 72 | end 73 | 74 | config.after do 75 | DatabaseCleaner.clean 76 | end 77 | end 78 | --------------------------------------------------------------------------------