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/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
|
|
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
|
|
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.
|
|
84
|
-
#
|
|
85
|
-
#
|
|
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: [
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
31
|
-
|
|
36
|
+
# Encodes the argument hash for insertion.
|
|
32
37
|
def to_json
|
|
33
|
-
JSON.dump
|
|
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
|
-
#
|
|
40
|
-
|
|
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
|