riverqueue 0.11.0 → 0.13.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -0
  3. data/LICENSE +374 -0
  4. data/README.md +22 -0
  5. data/docs/README.md +812 -0
  6. data/docs/conformance.md +72 -0
  7. data/docs/migrations.md +118 -0
  8. data/docs/testing.md +173 -0
  9. data/docs/workers.md +126 -0
  10. data/exe/river +7 -0
  11. data/lib/cli.rb +55 -0
  12. data/lib/client.rb +347 -54
  13. data/lib/client_runtime.rb +721 -0
  14. data/lib/config.rb +154 -0
  15. data/lib/driver/job_row_decoder.rb +85 -0
  16. data/lib/driver/postgres_capabilities.rb +41 -0
  17. data/lib/driver/runtime.rb +772 -0
  18. data/lib/driver.rb +13 -6
  19. data/lib/errors.rb +83 -0
  20. data/lib/event.rb +85 -0
  21. data/lib/insert_opts.rb +46 -12
  22. data/lib/job.rb +28 -13
  23. data/lib/job_persisted_logging/plugin.rb +108 -0
  24. data/lib/migration_cli.rb +90 -0
  25. data/lib/migrator.rb +189 -0
  26. data/lib/params.rb +96 -0
  27. data/lib/periodic_cron.rb +59 -0
  28. data/lib/periodic_job.rb +143 -0
  29. data/lib/resumable.rb +148 -0
  30. data/lib/riverqueue/testing/minitest.rb +18 -0
  31. data/lib/riverqueue/testing/rspec.rb +109 -0
  32. data/lib/riverqueue/testing.rb +134 -0
  33. data/lib/riverqueue.rb +16 -0
  34. data/lib/unique_args.rb +97 -0
  35. data/lib/unique_bitmask.rb +14 -12
  36. data/lib/worker.rb +152 -0
  37. data/lib/worker_runner.rb +106 -0
  38. data/migration/LICENSE +374 -0
  39. data/migration/README.md +13 -0
  40. data/migration/manifest.json +37 -0
  41. data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
  42. data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
  43. data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
  44. data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
  45. data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
  46. data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
  47. data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
  48. data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
  49. data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
  50. data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
  51. data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
  52. data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
  53. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
  54. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
  55. data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
  56. data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
  57. data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
  58. data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
  59. data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
  60. data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
  61. data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
  62. data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
  63. data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
  64. data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
  65. data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
  66. data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
  67. data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
  68. data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
  69. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
  70. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
  71. data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
  72. data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
  73. data/sig/client.rbs +98 -0
  74. data/sig/driver.rbs +172 -0
  75. data/sig/insert_opts.rbs +25 -0
  76. data/sig/job.rbs +74 -0
  77. data/sig/job_persisted_logging/plugin.rbs +29 -0
  78. data/sig/migrator.rbs +44 -0
  79. data/sig/riverqueue.rbs +2 -0
  80. data/sig/runtime.rbs +402 -0
  81. data/sig/testing.rbs +95 -0
  82. data/sig/unique_args.rbs +8 -0
  83. data/sig/unique_bitmask.rbs +8 -0
  84. data/sig/worker_runner.rbs +22 -0
  85. metadata +170 -13
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,85 @@
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, in seconds.
16
+ # run_duration measures worker execution including work hooks;
17
+ # complete_duration measures finalization after execution finishes.
18
+ # queue_wait_duration uses the schedule at the start of execution, before
19
+ # retries or snoozes change it.
20
+ # Execution and completion durations use a monotonic clock. Externally
21
+ # executed jobs have zero run_duration because this runtime does not observe
22
+ # their execution; their queue wait ends at the row's attempted_at timestamp.
23
+ JobStatistics = Data.define(:complete_duration, :queue_wait_duration, :run_duration)
24
+
25
+ # Bounded stream of selected Client lifecycle events.
26
+ class Subscription
27
+ # Creates an event subscription. Applications normally receive instances
28
+ # from Client#subscribe.
29
+ def initialize(kinds, buffer_size: 100, on_close: nil)
30
+ @closed = false
31
+ @kinds = kinds.map(&:to_sym).freeze
32
+ @mutex = Mutex.new
33
+ @on_close = on_close
34
+ @queue = SizedQueue.new(buffer_size)
35
+ end
36
+
37
+ # Closes the subscription and releases it from its client. Closing more than
38
+ # once is safe. Buffered events remain readable; blocked readers wake once
39
+ # the buffer is drained.
40
+ def close
41
+ on_close = @mutex.synchronize do
42
+ return if @closed
43
+
44
+ @closed = true
45
+ @queue.close
46
+ @on_close.tap { @on_close = nil }
47
+ end
48
+
49
+ on_close&.call(self)
50
+ nil
51
+ end
52
+
53
+ # Yields events as they arrive until the subscription is closed. Returns an
54
+ # Enumerator when no block is given.
55
+ def each
56
+ return enum_for(:each) unless block_given?
57
+
58
+ loop do
59
+ event = @queue.pop
60
+ break if event.nil?
61
+
62
+ yield event
63
+ end
64
+ end
65
+
66
+ # Removes and returns the next queued value, blocking unless +non_block+ is
67
+ # true. A blocking pop returns nil once the subscription is closed and its
68
+ # buffered events have been drained.
69
+ #
70
+ # A non-blocking pop raises ThreadError when no event is available.
71
+ def pop(non_block = false)
72
+ @queue.pop(non_block)
73
+ end
74
+
75
+ def publish(event)
76
+ @mutex.synchronize do
77
+ return if @closed || !@kinds.include?(event.kind)
78
+
79
+ @queue.push(event, true)
80
+ end
81
+ rescue ThreadError
82
+ nil
83
+ end
84
+ end
85
+ 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
@@ -5,8 +7,14 @@ module River
5
7
  class InsertOpts
