fluent-plugin-mysql-replicator 1.0.3 → 1.2.0
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/.devcontainer/Dockerfile +17 -0
- data/.devcontainer/devcontainer.json +16 -0
- data/.dockerignore +4 -0
- data/.github/workflows/ci.yml +165 -0
- data/.gitignore +1 -0
- data/CHANGELOG.md +90 -0
- data/README.md +80 -5
- data/Tutorial-mysql_replicator.md +21 -1
- data/ci/install_smoke.sh +19 -0
- data/ci/verify_plugins_load.rb +5 -0
- data/fluent-plugin-mysql-replicator.gemspec +1 -1
- data/lib/fluent/plugin/in_mysql_replicator.rb +47 -9
- data/lib/fluent/plugin/in_mysql_replicator_multi.rb +19 -0
- data/lib/fluent/plugin/out_mysql_replicator_elasticsearch.rb +44 -7
- data/setup_mysql_replicator_multi.sql +3 -0
- data/test/e2e/e2e_helper.rb +94 -0
- data/test/e2e/fluent_multi.conf +30 -0
- data/test/e2e/fluent_single.conf +29 -0
- data/test/e2e/replication_multi_e2e_test.rb +140 -0
- data/test/e2e/replication_single_e2e_test.rb +91 -0
- data/test/plugin/test_in_mysql_replicator.rb +100 -0
- data/test/plugin/test_out_mysql_replicator_elasticsearch.rb +22 -0
- metadata +20 -4
- data/.travis.yml +0 -18
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'net/http'
|
|
2
2
|
require 'date'
|
|
3
|
+
require 'yajl'
|
|
3
4
|
require 'fluent/plugin/output'
|
|
4
5
|
|
|
5
6
|
class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Output
|
|
@@ -38,6 +39,8 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
|
|
|
38
39
|
|
|
39
40
|
def start
|
|
40
41
|
super
|
|
42
|
+
# nil means "not yet detected"; resolved on the first write.
|
|
43
|
+
@suppress_type = nil
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
def format(tag, time, record)
|
|
@@ -57,6 +60,8 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
|
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
def write(chunk)
|
|
63
|
+
detect_type_suppression if @suppress_type.nil?
|
|
64
|
+
|
|
60
65
|
bulk_message = []
|
|
61
66
|
|
|
62
67
|
chunk.msgpack_each do |tag, time, record|
|
|
@@ -66,28 +71,60 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
|
|
|
66
71
|
id_key = tag_parts['primary_key']
|
|
67
72
|
|
|
68
73
|
if tag_parts['event'] == 'delete'
|
|
69
|
-
|
|
74
|
+
action = {"_index" => target_index, "_id" => record[id_key]}
|
|
75
|
+
action['_type'] = target_type unless @suppress_type
|
|
76
|
+
meta = { "delete" => action }
|
|
70
77
|
bulk_message << Yajl::Encoder.encode(meta)
|
|
71
78
|
else
|
|
72
|
-
|
|
79
|
+
action = {"_index" => target_index}
|
|
80
|
+
action['_type'] = target_type unless @suppress_type
|
|
73
81
|
if id_key && record[id_key]
|
|
74
|
-
|
|
82
|
+
action['_id'] = record[id_key]
|
|
75
83
|
end
|
|
84
|
+
meta = { "index" => action }
|
|
76
85
|
bulk_message << Yajl::Encoder.encode(meta)
|
|
77
86
|
bulk_message << Yajl::Encoder.encode(record)
|
|
78
87
|
end
|
|
79
88
|
end
|
|
80
89
|
bulk_message << ""
|
|
81
90
|
|
|
82
|
-
http = Net::HTTP.new(@host, @port.to_i)
|
|
83
|
-
http.use_ssl = @ssl
|
|
84
|
-
|
|
85
91
|
request = Net::HTTP::Post.new('/_bulk', {'content-type' => 'application/json; charset=utf-8'})
|
|
86
92
|
if @username && @password
|
|
87
93
|
request.basic_auth(@username, @password)
|
|
88
94
|
end
|
|
89
95
|
|
|
90
96
|
request.body = bulk_message.join("\n")
|
|
91
|
-
|
|
97
|
+
new_http.request(request).value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def new_http
|
|
103
|
+
http = Net::HTTP.new(@host, @port.to_i)
|
|
104
|
+
http.use_ssl = @ssl
|
|
105
|
+
http
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Mapping types were removed in Elasticsearch 8.x and deprecated in 7.x.
|
|
109
|
+
# Detect the major version once and omit "_type" for 7.x and later.
|
|
110
|
+
def detect_type_suppression
|
|
111
|
+
major = elasticsearch_major_version
|
|
112
|
+
@suppress_type = !major.nil? && major >= 7
|
|
113
|
+
if major
|
|
114
|
+
log.info "mysql_replicator_elasticsearch: detected Elasticsearch #{major}.x, suppress_type=#{@suppress_type}"
|
|
115
|
+
else
|
|
116
|
+
log.warn "mysql_replicator_elasticsearch: could not detect Elasticsearch version, sending '_type' (assuming 6.x)"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def elasticsearch_major_version
|
|
121
|
+
request = Net::HTTP::Get.new('/')
|
|
122
|
+
request.basic_auth(@username, @password) if @username && @password
|
|
123
|
+
response = new_http.request(request)
|
|
124
|
+
number = Yajl::Parser.parse(response.body).dig('version', 'number')
|
|
125
|
+
number.to_s.split('.').first.to_i
|
|
126
|
+
rescue => e
|
|
127
|
+
log.warn "mysql_replicator_elasticsearch: version detection failed: #{e.message}"
|
|
128
|
+
nil
|
|
92
129
|
end
|
|
93
130
|
end
|
|
@@ -24,6 +24,9 @@ CREATE TABLE IF NOT EXISTS `settings` (
|
|
|
24
24
|
`prepared_query` TEXT NOT NULL,
|
|
25
25
|
`interval` int(11) NOT NULL,
|
|
26
26
|
`primary_key` varchar(255) DEFAULT 'id',
|
|
27
|
+
-- Comma-separated column names whose MySQL JSON values should be parsed into nested objects.
|
|
28
|
+
-- Intended for Elasticsearch; leave empty for Solr or destinations that cannot store JSON objects.
|
|
29
|
+
`json_columns` varchar(255) DEFAULT NULL,
|
|
27
30
|
`enable_delete` int(11) DEFAULT '1',
|
|
28
31
|
-- On enabling 'enable_loose_insert: 1', make it faster synchronization to skip checking hash_tables.
|
|
29
32
|
`enable_loose_insert` int(11) DEFAULT '0',
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Shared helpers for the end-to-end replication tests.
|
|
2
|
+
#
|
|
3
|
+
# Connection settings come from environment variables (with local defaults):
|
|
4
|
+
# MYSQL_HOST MYSQL_PORT MYSQL_USER MYSQL_PASSWORD
|
|
5
|
+
# ES_HOST ES_PORT
|
|
6
|
+
require 'mysql2'
|
|
7
|
+
require 'net/http'
|
|
8
|
+
require 'json'
|
|
9
|
+
|
|
10
|
+
module E2E
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
ROOT = File.expand_path('../..', __dir__)
|
|
14
|
+
|
|
15
|
+
def mysql_config(database: nil)
|
|
16
|
+
cfg = {
|
|
17
|
+
host: ENV['MYSQL_HOST'] || '127.0.0.1',
|
|
18
|
+
port: (ENV['MYSQL_PORT'] || 3306).to_i,
|
|
19
|
+
username: ENV['MYSQL_USER'] || 'root',
|
|
20
|
+
password: ENV['MYSQL_PASSWORD'] || 'root',
|
|
21
|
+
}
|
|
22
|
+
cfg[:database] = database if database
|
|
23
|
+
cfg
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def es_base
|
|
27
|
+
"http://#{ENV['ES_HOST'] || '127.0.0.1'}:#{ENV['ES_PORT'] || '9200'}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Major version of the Elasticsearch under test (defaults to 6).
|
|
31
|
+
def es_major_version
|
|
32
|
+
(ENV['ES_MAJOR_VERSION'] || '6').to_i
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Realtime GET by id. Returns [http_status, parsed_body].
|
|
36
|
+
# Elasticsearch 7.x+ dropped custom mapping types, so documents are addressed
|
|
37
|
+
# via the "_doc" endpoint instead of the original type name.
|
|
38
|
+
def es_get(index, type, id)
|
|
39
|
+
type_path = es_major_version >= 7 ? '_doc' : type
|
|
40
|
+
res = Net::HTTP.get_response(URI("#{es_base}/#{index}/#{type_path}/#{id}"))
|
|
41
|
+
[res.code.to_i, (JSON.parse(res.body) rescue {})]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Drop an index so a test starts from a clean slate (ignores "not found").
|
|
45
|
+
def es_delete_index(index)
|
|
46
|
+
uri = URI("#{es_base}/#{index}")
|
|
47
|
+
Net::HTTP.start(uri.host, uri.port) { |h| h.request(Net::HTTP::Delete.new(uri)) }
|
|
48
|
+
rescue StandardError
|
|
49
|
+
# ignore: the index may not exist yet
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Poll a condition until it becomes truthy, otherwise fail with a timeout.
|
|
53
|
+
def wait_until(description, timeout: 90, log_path: nil)
|
|
54
|
+
deadline = Time.now + timeout
|
|
55
|
+
loop do
|
|
56
|
+
return if yield
|
|
57
|
+
fail_with("timeout (#{timeout}s) waiting for: #{description}", log_path) if Time.now > deadline
|
|
58
|
+
sleep 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fail_with(message, log_path = nil)
|
|
63
|
+
warn "\n[E2E] FAILED: #{message}"
|
|
64
|
+
if log_path && File.exist?(log_path)
|
|
65
|
+
warn "\n----- fluentd.log (tail) -----"
|
|
66
|
+
warn File.readlines(log_path).last(60).join
|
|
67
|
+
warn "------------------------------"
|
|
68
|
+
end
|
|
69
|
+
exit 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def step(message)
|
|
73
|
+
puts "[E2E] #{message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Boot fluentd as a child process. Returns the pid; logs go to log_path.
|
|
77
|
+
def spawn_fluentd(conf, log_path)
|
|
78
|
+
log = File.open(log_path, 'w')
|
|
79
|
+
Process.spawn(
|
|
80
|
+
'bundle', 'exec', 'fluentd',
|
|
81
|
+
'-c', conf,
|
|
82
|
+
'-p', File.join(ROOT, 'lib', 'fluent', 'plugin'),
|
|
83
|
+
'--no-supervisor',
|
|
84
|
+
out: log, err: log, chdir: ROOT
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stop_fluentd(pid)
|
|
89
|
+
Process.kill('TERM', pid)
|
|
90
|
+
Process.wait(pid)
|
|
91
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
92
|
+
# already gone
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# E2E pipeline for the multi-table input plugin.
|
|
2
|
+
# in_mysql_replicator_multi reads replication settings from the management
|
|
3
|
+
# database (replicator_manager.settings) and persists per-row hashes into
|
|
4
|
+
# replicator_manager.hash_tables. See test/e2e/replication_multi_e2e_test.rb.
|
|
5
|
+
#
|
|
6
|
+
# bulk_insert_count is set to 1 so hash_tables is flushed immediately on each
|
|
7
|
+
# insert, which keeps the test timing deterministic.
|
|
8
|
+
<source>
|
|
9
|
+
@type mysql_replicator_multi
|
|
10
|
+
manager_host "#{ENV['MYSQL_HOST'] || '127.0.0.1'}"
|
|
11
|
+
manager_port "#{ENV['MYSQL_PORT'] || '3306'}"
|
|
12
|
+
manager_username "#{ENV['MYSQL_USER'] || 'root'}"
|
|
13
|
+
manager_password "#{ENV['MYSQL_PASSWORD'] || 'root'}"
|
|
14
|
+
manager_database "#{ENV['MYSQL_MANAGER_DATABASE'] || 'replicator_manager'}"
|
|
15
|
+
tag multiindex.multitype.${event}.${primary_key}
|
|
16
|
+
bulk_insert_count 1
|
|
17
|
+
bulk_insert_timeout 2
|
|
18
|
+
</source>
|
|
19
|
+
|
|
20
|
+
<match multiindex.**>
|
|
21
|
+
@type mysql_replicator_elasticsearch
|
|
22
|
+
host "#{ENV['ES_HOST'] || '127.0.0.1'}"
|
|
23
|
+
port "#{ENV['ES_PORT'] || '9200'}"
|
|
24
|
+
<buffer>
|
|
25
|
+
@type memory
|
|
26
|
+
flush_mode interval
|
|
27
|
+
flush_interval 1s
|
|
28
|
+
flush_thread_count 1
|
|
29
|
+
</buffer>
|
|
30
|
+
</match>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# E2E pipeline: MySQL (in_mysql_replicator) -> Elasticsearch (out_mysql_replicator_elasticsearch)
|
|
2
|
+
# Connection details are injected via environment variables so the same config
|
|
3
|
+
# works both in CI and locally. See test/e2e/replication_e2e_test.rb.
|
|
4
|
+
<source>
|
|
5
|
+
@type mysql_replicator
|
|
6
|
+
host "#{ENV['MYSQL_HOST'] || '127.0.0.1'}"
|
|
7
|
+
port "#{ENV['MYSQL_PORT'] || '3306'}"
|
|
8
|
+
username "#{ENV['MYSQL_USER'] || 'root'}"
|
|
9
|
+
password "#{ENV['MYSQL_PASSWORD'] || 'root'}"
|
|
10
|
+
database "#{ENV['MYSQL_DATABASE'] || 'e2e_source'}"
|
|
11
|
+
query SELECT id, name, age, profile FROM users
|
|
12
|
+
primary_key id
|
|
13
|
+
json_columns profile
|
|
14
|
+
interval 2s
|
|
15
|
+
enable_delete true
|
|
16
|
+
tag myindex.mytype.${event}.${primary_key}
|
|
17
|
+
</source>
|
|
18
|
+
|
|
19
|
+
<match myindex.**>
|
|
20
|
+
@type mysql_replicator_elasticsearch
|
|
21
|
+
host "#{ENV['ES_HOST'] || '127.0.0.1'}"
|
|
22
|
+
port "#{ENV['ES_PORT'] || '9200'}"
|
|
23
|
+
<buffer>
|
|
24
|
+
@type memory
|
|
25
|
+
flush_mode interval
|
|
26
|
+
flush_interval 1s
|
|
27
|
+
flush_thread_count 1
|
|
28
|
+
</buffer>
|
|
29
|
+
</match>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# End-to-end test for the multi-table input plugin (in_mysql_replicator_multi).
|
|
3
|
+
#
|
|
4
|
+
# Unlike the single plugin, this one is driven by a management database:
|
|
5
|
+
# - replicator_manager.settings : one row per replication job
|
|
6
|
+
# - replicator_manager.hash_tables: persisted per-row hashes (delete detection)
|
|
7
|
+
#
|
|
8
|
+
# This test builds that management DB from setup_mysql_replicator_multi.sql,
|
|
9
|
+
# registers a settings row pointing at a source table, boots Fluentd wiring
|
|
10
|
+
# in_mysql_replicator_multi -> out_mysql_replicator_elasticsearch, and asserts
|
|
11
|
+
# that INSERT / UPDATE / DELETE propagate to Elasticsearch AND that the
|
|
12
|
+
# hash_tables state is maintained.
|
|
13
|
+
#
|
|
14
|
+
# Delete detection in the multi plugin is gap-based against hash_tables, so the
|
|
15
|
+
# test seeds three rows and deletes the *middle* one (id=2) to exercise it.
|
|
16
|
+
require_relative 'e2e_helper'
|
|
17
|
+
include E2E
|
|
18
|
+
|
|
19
|
+
CONF = File.join(ROOT, 'test', 'e2e', 'fluent_multi.conf')
|
|
20
|
+
LOG_PATH = File.join(ROOT, 'test', 'e2e', 'fluentd_multi.log')
|
|
21
|
+
SETUP_SQL = File.join(ROOT, 'setup_mysql_replicator_multi.sql')
|
|
22
|
+
|
|
23
|
+
SOURCE_DB = ENV['MYSQL_DATABASE'] || 'e2e_source'
|
|
24
|
+
MANAGER_DB = ENV['MYSQL_MANAGER_DATABASE'] || 'replicator_manager'
|
|
25
|
+
SETTING_NAME = 'users_to_es'
|
|
26
|
+
INDEX = 'multiindex'
|
|
27
|
+
TYPE = 'multitype'
|
|
28
|
+
|
|
29
|
+
def hash_table_count(client, pk: nil)
|
|
30
|
+
where = "setting_name = '#{SETTING_NAME}'"
|
|
31
|
+
where += " AND setting_query_pk = #{pk}" if pk
|
|
32
|
+
client.query("SELECT COUNT(*) AS c FROM `#{MANAGER_DB}`.hash_tables WHERE #{where}").first['c']
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# --- 1. Seed the source database --------------------------------------------
|
|
36
|
+
step "seeding MySQL source database '#{SOURCE_DB}'"
|
|
37
|
+
es_delete_index(INDEX)
|
|
38
|
+
client = Mysql2::Client.new(mysql_config)
|
|
39
|
+
client.query("DROP DATABASE IF EXISTS `#{SOURCE_DB}`")
|
|
40
|
+
client.query("CREATE DATABASE `#{SOURCE_DB}`")
|
|
41
|
+
client.query("USE `#{SOURCE_DB}`")
|
|
42
|
+
client.query(<<~SQL)
|
|
43
|
+
CREATE TABLE users (
|
|
44
|
+
id INT NOT NULL AUTO_INCREMENT,
|
|
45
|
+
name VARCHAR(255) NOT NULL,
|
|
46
|
+
age INT NOT NULL,
|
|
47
|
+
profile JSON,
|
|
48
|
+
PRIMARY KEY (id)
|
|
49
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8
|
|
50
|
+
SQL
|
|
51
|
+
client.query(<<~SQL)
|
|
52
|
+
INSERT INTO users (name, age, profile) VALUES
|
|
53
|
+
('alice', 20, '{"city":"Tokyo","tags":["a","b"]}'),
|
|
54
|
+
('bob', 30, '{"city":"Osaka","tags":["c"]}'),
|
|
55
|
+
('carol', 40, '{"city":"Nagoya","tags":[]}')
|
|
56
|
+
SQL
|
|
57
|
+
|
|
58
|
+
# --- 2. Build the management database and register a replication setting ----
|
|
59
|
+
step "building management database '#{MANAGER_DB}' from setup SQL"
|
|
60
|
+
client.query("DROP DATABASE IF EXISTS `#{MANAGER_DB}`")
|
|
61
|
+
File.read(SETUP_SQL).split(/;\s*$/).map(&:strip).reject(&:empty?).each do |stmt|
|
|
62
|
+
client.query(stmt)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
step "registering settings row '#{SETTING_NAME}'"
|
|
66
|
+
cfg = mysql_config
|
|
67
|
+
client.query(<<~SQL)
|
|
68
|
+
INSERT INTO `#{MANAGER_DB}`.settings
|
|
69
|
+
(is_active, name, host, port, username, password, `database`,
|
|
70
|
+
query, prepared_query, `interval`, primary_key, enable_delete, json_columns)
|
|
71
|
+
VALUES
|
|
72
|
+
(1, '#{SETTING_NAME}', '#{cfg[:host]}', #{cfg[:port]},
|
|
73
|
+
'#{cfg[:username]}', '#{client.escape(cfg[:password].to_s)}', '#{SOURCE_DB}',
|
|
74
|
+
'SELECT id, name, age, profile FROM users ORDER BY id', '', 2, 'id', 1, 'profile')
|
|
75
|
+
SQL
|
|
76
|
+
|
|
77
|
+
# --- 3. Boot Fluentd --------------------------------------------------------
|
|
78
|
+
step "starting Fluentd (#{CONF})"
|
|
79
|
+
fluentd_pid = spawn_fluentd(CONF, LOG_PATH)
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
# --- 4. INSERT is replicated to Elasticsearch -----------------------------
|
|
83
|
+
step "asserting INSERT replication"
|
|
84
|
+
{1 => 'alice', 2 => 'bob', 3 => 'carol'}.each do |id, name|
|
|
85
|
+
wait_until("user #{id} (#{name}) indexed in Elasticsearch", log_path: LOG_PATH) do
|
|
86
|
+
code, body = es_get(INDEX, TYPE, id)
|
|
87
|
+
code == 200 && body.dig('_source', 'name') == name
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
step " INSERT OK"
|
|
91
|
+
|
|
92
|
+
# --- 4b. JSON column is indexed as a nested object (not an escaped string) -
|
|
93
|
+
step "asserting JSON column is replicated as a nested object"
|
|
94
|
+
wait_until("user 1 profile.city == Tokyo in Elasticsearch", log_path: LOG_PATH) do
|
|
95
|
+
code, body = es_get(INDEX, TYPE, 1)
|
|
96
|
+
code == 200 && body.dig('_source', 'profile', 'city') == 'Tokyo'
|
|
97
|
+
end
|
|
98
|
+
_, body = es_get(INDEX, TYPE, 1)
|
|
99
|
+
unless body.dig('_source', 'profile', 'tags') == ['a', 'b']
|
|
100
|
+
fail_with("profile was not indexed as a nested object: #{body.dig('_source', 'profile').inspect}", LOG_PATH)
|
|
101
|
+
end
|
|
102
|
+
step " JSON OK"
|
|
103
|
+
|
|
104
|
+
# --- 5. hash_tables state is persisted ------------------------------------
|
|
105
|
+
step "asserting hash_tables persistence"
|
|
106
|
+
wait_until("3 rows recorded in hash_tables for '#{SETTING_NAME}'", log_path: LOG_PATH) do
|
|
107
|
+
hash_table_count(client) == 3
|
|
108
|
+
end
|
|
109
|
+
step " hash_tables OK"
|
|
110
|
+
|
|
111
|
+
# --- 6. UPDATE is replicated ----------------------------------------------
|
|
112
|
+
step "asserting UPDATE replication"
|
|
113
|
+
client.query("UPDATE `#{SOURCE_DB}`.users SET age = 21 WHERE id = 1")
|
|
114
|
+
wait_until("user 1 age updated to 21 in Elasticsearch", log_path: LOG_PATH) do
|
|
115
|
+
code, body = es_get(INDEX, TYPE, 1)
|
|
116
|
+
code == 200 && body.dig('_source', 'age') == 21
|
|
117
|
+
end
|
|
118
|
+
step " UPDATE OK"
|
|
119
|
+
|
|
120
|
+
# --- 7. DELETE (middle id) is replicated ----------------------------------
|
|
121
|
+
step "asserting DELETE replication (middle id=2)"
|
|
122
|
+
client.query("DELETE FROM `#{SOURCE_DB}`.users WHERE id = 2")
|
|
123
|
+
wait_until("user 2 removed from Elasticsearch", log_path: LOG_PATH) do
|
|
124
|
+
code, _ = es_get(INDEX, TYPE, 2)
|
|
125
|
+
code == 404
|
|
126
|
+
end
|
|
127
|
+
wait_until("hash_tables entry for id=2 removed", log_path: LOG_PATH) do
|
|
128
|
+
hash_table_count(client, pk: 2) == 0
|
|
129
|
+
end
|
|
130
|
+
# Surviving rows must remain.
|
|
131
|
+
code1, = es_get(INDEX, TYPE, 1)
|
|
132
|
+
code3, = es_get(INDEX, TYPE, 3)
|
|
133
|
+
fail_with("surviving rows were unexpectedly removed (id1=#{code1}, id3=#{code3})", LOG_PATH) unless code1 == 200 && code3 == 200
|
|
134
|
+
step " DELETE OK"
|
|
135
|
+
|
|
136
|
+
puts "\n[E2E] PASSED (multi): insert/update/delete replicated and hash_tables maintained"
|
|
137
|
+
ensure
|
|
138
|
+
step "stopping Fluentd"
|
|
139
|
+
stop_fluentd(fluentd_pid)
|
|
140
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# End-to-end test for the single-table input plugin (in_mysql_replicator).
|
|
3
|
+
#
|
|
4
|
+
# It seeds a source table in MySQL, boots a real Fluentd process wiring
|
|
5
|
+
# in_mysql_replicator -> out_mysql_replicator_elasticsearch (fluent_single.conf),
|
|
6
|
+
# and asserts that INSERT / UPDATE / DELETE on MySQL are replicated to
|
|
7
|
+
# Elasticsearch.
|
|
8
|
+
require_relative 'e2e_helper'
|
|
9
|
+
include E2E
|
|
10
|
+
|
|
11
|
+
CONF = File.join(ROOT, 'test', 'e2e', 'fluent_single.conf')
|
|
12
|
+
LOG_PATH = File.join(ROOT, 'test', 'e2e', 'fluentd_single.log')
|
|
13
|
+
|
|
14
|
+
DB = ENV['MYSQL_DATABASE'] || 'e2e_source'
|
|
15
|
+
INDEX = 'myindex'
|
|
16
|
+
TYPE = 'mytype'
|
|
17
|
+
|
|
18
|
+
# --- 1. Seed the source database --------------------------------------------
|
|
19
|
+
step "seeding MySQL source database '#{DB}'"
|
|
20
|
+
es_delete_index(INDEX)
|
|
21
|
+
client = Mysql2::Client.new(mysql_config)
|
|
22
|
+
client.query("DROP DATABASE IF EXISTS `#{DB}`")
|
|
23
|
+
client.query("CREATE DATABASE `#{DB}`")
|
|
24
|
+
client.query("USE `#{DB}`")
|
|
25
|
+
client.query(<<~SQL)
|
|
26
|
+
CREATE TABLE users (
|
|
27
|
+
id INT NOT NULL AUTO_INCREMENT,
|
|
28
|
+
name VARCHAR(255) NOT NULL,
|
|
29
|
+
age INT NOT NULL,
|
|
30
|
+
profile JSON,
|
|
31
|
+
PRIMARY KEY (id)
|
|
32
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8
|
|
33
|
+
SQL
|
|
34
|
+
client.query(<<~SQL)
|
|
35
|
+
INSERT INTO users (name, age, profile) VALUES
|
|
36
|
+
('alice', 20, '{"city":"Tokyo","tags":["a","b"]}'),
|
|
37
|
+
('bob', 30, '{"city":"Osaka","tags":["c"]}')
|
|
38
|
+
SQL
|
|
39
|
+
|
|
40
|
+
# --- 2. Boot Fluentd --------------------------------------------------------
|
|
41
|
+
step "starting Fluentd (#{CONF})"
|
|
42
|
+
fluentd_pid = spawn_fluentd(CONF, LOG_PATH)
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
# --- 3. INSERT is replicated ----------------------------------------------
|
|
46
|
+
step "asserting INSERT replication"
|
|
47
|
+
wait_until("user 1 (alice) indexed in Elasticsearch", log_path: LOG_PATH) do
|
|
48
|
+
code, body = es_get(INDEX, TYPE, 1)
|
|
49
|
+
code == 200 && body.dig('_source', 'name') == 'alice'
|
|
50
|
+
end
|
|
51
|
+
wait_until("user 2 (bob) indexed in Elasticsearch", log_path: LOG_PATH) do
|
|
52
|
+
code, body = es_get(INDEX, TYPE, 2)
|
|
53
|
+
code == 200 && body.dig('_source', 'name') == 'bob'
|
|
54
|
+
end
|
|
55
|
+
step " INSERT OK"
|
|
56
|
+
|
|
57
|
+
# --- 3b. JSON column is indexed as a nested object (not an escaped string) -
|
|
58
|
+
step "asserting JSON column is replicated as a nested object"
|
|
59
|
+
wait_until("user 1 profile.city == Tokyo in Elasticsearch", log_path: LOG_PATH) do
|
|
60
|
+
code, body = es_get(INDEX, TYPE, 1)
|
|
61
|
+
code == 200 && body.dig('_source', 'profile', 'city') == 'Tokyo'
|
|
62
|
+
end
|
|
63
|
+
_, body = es_get(INDEX, TYPE, 1)
|
|
64
|
+
unless body.dig('_source', 'profile', 'tags') == ['a', 'b']
|
|
65
|
+
fail_with("profile was not indexed as a nested object: #{body.dig('_source', 'profile').inspect}", LOG_PATH)
|
|
66
|
+
end
|
|
67
|
+
step " JSON OK"
|
|
68
|
+
|
|
69
|
+
# --- 4. UPDATE is replicated ----------------------------------------------
|
|
70
|
+
step "asserting UPDATE replication"
|
|
71
|
+
client.query("UPDATE users SET age = 21 WHERE id = 1")
|
|
72
|
+
wait_until("user 1 age updated to 21 in Elasticsearch", log_path: LOG_PATH) do
|
|
73
|
+
code, body = es_get(INDEX, TYPE, 1)
|
|
74
|
+
code == 200 && body.dig('_source', 'age') == 21
|
|
75
|
+
end
|
|
76
|
+
step " UPDATE OK"
|
|
77
|
+
|
|
78
|
+
# --- 5. DELETE is replicated ----------------------------------------------
|
|
79
|
+
step "asserting DELETE replication"
|
|
80
|
+
client.query("DELETE FROM users WHERE id = 2")
|
|
81
|
+
wait_until("user 2 removed from Elasticsearch", log_path: LOG_PATH) do
|
|
82
|
+
code, _ = es_get(INDEX, TYPE, 2)
|
|
83
|
+
code == 404
|
|
84
|
+
end
|
|
85
|
+
step " DELETE OK"
|
|
86
|
+
|
|
87
|
+
puts "\n[E2E] PASSED (single): insert/update/delete replicated MySQL -> Elasticsearch"
|
|
88
|
+
ensure
|
|
89
|
+
step "stopping Fluentd"
|
|
90
|
+
stop_fluentd(fluentd_pid)
|
|
91
|
+
end
|
|
@@ -30,11 +30,111 @@ class MysqlReplicatorInputTest < Test::Unit::TestCase
|
|
|
30
30
|
tag input.mysql
|
|
31
31
|
query SELECT id, text from search_text
|
|
32
32
|
enable_delete no
|
|
33
|
+
json_columns geometry,attrs
|
|
33
34
|
]
|
|
34
35
|
assert_equal 'localhost', d.instance.host
|
|
35
36
|
assert_equal 3306, d.instance.port
|
|
36
37
|
assert_equal 30, d.instance.interval
|
|
37
38
|
assert_equal 'input.mysql', d.instance.tag
|
|
38
39
|
assert_equal false, d.instance.enable_delete
|
|
40
|
+
assert_equal ['geometry', 'attrs'], d.instance.json_columns
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_json_columns_defaults_to_empty
|
|
44
|
+
d = create_driver
|
|
45
|
+
assert_equal [], d.instance.json_columns
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_parse_json_columns
|
|
49
|
+
d = create_driver
|
|
50
|
+
row = {
|
|
51
|
+
'id' => 1,
|
|
52
|
+
'geometry' => '{"type":"Polygon","coordinates":[[136.8,35.1]]}',
|
|
53
|
+
'tags' => '[1,2,3]',
|
|
54
|
+
'broken' => 'not json {',
|
|
55
|
+
'plain' => 'hello',
|
|
56
|
+
'number' => 5,
|
|
57
|
+
}
|
|
58
|
+
d.instance.parse_json_columns!(row, ['geometry', 'tags', 'broken', 'number', 'missing'])
|
|
59
|
+
|
|
60
|
+
assert_equal({'type' => 'Polygon', 'coordinates' => [[136.8, 35.1]]}, row['geometry'])
|
|
61
|
+
assert_equal([1, 2, 3], row['tags'])
|
|
62
|
+
assert_equal('not json {', row['broken']) # malformed JSON stays as the original string
|
|
63
|
+
assert_equal(5, row['number']) # non-string values are untouched
|
|
64
|
+
assert_equal('hello', row['plain']) # columns not listed are untouched
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_parse_json_columns_noop_when_empty
|
|
68
|
+
d = create_driver
|
|
69
|
+
row = {'geometry' => '{"a":1}'}
|
|
70
|
+
d.instance.parse_json_columns!(row, [])
|
|
71
|
+
assert_equal('{"a":1}', row['geometry'])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# --- #42: delete detection must not build an integer Range from primary keys ---
|
|
75
|
+
|
|
76
|
+
def deleted_ids(previous_ids, current_ids)
|
|
77
|
+
conf = %[
|
|
78
|
+
tag input.mysql
|
|
79
|
+
query SELECT id, text from search_test
|
|
80
|
+
]
|
|
81
|
+
create_driver(conf).instance.detect_deleted_ids(previous_ids, current_ids)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_detect_deleted_ids_first_poll_reports_no_deletes
|
|
85
|
+
assert_equal [], deleted_ids([], [1, 2, 3])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_detect_deleted_ids_first_poll_with_string_keys_does_not_raise
|
|
89
|
+
# Regression for #42: the old `[*1...'c']` raised "bad value for range".
|
|
90
|
+
assert_nothing_raised do
|
|
91
|
+
assert_equal [], deleted_ids([], %w[a b c])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_detect_deleted_ids_first_poll_with_sparse_ids_has_no_phantom_deletes
|
|
96
|
+
# The old code returned `[*1...99] - [1, 50, 99]`, emitting deletes for ids
|
|
97
|
+
# that never existed. The first poll must only establish a baseline.
|
|
98
|
+
assert_equal [], deleted_ids([], [1, 50, 99])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_detect_deleted_ids_diffs_against_previous_snapshot
|
|
102
|
+
assert_equal [2], deleted_ids([1, 2, 3], [1, 3])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def test_detect_deleted_ids_empty_current_does_not_mass_delete
|
|
106
|
+
assert_equal [], deleted_ids([1, 2, 3], [])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- #4: a nested sub-query fires only for a SELECT template with ${...} ---
|
|
110
|
+
|
|
111
|
+
def nested?(value)
|
|
112
|
+
conf = %[
|
|
113
|
+
tag input.mysql
|
|
114
|
+
query SELECT id, text from search_test
|
|
115
|
+
]
|
|
116
|
+
create_driver(conf).instance.nested_query_value?(value)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def test_nested_query_value_true_for_select_with_placeholder
|
|
120
|
+
assert_true nested?("SELECT * FROM child WHERE parent_id = ${id}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def test_nested_query_value_false_for_plain_text_starting_with_select
|
|
124
|
+
# Regression for #4: a data value beginning with "SELECT" must not run as SQL.
|
|
125
|
+
assert_false nested?("SELECT YOUR PLAN")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_nested_query_value_false_for_word_select
|
|
129
|
+
assert_false nested?("Selecting the best option")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def test_nested_query_value_false_for_select_without_placeholder
|
|
133
|
+
assert_false nested?("select count(*) from search_test")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_nested_query_value_false_for_non_string_values
|
|
137
|
+
assert_false nested?(12345)
|
|
138
|
+
assert_false nested?(nil)
|
|
39
139
|
end
|
|
40
140
|
end
|
|
@@ -21,7 +21,18 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
|
|
|
21
21
|
{'age' => 26, 'request_id' => '42'}
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# The plugin detects the Elasticsearch version on the first write via GET /,
|
|
25
|
+
# so stub that endpoint (defaulting to 6.x, which keeps "_type").
|
|
26
|
+
def stub_elastic_version(url, version="6.8.23")
|
|
27
|
+
stub_request(:get, url).to_return(
|
|
28
|
+
:status => 200,
|
|
29
|
+
:headers => {"Content-Type" => "application/json"},
|
|
30
|
+
:body => %({"version":{"number":"#{version}"}})
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
def stub_elastic(url="http://localhost:9200/_bulk")
|
|
35
|
+
stub_elastic_version(url.sub('/_bulk', '/'))
|
|
25
36
|
stub_request(:post, url).with do |req|
|
|
26
37
|
@content_type = req.headers["Content-Type"]
|
|
27
38
|
@index_cmds = req.body.split("\n").map {|r| JSON.parse(r) }
|
|
@@ -29,6 +40,7 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
|
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
def stub_elastic_unavailable(url="http://localhost:9200/_bulk")
|
|
43
|
+
stub_elastic_version(url.sub('/_bulk', '/'))
|
|
32
44
|
stub_request(:post, url).to_return(:status => [503, "Service Unavailable"])
|
|
33
45
|
end
|
|
34
46
|
|
|
@@ -58,6 +70,16 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
|
|
|
58
70
|
assert_equal('mytype', index_cmds.first['index']['_type'])
|
|
59
71
|
end
|
|
60
72
|
|
|
73
|
+
def test_auto_detects_es8_and_omits_type
|
|
74
|
+
stub_elastic
|
|
75
|
+
# Override the version endpoint to report Elasticsearch 8.x.
|
|
76
|
+
stub_elastic_version("http://localhost:9200/", "8.18.0")
|
|
77
|
+
driver.run(default_tag: @tag) do
|
|
78
|
+
driver.feed(sample_record)
|
|
79
|
+
end
|
|
80
|
+
assert(!index_cmds.first['index'].has_key?('_type'))
|
|
81
|
+
end
|
|
82
|
+
|
|
61
83
|
def test_writes_to_speficied_host
|
|
62
84
|
driver.configure("host 192.168.33.50\n")
|
|
63
85
|
elastic_request = stub_elastic("http://192.168.33.50:9200/_bulk")
|