scatter_gather 0.1.1 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1226dbbaeb58d454646982ccecd8bc3512efd45b5ae51a22031ea744b061768b
4
- data.tar.gz: a75ecd0241e1556d8b060321b9e0d3a46f18c070c4d85efe9d80239a5c5f4eae
3
+ metadata.gz: 97e842d4c2d232e8b3230e0fc7139190af57d3495ccc43dde441e7e873788546
4
+ data.tar.gz: f542418d42fd82919da4efc41624e5a6855bbaf55fc007cf7bbe2891a461090f
5
5
  SHA512:
6
- metadata.gz: 8a44383f3f3d215668122e7a262059c891fff87f2892c30a5bf72630eaad2d2ab6008f2abacbb831c72bf9e66647ffbc9d340d988e056effc75b5f7826704e97
7
- data.tar.gz: d60c239b73a6f988c195194bdb26d9c0e6458eff9c11e4c387cfd7607fe13f5461086c7f210acc32ec6b540d50bf8536197f3964e8e5002b534ac4c0328fa4cb
6
+ metadata.gz: 6e73aad981d7f03948b71b7ee87ceba00a8e53d4864456f4f3a4ba7254d66b54450e5d305975af27535fa59c430e669043694a6fc8216cd3457cd9664099ff53
7
+ data.tar.gz: c25cd3dcac36b969e2eb8d9131c710ee582e97623d2617ab5c98e0f2a7e9f2ba2daef543d4670f6f14dd7c63a07fa833cce64907a26defd8b63e2ff6be1e86c4
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module ScatterGather
7
+ module Generators
8
+ # The generator is used to install ScatterGather. It adds the migration that creates
9
+ # the scatter_gather_completions table.
10
+ # Run it with `bin/rails g scatter_gather:install` in your console.
11
+ class InstallGenerator < Rails::Generators::Base
12
+ include ActiveRecord::Generators::Migration
13
+
14
+ source_paths << File.dirname(__FILE__)
15
+
16
+ # Generates migration file that creates the scatter_gather_completions table.
17
+ def create_migration_file
18
+ # Migration files are named "...migration_001.rb" etc. This allows them to be emitted
19
+ # as they get added, and the order of the migrations can be controlled using predictable sorting.
20
+ # Adding a new migration to the gem is then just adding a file.
21
+ migration_file_paths_in_order = Dir.glob(__dir__ + "/*_migration_*.rb.erb").sort
22
+ migration_file_paths_in_order.each do |migration_template_path|
23
+ untemplated_migration_filename = File.basename(migration_template_path).gsub(/\.erb$/, "")
24
+ migration_template(migration_template_path, File.join(db_migrate_path, untemplated_migration_filename))
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def migration_version
31
+ ActiveRecord::VERSION::STRING.split(".").take(2).join(".")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,59 @@
1
+ class ScatterGatherMigration001 < ActiveRecord::Migration[<%= migration_version %>]
2
+ def up
3
+ # Detect dominant ID type by scanning existing tables
4
+ id_type = detect_dominant_id_type
5
+
6
+ # Only specify id: :uuid if UUID is dominant, otherwise use Rails defaults
7
+ table_options = (id_type == :uuid) ? {id: :uuid} : {}
8
+
9
+ create_table :scatter_gather_completions, **table_options do |t|
10
+ t.string :active_job_id, null: false
11
+ t.string :active_job_class_name
12
+ t.string :status, default: "unknown"
13
+ t.timestamps
14
+ end
15
+ add_index :scatter_gather_completions, [:active_job_id], unique: true # For lookups
16
+ add_index :scatter_gather_completions, [:created_at] # For cleanup
17
+ end
18
+
19
+ private
20
+
21
+ # Scans existing tables to detect whether UUID or serial ID is dominant
22
+ # @return [Symbol] :uuid or :serial based on dominant ID type
23
+ def detect_dominant_id_type
24
+ return :serial if table_count == 0
25
+
26
+ uuid_count = 0
27
+ serial_count = 0
28
+
29
+ connection.tables.each do |table_name|
30
+ next if table_name == "schema_migrations" || table_name == "ar_internal_metadata"
31
+
32
+ begin
33
+ columns = connection.columns(table_name)
34
+ id_column = columns.find { |col| col.name == "id" }
35
+ next unless id_column
36
+
37
+ if id_column.type == :uuid
38
+ uuid_count += 1
39
+ elsif id_column.type == :integer || id_column.type == :bigint
40
+ serial_count += 1
41
+ end
42
+ rescue => e
43
+ # Skip tables that can't be inspected (e.g., views, system tables)
44
+ Rails.logger.debug "Could not inspect table #{table_name}: #{e.message}"
45
+ end
46
+ end
47
+
48
+ # Default to serial if no clear preference or equal counts
49
+ (uuid_count > serial_count) ? :uuid : :serial
50
+ end
51
+
52
+ def table_count
53
+ @table_count ||= connection.tables.count
54
+ end
55
+
56
+ def down
57
+ drop_table :scatter_gather_completions
58
+ end
59
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ActiveJob itself does not maintain a "one record per job" database entry where we could reliably track job completion.
4
+ # The underlying job storage (e.g., Sidekiq, Que, DelayedJob) is determined by the ActiveJob adapter, and their implementation details vary.
5
+ # As a result, to enable reliable scatter-gather orchestration, we create our own completion tracking records.
6
+ # Each job that is meant to be gathered later inserts a record into the scatter_gather_completions table when it is enqueued,
7
+ # and marks it as completed upon finishing. This ensures adapter-independent, uniform dependency tracking.
8
+ class ScatterGather::Completion < ActiveRecord::Base
9
+ self.table_name = "scatter_gather_completions"
10
+
11
+ # Collect status information for the given active job IDs
12
+ # @param active_job_ids [Array<String>] Array of ActiveJob IDs to check
13
+ # @return [Array<DependencyStatus>] Array of DependencyStatus objects
14
+ def self.collect_statuses(active_job_ids)
15
+ # Initialize all job IDs with unknown status
16
+ statuses = active_job_ids.map { |id| [id, :unknown] }.to_h
17
+
18
+ # Get statuses from completion records
19
+ completions = where(active_job_id: active_job_ids)
20
+ .pluck(:active_job_id, :active_job_class_name, :status)
21
+ .map do |(id, class_name, status)|
22
+ [id, {class_name: class_name, status: status.to_sym}]
23
+ end.to_h
24
+
25
+ # Update statuses with completion data
26
+ completions.each do |id, data|
27
+ statuses[id] = data[:status]
28
+ end
29
+
30
+ # Create DependencyStatus objects
31
+ dependency_statuses = active_job_ids.map do |id|
32
+ completion_data = completions[id]
33
+ class_name = completion_data&.dig(:class_name)
34
+ status = statuses[id]
35
+
36
+ ScatterGather::DependencyStatus.new(id, class_name, status)
37
+ end
38
+
39
+ # Sort by status first (unknown, pending, completed), then by active_job_id
40
+ dependency_statuses.sort_by do |ds|
41
+ status_order = case ds.status
42
+ when :unknown then 0
43
+ when :pending then 1
44
+ when :completed then 2
45
+ else 3
46
+ end
47
+ [status_order, ds.active_job_id.to_s]
48
+ end
49
+ end
50
+
51
+ # Check if all dependencies are completed
52
+ # @param dependency_statuses [Array<DependencyStatus>] Array of dependency statuses
53
+ # @return [Boolean] true if all dependencies are completed
54
+ def self.all_dependencies_completed?(dependency_statuses)
55
+ dependency_statuses.all? { |ds| ds.status == :completed }
56
+ end
57
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Struct to represent the status of a dependency job
4
+ # @param active_job_id [String] The ActiveJob ID
5
+ # @param active_job_class [String] The ActiveJob class name
6
+ # @param status [Symbol] The status of the job (:completed, :pending, :unknown)
7
+ ScatterGather::DependencyStatus = Struct.new(:active_job_id, :active_job_class, :status) do
8
+ # Get a display-friendly class name for unknown jobs
9
+ # @return [String] The class name or "(unknown)" for unknown jobs
10
+ def display_class
11
+ active_job_class || "(unknown)"
12
+ end
13
+
14
+ # Get a checkmark for completed jobs
15
+ # @return [String] "✓" for completed jobs, " " for others
16
+ def checkmark
17
+ (status == :completed) ? "✓" : " "
18
+ end
19
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Custom exception for when gather job exhausts attempts
4
+ class ScatterGather::DependencyTimeoutError < StandardError
5
+ attr_reader :dependency_statuses, :max_attempts
6
+
7
+ def initialize(max_attempts, dependency_statuses)
8
+ @max_attempts = max_attempts
9
+ @dependency_statuses = dependency_statuses
10
+ super(<<~MSG)
11
+ Gather failed after #{max_attempts} attempts. Dependencies:
12
+
13
+ #{format_dependency_table(dependency_statuses)}
14
+ MSG
15
+ end
16
+
17
+ private
18
+
19
+ # Format dependency statuses as a plaintext table
20
+ # @param dependency_statuses [Array<DependencyStatus>] Array of dependency statuses
21
+ # @return [String] Formatted table string
22
+ def format_dependency_table(dependency_statuses)
23
+ return "No dependencies" if dependency_statuses.empty?
24
+
25
+ # Calculate column widths
26
+ max_id_width = dependency_statuses.map { |ds| ds.active_job_id.length }.max || 0
27
+ max_class_width = dependency_statuses.map { |ds| ds.display_class.length }.max || 0
28
+ max_status_width = dependency_statuses.map { |ds| ds.status.to_s.length }.max || 0
29
+
30
+ # Ensure minimum widths
31
+ id_width = [max_id_width, 6].max # "Job ID".length = 6
32
+ class_width = [max_class_width, 5].max # "Class".length = 5
33
+ status_width = [max_status_width, 5].max # "Status".length = 5
34
+
35
+ # Build table
36
+ lines = []
37
+
38
+ # Header
39
+ header = "| ✓ | %-#{id_width}s | %-#{class_width}s | %-#{status_width}s |" % ["Job ID", "Class", "Status"]
40
+ lines << header
41
+ lines << "|---|#{"-" * (id_width + 2)}|#{"-" * (class_width + 2)}|#{"-" * (status_width + 2)}|"
42
+
43
+ # Rows (dependency_statuses are already sorted from collect_statuses)
44
+ dependency_statuses.each do |ds|
45
+ row = "| %s | %-#{id_width}s | %-#{class_width}s | %-#{status_width}s |" % [
46
+ ds.checkmark,
47
+ ds.active_job_id,
48
+ ds.display_class,
49
+ ds.status.to_s
50
+ ]
51
+ lines << row
52
+ end
53
+
54
+ lines.join("\n")
55
+ end
56
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Internal job class for polling and coordinating gather operations
4
+ class ScatterGather::GatherJob < ActiveJob::Base
5
+ include ScatterGather
6
+ discard_on ScatterGather::DependencyTimeoutError
7
+
8
+ def logger = ActiveSupport::TaggedLogging.new(super).tagged("ScatterGather")
9
+
10
+ def perform(wait_for_active_job_ids:, target_job:, gather_config:, remaining_attempts:)
11
+ dependency_statuses = ScatterGather::Completion.collect_statuses(wait_for_active_job_ids)
12
+ logger.info { "Gathered completions #{tally_in_logger_format(dependency_statuses)}" }
13
+
14
+ if ScatterGather::Completion.all_dependencies_completed?(dependency_statuses)
15
+ logger.info { "Dependencies done, enqueueing #{target_job.fetch(:cn)}" }
16
+ perform_target_later_from_args(target_job)
17
+ ScatterGather::Completion.where(active_job_id: wait_for_active_job_ids).delete_all
18
+ elsif remaining_attempts < 1
19
+ max_attempts = gather_config.fetch(:max_attempts)
20
+ error = ScatterGather::DependencyTimeoutError.new(max_attempts, dependency_statuses)
21
+ logger.warn { "Failed to gather dependencies after #{max_attempts} attempts" }
22
+ ScatterGather::Completion.where(active_job_id: wait_for_active_job_ids).delete_all
23
+
24
+ # We configure our job to discard on timeout, and discard does not report the error by default
25
+ Rails.error.report(error)
26
+ raise error
27
+ else
28
+ # Re-enqueue with delay. We could poll only for dependencies which are still remaining,
29
+ # but for debugging this is actually worse because for hanging stuff there will be one
30
+ # job that hangs in the end. Knowing which jobs were part of the batch is useful!
31
+ args = {
32
+ wait_for_active_job_ids:,
33
+ target_job:,
34
+ gather_config:,
35
+ remaining_attempts: remaining_attempts - 1
36
+ }
37
+ wait = gather_config.fetch(:poll_interval).seconds
38
+ self.class.set(wait:).perform_later(**args)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def tally_in_logger_format(dependency_statuses)
45
+ dependency_statuses.map(&:status).tally.map do |status, count|
46
+ "#{status}=#{count}"
47
+ end.join(" ")
48
+ end
49
+
50
+ def perform_target_later_from_args(target_job)
51
+ # The only purpose of this is to pass all variations
52
+ # of `perform_later` argument shapes correctly
53
+ job_class = target_job.fetch(:cn).constantize
54
+ if target_job[:p].any? && target_job[:k] # Both
55
+ job_class.perform_later(*target_job[:p], **target_job[:k])
56
+ elsif target_job[:k] # Just kwargs
57
+ job_class.perform_later(**target_job[:k])
58
+ elsif target_job[:p] # Just posargs
59
+ job_class.perform_later(*target_job[:p])
60
+ else
61
+ job_class.perform_later # No args
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Proxy class that mimics ActiveJob behavior for gather jobs
4
+ class ScatterGather::GatherJobProxy
5
+ def initialize(target_class, ids, config)
6
+ @target_class = target_class
7
+ @ids = ids
8
+ @config = config.with_indifferent_access
9
+ end
10
+
11
+ # Mimic ActiveJob's perform_later method
12
+ # @param args [Array] Positional arguments to pass to the target job's perform method
13
+ # @param kwargs [Hash] Keyword arguments to pass to the target job's perform method
14
+ # @return [void] Enqueues the gather job
15
+ def perform_later(*args, **kwargs)
16
+ job_arguments = {cn: @target_class.name, p: args, k: kwargs}
17
+ gather_job_params = {
18
+ wait_for_active_job_ids: @ids,
19
+ target_job: job_arguments,
20
+ gather_config: @config,
21
+ remaining_attempts: @config.fetch(:max_attempts) - 1
22
+ }
23
+ tagged = ActiveSupport::TaggedLogging.new(Rails.logger).tagged("ScatterGather")
24
+ tagged.info { "Enqueueing gather job waiting for #{@ids.inspect} to run a #{@target_class.name} after" }
25
+ ScatterGather::GatherJob.perform_later(**gather_job_params)
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ScatterGather
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -4,6 +4,7 @@ require "active_support"
4
4
  require "active_record"