6
8
  # The maximum number of total attempts (including both the original run and
7
9
  # all retries) before a job is abandoned and set as discarded.
10
+ # Must be between 1 and MAX_ATTEMPTS_LIMIT (32,767).
11
+ # Defaults to MAX_ATTEMPTS_DEFAULT.
8
12
  attr_accessor :max_attempts
9
13
 
14
+ # Arbitrary metadata to merge into the persisted job. River and River Pro
15
+ # reserve keys prefixed with `river:` and workflow metadata keys.
16
+ attr_accessor :metadata
17
+
10
18
  # The priority of the job, with 1 being the highest priority and 4 being the
11
19
  # lowest. When fetching available jobs to work, the highest priority jobs
12
20
  # will always be fetched before any lower priority jobs are fetched. Note
@@ -16,7 +24,8 @@ module River
16
24
  # Defaults to PRIORITY_DEFAULT.
17
25
  attr_accessor :priority
18
26
 
19
- # The name of the job queue in which to insert the job.
27
+ # The name of the job queue in which to insert the job, as a symbol or string.
28
+ # Must contain 1 to 127 ASCII letters, digits, underscores, hyphens, colons, or periods.
20
29
  #
21
30
  # Defaults to QUEUE_DEFAULT.
22
31
  attr_accessor :queue
@@ -31,6 +40,11 @@ module River
31
40
  # it will work in both cases.
32
41
  attr_accessor :scheduled_at
33
42
 
43
+ # Initial state. Primarily intended for extensions such as workflows and
44
+ # sequences, which insert blocked jobs as `:pending`. Accepts only
45
+ # `available`, `pending`, or `scheduled`, as symbols or strings.
46
+ attr_accessor :state
47
+
34
48
  # An arbitrary list of keywords to add to the job. They have no functional
35
49
  # behavior and are meant entirely as a user-specified construct to help
36
50
  # group and categorize jobs.
@@ -43,18 +57,23 @@ module River
43
57
  # is never treated as unique.
44
58
  attr_accessor :unique_opts
45
59
 
60
+ # Creates options that override the defaults for a single job insertion.
46
61
  def initialize(
47
62
  max_attempts: nil,
63
+ metadata: nil,
48
64
  priority: nil,
49
65
  queue: nil,
50
66
  scheduled_at: nil,
67
+ state: nil,
51
68
  tags: nil,
52
69
  unique_opts: nil
53
70
  )
