fluent-plugin-mysql-replicator 1.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16c6600b210b85bad55965f707eadd53017558fd8a6a2b55cdf28bc1e86b7be3
4
- data.tar.gz: 07d8b7367ee234afb128845fb8ef9169929cb18caf4c1813040efe951e72f68e
3
+ metadata.gz: ea8d93c147df39ff2c34ac573a5e981c1e0a46da8445395eaf38cdc44abf7c0e
4
+ data.tar.gz: f190b88e93c4318b9c2ec42863186f7dc1f0a3a911bb60e2b689a84687bffdcd
5
5
  SHA512:
6
- metadata.gz: 65ec0c92b273597f7f7ff976fc42878e4fa413d8471947125a6873d7209142bec87a59aa37e3c491fd1a81dea4a9d448e3280cbca1eafd381fdee241d723bcae
7
- data.tar.gz: 20fb6ffe6e217e5d8ee0108c5243fad7f6abd910f1a2d336f65bcb8ba0388ac22e329d408edaa5ad8f298c959cffc2656932d71302d6b2b38c21c28cab14585d
6
+ metadata.gz: bfbf04041525c768d56c7b0bb3d2cd69ca23076eca57d162277d81a06c1cb4290e4b0d402e46e4b136ebf2d45b8fdca59555c1406c9763336b67f3cf2b4be35d
7
+ data.tar.gz: 2872f2e0d15084d3680a77498411aa5165acb000b2b5744be19686a2dcd89b41ccee8169956203bb3b9c9a004b0aba6d16d741147215b518e0690889b220ab98
@@ -1,4 +1,4 @@
1
- FROM ruby:3.3-slim
1
+ FROM ruby:3.4-slim
2
2
 
3
3
  ENV DEBIAN_FRONTEND=noninteractive
4
4
  ENV BUNDLE_PATH=/workspace/vendor/bundle
@@ -100,3 +100,66 @@ jobs:
100
100
 
101
101
  - name: Run multi-table replication E2E test
102
102
  run: bundle exec ruby test/e2e/replication_multi_e2e_test.rb