5
5
  require "active_job"
6
6
  require "json"
7
+ require_relative "scatter_gather/version"
7
8
 
8
9
  # Scatter-Gather Pattern for ActiveJob
9
10
  #
@@ -35,130 +36,19 @@ require "json"
35
36
  module ScatterGather
36
37
  extend ActiveSupport::Concern
37
38
 
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
39
+ # Autoload sub-modules
40
+ autoload :DependencyStatus, "scatter_gather/dependency_status"
41
+ autoload :Completion, "scatter_gather/completion"
42
+ autoload :GatherJobProxy, "scatter_gather/gather_job_proxy"
43
+ autoload :DependencyTimeoutError, "scatter_gather/dependency_timeout_error"
44
+ autoload :GatherJob, "scatter_gather/gather_job"
51
45
 
52
46
  # Default configuration for gather jobs
53
47
  DEFAULT_GATHER_CONFIG = {
54
48
  max_attempts: 10,
55
- poll_interval: 2.seconds
49
+ poll_interval: 2
56
50
  }.freeze
57
51
 
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
52
  included do
163
53
  after_perform :register_completion_for_gathering
164
54
  discard_on ScatterGather::DependencyTimeoutError
@@ -30,52 +30,38 @@ module ScatterGather
30
30
  extend ActiveSupport::Concern
