scatter_gather 0.1.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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/instructions.mdc +21 -0
  3. data/.github/dependabot.yml +12 -0
  4. data/.github/workflows/ci.yml +55 -0
  5. data/CHANGELOG.md +7 -0
  6. data/Gemfile +11 -0
  7. data/LICENSE.md +21 -0
  8. data/README.md +86 -0
  9. data/Rakefile +32 -0
  10. data/bin/console +11 -0
  11. data/bin/setup +8 -0
  12. data/bin/test +5 -0
  13. data/lib/generators/install_generator.rb +33 -0
  14. data/lib/generators/scatter_gather_migration_001.rb.erb +16 -0
  15. data/lib/scatter_gather/version.rb +5 -0
  16. data/lib/scatter_gather.rb +196 -0
  17. data/lib/tasks/scatter_gather_tasks.rake +8 -0
  18. data/rbi/scatter_gather.rbi +135 -0
  19. data/scatter_gather-0.1.19.gem +0 -0
  20. data/scatter_gather.gemspec +46 -0
  21. data/sig/scatter_gather.rbs +116 -0
  22. data/test/dummy/Rakefile +8 -0
  23. data/test/dummy/app/assets/stylesheets/application.css +1 -0
  24. data/test/dummy/app/controllers/application_controller.rb +6 -0
  25. data/test/dummy/app/helpers/application_helper.rb +4 -0
  26. data/test/dummy/app/jobs/application_job.rb +9 -0
  27. data/test/dummy/app/mailers/application_mailer.rb +6 -0
  28. data/test/dummy/app/models/application_record.rb +5 -0
  29. data/test/dummy/app/views/layouts/application.html.erb +27 -0
  30. data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  31. data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  32. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  33. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  34. data/test/dummy/bin/dev +2 -0
  35. data/test/dummy/bin/rails +4 -0
  36. data/test/dummy/bin/rake +4 -0
  37. data/test/dummy/bin/setup +34 -0
  38. data/test/dummy/config/application.rb +28 -0
  39. data/test/dummy/config/boot.rb +7 -0
  40. data/test/dummy/config/cable.yml +10 -0
  41. data/test/dummy/config/database.sqlite3.yml +32 -0
  42. data/test/dummy/config/database.yml +32 -0
  43. data/test/dummy/config/environment.rb +7 -0
  44. data/test/dummy/config/environments/development.rb +71 -0
  45. data/test/dummy/config/environments/production.rb +91 -0
  46. data/test/dummy/config/environments/test.rb +55 -0
  47. data/test/dummy/config/initializers/content_security_policy.rb +27 -0
  48. data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
  49. data/test/dummy/config/initializers/inflections.rb +18 -0
  50. data/test/dummy/config/locales/en.yml +31 -0
  51. data/test/dummy/config/puma.rb +40 -0
  52. data/test/dummy/config/routes.rb +16 -0
  53. data/test/dummy/config/storage.yml +34 -0
  54. data/test/dummy/config.ru +8 -0
  55. data/test/dummy/db/migrate/20250101000001_add_scatter_gather_completions.rb +16 -0
  56. data/test/dummy/db/schema.rb +23 -0
  57. data/test/dummy/public/400.html +114 -0
  58. data/test/dummy/public/404.html +114 -0
  59. data/test/dummy/public/406-unsupported-browser.html +114 -0
  60. data/test/dummy/public/422.html +114 -0
  61. data/test/dummy/public/500.html +114 -0
  62. data/test/dummy/public/icon.png +0 -0
  63. data/test/dummy/public/icon.svg +3 -0
  64. data/test/scatter_gather_test.rb +180 -0
  65. data/test/test_helper.rb +17 -0
  66. metadata +285 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 423f509998336fa071a30240b18544524dbb9ee711c1250e3daf597c10bd9993
4
+ data.tar.gz: 14b5c6c4084f4c9c9dc440ab4c015e85f67695b6512ba1a23dc94e477bdaca6c
5
+ SHA512:
6
+ metadata.gz: df506e118824334796d6f2da4464672740bbfc376a58a29e754f7224958bf369983ca253f7e38887bbcbdab1f1159616d31790bbdefe6f0ed4f216f8b7d522dd
7
+ data.tar.gz: a47b8afba198ff4ab3428ed11fdbb4469f83ab45139e8f8dcf2f29261c0c144f54d38c26181eff9a74c6d694d72d01f6e505c744a1451a867543102a13cc838b
@@ -0,0 +1,21 @@
1
+ ---
2
+ alwaysApply: true
3
+ ---
4
+
5
+ ## Linting
6
+
7
+ * Run standardrb --fix-unsafely on any .rb file you create or make edits to
8
+
9
+
10
+ ## Commit message titles
11
+
12
+ * Do not use feat/chore prefixes for commit message titles
13
+
14
+ ## Type definitions
15
+
16
+ * Do not update type definitions (rbi and rbs files) they get updated automatically on every gem build
17
+
18
+ ## Comments
19
+
20
+ * Add generous YARD comments to the public methods you add. Add type definitions for parameters and return values
21
+ * Specify @return [void] for methods that return no value / undefined value
@@ -0,0 +1,12 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: bundler
4
+ directory: "/"
5
+ schedule:
6
+ interval: daily
7
+ open-pull-requests-limit: 10
8
+ - package-ecosystem: github-actions
9
+ directory: "/"
10
+ schedule:
11
+ interval: daily
12
+ open-pull-requests-limit: 10
@@ -0,0 +1,55 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [ main ]
7
+
8
+ jobs:
9
+ lint:
10
+ name: "Lint (standardrb)"
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v5
15
+
16
+ - name: Set up Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: 3.2.2
20
+ bundler-cache: true
21
+
22
+ - name: Lint code for consistent style
23
+ run: bundle exec standardrb
24
+
25
+ test:
26
+ name: "Tests (SQLite)"
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - name: Checkout code
30
+ uses: actions/checkout@v5
31
+
32
+ - name: Set up Ruby
33
+ uses: ruby/setup-ruby@v1
34
+ with:
35
+ ruby-version: 3.2.2
36
+ bundler-cache: true
37
+
38
+ - name: Remove existing schema.rb
39
+ run: rm -f test/dummy/db/schema.rb
40
+
41
+ - name: Setup database
42
+ env:
43
+ RAILS_ENV: test
44
+ DATABASE_URL: sqlite3:db/test.sqlite3
45
+ run: |
46
+ cd test/dummy
47
+ bundle exec rails db:create
48
+ bundle exec rails db:migrate
49
+ cd ../..
50
+
51
+ - name: Run tests
52
+ env:
53
+ RAILS_ENV: test
54
+ DATABASE_URL: sqlite3:db/test.sqlite3
55
+ run: bin/test
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## [0.1.0] - 2025-09-30
6
+
7
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "puma"
8
+ gem "sqlite3"
9
+
10
+ # Start debugger with binding.b [https://github.com/ruby/debug]
11
+ # gem "debug", ">= 1.0.0"
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Julik Tarkhanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # scatter_gather
2
+
3
+ A Ruby gem that provides a scatter-gather pattern for coordinating ActiveJob execution. Jobs can wait for other jobs to complete before executing, with configurable polling, retry, and timeout behavior.
4
+
5
+ ## Usage
6
+
7
+ Start some scatter jobs and create a gather job that waits for all dependencies to complete:
8
+
9
+ ```ruby
10
+ class EmailProcessorJob < ActiveJob::Base
11
+ include ScatterGather
12
+
13
+ def perform(email_id)
14
+ # Process email
15
+ end
16
+ end
17
+
18
+ class AttachmentProcessorJob < ActiveJob::Base
19
+ include ScatterGather
20
+
21
+ def perform(email_id)
22
+ # Process attachments
23
+ end
24
+ end
25
+
26
+ class AICategorizerJob < ActiveJob::Base
27
+ include ScatterGather
28
+
29
+ def perform(email_id)
30
+ # Categorize email with AI
31
+ end
32
+ end
33
+
34
+ class NotifyCompleteJob < ActiveJob::Base
35
+ include ScatterGather
36
+
37
+ def perform(email_id)
38
+ # Notify that all processing is complete
39
+ end
40
+ end
41
+
42
+ # Start the scatter jobs
43
+ email_parser_job = EmailProcessorJob.perform_later(email_id: 123)
44
+ attachment_processor_job = AttachmentProcessorJob.perform_later(email_id: 123)
45
+ ai_categorizer_job = AICategorizerJob.perform_later(email_id: 123)
46
+
47
+ # Create a gather job that waits for all dependencies to complete
48
+ NotifyCompleteJob.gather(email_parser_job, attachment_processor_job, ai_categorizer_job).perform_later(email_id: 123)
49
+ ```
50
+
51
+ The gather job will:
52
+ - Check if all dependencies are complete
53
+ - If complete: enqueue the target job immediately
54
+ - If not complete: poll every 2 seconds (configurable), re-enqueuing itself
55
+ - After 10 attempts (configurable): discard with error reporting
56
+
57
+ ### Configuration Options
58
+
59
+ - `max_attempts`: Number of polling attempts before giving up (default: 10)
60
+ - `poll_interval`: Time between polling attempts (default: 2.seconds)
61
+
62
+ ```ruby
63
+ # Example with custom configuration
64
+ TouchingJob.gather(jobs, poll_interval: 0.2.seconds, max_attempts: 4).perform_later(final_path)
65
+ ```
66
+
67
+ ## Installation
68
+
69
+ Add the gem to the application's Gemfile, and then generate and run the migration:
70
+
71
+ $ bundle add scatter_gather
72
+ $ bundle install
73
+ $ bin/rails g scatter_gather:install
74
+ $ bin/rails db:migrate
75
+
76
+ ## Development
77
+
78
+ After checking out the repo, run `bundle` to install dependencies. The development process from there on is like any other gem.
79
+
80
+ ## License
81
+
82
+ This gem is made available under the MIT license
83
+
84
+ ## Contributing
85
+
86
+ Bug reports and pull requests are welcome on GitHub at https://github.com/julik/scatter_gather.
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ Bundler::GemHelper.install_tasks(name: "scatter_gather")
5
+ require "rake/testtask"
6
+ require "standard/rake"
7
+ require "yard"
8
+
9
+ YARD::Rake::YardocTask.new(:doc)
10
+
11
+ Rake::TestTask.new(:test) do |t|
12
+ t.libs << "test"
13
+ t.libs << "lib"
14
+ t.test_files = FileList["test/**/*_test.rb"]
15
+ t.warning = false # To avoid any warnings from dependencies
16
+ end
17
+
18
+ task :format do
19
+ `bundle exec standardrb --fix`
20
+ `bundle exec magic_frozen_string_literal .`
21
+ end
22
+
23
+ task :generate_typedefs do
24
+ `bundle exec sord rbi/scatter_gather.rbi`
25
+ `bundle exec sord sig/scatter_gather.rbs`
26
+ end
27
+
28
+ task default: [:test, :standard, :generate_typedefs]
29
+
30
+ # When building the gem, generate typedefs beforehand,
31
+ # so that they get included
32
+ Rake::Task["build"].enhance(["generate_typedefs"])
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "scatter_gather"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require "irb"
11
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/bin/test ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ $: << File.expand_path("../test", __dir__)
3
+
4
+ require "bundler/setup"
5
+ require "rails/plugin/test"
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module ScatterGather
7
+ # The generator is used to install ScatterGather. It adds the migration that creates
8
+ # the scatter_gather_completions table.
9
+ # Run it with `bin/rails g scatter_gather:install` in your console.
10
+ class InstallGenerator < Rails::Generators::Base
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_paths << File.join(File.dirname(__FILE__, 2))
14
+
15
+ # Generates migration file that creates the scatter_gather_completions table.
16
+ def create_migration_file
17
+ # Migration files are named "...migration_001.rb" etc. This allows them to be emitted
18
+ # as they get added, and the order of the migrations can be controlled using predictable sorting.
19
+ # Adding a new migration to the gem is then just adding a file.
20
+ migration_file_paths_in_order = Dir.glob(__dir__ + "/*_migration_*.rb.erb").sort
21
+ migration_file_paths_in_order.each do |migration_template_path|
22
+ untemplated_migration_filename = File.basename(migration_template_path).gsub(/\.erb$/, "")
23
+ migration_template(migration_template_path, File.join(db_migrate_path, untemplated_migration_filename))
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def migration_version
30
+ ActiveRecord::VERSION::STRING.split(".").take(2).join(".")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ class AddScatterGatherCompletions < ActiveRecord::Migration[<%= migration_version %>]
2
+ def up
3
+ create_table :scatter_gather_completions do |t|
4
+ t.string :active_job_id, null: false
5
+ t.string :active_job_class_name
6
+ t.string :status, default: "unknown"
7
+ t.timestamps
8
+ end
9
+ add_index :scatter_gather_completions, [:active_job_id], unique: true # For lookups
10
+ add_index :scatter_gather_completions, [:created_at] # For cleanup
11
+ end
12
+
13
+ def down
14
+ drop_table :scatter_gather_completions
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScatterGather
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_record"
5
+ require "active_job"
6
+ require "json"
7
+
8
+ # Scatter-Gather Pattern for ActiveJob
9
+ #
10
+ # This module provides a scatter-gather pattern for coordinating job execution.
11
+ # Jobs can wait for other jobs to complete before executing, with configurable
12
+ # polling, retry, and timeout behavior.
13
+ #
14
+ # Example workflow:
15
+ # # Start some scatter jobs
16
+ # email_parser_job = EmailParserJob.perform_later(email_id: 123)
17
+ # attachment_processor_job = AttachmentProcessorJob.perform_later(email_id: 123)
18
+ # ai_categorizer_job = AICategorizerJob.perform_later(email_id: 123)
19
+ #
20
+ # # Create a gather job that waits for all dependencies to complete
21
+ # NotifyCompleteJob.gather(email_parser_job, attachment_processor_job, ai_categorizer_job).perform_later
22
+ #
23
+ # The gather job will:
24
+ # - Check if all dependencies are complete
25
+ # - If complete: enqueue the target job immediately
26
+ # - If not complete: poll every 2 seconds (configurable), re-enqueuing itself
27
+ # - After 10 attempts (configurable): discard with error reporting
28
+ #
29
+ # Configuration options:
30
+ # - max_attempts: Number of polling attempts before giving up (default: 10)
31
+ # - poll_interval: Time between polling attempts (default: 2.seconds)
32
+ #
33
+ # Example with custom configuration:
34
+ # TouchingJob.gather(jobs, poll_interval: 0.2.seconds, max_attempts: 4).perform_later(final_path)
35
+ module ScatterGather
36
+ extend ActiveSupport::Concern
37
+
38
+ class Completion < ActiveRecord::Base
39
+ self.table_name = "scatter_gather_completions"
40
+
41
+ def self.collect_statuses(active_job_ids)
42
+ statuses = active_job_ids.map { |it| [it, :unknown] }.to_h
43
+ statuses_from_completions = where(active_job_id: active_job_ids)
44
+ .pluck(:active_job_id, :status)
45
+ .map do |(id, st)|
46
+ [id, st.to_sym]
47
+ end.to_h
48
+ statuses.merge!(statuses_from_completions)
49
+ end
50
+ end
51
+
52
+ # Default configuration for gather jobs
53
+ DEFAULT_GATHER_CONFIG = {
54
+ max_attempts: 10,
55
+ poll_interval: 2.seconds
56
+ }.freeze
57
+
58
+ # Proxy class that mimics ActiveJob behavior for gather jobs
59
+ class GatherJobProxy
60
+ def initialize(target_class, ids, config)
61
+ @target_class = target_class
62
+ @ids = ids
63
+ @config = config.with_indifferent_access
64
+ end
65
+
66
+ # Mimic ActiveJob's perform_later method
67
+ # @param args [Array] Positional arguments to pass to the target job's perform method
68
+ # @param kwargs [Hash] Keyword arguments to pass to the target job's perform method
69
+ # @return [void] Enqueues the gather job
70
+ def perform_later(*args, **kwargs)
71
+ job_arguments = {cn: @target_class.name, p: args, k: kwargs}
72
+ gather_job_params = {
73
+ wait_for_active_job_ids: @ids,
74
+ target_job: job_arguments,
75
+ gather_config: @config,
76
+ remaining_attempts: @config.fetch(:max_attempts) - 1
77
+ }
78
+ tagged = ActiveSupport::TaggedLogging.new(Rails.logger).tagged("ScatterGather")
79
+ tagged.info { "Enqueueing gather job waiting for #{@ids.inspect} to run a #{@target_class.name} after" }
80
+ GatherJob.perform_later(**gather_job_params)
81
+ end
82
+ end
83
+
84
+ # Custom exception for when gather job exhausts attempts
85
+ class DependencyTimeoutError < StandardError
86
+ attr_reader :dependency_status
87
+
88
+ def initialize(max_attempts, dependency_status)
89
+ @dependency_status = dependency_status
90
+ super(<<~MSG)
91
+ Gather failed after #{max_attempts} attempts. Dependencies:
92
+
93
+ #{JSON.pretty_generate(dependency_status)}
94
+ MSG
95
+ end
96
+ end
97
+
98
+ # Internal job class for polling and coordinating gather operations
99
+ class GatherJob < ActiveJob::Base
100
+ include ScatterGather
101
+ discard_on DependencyTimeoutError
102
+
103
+ def logger = ActiveSupport::TaggedLogging.new(super).tagged("ScatterGather")
104
+
105
+ def perform(wait_for_active_job_ids:, target_job:, gather_config:, remaining_attempts:)
106
+ deps = ScatterGather::Completion.collect_statuses(wait_for_active_job_ids)
107
+ logger.info { "Gathered completions #{tally_in_logger_format(deps)}" }
108
+
109
+ all_done = deps.values.all? { |it| it == :completed }
110
+ if all_done
111
+ logger.info { "Dependencies done, enqueueing #{target_job.fetch(:cn)}" }
112
+ perform_target_later_from_args(target_job)
113
+ Completion.where(active_job_id: wait_for_active_job_ids).delete_all
114
+ elsif remaining_attempts < 1
115
+ max_attempts = gather_config.fetch(:max_attempts)
116
+ error = DependencyTimeoutError.new(max_attempts, deps)
117
+ logger.warn { "Failed to gather dependencies after #{max_attempts} attempts" }
118
+ Completion.where(active_job_id: wait_for_active_job_ids).delete_all
119
+
120
+ # We configure our job to discard on timeout, and discard does not report the error by default
121
+ Rails.error.report(error)
122
+ raise error
123
+ else
124
+ # Re-enqueue with delay. We could poll only for dependencies which are still remaining,
125
+ # but for debugging this is actually worse because for hanging stuff there will be one
126
+ # job that hangs in the end. Knowing which jobs were part of the batch is useful!
127
+ args = {
128
+ wait_for_active_job_ids:,
129
+ target_job:,
130
+ gather_config:,
131
+ remaining_attempts: remaining_attempts - 1
132
+ }
133
+ wait = gather_config.fetch(:poll_interval)
134
+ self.class.set(wait:).perform_later(**args)
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def tally_in_logger_format(hash)
141
+ hash.values.tally.map do |k, count|
142
+ "#{k}=#{count}"
143
+ end.join(" ")
144
+ end
145
+
146
+ def perform_target_later_from_args(target_job)
147
+ # The only purpose of this is to pass all variations
148
+ # of `perform_later` argument shapes correctly
149
+ job_class = target_job.fetch(:cn).constantize
150
+ if target_job[:p].any? && target_job[:k] # Both
151
+ job_class.perform_later(*target_job[:p], **target_job[:k])
152
+ elsif target_job[:k] # Just kwargs
153
+ job_class.perform_later(**target_job[:k])
154
+ elsif target_job[:p] # Just posargs
155
+ job_class.perform_later(*target_job[:p])
156
+ else
157
+ job_class.perform_later # No args
158
+ end
159
+ end
160
+ end
161
+
162
+ included do
163
+ after_perform :register_completion_for_gathering
164
+ discard_on ScatterGather::DependencyTimeoutError
165
+
166
+ def self.gather(*active_jobs, **gather_config_options)
167
+ active_jobs = Array(active_jobs).flatten
168
+ config = DEFAULT_GATHER_CONFIG.merge(gather_config_options)
169
+
170
+ # Pre-insert IDs to wait for
171
+ t = Time.current
172
+ attrs = active_jobs.map do |aj|
173
+ {
174
+ active_job_id: aj.job_id,
175
+ active_job_class_name: aj.class.name,
176
+ status: "pending",
177
+ created_at: t,
178
+ updated_at: t
179
+ }
180
+ end
181
+ ScatterGather::Completion.insert_all(attrs)
182
+ ScatterGather::Completion.where("created_at < ?", 1.week.ago).delete_all
183
+
184
+ # Return a proxy object that behaves like an ActiveJob proxy
185
+ GatherJobProxy.new(self, active_jobs.map(&:job_id), config)
186
+ end
187
+ end
188
+
189
+ # Updates the completions table with the status of this job
190
+ def register_completion_for_gathering
191
+ n_updated = ScatterGather::Completion.where(active_job_id: job_id).update_all(status: "completed", updated_at: Time.current)
192
+ if n_updated > 0
193
+ logger.tagged("ScatterGather").info { "Registered completion of #{self.class.name} id=#{job_id} since it will be gathered" }
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :scatter_gather do
4
+ desc "Recover all journeys hanging in the 'performing' state"
5
+ task :recovery do
6
+ ScatterGather::RecoverStuckJourneysJob.perform_now
7
+ end
8
+ end