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/driver.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module River
2
4
  # Contains an interface used by the top-level River module to interface with
3
5
  # its driver implementations. All types and methods in this module should be
@@ -7,9 +9,11 @@ module River
7
9
  # Insert parameters for a job. This is sent to underlying drivers and is meant
8
10
  # for internal use only. Its interface is subject to change.
9
11
  class JobInsertParams
12
+ attr_accessor :args
10
13
  attr_accessor :encoded_args
11
14
  attr_accessor :kind
12
15
  attr_accessor :max_attempts
16
+ attr_accessor :metadata
13
17
  attr_accessor :priority
14
18
  attr_accessor :queue
15
19
  attr_accessor :scheduled_at
@@ -22,17 +26,16 @@ module River
22
26
  encoded_args:,
23
27
  kind:,
24
28
  max_attempts:,
25
- priority:,
26
- queue:,
27
- scheduled_at:,
28
- state:,
29
- tags:,
29
+ priority:, queue:, scheduled_at:, state:, tags:, metadata: {},
30
30
  unique_key: nil,
31
- unique_states: nil
31
+ unique_states: nil,
32
+ args: nil
32
33
  )
34
+ self.args = args
33
35
  self.encoded_args = encoded_args
34
36
  self.kind = kind
35
37
  self.max_attempts = max_attempts
38
+ self.metadata = metadata
36
39
  self.priority = priority
37
40
  self.queue = queue
38
41
  self.scheduled_at = scheduled_at
@@ -44,3 +47,7 @@ module River
44
47
  end
45
48
  end
46
49
  end