31
31
  DEFAULT_GATHER_CONFIG = T.let({
32
32
  max_attempts: 10,
33
- poll_interval: 2.seconds
33
+ poll_interval: 2
34
34
  }.freeze, T.untyped)
35
- VERSION = T.let("0.1.1", T.untyped)
35
+ VERSION = T.let("0.1.2", T.untyped)
36
36
 
37
37
  # sord omit - no YARD return type given, using untyped
38
38
  # Updates the completions table with the status of this job
39
39
  sig { returns(T.untyped) }
40
40
  def register_completion_for_gathering; end
41
41
 
42
+ # ActiveJob itself does not maintain a "one record per job" database entry where we could reliably track job completion.
43
+ # The underlying job storage (e.g., Sidekiq, Que, DelayedJob) is determined by the ActiveJob adapter, and their implementation details vary.
44
+ # As a result, to enable reliable scatter-gather orchestration, we create our own completion tracking records.
45
+ # Each job that is meant to be gathered later inserts a record into the scatter_gather_completions table when it is enqueued,
46
+ # and marks it as completed upon finishing. This ensures adapter-independent, uniform dependency tracking.
42
47
  class Completion < ActiveRecord::Base
43
- # sord omit - no YARD type given for "active_job_ids", using untyped
44
- # sord omit - no YARD return type given, using untyped
45
- sig { params(active_job_ids: T.untyped).returns(T.untyped) }
48
+ # sord warn - DependencyStatus wasn't able to be resolved to a constant in this project
49
+ # Collect status information for the given active job IDs
50
+ #
51
+ # _@param_ `active_job_ids` — Array of ActiveJob IDs to check
52
+ #
53
+ # _@return_ — Array of DependencyStatus objects
54
+ sig { params(active_job_ids: T::Array[String]).returns(T::Array[DependencyStatus]) }
46
55
  def self.collect_statuses(active_job_ids); end
