groonga-delta 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +26 -0
- data/LICENSE.txt +674 -0
- data/README.md +43 -0
- data/Rakefile +36 -0
- data/bin/groonga-delta-apply +21 -0
- data/bin/groonga-delta-import +21 -0
- data/doc/text/news.md +5 -0
- data/groonga-delta.gemspec +54 -0
- data/lib/groonga-delta/apply-command.rb +38 -0
- data/lib/groonga-delta/apply-config.rb +78 -0
- data/lib/groonga-delta/apply-status.rb +24 -0
- data/lib/groonga-delta/command.rb +75 -0
- data/lib/groonga-delta/config.rb +99 -0
- data/lib/groonga-delta/error.rb +28 -0
- data/lib/groonga-delta/import-command.rb +43 -0
- data/lib/groonga-delta/import-config.rb +168 -0
- data/lib/groonga-delta/import-status.rb +68 -0
- data/lib/groonga-delta/local-delta.rb +386 -0
- data/lib/groonga-delta/local-source.rb +134 -0
- data/lib/groonga-delta/ltsv-log-formatter.rb +50 -0
- data/lib/groonga-delta/mapping.rb +314 -0
- data/lib/groonga-delta/mysql-source.rb +391 -0
- data/lib/groonga-delta/status.rb +43 -0
- data/lib/groonga-delta/version.rb +18 -0
- data/lib/groonga-delta/writer.rb +135 -0
- data/lib/groonga-delta.rb +18 -0
- metadata +114 -0
@@ -0,0 +1,391 @@
|
|
1
|
+
# Copyright (C) 2021-2022 Sutou Kouhei <kou@clear-code.com>
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
require "arrow"
|
17
|
+
require "mysql2"
|
18
|
+
|
19
|
+
require_relative "error"
|
20
|
+
require_relative "writer"
|
21
|
+
|
22
|
+
module GroongaDelta
|
23
|
+
class MySQLSource
|
24
|
+
def initialize(config, status)
|
25
|
+
@logger = config.logger
|
26
|
+
@writer = Writer.new(@logger, config.delta_dir)
|
27
|
+
@config = config.mysql
|
28
|
+
@binlog_dir = @config.binlog_dir
|
29
|
+
@mapping = config.mapping
|
30
|
+
@status = status.mysql
|
31
|
+
@tables = {}
|
32
|
+
end
|
33
|
+
|
34
|
+
def import
|
35
|
+
case ENV["GROONGA_DELTA_IMPORT_MYSQL_SOURCE_BACKEND"]
|
36
|
+
when "mysqlbinlog"
|
37
|
+
require "mysql_binlog"
|
38
|
+
import_mysqlbinlog
|
39
|
+
when "mysql2-replication"
|
40
|
+
require "mysql2-replication"
|
41
|
+
import_mysql2_replication
|
42
|
+
else
|
43
|
+
begin
|
44
|
+
require "mysql2-replication"
|
45
|
+
rescue LoadError
|
46
|
+
require "mysql_binlog"
|
47
|
+
import_mysqlbinlog
|
48
|
+
else
|
49
|
+
import_mysql2_replication
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
def import_mysqlbinlog
|
56
|
+
file, position = read_current_status
|
57
|
+
FileUtils.mkdir_p(@binlog_dir)
|
58
|
+
local_file = File.join(@binlog_dir, file)
|
59
|
+
unless File.exist?(local_file.succ)
|
60
|
+
command_line = [@config.mysqlbinlog].flatten
|
61
|
+
command_line << "--host=#{@config.host}" if @config.host
|
62
|
+
command_line << "--port=#{@config.port}" if @config.port
|
63
|
+
command_line << "--socket=#{@config.socket}" if @config.socket
|
64
|
+
if @config.replication_slave_user
|
65
|
+
command_line << "--user=#{@config.replication_slave_user}"
|
66
|
+
end
|
67
|
+
if @config.replication_slave_password
|
68
|
+
command_line << "--password=#{@config.replication_slave_password}"
|
69
|
+
end
|
70
|
+
command_line << "--read-from-remote-server"
|
71
|
+
command_line << "--raw"
|
72
|
+
command_line << "--result-file=#{@binlog_dir}/"
|
73
|
+
command_line << file
|
74
|
+
spawn_process(*command_line) do |pid, output_read, error_read|
|
75
|
+
end
|
76
|
+
end
|
77
|
+
reader = MysqlBinlog::BinlogFileReader.new(local_file)
|
78
|
+
binlog = MysqlBinlog::Binlog.new(reader)
|
79
|
+
binlog.checksum = @config.checksum
|
80
|
+
binlog.ignore_rotate = true
|
81
|
+
binlog.each_event do |event|
|
82
|
+
next if event[:position] < position
|
83
|
+
case event[:type]
|
84
|
+
when :rotate_event
|
85
|
+
@status.update("file" => event[:event][:name],
|
86
|
+
"position" => event[:event][:pos])
|
87
|
+
when :write_rows_event_v1,
|
88
|
+
:write_rows_event_v2,
|
89
|
+
:update_rows_event_v1,
|
90
|
+
:update_rows_event_v2,
|
91
|
+
:delete_rows_event_v1,
|
92
|
+
:delete_rows_event_v2
|
93
|
+
normalized_type = event[:type].to_s.gsub(/_v\d\z/, "").to_sym
|
94
|
+
import_rows_event(normalized_type,
|
95
|
+
event[:event][:table][:db],
|
96
|
+
event[:event][:table][:table],
|
97
|
+
file,
|
98
|
+
event[:header][:next_position]) do
|
99
|
+
case normalized_type
|
100
|
+
when :write_rows_event,
|
101
|
+
:update_rows_event
|
102
|
+
event[:event][:row_image].collect do |row_image|
|
103
|
+
build_row(row_image[:after][:image])
|
104
|
+
end
|
105
|
+
when :delete_rows_event
|
106
|
+
event[:event][:row_image].collect do |row_image|
|
107
|
+
build_row(row_image[:before][:image])
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
position = event[:header][:next_position]
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def import_mysql2_replication
|
117
|
+
file, position = read_current_status
|
118
|
+
is_mysql_56_or_later = mysql(@config.select_user,
|
119
|
+
@config.select_password) do |select_client|
|
120
|
+
mysql_version(select_client) >= Gem::Version.new("5.6")
|
121
|
+
end
|
122
|
+
mysql(@config.replication_slave_user,
|
123
|
+
@config.replication_slave_password) do |client|
|
124
|
+
if is_mysql_56_or_later
|
125
|
+
replication_client = Mysql2Replication::Client.new(client)
|
126
|
+
else
|
127
|
+
replication_client = Mysql2Replication::Client.new(client,
|
128
|
+
checksum: "NONE")
|
129
|
+
end
|
130
|
+
replication_client.file_name = file
|
131
|
+
replication_client.start_position = position
|
132
|
+
replication_client.open do
|
133
|
+
replication_client.each do |event|
|
134
|
+
case event
|
135
|
+
when Mysql2Replication::RotateEvent
|
136
|
+
file = event.file_name
|
137
|
+
when Mysql2Replication::RowsEvent
|
138
|
+
event_name = event.class.name.split("::").last
|
139
|
+
normalized_type =
|
140
|
+
event_name.scan(/[A-Z][a-z]+/).
|
141
|
+
collect(&:downcase).
|
142
|
+
join("_").
|
143
|
+
to_sym
|
144
|
+
import_rows_event(normalized_type,
|
145
|
+
event.table_map.database,
|
146
|
+
event.table_map.table,
|
147
|
+
file,
|
148
|
+
event.next_position) do
|
149
|
+
case normalized_type
|
150
|
+
when :update_rows_event
|
151
|
+
event.updated_rows
|
152
|
+
else
|
153
|
+
event.rows
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def import_rows_event(type,
|
163
|
+
database_name,
|
164
|
+
table_name,
|
165
|
+
file,
|
166
|
+
next_position,
|
167
|
+
&block)
|
168
|
+
source_table = @mapping[database_name, table_name]
|
169
|
+
return if source_table.nil?
|
170
|
+
|
171
|
+
table = find_table(database_name, table_name)
|
172
|
+
groonga_table = source_table.groonga_table
|
173
|
+
target_rows = block.call
|
174
|
+
groonga_records = target_rows.collect do |row|
|
175
|
+
record = build_record(table, row)
|
176
|
+
groonga_table.generate_record(record)
|
177
|
+
end
|
178
|
+
return if groonga_records.empty?
|
179
|
+
|
180
|
+
case type
|
181
|
+
when :write_rows_event,
|
182
|
+
:update_rows_event
|
183
|
+
@writer.write_upserts(groonga_table.name, groonga_records)
|
184
|
+
when :delete_rows_event
|
185
|
+
groonga_record_keys = groonga_records.collect do |record|
|
186
|
+
record[:_key]
|
187
|
+
end
|
188
|
+
@writer.write_deletes(groonga_table.name,
|
189
|
+
groonga_record_keys)
|
190
|
+
end
|
191
|
+
@status.update("file" => file,
|
192
|
+
"position" => next_position)
|
193
|
+
end
|
194
|
+
|
195
|
+
def wait_process(command_line, pid, output_read, error_read)
|
196
|
+
begin
|
197
|
+
_, status = Process.waitpid2(pid)
|
198
|
+
rescue SystemCallError
|
199
|
+
else
|
200
|
+
unless status.success?
|
201
|
+
message = "Failed to run: #{command_line.join(' ')}\n"
|
202
|
+
message << "--- output ---\n"
|
203
|
+
message << output_read.read
|
204
|
+
message << "--------------\n"
|
205
|
+
message << "--- error ----\n"
|
206
|
+
message << error_read.read
|
207
|
+
message << "--------------\n"
|
208
|
+
raise ProcessError, message
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
def spawn_process(*command_line)
|
214
|
+
env = {
|
215
|
+
"LC_ALL" => "C",
|
216
|
+
}
|
217
|
+
output_read, output_write = IO.pipe
|
218
|
+
error_read, error_write = IO.pipe
|
219
|
+
options = {
|
220
|
+
:out => output_write,
|
221
|
+
:err => error_write,
|
222
|
+
}
|
223
|
+
pid = spawn(env, *command_line, options)
|
224
|
+
output_write.close
|
225
|
+
error_write.close
|
226
|
+
if block_given?
|
227
|
+
begin
|
228
|
+
yield(pid, output_read, error_read)
|
229
|
+
rescue
|
230
|
+
begin
|
231
|
+
Process.kill(:TERM, pid)
|
232
|
+
rescue SystemCallError
|
233
|
+
end
|
234
|
+
raise
|
235
|
+
ensure
|
236
|
+
wait_process(command_line, pid, output_read, error_read)
|
237
|
+
output_read.close unless output_read.closed?
|
238
|
+
error_read.close unless error_read.closed?
|
239
|
+
end
|
240
|
+
else
|
241
|
+
[pid, output_read, error_read]
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def mysql(user, password)
|
246
|
+
options = {}
|
247
|
+
options[:host] = @config.host if @config.host
|
248
|
+
options[:port] = @config.port if @config.port
|
249
|
+
options[:socket] = @config.socket if @config.socket
|
250
|
+
options[:username] = user if user
|
251
|
+
options[:password] = password if password
|
252
|
+
client = Mysql2::Client.new(**options)
|
253
|
+
begin
|
254
|
+
yield(client)
|
255
|
+
ensure
|
256
|
+
client.close unless client.closed?
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
def mysql_version(client)
|
261
|
+
version = client.query("SELECT version()", as: :array).first.first
|
262
|
+
Gem::Version.new(version)
|
263
|
+
end
|
264
|
+
|
265
|
+
def read_current_status
|
266
|
+
if @status.file
|
267
|
+
[@status.file, @status.position]
|
268
|
+
else
|
269
|
+
file = nil
|
270
|
+
position = 0
|
271
|
+
mysql(@config.replication_client_user,
|
272
|
+
@config.replication_client_password) do |replication_client|
|
273
|
+
replication_client.query("FLUSH TABLES WITH READ LOCK")
|
274
|
+
result = replication_client.query("SHOW MASTER STATUS").first
|
275
|
+
file = result["File"]
|
276
|
+
position = result["Position"]
|
277
|
+
mysql(@config.select_user,
|
278
|
+
@config.select_password) do |select_client|
|
279
|
+
start_transaction = "START TRANSACTION " +
|
280
|
+
"WITH CONSISTENT SNAPSHOT"
|
281
|
+
if mysql_version(select_client) >= Gem::Version.new("5.6")
|
282
|
+
start_transaction += ", READ ONLY"
|
283
|
+
end
|
284
|
+
select_client.query(start_transaction)
|
285
|
+
replication_client.close
|
286
|
+
import_existing_data(select_client)
|
287
|
+
select_client.query("ROLLBACK")
|
288
|
+
end
|
289
|
+
end
|
290
|
+
@status.update("file" => file,
|
291
|
+
"position" => position)
|
292
|
+
[file, position]
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
def import_existing_data(client)
|
297
|
+
@mapping.source_databases.each do |source_database|
|
298
|
+
source_database.source_tables.each do |source_table|
|
299
|
+
statement = client.prepare(<<~SQL)
|
300
|
+
SELECT COUNT(*) AS n_tables
|
301
|
+
FROM information_schema.tables
|
302
|
+
WHERE
|
303
|
+
table_schema = ? AND
|
304
|
+
table_name = ?
|
305
|
+
SQL
|
306
|
+
result = statement.execute(source_database.name,
|
307
|
+
source_table.name)
|
308
|
+
n_tables = result.first["n_tables"]
|
309
|
+
statement.close
|
310
|
+
next if n_tables.zero?
|
311
|
+
full_table_name = "#{source_database.name}.#{source_table.name}"
|
312
|
+
source_column_names = source_table.source_column_names
|
313
|
+
column_list = source_column_names.join(", ")
|
314
|
+
select = "SELECT #{column_list} FROM #{full_table_name}"
|
315
|
+
if source_table.source_filter
|
316
|
+
select << " WHERE #{source_table.source_filter}"
|
317
|
+
end
|
318
|
+
result = client.query(select,
|
319
|
+
symbolize_keys: true,
|
320
|
+
cache_rows: false,
|
321
|
+
stream: true)
|
322
|
+
groonga_table = source_table.groonga_table
|
323
|
+
target_message = "#{full_table_name} -> #{groonga_table.name}"
|
324
|
+
@logger.info("Start importing: #{target_message}")
|
325
|
+
enumerator = result.to_enum(:each)
|
326
|
+
n_rows = 0
|
327
|
+
batch_size = @config.initial_import_batch_size
|
328
|
+
enumerator.each_slice(batch_size) do |rows|
|
329
|
+
@logger.info("Generating records: #{target_message}")
|
330
|
+
groonga_record_batch = groonga_table.generate_record_batch(rows)
|
331
|
+
@logger.info("Generated records: #{target_message}")
|
332
|
+
@writer.write_upserts(groonga_table.name,
|
333
|
+
groonga_record_batch.to_table)
|
334
|
+
n_rows += rows.size
|
335
|
+
@logger.info("Importing: #{target_message}: " +
|
336
|
+
"#{n_rows}(+#{rows.size})")
|
337
|
+
end
|
338
|
+
@logger.info("Imported: #{target_message}: #{n_rows}")
|
339
|
+
end
|
340
|
+
end
|
341
|
+
end
|
342
|
+
|
343
|
+
def find_table(database_name, table_name)
|
344
|
+
return @tables[table_name] if @tables.key?(table_name)
|
345
|
+
|
346
|
+
mysql(@config.select_user,
|
347
|
+
@config.select_password) do |client|
|
348
|
+
statement = client.prepare(<<~SQL)
|
349
|
+
SELECT column_name,
|
350
|
+
ordinal_position,
|
351
|
+
data_type,
|
352
|
+
column_key
|
353
|
+
FROM information_schema.columns
|
354
|
+
WHERE
|
355
|
+
table_schema = ? AND
|
356
|
+
table_name = ?
|
357
|
+
SQL
|
358
|
+
result = statement.execute(database_name, table_name)
|
359
|
+
columns = result.collect do |column|
|
360
|
+
{
|
361
|
+
name: column["column_name"],
|
362
|
+
ordinal_position: column["ordinal_position"],
|
363
|
+
data_type: column["data_type"],
|
364
|
+
is_primary_key: column["column_key"] == "PRI",
|
365
|
+
}
|
366
|
+
end
|
367
|
+
@tables[table_name] = columns.sort_by do |column|
|
368
|
+
column[:ordinal_position]
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
|
373
|
+
def build_row(value_pairs)
|
374
|
+
row = {}
|
375
|
+
value_pairs.each do |value_pair|
|
376
|
+
value_pair.each do |column_index, value|
|
377
|
+
row[column_index] = value
|
378
|
+
end
|
379
|
+
end
|
380
|
+
row
|
381
|
+
end
|
382
|
+
|
383
|
+
def build_record(table, row)
|
384
|
+
record = {}
|
385
|
+
row.each do |column_index, value|
|
386
|
+
record[table[column_index][:name].to_sym] = value
|
387
|
+
end
|
388
|
+
record
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# Copyright (C) 2021-2022 Sutou Kouhei <kou@clear-code.com>
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
require "fileutils"
|
17
|
+
require "yaml"
|
18
|
+
|
19
|
+
module GroongaDelta
|
20
|
+
class Status
|
21
|
+
def initialize(dir)
|
22
|
+
@dir = dir
|
23
|
+
@path = File.join(@dir, "status.yaml")
|
24
|
+
if File.exist?(@path)
|
25
|
+
@data = YAML.load(File.read(@path))
|
26
|
+
else
|
27
|
+
@data = {}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def [](key)
|
32
|
+
@data[key]
|
33
|
+
end
|
34
|
+
|
35
|
+
def update(data)
|
36
|
+
@data.update(data)
|
37
|
+
FileUtils.mkdir_p(@dir)
|
38
|
+
File.open(@path, "w") do |output|
|
39
|
+
output.puts(YAML.dump(@data))
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Copyright (C) 2021-2022 Sutou Kouhei <kou@clear-code.com>
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
module GroongaDelta
|
17
|
+
VERSION = "1.0.0"
|
18
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# Copyright (C) 2021-2022 Sutou Kouhei <kou@clear-code.com>
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
require "fileutils"
|
17
|
+
require "json"
|
18
|
+
|
19
|
+
require "groonga/command"
|
20
|
+
require "parquet"
|
21
|
+
|
22
|
+
module GroongaDelta
|
23
|
+
class Writer
|
24
|
+
def initialize(logger, dir)
|
25
|
+
@logger = logger
|
26
|
+
@dir = dir
|
27
|
+
end
|
28
|
+
|
29
|
+
def write_upserts(table, records, packed: false)
|
30
|
+
if records.is_a?(Arrow::Table)
|
31
|
+
write_data(table,
|
32
|
+
"upsert",
|
33
|
+
".parquet",
|
34
|
+
packed: packed,
|
35
|
+
open_output: false) do |output|
|
36
|
+
records.save(output, format: :parquet)
|
37
|
+
end
|
38
|
+
else
|
39
|
+
write_data(table, "upsert", ".grn", packed: packed) do |output|
|
40
|
+
first_record = true
|
41
|
+
records.each do |record|
|
42
|
+
if first_record
|
43
|
+
output.puts("load --table #{table}")
|
44
|
+
output.print("[")
|
45
|
+
first_record = false
|
46
|
+
else
|
47
|
+
output.print(",")
|
48
|
+
end
|
49
|
+
output.puts
|
50
|
+
json = "{"
|
51
|
+
record.each_with_index do |(key, value), i|
|
52
|
+
json << "," unless i.zero?
|
53
|
+
json << "#{key.to_s.to_json}:"
|
54
|
+
case value
|
55
|
+
when Time
|
56
|
+
json << value.dup.localtime.strftime("%Y-%m-%d %H:%M:%S").to_json
|
57
|
+
else
|
58
|
+
json << value.to_json
|
59
|
+
end
|
60
|
+
end
|
61
|
+
json << "}"
|
62
|
+
output.print(json)
|
63
|
+
end
|
64
|
+
unless first_record
|
65
|
+
output.puts()
|
66
|
+
output.puts("]")
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def write_deletes(table, keys)
|
73
|
+
write_data(table, "delete", ".grn") do |output|
|
74
|
+
delete = Groonga::Command::Delete.new
|
75
|
+
delete[:table] = table
|
76
|
+
keys.each do |key|
|
77
|
+
delete[:key] = key
|
78
|
+
output.puts(delete.to_command_format)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def write_schema(command)
|
84
|
+
write_entry("schema", ".grn") do |output|
|
85
|
+
output.puts(command.to_command_format)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
def write_entry(prefix, suffix, packed: false, open_output: true)
|
91
|
+
timestamp = Time.now.utc
|
92
|
+
base_name = timestamp.strftime("%Y-%m-%d-%H-%M-%S-%N#{suffix}")
|
93
|
+
if packed
|
94
|
+
dir = "#{@dir}/#{prefix}/packed"
|
95
|
+
packed_dir_base_name = timestamp.strftime("%Y-%m-%d-%H-%M-%S-%N")
|
96
|
+
temporary_path = "#{dir}/.#{packed_dir_base_name}/#{base_name}"
|
97
|
+
path = "#{dir}/#{packed_dir_base_name}/#{base_name}"
|
98
|
+
else
|
99
|
+
day = timestamp.strftime("%Y-%m-%d")
|
100
|
+
dir = "#{@dir}/#{prefix}/#{day}"
|
101
|
+
temporary_path = "#{dir}/.#{base_name}"
|
102
|
+
path = "#{dir}/#{base_name}"
|
103
|
+
end
|
104
|
+
@logger.info("Start writing: #{temporary_path}")
|
105
|
+
FileUtils.mkdir_p(File.dirname(temporary_path))
|
106
|
+
if open_output
|
107
|
+
File.open(temporary_path, "w") do |output|
|
108
|
+
yield(output)
|
109
|
+
end
|
110
|
+
else
|
111
|
+
yield(temporary_path)
|
112
|
+
end
|
113
|
+
if packed
|
114
|
+
FileUtils.mv(File.dirname(temporary_path),
|
115
|
+
File.dirname(path))
|
116
|
+
else
|
117
|
+
FileUtils.mv(temporary_path, path)
|
118
|
+
end
|
119
|
+
@logger.info("Wrote: #{path}")
|
120
|
+
end
|
121
|
+
|
122
|
+
def write_data(table,
|
123
|
+
action,
|
124
|
+
suffix,
|
125
|
+
packed: false,
|
126
|
+
open_output: true,
|
127
|
+
&block)
|
128
|
+
write_entry("data/#{table}",
|
129
|
+
"-#{action}#{suffix}",
|
130
|
+
packed: packed,
|
131
|
+
open_output: open_output,
|
132
|
+
&block)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Copyright (C) 2021-2022 Sutou Kouhei <kou@clear-code.com>
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
require_relative "groonga-delta/apply-command"
|
17
|
+
require_relative "groonga-delta/import-command"
|
18
|
+
require_relative "groonga-delta/version"
|