50
+
51
+ require_relative "driver/postgres_capabilities"
52
+ require_relative "driver/job_row_decoder"
53
+ require_relative "driver/runtime"
data/lib/errors.rb ADDED
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module River
4
+ class Error < StandardError; end
5
+
6
+ class NotFoundError < Error; end
7
+
8
+ class JobRunningError < Error; end
9
+
10
+ # A stored job contains fields that cannot be decoded. Administrative reads
11
+ # raise this error; consumers fail this job's attempt without blocking peers.
12
+ class JobRowDecodeError < Error
13
+ attr_reader :job
14
+
15
+ def initialize(job, detail)
16
+ @job = job
17
+ super("job row #{job.id} couldn't be decoded: #{detail}")
18
+ job.__decode_error = self
19
+ end
20
+ end
21
+
22
+ class JobCancelError < Error
23
+ # Original exception that caused cancellation, when supplied.
24
+ attr_reader :cause
25
+
26
+ def initialize(message = "job cancelled", cause: nil)
27
+ @cause = cause
28
+ super(message)
29
+ end
30
+ end
31
+
32
+ class JobSnoozeError < Error
33
+ # Number of seconds for which the job should be snoozed.
34
+ attr_reader :duration
35
+
36
+ def initialize(duration)
37
+ @duration = Float(duration, exception: true) #: Float
38
+ raise ArgumentError, "duration must be finite" unless @duration.finite?
39
+ raise ArgumentError, "duration must be zero or greater" if @duration.negative?
40
+
41
+ super("job snoozed for #{@duration} seconds")
42
+ end
43
+ end
44
+
45
+ class UnknownJobKindError < Error
46
+ # Unregistered job kind encountered by the runtime.
47
+ attr_reader :kind
48
+
49
+ def initialize(kind)
50
+ @kind = kind
51
+ super("unknown job kind: #{kind}")
52
+ end
53
+ end
54
+
55
+ class ClientNotStartedError < Error; end
56
+
57
+ class ClientAlreadyStartedError < Error; end
58
+
59
+ # Returns an exception that tells River to cancel the current job.
60
+ #
61
+ # An Exception argument is retained as the cancellation's cause; any other
62
+ # non-nil argument is used as its message. Raise the result from a worker:
63
+ #
64
+ # raise River.job_cancel("account closed")
65
+ def self.job_cancel(error = nil)
66
+ case error
67
+ when Exception
68
+ JobCancelError.new(error.message, cause: error)
69
+ when nil
70
+ JobCancelError.new
71
+ else
72
+ JobCancelError.new(error.to_s)
73
+ end
74
+ end
75
+
76
+ # Returns an exception that tells River to reschedule the current job after
77
+ # +duration+ seconds without treating the attempt as an error.
78
+ #
79
+ # raise River.job_snooze(30)
80
+ def self.job_snooze(duration)
81
+ JobSnoozeError.new(duration)
82
+ end
83
+ end
data/lib/event.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module River
4
+ EVENT_JOB_CANCELLED = :job_cancelled
5
+ EVENT_JOB_COMPLETED = :job_completed
6
+ EVENT_JOB_FAILED = :job_failed
7
+ EVENT_JOB_INTERRUPTED = :job_interrupted
8
+ EVENT_JOB_SNOOZED = :job_snoozed
9
+ EVENT_QUEUE_PAUSED = :queue_paused
10
+ EVENT_QUEUE_RESUMED = :queue_resumed
11
+
12
+ # Event emitted by a Client and delivered through a Subscription.
13
+ Event = Data.define(:kind, :job, :queue, :stats)
14
+
15
+ # Timing statistics attached to job lifecycle events.
16
+ JobStatistics = Data.define(:complete_duration, :queue_wait_duration, :run_duration)
17
+
18
+ # Bounded stream of selected Client lifecycle events.
19
+ class Subscription
20
+ # Creates an event subscription. Applications normally receive instances
21
+ # from Client#subscribe.
22
+ def initialize(kinds, buffer_size: 100, on_close: nil)
23
+ @closed = false
24
+ @kinds = kinds.map(&:to_sym).freeze
25
+ @mutex = Mutex.new
26
+ @on_close = on_close
27
+ @queue = SizedQueue.new(buffer_size)
28
+ end
29
+
30
+ # Closes the subscription and releases it from its client. Closing more than
31
+ # once is safe. Buffered events remain readable; blocked readers wake once
32
+ # the buffer is drained.
33
+ def close
34
+ on_close = @mutex.synchronize do
35
+ return if @closed
36
+
37
+ @closed = true
38
+ @queue.close
39
+ @on_close.tap { @on_close = nil }
40
+ end
41
+
42
+ on_close&.call(self)
43
+ nil
44
+ end
45
+
46
+ # Yields events as they arrive until the subscription is closed. Returns an
47
+ # Enumerator when no block is given.
48
+ def each
49
+ return enum_for(:each) unless block_given?
50
+
51
+ loop do
52
+ event = @queue.pop
53
+ break if event.nil?
54
+
55
+ yield event
56
+ end
57
+ end
58
+
59
+ # Removes and returns the next queued value, blocking unless +non_block+ is
60
+ # true. A blocking pop returns nil once the subscription is closed and its
61
+ # buffered events have been drained.
62
+ #
63
+ # A non-blocking pop raises ThreadError when no event is available.
64
+ def pop(non_block = false)
65
+ @queue.pop(non_block)
66
+ end
67
+
68
+ def publish(event)
69
+ @mutex.synchronize do
70
+ return if @closed || !@kinds.include?(event.kind)
71
+
72
+ @queue.push(event, true)
73
+ end
74
+ rescue ThreadError
75
+ nil
76
+ end
77
+ end
78
+ end
data/lib/insert_opts.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module River
2
4
  # Options for job insertion, and which can be provided by implementing
3
5
  # #insert_opts on job args, or specified as a parameter on #insert or
@@ -7,6 +9,10 @@ module River
7
9
  # all retries) before a job is abandoned and set as discarded.
8
10
  attr_accessor :max_attempts
9
11
 
12
+ # Arbitrary metadata to merge into the persisted job. River and River Pro
13
+ # reserve keys prefixed with `river:` and workflow metadata keys.
14
+ attr_accessor :metadata
15
+
10
16
  # The priority of the job, with 1 being the highest priority and 4 being the
11
17
  # lowest. When fetching available jobs to work, the highest priority jobs
12
18
  # will always be fetched before any lower priority jobs are fetched. Note
@@ -16,7 +22,7 @@ module River
16
22
  # Defaults to PRIORITY_DEFAULT.
17
23
  attr_accessor :priority
18
24
 
19
- # The name of the job queue in which to insert the job.
25
+ # The name of the job queue in which to insert the job, as a symbol or string.
20
26
  #
21
27
  # Defaults to QUEUE_DEFAULT.
22
28
  attr_accessor :queue
@@ -31,6 +37,10 @@ module River
31
37
  # it will work in both cases.
32
38
  attr_accessor :scheduled_at
33
39
 
40
+ # Initial state. Primarily intended for extensions such as workflows and
41
+ # sequences, which insert blocked jobs as `:pending`. Accepts symbols or strings.
42
+ attr_accessor :state
43
+
34
44
  # An arbitrary list of keywords to add to the job. They have no functional