47
- end
48
56
 
49
- # Proxy class that mimics ActiveJob behavior for gather jobs
50
- class GatherJobProxy
51
- # sord omit - no YARD type given for "target_class", using untyped
52
- # sord omit - no YARD type given for "ids", using untyped
53
- # sord omit - no YARD type given for "config", using untyped
54
- sig { params(target_class: T.untyped, ids: T.untyped, config: T.untyped).void }
55
- def initialize(target_class, ids, config); end
56
-
57
- # Mimic ActiveJob's perform_later method
57
+ # sord warn - DependencyStatus wasn't able to be resolved to a constant in this project
58
+ # Check if all dependencies are completed
58
59
  #
59
- # _@param_ `args` — Positional arguments to pass to the target job's perform method
60
+ # _@param_ `dependency_statuses` — Array of dependency statuses
60
61
  #
61
- # _@param_ `kwargs` Keyword arguments to pass to the target job's perform method
62
- #
63
- # _@return_ — Enqueues the gather job
64
- sig { params(args: T::Array[T.untyped], kwargs: T::Hash[T.untyped, T.untyped]).void }
65
- def perform_later(*args, **kwargs); end
66
- end
67
-
68
- # Custom exception for when gather job exhausts attempts
69
- class DependencyTimeoutError < StandardError
70
- # sord omit - no YARD type given for "max_attempts", using untyped
71
- # sord omit - no YARD type given for "dependency_status", using untyped
72
- sig { params(max_attempts: T.untyped, dependency_status: T.untyped).void }
73
- def initialize(max_attempts, dependency_status); end
74
-
75
- # sord omit - no YARD type given for :dependency_status, using untyped
76
- # Returns the value of attribute dependency_status.
77
- sig { returns(T.untyped) }
78
- attr_reader :dependency_status
62
+ # _@return_true if all dependencies are completed
63
+ sig { params(dependency_statuses: T::Array[DependencyStatus]).returns(T::Boolean) }
64
+ def self.all_dependencies_completed?(dependency_statuses); end
79
65
  end
80
66
 
81
67
  # Internal job class for polling and coordinating gather operations
@@ -101,10 +87,10 @@ module ScatterGather
101
87
  end
102
88
  def perform(wait_for_active_job_ids:, target_job:, gather_config:, remaining_attempts:); end
103
89
 
104
- # sord omit - no YARD type given for "hash", using untyped
90
+ # sord omit - no YARD type given for "dependency_statuses", using untyped
105
91
  # sord omit - no YARD return type given, using untyped
106
- sig { params(hash: T.untyped).returns(T.untyped) }
107
- def tally_in_logger_format(hash); end
92
+ sig { params(dependency_statuses: T.untyped).returns(T.untyped) }
93
+ def tally_in_logger_format(dependency_statuses); end
108
94
 
109
95
  # sord omit - no YARD type given for "target_job", using untyped
110
96
  # sord omit - no YARD return type given, using untyped
@@ -117,19 +103,67 @@ module ScatterGather
117
103
  def register_completion_for_gathering; end
118
104
  end
119
105
 
120
- # The generator is used to install ScatterGather. It adds the migration that creates
121
- # the scatter_gather_completions table.
122
- # Run it with `bin/rails g scatter_gather:install` in your console.
123
- class InstallGenerator < Rails::Generators::Base
124
- include ActiveRecord::Generators::Migration
106
+ # Proxy class that mimics ActiveJob behavior for gather jobs
107
+ class GatherJobProxy
108
+ # sord omit - no YARD type given for "target_class", using untyped
109
+ # sord omit - no YARD type given for "ids", using untyped
110
+ # sord omit - no YARD type given for "config", using untyped
111
+ sig { params(target_class: T.untyped, ids: T.untyped, config: T.untyped).void }
112
+ def initialize(target_class, ids, config); end
113
+
114
+ # Mimic ActiveJob's perform_later method
115
+ #
116
+ # _@param_ `args` — Positional arguments to pass to the target job's perform method
117
+ #
118
+ # _@param_ `kwargs` — Keyword arguments to pass to the target job's perform method
119
+ #
120
+ # _@return_ — Enqueues the gather job
121
+ sig { params(args: T::Array[T.untyped], kwargs: T::Hash[T.untyped, T.untyped]).void }
122
+ def perform_later(*args, **kwargs); end
123
+ end
124
+
125
+ # Custom exception for when gather job exhausts attempts
126
+ class DependencyTimeoutError < StandardError
127
+ # sord omit - no YARD type given for "max_attempts", using untyped
128
+ # sord omit - no YARD type given for "dependency_statuses", using untyped
129
+ sig { params(max_attempts: T.untyped, dependency_statuses: T.untyped).void }
130
+ def initialize(max_attempts, dependency_statuses); end
125
131
 
