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 +4 -4
- data/lib/generators/scatter_gather/install_generator.rb +35 -0
- data/lib/generators/scatter_gather/scatter_gather_migration_001.rb.erb +59 -0
- data/lib/scatter_gather/completion.rb +57 -0
- data/lib/scatter_gather/dependency_status.rb +19 -0
- data/lib/scatter_gather/dependency_timeout_error.rb +56 -0
- data/lib/scatter_gather/gather_job.rb +64 -0
- data/lib/scatter_gather/gather_job_proxy.rb +27 -0
- data/lib/scatter_gather/version.rb +1 -1
- data/lib/scatter_gather.rb +8 -118
- data/rbi/scatter_gather.rbi +81 -47
- data/sig/scatter_gather.rbs +71 -40
- data/test/dummy/db/schema.rb +4 -4
- data/test/scatter_gather_test.rb +93 -0
- data/test/smoke_test.rb +114 -0
- metadata +10 -6
- data/lib/generators/install_generator.rb +0 -33
- data/lib/generators/scatter_gather_migration_001.rb.erb +0 -16
- data/scatter_gather-0.1.19.gem +0 -0
- data/test/dummy/db/migrate/20250101000001_add_scatter_gather_completions.rb +0 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 97e842d4c2d232e8b3230e0fc7139190af57d3495ccc43dde441e7e873788546
|
4
|
+
data.tar.gz: f542418d42fd82919da4efc41624e5a6855bbaf55fc007cf7bbe2891a461090f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/scatter_gather.rb
CHANGED
@@ -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
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
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
|
data/rbi/scatter_gather.rbi
CHANGED
@@ -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
|
33
|
+
poll_interval: 2
|
34
34
|
}.freeze, T.untyped)
|
35
|
-
VERSION = T.let("0.1.
|
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
|
44
|
-
#
|
45
|
-
|
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
|
-
|
50
|
-
|
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_ `
|
60
|
+
# _@param_ `dependency_statuses` — Array of dependency statuses
|
60
61
|
#
|
61
|
-
# _@
|
62
|
-
|
63
|
-
|
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 "
|
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(
|
107
|
-
def tally_in_logger_format(
|
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
|
-
#
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
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
|
127
|
-
#
|
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
|
-
|
144
|
+
attr_reader :dependency_statuses
|
130
145
|
|
131
|
-
# sord omit - no YARD
|
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
|
-
|
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
|
data/sig/scatter_gather.rbs
CHANGED
@@ -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
|
39
|
-
#
|
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_ `
|
46
|
+
# _@param_ `active_job_ids` — Array of ActiveJob IDs to check
|
55
47
|
#
|
56
|
-
# _@return_ —
|
57
|
-
def
|
58
|
-
end
|
48
|
+
# _@return_ — Array of DependencyStatus objects
|
49
|
+
def self.collect_statuses: (::Array[String] active_job_ids) -> ::Array[DependencyStatus]
|
59
50
|
|
60
|
-
|
61
|
-
|
62
|
-
#
|
63
|
-
#
|
64
|
-
|
65
|
-
|
66
|
-
|
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 "
|
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
|
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
|
-
#
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
110
|
-
#
|
111
|
-
|
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
|
114
|
-
|
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
|
data/test/dummy/db/schema.rb
CHANGED
@@ -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
|
8
|
-
# Old migrations may fail to apply correctly if those
|
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:
|
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"
|
data/test/scatter_gather_test.rb
CHANGED
@@ -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
|
data/test/smoke_test.rb
ADDED
@@ -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.
|
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-
|
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
|
data/scatter_gather-0.1.19.gem
DELETED
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
|