pg_cron 2.0.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.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +25 -0
  3. data/CHANGELOG.md +56 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +121 -0
  6. data/LICENSE +21 -0
  7. data/README.md +54 -0
  8. data/Rakefile +16 -0
  9. data/bin/console +15 -0
  10. data/bin/increment-version +37 -0
  11. data/bin/release-gem +32 -0
  12. data/bin/setup +8 -0
  13. data/lib/generators/pg_cron/job/USAGE +19 -0
  14. data/lib/generators/pg_cron/job/job_generator.rb +116 -0
  15. data/lib/generators/pg_cron/job/templates/db/migrate/create_job.erb +5 -0
  16. data/lib/generators/pg_cron/job/templates/db/migrate/update_job.erb +5 -0
  17. data/lib/generators/pg_cron/migration_helper.rb +48 -0
  18. data/lib/generators/pg_cron/name_helper.rb +29 -0
  19. data/lib/generators/pg_cron/version_helper.rb +53 -0
  20. data/lib/pg_cron/adapters/postgres/connection.rb +26 -0
  21. data/lib/pg_cron/adapters/postgres/jobs.rb +48 -0
  22. data/lib/pg_cron/adapters/postgres/query_executor.rb +37 -0
  23. data/lib/pg_cron/adapters/postgres.rb +116 -0
  24. data/lib/pg_cron/command_recorder.rb +50 -0
  25. data/lib/pg_cron/configuration.rb +37 -0
  26. data/lib/pg_cron/definition.rb +74 -0
  27. data/lib/pg_cron/job.rb +68 -0
  28. data/lib/pg_cron/railtie.rb +22 -0
  29. data/lib/pg_cron/schema_dumper.rb +35 -0
  30. data/lib/pg_cron/statements.rb +126 -0
  31. data/lib/pg_cron/version.rb +5 -0
  32. data/lib/pg_cron/version.rb.erb +5 -0
  33. data/lib/pg_cron.rb +44 -0
  34. data/lib/tasks/pg_cron/down.rake +21 -0
  35. data/lib/tasks/pg_cron/schedule_all_jobs.rake +33 -0
  36. data/lib/tasks/pg_cron/schedule_job.rake +12 -0
  37. data/lib/tasks/pg_cron/unschedule_job.rake +12 -0
  38. data/lib/tasks/pg_cron/up.rake +25 -0
  39. data/lib/tasks/pg_cron/utils.rb +124 -0
  40. data/spec/dummy/.gitignore +71 -0
  41. data/spec/dummy/.ruby-version +1 -0
  42. data/spec/dummy/README.md +24 -0
  43. data/spec/dummy/Rakefile +6 -0
  44. data/spec/dummy/app/controllers/application_controller.rb +2 -0
  45. data/spec/dummy/app/controllers/concerns/.keep +0 -0
  46. data/spec/dummy/app/models/application_record.rb +3 -0
  47. data/spec/dummy/app/models/concerns/.keep +0 -0
  48. data/spec/dummy/bin/rails +4 -0
  49. data/spec/dummy/bin/rake +4 -0
  50. data/spec/dummy/bin/setup +33 -0
  51. data/spec/dummy/config/application.rb +40 -0
  52. data/spec/dummy/config/boot.rb +3 -0
  53. data/spec/dummy/config/credentials.yml.enc +1 -0
  54. data/spec/dummy/config/database.yml +86 -0
  55. data/spec/dummy/config/environment.rb +5 -0
  56. data/spec/dummy/config/environments/development.rb +58 -0
  57. data/spec/dummy/config/environments/production.rb +96 -0
  58. data/spec/dummy/config/environments/test.rb +49 -0
  59. data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
  60. data/spec/dummy/config/initializers/backtrace_silencers.rb +8 -0
  61. data/spec/dummy/config/initializers/cors.rb +16 -0
  62. data/spec/dummy/config/initializers/filter_parameter_logging.rb +6 -0
  63. data/spec/dummy/config/initializers/inflections.rb +16 -0
  64. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  65. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  66. data/spec/dummy/config/locales/en.yml +33 -0
  67. data/spec/dummy/config/puma.rb +43 -0
  68. data/spec/dummy/config/routes.rb +3 -0
  69. data/spec/dummy/config.ru +6 -0
  70. data/spec/dummy/db/pg_cron_jobs/dummy_job.yml +3 -0
  71. data/spec/dummy/db/seeds.rb +7 -0
  72. data/spec/dummy/lib/tasks/.keep +0 -0
  73. data/spec/dummy/log/.keep +0 -0
  74. data/spec/dummy/public/robots.txt +1 -0
  75. data/spec/dummy/tmp/.keep +0 -0
  76. data/spec/dummy/vendor/.keep +0 -0
  77. data/spec/pg_cron/configuration_spec.rb +20 -0
  78. data/spec/pg_cron/fx_coexistence_spec.rb +53 -0
  79. data/spec/pg_cron/statements_spec.rb +9 -0
  80. data/spec/spec_helper.rb +6 -0
  81. metadata +245 -0
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCron
4
+ module Generators
5
+ # Works out which version of a definition we are creating.
6
+ #
7
+ # Copied from Fx::Generators::VersionHelper. The scan is over db/cron rather
8
+ # than db/functions, and there is one type instead of two, so
9
+ # definition_for_version takes no `type`.
10
+ #
11
+ # @api private
12
+ class VersionHelper
13
+ def initialize(file_name:, definition_path:)
14
+ @file_name = file_name
15
+ @definition_path = definition_path
16
+ end
17
+
18
+ def previous_version
19
+ @previous_version ||= existing_versions.max || 0
20
+ end
21
+
22
+ def current_version
23
+ previous_version.next
24
+ end
25
+
26
+ def updating_existing?
27
+ previous_version > 0
28
+ end
29
+
30
+ def creating_new?
31
+ previous_version == 0
32
+ end
33
+
34
+ def definition_for_version(version:)
35
+ PgCron::Definition.job(name: file_name, version: version)
36
+ end
37
+
38
+ private
39
+
40
+ VERSION_PATTERN = /v(\d+)/
41
+ private_constant :VERSION_PATTERN
42
+
43
+ attr_reader :file_name, :definition_path
44
+
45
+ def existing_versions
46
+ Dir
47
+ .glob("#{file_name}_v*.sql", base: definition_path)
48
+ .map { |file| file[VERSION_PATTERN, 1].to_i }
49
+ .compact
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module PgCron
6
+ module Adapters
7
+ class Postgres
8
+ # Decorates an ActiveRecord connection with methods that help determine
9
+ # the connection's capabilities.
10
+ #
11
+ # The F(x) equivalent asks about DROP FUNCTION argument lists. The
12
+ # capability that matters here is whether pg_cron is installed at all:
13
+ # cron.job does not exist until CREATE EXTENSION pg_cron has run, and that
14
+ # additionally requires pg_cron in shared_preload_libraries and a server
15
+ # restart. Every caller checks this first so a migration or a schema dump
16
+ # against a database without cron is a no-op rather than an error.
17
+ #
18
+ # @api private
19
+ class Connection < SimpleDelegator
20
+ def pg_cron_enabled?
21
+ extension_enabled?("pg_cron")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg_cron/job"
4
+ require "pg_cron/adapters/postgres/query_executor"
5
+
6
+ module PgCron
7
+ module Adapters
8
+ class Postgres
9
+ # Fetches scheduled jobs from the postgres connection.
10
+ #
11
+ # The F(x) counterpart is Adapters::Postgres::Functions, which reads
12
+ # pg_proc. This reads cron.job — pg_cron's own catalogue.
13
+ #
14
+ # @api private
15
+ class Jobs
16
+ # The query used to retrieve the jobs considered dumpable into
17
+ # `db/schema.rb`.
18
+ #
19
+ # jobname can be NULL: cron.schedule() has an arity that does not take
20
+ # one. Such a job cannot be expressed as create_cron_job, which is keyed
21
+ # by name, so it is excluded rather than dumped wrongly.
22
+ JOBS_WITH_DEFINITIONS_QUERY = <<~SQL.freeze
23
+ SELECT
24
+ jobname,
25
+ schedule,
26
+ command,
27
+ database,
28
+ username,
29
+ active
30
+ FROM cron.job
31
+ WHERE jobname IS NOT NULL
32
+ ORDER BY jobname;
33
+ SQL
34
+
35
+ # Wraps #all as a static facade.
36
+ #
37
+ # @return [Array<PgCron::Job>]
38
+ def self.all(connection)
39
+ PgCron::Adapters::Postgres::QueryExecutor.call(
40
+ connection: connection,
41
+ query: JOBS_WITH_DEFINITIONS_QUERY,
42
+ model_class: PgCron::Job
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCron
4
+ module Adapters
5
+ class Postgres
6
+ # Executes database queries and maps results to domain objects.
7
+ #
8
+ # Copied from Fx::Adapters::Postgres::QueryExecutor.
9
+ #
10
+ # @api private
11
+ class QueryExecutor
12
+ def self.call(...)
13
+ new(...).call
14
+ end
15
+
16
+ def initialize(connection:, query:, model_class:)
17
+ @connection = connection
18
+ @query = query
19
+ @model_class = model_class
20
+ end
21
+
22
+ # @return [Array] Array of domain objects
23
+ def call
24
+ results_from_postgres.map { |result| model_class.new(result) }
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :connection, :query, :model_class
30
+
31
+ def results_from_postgres
32
+ connection.execute(query)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg_cron/adapters/postgres/connection"
4
+ require "pg_cron/adapters/postgres/jobs"
5
+
6
+ module PgCron
7
+ # Database adapters.
8
+ #
9
+ # Ships with a Postgres adapter only — pg_cron is a Postgres extension, so
10
+ # there is no other engine to support — but the interface is the same one F(x)
11
+ # defines, so an alternative could be substituted the same way.
12
+ module Adapters
13
+ # The Postgres adapter.
14
+ #
15
+ # @param [#connection] connectable An object that returns the connection to
16
+ # use. Defaults to `ActiveRecord::Base`.
17
+ #
18
+ # @example
19
+ # PgCron.configure do |config|
20
+ # config.adapter = PgCron::Adapters::Postgres.new
21
+ # end
22
+ class Postgres
23
+ def initialize(connectable = ActiveRecord::Base)
24
+ @connectable = connectable
25
+ end
26
+
27
+ # Whether pg_cron is installed in this database.
28
+ #
29
+ # Every statement checks this first, so a migration that schedules a job
30
+ # still runs against a database without the extension — a test database,
31
+ # or an environment where cron is not wanted — instead of every such
32
+ # migration needing its own guard.
33
+ def pg_cron_enabled?
34
+ connection.pg_cron_enabled?
35
+ end
36
+
37
+ # Every scheduled job in the database.
38
+ #
39
+ # Used by {PgCron::SchemaDumper} to populate `schema.rb`. Returns
40
+ # nothing when pg_cron is not installed, because cron.job does not exist
41
+ # then and a schema dump must not fail on a database that simply has no
42
+ # cron.
43
+ #
44
+ # @return [Array<PgCron::Job>]
45
+ def jobs
46
+ return [] unless connection.pg_cron_enabled?
47
+
48
+ PgCron::Adapters::Postgres::Jobs.all(connection)
49
+ end
50
+
51
+ # Creates a job in the database.
52
+ #
53
+ # The definition is executed AS GIVEN — it is the cron.schedule() call from
54
+ # db/cron/<name>_v01.sql, so the schedule, the command and any quoting are
55
+ # that file's business. This neither parses nor rewrites it, which is the
56
+ # substantive difference from assembling SQL by interpolating values: a
57
+ # name or command containing a quote produced broken SQL that way.
58
+ #
59
+ # @param sql_definition [String] The SQL for the job.
60
+ # @return [void]
61
+ def create_job(sql_definition)
62
+ execute(sql_definition)
63
+ end
64
+
65
+ # Updates a job in the database.
66
+ #
67
+ # NOT drop-then-create, which is what F(x) does for a function: pg_cron's
68
+ # cron.schedule() against an existing jobname REPLACES that job, so the
69
+ # update is a single statement. Dropping first would leave a window with
70
+ # no schedule, and on a frequently-firing job that window is a missed run.
71
+ #
72
+ # The existence check is deliberate — cron.schedule() would otherwise
73
+ # silently CREATE a job that was expected to be there, hiding a migration
74
+ # applied out of order.
75
+ #
76
+ # @param name [String, Symbol] The name of the job.
77
+ # @param sql_definition [String] The SQL for the job.
78
+ # @return [void]
79
+ def update_job(name, sql_definition)
80
+ connection.transaction do
81
+ unless job_exists?(name)
82
+ raise PG::InternalError, "ERROR: could not find valid entry for job '#{name}'"
83
+ end
84
+
85
+ execute(sql_definition)
86
+ end
87
+ end
88
+
89
+ # Drops the job from the database.
90
+ #
91
+ # @param name [String, Symbol] The name of the job to drop.
92
+ # @return [void]
93
+ def drop_job(name)
94
+ execute("SELECT cron.unschedule(#{quoted(name)});")
95
+ end
96
+
97
+ private
98
+
99
+ attr_reader :connectable
100
+
101
+ delegate :execute, to: :connection
102
+
103
+ def connection
104
+ PgCron::Adapters::Postgres::Connection.new(connectable.connection)
105
+ end
106
+
107
+ def job_exists?(name)
108
+ execute("SELECT 1 FROM cron.job WHERE jobname = #{quoted(name)}").any?
109
+ end
110
+
111
+ def quoted(name)
112
+ "'#{name.to_s.gsub("'", "''")}'"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCron
4
+ # Makes the statements reversible, following F(x)'s CommandRecorder.
5
+ #
6
+ # The `revert_to_version` handling is the point. Inverting create_cron_job is
7
+ # a drop, but inverting DROP or UPDATE is only meaningful if the migration
8
+ # said what to go back to — otherwise a rollback either fails or, worse,
9
+ # leaves the job missing. That is why the previous schedule_pg_cron_job /
10
+ # unschedule_pg_cron_job pair could not invert an update at all.
11
+ module CommandRecorder
12
+ def create_cron_job(*args, &block)
13
+ record(:create_cron_job, args, &block)
14
+ end
15
+
16
+ def drop_cron_job(*args, &block)
17
+ record(:drop_cron_job, args, &block)
18
+ end
19
+
20
+ def update_cron_job(*args, &block)
21
+ record(:update_cron_job, args, &block)
22
+ end
23
+
24
+ def invert_create_cron_job(args)
25
+ [:drop_cron_job, args.first]
26
+ end
27
+
28
+ def invert_drop_cron_job(args)
29
+ perform_cron_job_inversion(:create_cron_job, args)
30
+ end
31
+
32
+ def invert_update_cron_job(args)
33
+ perform_cron_job_inversion(:update_cron_job, args)
34
+ end
35
+
36
+ private
37
+
38
+ def perform_cron_job_inversion(method, args)
39
+ name, options = args
40
+ options ||= {}
41
+
42
+ unless options[:revert_to_version]
43
+ message = "`#{method}` is reversible only if given a `revert_to_version`"
44
+ raise ActiveRecord::IrreversibleMigration, message
45
+ end
46
+
47
+ [method, [name, { version: options[:revert_to_version] }]]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCron
4
+ class Configuration
5
+ # Kept for anything still referencing it. PgCron::Definition owns the real
6
+ # path; a literal rather than an interpolation of Definition::DIRECTORY so
7
+ # this does not depend on require order.
8
+ JOBS_DIRECTORY = "db/cron"
9
+
10
+ # The adapter used to run cron statements. Defaults to the Postgres adapter
11
+ # over the application's own connection.
12
+ attr_reader :adapter
13
+
14
+ def initialize(adapter: PgCron::Adapters::Postgres.new)
15
+ @adapter = adapter
16
+ end
17
+
18
+ attr_writer :adapter
19
+
20
+ # Retained so `PgCronRails.connection`-era callers keep working.
21
+ def connection
22
+ adapter
23
+ end
24
+ end
25
+
26
+ def self.configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def self.configure
31
+ yield configuration
32
+ end
33
+
34
+ def self.database
35
+ configuration.adapter
36
+ end
37
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCron
4
+ # Resolves a job name and version to its definition file.
5
+ #
6
+ # Modelled on F(x)'s Fx::Definition: versioned SQL files, looked up first
7
+ # alongside any engine's migration paths and then in the app's own db/.
8
+ #
9
+ # SQL rather than the YAML this gem used to read. A job's body is a SQL
10
+ # statement either way, so YAML meant a SQL string embedded in a YAML scalar —
11
+ # nothing could syntax-check it, dollar-quoting had to survive two levels of
12
+ # escaping, and the schema dumper could not round-trip it. A .sql file holds
13
+ # the cron.schedule() call verbatim.
14
+ #
15
+ # @api private
16
+ class Definition
17
+ JOB = "job"
18
+
19
+ # `db/cron`, singular. F(x) derives its directory from the type
20
+ # ("function" -> db/functions) because it manages two kinds of object; this
21
+ # gem manages one, and "db/jobs" would read like Active Job's queues rather
22
+ # than the database's schedule.
23
+ DIRECTORY = "cron"
24
+
25
+ def self.job(name:, version:)
26
+ new(name: name, version: version, type: JOB)
27
+ end
28
+
29
+ def initialize(name:, version:, type: JOB)
30
+ @name = name
31
+ @version_number = version.to_i
32
+ @type = type
33
+ end
34
+
35
+ def to_sql
36
+ content = File.read(find_file || full_path)
37
+ raise "Define #{type} in #{path} before migrating." if content.empty?
38
+
39
+ content
40
+ end
41
+
42
+ def full_path
43
+ Rails.root.join(path)
44
+ end
45
+
46
+ def path
47
+ @_path ||= File.join("db", DIRECTORY, filename)
48
+ end
49
+
50
+ def version
51
+ version_number.to_s.rjust(2, "0")
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :name, :version_number, :type
57
+
58
+ def filename
59
+ @_filename ||= "#{name}_v#{version}.sql"
60
+ end
61
+
62
+ # Engines can ship a db/cron beside their migrations; look there before
63
+ # falling back to the host application's.
64
+ def find_file
65
+ migration_paths.lazy
66
+ .map { |migration_path| File.expand_path(File.join("..", "..", path), migration_path) }
67
+ .find { |definition_path| File.exist?(definition_path) }
68
+ end
69
+
70
+ def migration_paths
71
+ Rails.application.config.paths["db/migrate"].expanded
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCron
4
+ # A scheduled job as it exists in cron.job, and how it is written back into
5
+ # db/schema.rb.
6
+ #
7
+ # F(x)'s equivalent is Fx::Function, whose identity is a signature — name plus
8
+ # argument types, because Postgres allows overloads. A cron job has none:
9
+ # pg_cron keys on jobname, so the name is the identity and scheduling over an
10
+ # existing name replaces it.
11
+ #
12
+ # @api private
13
+ class Job
14
+ include Comparable
15
+
16
+ attr_reader :name, :schedule, :command, :database, :username, :active
17
+
18
+ def initialize(row)
19
+ @name = row.fetch("jobname")
20
+ @schedule = row.fetch("schedule")
21
+ @command = row.fetch("command")
22
+ @database = row.fetch("database", nil)
23
+ @username = row.fetch("username", nil)
24
+ @active = row.fetch("active", nil)
25
+ end
26
+
27
+ def <=>(other)
28
+ name <=> other.name
29
+ end
30
+
31
+ def ==(other)
32
+ name == other.name && definition == other.definition
33
+ end
34
+
35
+ # Rebuilt as a cron.schedule() call rather than dumped verbatim: pg_cron
36
+ # stores the PARTS (jobname, schedule, command) in cron.job and keeps no
37
+ # record of the statement that created them, so there is nothing to quote
38
+ # back. This is the same reason F(x) can use pg_get_functiondef and this
39
+ # cannot.
40
+ #
41
+ # The command is dollar-quoted because any non-trivial SQL command contains
42
+ # quotes of its own. $job$ rather than $$ so a command that itself uses $$
43
+ # (a plpgsql body, say) still nests correctly.
44
+ def definition
45
+ <<~SQL
46
+ SELECT cron.schedule(
47
+ #{quote(name)},
48
+ #{quote(schedule)},
49
+ $job$#{command}$job$
50
+ );
51
+ SQL
52
+ end
53
+
54
+ def to_schema
55
+ <<~SCHEMA.indent(2)
56
+ create_cron_job :#{name}, sql_definition: <<-'SQL'
57
+ #{definition.indent(4).rstrip}
58
+ SQL
59
+ SCHEMA
60
+ end
61
+
62
+ private
63
+
64
+ def quote(value)
65
+ "'#{value.to_s.gsub("'", "''")}'"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module PgCron
6
+ class Railtie < ::Rails::Railtie
7
+ railtie_name :pg_cron
8
+
9
+ initializer "pg_cron.load" do
10
+ ActiveSupport.on_load :active_record do
11
+ PgCron.load
12
+ end
13
+ end
14
+
15
+ rake_tasks do
16
+ path = File.expand_path("..", __dir__)
17
+ Dir.glob("#{path}/tasks/**/*.rake").each do |task|
18
+ load task
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCron
4
+ # Writes scheduled jobs into db/schema.rb.
5
+ #
6
+ # F(x)'s SchemaDumper with `functions`/`triggers` replaced by `jobs`. Hooking
7
+ # #tables rather than #extensions is F(x)'s choice and is load-bearing:
8
+ # #extensions is private AND redefined by the PostgreSQL-specific dumper, so a
9
+ # module prepended to ActiveRecord::SchemaDumper never intercepts it and the
10
+ # dump silently comes out with no cron section.
11
+ #
12
+ # Emitted after super, so the cron block follows the tables and F(x)'s
13
+ # functions — which matters, because a create_cron_job line schedules a command
14
+ # that generally calls them.
15
+ #
16
+ # @api private
17
+ module SchemaDumper
18
+ def tables(stream)
19
+ super
20
+
21
+ jobs(stream)
22
+ end
23
+
24
+ private
25
+
26
+ def jobs(stream)
27
+ dumpable_jobs_in_database = PgCron.database.jobs
28
+
29
+ dumpable_jobs_in_database.each do |job|
30
+ stream.puts
31
+ stream.puts(job.to_schema)
32
+ end
33
+ end
34
+ end
35
+ end