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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea8d93c147df39ff2c34ac573a5e981c1e0a46da8445395eaf38cdc44abf7c0e
4
- data.tar.gz: f190b88e93c4318b9c2ec42863186f7dc1f0a3a911bb60e2b689a84687bffdcd
3
+ metadata.gz: 26f611dc962db43660ca6004ce2b634e9a863d554ff25328d7d39e1bc4c5a14e
4
+ data.tar.gz: dd4834e5c827564da74189882a605381fbe9be31b1755cc772ceb9bed0c09937
5
5
  SHA512:
6
- metadata.gz: bfbf04041525c768d56c7b0bb3d2cd69ca23076eca57d162277d81a06c1cb4290e4b0d402e46e4b136ebf2d45b8fdca59555c1406c9763336b67f3cf2b4be35d
7
- data.tar.gz: 2872f2e0d15084d3680a77498411aa5165acb000b2b5744be19686a2dcd89b41ccee8169956203bb3b9c9a004b0aba6d16d741147215b518e0690889b220ab98
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.2.0"
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
- config_param :primary_key, :string, :default => 'id'
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
- current_ids << row[@primary_key]
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 row[@primary_key].nil?
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?(row[@primary_key])
97
+ if !table_hash.include?(id)
94
98
  tag = format_tag(@tag, {:event => :insert})
95
99
  emit_record(tag, row)
96
- elsif table_hash[row[@primary_key]] != current_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[row[@primary_key]] = current_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, {@primary_key => id})
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
- id_key = tag_parts['primary_key']
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[id_key]}
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 id_key && record[id_key]
82
- action['_id'] = record[id_key]
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 major version once and omit "_type" for 7.x and later.
150
+ # Detect the version once and omit "_type" for 7.x and later.
110
151
  def detect_type_suppression
111
- major = elasticsearch_major_version
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 #{major}.x, suppress_type=#{@suppress_type}"
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
- def elasticsearch_major_version
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.to_s.split('.').first.to_i
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.2.0
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-16 00:00:00.000000000 Z
11
+ date: 2026-06-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake