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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +132 -0
  3. data/LICENSE +374 -0
  4. data/docs/README.md +765 -0
  5. data/docs/migrations.md +121 -0
  6. data/docs/testing.md +173 -0
  7. data/docs/workers.md +126 -0
  8. data/exe/river +7 -0
  9. data/lib/cli.rb +55 -0
  10. data/lib/client.rb +314 -54
  11. data/lib/client_runtime.rb +612 -0
  12. data/lib/config.rb +154 -0
  13. data/lib/driver/job_row_decoder.rb +85 -0
  14. data/lib/driver/postgres_capabilities.rb +41 -0
  15. data/lib/driver/runtime.rb +661 -0
  16. data/lib/driver.rb +13 -6
  17. data/lib/errors.rb +83 -0
  18. data/lib/event.rb +78 -0
  19. data/lib/insert_opts.rb +40 -11
  20. data/lib/job.rb +28 -13
  21. data/lib/job_persisted_logging/plugin.rb +108 -0
  22. data/lib/migration_cli.rb +90 -0
  23. data/lib/migrator.rb +189 -0
  24. data/lib/params.rb +96 -0
  25. data/lib/periodic_cron.rb +24 -0
  26. data/lib/periodic_job.rb +135 -0
  27. data/lib/resumable.rb +148 -0
  28. data/lib/riverqueue/testing/minitest.rb +18 -0
  29. data/lib/riverqueue/testing/rspec.rb +109 -0
  30. data/lib/riverqueue/testing.rb +134 -0
  31. data/lib/riverqueue.rb +16 -0
  32. data/lib/unique_args.rb +97 -0
  33. data/lib/unique_bitmask.rb +14 -12
  34. data/lib/worker.rb +152 -0
  35. data/lib/worker_runner.rb +106 -0
  36. data/migration/LICENSE +374 -0
  37. data/migration/README.md +11 -0
  38. data/migration/manifest.json +38 -0
  39. data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
  40. data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
  41. data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
  42. data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
  43. data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
  44. data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
  45. data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
  46. data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
  47. data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
  48. data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
  49. data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
  50. data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
  51. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
  52. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
  53. data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
  54. data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
  55. data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
  56. data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
  57. data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
  58. data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
  59. data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
  60. data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
  61. data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
  62. data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
  63. data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
  64. data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
  65. data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
  66. data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
  67. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
  68. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
  69. data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
  70. data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
  71. data/sig/client.rbs +95 -0
  72. data/sig/driver.rbs +170 -0
  73. data/sig/insert_opts.rbs +25 -0
  74. data/sig/job.rbs +72 -0
  75. data/sig/job_persisted_logging/plugin.rbs +29 -0
  76. data/sig/migrator.rbs +44 -0
  77. data/sig/riverqueue.rbs +2 -0
  78. data/sig/runtime.rbs +382 -0
  79. data/sig/testing.rbs +95 -0
  80. data/sig/unique_args.rbs +8 -0
  81. data/sig/unique_bitmask.rbs +8 -0
  82. data/sig/worker_runner.rbs +22 -0
  83. 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
@@ -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