35
45
  # behavior and are meant entirely as a user-specified construct to help
36
46
  # group and categorize jobs.
@@ -43,18 +53,23 @@ module River
43
53
  # is never treated as unique.
44
54
  attr_accessor :unique_opts
45
55
 
56
+ # Creates options that override the defaults for a single job insertion.
46
57
  def initialize(
47
58
  max_attempts: nil,
59
+ metadata: nil,
48
60
  priority: nil,
49
61
  queue: nil,
50
62
  scheduled_at: nil,
63
+ state: nil,
51
64
  tags: nil,
52
65
  unique_opts: nil
53
66
  )
54
67
  self.max_attempts = max_attempts
68
+ self.metadata = metadata
55
69
  self.priority = priority
56
70
  self.queue = queue
57
71
  self.scheduled_at = scheduled_at
72
+ self.state = state
58
73
  self.tags = tags
59
74
  self.unique_opts = unique_opts
60
75
  end
@@ -62,9 +77,10 @@ module River
62
77
 
63
78
  # Parameters for uniqueness for a job.
64
79
  #
65
- # If all properties are nil, no uniqueness at is enforced. As each property is
80
+ # If all properties are nil, no uniqueness is enforced. As each property is
66
81
  # initialized, it's added as a dimension on the uniqueness matrix, and with
67
- # any property on, the job's kind always counts toward uniqueness.
82
+ # any property on, the job's kind counts toward uniqueness unless #exclude_kind
83
+ # is enabled.
68
84
  #
69
85
  # So for example, if only #by_queue is on, then for the given job kind, only a
70
86
  # single instance is allowed in any given queue, regardless of other
@@ -73,17 +89,24 @@ module River
73
89
  # and queues. If either args or queue is changed on a new job, it's allowed to
74
90
  # be inserted as a new job.
75
91
  class UniqueOpts
92
+ # Empty options don't enable uniqueness. Zero periods and empty state lists
93
+ # have the same meaning as their unset equivalents in the Go client.
94
+ def empty?
95
+ !by_args && !by_queue && !exclude_kind && (!by_period || by_period == 0) && (!by_state || by_state.empty?)
96
+ end
97
+
76
98
  # Indicates that uniqueness should be enforced for any specific instance of
77
- # encoded args for a job.
99
+ # encoded args for a job. An array of symbol or string keys selects only
100
+ # those top-level arguments. Nested arrays select paths, for example
101
+ # +by_args: [[:account, :id], :label]+. Dots in a string key are literal.
78
102
  #
79
103
  # Default is false, meaning that as long as any other unique property is
80
104
  # enabled, uniqueness will be enforced for a kind regardless of input args.
81
105
  attr_accessor :by_args
82
106
 
83
- # Defines uniqueness within a given period. On an insert time is rounded
84
- # down to the nearest multiple of the given period, and a job is only
85
- # inserted if there isn't an existing job that will run between then and the
86
- # next multiple of the period.
107
+ # Defines uniqueness within a given period. The effective scheduled time
108
+ # (or current time for unscheduled jobs) is rounded down to a period boundary
109
+ # in UTC. Equivalent times in different zones produce the same unique key.
87
110
  #
88
111
  # The period should be specified in seconds. So a job that's unique every 15
89
112
  # minute period would have a value of 900.
@@ -108,21 +131,27 @@ module River
108
131
  # Unlike other unique options, ByState gets a default when it's not set for
109
132
  # user convenience. The default is equivalent to:
110
133
  #
111
- # by_state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_COMPLETED, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_RETRYABLE, River::JOB_STATE_SCHEDULED]
134
+ # by_state: %i[available completed pending running retryable scheduled]
112
135
  #
113
- # With this setting, any jobs of the same kind that have been completed or
136
+ # With this setting, any jobs of the same kind that have been cancelled or
114
137
  # discarded, but not yet cleaned out by the system, won't count towards the
115
138
  # uniqueness of a new insert.
116
139
  #
117
140
  # The pending, scheduled, available, and running states are required when
118
- # customizing this list.
141
+ # customizing this list. State names accept symbols or strings.
119
142
  attr_accessor :by_state
120
143
 
121
144
  # Indicates that the job kind should not be considered for uniqueness. This
122
145
  # is useful when you want to enforce uniqueness based on other properties
