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 +7 -0
- data/VERSION +1 -0
- data/lib/massive-import/dashboard_server.rb +180 -0
- data/lib/massive-import/migration.rb +39 -0
- data/lib/massive-import/planner_job.rb +177 -0
- data/lib/massive-import/processor_job.rb +102 -0
- data/lib/massive-import.rb +180 -0
- metadata +156 -0
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="/">← 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: []
|