massive-import 0.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d69a58e87200826ed11aae6cc340efb71e41adb4bb153d86c9eb8a8d806efa17
4
+ data.tar.gz: 6068985e427a84ea61c715f916720c7a36452c78a8a23fc33c70aba07c80339c
5
+ SHA512:
6
+ metadata.gz: 2906b06253e68135693037f38c9f95d2a67c253d449a90688ea2a8c2f395b99459a9d4038e072446c18f7b3ad370e16528f06657122f6a175f4a662669e9751a
7
+ data.tar.gz: b3f72a3b62433ad7239847f7f3ebcf854b90e6270153ae83cc5cd1e683a33c02a0ac7bd10bdfafc5200286ec32fccc17a4817f3401fd36e45405d6a17b48d496
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.2
@@ -0,0 +1,180 @@
1
+ require 'webrick'
2
+ require 'cgi'
3
+
4
+ module MassiveImport
5
+ class DashboardServer
6
+ def start(**options)
7
+ port = options.fetch(:dashboard_port, (ENV["dashboard_port"] || 9292).to_i)
8
+ host = options.fetch(:dashboard_host, ENV["dashboard_host"] || "127.0.0.1")
9
+
10
+ logger = WEBrick::Log.new($stdout, WEBrick::Log::INFO)
11
+ server = WEBrick::HTTPServer.new(
12
+ BindAddress: host,
13
+ Port: port,
14
+ Logger: logger,
15
+ AccessLog: []
16
+ )
17
+
18
+ server.mount_proc('/') do |request, response|
19
+ dispatch(request, response, logger) do
20
+ home(request)
21
+ end
22
+ end
23
+
24
+ server.mount_proc('/imports') do |request, response|
25
+ dispatch(request, response, logger) do
26
+ import(request)
27
+ end
28
+ end
29
+
30
+ %w[INT TERM].each { |signal| trap(signal) { server.shutdown } }
31
+
32
+ server.start
33
+ end
34
+
35
+ def dispatch(request, response, logger)
36
+ unless request.request_method == 'GET'
37
+ respond(response, 405, layout(request, 'Method not allowed', '<h1>405 Method Not Allowed</h1>'))
38
+ return
39
+ end
40
+
41
+ title, content = yield
42
+
43
+ if content
44
+ respond(response, 200, layout(request, title, content))
45
+ else
46
+ respond(response, 404, layout(request, 'Not found', '<h1>404 Not Found</h1><p><a href="/">Back to imports</a></p>'))
47
+ end
48
+ rescue => e
49
+ logger.info("Unhandled exception: message=#{e.message}")
50
+ logger.info("Unhandled exception: trace=#{e.backtrace}")
51
+ respond(response, 500, layout(request, 'Error', "<h1>500 Internal Server Error</h1><p><a href=\"/\">Back to imports</a></p>"))
52
+ end
53
+
54
+ def home(request)
55
+ render_imports if request.path_info == '/'
56
+ end
57
+
58
+ def import(request)
59
+ render_import($1.to_i) if request.path_info =~ %r{\A/(\d+)\z}
60
+ end
61
+
62
+ def respond(response, status, body)
63
+ response.status = status
64
+ response['Content-Type'] = 'text/html; charset=utf-8'
65
+ response.body = body
66
+ end
67
+
68
+ def render_imports
69
+ imports = Import.order(id: :desc).to_a
70
+ return ['Imports', '<h1>Imports</h1><p>No imports found.</p>'] if imports.empty?
71
+
72
+ headers = ['ID', 'Status', 'Processor', 'Attempt', 'Total', 'Batched', 'Concurrency', 'Batch size']
73
+ rows = imports.map do |imp|
74
+ [
75
+ %(<a href="/imports/#{imp.id}">##{imp.id}</a>),
76
+ h(imp.status),
77
+ h(imp.processor_class),
78
+ "#{imp.attempt} / #{imp.max_attempts}",
79
+ imp.total_records,
80
+ "#{imp.batch_records} / #{imp.total_records}",
81
+ "#{imp.current_batch_concurrency} / #{imp.max_batch_concurrency}",
82
+ imp.batch_size
83
+ ]
84
+ end
85
+
86
+ ['Imports', "<h1>Imports</h1>#{table(headers, rows)}"]
87
+ end
88
+
89
+ def render_import(id)
90
+ imp = Import.find_by(id: id)
91
+ return ['Not found', '<h1>Import not found</h1><p><a href="/">Back to imports</a></p>'] unless imp
92
+
93
+ batch_stats = Batch.where(import_id: imp.id).group(:status).count
94
+ record_stats = Record.where(import_id: imp.id).group(:status).count
95
+ batches = Batch.where(import_id: imp.id).order(id: :desc).limit(100).to_a
96
+
97
+ summary_headers = ['Processor', 'Attempt', 'Total records', 'Batched records', 'Concurrency', 'Batch size']
98
+ summary_values = [
99
+ h(imp.processor_class),
100
+ "#{imp.attempt} / #{imp.max_attempts}",
101
+ imp.total_records,
102
+ "#{imp.batch_records} / #{imp.total_records}",
103
+ "#{imp.current_batch_concurrency} / #{imp.max_batch_concurrency}",
104
+ imp.batch_size
105
+ ]
106
+
107
+ content = +''
108
+ content << %(<p><a href="/">&larr; All imports</a></p>)
109
+ content << "<h1>Import ##{imp.id} (#{h(imp.status)})</h1>"
110
+ content << table(summary_headers, [summary_values])
111
+
112
+ content << '<h2>Batches by status</h2>'
113
+ content << stats_table(batch_stats)
114
+ content << '<h2>Records by status</h2>'
115
+ content << stats_table(record_stats)
116
+ content << '<h2>Recent 100 batches</h2>'
117
+ content << batches_table(batches)
118
+
119
+ ["Import ##{imp.id}", content]
120
+ end
121
+
122
+ def stats_table(stats)
123
+ return '<p>None.</p>' if stats.empty?
124
+
125
+ rows = stats.map { |status, count| [h(status), count] }
126
+ rows << ['<strong>Total</strong>', "<strong>#{stats.values.sum}</strong>"]
127
+
128
+ table(['Status', 'Count'], rows)
129
+ end
130
+
131
+ def batches_table(batches)
132
+ return '<p>No batches yet.</p>' if batches.empty?
133
+
134
+ rows = batches.map do |b|
135
+ started = b.started_at ? Time.at(b.started_at.to_i).strftime('%Y-%m-%d %H:%M:%S') : '-'
136
+ ["##{b.id}", b.attempt, h(b.status), b.start_id, b.end_id, started]
137
+ end
138
+
139
+ table(['Batch', 'Attempt', 'Status', 'Start ID', 'End ID', 'Started'], rows)
140
+ end
141
+
142
+ def table(headers, rows)
143
+ head = headers.empty? ? '' : "<thead><tr>#{headers.map { |header| "<th>#{header}</th>" }.join}</tr></thead>"
144
+ body = rows.map do |cells|
145
+ "<tr>#{cells.map { |cell| "<td>#{cell}</td>" }.join}</tr>"
146
+ end.join
147
+
148
+ <<~TABLE
149
+ <table border="1" cellpadding="4" cellspacing="0">
150
+ #{head}
151
+ <tbody>#{body}</tbody>
152
+ </table>
153
+ TABLE
154
+ end
155
+
156
+ def h(text)
157
+ CGI.escapeHTML(text.to_s)
158
+ end
159
+
160
+ def layout(request, title, content)
161
+ refresh = request.query['refresh'].to_i
162
+ refresh_html = +''
163
+ refresh_html << %(<meta http-equiv="refresh" content="#{refresh}">) if refresh > 0
164
+
165
+ <<~HTML
166
+ <!DOCTYPE html>
167
+ <html lang="en">
168
+ <head>
169
+ <meta charset="utf-8">
170
+ #{refresh_html}
171
+ <title>#{h(title)}</title>
172
+ </head>
173
+ <body>
174
+ #{content}
175
+ </body>
176
+ </html>
177
+ HTML
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,39 @@
1
+ # class CreateMassiveImportTables < ActiveRecord::Migration[8.1]
2
+ # def change
3
+ # create_table :massive_import_imports do |t|
4
+ # t.column :attempt, :tinyint, null: false, default: 1
5
+ # t.column :status, :string, limit: 50, default: 'PENDING'
6
+ # t.column :total_records, :bigint, null: false, default: 0
7
+ # t.column :batch_records, :bigint, null: false, default: 0
8
+ # t.column :max_batch_concurrency, :smallint, null: false, default: 5
9
+ # t.column :current_batch_concurrency, :smallint, null: false, default: 0
10
+ # t.column :max_attempts, :tinyint, null: false, default: 3
11
+ # t.column :batch_size, :smallint, null: false, default: 50
12
+ # t.column :end_id, :bigint, null: false, default: 0
13
+ # t.text :processor_class
14
+ # t.column :planner_locked_until, :bigint
15
+ # t.text :planner_token
16
+ # end
17
+ #
18
+ # create_table :massive_import_batches do |t|
19
+ # t.column :import_id, :bigint, null: false
20
+ # t.column :attempt, :tinyint, null: false, default: 1
21
+ # t.column :status, :string, limit: 50, default: 'PENDING'
22
+ # t.column :start_id, :bigint, null: false, default: 0
23
+ # t.column :end_id, :bigint, null: false, default: 0
24
+ # t.column :started_at, :bigint
25
+ # t.text :token
26
+ # end
27
+ #
28
+ # add_index :massive_import_batches, [:import_id, :attempt, :status], name: 'idx_import_attempt_status'
29
+ #
30
+ # create_table :massive_import_records do |t|
31
+ # t.column :import_id, :bigint, null: false
32
+ # t.column :attempt, :tinyint, null: false, default: 1
33
+ # t.column :status, :string, limit: 50, default: 'PENDING'
34
+ # t.json :data
35
+ # end
36
+ #
37
+ # add_index :massive_import_records, [:import_id, :attempt, :status], name: 'idx_import_attempt_status'
38
+ # end
39
+ # end
@@ -0,0 +1,177 @@
1
+ require 'securerandom'
2
+
3
+ module MassiveImport
4
+ class PlannerJob
5
+ include Sidekiq::Job
6
+ sidekiq_retry_in do |count, exception, jobhash|
7
+ MassiveImport.configuration.planning_interval + rand(1..10)
8
+ end
9
+
10
+ def perform(args)
11
+ import_id = args['import_id']
12
+ return unless import_id
13
+
14
+ import = Import.find_by(id: import_id, status: 'RUNNING')
15
+ return unless import
16
+
17
+ token = SecureRandom.hex
18
+ lock_acquired = acquire_planner_lock(import, token)
19
+ return unless lock_acquired
20
+
21
+ if import.total_records == 0
22
+ import.update_columns(status: 'COMPLETED')
23
+ return
24
+ end
25
+
26
+ recover_timedout_batches(import)
27
+
28
+ batch_stats = Batch.where(import_id: import.id, attempt: import.attempt).group(:status).count
29
+ pending_batches = batch_stats.fetch('PENDING', 0)
30
+ running_batches = batch_stats.fetch('RUNNING', 0)
31
+ batches_created = 0
32
+
33
+ if pending_batches * 2 <= import.max_batch_concurrency
34
+ batches_created = create_batches(import, token)
35
+ return if batches_created < 0
36
+
37
+ if batches_created == 0 && pending_batches == 0 && running_batches == 0
38
+ import.reload
39
+ done = handle_attempt_completion(import, token)
40
+ return if done
41
+ end
42
+ end
43
+
44
+ import.reload
45
+ dispatch_workers(import, import.max_batch_concurrency, token)
46
+ enqueue_self(import)
47
+ ensure
48
+ release_planner_lock(import, token) if lock_acquired
49
+ end
50
+
51
+ private
52
+
53
+ def acquire_planner_lock(import, token)
54
+ now = Time.current.to_i
55
+ lease = MassiveImport.configuration.planner_lease
56
+
57
+ Import
58
+ .where(id: import.id, status: 'RUNNING')
59
+ .where("planner_locked_until IS NULL OR planner_locked_until < ?", now)
60
+ .update_all(planner_locked_until: now + lease, planner_token: token) > 0
61
+ end
62
+
63
+ def release_planner_lock(import, token)
64
+ Import
65
+ .where(id: import.id, planner_token: token)
66
+ .update_all(planner_locked_until: nil, planner_token: nil)
67
+ end
68
+
69
+ def recover_timedout_batches(import)
70
+ updated = Batch
71
+ .where(import_id: import.id, attempt: import.attempt, status: 'RUNNING')
72
+ .where('started_at < ?', Time.current.to_i - MassiveImport.configuration.batch_timeout)
73
+ .update_all(status: 'PENDING', started_at: nil)
74
+
75
+ if updated > 0
76
+ Import.where(id: import.id)
77
+ .where("current_batch_concurrency > 0")
78
+ .update_all(["current_batch_concurrency = GREATEST(0, current_batch_concurrency - ?)", updated])
79
+ import.reload
80
+ end
81
+ end
82
+
83
+ def dispatch_workers(import, requested_count, token)
84
+ slots_available = import.max_batch_concurrency - import.current_batch_concurrency
85
+ workers_to_enqueue = [requested_count, slots_available].min
86
+
87
+ workers_to_enqueue.times do
88
+ enqueue_processor(import, token)
89
+ end
90
+ end
91
+
92
+ def enqueue_processor(import, token)
93
+ updated = Import
94
+ .where(id: import.id, status: 'RUNNING', planner_token: token)
95
+ .where("current_batch_concurrency < max_batch_concurrency")
96
+ .update_all("current_batch_concurrency = current_batch_concurrency + 1")
97
+
98
+ if updated > 0
99
+ ProcessorJob
100
+ .set(queue: MassiveImport.configuration.queue_name)
101
+ .perform_async({ 'import_id' => import.id })
102
+ end
103
+ end
104
+
105
+ def enqueue_self(import)
106
+ self.class.set(queue: MassiveImport.configuration.queue_name)
107
+ .perform_in(MassiveImport.configuration.planning_interval, { 'import_id' => import.id })
108
+ end
109
+
110
+ def create_batches(import, token)
111
+ return 0 if (import.batch_records >= import.total_records)
112
+
113
+ batch_size = import.batch_size
114
+ end_id = import.end_id
115
+ new_batches = []
116
+ new_batch_records = 0
117
+ import.max_batch_concurrency.times do
118
+ records = Record
119
+ .where(import_id: import.id, attempt: import.attempt, status: 'PENDING')
120
+ .where("id > ?", end_id)
121
+ .order(:id)
122
+ .limit(batch_size)
123
+ .pluck(:id)
124
+
125
+ break if records.empty?
126
+
127
+ end_id = records[-1]
128
+ new_batch_records += records.size
129
+
130
+ new_batches << {
131
+ import_id: import.id,
132
+ attempt: import.attempt,
133
+ status: 'PENDING',
134
+ start_id: records[0],
135
+ end_id: end_id
136
+ }
137
+ end
138
+ return 0 if new_batches.empty?
139
+
140
+ committed =
141
+ ActiveRecord::Base.transaction do
142
+ updated = Import
143
+ .where(id: import.id, status: 'RUNNING', planner_token: token)
144
+ .update_all(["end_id = ?, batch_records = batch_records + ?", end_id, new_batch_records])
145
+
146
+ raise ActiveRecord::Rollback unless updated > 0
147
+
148
+ Batch.insert_all(new_batches)
149
+ end
150
+
151
+ return -1 unless committed
152
+ new_batches.size
153
+ end
154
+
155
+ def handle_attempt_completion(import, token)
156
+ batch_records = import.batch_records
157
+ total_records = import.total_records
158
+ return false if batch_records < total_records
159
+
160
+ retry_count = Record.where(import_id: import.id, attempt: import.attempt + 1, status: 'PENDING').count
161
+ if retry_count == 0 || import.attempt >= import.max_attempts
162
+ updated = Import
163
+ .where(id: import.id, status: 'RUNNING', planner_token: token)
164
+ .update_all(status: 'COMPLETED')
165
+ return updated > 0
166
+ end
167
+
168
+ updated = Import
169
+ .where(id: import.id, status: 'RUNNING', planner_token: token)
170
+ .update_all(["attempt = attempt + 1, end_id = 0, batch_records = 0, total_records = ?", retry_count])
171
+ return false unless updated > 0
172
+
173
+ enqueue_self(import)
174
+ true
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,102 @@
1
+ require 'securerandom'
2
+
3
+ module MassiveImport
4
+ class ProcessorJob
5
+ include Sidekiq::Job
6
+ sidekiq_options retry: false
7
+
8
+ VALID_RECORD_STATUSES = ['COMPLETED', 'INVALID']
9
+
10
+ def perform(args)
11
+ batch_claimed = false
12
+ import_id = args['import_id']
13
+ return unless import_id
14
+
15
+ import = Import.find_by(id: import_id, status: 'RUNNING')
16
+ return unless import
17
+
18
+ token = SecureRandom.hex
19
+ batch = claim_batch(import, token)
20
+ return unless batch
21
+
22
+ batch_claimed = true
23
+ process_batch(import, batch, token)
24
+ rescue
25
+ decrement_current_concurrency(import_id) unless batch_claimed
26
+ raise
27
+ end
28
+
29
+ def claim_batch(import, token)
30
+ ActiveRecord::Base.transaction do
31
+ batch = Batch
32
+ .lock("FOR UPDATE SKIP LOCKED")
33
+ .find_by(import_id: import.id, status: 'PENDING', attempt: import.attempt)
34
+
35
+ if batch
36
+ batch.update_columns(status: 'RUNNING', started_at: Time.current.to_i, token: token)
37
+ batch
38
+ else
39
+ decrement_current_concurrency(import.id)
40
+ nil
41
+ end
42
+ end
43
+ end
44
+
45
+ def decrement_current_concurrency(import_id)
46
+ Import
47
+ .where(id: import_id)
48
+ .where("current_batch_concurrency > 0")
49
+ .update_all("current_batch_concurrency = GREATEST(0, current_batch_concurrency - 1)")
50
+ end
51
+
52
+ def process_batch(import, batch, token)
53
+ processor_instance = (import.processor_class.constantize.new rescue nil)
54
+
55
+ Record.where(import_id: import.id, attempt: import.attempt, status: 'PENDING', id: (batch.start_id..batch.end_id))
56
+ .find_in_batches(batch_size: 50) do |slice|
57
+
58
+ updated = Batch.where(id: batch.id, status: 'RUNNING', token: token).update_all(started_at: Time.current.to_i)
59
+ return unless updated > 0
60
+
61
+ records_by_status = Hash.new { |h, k| h[k] = [] }
62
+
63
+ slice.each do |record|
64
+ status = process_record(processor_instance, record)
65
+ records_by_status[status] << record.id
66
+ end
67
+
68
+ update_record_statuses(import, records_by_status)
69
+ end
70
+
71
+ ActiveRecord::Base.transaction do
72
+ updated = Batch
73
+ .where(id: batch.id, status: 'RUNNING', token: token)
74
+ .update_all(status: 'COMPLETED')
75
+ raise ActiveRecord::Rollback unless updated > 0
76
+ decrement_current_concurrency(import.id)
77
+ end
78
+ end
79
+
80
+ def process_record(processor_instance, record)
81
+ return 'INVALID' unless processor_instance && processor_instance.respond_to?(:process)
82
+ status = processor_instance.process(record.data)
83
+ VALID_RECORD_STATUSES.include?(status) ? status : 'RETRY'
84
+ rescue
85
+ 'RETRY'
86
+ end
87
+
88
+ def update_record_statuses(import, records_by_status)
89
+ records_by_status.each do |record_status, ids|
90
+ if record_status == 'RETRY'
91
+ Record.where(import_id: import.id, attempt: import.attempt, id: ids)
92
+ .update_all("status = 'PENDING', attempt = attempt + 1")
93
+ else
94
+ Record.where(import_id: import.id, attempt: import.attempt, id: ids)
95
+ .update_all(status: record_status)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+
@@ -0,0 +1,180 @@
1
+ require 'active_record'
2
+ require 'sidekiq'
3
+ require 'logger'
4
+
5
+ require_relative 'massive-import/planner_job'
6
+ require_relative 'massive-import/processor_job'
7
+ require_relative 'massive-import/dashboard_server'
8
+
9
+
10
+ module MassiveImport
11
+ class Configuration
12
+ attr_accessor :queue_name, :batch_timeout, :planning_interval, :planner_lease
13
+
14
+ def initialize
15
+ unless ActiveRecord::Base.connected?
16
+ ActiveRecord::Base.establish_connection(
17
+ adapter: 'mysql2',
18
+ host: ENV['host'],
19
+ username: ENV['username'],
20
+ password: ENV['password'],
21
+ database: ENV['database']
22
+ )
23
+ end
24
+ ActiveRecord::Base.logger ||= Logger.new($stdout)
25
+ end
26
+
27
+ def queue_name
28
+ @queue_name || :default
29
+ end
30
+
31
+ def batch_timeout
32
+ @batch_timeout || 900
33
+ end
34
+
35
+ def planning_interval
36
+ @planning_interval || 5
37
+ end
38
+
39
+ def planner_lease
40
+ @planner_lease || 900
41
+ end
42
+ end
43
+
44
+ # CREATE TABLE `massive_import_imports` (
45
+ # `id` bigint unsigned NOT NULL AUTO_INCREMENT,
46
+ # `attempt` tinyint NOT NULL DEFAULT 1,
47
+ # `status` varchar(50) DEFAULT 'PENDING',
48
+ # `total_records` bigint unsigned NOT NULL DEFAULT 0,
49
+ # `batch_records` bigint unsigned NOT NULL DEFAULT 0,
50
+ # `max_batch_concurrency` smallint unsigned NOT NULL DEFAULT 5,
51
+ # `current_batch_concurrency` smallint unsigned NOT NULL DEFAULT 0,
52
+ # `max_attempts` tinyint NOT NULL DEFAULT 3,
53
+ # `batch_size` smallint NOT NULL DEFAULT 50,
54
+ # `end_id` bigint unsigned NOT NULL DEFAULT 0,
55
+ # `processor_class` text DEFAULT NULL,
56
+ # `planner_locked_until` bigint unsigned DEFAULT NULL,
57
+ # `planner_token` text DEFAULT NULL,
58
+ # PRIMARY KEY (`id`)
59
+ # ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
60
+
61
+ class Import < ActiveRecord::Base
62
+ def self.table_name_prefix
63
+ 'massive_import_'
64
+ end
65
+ end
66
+
67
+ # CREATE TABLE `massive_import_batches` (
68
+ # `id` bigint unsigned NOT NULL AUTO_INCREMENT,
69
+ # `import_id` bigint unsigned NOT NULL,
70
+ # `attempt` tinyint NOT NULL DEFAULT 1,
71
+ # `status` varchar(50) DEFAULT 'PENDING',
72
+ # `start_id` bigint unsigned NOT NULL DEFAULT 0,
73
+ # `end_id` bigint unsigned NOT NULL DEFAULT 0,
74
+ # `started_at` bigint unsigned DEFAULT NULL,
75
+ # `token` text DEFAULT NULL,
76
+ # PRIMARY KEY (`id`),
77
+ # KEY `idx_import_attempt_status` (`import_id`, `attempt`, `status`)
78
+ # ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
79
+
80
+
81
+ class Batch < ActiveRecord::Base
82
+ def self.table_name_prefix
83
+ 'massive_import_'
84
+ end
85
+ end
86
+
87
+ # CREATE TABLE `massive_import_records` (
88
+ # `id` bigint unsigned NOT NULL AUTO_INCREMENT,
89
+ # `import_id` bigint unsigned NOT NULL,
90
+ # `attempt` tinyint NOT NULL DEFAULT 1,
91
+ # `status` varchar(50) DEFAULT 'PENDING',
92
+ # `data` json DEFAULT NULL,
93
+ # PRIMARY KEY (`id`),
94
+ # KEY `idx_import_attempt_status` (`import_id`, `attempt`, `status`)
95
+ # ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
96
+
97
+ class Record < ActiveRecord::Base
98
+ def self.table_name_prefix
99
+ 'massive_import_'
100
+ end
101
+ end
102
+
103
+ class << self
104
+ attr_accessor :configuration
105
+
106
+ def configuration
107
+ @configuration ||= Configuration.new
108
+ end
109
+
110
+ def configure
111
+ yield(configuration)
112
+ end
113
+
114
+ def reset!
115
+ @configuration = Configuration.new
116
+ end
117
+
118
+ def stage_records(import, enumerable, **options)
119
+ import.reload
120
+ max_attempts = options.fetch(:max_attempts, import.max_attempts)
121
+ processor_class = options.fetch(:processor_class, import.processor_class)
122
+ batch_size = options.fetch(:batch_size, import.batch_size)
123
+ max_concurrency = options.fetch(:max_batch_concurrency, import.max_batch_concurrency)
124
+
125
+ updated = Import
126
+ .where(id: import.id, status: 'PENDING')
127
+ .update_all(
128
+ status: 'STAGING',
129
+ max_attempts: max_attempts,
130
+ processor_class: processor_class.to_s,
131
+ batch_size: batch_size,
132
+ max_batch_concurrency: max_concurrency
133
+ )
134
+
135
+ return unless updated > 0
136
+
137
+ import.reload
138
+ end_id = import.end_id
139
+ enumerable.each_slice(import.batch_size) do |slice|
140
+ records = slice.map do |record|
141
+ {
142
+ import_id: import.id,
143
+ attempt: import.attempt,
144
+ status: 'PENDING',
145
+ data: record
146
+ }
147
+ end
148
+
149
+ committed =
150
+ ActiveRecord::Base.transaction do
151
+ Record.insert_all(records)
152
+ end_id = Record
153
+ .where(import_id: import.id, attempt: import.attempt)
154
+ .order(id: :desc)
155
+ .limit(1)
156
+ .pick(:id)
157
+ raise ActiveRecord::Rollback unless end_id
158
+
159
+ updated = Import
160
+ .where(id: import.id, status: 'STAGING')
161
+ .update_all(["total_records = total_records + ?, end_id = ?", records.size, end_id])
162
+ raise ActiveRecord::Rollback unless updated > 0
163
+
164
+ true
165
+ end
166
+
167
+ return unless committed
168
+ end
169
+
170
+ updated = Import
171
+ .where(id: import.id, status: 'STAGING')
172
+ .update_all(status: 'RUNNING', end_id: 0)
173
+ return unless updated > 0
174
+
175
+ PlannerJob.set(queue: MassiveImport.configuration.queue_name).perform_async({'import_id' => import.id})
176
+ end
177
+ end
178
+
179
+ configuration
180
+ end
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: massive-import
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Laughing Orca
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-08-11 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: mysql2
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sidekiq
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '8.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '8.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webrick
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.9'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.9'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rack
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.2'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.2'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rack-session
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.1'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.1'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rackup
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '2.3'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '2.3'
110
+ - !ruby/object:Gem::Dependency
111
+ name: byebug
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '13.0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '13.0'
124
+ description: execute massive imports!
125
+ email: trulyop100@gmail.com
126
+ executables: []
127
+ extensions: []
128
+ extra_rdoc_files: []
129
+ files:
130
+ - VERSION
131
+ - lib/massive-import.rb
132
+ - lib/massive-import/dashboard_server.rb
133
+ - lib/massive-import/migration.rb
134
+ - lib/massive-import/planner_job.rb
135
+ - lib/massive-import/processor_job.rb
136
+ homepage: https://github.com/laughing-orca/massive-import
137
+ licenses: []
138
+ metadata: {}
139
+ rdoc_options: []
140
+ require_paths:
141
+ - lib
142
+ required_ruby_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ required_rubygems_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ requirements: []
153
+ rubygems_version: 3.6.2
154
+ specification_version: 4
155
+ summary: execute massive imports!
156
+ test_files: []