flight_control 2.0.0.beta.1

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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +265 -0
  5. data/Rakefile +6 -0
  6. data/lib/active_job/errors/invalid_operation.rb +5 -0
  7. data/lib/active_job/errors/job_not_found_error.rb +14 -0
  8. data/lib/active_job/errors/query_error.rb +5 -0
  9. data/lib/active_job/executing.rb +41 -0
  10. data/lib/active_job/execution_error.rb +8 -0
  11. data/lib/active_job/failed.rb +7 -0
  12. data/lib/active_job/job_proxy.rb +39 -0
  13. data/lib/active_job/jobs_relation.rb +321 -0
  14. data/lib/active_job/querying.rb +45 -0
  15. data/lib/active_job/queue.rb +63 -0
  16. data/lib/active_job/queue_adapters/solid_queue_ext/recurring_tasks.rb +53 -0
  17. data/lib/active_job/queue_adapters/solid_queue_ext/workers.rb +42 -0
  18. data/lib/active_job/queue_adapters/solid_queue_ext.rb +309 -0
  19. data/lib/active_job/queues.rb +34 -0
  20. data/lib/flight_control/adapter.rb +155 -0
  21. data/lib/flight_control/application.rb +20 -0
  22. data/lib/flight_control/applications.rb +8 -0
  23. data/lib/flight_control/arguments_filter.rb +25 -0
  24. data/lib/flight_control/authentication.rb +68 -0
  25. data/lib/flight_control/console/connect_to.rb +15 -0
  26. data/lib/flight_control/console/context.rb +11 -0
  27. data/lib/flight_control/console/jobs_help.rb +23 -0
  28. data/lib/flight_control/engine.rb +102 -0
  29. data/lib/flight_control/errors/incompatible_adapter.rb +2 -0
  30. data/lib/flight_control/errors/resource_not_found.rb +2 -0
  31. data/lib/flight_control/errors/unsupported_adapter.rb +2 -0
  32. data/lib/flight_control/i18n_config.rb +17 -0
  33. data/lib/flight_control/identified_by_name.rb +18 -0
  34. data/lib/flight_control/identified_elements.rb +24 -0
  35. data/lib/flight_control/server/recurring_tasks.rb +15 -0
  36. data/lib/flight_control/server/serializable.rb +24 -0
  37. data/lib/flight_control/server/workers.rb +13 -0
  38. data/lib/flight_control/server.rb +33 -0
  39. data/lib/flight_control/tasks.rb +6 -0
  40. data/lib/flight_control/version.rb +3 -0
  41. data/lib/flight_control/workers_relation.rb +79 -0
  42. data/lib/flight_control.rb +39 -0
  43. metadata +377 -0
@@ -0,0 +1,321 @@
1
+ # A relation of jobs that can be filtered and acted on.
2
+ #
3
+ # Relations of jobs are normally fetched via +ActiveJob.jobs+
4
+ # or through a given queue (+ActiveJob::Queue#jobs+).
5
+ #
6
+ # This class offers a fluid interface to query a subset of jobs. For
7
+ # example:
8
+ #
9
+ # queue = ActiveJob.queues[:default]
10
+ # queue.jobs.limit(10).where(job_class_name: "DummyJob").last
11
+ #
12
+ # Relations are enumerable, so you can use +Enumerable+ methods on them.
13
+ # Notice however that using these methods will imply loading all the relation
14
+ # in memory, which could introduce performance concerns.
15
+ #
16
+ # Internally, +ActiveJob+ will always use paginated queries to the underlying
17
+ # queue adapter. The page size can be controlled via +config.active_job.default_page_size+
18
+ # (1000 by default).
19
+ #
20
+ # There are additional performance concerns depending on the configured
21
+ # adapter. Please check +ActiveJob::Relation#where+, +ActiveJob::Relation#count+.
22
+ class ActiveJob::JobsRelation
23
+ include Enumerable
24
+
25
+ STATUSES = %i[pending failed in_progress blocked scheduled finished]
26
+ FILTERS = %i[queue_name job_class_name]
27
+
28
+ PROPERTIES = %i[queue_name status offset_value limit_value job_class_name worker_id recurring_task_id finished_at]
29
+ attr_reader(*PROPERTIES, :default_page_size)
30
+
31
+ delegate :last, :[], :reverse, to: :to_a
32
+ delegate :logger, to: FlightControl
33
+
34
+ ALL_JOBS_LIMIT = 100_000_000 # When no limit value it defaults to "all jobs"
35
+
36
+ def initialize(queue_adapter: ActiveJob::Base.queue_adapter, default_page_size: ActiveJob::Base.default_page_size)
37
+ @queue_adapter = queue_adapter
38
+ @default_page_size = default_page_size
39
+
40
+ set_defaults
41
+ end
42
+
43
+ # Returns a +ActiveJob::JobsRelation+ with the configured filtering options.
44
+ #
45
+ # === Options
46
+ #
47
+ # * <tt>:job_class_name</tt> - To only include the jobs of a given class.
48
+ # Depending on the configured queue adapter, this will perform the
49
+ # filtering in memory, which could introduce performance concerns
50
+ # for large sets of jobs.
51
+ # * <tt>:queue_name</tt> - To only include the jobs in the provided queue.
52
+ # * <tt>:worker_id</tt> - To only include the jobs processed by the provided worker.
53
+ # * <tt>:recurring_task_id</tt> - To only include the jobs corresponding to runs of a recurring task.
54
+ # * <tt>:finished_at</tt> - (Range) To only include the jobs finished between the provided range
55
+ def where(job_class_name: nil, queue_name: nil, worker_id: nil, recurring_task_id: nil, finished_at: nil)
56
+ # Remove nil arguments to avoid overriding parameters when concatenating +where+ clauses
57
+ arguments = {job_class_name: job_class_name,
58
+ queue_name: queue_name&.to_s,
59
+ worker_id: worker_id,
60
+ recurring_task_id: recurring_task_id,
61
+ finished_at: finished_at}.compact
62
+
63
+ clone_with(**arguments)
64
+ end
65
+
66
+ def with_status(status)
67
+ if status.to_sym.in? STATUSES
68
+ clone_with status: status.to_sym
69
+ else
70
+ self
71
+ end
72
+ end
73
+
74
+ STATUSES.each do |status|
75
+ define_method status do
76
+ with_status(status)
77
+ end
78
+
79
+ define_method "#{status}?" do
80
+ self.status == status
81
+ end
82
+ end
83
+
84
+ # Sets an offset for the jobs-fetching query. The first position is 0.
85
+ def offset(offset)
86
+ clone_with offset_value: offset
87
+ end
88
+
89
+ # Sets the max number of jobs to fetch in the query.
90
+ def limit(limit)
91
+ clone_with limit_value: limit
92
+ end
93
+
94
+ # Returns the number of jobs in the relation.
95
+ #
96
+ # When filtering jobs, if the adapter doesn't support the filter(s)
97
+ # directly, this will load all the jobs in memory to filter them.
98
+ def count
99
+ if loaded? || filtering_needed?
100
+ to_a.length
101
+ else
102
+ query_count
103
+ end
104
+ end
105
+
106
+ alias_method :length, :count
107
+ alias_method :size, :count
108
+
109
+ def empty?
110
+ count == 0
111
+ end
112
+
113
+ def to_s
114
+ properties_with_values = PROPERTIES.collect do |name|
115
+ value = public_send(name)
116
+ "#{name}: #{value}" unless value.nil?
117
+ end.compact.join(", ")
118
+ "<Jobs with [#{properties_with_values}]> (loaded: #{loaded?})"
119
+ end
120
+
121
+ alias_method :inspect, :to_s
122
+
123
+ def each(&block)
124
+ loaded_jobs&.each(&block) || load_jobs(&block)
125
+ end
126
+
127
+ # Retry all the jobs in the queue.
128
+ #
129
+ # This operation is only valid for sets of failed jobs. It will
130
+ # raise an error +ActiveJob::Errors::InvalidOperation+ otherwise.
131
+ def retry_all
132
+ ensure_failed_status
133
+ queue_adapter.retry_all_jobs(self)
134
+ nil
135
+ end
136
+
137
+ # Retry the provided job.
138
+ #
139
+ # This operation is only valid for sets of failed jobs. It will
140
+ # raise an error +ActiveJob::Errors::InvalidOperation+ otherwise.
141
+ def retry_job(job)
142
+ ensure_failed_status
143
+ queue_adapter.retry_job(job, self)
144
+ end
145
+
146
+ # Discard all the jobs in the relation.
147
+ def discard_all
148
+ queue_adapter.discard_all_jobs(self)
149
+ nil
150
+ end
151
+
152
+ # Discard the provided job.
153
+ def discard_job(job)
154
+ queue_adapter.discard_job(job, self)
155
+ end
156
+
157
+ # Dispatch the provided job.
158
+ #
159
+ # This operation is only valid for blocked or scheduled jobs. It will
160
+ # raise an error +ActiveJob::Errors::InvalidOperation+ otherwise.
161
+ def dispatch_job(job)
162
+ raise ActiveJob::Errors::InvalidOperation, "This operation can only be performed on blocked or scheduled jobs, but this job is #{job.status}" unless job.blocked? || job.scheduled?
163
+
164
+ queue_adapter.dispatch_job(job, self)
165
+ end
166
+
167
+ # Find a job by id.
168
+ #
169
+ # Returns nil when not found.
170
+ def find_by_id(job_id)
171
+ queue_adapter.find_job(job_id, self)
172
+ end
173
+
174
+ # Find a job by id.
175
+ #
176
+ # Raises +ActiveJob::Errors::JobNotFoundError+ when not found.
177
+ def find_by_id!(job_id)
178
+ queue_adapter.find_job(job_id, self) or raise ActiveJob::Errors::JobNotFoundError.new(job_id, self)
179
+ end
180
+
181
+ # Returns an array of jobs class names in the first +from_first+ jobs.
182
+ def job_class_names(from_first: 500)
183
+ first(from_first).collect(&:job_class_name).uniq
184
+ end
185
+
186
+ def reload
187
+ @count = nil
188
+ @loaded_jobs = nil
189
+ @filters = nil
190
+
191
+ self
192
+ end
193
+
194
+ def in_batches(of: default_page_size, order: :asc, &block)
195
+ validate_looping_in_batches_is_possible
196
+
197
+ case order
198
+ when :asc
199
+ in_ascending_batches(of: of, &block)
200
+ when :desc
201
+ in_descending_batches(of: of, &block)
202
+ else
203
+ raise "Unsupported order: #{order}. Valid values: :asc, :desc."
204
+ end
205
+ end
206
+
207
+ def paginated?
208
+ offset_value > 0 || limit_value_provided?
209
+ end
210
+
211
+ def limit_value_provided?
212
+ limit_value.present? && limit_value != ActiveJob::JobsRelation::ALL_JOBS_LIMIT
213
+ end
214
+
215
+ def filtering_needed?
216
+ filters.any?
217
+ end
218
+
219
+ private
220
+
221
+ attr_reader :queue_adapter, :loaded_jobs
222
+ attr_writer(*PROPERTIES)
223
+
224
+ def set_defaults
225
+ self.offset_value = 0
226
+ self.limit_value = ALL_JOBS_LIMIT
227
+ end
228
+
229
+ def clone_with(**properties)
230
+ dup.reload.tap do |relation|
231
+ properties.each do |key, value|
232
+ relation.send("#{key}=", value)
233
+ end
234
+ end
235
+ end
236
+
237
+ def query_count
238
+ @count ||= queue_adapter.jobs_count(self)
239
+ end
240
+
241
+ def load_jobs
242
+ @loaded_jobs = []
243
+ perform_each do |job|
244
+ @loaded_jobs << job
245
+ yield job
246
+ end
247
+ end
248
+
249
+ def perform_each
250
+ current_offset = offset_value
251
+ pending_count = limit_value || Float::INFINITY
252
+
253
+ loop do
254
+ limit = [pending_count, default_page_size].min
255
+ page = offset(current_offset).limit(limit)
256
+ jobs = queue_adapter.fetch_jobs(page)
257
+ finished = jobs.empty?
258
+ jobs = filter(jobs) if filtering_needed?
259
+ Array(jobs).each { |job| yield job }
260
+ current_offset += limit
261
+ pending_count -= jobs.length
262
+ break if finished || pending_count.zero?
263
+ end
264
+ end
265
+
266
+ def loaded?
267
+ !@loaded_jobs.nil?
268
+ end
269
+
270
+ # Filtering for not natively supported filters is performed in memory
271
+ def filter(jobs)
272
+ jobs.filter { |job| satisfy_filter?(job) }
273
+ end
274
+
275
+ def satisfy_filter?(job)
276
+ filters.all? { |property| public_send(property) == job.public_send(property) }
277
+ end
278
+
279
+ def filters
280
+ @filters ||= FILTERS.select { |property| public_send(property).present? && !queue_adapter.supports_job_filter?(self, property) }
281
+ end
282
+
283
+ def ensure_failed_status
284
+ raise ActiveJob::Errors::InvalidOperation, "This operation can only be performed on failed jobs, but these jobs are #{status}" unless failed?
285
+ end
286
+
287
+ def validate_looping_in_batches_is_possible
288
+ raise ActiveJob::Errors::InvalidOperation, "Looping in batches is not compatible with providing offset or limit" if paginated?
289
+ end
290
+
291
+ def in_ascending_batches(of:)
292
+ current_offset = 0
293
+ max = count
294
+ loop do
295
+ page = offset(current_offset).limit(of)
296
+ current_offset += of
297
+ logger.info page
298
+ yield page
299
+ wait_batch_delay
300
+ break if current_offset >= max
301
+ end
302
+ end
303
+
304
+ def in_descending_batches(of:)
305
+ current_offset = count - of
306
+
307
+ loop do
308
+ limit = (current_offset < 0) ? of + current_offset : of
309
+ page = offset([current_offset, 0].max).limit(limit)
310
+ current_offset -= of
311
+ logger.info page
312
+ yield page
313
+ wait_batch_delay
314
+ break if current_offset + of <= 0
315
+ end
316
+ end
317
+
318
+ def wait_batch_delay
319
+ sleep FlightControl.delay_between_bulk_operation_batches if FlightControl.delay_between_bulk_operation_batches.to_i > 0
320
+ end
321
+ end
@@ -0,0 +1,45 @@
1
+ module ActiveJob::Querying
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # ActiveJob will use pagination internally when fetching relations of jobs. This
6
+ # parameter sets the max amount of jobs to fetch in each data store query.
7
+ class_attribute :default_page_size, default: 1000
8
+ end
9
+
10
+ class_methods do
11
+ # Returns the list of queues.
12
+ #
13
+ # See +ActiveJob::Queues+
14
+ def queues
15
+ ActiveJob::Queues.new(fetch_queues)
16
+ end
17
+
18
+ def jobs
19
+ ActiveJob::JobsRelation.new(queue_adapter: queue_adapter, default_page_size: default_page_size)
20
+ end
21
+
22
+ private
23
+
24
+ def fetch_queues
25
+ queue_adapter.queues.collect do |queue|
26
+ ActiveJob::Queue.new(queue[:name], size: queue[:size], active: queue[:active], queue_adapter: queue_adapter)
27
+ end
28
+ end
29
+ end
30
+
31
+ def queue
32
+ self.class.queues[queue_name]
33
+ end
34
+
35
+ # Top-level query methods added to `ActiveJob`
36
+ module Root
37
+ def queues
38
+ ActiveJob::Base.queues
39
+ end
40
+
41
+ def jobs
42
+ ActiveJob::Base.jobs
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,63 @@
1
+ # A queue of jobs
2
+ class ActiveJob::Queue
3
+ attr_reader :name
4
+
5
+ def initialize(name, size: nil, active: nil, queue_adapter: ActiveJob::Base.queue_adapter)
6
+ @name = name
7
+ @queue_adapter = queue_adapter
8
+
9
+ @size = size
10
+ @active = active
11
+ end
12
+
13
+ def size
14
+ @size ||= queue_adapter.queue_size(name)
15
+ end
16
+
17
+ alias_method :length, :size
18
+
19
+ def clear
20
+ queue_adapter.clear_queue(name)
21
+ end
22
+
23
+ def empty?
24
+ size == 0
25
+ end
26
+
27
+ def pause
28
+ queue_adapter.pause_queue(name)
29
+ end
30
+
31
+ def resume
32
+ queue_adapter.resume_queue(name)
33
+ end
34
+
35
+ def paused?
36
+ !active?
37
+ end
38
+
39
+ def active?
40
+ return @active unless @active.nil?
41
+ @active = !queue_adapter.queue_paused?(name)
42
+ end
43
+
44
+ # Return an +ActiveJob::JobsRelation+ with the pending jobs in the queue.
45
+ def jobs
46
+ ActiveJob::JobsRelation.new(queue_adapter: queue_adapter).pending.where(queue_name: name)
47
+ end
48
+
49
+ def reload
50
+ @active = @size = nil
51
+ self
52
+ end
53
+
54
+ def id
55
+ name.parameterize
56
+ end
57
+
58
+ alias_method :to_param, :id
59
+
60
+ private
61
+
62
+ attr_reader :queue_adapter
63
+ end
@@ -0,0 +1,53 @@
1
+ module ActiveJob::QueueAdapters::SolidQueueExt::RecurringTasks
2
+ def supports_recurring_tasks?
3
+ true
4
+ end
5
+
6
+ def recurring_tasks
7
+ tasks = SolidQueue::RecurringTask.all
8
+ last_enqueued_at_times = recurring_task_last_enqueued_at(tasks.map(&:key))
9
+
10
+ tasks.collect do |task|
11
+ recurring_task_attributes_from_solid_queue_recurring_task(task).merge \
12
+ last_enqueued_at: last_enqueued_at_times[task.key]
13
+ end
14
+ end
15
+
16
+ def find_recurring_task(task_id)
17
+ if (task = SolidQueue::RecurringTask.find_by(key: task_id))
18
+ recurring_task_attributes_from_solid_queue_recurring_task(task).merge \
19
+ last_enqueued_at: recurring_task_last_enqueued_at(task.key).values&.first
20
+ end
21
+ end
22
+
23
+ def enqueue_recurring_task(task_id)
24
+ if (task = SolidQueue::RecurringTask.find_by(key: task_id))
25
+ task.enqueue(at: Time.now)
26
+ end
27
+ end
28
+
29
+ def can_enqueue_recurring_task?(task_id)
30
+ if (task = SolidQueue::RecurringTask.find_by(key: task_id))
31
+ task.valid?
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def recurring_task_attributes_from_solid_queue_recurring_task(task)
38
+ {
39
+ id: task.key,
40
+ job_class_name: task.class_name,
41
+ command: task.command,
42
+ arguments: task.arguments,
43
+ schedule: task.schedule,
44
+ next_time: task.next_time,
45
+ queue_name: task.queue_name,
46
+ priority: task.priority
47
+ }
48
+ end
49
+
50
+ def recurring_task_last_enqueued_at(task_keys)
51
+ SolidQueue::RecurringExecution.where(task_key: task_keys).group(:task_key).maximum(:run_at)
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ module ActiveJob::QueueAdapters::SolidQueueExt::Workers
2
+ def exposes_workers?
3
+ true
4
+ end
5
+
6
+ def fetch_workers(workers_relation)
7
+ solid_queue_processes_from_workers_relation(workers_relation).collect do |process|
8
+ worker_from_solid_queue_process(process)
9
+ end
10
+ end
11
+
12
+ def count_workers(workers_relation)
13
+ solid_queue_processes_from_workers_relation(workers_relation).count
14
+ end
15
+
16
+ def find_worker(worker_id)
17
+ if (process = SolidQueue::Process.find_by(id: worker_id))
18
+ worker_attributes_from_solid_queue_process(process)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def solid_queue_processes_from_workers_relation(relation)
25
+ SolidQueue::Process.where(kind: "Worker").offset(relation.offset_value).limit(relation.limit_value)
26
+ end
27
+
28
+ def worker_from_solid_queue_process(process)
29
+ FlightControl::Worker.new(queue_adapter: self, **worker_attributes_from_solid_queue_process(process))
30
+ end
31
+
32
+ def worker_attributes_from_solid_queue_process(process)
33
+ {
34
+ id: process.id,
35
+ name: "PID: #{process.pid}",
36
+ hostname: process.hostname,
37
+ last_heartbeat_at: process.last_heartbeat_at,
38
+ configuration: process.metadata,
39
+ raw_data: process.as_json
40
+ }
41
+ end
42
+ end