103
+
104
+ # Container path: the official fluent/fluentd Docker image (gem-based).
105
+ install-smoke-docker:
106
+ name: Install smoke (Docker fluent/fluentd ${{ matrix.fluentd_tag }})
107
+ runs-on: ubuntu-latest
108
+
109
+ strategy:
110
+ fail-fast: false
111
+ matrix:
112
+ # "edge" tracks the latest Fluentd release to catch drift early. The
113
+ # packaged-distribution path is covered by install-smoke-fluent-package.
114
+ fluentd_tag: ['edge-debian']
115
+
116
+ steps:
117
+ - uses: actions/checkout@v5
118
+
119
+ - uses: ruby/setup-ruby@v1
120
+ with:
121
+ ruby-version: '3.4'
122
+
123
+ - name: Build gem
124
+ run: |
125
+ mkdir -p pkg
126
+ gem build fluent-plugin-mysql-replicator.gemspec -o pkg/fluent-plugin-mysql-replicator.gem
127
+
128
+ - name: Install on official fluentd image (reproduces README steps)
129
+ run: |
130
+ docker run --rm --user root --entrypoint sh \
131
+ -v "${{ github.workspace }}:/workspace:ro" \
132
+ "fluent/fluentd:${{ matrix.fluentd_tag }}" \
133
+ /workspace/ci/install_smoke.sh
134
+
135
+ # Package path: fluent-package, the LTS distribution that replaced td-agent
136
+ # and is what most production users run today. v6 LTS bundles Fluentd v1.19.
137
+ install-smoke-fluent-package:
138
+ name: Install smoke (fluent-package v6 LTS)
139
+ runs-on: ubuntu-latest # GitHub's ubuntu-latest is Ubuntu 24.04 (Noble)
140
+
141
+ steps:
142
+ - uses: actions/checkout@v5
143
+
144
+ - uses: ruby/setup-ruby@v1
145
+ with:
146
+ ruby-version: '3.4'
147
+
148
+ - name: Build gem
149
+ run: |
150
+ mkdir -p pkg
151
+ gem build fluent-plugin-mysql-replicator.gemspec -o pkg/fluent-plugin-mysql-replicator.gem
152
+
153
+ - name: Install fluent-package v6 LTS
154
+ run: |
155
+ curl -fsSL https://fluentd.cdn.cncf.io/sh/install-ubuntu-noble-fluent-package6-lts.sh | sh
156
+ /opt/fluent/bin/fluentd --version
157
+
158
+ - name: Install MySQL client headers
159
+ run: sudo apt-get update && sudo apt-get install -y build-essential default-libmysqlclient-dev
160
+
161
+ - name: Install plugin via fluent-gem and verify it loads
162
+ run: |
163
+ sudo /opt/fluent/bin/fluent-gem install pkg/fluent-plugin-mysql-replicator.gem
164
+ sudo /opt/fluent/bin/fluent-gem list fluent-plugin-mysql-replicator
165
+ /opt/fluent/bin/ruby ci/verify_plugins_load.rb
data/CHANGELOG.md ADDED
@@ -0,0 +1,90 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.2.0] - 2026-06-16
9
+
10
+ ### Added
11
+ - JSON column support for nested object indexing. The new opt-in `json_columns`
12
+ option parses MySQL `JSON` string columns into nested objects before they are
13
+ emitted, so Elasticsearch indexes them as real nested JSON instead of escaped
14
+ strings. Available on both `mysql_replicator` (config option) and
15
+ `mysql_replicator_multi` (new `json_columns` column on the `settings` table).
16
+ Malformed JSON and non-string values are left untouched. ([#48], implements [#18])
17
+
18
+ ### Fixed
19
+ - Delete detection no longer raises `bad value for range` (and no longer
20
+ allocates a huge array or emits phantom delete events) when the `primary_key`
21
+ is non-integer (e.g. a UUID `CHAR(36)`) or a large/sparse integer. The first
22
+ poll now only establishes a baseline. ([#42])
23
+ - In `mysql_replicator`, a nested sub-query is now triggered only when a column
24
+ value is an actual query template containing a `${placeholder}`. Previously any
25
+ value that merely began with the word `SELECT` was executed as SQL. This aligns
26
+ the single plugin with the fix already present in `mysql_replicator_multi`. ([#4])
27
+
28
+ ### Documentation
29
+ - Document that the `mysql2` native extension requires the MySQL client
30
+ development headers (`default-libmysqlclient-dev` / `mysql-devel`), add a
31
+ "Failed to build gem native extension" troubleshooting note, and clarify that
32
+ change detection uses an in-memory hash of every row (not an `updated_at`
33
+ column). ([#43], [#40])
34
+
35
+ ### CI
36
+ - Add install-smoke jobs that build and install the gem on **fluent-package v6
37
+ LTS** (the td-agent successor) and on the official `fluent/fluentd` image,
38
+ guarding against native-extension build regressions.
39
+
40
+ ## [1.1.0] - 2026-06-15
41
+
42
+ ### Added
43
+ - Auto-detect the Elasticsearch version on first write and support Elasticsearch
44
+ 6.x through 9.x. The `_type` field is omitted automatically for 7.x and later,
45
+ where mapping types were removed.
46
+ - Dev Container configuration and end-to-end (E2E) replication tests, including
47
+ multi-table replication.
48
+
49
+ ### Changed
50
+ - Migrated CI to GitHub Actions (removed Travis CI) and run the unit-test matrix
51
+ on Ruby 3.2–4.0, aligned with supported Fluentd versions.
52
+
53
+ ## [1.0.3] - 2024-02-28
54
+
55
+ ### Added
56
+ - Reconnection option to recover from database connection errors. ([#45])
57
+
58
+ ## [1.0.2] - 2021-10-27
59
+
60
+ ### Fixed
61
+ - Manage threads correctly in `mysql_replicator_multi`. ([#39])
62
+
63
+ ## [1.0.1] - 2019-11-19
64
+
65
+ ### Fixed
66
+ - Ensure the nested-query database connection (`nest_db`) is always closed.
67
+
68
+ ### Documentation
69
+ - Documentation updates.
70
+
71
+ ## [1.0.0] - 2019-11-13
72
+
73
+ - First 1.0 release, targeting the Fluentd v0.14+ plugin API. Earlier 0.x
74
+ history is available in the git log.
75
+
76
+ [1.2.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.1.0...v1.2.0
77
+ [1.1.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.3...v1.1.0
78
+ [1.0.3]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.2...v1.0.3
79
+ [1.0.2]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.1...v1.0.2
80
+ [1.0.1]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.0...v1.0.1
81
+ [1.0.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/releases/tag/v1.0.0
82
+
83
+ [#4]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/4
84
+ [#18]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/18
85
+ [#39]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/39
86
+ [#40]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/40
87
+ [#42]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/42
88
+ [#43]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/43
89
+ [#45]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/45
90
+ [#48]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/48
data/README.md CHANGED
@@ -15,14 +15,28 @@ It's comming support replicate to another RDB/noSQL.
15
15
 
16
16
  ## Dependency
17
17
 
18
- before use, install dependent library as:
18
+ This plugin depends on the [mysql2](https://rubygems.org/gems/mysql2) gem, which
19
+ builds a native extension at install time. You need both a C compiler toolchain
20
+ **and the MySQL client development headers** before installing.
19
21
 
20
22
  ```bash
21
- # for RHEL/CentOS
23
+ # for RHEL/CentOS/Fedora
22
24
  $ sudo yum group install "Development Tools"
25
+ $ sudo yum install mysql-devel # or mariadb-devel
23
26
 
24
- # for Ubuntu/Debian
25
- $ sudo apt-get install build-essential
27
+ # for Ubuntu/Debian (including the official fluent/fluentd Docker image)
28
+ $ sudo apt-get install build-essential default-libmysqlclient-dev
29
+ ```
30
+
31
+ ### Troubleshooting: "Failed to build gem native extension"
32
+
33
+ If `gem install` (or `td-agent-gem install`) fails while building the `mysql2`
34
+ native extension, the MySQL client headers are almost always missing. Install
35
+ the development package for your platform shown above, then retry the
36
+ installation. On the official `fluent/fluentd:*-debian` images, run:
37
+
38
+ ```bash
39
+ $ apt-get update && apt-get install -y build-essential default-libmysqlclient-dev
26
40
  ```
27
41
 
28
42
  ## Installation
@@ -74,6 +88,40 @@ Mapping types were removed in Elasticsearch 8.x (and deprecated in 7.x), so the
74
88
  Elasticsearch version on the first write and automatically omits `_type` for
75
89
  7.x and later, so no extra configuration is required.
76
90
 
91
+ ## JSON column support
92
+
93
+ MySQL `JSON` columns (MySQL 5.7.8+ / 8.x) are returned by the driver as plain
94
+ strings, so by default they reach Elasticsearch as escaped strings rather than
95
+ nested objects. List such columns in `json_columns` to have their values parsed
96
+ into nested objects before they are emitted.
97
+
98
+ * `mysql_replicator` (single): set the `json_columns` option (comma-separated).
99
+
100
+ ```
101
+ <source>
102
+ @type mysql_replicator
103
+ # ...
104
+ query SELECT id, name, geometry FROM places
105
+ json_columns geometry,attrs
106
+ </source>
107
+ ```
108
+
109
+ * `mysql_replicator_multi`: set the `json_columns` column (comma-separated) on
110
+ the relevant row of the `settings` management table. Existing installs can add
111
+ the column with:
112
+
113
+ ```sql
114
+ ALTER TABLE settings ADD COLUMN `json_columns` varchar(255) DEFAULT NULL AFTER `primary_key`;
115
+ ```
116
+
117
+ Notes:
118
+
119
+ * This option is intended for Elasticsearch. **Do not set it when the destination
120
+ cannot store JSON objects (e.g. the Solr output)** — leave it empty there.
121
+ * Only top-level columns are parsed (columns inside nested documents are not).
122
+ * Malformed JSON and non-string values are left untouched, so enabling the option
123
+ never corrupts non-JSON data.
124
+
77
125
  ## Output example
78
126
 
79
127
  It is a example when detecting insert/update/delete events.
@@ -8,6 +8,26 @@ It is useful for these purpose.
8
8
  **Note:**
9
9
  On syncing 300 million rows table, it will consume around 800MB of memory with ruby 1.9.3 environment.
10
10
 
11
+ ### How it works
12
+
13
+ `mysql_replicator` does **not** rely on any `updated_at`/timestamp column to
14
+ detect changes. On every `interval`, it re-runs the configured `query`, computes
15
+ a hash for each returned row, and keeps an **in-memory hash table of every row**
16
+ (keyed by `primary_key`) to compare against the previous run:
17
+
18
+ * a row whose `primary_key` was not seen before → `insert` event
19
+ * a row whose hash changed since the previous run → `update` event
20
+ * a `primary_key` that disappeared from the result set → `delete` event (when `enable_delete yes`)
21
+
22
+ Because the whole result set is held in memory, this plugin is best suited for
23
+ small-to-medium tables. For millions of rows or multiple tables, prefer
24
+ [`mysql_replicator_multi`](Tutorial-mysql_replicator_multi.md), which stores the
25
+ hash table in a MySQL management table instead of Ruby memory.
26
+
27
+ The `updated_at` column is therefore not required. It only becomes useful if you
28
+ intentionally narrow the `query` to recently changed rows together with
29
+ `enable_delete no`, as shown in the `enable_delete` comment below.
30
+
11
31
  ### configuration
12
32
 
13
33
  `````
@@ -21,7 +41,7 @@ On syncing 300 million rows table, it will consume around 800MB of memory with r
21
41
  database myweb
22
42
 
23
43
  # Set replicate query configuration.
24
- query SELECT id, text, updated_at from search_test;
44
+ query SELECT id, text from search_test;
25
45
  primary_key id # specify unique key (default: id)
26
46
  interval 10s # execute query interval (default: 1m)
27
47
 
@@ -0,0 +1,19 @@
1
+ #!/bin/sh
2
+ # Install smoke test: reproduces the README install steps inside the official
3
+ # fluent/fluentd Debian image to verify the mysql2 native extension actually
4
+ # builds and the plugins load. Guards against the "Failed to build gem native
5
+ # extension" class of breakage reported in #43 / #16 / #35.
6
+ set -eux
7
+
8
+ GEM_FILE="${GEM_FILE:-/workspace/pkg/fluent-plugin-mysql-replicator.gem}"
9
+
10
+ # Build toolchain + MySQL client development headers (the bit users forget).
11
+ apt-get update
12
+ apt-get install -y build-essential default-libmysqlclient-dev
13
+
14
+ # Installing the gem compiles the mysql2 native extension; this fails loudly
15
+ # if the client headers are missing, which is exactly what we want to catch.
16
+ gem install "$GEM_FILE"
17
+
18
+ # Confirm the installed plugins (and the mysql2 native extension) load at runtime.
19
+ ruby "$(dirname "$0")/verify_plugins_load.rb"
@@ -0,0 +1,5 @@
1
+ # Loads the plugins to confirm they (and the mysql2 native extension) are
2
+ # usable at runtime after a fresh install. Shared by both install-smoke jobs.
3
+ require "fluent/plugin/in_mysql_replicator"
4
+ require "fluent/plugin/in_mysql_replicator_multi"
5
+ puts "input plugins loaded; mysql2 native extension OK"
@@ -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.1.0"
4
+ s.version = "1.2.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"
@@ -1,5 +1,6 @@
1
1
  require 'mysql2'
2
2
  require 'digest/sha1'
3
+ require 'json'
3
4
  require 'fluent/plugin/input'
4
5
 
5
6
  module Fluent::Plugin
@@ -19,6 +20,10 @@ module Fluent::Plugin
19
20
  config_param :primary_key, :string, :default => 'id'
20
21
  config_param :interval, :string, :default => '1m'
21
22
  config_param :enable_delete, :bool, :default => true
23
+ # Comma-separated column names whose MySQL JSON values should be parsed into
24
+ # nested objects before emitting. Intended for Elasticsearch; do not set it
25
+ # when the destination cannot store JSON objects (e.g. Solr).
26
+ config_param :json_columns, :array, :default => []
22
27
  config_param :tag, :string, :default => nil
23
28
 
24
29
  def configure(conf)
@@ -29,7 +34,7 @@ module Fluent::Plugin
29
34
  raise Fluent::ConfigError, "mysql_replicator: missing 'tag' parameter. Please add following line into config like 'tag replicator.mydatabase.mytable.${event}.${primary_key}'"
30
35
  end
31
36
 
32
- log.info "adding mysql_replicator worker. :tag=>#{tag} :query=>#{@query} :prepared_query=>#{@prepared_query} :interval=>#{@interval}sec :enable_delete=>#{enable_delete}"
37
+ log.info "adding mysql_replicator worker. :tag=>#{tag} :query=>#{@query} :prepared_query=>#{@prepared_query} :interval=>#{@interval}sec :enable_delete=>#{enable_delete} :json_columns=>#{@json_columns}"
33
38
  end
34
39
 
35
40
  def start
@@ -71,7 +76,8 @@ module Fluent::Plugin
71
76
  current_ids << row[@primary_key]
72
77
  current_hash = Digest::SHA1.hexdigest(row.flatten.join)
73
78
  row.each {|k, v| row[k] = v.to_s if v.is_a?(Time) || v.is_a?(Date) || v.is_a?(BigDecimal)}
74
- row.select {|k, v| v.to_s.strip.match(/^SELECT(\s+)/i) }.each do |k, v|
79
+ parse_json_columns!(row, @json_columns)
80
+ row.select {|k, v| nested_query_value?(v) }.each do |k, v|
75
81
  row[k] = [] unless row[k].is_a?(Array)
76
82
  nest_rows, prepared_con = query(v.gsub(/\$\{([^\}]+)\}/, row[$1].to_s), prepared_con)
77
83
  nest_rows.each do |nest_row|
@@ -97,13 +103,7 @@ module Fluent::Plugin
97
103
  con.close
98
104
  ids = current_ids
99
105
  if @enable_delete
100
- if current_ids.empty?
101
- deleted_ids = Array.new
102
- elsif previous_ids.empty?
103
- deleted_ids = [*1...current_ids.max] - current_ids
104
- else
105
- deleted_ids = previous_ids - current_ids
106
- end
106
+ deleted_ids = detect_deleted_ids(previous_ids, current_ids)
107
107
  if deleted_ids.count > 0
108
108
  hash_delete_by_list(table_hash, deleted_ids)
109
109
  deleted_ids.each do |id|
@@ -122,6 +122,35 @@ module Fluent::Plugin
122
122
  deleted_keys.each{|k| hash.delete(k)}
123
123
  end
124
124
 
125
+ # Returns the primary keys that disappeared since the previous poll.
126
+ #
127
+ # The first poll only establishes a baseline: there is no previous snapshot
128
+ # to diff against, so nothing is reported as deleted yet. This also avoids
129
+ # the old `[*1...current_ids.max]` range, which raised "bad value for range"
130
+ # for non-integer primary keys and allocated a huge array (and emitted
131
+ # phantom deletes) for large / sparse integer ids. (#42)
132
+ def detect_deleted_ids(previous_ids, current_ids)
133
+ return [] if previous_ids.empty?
134
+ return [] if current_ids.empty?
135
+ previous_ids - current_ids
136
+ end
137
+
138
+ # Parse the given columns' JSON string values into nested objects in place.
139
+ # Non-string values, missing columns, and malformed JSON are left untouched
140
+ # so enabling this never corrupts non-JSON data.
141
+ def parse_json_columns!(row, columns)
142
+ return if columns.empty?
143
+ columns.each do |col|
144
+ v = row[col]
145
+ next unless v.is_a?(String)
146
+ begin
147
+ row[col] = JSON.parse(v)
148
+ rescue JSON::ParserError
149
+ # leave the original string as-is on malformed JSON
150
+ end
151
+ end
152
+ end
153
+
125
154
  def format_tag(tag, param)
126
155
  pattern = {'${event}' => param[:event].to_s, '${primary_key}' => @primary_key}
127
156
  tag.gsub(/(\${[a-z_]+})/) do
@@ -130,6 +159,15 @@ module Fluent::Plugin
130
159
  end
131
160
  end
132
161
 
162
+ # A column value triggers a nested sub-query only when it is a query
163
+ # template containing a `${placeholder}` (e.g. "SELECT ... WHERE x = ${id}").
164
+ # Requiring the placeholder prevents ordinary text values that merely begin
165
+ # with the word "SELECT" from being executed as SQL. (#4; mirrors the fix
166
+ # already applied to mysql_replicator_multi in #6.)
167
+ def nested_query_value?(value)
168
+ value.to_s.strip.match?(/^SELECT[^\$]+\$\{[^\}]+\}/i)
169
+ end
170
+
133
171
  def emit_record(tag, record)
134
172
  router.emit(tag, Fluent::Engine.now, record)
135
173
  end
@@ -9,6 +9,7 @@ module Fluent::Plugin
9
9
  def initialize
10
10
  require 'mysql2'
11
11
  require 'digest/sha1'
12
+ require 'json'
12
13
  super
13
14
  end
14
15
 
@@ -81,6 +82,7 @@ module Fluent::Plugin
81
82
  log.info "mysql_replicator_multi: polling start. :config=>#{masked_config}"
82
83
  }
83
84
  primary_key = config['primary_key']
85
+ json_columns = (config['json_columns'] || '').split(',').map(&:strip).reject(&:empty?)
84
86
  previous_id = current_id = nil
85
87
  while @running
86
88
  rows_count = 0
@@ -97,6 +99,7 @@ module Fluent::Plugin
97
99
  db = get_origin_connection(config)
98
100
  db.query(config['query']).each do |row|
99
101
  row.each {|k, v| row[k] = v.to_s if v.is_a?(Time) || v.is_a?(Date) || v.is_a?(BigDecimal)}
102
+ parse_json_columns!(row, json_columns)
100
103
  row.select {|k, v| v.to_s.strip.match(/^SELECT[^\$]+\$\{[^\}]+\}/i) }.each do |k, v|
101
104
  row[k] = [] unless row[k].is_a?(Array)
102
105
  nest_db.query(v.gsub(/\$\{([^\}]+)\}/) {|matched| row[$1].to_s}).each do |nest_row|
@@ -266,6 +269,22 @@ module Fluent::Plugin
266
269
  @hash_table_bulk_insert_last_time = Time.now
267
270
  end
268
271
 
272
+ # Parse the given columns' JSON string values into nested objects in place.
273
+ # Non-string values, missing columns, and malformed JSON are left untouched
274
+ # so enabling this never corrupts non-JSON data.
275
+ def parse_json_columns!(row, columns)
276
+ return if columns.empty?
277
+ columns.each do |col|
278
+ v = row[col]
279
+ next unless v.is_a?(String)
280
+ begin
281
+ row[col] = JSON.parse(v)
282
+ rescue JSON::ParserError
283
+ # leave the original string as-is on malformed JSON
284
+ end
285
+ end
286
+ end
287
+
269
288
  def emit_record(tag, record)
270
289
  router.emit(tag, Fluent::Engine.now, record)
271
290
  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',
@@ -8,8 +8,9 @@
8
8
  username "#{ENV['MYSQL_USER'] || 'root'}"
9
9
  password "#{ENV['MYSQL_PASSWORD'] || 'root'}"
10
10
  database "#{ENV['MYSQL_DATABASE'] || 'e2e_source'}"
11
- query SELECT id, name, age FROM users
11
+ query SELECT id, name, age, profile FROM users
12
12
  primary_key id
13
+ json_columns profile
13
14
  interval 2s
14
15
  enable_delete true
15
16
  tag myindex.mytype.${event}.${primary_key}
@@ -41,13 +41,19 @@ client.query("CREATE DATABASE `#{SOURCE_DB}`")
41
41
  client.query("USE `#{SOURCE_DB}`")
42
42
  client.query(<<~SQL)
43
43
  CREATE TABLE users (
44
- id INT NOT NULL AUTO_INCREMENT,
45
- name VARCHAR(255) NOT NULL,
46
- age INT NOT NULL,
44
+ id INT NOT NULL AUTO_INCREMENT,
45
+ name VARCHAR(255) NOT NULL,
46
+ age INT NOT NULL,
47
+ profile JSON,
47
48
  PRIMARY KEY (id)
48
49
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8
49
50
  SQL
50
- client.query("INSERT INTO users (name, age) VALUES ('alice', 20), ('bob', 30), ('carol', 40)")
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
51
57
 
52
58
  # --- 2. Build the management database and register a replication setting ----
53
59
  step "building management database '#{MANAGER_DB}' from setup SQL"
@@ -61,11 +67,11 @@ cfg = mysql_config
61
67
  client.query(<<~SQL)
62
68
  INSERT INTO `#{MANAGER_DB}`.settings
63
69
  (is_active, name, host, port, username, password, `database`,
64
- query, prepared_query, `interval`, primary_key, enable_delete)
70
+ query, prepared_query, `interval`, primary_key, enable_delete, json_columns)
65
71
  VALUES
66
72
  (1, '#{SETTING_NAME}', '#{cfg[:host]}', #{cfg[:port]},
67
73
  '#{cfg[:username]}', '#{client.escape(cfg[:password].to_s)}', '#{SOURCE_DB}',
68
- 'SELECT id, name, age FROM users ORDER BY id', '', 2, 'id', 1)
74
+ 'SELECT id, name, age, profile FROM users ORDER BY id', '', 2, 'id', 1, 'profile')
69
75
  SQL
70
76
 
71
77
  # --- 3. Boot Fluentd --------------------------------------------------------
@@ -83,6 +89,18 @@ begin
83
89
  end
84
90
  step " INSERT OK"
85
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
+
86
104
  # --- 5. hash_tables state is persisted ------------------------------------
87
105
  step "asserting hash_tables persistence"
88
106
  wait_until("3 rows recorded in hash_tables for '#{SETTING_NAME}'", log_path: LOG_PATH) do
@@ -24,13 +24,18 @@ client.query("CREATE DATABASE `#{DB}`")
24
24
  client.query("USE `#{DB}`")
25
25
  client.query(<<~SQL)
26
26
  CREATE TABLE users (
27
- id INT NOT NULL AUTO_INCREMENT,
28
- name VARCHAR(255) NOT NULL,
29
- age INT NOT NULL,
27
+ id INT NOT NULL AUTO_INCREMENT,
28
+ name VARCHAR(255) NOT NULL,
29
+ age INT NOT NULL,
30
+ profile JSON,
30
31
  PRIMARY KEY (id)
31
32
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8
32
33
  SQL
33
- client.query("INSERT INTO users (name, age) VALUES ('alice', 20), ('bob', 30)")
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
34
39
 
35
40
  # --- 2. Boot Fluentd --------------------------------------------------------
36
41
  step "starting Fluentd (#{CONF})"
@@ -49,6 +54,18 @@ begin
49
54
  end
50
55
  step " INSERT OK"
51
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
+
52
69
  # --- 4. UPDATE is replicated ----------------------------------------------
53
70
  step "asserting UPDATE replication"
54
71
  client.query("UPDATE users SET age = 21 WHERE id = 1")
@@ -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
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.1.0
4
+ version: 1.2.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-15 00:00:00.000000000 Z
11
+ date: 2026-06-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -112,12 +112,15 @@ files:
112
112
  - ".dockerignore"
113
113
  - ".github/workflows/ci.yml"
114
114
  - ".gitignore"
115
+ - CHANGELOG.md
115
116
  - Gemfile
116
117
  - LICENSE
117
118
  - README.md
118
119
  - Rakefile
119
120
  - Tutorial-mysql_replicator.md
120
121
  - Tutorial-mysql_replicator_multi.md
122
+ - ci/install_smoke.sh
123
+ - ci/verify_plugins_load.rb
121
124
  - example/mysql_multi_table_to_elasticsearch.md
122
125
  - example/mysql_multi_table_to_solr.md
123
126
  - example/mysql_single_table_to_elasticsearch.md