fluent-plugin-mysql-replicator 1.1.0 → 1.3.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: 50b2ac9d97e49b3febf1c20fc98c815b3d6a919d299a24511a72b8b1637d5585
4
+ data.tar.gz: f39b735a34afd31a77f5e82339c449265dd4ef455456a85e2902dab91db79130
5
5
  SHA512:
6
- metadata.gz: 65ec0c92b273597f7f7ff976fc42878e4fa413d8471947125a6873d7209142bec87a59aa37e3c491fd1a81dea4a9d448e3280cbca1eafd381fdee241d723bcae
7
- data.tar.gz: 20fb6ffe6e217e5d8ee0108c5243fad7f6abd910f1a2d336f65bcb8ba0388ac22e329d408edaa5ad8f298c959cffc2656932d71302d6b2b38c21c28cab14585d
6
+ metadata.gz: d57b725ee936f2ffc8ae55631e75759c68de863022cb5e5740be87eae6a756b9ddc5a531825bb603a02f6a7f57752ce75d389ff3fd777ba86e5126f83f392710
7
+ data.tar.gz: 8b644659039eae41a70dbe27f206673fb1420372321c00af802925c52b7d8fe3b5abb880d0aaf059521ceb80fbd414c1a41a7b4fef6c6dd8fd2a05ef746fdf05
@@ -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,105 @@
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.3.0] - 2026-06-16
9
+
10
+ ### Added
11
+ - Date-based Elasticsearch index names. If the index-name segment of the tag
12
+ contains `strftime` tokens (e.g. `%Y%m%d`), they are expanded using the
13
+ record's event time, enabling Logstash-style dated indices such as
14
+ `myindex-20180831`. Index names without a `%` are unchanged. ([#27])
15
+ - Composite primary key support in `mysql_replicator`. `primary_key` now accepts
16
+ a comma-separated list of columns; the combination is used for change
17
+ detection and as the Elasticsearch document `_id` (values joined by `,`). A
18
+ single-column key behaves exactly as before. ([#7])
19
+
20
+ ## [1.2.0] - 2026-06-16
21
+
22
+ ### Added
23
+ - JSON column support for nested object indexing. The new opt-in `json_columns`
24
+ option parses MySQL `JSON` string columns into nested objects before they are
25
+ emitted, so Elasticsearch indexes them as real nested JSON instead of escaped
26
+ strings. Available on both `mysql_replicator` (config option) and
27
+ `mysql_replicator_multi` (new `json_columns` column on the `settings` table).
28
+ Malformed JSON and non-string values are left untouched. ([#48], implements [#18])
29
+
30
+ ### Fixed
31
+ - Delete detection no longer raises `bad value for range` (and no longer
32
+ allocates a huge array or emits phantom delete events) when the `primary_key`
33
+ is non-integer (e.g. a UUID `CHAR(36)`) or a large/sparse integer. The first
34
+ poll now only establishes a baseline. ([#42])
35
+ - In `mysql_replicator`, a nested sub-query is now triggered only when a column
36
+ value is an actual query template containing a `${placeholder}`. Previously any
37
+ value that merely began with the word `SELECT` was executed as SQL. This aligns
38
+ the single plugin with the fix already present in `mysql_replicator_multi`. ([#4])
39
+
40
+ ### Documentation
41
+ - Document that the `mysql2` native extension requires the MySQL client
42
+ development headers (`default-libmysqlclient-dev` / `mysql-devel`), add a
43
+ "Failed to build gem native extension" troubleshooting note, and clarify that
44
+ change detection uses an in-memory hash of every row (not an `updated_at`
45
+ column). ([#43], [#40])
46
+
47
+ ### CI
48
+ - Add install-smoke jobs that build and install the gem on **fluent-package v6
49
+ LTS** (the td-agent successor) and on the official `fluent/fluentd` image,
50
+ guarding against native-extension build regressions.
51
+
52
+ ## [1.1.0] - 2026-06-15
53
+
54
+ ### Added
55
+ - Auto-detect the Elasticsearch version on first write and support Elasticsearch
56
+ 6.x through 9.x. The `_type` field is omitted automatically for 7.x and later,
57
+ where mapping types were removed.
58
+ - Dev Container configuration and end-to-end (E2E) replication tests, including
59
+ multi-table replication.
60
+
61
+ ### Changed
62
+ - Migrated CI to GitHub Actions (removed Travis CI) and run the unit-test matrix
63
+ on Ruby 3.2–4.0, aligned with supported Fluentd versions.
64
+
65
+ ## [1.0.3] - 2024-02-28
66
+
67
+ ### Added
68
+ - Reconnection option to recover from database connection errors. ([#45])
69
+
70
+ ## [1.0.2] - 2021-10-27
71
+
72
+ ### Fixed
73
+ - Manage threads correctly in `mysql_replicator_multi`. ([#39])
74
+
75
+ ## [1.0.1] - 2019-11-19
76
+
77
+ ### Fixed
78
+ - Ensure the nested-query database connection (`nest_db`) is always closed.
79
+
80
+ ### Documentation
81
+ - Documentation updates.
82
+
83
+ ## [1.0.0] - 2019-11-13
84
+
85
+ - First 1.0 release, targeting the Fluentd v0.14+ plugin API. Earlier 0.x
86
+ history is available in the git log.
87
+
88
+ [1.3.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.2.0...v1.3.0
89
+ [1.2.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.1.0...v1.2.0
90
+ [1.1.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.3...v1.1.0
91
+ [1.0.3]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.2...v1.0.3
92
+ [1.0.2]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.1...v1.0.2
93
+ [1.0.1]: https://github.com/y-ken/fluent-plugin-mysql-replicator/compare/v1.0.0...v1.0.1
94
+ [1.0.0]: https://github.com/y-ken/fluent-plugin-mysql-replicator/releases/tag/v1.0.0
95
+
96
+ [#4]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/4
97
+ [#7]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/7
98
+ [#18]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/18
99
+ [#27]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/27
100
+ [#39]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/39
101
+ [#40]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/40
102
+ [#42]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/42
103
+ [#43]: https://github.com/y-ken/fluent-plugin-mysql-replicator/issues/43
104
+ [#45]: https://github.com/y-ken/fluent-plugin-mysql-replicator/pull/45
105
+ [#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,87 @@ 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
+
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
+
77
172
  ## Output example
78
173
 
79
174
  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.3.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
@@ -16,9 +17,16 @@ module Fluent::Plugin
16
17
  config_param :encoding, :string, :default => 'utf8'
17
18
  config_param :query, :string
18
19
  config_param :prepared_query, :string, :default => nil
19
- 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']
20
24
  config_param :interval, :string, :default => '1m'
21
25
  config_param :enable_delete, :bool, :default => true
26
+ # Comma-separated column names whose MySQL JSON values should be parsed into
27
+ # nested objects before emitting. Intended for Elasticsearch; do not set it
28
+ # when the destination cannot store JSON objects (e.g. Solr).
29
+ config_param :json_columns, :array, :default => []
22
30
  config_param :tag, :string, :default => nil
23
31
 
24
32
  def configure(conf)
@@ -29,7 +37,7 @@ module Fluent::Plugin
29
37
  raise Fluent::ConfigError, "mysql_replicator: missing 'tag' parameter. Please add following line into config like 'tag replicator.mydatabase.mytable.${event}.${primary_key}'"
30
38
  end
31
39
 
32
- log.info "adding mysql_replicator worker. :tag=>#{tag} :query=>#{@query} :prepared_query=>#{@prepared_query} :interval=>#{@interval}sec :enable_delete=>#{enable_delete}"
40
+ 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
41
  end
34
42
 
35
43
  def start
@@ -68,10 +76,12 @@ module Fluent::Plugin
68
76
  end
69
77
  rows, con = query(@query, con)
70
78
  rows.each do |row|
71
- current_ids << row[@primary_key]
79
+ id = extract_id(row)
80
+ current_ids << id
72
81
  current_hash = Digest::SHA1.hexdigest(row.flatten.join)
73
82
  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|
83
+ parse_json_columns!(row, @json_columns)
84
+ row.select {|k, v| nested_query_value?(v) }.each do |k, v|
75
85
  row[k] = [] unless row[k].is_a?(Array)
76
86
  nest_rows, prepared_con = query(v.gsub(/\$\{([^\}]+)\}/, row[$1].to_s), prepared_con)
77
87
  nest_rows.each do |nest_row|
@@ -80,35 +90,29 @@ module Fluent::Plugin
80
90
  end
81
91
  prepared_con.close
82
92
  end
83
- if row[@primary_key].nil?
84
- 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}"
85
95
  break
86
96
  end
87
- if !table_hash.include?(row[@primary_key])
97
+ if !table_hash.include?(id)
88
98
  tag = format_tag(@tag, {:event => :insert})
89
99
  emit_record(tag, row)
90
- elsif table_hash[row[@primary_key]] != current_hash
100
+ elsif table_hash[id] != current_hash
91
101
  tag = format_tag(@tag, {:event => :update})
92
102
  emit_record(tag, row)
93
103
  end
94
- table_hash[row[@primary_key]] = current_hash
104
+ table_hash[id] = current_hash
95
105
  rows_count += 1
96
106
  end
97
107
  con.close
98
108
  ids = current_ids
99
109
  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
110
+ deleted_ids = detect_deleted_ids(previous_ids, current_ids)
107
111
  if deleted_ids.count > 0
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,14 +126,58 @@ 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
+
135
+ # Returns the primary keys that disappeared since the previous poll.
136
+ #
137
+ # The first poll only establishes a baseline: there is no previous snapshot
138
+ # to diff against, so nothing is reported as deleted yet. This also avoids
139
+ # the old `[*1...current_ids.max]` range, which raised "bad value for range"
140
+ # for non-integer primary keys and allocated a huge array (and emitted
141
+ # phantom deletes) for large / sparse integer ids. (#42)
142
+ def detect_deleted_ids(previous_ids, current_ids)
143
+ return [] if previous_ids.empty?
144
+ return [] if current_ids.empty?
145
+ previous_ids - current_ids
146
+ end
147
+
148
+ # Parse the given columns' JSON string values into nested objects in place.
149
+ # Non-string values, missing columns, and malformed JSON are left untouched
150
+ # so enabling this never corrupts non-JSON data.
151
+ def parse_json_columns!(row, columns)
152
+ return if columns.empty?
153
+ columns.each do |col|
154
+ v = row[col]
155
+ next unless v.is_a?(String)
156
+ begin
157
+ row[col] = JSON.parse(v)
158
+ rescue JSON::ParserError
159
+ # leave the original string as-is on malformed JSON
160
+ end
161
+ end
162
+ end
163
+
125
164
  def format_tag(tag, param)
126
- pattern = {'${event}' => param[:event].to_s, '${primary_key}' => @primary_key}
165
+ pattern = {'${event}' => param[:event].to_s, '${primary_key}' => @primary_key.join(',')}
127
166
  tag.gsub(/(\${[a-z_]+})/) do
128
167
  log.warn "mysql_replicator: missing placeholder. :tag=>#{tag} :placeholder=>#{$1}" unless pattern.include?($1)
129
168
  pattern[$1]
130
169
  end
131
170
  end
132
171
 
172
+ # A column value triggers a nested sub-query only when it is a query
173
+ # template containing a `${placeholder}` (e.g. "SELECT ... WHERE x = ${id}").
174
+ # Requiring the placeholder prevents ordinary text values that merely begin
175
+ # with the word "SELECT" from being executed as SQL. (#4; mirrors the fix
176
+ # already applied to mysql_replicator_multi in #6.)
177
+ def nested_query_value?(value)
178
+ value.to_s.strip.match?(/^SELECT[^\$]+\$\{[^\}]+\}/i)
179
+ end
180
+
133
181
  def emit_record(tag, record)
134
182
  router.emit(tag, Fluent::Engine.now, record)
135
183
  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
@@ -66,20 +66,20 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
66
66
 
67
67
  chunk.msgpack_each do |tag, time, record|
68
68
  tag_parts = tag.match(@tag_format)
69
- target_index = tag_parts['index_name']
69
+ target_index = resolve_index_name(tag_parts['index_name'], time)
70
70
  target_type = tag_parts['type_name']
71
- id_key = tag_parts['primary_key']
71
+ id_keys = tag_parts['primary_key'].to_s.split(',')
72
72
 
73
73
  if tag_parts['event'] == 'delete'
74
- action = {"_index" => target_index, "_id" => record[id_key]}
74
+ action = {"_index" => target_index, "_id" => join_id(record, id_keys)}
75
75
  action['_type'] = target_type unless @suppress_type
76
76
  meta = { "delete" => action }
77
77
  bulk_message << Yajl::Encoder.encode(meta)
78
78
  else
79
79
  action = {"_index" => target_index}
80
80
  action['_type'] = target_type unless @suppress_type
81
- if id_key && record[id_key]
82
- action['_id'] = record[id_key]
81
+ if !id_keys.empty? && id_keys.all? {|k| !record[k].nil? }
82
+ action['_id'] = join_id(record, id_keys)
83
83
  end
84
84
  meta = { "index" => action }
85
85
  bulk_message << Yajl::Encoder.encode(meta)
@@ -105,6 +105,23 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
105
105
  http
106
106
  end
107
107
 
108
+ # Build the document _id from one or more primary-key columns. A single key
109
+ # yields its value; a composite key yields the values joined by ",".
110
+ def join_id(record, id_keys)
111
+ id_keys.map {|k| record[k] }.join(',')
112
+ end
113
+
114
+ # Expand strftime tokens (e.g. "%Y%m%d") in the index name using the record's
115
+ # event time, enabling date-based indices such as "myindex-20180831". Index
116
+ # names without a "%" are returned unchanged.
117
+ def resolve_index_name(index_name, time)
118
+ return index_name unless index_name && index_name.include?('%')
119
+ Time.at(time.to_i).strftime(index_name)
120
+ rescue => e
121
+ log.warn "mysql_replicator_elasticsearch: failed to expand index name '#{index_name}': #{e.message}"
122
+ index_name
123
+ end
124
+
108
125
  # Mapping types were removed in Elasticsearch 8.x and deprecated in 7.x.
109
126
  # Detect the major version once and omit "_type" for 7.x and later.
110
127
  def detect_type_suppression
@@ -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,137 @@ 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)
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'})
39
165
  end
40
166
  end
@@ -61,6 +61,32 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
61
61
  assert_equal('myindex', index_cmds.first['index']['_index'])
62
62
  end
63
63
 
64
+ def test_expands_strftime_tokens_in_index_name
65
+ stub_elastic
66
+ time = Time.utc(2018, 8, 31, 12, 0, 0).to_i
67
+ driver.run(default_tag: 'myindex-%Y%m%d.mytype.insert.id') do
68
+ driver.feed(time, sample_record)
69
+ end
70
+ expected = "myindex-#{Time.at(time).strftime('%Y%m%d')}"
71
+ assert_equal(expected, index_cmds.first['index']['_index'])
72
+ end
73
+
74
+ def test_index_name_without_token_is_unchanged
75
+ stub_elastic
76
+ driver.run(default_tag: 'plainindex.mytype.insert.id') do
77
+ driver.feed(sample_record)
78
+ end
79
+ assert_equal('plainindex', index_cmds.first['index']['_index'])
80
+ end
81
+
82
+ def test_composite_primary_key_builds_joined_id
83
+ stub_elastic
84
+ driver.run(default_tag: 'myindex.mytype.insert.tenant_id,id') do
85
+ driver.feed({'tenant_id' => 10, 'id' => 7, 'text' => 'x'})
86
+ end
87
+ assert_equal('10,7', index_cmds.first['index']['_id'])
88
+ end
89
+
64
90
  def test_writes_to_speficied_type
65
91
  driver.configure("type_name mytype\n")
66
92
  stub_elastic
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.3.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