riverqueue 0.11.0 → 0.12.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 +4 -4
- data/CHANGELOG.md +132 -0
- data/LICENSE +374 -0
- data/docs/README.md +765 -0
- data/docs/migrations.md +121 -0
- data/docs/testing.md +173 -0
- data/docs/workers.md +126 -0
- data/exe/river +7 -0
- data/lib/cli.rb +55 -0
- data/lib/client.rb +314 -54
- data/lib/client_runtime.rb +612 -0
- data/lib/config.rb +154 -0
- data/lib/driver/job_row_decoder.rb +85 -0
- data/lib/driver/postgres_capabilities.rb +41 -0
- data/lib/driver/runtime.rb +661 -0
- data/lib/driver.rb +13 -6
- data/lib/errors.rb +83 -0
- data/lib/event.rb +78 -0
- data/lib/insert_opts.rb +40 -11
- data/lib/job.rb +28 -13
- data/lib/job_persisted_logging/plugin.rb +108 -0
- data/lib/migration_cli.rb +90 -0
- data/lib/migrator.rb +189 -0
- data/lib/params.rb +96 -0
- data/lib/periodic_cron.rb +24 -0
- data/lib/periodic_job.rb +135 -0
- data/lib/resumable.rb +148 -0
- data/lib/riverqueue/testing/minitest.rb +18 -0
- data/lib/riverqueue/testing/rspec.rb +109 -0
- data/lib/riverqueue/testing.rb +134 -0
- data/lib/riverqueue.rb +16 -0
- data/lib/unique_args.rb +97 -0
- data/lib/unique_bitmask.rb +14 -12
- data/lib/worker.rb +152 -0
- data/lib/worker_runner.rb +106 -0
- data/migration/LICENSE +374 -0
- data/migration/README.md +11 -0
- data/migration/manifest.json +38 -0
- data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
- data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
- data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
- data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
- data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
- data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
- data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
- data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
- data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
- data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
- data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
- data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
- data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
- data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
- data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
- data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
- data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
- data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
- data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
- data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
- data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
- data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
- data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
- data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
- data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
- data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
- data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
- data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
- data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
- data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
- data/sig/client.rbs +95 -0
- data/sig/driver.rbs +170 -0
- data/sig/insert_opts.rbs +25 -0
- data/sig/job.rbs +72 -0
- data/sig/job_persisted_logging/plugin.rbs +29 -0
- data/sig/migrator.rbs +44 -0
- data/sig/riverqueue.rbs +2 -0
- data/sig/runtime.rbs +382 -0
- data/sig/testing.rbs +95 -0
- data/sig/unique_args.rbs +8 -0
- data/sig/unique_bitmask.rbs +8 -0
- data/sig/worker_runner.rbs +22 -0
- metadata +165 -10
data/lib/migrator.rb
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module River
|
|
4
|
+
# Runs River's canonical SQL migrations, committing each version separately.
|
|
5
|
+
# Use a dedicated migration connection and stop workers before downgrading.
|
|
6
|
+
class Migrator
|
|
7
|
+
Migration = Data.define(:version, :name, :sql_up, :sql_down)
|
|
8
|
+
Status = Data.define(:version, :name, :applied)
|
|
9
|
+
|
|
10
|
+
attr_reader :migrations
|
|
11
|
+
|
|
12
|
+
# Creates a migrator for an installed River driver. PostgreSQL schemas must
|
|
13
|
+
# already exist and use simple SQL identifiers; SQLite uses its main schema.
|
|
14
|
+
# The optional migrations_path is a root containing backend/line/*.sql.
|
|
15
|
+
def initialize(driver, line: "main", migrations_path: File.expand_path("../migration", __dir__.to_s), schema: nil)
|
|
16
|
+
@backend = driver.migration_backend
|
|
17
|
+
@driver = driver
|
|
18
|
+
raise ArgumentError, "unsupported migration backend" unless [:postgresql, :sqlite].include?(@backend)
|
|
19
|
+
raise ArgumentError, "invalid migration line" unless line.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
20
|
+
raise ArgumentError, "SQLite does not support a migration schema option" if @backend == :sqlite && schema
|
|
21
|
+
|
|
22
|
+
@line = line
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@requested_schema = schema
|
|
25
|
+
|
|
26
|
+
directory = File.join(migrations_path, @backend.to_s, line)
|
|
27
|
+
@migrations = Dir.glob(File.join(directory, "*.up.sql")).sort.map do |path|
|
|
28
|
+
version, name = File.basename(path).delete_suffix(".up.sql").split("_", 2)
|
|
29
|
+
Migration.new(Integer(version.to_s, 10), name, File.read(path).freeze, File.read(path.sub(/\.up\.sql\z/, ".down.sql")).freeze)
|
|
30
|
+
end.freeze
|
|
31
|
+
unless @migrations.any? && @migrations.map(&:version) == (1..@migrations.length).to_a
|
|
32
|
+
raise ArgumentError, "migration files must contain contiguous versions starting at 1"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Applies pending migrations up (all by default) or down (one by default).
|
|
37
|
+
# target is the version to end at; target: 0 removes the migration line.
|
|
38
|
+
# An up target that is already applied is a no-op.
|
|
39
|
+
# steps limits the number applied. dry_run returns the plan without writes.
|
|
40
|
+
# Returns Migration objects for the versions applied or planned.
|
|
41
|
+
def migrate(direction: :up, dry_run: false, steps: nil, target: nil)
|
|
42
|
+
raise ArgumentError, "direction must be up or down" unless [:up, :down].include?(direction)
|
|
43
|
+
raise ArgumentError, "steps must be positive" if steps && (!steps.is_a?(Integer) || steps <= 0)
|
|
44
|
+
raise ArgumentError, "target must be a bundled version or zero" if target && (!target.is_a?(Integer) || !(0..migrations.length).cover?(target))
|
|
45
|
+
|
|
46
|
+
session(lock: !dry_run) do
|
|
47
|
+
existing = existing_versions
|
|
48
|
+
current = existing.last || 0
|
|
49
|
+
destination = target || ((direction == :up) ? migrations.length : 0)
|
|
50
|
+
if direction == :down && destination > current
|
|
51
|
+
raise ArgumentError, "target is in the opposite direction"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
plan = if direction == :up
|
|
55
|
+
migrations.select { |migration| migration.version > current && migration.version <= destination }
|
|
56
|
+
else
|
|
57
|
+
migrations.reverse.select { |migration| migration.version <= current && migration.version > destination }
|
|
58
|
+
end
|
|
59
|
+
limit = steps || ((direction == :down && target.nil?) ? 1 : plan.length)
|
|
60
|
+
plan = plan.first(limit)
|
|
61
|
+
|
|
62
|
+
unless dry_run
|
|
63
|
+
plan.each do |migration|
|
|
64
|
+
execute((@backend == :sqlite) ? "BEGIN IMMEDIATE" : "BEGIN")
|
|
65
|
+
committed = false
|
|
66
|
+
begin
|
|
67
|
+
raise River::Error, "migration state changed concurrently; rerun migration" unless existing_versions == existing
|
|
68
|
+
|
|
69
|
+
if direction == :down && @line == "main" && line_column?
|
|
70
|
+
raise River::Error, "remove non-main migration lines before downgrading main" if query("SELECT version FROM #{table} WHERE line <> 'main'").any?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sql = (direction == :up) ? migration.sql_up : migration.sql_down
|
|
74
|
+
execute(sql.gsub("/* TEMPLATE: schema */", @schema ? %("#{@schema}".) : ""))
|
|
75
|
+
|
|
76
|
+
if direction == :up
|
|
77
|
+
columns, values = line_column? ? ["line, version", "'#{@line}', #{migration.version}"] : ["version", migration.version.to_s]
|
|
78
|
+
execute("INSERT INTO #{table} (#{columns}) VALUES (#{values})")
|
|
79
|
+
existing += [migration.version]
|
|
80
|
+
else
|
|
81
|
+
unless @line == "main" && migration.version == 1
|
|
82
|
+
filter = line_column? ? " AND line = '#{@line}'" : ""
|
|
83
|
+
execute("DELETE FROM #{table} WHERE version = #{migration.version}#{filter}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
existing -= [migration.version]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
execute("COMMIT")
|
|
90
|
+
committed = true
|
|
91
|
+
ensure
|
|
92
|
+
execute("ROLLBACK") unless committed
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
plan
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns the bundled versions with their database-applied status.
|
|
102
|
+
def status
|
|
103
|
+
session do
|
|
104
|
+
existing = existing_versions
|
|
105
|
+
migrations.map { |migration| Status.new(migration.version, migration.name, existing.include?(migration.version)) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private def execute(sql)
|
|
110
|
+
if @backend == :postgresql
|
|
111
|
+
@connection.exec(sql)
|
|
112
|
+
else
|
|
113
|
+
@connection.execute_batch(sql)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private def existing_versions
|
|
118
|
+
exists = if @backend == :postgresql
|
|
119
|
+
query("SELECT to_regclass('#{table}') AS name").first.fetch("name")
|
|
120
|
+
else
|
|
121
|
+
query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'river_migration'").any?
|
|
122
|
+
end
|
|
123
|
+
if @line != "main"
|
|
124
|
+
unless exists && line_column? && query("SELECT version FROM #{table} WHERE line = 'main' AND version >= 7").any?
|
|
125
|
+
raise River::Error, "migrate main to version 7 or later before migrating #{@line}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
return [] unless exists
|
|
130
|
+
|
|
131
|
+
filter = line_column? ? " WHERE line = '#{@line}'" : ""
|
|
132
|
+
versions = query("SELECT version FROM #{table}#{filter} ORDER BY version").map { |row| row.fetch("version").to_i }
|
|
133
|
+
unless versions == (1..versions.length).to_a && versions.all? { |version| version <= migrations.length }
|
|
134
|
+
raise River::Error, "database migration history is incomplete or newer than this gem"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
versions
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private def line_column?
|
|
141
|
+
if @backend == :postgresql
|
|
142
|
+
query("SELECT column_name FROM information_schema.columns WHERE table_schema = '#{@schema}' AND table_name = 'river_migration' AND column_name = 'line'").any?
|
|
143
|
+
else
|
|
144
|
+
query("PRAGMA table_info(river_migration)").any? { |row| row.fetch("name") == "line" }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private def query(sql)
|
|
149
|
+
if @backend == :postgresql
|
|
150
|
+
@connection.exec(sql).to_a
|
|
151
|
+
else
|
|
152
|
+
rows = @connection.execute2(sql)
|
|
153
|
+
columns = rows.shift
|
|
154
|
+
rows.map { |row| row.is_a?(Hash) ? row : columns.zip(row).to_h }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private def session(lock: false)
|
|
159
|
+
@mutex.synchronize do
|
|
160
|
+
@driver.migration_connection do |connection|
|
|
161
|
+
@connection = connection
|
|
162
|
+
@schema = if @backend == :postgresql
|
|
163
|
+
@requested_schema || query("SELECT current_schema() AS name").first.fetch("name")
|
|
164
|
+
end
|
|
165
|
+
if @backend == :postgresql
|
|
166
|
+
raise ArgumentError, "schema must be a simple SQL identifier" unless @schema&.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
167
|
+
|
|
168
|
+
if lock
|
|
169
|
+
locked = query("SELECT pg_try_advisory_lock(hashtext(current_database()), hashtext('river_migrate:#{@schema}')) AS locked").first.fetch("locked")
|
|
170
|
+
raise River::Error, "another Ruby migrator holds the schema lock" unless [true, "t"].include?(locked)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
begin
|
|
175
|
+
yield
|
|
176
|
+
ensure
|
|
177
|
+
if @backend == :postgresql && lock
|
|
178
|
+
query("SELECT pg_advisory_unlock(hashtext(current_database()), hashtext('river_migrate:#{@schema}'))")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private def table
|
|
186
|
+
@schema ? %("#{@schema}".river_migration) : "river_migration"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
data/lib/params.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module River
|
|
4
|
+
# Result of Client#job_delete_many.
|
|
5
|
+
JobDeleteManyResult = Data.define(:jobs)
|
|
6
|
+
|
|
7
|
+
# Position in a job listing, including the ordering value so pagination still
|
|
8
|
+
# works after the job at the end of the previous page has been deleted.
|
|
9
|
+
JobListCursor = Data.define(:id, :sort_by, :sort_order, :value)
|
|
10
|
+
|
|
11
|
+
# A page of jobs and the cursor for fetching the next page.
|
|
12
|
+
JobListResult = Data.define(:jobs, :last_cursor)
|
|
13
|
+
|
|
14
|
+
# A River queue record persisted in the database.
|
|
15
|
+
Queue = Data.define(:name, :created_at, :metadata, :paused_at, :updated_at)
|
|
16
|
+
|
|
17
|
+
# Result of Client#queue_list.
|
|
18
|
+
QueueListResult = Data.define(:queues)
|
|
19
|
+
|
|
20
|
+
# Filtering, ordering, and pagination parameters for Client#job_list and
|
|
21
|
+
# Client#job_delete_many.
|
|
22
|
+
class JobListParams
|
|
23
|
+
# Normalized filtering, pagination, and ordering values.
|
|
24
|
+
attr_reader :after, :after_id, :ids, :kinds, :limit, :metadata, :priorities, :queues, :sort_by,
|
|
25
|
+
:sort_order, :states, :tags_all, :tags_any
|
|
26
|
+
|
|
27
|
+
# Creates job-list parameters. Pass JobListResult#last_cursor as +after+ and
|
|
28
|
+
# keep the same filters and ordering for subsequent pages. +after_id+ is a
|
|
29
|
+
# shortcut for ID-ordered listings. Null timestamps always sort last.
|
|
30
|
+
# Kind, queue, and state filters accept symbols or strings.
|
|
31
|
+
def initialize(after: nil, after_id: nil, ids: nil, kinds: nil, limit: 100, priorities: nil,
|
|
32
|
+
metadata: nil, queues: nil, sort_by: :id, sort_order: :asc, states: nil, tags_all: nil, tags_any: nil)
|
|
33
|
+
@after = after
|
|
34
|
+
@after_id = after_id.nil? ? nil : Integer(after_id)
|
|
35
|
+
@ids = ids
|
|
36
|
+
@kinds = kinds&.map(&:to_s)
|
|
37
|
+
@limit = Integer(limit)
|
|
38
|
+
@metadata = metadata
|
|
39
|
+
@priorities = priorities
|
|
40
|
+
@queues = queues&.map(&:to_s)
|
|
41
|
+
@sort_by = sort_by.to_sym
|
|
42
|
+
@sort_order = sort_order.to_sym
|
|
43
|
+
@states = states&.map(&:to_s)
|
|
44
|
+
@tags_all = tags_all
|
|
45
|
+
@tags_any = tags_any
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "limit must be between 1 and 10,000" unless (1..10_000).cover?(@limit)
|
|
48
|
+
raise ArgumentError, "invalid sort field" unless [:id, :scheduled_at, :finalized_at].include?(@sort_by)
|
|
49
|
+
raise ArgumentError, "invalid sort order" unless [:asc, :desc].include?(@sort_order)
|
|
50
|
+
raise ArgumentError, "use either after or after_id" if after && after_id
|
|
51
|
+
raise ArgumentError, "after_id requires sorting by id; use after for timestamp ordering" if after_id && @sort_by != :id
|
|
52
|
+
if after && (!after.is_a?(JobListCursor) || after.sort_by != @sort_by || after.sort_order != @sort_order)
|
|
53
|
+
raise ArgumentError, "after must be a JobListCursor with the same ordering"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def filters?
|
|
58
|
+
[after, after_id, ids, kinds, metadata, priorities, queues, states, tags_all, tags_any].any? do |value|
|
|
59
|
+
value.respond_to?(:empty?) ? !value.empty? : !value.nil?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Fields to change with Client#job_update. Omitted fields are left unchanged;
|
|
65
|
+
# explicitly passing nil clears nullable fields.
|
|
66
|
+
class JobUpdateParams
|
|
67
|
+
UNSET = Object.new.freeze
|
|
68
|
+
|
|
69
|
+
# Values to apply, with omitted fields represented internally by UNSET.
|
|
70
|
+
attr_reader :attempt, :attempted_at, :attempted_by, :errors, :finalized_at,
|
|
71
|
+
:max_attempts, :metadata, :state
|
|
72
|
+
|
|
73
|
+
# Creates a partial set of updates for a persisted job.
|
|
74
|
+
def initialize(attempt: UNSET, attempted_at: UNSET, attempted_by: UNSET,
|
|
75
|
+
errors: UNSET, finalized_at: UNSET, max_attempts: UNSET, metadata: UNSET,
|
|
76
|
+
state: UNSET)
|
|
77
|
+
@attempt = attempt
|
|
78
|
+
@attempted_at = attempted_at
|
|
79
|
+
@attempted_by = attempted_by
|
|
80
|
+
@errors = errors
|
|
81
|
+
@finalized_at = finalized_at
|
|
82
|
+
@max_attempts = max_attempts
|
|
83
|
+
@metadata = metadata
|
|
84
|
+
@state = state.is_a?(Symbol) ? state.to_s : state
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def each
|
|
88
|
+
return enum_for(:each) unless block_given?
|
|
89
|
+
|
|
90
|
+
instance_variables.each do |ivar|
|
|
91
|
+
value = instance_variable_get(ivar)
|
|
92
|
+
yield ivar.to_s.delete_prefix("@").to_sym, value unless value.equal?(UNSET)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module River
|
|
4
|
+
# A cron schedule for PeriodicJob. Add the optional +fugit+ gem to use it.
|
|
5
|
+
class PeriodicCron
|
|
6
|
+
# Parses a cron expression once. +timezone+ defaults to UTC; use an IANA
|
|
7
|
+
# name such as America/New_York for local calendar schedules. Specify the
|
|
8
|
+
# timezone here, not inside +expression+. Fugit is loaded only on construction.
|
|
9
|
+
def initialize(expression, timezone: "UTC")
|
|
10
|
+
require "fugit"
|
|
11
|
+
|
|
12
|
+
raise ArgumentError, "cron expression must be a String" unless expression.is_a?(String)
|
|
13
|
+
raise ArgumentError, "timezone must be a nonempty name without whitespace" unless timezone.is_a?(String) && /\A\S+\z/.match?(timezone)
|
|
14
|
+
|
|
15
|
+
@cron = Object.const_get(:Fugit).const_get(:Cron).do_parse("#{expression} #{timezone}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns a UTC Time strictly after +time+. Calendar and daylight-saving
|
|
19
|
+
# rules are provided by Fugit; this helper does not enqueue or persist jobs.
|
|
20
|
+
def next(time)
|
|
21
|
+
@cron.next_time(time).to_t.getutc
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/periodic_job.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module River
|
|
4
|
+
# Definition of a recurring job and the schedule used to construct it.
|
|
5
|
+
class PeriodicJob
|
|
6
|
+
# Callable that produces job arguments, or an arguments/options pair.
|
|
7
|
+
attr_reader :constructor
|
|
8
|
+
|
|
9
|
+
# Stable string identifier used for removal and durable Pro scheduling.
|
|
10
|
+
attr_reader :id
|
|
11
|
+
|
|
12
|
+
# Whether the job should run immediately when its scheduler starts.
|
|
13
|
+
attr_reader :run_on_start
|
|
14
|
+
|
|
15
|
+
# Object or callable that computes the next run time.
|
|
16
|
+
attr_reader :schedule
|
|
17
|
+
|
|
18
|
+
# Creates a periodic job. +schedule+ may respond to +next(time)+ or be a
|
|
19
|
+
# callable, and must return a Time strictly after its argument. Supply a
|
|
20
|
+
# factory block or a +constructor+ callable (not both),
|
|
21
|
+
# invoked whenever the job is due. +id+ accepts a symbol or string; nil
|
|
22
|
+
# leaves the registration anonymous.
|
|
23
|
+
def initialize(schedule:, constructor: nil, id: nil, run_on_start: false, &block)
|
|
24
|
+
raise ArgumentError, "use constructor or a block, not both" if constructor && block
|
|
25
|
+
|
|
26
|
+
@constructor = constructor || block
|
|
27
|
+
raise ArgumentError, "a factory block or callable constructor is required" unless @constructor.respond_to?(:call)
|
|
28
|
+
raise ArgumentError, "schedule must respond to next or call" unless schedule.respond_to?(:next) || schedule.respond_to?(:call)
|
|
29
|
+
@id = id&.to_s
|
|
30
|
+
@run_on_start = run_on_start
|
|
31
|
+
@schedule = schedule
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the next scheduled Time after +now+.
|
|
35
|
+
def next_at(now)
|
|
36
|
+
next_time = schedule.respond_to?(:next) ? schedule.next(now) : schedule.call(now)
|
|
37
|
+
raise ArgumentError, "schedule must return a future Time" unless next_time.is_a?(Time) && next_time > now
|
|
38
|
+
next_time
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A fixed-duration schedule suitable for PeriodicJob.
|
|
43
|
+
class PeriodicInterval
|
|
44
|
+
# Creates an interval measured in seconds.
|
|
45
|
+
def initialize(seconds)
|
|
46
|
+
@seconds = Float(seconds, exception: true) #: Float
|
|
47
|
+
raise ArgumentError, "period must be finite" unless @seconds.finite?
|
|
48
|
+
raise ArgumentError, "period must be greater than zero" unless @seconds.positive?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the next Time one interval after +time+.
|
|
52
|
+
def next(time)
|
|
53
|
+
time + @seconds
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Thread-safe collection used to change a client's periodic jobs at runtime.
|
|
58
|
+
class PeriodicJobBundle
|
|
59
|
+
def initialize(jobs, wake:)
|
|
60
|
+
@jobs = {}
|
|
61
|
+
@mutex = Mutex.new
|
|
62
|
+
@next_handle = 0
|
|
63
|
+
@wake = wake
|
|
64
|
+
add_many(jobs)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Adds a periodic job and returns a handle that can be passed to #remove.
|
|
68
|
+
def add(job)
|
|
69
|
+
add_many([job]).fetch(0)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Adds periodic jobs atomically and returns their handles in input order.
|
|
73
|
+
# Invalid schedules or duplicate IDs leave the registry unchanged.
|
|
74
|
+
def add_many(jobs)
|
|
75
|
+
now = Time.now.utc
|
|
76
|
+
entries = jobs.map { |job| {job: job, next_at: job.run_on_start ? now : job.next_at(now)} }
|
|
77
|
+
handles = @mutex.synchronize do
|
|
78
|
+
ids = @jobs.values.to_h { |entry| [entry[:job].id, true] }
|
|
79
|
+
jobs.each do |job|
|
|
80
|
+
next unless job.id
|
|
81
|
+
raise ArgumentError, "periodic job ID is already registered: #{job.id}" if ids[job.id]
|
|
82
|
+
ids[job.id] = true
|
|
83
|
+
end
|
|
84
|
+
entries.map do |entry|
|
|
85
|
+
@next_handle += 1
|
|
86
|
+
@jobs[@next_handle] = entry
|
|
87
|
+
@next_handle
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
@wake.call unless handles.empty?
|
|
91
|
+
handles
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Removes all periodic jobs from the bundle and returns self.
|
|
95
|
+
def clear
|
|
96
|
+
@mutex.synchronize { @jobs.clear }
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def due(now)
|
|
101
|
+
# Application schedules may reenter the registry, so evaluate outside its
|
|
102
|
+
# lock and only advance registrations that haven't been removed or claimed.
|
|
103
|
+
entries = @mutex.synchronize { @jobs.map { |handle, entry| [handle, entry, entry[:next_at]] } }
|
|
104
|
+
entries.filter_map do |handle, entry, next_at|
|
|
105
|
+
next if next_at > now
|
|
106
|
+
|
|
107
|
+
following = entry[:job].next_at(now)
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
next unless @jobs[handle].equal?(entry) && entry[:next_at] == next_at
|
|
110
|
+
entry[:next_at] = following
|
|
111
|
+
entry[:job]
|
|
112
|
+
end
|
|
113
|
+
rescue => error
|
|
114
|
+
raise unless block_given?
|
|
115
|
+
yield(entry[:job], error)
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Removes the job associated with +handle+, returning the PeriodicJob or
|
|
121
|
+
# nil when no such handle exists.
|
|
122
|
+
def remove(handle)
|
|
123
|
+
@mutex.synchronize { @jobs.delete(handle)&.fetch(:job) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Removes the periodic job with +id+. Returns whether a job was removed.
|
|
127
|
+
def remove_by_id(id)
|
|
128
|
+
id = id&.to_s
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
pair = @jobs.find { |_handle, entry| entry[:job].id == id }
|
|
131
|
+
!!(pair && @jobs.delete(pair.first))
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/resumable.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module River
|
|
4
|
+
RESUMABLE_CURSOR_METADATA_KEY = "river:resumable_cursor"
|
|
5
|
+
RESUMABLE_STEP_METADATA_KEY = "river:resumable_step"
|
|
6
|
+
|
|
7
|
+
# Execution state for resumable steps. Applications normally interact with
|
|
8
|
+
# this through Job#resumable_step and Job#resumable_step_cursor.
|
|
9
|
+
class ResumableState
|
|
10
|
+
attr_reader :all_step_names
|
|
11
|
+
attr_accessor :completed_step
|
|
12
|
+
attr_reader :cursors
|
|
13
|
+
attr_accessor :cursors_dirty
|
|
14
|
+
attr_accessor :resume_matched
|
|
15
|
+
attr_reader :resume_step
|
|
16
|
+
attr_accessor :step_name
|
|
17
|
+
|
|
18
|
+
def initialize(metadata)
|
|
19
|
+
@all_step_names = {}
|
|
20
|
+
@completed_step = nil
|
|
21
|
+
@cursors = (metadata[RESUMABLE_CURSOR_METADATA_KEY] || {}).dup
|
|
22
|
+
@cursors_dirty = false
|
|
23
|
+
@resume_step = metadata[RESUMABLE_STEP_METADATA_KEY]
|
|
24
|
+
@step_name = nil
|
|
25
|
+
|
|
26
|
+
@resume_matched = @resume_step.to_s.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register(name)
|
|
30
|
+
raise Error, "duplicate resumable step name #{name.inspect}" if all_step_names.key?(name)
|
|
31
|
+
|
|
32
|
+
all_step_names[name] = true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class Job
|
|
37
|
+
# Internal runtime boundary: attach progress only to attempts that did not
|
|
38
|
+
# complete, matching River Go's persisted metadata format.
|
|
39
|
+
def __capture_resumable_metadata!
|
|
40
|
+
if @resumable_state.cursors_dirty
|
|
41
|
+
@metadata_updates[RESUMABLE_CURSOR_METADATA_KEY] = @resumable_state.cursors.empty? ? nil : @resumable_state.cursors.dup
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if @resumable_state.completed_step
|
|
45
|
+
@metadata_updates[RESUMABLE_STEP_METADATA_KEY] = @resumable_state.completed_step
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Internal runtime boundary: validate that the recorded resume point still
|
|
50
|
+
# exists in the worker after it has returned.
|
|
51
|
+
def __finish_resumable_work!
|
|
52
|
+
if @resumable_state.resume_step && !@resumable_state.resume_matched
|
|
53
|
+
raise Error, "resumable step #{@resumable_state.resume_step.inspect} not found in worker"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Immediately checkpoints the current step and cursor progress. Wrap the
|
|
58
|
+
# call in Driver#transaction alongside application writes when an atomic
|
|
59
|
+
# checkpoint is needed.
|
|
60
|
+
def resumable_checkpoint(cursor: RESUMABLE_CURSOR_UNSET)
|
|
61
|
+
step_name = @resumable_state.step_name
|
|
62
|
+
raise Error, "resumable step can only be persisted inside a resumable step" unless step_name
|
|
63
|
+
raise Error, "job must be running" unless row.state == JOB_STATE_RUNNING
|
|
64
|
+
|
|
65
|
+
cursors = @resumable_state.cursors.dup
|
|
66
|
+
cursors[step_name] = JSON.parse(JSON.generate(cursor)) unless cursor.equal?(RESUMABLE_CURSOR_UNSET)
|
|
67
|
+
updates = {
|
|
68
|
+
RESUMABLE_STEP_METADATA_KEY => step_name,
|
|
69
|
+
RESUMABLE_CURSOR_METADATA_KEY => cursors.empty? ? nil : cursors
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
updated = client.driver.job_metadata_merge(row.id, updates) || raise(NotFoundError, "job not found: #{row.id}")
|
|
73
|
+
# The database owns checkpointed progress, including rollback of an
|
|
74
|
+
# enclosing application transaction. Only progress made after this write
|
|
75
|
+
# belongs in the attempt's deferred updates; replaying the checkpoint on
|
|
76
|
+
# failure could otherwise commit progress whose application writes rolled back.
|
|
77
|
+
@resumable_state.cursors.replace(cursors)
|
|
78
|
+
@resumable_state.cursors_dirty = false
|
|
79
|
+
@resumable_state.completed_step = nil
|
|
80
|
+
@row = updated
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Records JSON-compatible cursor data for the current step. It is persisted
|
|
84
|
+
# with the failed attempt so that its retry can continue from this value.
|
|
85
|
+
def resumable_set_cursor(cursor)
|
|
86
|
+
step_name = @resumable_state.step_name
|
|
87
|
+
raise Error, "resumable cursor can only be set inside a resumable step" unless step_name
|
|
88
|
+
|
|
89
|
+
@resumable_state.cursors[step_name] = JSON.parse(JSON.generate(cursor))
|
|
90
|
+
@resumable_state.cursors_dirty = true
|
|
91
|
+
cursor
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Runs a named step, skipping it on retry when a previous attempt already
|
|
95
|
+
# completed it. Names accept symbols or strings and must be unique within a
|
|
96
|
+
# worker invocation. Steps cannot be nested: a nested resume point would be
|
|
97
|
+
# unreachable when its enclosing step is skipped on retry. Persisted
|
|
98
|
+
# checkpoints always use string names.
|
|
99
|
+
# Exceptions propagate immediately, just as they do outside a step.
|
|
100
|
+
def resumable_step(name, &block)
|
|
101
|
+
run_resumable_step(name.to_s, cursor: false, default: nil, &block)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Runs a named step with the cursor saved by #resumable_set_cursor during a
|
|
105
|
+
# previous failed attempt. Names accept symbols or strings.
|
|
106
|
+
def resumable_step_cursor(name, default: nil, &block)
|
|
107
|
+
run_resumable_step(name.to_s, cursor: true, default: default, &block)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
RESUMABLE_CURSOR_UNSET = Object.new.freeze
|
|
111
|
+
private_constant :RESUMABLE_CURSOR_UNSET
|
|
112
|
+
|
|
113
|
+
private def initialize_resumable_state
|
|
114
|
+
@resumable_state = ResumableState.new(row.metadata)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private def run_resumable_step(name, cursor:, default:)
|
|
118
|
+
raise Error, "resumable steps cannot be nested" if @resumable_state.step_name
|
|
119
|
+
raise ArgumentError, "resumable step name must be non-empty" if name.empty?
|
|
120
|
+
@resumable_state.register(name)
|
|
121
|
+
|
|
122
|
+
unless @resumable_state.resume_matched
|
|
123
|
+
if name == @resumable_state.resume_step
|
|
124
|
+
@resumable_state.completed_step = name
|
|
125
|
+
@resumable_state.resume_matched = true
|
|
126
|
+
return unless cursor && @resumable_state.cursors.key?(name)
|
|
127
|
+
else
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
@resumable_state.step_name = name
|
|
133
|
+
begin
|
|
134
|
+
value = (cursor && @resumable_state.cursors.key?(name)) ? @resumable_state.cursors[name] : default
|
|
135
|
+
result = cursor ? yield(value) : yield
|
|
136
|
+
@resumable_state.completed_step = name
|
|
137
|
+
if cursor && @resumable_state.cursors.key?(name)
|
|
138
|
+
@resumable_state.cursors.delete(name)
|
|
139
|
+
@resumable_state.cursors_dirty = true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
result
|
|
143
|
+
ensure
|
|
144
|
+
@resumable_state.step_name = nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
require "riverqueue/testing"
|
|
5
|
+
|
|
6
|
+
module River
|
|
7
|
+
module Testing
|
|
8
|
+
# Include in Minitest::Test (or an individual test class) to use River
|
|
9
|
+
# assertions with Minitest's failure reporting and assertion count.
|
|
10
|
+
module Minitest
|
|
11
|
+
include Assertions
|
|
12
|
+
|
|
13
|
+
private def river_assert(condition, message)
|
|
14
|
+
assert(condition, message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|