fluent-plugin-mysql-replicator 1.2.0 → 1.4.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/CHANGELOG.md +37 -0
- data/README.md +111 -0
- data/fluent-plugin-mysql-replicator.gemspec +1 -1
- data/lib/fluent/plugin/in_mysql_replicator.rb +19 -9
- data/lib/fluent/plugin/out_mysql_replicator_elasticsearch.rb +102 -10
- data/test/plugin/test_in_mysql_replicator.rb +26 -0
- data/test/plugin/test_out_mysql_replicator_elasticsearch.rb +83 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 26f611dc962db43660ca6004ce2b634e9a863d554ff25328d7d39e1bc4c5a14e
|
|
4
|
+
data.tar.gz: dd4834e5c827564da74189882a605381fbe9be31b1755cc772ceb9bed0c09937
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: efc707ef5d9f6606baa7d573d974701bd1cedb4e34bf6a67ebda681c313b190d88ca700c505857526694b216759093cf5f834486c065074a99adb3705d5b076f
|
|
7
|
+
data.tar.gz: 17bec8dd052fb658430c1cd4d15c7731c5f7afaf27a62add1e56b6e6134f928bc5934d5db825a2918a894b0ff2f3504fe9b120ff19eaa9ca89f3c8f93d1d84b5
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.4.0] - 2026-06-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Plugin-managed Elasticsearch index templates. New `template_name`,
|
|
12
|
+
`template_file`, `template_overwrite`, and `use_legacy_template` options on
|
|
13
|
+
`mysql_replicator_elasticsearch` install an index template on startup, so newly
|
|
14
|
+
created indices — including future date-rolled ones — get the desired mapping
|
|
15
|
+
before the first document locks in dynamic mapping. This enables mappings that
|
|
16
|
+
cannot be changed after the fact: `keyword` / non-analyzed fields ([#20]) and
|
|
17
|
+
`geo_point` ([#36]). `use_legacy_template` defaults to `false` (composable
|
|
18
|
+
`PUT /_index_template`, Elasticsearch >= 7.8); set it to `true` for the legacy
|
|
19
|
+
`PUT /_template` API (Elasticsearch 6.x+).
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
- Document that MySQL `DECIMAL` columns are emitted as strings (because
|
|
23
|
+
`BigDecimal` cannot cross Fluentd's msgpack buffer, which also preserves exact
|
|
24
|
+
precision) and should be mapped as `double` or `scaled_float` in an index
|
|
25
|
+
template, where Elasticsearch coerces the numeric string at index time. ([#36])
|
|
26
|
+
|
|
27
|
+
## [1.3.0] - 2026-06-16
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Date-based Elasticsearch index names. If the index-name segment of the tag
|
|
31
|
+
contains `strftime` tokens (e.g. `%Y%m%d`), they are expanded using the
|
|
32
|
+
record's event time, enabling Logstash-style dated indices such as
|
|
33
|
+
`myindex-20180831`. Index names without a `%` are unchanged. ([#27])
|
|
34
|
+
- Composite primary key support in `mysql_replicator`. `primary_key` now accepts
|
|
35
|
+
a comma-separated list of columns; the combination is used for change
|
|
36
|
+
detection and as the Elasticsearch document `_id` (values joined by `,`). A
|
|
37
|
+
single-column key behaves exactly as before. ([#7])
|
|
38
|
+
|
|
8
39
|
## [1.2.0] - 2026-06-16
|
|
9
40
|
|
|
10
41
|
### Added
|
|
@@ -73,6 +104,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
73
104
|
- First 1.0 release, targeting the Fluentd v0.14+ plugin API. Earlier 0.x
|
|
74
105
|
history is available in the git log.
|
|
75
106
|
|
|
107
|
+
[1.4.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.3.0...v1.4.0
|
|
108
|
+
[1.3.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.2.0...v1.3.0
|
|
76
109
|
[1.2.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.1.0...v1.2.0
|
|
77
110
|
[1.1.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.3...v1.1.0
|
|
78
111
|
[1.0.3]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.2...v1.0.3
|
|
@@ -81,7 +114,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
81
114
|
[1.0.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/releases/tag/v1.0.0
|
|
82
115
|
|
|
83
116
|
[#4]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/4
|
|
117
|
+
[#7]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/7
|
|
84
118
|
[#18]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/18
|
|
119
|
+
[#20]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/20
|
|
120
|
+
[#27]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/27
|
|
121
|
+
[#36]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/36
|
|
85
122
|
[#39]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/39
|
|
86
123
|
[#40]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/40
|
|
87
124
|
[#42]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/42
|
data/README.md
CHANGED
|
@@ -122,6 +122,117 @@ Notes:
|
|
|
122
122
|
* Malformed JSON and non-string values are left untouched, so enabling the option
|
|
123
123
|
never corrupts non-JSON data.
|
|
124
124
|
|
|
125
|
+
## Date-based index names
|
|
126
|
+
|
|
127
|
+
`mysql_replicator_elasticsearch` resolves the target index name from the tag
|
|
128
|
+
(via `tag_format`). If that index-name segment contains `strftime` tokens such
|
|
129
|
+
as `%Y%m%d`, they are expanded using the record's event time, so you can create
|
|
130
|
+
Logstash-style dated indices like `myindex-20180831`.
|
|
131
|
+
|
|
132
|
+
Put the tokens in the index-name part of the input plugin's `tag` (the segment
|
|
133
|
+
must not contain `.`, so use `%Y%m%d` or `%Y-%m-%d`):
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
<source>
|
|
137
|
+
@type mysql_replicator
|
|
138
|
+
# ...
|
|
139
|
+
tag myindex-%Y%m%d.mytype.${event}.${primary_key}
|
|
140
|
+
</source>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Index names that contain no `%` are left unchanged, so this is fully backward
|
|
144
|
+
compatible.
|
|
145
|
+
|
|
146
|
+
> **Note on deletions:** delete events target the index computed from the delete
|
|
147
|
+
> event's own time, so date-rotated indices are best suited to insert-only data
|
|
148
|
+
> (a record inserted on a previous day lives in that day's index).
|
|
149
|
+
|
|
150
|
+
## Composite primary keys
|
|
151
|
+
|
|
152
|
+
`primary_key` accepts a comma-separated list of columns, so tables keyed by more
|
|
153
|
+
than one column are supported:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
<source>
|
|
157
|
+
@type mysql_replicator
|
|
158
|
+
# ...
|
|
159
|
+
query SELECT tenant_id, id, name FROM items
|
|
160
|
+
primary_key tenant_id,id
|
|
161
|
+
</source>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Change detection (insert/update/delete) then keys on the combination of those
|
|
165
|
+
columns, and the Elasticsearch document `_id` becomes their values joined by `,`
|
|
166
|
+
(e.g. `10,7`). A single-column `primary_key` (the default `id`) behaves exactly
|
|
167
|
+
as before.
|
|
168
|
+
|
|
169
|
+
This applies to `mysql_replicator`; `mysql_replicator_multi` still expects a
|
|
170
|
+
single-column primary key.
|
|
171
|
+
|
|
172
|
+
## Index templates (mappings)
|
|
173
|
+
|
|
174
|
+
By default Elasticsearch infers field types from the first document (dynamic
|
|
175
|
+
mapping), and an existing field's mapping cannot be changed afterwards. To
|
|
176
|
+
control a field's mapping — e.g. `keyword` for a non-analyzed field, or
|
|
177
|
+
`geo_point`, which dynamic mapping never infers — install an **index template**
|
|
178
|
+
so it is applied to indices *before* the first document is written.
|
|
179
|
+
|
|
180
|
+
`mysql_replicator_elasticsearch` can install a template on startup. The option
|
|
181
|
+
names mirror `fluent-plugin-elasticsearch` (the default here uses the modern
|
|
182
|
+
composable API, whereas fluent-plugin-elasticsearch defaults to legacy):
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
<match replicator.**>
|
|
186
|
+
@type mysql_replicator_elasticsearch
|
|
187
|
+
# ...
|
|
188
|
+
template_name myindex_template
|
|
189
|
+
template_file /etc/fluent/myindex_template.json
|
|
190
|
+
template_overwrite false # set true to replace an existing template
|
|
191
|
+
use_legacy_template false # default. false: PUT /_index_template (ES >= 7.8); true: PUT /_template (legacy, ES 6.x+)
|
|
192
|
+
</match>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`template_name` and `template_file` must be set together. The template is a
|
|
196
|
+
**server-side rule**, so once installed it is applied automatically to every new
|
|
197
|
+
index whose name matches its `index_patterns` — **including future date-rolled
|
|
198
|
+
indices** (see *Date-based index names* above) — with no per-write work.
|
|
199
|
+
|
|
200
|
+
Example `template_file` (composable format, the default) mapping a non-analyzed
|
|
201
|
+
field as `keyword`, a coordinate field as `geo_point`, and a `DECIMAL` column as
|
|
202
|
+
a numeric (see below):
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{
|
|
206
|
+
"index_patterns": ["myindex-*"],
|
|
207
|
+
"template": {
|
|
208
|
+
"mappings": {
|
|
209
|
+
"properties": {
|
|
210
|
+
"message": { "type": "keyword" },
|
|
211
|
+
"location": { "type": "geo_point" },
|
|
212
|
+
"price": { "type": "scaled_float", "scaling_factor": 100 }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
For Elasticsearch 6.x — or to reuse an existing `fluent-plugin-elasticsearch`
|
|
220
|
+
legacy template — set `use_legacy_template true` and put `settings`/`mappings`
|
|
221
|
+
at the top level (not under `template`).
|
|
222
|
+
|
|
223
|
+
### Numeric columns (DECIMAL)
|
|
224
|
+
|
|
225
|
+
MySQL `DECIMAL` columns are sent to Elasticsearch **as strings** — not because of
|
|
226
|
+
a type-inference issue, but because `BigDecimal` cannot cross Fluentd's msgpack
|
|
227
|
+
buffer between the input and output plugins, so the value is stringified (which
|
|
228
|
+
also preserves its exact precision). Under the default dynamic mapping such a
|
|
229
|
+
field is therefore indexed as text.
|
|
230
|
+
|
|
231
|
+
To index it as a number, map the field in your template as `double`, or — to keep
|
|
232
|
+
exact fixed-point precision — `scaled_float` (as `price` above). Elasticsearch's
|
|
233
|
+
`coerce` (enabled by default for numeric types) converts the numeric string to a
|
|
234
|
+
number at index time, so no value conversion is needed in the plugin.
|
|
235
|
+
|
|
125
236
|
## Output example
|
|
126
237
|
|
|
127
238
|
It is a example when detecting insert/update/delete events.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
2
|
Gem::Specification.new do |s|
|
|
3
3
|
s.name = "fluent-plugin-mysql-replicator"
|
|
4
|
-
s.version = "1.
|
|
4
|
+
s.version = "1.4.0"
|
|
5
5
|
s.authors = ["Kentaro Yoshida"]
|
|
6
6
|
s.email = ["y.ken.studio@gmail.com"]
|
|
7
7
|
s.homepage = "https://github.com/y-ken/fluent-plugin-mysql-replicator"
|
|
@@ -17,7 +17,10 @@ module Fluent::Plugin
|
|
|
17
17
|
config_param :encoding, :string, :default => 'utf8'
|
|
18
18
|
config_param :query, :string
|
|
19
19
|
config_param :prepared_query, :string, :default => nil
|
|
20
|
-
|
|
20
|
+
# A single column name, or a comma-separated list for a composite key
|
|
21
|
+
# (e.g. "tenant_id,id"). The id used for change detection and the
|
|
22
|
+
# Elasticsearch document _id is the combination of these columns.
|
|
23
|
+
config_param :primary_key, :array, :default => ['id']
|
|
21
24
|
config_param :interval, :string, :default => '1m'
|
|
22
25
|
config_param :enable_delete, :bool, :default => true
|
|
23
26
|
# Comma-separated column names whose MySQL JSON values should be parsed into
|
|
@@ -73,7 +76,8 @@ module Fluent::Plugin
|
|
|
73
76
|
end
|
|
74
77
|
rows, con = query(@query, con)
|
|
75
78
|
rows.each do |row|
|
|
76
|
-
|
|
79
|
+
id = extract_id(row)
|
|
80
|
+
current_ids << id
|
|
77
81
|
current_hash = Digest::SHA1.hexdigest(row.flatten.join)
|
|
78
82
|
row.each {|k, v| row[k] = v.to_s if v.is_a?(Time) || v.is_a?(Date) || v.is_a?(BigDecimal)}
|
|
79
83
|
parse_json_columns!(row, @json_columns)
|
|
@@ -86,18 +90,18 @@ module Fluent::Plugin
|
|
|
86
90
|
end
|
|
87
91
|
prepared_con.close
|
|
88
92
|
end
|
|
89
|
-
if
|
|
90
|
-
log.error "mysql_replicator: missing primary_key. :tag=>#{tag} :primary_key=>#{primary_key}"
|
|
93
|
+
if id.any?(&:nil?)
|
|
94
|
+
log.error "mysql_replicator: missing primary_key. :tag=>#{tag} :primary_key=>#{@primary_key.join(',')} :id=>#{id}"
|
|
91
95
|
break
|
|
92
96
|
end
|
|
93
|
-
if !table_hash.include?(
|
|
97
|
+
if !table_hash.include?(id)
|
|
94
98
|
tag = format_tag(@tag, {:event => :insert})
|
|
95
99
|
emit_record(tag, row)
|
|
96
|
-
elsif table_hash[
|
|
100
|
+
elsif table_hash[id] != current_hash
|
|
97
101
|
tag = format_tag(@tag, {:event => :update})
|
|
98
102
|
emit_record(tag, row)
|
|
99
103
|
end
|
|
100
|
-
table_hash[
|
|
104
|
+
table_hash[id] = current_hash
|
|
101
105
|
rows_count += 1
|
|
102
106
|
end
|
|
103
107
|
con.close
|
|
@@ -108,7 +112,7 @@ module Fluent::Plugin
|
|
|
108
112
|
hash_delete_by_list(table_hash, deleted_ids)
|
|
109
113
|
deleted_ids.each do |id|
|
|
110
114
|
tag = format_tag(@tag, {:event => :delete})
|
|
111
|
-
emit_record(tag,
|
|
115
|
+
emit_record(tag, Hash[@primary_key.zip(id)])
|
|
112
116
|
end
|
|
113
117
|
end
|
|
114
118
|
end
|
|
@@ -122,6 +126,12 @@ module Fluent::Plugin
|
|
|
122
126
|
deleted_keys.each{|k| hash.delete(k)}
|
|
123
127
|
end
|
|
124
128
|
|
|
129
|
+
# A row's id is the array of its primary-key column values, supporting
|
|
130
|
+
# composite keys. It is a single-element array for a single-column key.
|
|
131
|
+
def extract_id(row)
|
|
132
|
+
@primary_key.map {|col| row[col] }
|
|
133
|
+
end
|
|
134
|
+
|
|
125
135
|
# Returns the primary keys that disappeared since the previous poll.
|
|
126
136
|
#
|
|
127
137
|
# The first poll only establishes a baseline: there is no previous snapshot
|
|
@@ -152,7 +162,7 @@ module Fluent::Plugin
|
|
|
152
162
|
end
|
|
153
163
|
|
|
154
164
|
def format_tag(tag, param)
|
|
155
|
-
pattern = {'${event}' => param[:event].to_s, '${primary_key}' => @primary_key}
|
|
165
|
+
pattern = {'${event}' => param[:event].to_s, '${primary_key}' => @primary_key.join(',')}
|
|
156
166
|
tag.gsub(/(\${[a-z_]+})/) do
|
|
157
167
|
log.warn "mysql_replicator: missing placeholder. :tag=>#{tag} :placeholder=>#{$1}" unless pattern.include?($1)
|
|
158
168
|
pattern[$1]
|
|
@@ -17,6 +17,20 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
|
|
|
17
17
|
config_param :username, :string, :default => nil
|
|
18
18
|
config_param :password, :string, :default => nil, :secret => true
|
|
19
19
|
|
|
20
|
+
# Optional: install an Elasticsearch index template on startup so that newly
|
|
21
|
+
# created indices (including future date-rolled ones) get the desired mapping
|
|
22
|
+
# (e.g. geo_point or keyword fields) before the first document locks in
|
|
23
|
+
# dynamic mapping. template_name and template_file must be set together.
|
|
24
|
+
#
|
|
25
|
+
# Parameter names mirror fluent-plugin-elasticsearch. The default uses the
|
|
26
|
+
# modern composable API (fluent-plugin-elasticsearch defaults to legacy):
|
|
27
|
+
# use_legacy_template false (default) -> PUT /_index_template/<name> (composable, ES >= 7.8)
|
|
28
|
+
# use_legacy_template true -> PUT /_template/<name> (legacy, ES 6.x+)
|
|
29
|
+
config_param :template_name, :string, :default => nil
|
|
30
|
+
config_param :template_file, :string, :default => nil
|
|
31
|
+
config_param :template_overwrite, :bool, :default => false
|
|
32
|
+
config_param :use_legacy_template, :bool, :default => false
|
|
33
|
+
|
|
20
34
|
config_section :buffer do
|
|
21
35
|
config_set_default :@type, DEFAULT_BUFFER_TYPE
|
|
22
36
|
end
|
|
@@ -35,12 +49,21 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
|
|
|
35
49
|
else
|
|
36
50
|
@tag_format = Regexp.new(conf['tag_format'])
|
|
37
51
|
end
|
|
52
|
+
|
|
53
|
+
if @template_name.nil? != @template_file.nil?
|
|
54
|
+
raise Fluent::ConfigError, "mysql_replicator_elasticsearch: 'template_name' and 'template_file' must be set together"
|
|
55
|
+
end
|
|
56
|
+
if @template_file && !File.exist?(@template_file)
|
|
57
|
+
raise Fluent::ConfigError, "mysql_replicator_elasticsearch: template_file not found: #{@template_file}"
|
|
58
|
+
end
|
|
38
59
|
end
|
|
39
60
|
|
|
40
61
|
def start
|
|
41
62
|
super
|
|
42
63
|
# nil means "not yet detected"; resolved on the first write.
|
|
43
64
|
@suppress_type = nil
|
|
65
|
+
@es_version = nil
|
|
66
|
+
@template_installed = false
|
|
44
67
|
end
|
|
45
68
|
|
|
46
69
|
def format(tag, time, record)
|
|
@@ -61,25 +84,26 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
|
|
|
61
84
|
|
|
62
85
|
def write(chunk)
|
|
63
86
|
detect_type_suppression if @suppress_type.nil?
|
|
87
|
+
install_template_once if @template_name
|
|
64
88
|
|
|
65
89
|
bulk_message = []
|
|
66
90
|
|
|
67
91
|
chunk.msgpack_each do |tag, time, record|
|
|
68
92
|
tag_parts = tag.match(@tag_format)
|
|
69
|
-
target_index = tag_parts['index_name']
|
|
93
|
+
target_index = resolve_index_name(tag_parts['index_name'], time)
|
|
70
94
|
target_type = tag_parts['type_name']
|
|
71
|
-
|
|
95
|
+
id_keys = tag_parts['primary_key'].to_s.split(',')
|
|
72
96
|
|
|
73
97
|
if tag_parts['event'] == 'delete'
|
|
74
|
-
action = {"_index" => target_index, "_id" => record
|
|
98
|
+
action = {"_index" => target_index, "_id" => join_id(record, id_keys)}
|
|
75
99
|
action['_type'] = target_type unless @suppress_type
|
|
76
100
|
meta = { "delete" => action }
|
|
77
101
|
bulk_message << Yajl::Encoder.encode(meta)
|
|
78
102
|
else
|
|
79
103
|
action = {"_index" => target_index}
|
|
80
104
|
action['_type'] = target_type unless @suppress_type
|
|
81
|
-
if
|
|
82
|
-
action['_id'] = record
|
|
105
|
+
if !id_keys.empty? && id_keys.all? {|k| !record[k].nil? }
|
|
106
|
+
action['_id'] = join_id(record, id_keys)
|
|
83
107
|
end
|
|
84
108
|
meta = { "index" => action }
|
|
85
109
|
bulk_message << Yajl::Encoder.encode(meta)
|
|
@@ -105,26 +129,94 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
|
|
|
105
129
|
http
|
|
106
130
|
end
|
|
107
131
|
|
|
132
|
+
# Build the document _id from one or more primary-key columns. A single key
|
|
133
|
+
# yields its value; a composite key yields the values joined by ",".
|
|
134
|
+
def join_id(record, id_keys)
|
|
135
|
+
id_keys.map {|k| record[k] }.join(',')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Expand strftime tokens (e.g. "%Y%m%d") in the index name using the record's
|
|
139
|
+
# event time, enabling date-based indices such as "myindex-20180831". Index
|
|
140
|
+
# names without a "%" are returned unchanged.
|
|
141
|
+
def resolve_index_name(index_name, time)
|
|
142
|
+
return index_name unless index_name && index_name.include?('%')
|
|
143
|
+
Time.at(time.to_i).strftime(index_name)
|
|
144
|
+
rescue => e
|
|
145
|
+
log.warn "mysql_replicator_elasticsearch: failed to expand index name '#{index_name}': #{e.message}"
|
|
146
|
+
index_name
|
|
147
|
+
end
|
|
148
|
+
|
|
108
149
|
# Mapping types were removed in Elasticsearch 8.x and deprecated in 7.x.
|
|
109
|
-
# Detect the
|
|
150
|
+
# Detect the version once and omit "_type" for 7.x and later.
|
|
110
151
|
def detect_type_suppression
|
|
111
|
-
|
|
152
|
+
@es_version = elasticsearch_version
|
|
153
|
+
major = @es_version&.first
|
|
112
154
|
@suppress_type = !major.nil? && major >= 7
|
|
113
155
|
if major
|
|
114
|
-
log.info "mysql_replicator_elasticsearch: detected Elasticsearch #{
|
|
156
|
+
log.info "mysql_replicator_elasticsearch: detected Elasticsearch #{@es_version.join('.')}, suppress_type=#{@suppress_type}"
|
|
115
157
|
else
|
|
116
158
|
log.warn "mysql_replicator_elasticsearch: could not detect Elasticsearch version, sending '_type' (assuming 6.x)"
|
|
117
159
|
end
|
|
118
160
|
end
|
|
119
161
|
|
|
120
|
-
|
|
162
|
+
# Returns the Elasticsearch version as [major, minor], or nil if undetectable.
|
|
163
|
+
def elasticsearch_version
|
|
121
164
|
request = Net::HTTP::Get.new('/')
|
|
122
165
|
request.basic_auth(@username, @password) if @username && @password
|
|
123
166
|
response = new_http.request(request)
|
|
124
167
|
number = Yajl::Parser.parse(response.body).dig('version', 'number')
|
|
125
|
-
number.
|
|
168
|
+
return nil if number.nil?
|
|
169
|
+
number.to_s.split('.').first(2).map(&:to_i)
|
|
126
170
|
rescue => e
|
|
127
171
|
log.warn "mysql_replicator_elasticsearch: version detection failed: #{e.message}"
|
|
128
172
|
nil
|
|
129
173
|
end
|
|
174
|
+
|
|
175
|
+
# Install the configured index template once, on the first write. The template
|
|
176
|
+
# is a server-side rule, so Elasticsearch applies it to every new index whose
|
|
177
|
+
# name matches its index_patterns -- including future date-rolled indices --
|
|
178
|
+
# without any further action here. Failures are logged but never abort indexing.
|
|
179
|
+
def install_template_once
|
|
180
|
+
return if @template_installed
|
|
181
|
+
@template_installed = true
|
|
182
|
+
install_template
|
|
183
|
+
rescue => e
|
|
184
|
+
log.warn "mysql_replicator_elasticsearch: failed to install index template '#{@template_name}': #{e.message}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def install_template
|
|
188
|
+
if !@use_legacy_template && !composable_templates_supported?
|
|
189
|
+
log.warn "mysql_replicator_elasticsearch: composable index templates require Elasticsearch >= 7.8; skipping template '#{@template_name}' (detected #{@es_version&.join('.') || 'unknown'}). Set 'use_legacy_template true' for older Elasticsearch."
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
if !@template_overwrite && template_exists?
|
|
193
|
+
log.info "mysql_replicator_elasticsearch: index template '#{@template_name}' already exists; skipping (set 'template_overwrite true' to replace)"
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
put_template(File.read(@template_file))
|
|
197
|
+
log.info "mysql_replicator_elasticsearch: installed index template '#{@template_name}' (#{@use_legacy_template ? 'legacy' : 'composable'})"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def composable_templates_supported?
|
|
201
|
+
return false if @es_version.nil?
|
|
202
|
+
major, minor = @es_version
|
|
203
|
+
major > 7 || (major == 7 && minor >= 8)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def template_path
|
|
207
|
+
@use_legacy_template ? "/_template/#{@template_name}" : "/_index_template/#{@template_name}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def template_exists?
|
|
211
|
+
request = Net::HTTP::Get.new(template_path)
|
|
212
|
+
request.basic_auth(@username, @password) if @username && @password
|
|
213
|
+
new_http.request(request).code.to_i == 200
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def put_template(body)
|
|
217
|
+
request = Net::HTTP::Put.new(template_path, {'content-type' => 'application/json; charset=utf-8'})
|
|
218
|
+
request.basic_auth(@username, @password) if @username && @password
|
|
219
|
+
request.body = body
|
|
220
|
+
new_http.request(request).value
|
|
221
|
+
end
|
|
130
222
|
end
|
|
@@ -137,4 +137,30 @@ class MysqlReplicatorInputTest < Test::Unit::TestCase
|
|
|
137
137
|
assert_false nested?(12345)
|
|
138
138
|
assert_false nested?(nil)
|
|
139
139
|
end
|
|
140
|
+
|
|
141
|
+
# --- #7: composite primary key support ---
|
|
142
|
+
|
|
143
|
+
def composite_driver
|
|
144
|
+
create_driver(%[
|
|
145
|
+
tag input.mysql
|
|
146
|
+
query SELECT tenant_id, id, text from t
|
|
147
|
+
primary_key tenant_id,id
|
|
148
|
+
])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def test_primary_key_defaults_to_id_array
|
|
152
|
+
assert_equal ['id'], create_driver.instance.primary_key
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_primary_key_parses_composite_list
|
|
156
|
+
assert_equal ['tenant_id', 'id'], composite_driver.instance.primary_key
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_extract_id_single_key_is_one_element_array
|
|
160
|
+
assert_equal [7], create_driver.instance.extract_id({'id' => 7, 'text' => 'x'})
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def test_extract_id_returns_composite_values
|
|
164
|
+
assert_equal [10, 7], composite_driver.instance.extract_id({'tenant_id' => 10, 'id' => 7, 'text' => 'x'})
|
|
165
|
+
end
|
|
140
166
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require 'helper'
|
|
2
2
|
require 'fluent/test/driver/output'
|
|
3
3
|
require 'webmock/test_unit'
|
|
4
|
+
require 'tempfile'
|
|
4
5
|
|
|
5
6
|
WebMock.disable_net_connect!
|
|
6
7
|
|
|
@@ -13,6 +14,19 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
|
|
|
13
14
|
@tag = 'myindex.mytype.insert.id'
|
|
14
15
|
end
|
|
15
16
|
|
|
17
|
+
def teardown
|
|
18
|
+
WebMock.reset!
|
|
19
|
+
(@tmpfiles || []).each {|f| f.close! rescue nil }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write_template_file(body = '{"index_patterns":["myindex-*"],"mappings":{"properties":{"loc":{"type":"geo_point"}}}}')
|
|
23
|
+
file = Tempfile.new(['tmpl', '.json'])
|
|
24
|
+
file.write(body)
|
|
25
|
+
file.flush
|
|
26
|
+
(@tmpfiles ||= []) << file
|
|
27
|
+
file.path
|
|
28
|
+
end
|
|
29
|
+
|
|
16
30
|
def driver(conf='')
|
|
17
31
|
@driver ||= Fluent::Test::Driver::Output.new(Fluent::Plugin::MysqlReplicatorElasticsearchOutput).configure(conf)
|
|
18
32
|
end
|
|
@@ -61,6 +75,32 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
|
|
|
61
75
|
assert_equal('myindex', index_cmds.first['index']['_index'])
|
|
62
76
|
end
|
|
63
77
|
|
|
78
|
+
def test_expands_strftime_tokens_in_index_name
|
|
79
|
+
stub_elastic
|
|
80
|
+
time = Time.utc(2018, 8, 31, 12, 0, 0).to_i
|
|
81
|
+
driver.run(default_tag: 'myindex-%Y%m%d.mytype.insert.id') do
|
|
82
|
+
driver.feed(time, sample_record)
|
|
83
|
+
end
|
|
84
|
+
expected = "myindex-#{Time.at(time).strftime('%Y%m%d')}"
|
|
85
|
+
assert_equal(expected, index_cmds.first['index']['_index'])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_index_name_without_token_is_unchanged
|
|
89
|
+
stub_elastic
|
|
90
|
+
driver.run(default_tag: 'plainindex.mytype.insert.id') do
|
|
91
|
+
driver.feed(sample_record)
|
|
92
|
+
end
|
|
93
|
+
assert_equal('plainindex', index_cmds.first['index']['_index'])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_composite_primary_key_builds_joined_id
|
|
97
|
+
stub_elastic
|
|
98
|
+
driver.run(default_tag: 'myindex.mytype.insert.tenant_id,id') do
|
|
99
|
+
driver.feed({'tenant_id' => 10, 'id' => 7, 'text' => 'x'})
|
|
100
|
+
end
|
|
101
|
+
assert_equal('10,7', index_cmds.first['index']['_id'])
|
|
102
|
+
end
|
|
103
|
+
|
|
64
104
|
def test_writes_to_speficied_type
|
|
65
105
|
driver.configure("type_name mytype\n")
|
|
66
106
|
stub_elastic
|
|
@@ -161,6 +201,49 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
|
|
|
161
201
|
}
|
|
162
202
|
end
|
|
163
203
|
|
|
204
|
+
def test_installs_composable_template_by_default
|
|
205
|
+
stub_elastic
|
|
206
|
+
stub_elastic_version("http://localhost:9200/", "8.18.0")
|
|
207
|
+
stub_request(:get, "http://localhost:9200/_index_template/my_tmpl").to_return(:status => 404)
|
|
208
|
+
put_tmpl = stub_request(:put, "http://localhost:9200/_index_template/my_tmpl").to_return(:status => 200, :body => "{}")
|
|
209
|
+
driver.configure("template_name my_tmpl\ntemplate_file #{write_template_file}\n")
|
|
210
|
+
driver.run(default_tag: @tag) { driver.feed(sample_record) }
|
|
211
|
+
assert_requested(put_tmpl)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def test_installs_legacy_template_when_enabled
|
|
215
|
+
stub_elastic # version 6.8.23
|
|
216
|
+
stub_request(:get, "http://localhost:9200/_template/my_tmpl").to_return(:status => 404)
|
|
217
|
+
put_tmpl = stub_request(:put, "http://localhost:9200/_template/my_tmpl").to_return(:status => 200, :body => "{}")
|
|
218
|
+
driver.configure("template_name my_tmpl\ntemplate_file #{write_template_file}\nuse_legacy_template true\n")
|
|
219
|
+
driver.run(default_tag: @tag) { driver.feed(sample_record) }
|
|
220
|
+
assert_requested(put_tmpl)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def test_skips_composable_template_on_old_elasticsearch
|
|
224
|
+
stub_elastic # version 6.8.23 (< 7.8), default composable
|
|
225
|
+
put_tmpl = stub_request(:put, "http://localhost:9200/_index_template/my_tmpl")
|
|
226
|
+
driver.configure("template_name my_tmpl\ntemplate_file #{write_template_file}\n")
|
|
227
|
+
driver.run(default_tag: @tag) { driver.feed(sample_record) }
|
|
228
|
+
assert_not_requested(put_tmpl)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def test_skips_template_when_it_exists_and_no_overwrite
|
|
232
|
+
stub_elastic
|
|
233
|
+
stub_elastic_version("http://localhost:9200/", "8.18.0")
|
|
234
|
+
stub_request(:get, "http://localhost:9200/_index_template/my_tmpl").to_return(:status => 200, :body => "{}")
|
|
235
|
+
put_tmpl = stub_request(:put, "http://localhost:9200/_index_template/my_tmpl")
|
|
236
|
+
driver.configure("template_name my_tmpl\ntemplate_file #{write_template_file}\n")
|
|
237
|
+
driver.run(default_tag: @tag) { driver.feed(sample_record) }
|
|
238
|
+
assert_not_requested(put_tmpl)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def test_template_name_without_file_raises
|
|
242
|
+
assert_raise(Fluent::ConfigError) do
|
|
243
|
+
driver.configure("template_name my_tmpl\n")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
164
247
|
def test_writes_to_https_host
|
|
165
248
|
driver.configure("ssl true\n")
|
|
166
249
|
elastic_request = stub_elastic("https://localhost:9200/_bulk")
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fluent-plugin-mysql-replicator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kentaro Yoshida
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|