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.
- checksums.yaml +7 -0
- data/.rubocop.yml +25 -0
- data/CHANGELOG.md +56 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +121 -0
- data/LICENSE +21 -0
- data/README.md +54 -0
- data/Rakefile +16 -0
- data/bin/console +15 -0
- data/bin/increment-version +37 -0
- data/bin/release-gem +32 -0
- data/bin/setup +8 -0
- data/lib/generators/pg_cron/job/USAGE +19 -0
- data/lib/generators/pg_cron/job/job_generator.rb +116 -0
- data/lib/generators/pg_cron/job/templates/db/migrate/create_job.erb +5 -0
- data/lib/generators/pg_cron/job/templates/db/migrate/update_job.erb +5 -0
- data/lib/generators/pg_cron/migration_helper.rb +48 -0
- data/lib/generators/pg_cron/name_helper.rb +29 -0
- data/lib/generators/pg_cron/version_helper.rb +53 -0
- data/lib/pg_cron/adapters/postgres/connection.rb +26 -0
- data/lib/pg_cron/adapters/postgres/jobs.rb +48 -0
- data/lib/pg_cron/adapters/postgres/query_executor.rb +37 -0
- data/lib/pg_cron/adapters/postgres.rb +116 -0
- data/lib/pg_cron/command_recorder.rb +50 -0
- data/lib/pg_cron/configuration.rb +37 -0
- data/lib/pg_cron/definition.rb +74 -0
- data/lib/pg_cron/job.rb +68 -0
- data/lib/pg_cron/railtie.rb +22 -0
- data/lib/pg_cron/schema_dumper.rb +35 -0
- data/lib/pg_cron/statements.rb +126 -0
- data/lib/pg_cron/version.rb +5 -0
- data/lib/pg_cron/version.rb.erb +5 -0
- data/lib/pg_cron.rb +44 -0
- data/lib/tasks/pg_cron/down.rake +21 -0
- data/lib/tasks/pg_cron/schedule_all_jobs.rake +33 -0
- data/lib/tasks/pg_cron/schedule_job.rake +12 -0
- data/lib/tasks/pg_cron/unschedule_job.rake +12 -0
- data/lib/tasks/pg_cron/up.rake +25 -0
- data/lib/tasks/pg_cron/utils.rb +124 -0
- data/spec/dummy/.gitignore +71 -0
- data/spec/dummy/.ruby-version +1 -0
- data/spec/dummy/README.md +24 -0
- data/spec/dummy/Rakefile +6 -0
- data/spec/dummy/app/controllers/application_controller.rb +2 -0
- data/spec/dummy/app/controllers/concerns/.keep +0 -0
- data/spec/dummy/app/models/application_record.rb +3 -0
- data/spec/dummy/app/models/concerns/.keep +0 -0
- data/spec/dummy/bin/rails +4 -0
- data/spec/dummy/bin/rake +4 -0
- data/spec/dummy/bin/setup +33 -0
- data/spec/dummy/config/application.rb +40 -0
- data/spec/dummy/config/boot.rb +3 -0
- data/spec/dummy/config/credentials.yml.enc +1 -0
- data/spec/dummy/config/database.yml +86 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +58 -0
- data/spec/dummy/config/environments/production.rb +96 -0
- data/spec/dummy/config/environments/test.rb +49 -0
- data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +8 -0
- data/spec/dummy/config/initializers/cors.rb +16 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +6 -0
- data/spec/dummy/config/initializers/inflections.rb +16 -0
- data/spec/dummy/config/initializers/mime_types.rb +4 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/spec/dummy/config/locales/en.yml +33 -0
- data/spec/dummy/config/puma.rb +43 -0
- data/spec/dummy/config/routes.rb +3 -0
- data/spec/dummy/config.ru +6 -0
- data/spec/dummy/db/pg_cron_jobs/dummy_job.yml +3 -0
- data/spec/dummy/db/seeds.rb +7 -0
- data/spec/dummy/lib/tasks/.keep +0 -0
- data/spec/dummy/log/.keep +0 -0
- data/spec/dummy/public/robots.txt +1 -0
- data/spec/dummy/tmp/.keep +0 -0
- data/spec/dummy/vendor/.keep +0 -0
- data/spec/pg_cron/configuration_spec.rb +20 -0
- data/spec/pg_cron/fx_coexistence_spec.rb +53 -0
- data/spec/pg_cron/statements_spec.rb +9 -0
- data/spec/spec_helper.rb +6 -0
- 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
|
data/lib/pg_cron/job.rb
ADDED
|
@@ -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
|