54
71
  self.max_attempts = max_attempts
72
+ self.metadata = metadata
55
73
  self.priority = priority
56
74
  self.queue = queue
57
75
  self.scheduled_at = scheduled_at
76
+ self.state = state
58
77
  self.tags = tags
59
78
  self.unique_opts = unique_opts
60
79
  end
@@ -62,9 +81,10 @@ module River
62
81
 
63
82
  # Parameters for uniqueness for a job.
64
83
  #
65
- # If all properties are nil, no uniqueness at is enforced. As each property is
84
+ # If all properties are nil, no uniqueness is enforced. As each property is
66
85
  # 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.
86
+ # any property on, the job's kind counts toward uniqueness unless #exclude_kind
87
+ # is enabled.
68
88
  #
69
89
  # So for example, if only #by_queue is on, then for the given job kind, only a
70
90
  # single instance is allowed in any given queue, regardless of other
@@ -73,20 +93,28 @@ module River
73
93
  # and queues. If either args or queue is changed on a new job, it's allowed to
74
94
  # be inserted as a new job.
75
95
  class UniqueOpts
96
+ # Empty options don't enable uniqueness. Zero periods and empty state lists
97
+ # have the same meaning as their unset equivalents in the Go client.
98
+ def empty?
99
+ !by_args && !by_queue && !exclude_kind && (!by_period || by_period == 0) && (!by_state || by_state.empty?)
100
+ end
101
+
76
102
  # Indicates that uniqueness should be enforced for any specific instance of
77
- # encoded args for a job.
103
+ # encoded args for a job. An array of symbol or string keys selects only
104
+ # those top-level arguments. Nested arrays select paths, for example
105
+ # +by_args: [[:account, :id], :label]+. Dots in a string key are literal.
78
106
  #
79
107
  # Default is false, meaning that as long as any other unique property is
80
108
  # enabled, uniqueness will be enforced for a kind regardless of input args.
81
109
  attr_accessor :by_args
82
110
 
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.
111
+ # Defines uniqueness within a given period. The effective scheduled time
112
+ # (or current time for unscheduled jobs) is rounded down to a period boundary
113
+ # in UTC. Equivalent times in different zones produce the same unique key.
87
114
  #
88
115
  # The period should be specified in seconds. So a job that's unique every 15
89
- # minute period would have a value of 900.
116
+ # minute period would have a value of 900. Nonzero periods must be at least
117
+ # one second; zero disables period-based uniqueness.
90
118
  #
91
119
  # Default is no unique period, meaning that as long as any other unique
92
120
  # property is enabled, uniqueness will be enforced across all jobs of the
@@ -108,21 +136,27 @@ module River
108
136
  # Unlike other unique options, ByState gets a default when it's not set for
109
137
  # user convenience. The default is equivalent to:
110
138
  #
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]
139
+ # by_state: %i[available completed pending running retryable scheduled]
112
140
  #
113
- # With this setting, any jobs of the same kind that have been completed or
141
+ # With this setting, any jobs of the same kind that have been cancelled or
114
142
  # discarded, but not yet cleaned out by the system, won't count towards the
115
143
  # uniqueness of a new insert.
116
144
  #
117
145
  # The pending, scheduled, available, and running states are required when
118
- # customizing this list.
146
+ # customizing this list. State names accept symbols or strings.
119
147
  attr_accessor :by_state
120
148
 
121
149
  # Indicates that the job kind should not be considered for uniqueness. This
122
150
  # is useful when you want to enforce uniqueness based on other properties
123
151
  # across multiple worker types.
152
+ #
153
+ # Requires #by_args, #by_queue, or a nonzero #by_period. Otherwise every job
154
+ # would share the same key, so insertion raises ArgumentError. #by_state
155
+ # alone does not supply a key dimension.
124
156
  attr_accessor :exclude_kind
125
157
 
158
+ # Creates a set of dimensions used to determine whether an inserted job is
159
+ # unique.
126
160
  def initialize(
127
161
  by_args: nil,
128
162
  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.getutc.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