123
146
  # across multiple worker types.
147
+ #
148
+ # Requires #by_args, #by_queue, or a nonzero #by_period. Otherwise every job
149
+ # would share the same key, so insertion raises ArgumentError. #by_state
150
+ # alone does not supply a key dimension.
124
151
  attr_accessor :exclude_kind
125
152
 
153
+ # Creates a set of dimensions used to determine whether an inserted job is
154
+ # unique.
126
155
  def initialize(
127
156
  by_args: nil,
128
157
  by_period: nil,
data/lib/job.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module River
2
4
  JOB_STATE_AVAILABLE = "available"
3
5
  JOB_STATE_CANCELLED = "cancelled"
@@ -10,36 +12,38 @@ module River
10
12
 
11
13
  # Provides a way of creating a job args from a simple Ruby hash for a quick
12
14
  # way to insert a job without having to define a class. The first argument is
13
- # a "kind" string for identifying the job in the database and the second is a
15
+ # a kind (symbol or string) identifying the job in the database and the second is a
14
16
  # hash that will be encoded to JSON.
15
17
  #
16
18
  # For example:
17
19
  #
18
- # insert_res = client.insert(River::JobArgsHash.new("job_kind", {
20
+ # insert_res = client.insert(River::JobArgsHash.new(:job_kind, {
19
21
  # job_num: 1
20
22
  # }))
21
23
  class JobArgsHash
24
+ # Job kind persisted to the database.
25
+ attr_reader :kind
26
+
27
+ # Creates job arguments with a database kind and JSON-compatible hash.
22
28
  def initialize(kind, hash)
23
- raise "kind should be non-nil" if !kind
24
- raise "hash should be non-nil" if !hash
29
+ raise ArgumentError, "kind should be non-nil" if !kind
30
+ raise ArgumentError, "hash should be non-nil" if !hash
25
31
 
26
- @kind = kind
27
32
  @hash = hash
33
+ @kind = kind.to_s
28
34
  end
29
35
 
30
- attr_reader :kind
31
-
36
+ # Encodes the argument hash for insertion.
32
37
  def to_json
33
- JSON.dump(@hash)
38
+ # JSON.dump reads mutable global options that are inaccessible in Ractors.
39
+ JSON.generate(@hash)
34
40
  end
35
41
  end
36
42
 
37
43
  # JobRow contains the properties of a job that are persisted to the database.
38
44
  class JobRow
39
- # ID of the job. Generated as part of a Postgres sequence and generally
40
- # ascending in nature, but there may be gaps in it as transactions roll
41
- # back.
42
- attr_accessor :id
45
+ # Internal driver/runtime diagnostic, not a persisted column.
46
+ attr_accessor :__decode_error
43
47
 
44
48
  # The job's args as a hash decoded from JSON.
45
49
  attr_accessor :args
@@ -72,6 +76,11 @@ module River
72
76
  # retried.
73
77
  attr_accessor :finalized_at
74
78
 
79
+ # ID of the job. Generated as part of a Postgres sequence and generally
80
+ # ascending in nature, but there may be gaps in it as transactions roll
81
+ # back.
82
+ attr_accessor :id
83
+
75
84
  # Kind uniquely identifies the type of job and instructs which worker
76
85
  # should work it. It is set at insertion time via `#kind` on job args.
77
86
  attr_accessor :kind
@@ -163,7 +172,8 @@ module River
163
172
  # A failed job work attempt containing information about the error or panic
164
173
  # that occurred.
165
174
  class AttemptError
166
- # The time at which the error occurred.
175
+ # The time at which the error occurred. Unreadable historical timestamps
176
+ # become Time.utc(1), matching Go's zero time.
167
177
  attr_accessor :at
168
178
 
169
179
  # The attempt number on which the error occurred (maps to #attempt on a job
@@ -189,5 +199,10 @@ module River
189
199
  self.error = error
190
200
  self.trace = trace
191
201
  end
202
+
203
+ # Returns the database-compatible representation of this attempt error.
204
+ def to_h
205
+ {at: at.utc.iso8601(6), attempt: attempt, error: error, trace: trace}
206
+ end
192
207
  end
193
208
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module River
6
+ module JobPersistedLogging
7
+ # Captures job.logger output and persists it with the attempt's state change,
8
+ # including failed attempts. Uses Go riverlog's metadata format for River UI.
9
+ # Register through Config#plugins, preferably before other work middleware.
10
+ class Plugin
11
+ # Limits are positive byte counts. History is capped at 64 MiB, dropping
12
+ # oldest entries first, but always retaining the newest attempt. An optional
13
+ # block receives a bounded writer and must return a fresh logger per attempt.
14
+ # By default this is a standard Ruby Logger at INFO level.
15
+ def initialize(max_size_bytes: 2 * 1024 * 1024, max_total_bytes: 8 * 1024 * 1024, &logger_factory)
16
+ {max_size_bytes: max_size_bytes, max_total_bytes: max_total_bytes}.each do |name, value|
17
+ raise ArgumentError, "#{name} must be a positive integer" unless value.is_a?(Integer) && value.positive?
18
+ end
19
+
20
+ @logger_factory = logger_factory
21
+ @max_size_bytes = max_size_bytes
22
+ @max_total_bytes = [max_total_bytes, 64 * 1024 * 1024].min
23
+ end
24
+
25
+ # Wraps the work hooks and worker without changing their return value or
26
+ # exception. No extra database round trip is needed to save the log.
27
+ def work(job, operation)
28
+ buffer = Buffer.new(@max_size_bytes)
29
+ factory = @logger_factory
30
+ logger = factory ? factory.call(buffer) : Logger.new(buffer, level: Logger::INFO)
31
+ begin
32
+ job.__with_logger(logger) { operation.call }
33
+ ensure
34
+ persist_log(job, buffer)
35
+ end
36
+ end
37
+
38
+ private def persist_log(job, buffer)
39
+ log, truncated = buffer.finish
40
+ return if log.empty?
41
+
42
+ history = job.metadata.fetch("river:log", [])
43
+ raise TypeError, '"river:log" value is not an array' unless history.is_a?(Array)
44
+
45
+ entries = [{"attempt" => job.row.attempt, "log" => log}]
46
+ size = JSON.generate(entries.first).bytesize + 2 # Array brackets.
47
+ # Walk only the newest suffix that fits; never repeatedly serialize or
48
+ # shift the entire history. Keep the latest entry even if it exceeds the cap.
49
+ history.reverse_each do |entry|
50
+ entry_size = JSON.generate(entry).bytesize + 1 # Separator comma.
51
+ break if size + entry_size > @max_total_bytes
52
+
53
+ entries << entry
54
+ size += entry_size
55
+ end
56
+ job.update_metadata("river:log" => entries.reverse)
57
+
58
+ job.client.config.logger.warn("River job log truncated to #{@max_size_bytes} bytes for job #{job.row.id}") if truncated
59
+ dropped = history.length + 1 - entries.length
60
+ job.client.config.logger.warn("River job log dropped #{dropped} oldest entries for job #{job.row.id}") if dropped.positive?
61
+ rescue JSON::JSONError, TypeError => error
62
+ # Corrupt history must not hide the worker's original error or turn
63
+ # otherwise successful work into a failure.
64
+ job.client.config.logger.error("River job log could not be persisted for job #{job.row.id}: #{error.message}")
65
+ end
66
+
67
+ # A per-attempt, thread-safe writer. Bound memory while writing, rather than
68
+ # accumulating an unbounded StringIO and truncating only when work finishes.
69
+ class Buffer
70
+ def initialize(limit)
71
+ @closed = false
72
+ @data = String.new(encoding: Encoding::BINARY)
73
+ @limit = limit
74
+ @mutex = Mutex.new
75
+ @truncated = false
76
+ end
77
+
78
+ def close
79
+ @mutex.synchronize { @closed = true }
80
+ nil
81
+ end
82
+
83
+ def finish
84
+ @mutex.synchronize do
85
+ @closed = true
86
+ # Drop invalid/incomplete UTF-8 sequences (including a character cut
87
+ # at the byte limit) and NULs, which PostgreSQL JSONB cannot represent.
88
+ [@data.dup.force_encoding(Encoding::UTF_8).scrub("").delete("\0"), @truncated]
89
+ end
90
+ end
91
+
92
+ def write(message)
93
+ message = message.to_s
94
+ @mutex.synchronize do
95
+ unless @closed
96
+ remaining = @limit - @data.bytesize
97
+ chunk = message.byteslice(0, remaining) #: String
98
+ @data << chunk.force_encoding(Encoding::BINARY)
99
+ @truncated ||= message.bytesize > remaining
100
+ end
101
+ end
102
+ message.bytesize
103
+ end
104
+ end
105
+ private_constant :Buffer
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "riverqueue"
5
+
6
+ module River
7
+ # Minimal command-line interface to the Ruby migration API.
8
+ module MigrationCLI
9
+ def self.run(argv, err: $stderr, out: $stdout)
10
+ # @type var database: untyped
11
+ # @type var active_record_base: untyped
12
+ options = {line: "main"} #: Hash[Symbol, untyped]
13
+ parser = OptionParser.new do |args|
14
+ args.banner = "Usage: river migrate-up|migrate-down|migrate-status [options]"
15
+ args.on("--database-url URL", "Defaults to DATABASE_URL") { |value| options[:url] = value }
16
+ args.on("--line NAME", %w[main pro]) { |value| options[:line] = value }
17
+ args.on("--schema NAME") { |value| options[:schema] = value }
18
+ args.on("--steps N", Integer) { |value| options[:steps] = value }
19
+ args.on("--target VERSION", Integer) { |value| options[:target] = value }
20
+ args.on("--dry-run") { options[:dry_run] = true }
21
+ args.on("--yes", "Confirm destructive down migrations") { options[:yes] = true }
22
+ args.on("-h", "--help") {
23
+ out.puts(args)
24
+ return 0
25
+ }
26
+ end
27
+
28
+ args = argv.dup
29
+ parser.parse!(args)
30
+ command = args.shift
31
+ raise ArgumentError, parser.banner unless %w[migrate-up migrate-down migrate-status].include?(command) && args.empty?
32
+
33
+ url = options[:url] || ENV["DATABASE_URL"]
34
+ raise ArgumentError, "provide --database-url or DATABASE_URL" if url.nil? || url.empty?
35
+
36
+ if command == "migrate-down" && !options[:yes] && !options[:dry_run]
37
+ raise ArgumentError, "down migrations may delete data; pass --yes to confirm"
38
+ end
39
+
40
+ adapter = detect_driver
41
+
42
+ if options[:line] == "pro"
43
+ require "riverqueue-pro"
44
+ end
45
+
46
+ migrator_class = (options[:line] == "pro") ? River.const_get(:Pro).const_get(:Migrator) : River::Migrator
47
+ if adapter == "activerecord"
48
+ require "riverqueue-activerecord"
49
+ active_record_base = Object.const_get("ActiveRecord::Base")
50
+ active_record_base.establish_connection(url)
51
+ driver = River::Driver.const_get(:ActiveRecord).new
52
+ else
53
+ require "riverqueue-sequel"
54
+ database = Object.const_get(:Sequel).connect(url)
55
+ driver = River::Driver.const_get(:Sequel).new(database)
56
+ end
57
+
58
+ migrator = migrator_class.new(driver, schema: options[:schema])
59
+ if command == "migrate-status"
60
+ migrator.status.each { |migration| out.puts("#{migration.applied ? "applied" : "pending"} #{migration.version.to_s.rjust(3, "0")} #{migration.name}") }
61
+ else
62
+ migrations = migrator.migrate(direction: (command == "migrate-up") ? :up : :down,
63
+ dry_run: !!options[:dry_run], steps: options[:steps], target: options[:target])
64
+ migrations.each { |migration| out.puts("#{options[:dry_run] ? "planned" : "applied"} #{migration.version.to_s.rjust(3, "0")} #{migration.name}") }
65
+ end
66
+
67
+ 0
68
+ rescue StandardError, LoadError => error
69
+ # Do not echo connection exception messages, which may contain credentials.
70
+ message = (error.is_a?(ArgumentError) || error.is_a?(OptionParser::ParseError) || error.is_a?(River::Error)) ? error.message : error.class.name
71
+ err.puts("Migration failed: #{message}")
72
+ 1
73
+ ensure
74
+ database&.disconnect
75
+ active_record_base&.connection_pool&.disconnect!
76
+ end
77
+
78
+ def self.detect_driver
79
+ driver = %w[sequel activerecord].find do |driver|
80
+ !Gem::Specification.find_all_by_name("riverqueue-#{driver}").empty?
81
+ end
82
+
83
+ unless driver
84
+ raise ArgumentError, "install riverqueue-activerecord or riverqueue-sequel in your bundle to run migrations"
85
+ end
86
+ driver
87
+ end
88
+ private_class_method :detect_driver
89
+ end
90
+ end