126
- # sord omit - no YARD return type given, using untyped
127
- # Generates migration file that creates the scatter_gather_completions table.
132
+ # sord warn - DependencyStatus wasn't able to be resolved to a constant in this project
133
+ # Format dependency statuses as a plaintext table
134
+ #
135
+ # _@param_ `dependency_statuses` — Array of dependency statuses
136
+ #
137
+ # _@return_ — Formatted table string
138
+ sig { params(dependency_statuses: T::Array[DependencyStatus]).returns(String) }
139
+ def format_dependency_table(dependency_statuses); end
140
+
141
+ # sord omit - no YARD type given for :dependency_statuses, using untyped
142
+ # Returns the value of attribute dependency_statuses.
128
143
  sig { returns(T.untyped) }
129
- def create_migration_file; end
144
+ attr_reader :dependency_statuses
130
145
 
131
- # sord omit - no YARD return type given, using untyped
146
+ # sord omit - no YARD type given for :max_attempts, using untyped
147
+ # Returns the value of attribute max_attempts.
132
148
  sig { returns(T.untyped) }
133
- def migration_version; end
149
+ attr_reader :max_attempts
150
+ end
151
+
152
+ module Generators
153
+ # The generator is used to install ScatterGather. It adds the migration that creates
154
+ # the scatter_gather_completions table.
155
+ # Run it with `bin/rails g scatter_gather:install` in your console.
156
+ class InstallGenerator < Rails::Generators::Base
157
+ include ActiveRecord::Generators::Migration
158
+
159
+ # sord omit - no YARD return type given, using untyped
160
+ # Generates migration file that creates the scatter_gather_completions table.
161
+ sig { returns(T.untyped) }
162
+ def create_migration_file; end
163
+
164
+ # sord omit - no YARD return type given, using untyped
165
+ sig { returns(T.untyped) }
166
+ def migration_version; end
167
+ end
134
168
  end
135
169
  end
@@ -34,38 +34,27 @@ module ScatterGather
34
34
  # Updates the completions table with the status of this job
35
35
  def register_completion_for_gathering: () -> untyped
36
36
 
37
+ # ActiveJob itself does not maintain a "one record per job" database entry where we could reliably track job completion.
38
+ # The underlying job storage (e.g., Sidekiq, Que, DelayedJob) is determined by the ActiveJob adapter, and their implementation details vary.
39
+ # As a result, to enable reliable scatter-gather orchestration, we create our own completion tracking records.
40
+ # Each job that is meant to be gathered later inserts a record into the scatter_gather_completions table when it is enqueued,
41
+ # and marks it as completed upon finishing. This ensures adapter-independent, uniform dependency tracking.
37
42
  class Completion < ActiveRecord::Base
38
- # sord omit - no YARD type given for "active_job_ids", using untyped
39
- # sord omit - no YARD return type given, using untyped
40
- def self.collect_statuses: (untyped active_job_ids) -> untyped
41
- end
42
-
43
- # Proxy class that mimics ActiveJob behavior for gather jobs
44
- class GatherJobProxy
45
- # sord omit - no YARD type given for "target_class", using untyped
46
- # sord omit - no YARD type given for "ids", using untyped
47
- # sord omit - no YARD type given for "config", using untyped
48
- def initialize: (untyped target_class, untyped ids, untyped config) -> void
49
-
50
- # Mimic ActiveJob's perform_later method
51
- #
52
- # _@param_ `args` — Positional arguments to pass to the target job's perform method
43
+ # sord warn - DependencyStatus wasn't able to be resolved to a constant in this project
44
+ # Collect status information for the given active job IDs
53
45
  #
54
- # _@param_ `kwargs` — Keyword arguments to pass to the target job's perform method
46
+ # _@param_ `active_job_ids` — Array of ActiveJob IDs to check
55
47
  #
56
- # _@return_ — Enqueues the gather job
57
- def perform_later: (*::Array[untyped] args, **::Hash[untyped, untyped] kwargs) -> void
58
- end
48
+ # _@return_ — Array of DependencyStatus objects
49
+ def self.collect_statuses: (::Array[String] active_job_ids) -> ::Array[DependencyStatus]
59
50
 
60
- # Custom exception for when gather job exhausts attempts
61
- class DependencyTimeoutError < StandardError
62
- # sord omit - no YARD type given for "max_attempts", using untyped
63
- # sord omit - no YARD type given for "dependency_status", using untyped
64
- def initialize: (untyped max_attempts, untyped dependency_status) -> void
65
-
66
- # sord omit - no YARD type given for :dependency_status, using untyped
67
- # Returns the value of attribute dependency_status.
68
- attr_reader dependency_status: untyped
51
+ # sord warn - DependencyStatus wasn't able to be resolved to a constant in this project
52
+ # Check if all dependencies are completed
53
+ #
54
+ # _@param_ `dependency_statuses` Array of dependency statuses
55
+ #
56
+ # _@return_ — true if all dependencies are completed
57
+ def self.all_dependencies_completed?: (::Array[DependencyStatus] dependency_statuses) -> bool
69
58
  end
70
59
 
71
60
  # Internal job class for polling and coordinating gather operations
@@ -87,9 +76,9 @@ module ScatterGather
87
76
  remaining_attempts: untyped
88
77
  ) -> untyped
89
78
 
90
- # sord omit - no YARD type given for "hash", using untyped
79
+ # sord omit - no YARD type given for "dependency_statuses", using untyped
91
80
  # sord omit - no YARD return type given, using untyped
92
- def tally_in_logger_format: (untyped hash) -> untyped
81
+ def tally_in_logger_format: (untyped dependency_statuses) -> untyped
93
82
 
94
83
  # sord omit - no YARD type given for "target_job", using untyped
95
84
  # sord omit - no YARD return type given, using untyped
@@ -100,17 +89,59 @@ module ScatterGather
100
89
  def register_completion_for_gathering: () -> untyped
101
90
  end
102
91
 
103
- # The generator is used to install ScatterGather. It adds the migration that creates
104
- # the scatter_gather_completions table.
105
- # Run it with `bin/rails g scatter_gather:install` in your console.
106
- class InstallGenerator < Rails::Generators::Base
107
- include ActiveRecord::Generators::Migration
92
+ # Proxy class that mimics ActiveJob behavior for gather jobs
93
+ class GatherJobProxy
94
+ # sord omit - no YARD type given for "target_class", using untyped
95
+ # sord omit - no YARD type given for "ids", using untyped
96
+ # sord omit - no YARD type given for "config", using untyped
97
+ def initialize: (untyped target_class, untyped ids, untyped config) -> void
98
+
99
+ # Mimic ActiveJob's perform_later method
100
+ #
101
+ # _@param_ `args` — Positional arguments to pass to the target job's perform method
102
+ #
103
+ # _@param_ `kwargs` — Keyword arguments to pass to the target job's perform method
104
+ #
105
+ # _@return_ — Enqueues the gather job
106
+ def perform_later: (*::Array[untyped] args, **::Hash[untyped, untyped] kwargs) -> void
107
+ end
108
+
109
+ # Custom exception for when gather job exhausts attempts
110
+ class DependencyTimeoutError < StandardError
111
+ # sord omit - no YARD type given for "max_attempts", using untyped
112
+ # sord omit - no YARD type given for "dependency_statuses", using untyped
113
+ def initialize: (untyped max_attempts, untyped dependency_statuses) -> void
108
114
 
109
- # sord omit - no YARD return type given, using untyped
110
- # Generates migration file that creates the scatter_gather_completions table.
111
- def create_migration_file: () -> untyped
115
+ # sord warn - DependencyStatus wasn't able to be resolved to a constant in this project
116
+ # Format dependency statuses as a plaintext table
117
+ #
118
+ # _@param_ `dependency_statuses` — Array of dependency statuses
119
+ #
120
+ # _@return_ — Formatted table string
121
+ def format_dependency_table: (::Array[DependencyStatus] dependency_statuses) -> String
112
122
 
113
- # sord omit - no YARD return type given, using untyped
114
- def migration_version: () -> untyped
123
+ # sord omit - no YARD type given for :dependency_statuses, using untyped
124
+ # Returns the value of attribute dependency_statuses.
125
+ attr_reader dependency_statuses: untyped
126
+
127
+ # sord omit - no YARD type given for :max_attempts, using untyped
128
+ # Returns the value of attribute max_attempts.
129
+ attr_reader max_attempts: untyped
130
+ end
131
+
132
+ module Generators
133
+ # The generator is used to install ScatterGather. It adds the migration that creates
134
+ # the scatter_gather_completions table.
135
+ # Run it with `bin/rails g scatter_gather:install` in your console.
136
+ class InstallGenerator < Rails::Generators::Base
137
+ include ActiveRecord::Generators::Migration
138
+
139
+ # sord omit - no YARD return type given, using untyped
140
+ # Generates migration file that creates the scatter_gather_completions table.
141
+ def create_migration_file: () -> untyped
142
+
143
+ # sord omit - no YARD return type given, using untyped
144
+ def migration_version: () -> untyped
145
+ end
115
146
  end
116
147
  end
@@ -4,13 +4,13 @@
4
4
  #
5
5
  # This file is the source Rails uses to define your schema when running `bin/rails
6
6
  # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
- # be faster and is less error-prone than running all of your migrations from scratch.
8
- # Old migrations may fail to apply correctly if those migrations used external
9
- # dependencies or application code.
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema[7.2].define(version: 2025_01_01_000001) do
13
+ ActiveRecord::Schema[7.2].define(version: 2025_10_01_094223) do
14
14
  create_table "scatter_gather_completions", force: :cascade do |t|
15
15
  t.string "active_job_id", null: false
16
16
  t.string "active_job_class_name"
@@ -177,4 +177,97 @@ class ScatterGatherTest < ActiveSupport::TestCase
177
177
  perform_enqueued_jobs
178
178
  end
179
179
  end
