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 +4 -4
- data/.devcontainer/Dockerfile +1 -1
- data/.github/workflows/ci.yml +63 -0
- data/CHANGELOG.md +105 -0
- data/README.md +99 -4
- data/Tutorial-mysql_replicator.md +21 -1
- data/ci/install_smoke.sh +19 -0
- data/ci/verify_plugins_load.rb +5 -0
- data/fluent-plugin-mysql-replicator.gemspec +1 -1
- data/lib/fluent/plugin/in_mysql_replicator.rb +66 -18
- data/lib/fluent/plugin/in_mysql_replicator_multi.rb +19 -0
- data/lib/fluent/plugin/out_mysql_replicator_elasticsearch.rb +22 -5
- data/setup_mysql_replicator_multi.sql +3 -0
- data/test/e2e/fluent_single.conf +2 -1
- data/test/e2e/replication_multi_e2e_test.rb +24 -6
- data/test/e2e/replication_single_e2e_test.rb +21 -4
- data/test/plugin/test_in_mysql_replicator.rb +126 -0
- data/test/plugin/test_out_mysql_replicator_elasticsearch.rb +26 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50b2ac9d97e49b3febf1c20fc98c815b3d6a919d299a24511a72b8b1637d5585
|
|
4
|
+
data.tar.gz: f39b735a34afd31a77f5e82339c449265dd4ef455456a85e2902dab91db79130
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d57b725ee936f2ffc8ae55631e75759c68de863022cb5e5740be87eae6a756b9ddc5a531825bb603a02f6a7f57752ce75d389ff3fd777ba86e5126f83f392710
|
|
7
|
+
data.tar.gz: 8b644659039eae41a70dbe27f206673fb1420372321c00af802925c52b7d8fe3b5abb880d0aaf059521ceb80fbd414c1a41a7b4fef6c6dd8fd2a05ef746fdf05
|
data/.devcontainer/Dockerfile
CHANGED
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
data/ci/install_smoke.sh
ADDED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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?(
|
|
97
|
+
if !table_hash.include?(id)
|
|
88
98
|
tag = format_tag(@tag, {:event => :insert})
|
|
89
99
|
emit_record(tag, row)
|
|
90
|
-
elsif table_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[
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
82
|
-
action['_id'] = record
|
|
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',
|
data/test/e2e/fluent_single.conf
CHANGED
|
@@ -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
|
|
45
|
-
name
|
|
46
|
-
age
|
|
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(
|
|
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
|
|
28
|
-
name
|
|
29
|
-
age
|
|
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(
|
|
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.
|
|
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-
|
|
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
|