180
+
181
+ test "Completion.collect_statuses handles missing job IDs correctly" do
182
+ # Create some job IDs - some that exist in the database, some that don't
183
+ existing_job_id_1 = "existing-job-1"
184
+ existing_job_id_2 = "existing-job-2"
185
+ missing_job_id_1 = "missing-job-1"
186
+ missing_job_id_2 = "missing-job-2"
187
+
188
+ # Create completion records for some jobs
189
+ ScatterGather::Completion.create!(
190
+ active_job_id: existing_job_id_1,
191
+ active_job_class_name: "TestJob",
192
+ status: "completed"
193
+ )
194
+
195
+ ScatterGather::Completion.create!(
196
+ active_job_id: existing_job_id_2,
197
+ active_job_class_name: "TestJob",
198
+ status: "pending"
199
+ )
200
+
201
+ # Test with a mix of existing and missing job IDs
202
+ job_ids = [existing_job_id_1, existing_job_id_2, missing_job_id_1, missing_job_id_2]
203
+
204
+ # Call the method and capture the result
205
+ result = ScatterGather::Completion.collect_statuses(job_ids)
206
+
207
+ # Verify the results
208
+ existing_completed = result.find { |ds| ds.active_job_id == existing_job_id_1 }
209
+ existing_pending = result.find { |ds| ds.active_job_id == existing_job_id_2 }
210
+ missing_1 = result.find { |ds| ds.active_job_id == missing_job_id_1 }
211
+ missing_2 = result.find { |ds| ds.active_job_id == missing_job_id_2 }
212
+
213
+ assert_not_nil existing_completed, "Should find existing completed job"
214
+ assert_equal :completed, existing_completed.status, "Existing completed job should have :completed status"
215
+ assert_equal "TestJob", existing_completed.active_job_class, "Should have correct class name"
216
+ assert_equal "✓", existing_completed.checkmark, "Completed job should have checkmark"
217
+
218
+ assert_not_nil existing_pending, "Should find existing pending job"
219
+ assert_equal :pending, existing_pending.status, "Existing pending job should have :pending status"
220
+ assert_equal "TestJob", existing_pending.active_job_class, "Should have correct class name"
221
+ assert_equal " ", existing_pending.checkmark, "Pending job should have space"
222
+
223
+ assert_not_nil missing_1, "Should find missing job 1"
224
+ assert_equal :unknown, missing_1.status, "Missing job should have :unknown status"
225
+ assert_nil missing_1.active_job_class, "Missing job should have nil class name"
226
+ assert_equal "(unknown)", missing_1.display_class, "Should display (unknown) for missing class"
227
+ assert_equal " ", missing_1.checkmark, "Unknown job should have space"
228
+
229
+ assert_not_nil missing_2, "Should find missing job 2"
230
+ assert_equal :unknown, missing_2.status, "Missing job should have :unknown status"
231
+ assert_nil missing_2.active_job_class, "Missing job should have nil class name"
232
+ assert_equal "(unknown)", missing_2.display_class, "Should display (unknown) for missing class"
233
+ assert_equal " ", missing_2.checkmark, "Unknown job should have space"
234
+
235
+ # Verify all job IDs are present in the result
236
+ assert_equal job_ids.length, result.length, "Result should contain all job IDs"
237
+ result_job_ids = result.map(&:active_job_id)
238
+ job_ids.each do |job_id|
239
+ assert_includes result_job_ids, job_id, "Result should contain job ID: #{job_id}"
240
+ end
241
+ end
242
+
243
+ test "DependencyTimeoutError formats dependency table correctly" do
244
+ # Create some dependency statuses with mixed states
245
+ dependency_statuses = [
246
+ ScatterGather::DependencyStatus.new("job-1", "TestJob", :completed),
247
+ ScatterGather::DependencyStatus.new("job-2", "AnotherJob", :pending),
248
+ ScatterGather::DependencyStatus.new("job-3", nil, :unknown),
249
+ ScatterGather::DependencyStatus.new("job-4", "LongClassNameJob", :pending)
250
+ ]
251
+
252
+ # Create the exception
253
+ error = ScatterGather::DependencyTimeoutError.new(5, dependency_statuses)
254
+
255
+ # Verify the exception has the correct attributes
256
+ assert_equal 5, error.max_attempts
257
+ assert_equal dependency_statuses, error.dependency_statuses
258
+
259
+ # Test the exact table format
260
+ expected_message = <<~MSG
261
+ Gather failed after 5 attempts. Dependencies:
262
+
263
+ | ✓ | Job ID | Class | Status |
264
+ |---|--------|------------------|-----------|
265
+ | ✓ | job-1 | TestJob | completed |
266
+ | | job-2 | AnotherJob | pending |
267
+ | | job-3 | (unknown) | unknown |
268
+ | | job-4 | LongClassNameJob | pending |
269
+ MSG
270
+
271
+ assert_equal expected_message, error.message
272
+ end
180
273
  end
@@ -0,0 +1,114 @@
1
+ require "test_helper"
2
+ require "fileutils"
3
+
4
+ class SmokeTest < ActiveSupport::TestCase
5
+ test "smoke test: clean gem installation succeeds" do
6
+ # Load the generator before changing directories
7
+ require "scatter_gather"
8
+ project_root = File.expand_path("../..", __FILE__)
9
+ require File.join(project_root, "lib", "generators", "scatter_gather", "install_generator")
10
+
11
+ # Create a temporary directory for the test
12
+ temp_dir = Dir.mktmpdir("scatter_gather_smoke_test")
13
+ dummy_app_source = Rails.root
14
+ dummy_app_target = File.join(temp_dir, "dummy_app")
15
+ original_cwd = Dir.pwd
16
+
17
+ begin
18
+ # Step 1: Copy dummy app to temporary directory
19
+ puts "Step 1: Copying dummy app to temporary directory..."
20
+ FileUtils.cp_r(dummy_app_source, dummy_app_target)
21
+
22
+ # Change to the temporary dummy app directory
23
+ Dir.chdir(dummy_app_target)
24
+
25
+ # Step 2: Clean up any existing migrations and database
26
+ puts "Step 2: Cleaning up existing migrations and database..."
27
+
28
+ # Remove any existing scatter_gather migration files
29
+ existing_migrations = Dir.glob(File.join(dummy_app_target, "db", "migrate", "*scatter_gather*"))
30
+ existing_migrations.each { |file| File.delete(file) }
31
+
32
+ result = system("bundle exec rails db:drop RAILS_ENV=test", out: File::NULL, err: File::NULL)
33
+ assert result, "Database drop should succeed"
34
+
35
+ # Step 3: Create fresh database
36
+ puts "Step 3: Creating fresh database..."
37
+ result = system("bundle exec rails db:create RAILS_ENV=test", out: File::NULL, err: File::NULL)
38
+ assert result, "Database create should succeed"
39
+
40
+ # Step 4: Manually run the generator to create migration
41
+ puts "Step 4: Running scatter_gather:install generator..."
42
+
43
+ # Create a temporary generator instance and run it
44
+ generator = ScatterGather::Generators::InstallGenerator.new
45
+ generator.create_migration_file
46
+
47
+ # Step 5: Verify migration file was created
48
+ puts "Step 5: Verifying migration file was created..."
49
+ migration_files = Dir.glob(File.join(dummy_app_target, "db", "migrate", "*scatter_gather_migration_001.rb"))
50
+ assert_equal 1, migration_files.length, "Should create exactly one migration file"
51
+
52
+ migration_file = migration_files.first
53
+ assert File.exist?(migration_file), "Migration file should exist"
54
+
55
+ # Step 6: Verify migration content includes ID detection logic
56
+ puts "Step 6: Verifying migration content..."
57
+ migration_content = File.read(migration_file)
58
+ assert_includes migration_content, "detect_dominant_id_type", "Migration should include ID detection method"
59
+ assert_includes migration_content, "table_options =", "Migration should use conditional ID type"
60
+ assert_includes migration_content, "id_type == :uuid", "Migration should check for UUID type"
61
+ assert_includes migration_content, "create_table :scatter_gather_completions", "Migration should create the correct table"
62
+
63
+ # Step 7: Run migrations
64
+ puts "Step 7: Running migrations..."
65
+ result = system("bundle exec rails db:migrate RAILS_ENV=test", out: File::NULL, err: File::NULL)
66
+ assert result, "Migrations should succeed"
67
+
68
+ # Step 8: Verify table was created with correct structure
69
+ puts "Step 8: Verifying table structure..."
70
+ table_exists = ActiveRecord::Base.connection.table_exists?(:scatter_gather_completions)
71
+ assert table_exists, "scatter_gather_completions table should exist"
72
+
73
+ # Verify ID column type (should be integer for clean database)
74
+ id_column = ActiveRecord::Base.connection.columns(:scatter_gather_completions).find { |col| col.name == "id" }
75
+ assert_not_nil id_column, "ID column should exist"
76
+ assert_equal :integer, id_column.type, "ID column should be integer type for clean database"
77
+
78
+ # Verify other columns exist
79
+ column_names = ActiveRecord::Base.connection.columns(:scatter_gather_completions).map(&:name)
80
+ assert_includes column_names, "active_job_id", "active_job_id column should exist"
81
+ assert_includes column_names, "active_job_class_name", "active_job_class_name column should exist"
82
+ assert_includes column_names, "status", "status column should exist"
83
+ assert_includes column_names, "created_at", "created_at column should exist"
84
+ assert_includes column_names, "updated_at", "updated_at column should exist"
85
+
86
+ # Step 9: Verify indexes were created
87
+ puts "Step 9: Verifying indexes..."
88
+ indexes = ActiveRecord::Base.connection.indexes(:scatter_gather_completions)
89
+ active_job_id_index = indexes.find { |idx| idx.columns == ["active_job_id"] }
90
+ assert_not_nil active_job_id_index, "active_job_id index should exist"
91
+ assert active_job_id_index.unique, "active_job_id index should be unique"
92
+
93
+ created_at_index = indexes.find { |idx| idx.columns == ["created_at"] }
94
+ assert_not_nil created_at_index, "created_at index should exist"
95
+
96
+ # Step 10: Verify Completion model works via Rails runner
97
+ puts "Step 10: Verifying Completion model via Rails runner..."
98
+ result = system("bundle exec rails runner 'puts ScatterGather::Completion.count'", out: File::NULL, err: File::NULL)
99
+ assert result, "Rails runner should succeed"
100
+
101
+ # Capture the output to verify count is 0
102
+ output = `bundle exec rails runner 'puts ScatterGather::Completion.count' 2>/dev/null`.strip
103
+ assert_equal "0", output, "Completion count should be 0 for fresh installation"
104
+
105
+ puts "Smoke test completed successfully!"
106
+ ensure
107
+ # Restore original working directory
108
+ Dir.chdir(original_cwd)
109
+
110
+ # Clean up temporary directory
111
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
112
+ end
113
+ end
114
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scatter_gather
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-09-30 00:00:00.000000000 Z
11
+ date: 2025-10-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -202,12 +202,16 @@ files:
202
202
  - bin/console
203
203
  - bin/setup
204
204
  - bin/test
205
- - lib/generators/install_generator.rb
206
- - lib/generators/scatter_gather_migration_001.rb.erb
205
+ - lib/generators/scatter_gather/install_generator.rb
206
+ - lib/generators/scatter_gather/scatter_gather_migration_001.rb.erb
207
207
  - lib/scatter_gather.rb
208
+ - lib/scatter_gather/completion.rb
209
+ - lib/scatter_gather/dependency_status.rb
210
+ - lib/scatter_gather/dependency_timeout_error.rb
211
+ - lib/scatter_gather/gather_job.rb
212
+ - lib/scatter_gather/gather_job_proxy.rb
208
213
  - lib/scatter_gather/version.rb
209
214
  - rbi/scatter_gather.rbi
210
- - scatter_gather-0.1.19.gem
211
215
  - scatter_gather.gemspec
212
216
  - sig/scatter_gather.rbs
213
217
  - test/dummy/Rakefile
@@ -243,7 +247,6 @@ files:
243
247
  - test/dummy/config/puma.rb
244
248
  - test/dummy/config/routes.rb
245
249
  - test/dummy/config/storage.yml
246
- - test/dummy/db/migrate/20250101000001_add_scatter_gather_completions.rb
247
250
  - test/dummy/db/schema.rb
248
251
  - test/dummy/public/400.html
249
252
  - test/dummy/public/404.html
@@ -253,6 +256,7 @@ files:
253
256
  - test/dummy/public/icon.png
254
257
  - test/dummy/public/icon.svg
255
258
  - test/scatter_gather_test.rb
259
+ - test/smoke_test.rb
256
260
  - test/test_helper.rb
257
261
  homepage: https://github.com/julik/scatter_gather
258
262
  licenses:
@@ -1,33 +0,0 @@
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
@@ -1,16 +0,0 @@
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
Binary file
@@ -1,16 +0,0 @@
1
- class AddScatterGatherCompletions < ActiveRecord::Migration[7.2]
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