Compare commits

..

4 Commits

Author SHA1 Message Date
Tim Vaillancourt
859e4bea09
Merge branch 'master' into latin1-utf8mb4 2021-07-19 17:00:52 +02:00
Tim Vaillancourt
dc2bf29854
Merge branch 'master' into latin1-utf8mb4 2021-07-17 19:51:23 +02:00
Josh Bielick
84e55ff904
copy and update text using convert when charset changes
addresses #290

Note: there is currently no issue backfilling the ghost table when the
characterset changes, likely because it's a insert-into-select-from and
it all occurs within mysql.

However, when applying DML events (UPDATE, DELETE, etc) the values are
sprintf'd into a prepared statement and due to the possibility of
migrating text column data containing invalid characters in the
destination charset, a conversion step is often necessary.

For example, when migrating a table/column from latin1 to utf8mb4, the
latin1 column may contain characters that are invalid single-byte utf8
characters. Characters in the \x80-\xFF range are most common. When
written to utf8mb4 column without conversion, they fail as they do not
exist in the utf8 codepage.

Converting these texts/characters to the destination charset using
convert(? using {charset}) will convert appropriately and the
update/replace will succeed.

I only point out the "Note:" above because there are two tests added
for this: latin1text-to-utf8mb4 and latin1text-to-ut8mb4-insert

The former is a test that fails prior to this commit. The latter is a
test that succeeds prior to this comment. Both are affected by the code
in this commit.

convert text to original charset, then destination

converting text first to the original charset and then to the
destination charset produces the most consistent results, as inserting
the binary into a utf8-charset column may encounter an error if there is
no prior context of latin1 encoding.

mysql> select hex(convert(char(189) using utf8mb4));
+---------------------------------------+
| hex(convert(char(189) using utf8mb4)) |
+---------------------------------------+
|                                       |
+---------------------------------------+
1 row in set, 1 warning (0.00 sec)

mysql> select hex(convert(convert(char(189) using latin1) using utf8mb4));
+-------------------------------------------------------------+
| hex(convert(convert(char(189) using latin1) using utf8mb4)) |
+-------------------------------------------------------------+
| C2BD                                                        |
+-------------------------------------------------------------+
1 row in set (0.00 sec)

as seen in this failure on 5.5.62

 Error 1300: Invalid utf8mb4 character string: 'BD'; query=
			replace /* gh-ost `test`.`_gh_ost_test_gho` */ into
				`test`.`_gh_ost_test_gho`
					(`id`, `t`)
				values
					(?, convert(? using utf8mb4))
2021-07-14 09:20:24 -04:00
Josh Bielick
b84af7bdc7
add failing test for #290, invalid utf8 char
this test assumes a latin1-encoded table with content containing bytes
in the \x80-\xFF, which are invalid single-byte characters in utf8 and
cannot be inserted in the altered table when the column containing these
characters is changed to utf8(mb4).

since these characters cannot be inserted, gh-ost fails.
2021-07-14 09:20:19 -04:00
433 changed files with 14949 additions and 22974 deletions

View File

@ -5,15 +5,15 @@ on: [pull_request]
jobs: jobs:
build: build:
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Go - name: Set up Go 1.16
uses: actions/setup-go@v1 uses: actions/setup-go@v1
with: with:
go-version: 1.17 go-version: 1.16
- name: Build - name: Build
run: script/cibuild run: script/cibuild

View File

@ -2,7 +2,6 @@ name: "CodeQL analysis"
on: on:
push: push:
pull_request:
schedule: schedule:
- cron: '0 0 * * 0' - cron: '0 0 * * 0'

View File

@ -1,23 +0,0 @@
name: golangci-lint
on:
push:
branches:
- master
pull_request:
permissions:
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
# pull-requests: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v3
with:
go-version: 1.17
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.46.2

View File

@ -5,18 +5,18 @@ on: [pull_request]
jobs: jobs:
build: build:
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
version: [mysql-5.7.25,mysql-8.0.16,PerconaServer-8.0.21] version: [mysql-5.5.62,mysql-5.6.43,mysql-5.7.25,mysql-8.0.16]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Go - name: Set up Go 1.16
uses: actions/setup-go@v1 uses: actions/setup-go@v1
with: with:
go-version: 1.17 go-version: 1.16
- name: migration tests - name: migration tests
env: env:

View File

@ -1,30 +0,0 @@
run:
timeout: 5m
linters:
disable:
- errcheck
enable:
- bodyclose
- containedctx
- contextcheck
- dogsled
- durationcheck
- errname
- errorlint
- execinquery
- gofmt
- ifshort
- misspell
- nilerr
- nilnil
- noctx
- nolintlint
- nosprintfhostport
- prealloc
- rowserrcheck
- sqlclosecheck
- unconvert
- unparam
- unused
- wastedassign
- whitespace

View File

@ -1,4 +1,4 @@
FROM golang:1.17 FROM golang:1.16.4
RUN apt-get update RUN apt-get update
RUN apt-get install -y ruby ruby-dev rubygems build-essential RUN apt-get install -y ruby ruby-dev rubygems build-essential

View File

@ -1,4 +1,4 @@
FROM golang:1.17 FROM golang:1.16.4
LABEL maintainer="github@github.com" LABEL maintainer="github@github.com"
RUN apt-get update RUN apt-get update

View File

@ -18,32 +18,30 @@ function build {
GOOS=$3 GOOS=$3
GOARCH=$4 GOARCH=$4
if ! go version | egrep -q 'go1\.(1[5-9]|[2-9][0-9]{1})' ; then if ! go version | egrep -q 'go(1\.1[56])' ; then
echo "go version must be 1.15 or above" echo "go version must be 1.15 or above"
exit 1 exit 1
fi fi
echo "Building ${osname}-${GOARCH} binary" echo "Building ${osname} binary"
export GOOS export GOOS
export GOARCH export GOARCH
go build -ldflags "$ldflags" -o $buildpath/$target go/cmd/gh-ost/main.go go build -ldflags "$ldflags" -o $buildpath/$target go/cmd/gh-ost/main.go
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "Build failed for ${osname} ${GOARCH}." echo "Build failed for ${osname}"
exit 1 exit 1
fi fi
(cd $buildpath && tar cfz ./gh-ost-binary-${osshort}-${GOARCH}-${timestamp}.tar.gz $target) (cd $buildpath && tar cfz ./gh-ost-binary-${osshort}-${timestamp}.tar.gz $target)
# build RPM and deb for Linux, x86-64 only if [ "$GOOS" == "linux" ] ; then
if [ "$GOOS" == "linux" ] && [ "$GOARCH" == "amd64" ] ; then
echo "Creating Distro full packages" echo "Creating Distro full packages"
builddir=$(setuptree) builddir=$(setuptree)
cp $buildpath/$target $builddir/gh-ost/usr/bin cp $buildpath/$target $builddir/gh-ost/usr/bin
cd $buildpath cd $buildpath
fpm -v "${RELEASE_VERSION}" --epoch 1 -f -s dir -n gh-ost -m 'GitHub' --description "GitHub's Online Schema Migrations for MySQL " --url "https://github.com/github/gh-ost" --vendor "GitHub" --license "Apache 2.0" -C $builddir/gh-ost --prefix=/ -t rpm --rpm-rpmbuild-define "_build_id_links none" --rpm-os linux . fpm -v "${RELEASE_VERSION}" --epoch 1 -f -s dir -n gh-ost -m 'shlomi-noach <shlomi-noach+gh-ost-deb@github.com>' --description "GitHub's Online Schema Migrations for MySQL " --url "https://github.com/github/gh-ost" --vendor "GitHub" --license "Apache 2.0" -C $builddir/gh-ost --prefix=/ -t rpm --rpm-rpmbuild-define "_build_id_links none" .
fpm -v "${RELEASE_VERSION}" --epoch 1 -f -s dir -n gh-ost -m 'GitHub' --description "GitHub's Online Schema Migrations for MySQL " --url "https://github.com/github/gh-ost" --vendor "GitHub" --license "Apache 2.0" -C $builddir/gh-ost --prefix=/ -t deb --deb-no-default-config-files . fpm -v "${RELEASE_VERSION}" --epoch 1 -f -s dir -n gh-ost -m 'shlomi-noach <shlomi-noach+gh-ost-deb@github.com>' --description "GitHub's Online Schema Migrations for MySQL " --url "https://github.com/github/gh-ost" --vendor "GitHub" --license "Apache 2.0" -C $builddir/gh-ost --prefix=/ -t deb --deb-no-default-config-files .
cd -
fi fi
} }
@ -64,15 +62,10 @@ main() {
mkdir -p ${buildpath} mkdir -p ${buildpath}
rm -rf ${buildpath:?}/* rm -rf ${buildpath:?}/*
build GNU/Linux linux linux amd64 build GNU/Linux linux linux amd64
build GNU/Linux linux linux arm64 # build macOS osx darwin amd64
build macOS osx darwin amd64
build macOS osx darwin arm64
echo "Binaries found in:" echo "Binaries found in:"
find $buildpath/gh-ost* -type f -maxdepth 1 find $buildpath/gh-ost* -type f -maxdepth 1
echo "Checksums:"
(cd $buildpath && shasum -a256 gh-ost* 2>/dev/null)
} }
main "$@" main "$@"

View File

@ -5,7 +5,7 @@
Getting started with gh-ost development is simple! Getting started with gh-ost development is simple!
- First obtain the repository with `git clone` or `go get`. - First obtain the repository with `git clone` or `go get`.
- From inside of the repository run `script/cibuild`. - From inside of the repository run `script/cibuild`
- This will bootstrap the environment if needed, format the code, build the code, and then run the unit test. - This will bootstrap the environment if needed, format the code, build the code, and then run the unit test.
## CI build workflow ## CI build workflow
@ -14,12 +14,6 @@ Getting started with gh-ost development is simple!
If additional steps are needed, please add them into this workflow so that the workflow remains simple. If additional steps are needed, please add them into this workflow so that the workflow remains simple.
## `golang-ci` linter
To enfore best-practices, Pull Requests are automatically linted by [`golang-ci`](https://golangci-lint.run/). The linter config is located at [`.golangci.yml`](https://github.com/github/gh-ost/blob/master/.golangci.yml) and the `golangci-lint` GitHub Action is located at [`.github/workflows/golangci-lint.yml`](https://github.com/github/gh-ost/blob/master/.github/workflows/golangci-lint.yml).
To run the `golang-ci` linters locally _(recommended before push)_, use `script/lint`.
## Notes: ## Notes:
Currently, `script/ensure-go-installed` will install `go` for Mac OS X and Linux. We welcome PR's to add other platforms. Currently, `script/ensure-go-installed` will install `go` for Mac OS X and Linux. We welcome PR's to add other platforms.

View File

@ -6,10 +6,6 @@ A more in-depth discussion of various `gh-ost` command line flags: implementatio
Add this flag when executing on Aliyun RDS. Add this flag when executing on Aliyun RDS.
### allow-zero-in-date
Allows the user to make schema changes that include a zero date or zero in date (e.g. adding a `datetime default '0000-00-00 00:00:00'` column), even if global `sql_mode` on MySQL has `NO_ZERO_IN_DATE,NO_ZERO_DATE`.
### azure ### azure
Add this flag when executing on Azure Database for MySQL. Add this flag when executing on Azure Database for MySQL.
@ -26,7 +22,7 @@ If, for some reason, you do not wish `gh-ost` to connect to a replica, you may c
### approve-renamed-columns ### approve-renamed-columns
When your migration issues a column rename (`change column old_name new_name ...`) `gh-ost` analyzes the statement to try and associate the old column name with new column name. Otherwise, the new structure may also look like some column was dropped and another was added. When your migration issues a column rename (`change column old_name new_name ...`) `gh-ost` analyzes the statement to try and associate the old column name with new column name. Otherwise the new structure may also look like some column was dropped and another was added.
`gh-ost` will print out what it thinks the _rename_ implied, but will not issue the migration unless you provide with `--approve-renamed-columns`. `gh-ost` will print out what it thinks the _rename_ implied, but will not issue the migration unless you provide with `--approve-renamed-columns`.
@ -36,7 +32,7 @@ If you think `gh-ost` is mistaken and that there's actually no _rename_ involved
`gh-ost` infers the identity of the master server by crawling up the replication topology. You may explicitly tell `gh-ost` the identity of the master host via `--assume-master-host=the.master.com`. This is useful in: `gh-ost` infers the identity of the master server by crawling up the replication topology. You may explicitly tell `gh-ost` the identity of the master host via `--assume-master-host=the.master.com`. This is useful in:
- _master-master_ topologies (together with [`--allow-master-master`](#allow-master-master)), where `gh-ost` can arbitrarily pick one of the co-masters, and you prefer that it picks a specific one - _master-master_ topologies (together with [`--allow-master-master`](#allow-master-master)), where `gh-ost` can arbitrarily pick one of the co-masters and you prefer that it picks a specific one
- _tungsten replicator_ topologies (together with [`--tungsten`](#tungsten)), where `gh-ost` is unable to crawl and detect the master - _tungsten replicator_ topologies (together with [`--tungsten`](#tungsten)), where `gh-ost` is unable to crawl and detect the master
### assume-rbr ### assume-rbr
@ -45,25 +41,6 @@ If you happen to _know_ your servers use RBR (Row Based Replication, i.e. `binlo
Skipping this step means `gh-ost` would not need the `SUPER` privilege in order to operate. Skipping this step means `gh-ost` would not need the `SUPER` privilege in order to operate.
You may want to use this on Amazon RDS. You may want to use this on Amazon RDS.
### attempt-instant-ddl
MySQL 8.0 supports "instant DDL" for some operations. If an alter statement can be completed with instant DDL, only a metadata change is required internally. Instant operations include:
- Adding a column
- Dropping a column
- Dropping an index
- Extending a varchar column
- Adding a virtual generated column
It is not reliable to parse the `ALTER` statement to determine if it is instant or not. This is because the table might be in an older row format, or have some other incompatibility that is difficult to identify.
`--attempt-instant-ddl` is disabled by default, but the risks of enabling it are relatively minor: `gh-ost` may need to acquire a metadata lock at the start of the operation. This is not a problem for most scenarios, but it could be a problem for users that start the DDL during a period with long running transactions.
`gh-ost` will automatically fallback to the normal DDL process if the attempt to use instant DDL is unsuccessful.
### binlogsyncer-max-reconnect-attempts
`--binlogsyncer-max-reconnect-attempts=0`, the maximum number of attempts to re-establish a broken inspector connection for sync binlog. `0` or `negative number` means infinite retry, default `0`
### conf ### conf
`--conf=/path/to/my.cnf`: file where credentials are specified. Should be in (or contain) the following format: `--conf=/path/to/my.cnf`: file where credentials are specified. Should be in (or contain) the following format:
@ -84,13 +61,7 @@ Comma delimited status-name=threshold, same format as [`--max-load`](#max-load).
`--critical-load` defines a threshold that, when met, `gh-ost` panics and bails out. The default behavior is to bail out immediately when meeting this threshold. `--critical-load` defines a threshold that, when met, `gh-ost` panics and bails out. The default behavior is to bail out immediately when meeting this threshold.
This may sometimes lead to migrations bailing out on a very short spike, that, while in itself is impacting production and is worth investigating, isn't reason enough to kill a 10-hour migration. This may sometimes lead to migrations bailing out on a very short spike, that, while in itself is impacting production and is worth investigating, isn't reason enough to kill a 10 hour migration.
### critical-load-hibernate-seconds
When `--critical-load-hibernate-seconds` is non-zero (e.g. `--critical-load-hibernate-seconds=300`), `critical-load` does not panic and bail out; instead, `gh-ost` goes into hibernation for the specified duration. It will not read/write anything from/to any server during this time. Execution then continues upon waking from hibernation.
If `critical-load` is met again, `gh-ost` will repeat this cycle, and never panic and bail out.
### critical-load-interval-millis ### critical-load-interval-millis
@ -127,7 +98,7 @@ Noteworthy is that setting `--dml-batch-size` to higher value _does not_ mean `g
### exact-rowcount ### exact-rowcount
A `gh-ost` execution need to copy whatever rows you have in your existing table onto the ghost table. This can and often will be, a large number. Exactly what that number is? A `gh-ost` execution need to copy whatever rows you have in your existing table onto the ghost table. This can, and often be, a large number. Exactly what that number is?
`gh-ost` initially estimates the number of rows in your table by issuing an `explain select * from your_table`. This will use statistics on your table and return with a rough estimate. How rough? It might go as low as half or as high as double the actual number of rows in your table. This is the same method as used in [`pt-online-schema-change`](https://www.percona.com/doc/percona-toolkit/2.2/pt-online-schema-change.html). `gh-ost` initially estimates the number of rows in your table by issuing an `explain select * from your_table`. This will use statistics on your table and return with a rough estimate. How rough? It might go as low as half or as high as double the actual number of rows in your table. This is the same method as used in [`pt-online-schema-change`](https://www.percona.com/doc/percona-toolkit/2.2/pt-online-schema-change.html).
`gh-ost` also supports the `--exact-rowcount` flag. When this flag is given, two things happen: `gh-ost` also supports the `--exact-rowcount` flag. When this flag is given, two things happen:
@ -164,10 +135,6 @@ Add this flag when executing on a 1st generation Google Cloud Platform (GCP).
Default 100. See [`subsecond-lag`](subsecond-lag.md) for details. Default 100. See [`subsecond-lag`](subsecond-lag.md) for details.
### hooks-status-interval
Defaults to 60 seconds. Configures how often the `gh-ost-on-status` hook is called, see [`hooks`](hooks.md) for full details on how to use hooks.
### initially-drop-ghost-table ### initially-drop-ghost-table
`gh-ost` maintains two tables while migrating: the _ghost_ table (which is synced from your original table and finally replaces it) and a changelog table, which is used internally for bookkeeping. By default, it panics and aborts if it sees those tables upon startup. Provide `--initially-drop-ghost-table` and `--initially-drop-old-table` to let `gh-ost` know it's OK to drop them beforehand. `gh-ost` maintains two tables while migrating: the _ghost_ table (which is synced from your original table and finally replaces it) and a changelog table, which is used internally for bookkeeping. By default, it panics and aborts if it sees those tables upon startup. Provide `--initially-drop-ghost-table` and `--initially-drop-old-table` to let `gh-ost` know it's OK to drop them beforehand.
@ -249,18 +216,6 @@ Allows `gh-ost` to connect to the MySQL servers using encrypted connections, but
`--ssl-key=/path/to/ssl-key.key`: SSL private key file (in PEM format). `--ssl-key=/path/to/ssl-key.key`: SSL private key file (in PEM format).
### storage-engine
Default is `innodb`, and `rocksdb` support is currently experimental. InnoDB and RocksDB are both transactional engines, supporting both shared and exclusive row locks.
But RocksDB currently lacks a few features support compared to InnoDB:
- Gap Locks
- Foreign Key
- Generated Columns
- Spatial
- Geometry
When `--storage-engine=rocksdb`, `gh-ost` will make some changes necessary (e.g. sets isolation level to `READ_COMMITTED`) to support RocksDB.
### test-on-replica ### test-on-replica
Issue the migration on a replica; do not modify data on master. Useful for validating, testing and benchmarking. See [`testing-on-replica`](testing-on-replica.md) Issue the migration on a replica; do not modify data on master. Useful for validating, testing and benchmarking. See [`testing-on-replica`](testing-on-replica.md)
@ -275,15 +230,7 @@ Provide a command delimited list of replicas; `gh-ost` will throttle when any of
### throttle-http ### throttle-http
Provide an HTTP endpoint; `gh-ost` will issue `HEAD` requests on given URL and throttle whenever response status code is not `200`. The URL can be queried and updated dynamically via [interactive commands](interactive-commands.md). Empty URL disables the HTTP check. Provide a HTTP endpoint; `gh-ost` will issue `HEAD` requests on given URL and throttle whenever response status code is not `200`. The URL can be queried and updated dynamically via [interactive commands](interactive-commands.md). Empty URL disables the HTTP check.
### throttle-http-interval-millis
Defaults to 100. Configures the HTTP throttle check interval in milliseconds.
### throttle-http-timeout-millis
Defaults to 1000 (1 second). Configures the HTTP throttler check timeout in milliseconds.
### timestamp-old-table ### timestamp-old-table

View File

@ -68,7 +68,6 @@ The following variables are available on all hooks:
- `GH_OST_INSPECTED_LAG` - lag in seconds (floating point) of inspected server - `GH_OST_INSPECTED_LAG` - lag in seconds (floating point) of inspected server
- `GH_OST_HEARTBEAT_LAG` - lag in seconds (floating point) of heartbeat - `GH_OST_HEARTBEAT_LAG` - lag in seconds (floating point) of heartbeat
- `GH_OST_PROGRESS` - progress pct ([0..100], floating point) of migration - `GH_OST_PROGRESS` - progress pct ([0..100], floating point) of migration
- `GH_OST_ETA_SECONDS` - estimated duration until migration finishes in seconds
- `GH_OST_MIGRATED_HOST` - `GH_OST_MIGRATED_HOST`
- `GH_OST_INSPECTED_HOST` - `GH_OST_INSPECTED_HOST`
- `GH_OST_EXECUTING_HOST` - `GH_OST_EXECUTING_HOST`

View File

@ -2,8 +2,6 @@
### Requirements ### Requirements
- `gh-ost` currently requires MySQL versions 5.7 and greater.
- You will need to have one server serving Row Based Replication (RBR) format binary logs. Right now `FULL` row image is supported. `MINIMAL` to be supported in the near future. `gh-ost` prefers to work with replicas. You may [still have your master configured with Statement Based Replication](migrating-with-sbr.md) (SBR). - You will need to have one server serving Row Based Replication (RBR) format binary logs. Right now `FULL` row image is supported. `MINIMAL` to be supported in the near future. `gh-ost` prefers to work with replicas. You may [still have your master configured with Statement Based Replication](migrating-with-sbr.md) (SBR).
- If you are using a replica, the table must have an identical schema between the master and replica. - If you are using a replica, the table must have an identical schema between the master and replica.
@ -20,8 +18,6 @@ The `SUPER` privilege is required for `STOP SLAVE`, `START SLAVE` operations. Th
- Switching your `binlog_format` to `ROW`, in the case where it is _not_ `ROW` and you explicitly specified `--switch-to-rbr` - Switching your `binlog_format` to `ROW`, in the case where it is _not_ `ROW` and you explicitly specified `--switch-to-rbr`
- If your replication is already in RBR (`binlog_format=ROW`) you can specify `--assume-rbr` to avoid the `STOP SLAVE/START SLAVE` operations, hence no need for `SUPER`. - If your replication is already in RBR (`binlog_format=ROW`) you can specify `--assume-rbr` to avoid the `STOP SLAVE/START SLAVE` operations, hence no need for `SUPER`.
- `gh-ost` uses the `REPEATABLE_READ` transaction isolation level for all MySQL connections, regardless of the server default.
- Running `--test-on-replica`: before the cut-over phase, `gh-ost` stops replication so that you can compare the two tables and satisfy that the migration is sound. - Running `--test-on-replica`: before the cut-over phase, `gh-ost` stops replication so that you can compare the two tables and satisfy that the migration is sound.
### Limitations ### Limitations

View File

@ -29,7 +29,7 @@ CREATE TABLE tbl (
(This is also the definition of the _ghost_ table, except that that table would be called `_tbl_gho`). (This is also the definition of the _ghost_ table, except that that table would be called `_tbl_gho`).
In this migration, the _before_ and _after_ versions contain the same unique not-null key (the PRIMARY KEY). To run this migration, `gh-ost` would iterate through the `tbl` table using the primary key, copy rows from `tbl` to the _ghost_ table `_tbl_gho` in primary key order, while also applying the binlog event writes from `tbl` onto `_tbl_gho`. In this migration, the _before_ and _after_ versions contain the same unique not-null key (the PRIMARY KEY). To run this migration, `gh-ost` would iterate through the `tbl` table using the primary key, copy rows from `tbl` to the _ghost_ table `_tbl_gho` in primary key order, while also applying the binlog event writes from `tble` onto `_tbl_gho`.
The applying of the binlog events is what requires the shared unique key. For example, an `UPDATE` statement to `tbl` translates to a `REPLACE` statement which `gh-ost` applies to `_tbl_gho`. A `REPLACE` statement expects to insert or replace an existing row based on its row's values and the table's unique key constraints. In particular, if inserting that row would result in a unique key violation (e.g., a row with that primary key already exists), it would _replace_ that existing row with the new values. The applying of the binlog events is what requires the shared unique key. For example, an `UPDATE` statement to `tbl` translates to a `REPLACE` statement which `gh-ost` applies to `_tbl_gho`. A `REPLACE` statement expects to insert or replace an existing row based on its row's values and the table's unique key constraints. In particular, if inserting that row would result in a unique key violation (e.g., a row with that primary key already exists), it would _replace_ that existing row with the new values.

View File

@ -38,7 +38,7 @@ Note that you may dynamically change both `--max-lag-millis` and the `throttle-c
`--max-load='Threads_running=100,Threads_connected=500'` `--max-load='Threads_running=100,Threads_connected=500'`
Metrics must be valid, numeric [status variables](https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html) Metrics must be valid, numeric [status variables](http://dev.mysql.com/doc/refman/5.6/en/server-status-variables.html)
#### Throttle query #### Throttle query
@ -97,7 +97,7 @@ Copy: 0/2915 0.0%; Applied: 0; Backlog: 0/100; Elapsed: 42s(copy), 42s(total); s
Throttling time is limited by the availability of the binary logs. When throttling begins, `gh-ost` suspends reading the binary logs, and expects to resume reading from same binary log where it paused. Throttling time is limited by the availability of the binary logs. When throttling begins, `gh-ost` suspends reading the binary logs, and expects to resume reading from same binary log where it paused.
Your availability of binary logs is typically determined by the [expire_logs_days](https://dev.mysql.com/doc/refman/5.7/en/replication-options-binary-log.html#sysvar_expire_logs_days) variable. If you have `expire_logs_days = 10` (or check `select @@global.expire_logs_days`), then you should be able to throttle for up to `10` days. Your availability of binary logs is typically determined by the [expire_logs_days](https://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_expire_logs_days) variable. If you have `expire_logs_days = 10` (or check `select @@global.expire_logs_days`), then you should be able to throttle for up to `10` days.
Having said that, throttling for so long is far fetching, in that the `gh-ost` process itself must be kept alive during that time; and the amount of binary logs to process once it resumes will potentially take days to replay. Having said that, throttling for so long is far fetching, in that the `gh-ost` process itself must be kept alive during that time; and the amount of binary logs to process once it resumes will potentially take days to replay.

View File

@ -112,7 +112,7 @@ It is also interesting to observe that `gh-ost` is the only application writing
When `gh-ost` pauses (throttles), it issues no writes on the ghost table. Because there are no triggers, write workload is decoupled from the `gh-ost` write workload. And because we're using an asynchronous approach, the algorithm already handles a time difference between a master write time and the ghost apply time. A difference of a few microseconds is no different from a difference of minutes or hours. When `gh-ost` pauses (throttles), it issues no writes on the ghost table. Because there are no triggers, write workload is decoupled from the `gh-ost` write workload. And because we're using an asynchronous approach, the algorithm already handles a time difference between a master write time and the ghost apply time. A difference of a few microseconds is no different from a difference of minutes or hours.
When `gh-ost` [throttles](throttle.md), either by replication lag, `max-load` setting or an explicit [interactive user command](interactive-commands.md), the master is back to normal. It sees no more writes on the ghost table. When `gh-ost` [throttles](throttle.md), either by replication lag, `max-load` setting or and explicit [interactive user command](interactive-commands.md), the master is back to normal. It sees no more writes on the ghost table.
An exception is the ongoing heartbeat writes onto the changelog table, which we consider to be negligible. An exception is the ongoing heartbeat writes onto the changelog table, which we consider to be negligible.
#### Testability #### Testability

View File

@ -7,7 +7,7 @@ Existing MySQL schema migration tools:
- [LHM](https://github.com/soundcloud/lhm) - [LHM](https://github.com/soundcloud/lhm)
- [oak-online-alter-table](https://github.com/shlomi-noach/openarkkit) - [oak-online-alter-table](https://github.com/shlomi-noach/openarkkit)
are all using [triggers](https://dev.mysql.com/doc/refman/5.7/en/triggers.html) to propagate live changes on your table onto a ghost/shadow table that is slowly being synchronized. The tools not all work the same: while most use a synchronous approach (all changes applied on the ghost table), the Facebook tool uses an asynchronous approach (changes are appended to a changelog table, later reviewed and applied on ghost table). are all using [triggers](http://dev.mysql.com/doc/refman/5.6/en/triggers.html) to propagate live changes on your table onto a ghost/shadow table that is slowly being synchronized. The tools not all work the same: while most use a synchronous approach (all changes applied on the ghost table), the Facebook tool uses an asynchronous approach (changes are appended to a changelog table, later reviewed and applied on ghost table).
Use of triggers simplifies a lot of the flow in doing a live table migration, but also poses some limitations or difficulties. Here are reasons why we choose to [design a triggerless solution](triggerless-design.md) to schema migrations. Use of triggers simplifies a lot of the flow in doing a live table migration, but also poses some limitations or difficulties. Here are reasons why we choose to [design a triggerless solution](triggerless-design.md) to schema migrations.

14
go.mod
View File

@ -1,6 +1,6 @@
module github.com/github/gh-ost module github.com/github/gh-ost
go 1.17 go 1.16
require ( require (
github.com/go-ini/ini v1.62.0 github.com/go-ini/ini v1.62.0
@ -8,20 +8,10 @@ require (
github.com/go-sql-driver/mysql v1.6.0 github.com/go-sql-driver/mysql v1.6.0
github.com/openark/golib v0.0.0-20210531070646-355f37940af8 github.com/openark/golib v0.0.0-20210531070646-355f37940af8
github.com/satori/go.uuid v1.2.0 github.com/satori/go.uuid v1.2.0
github.com/smartystreets/goconvey v1.6.4 // indirect
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
golang.org/x/net v0.0.0-20210224082022-3d97a244fca7 golang.org/x/net v0.0.0-20210224082022-3d97a244fca7
golang.org/x/text v0.3.6 golang.org/x/text v0.3.6
)
require (
github.com/pingcap/errors v0.11.5-0.20201126102027-b0a155152ca3 // indirect
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 // indirect
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 // indirect
github.com/siddontang/go-log v0.0.0-20180807004314-8d05993dda07 // indirect
github.com/smartystreets/goconvey v1.6.4 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/ini.v1 v1.62.0 // indirect
) )

4
go.sum
View File

@ -100,13 +100,9 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -15,7 +15,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
uuid "github.com/satori/go.uuid" "github.com/satori/go.uuid"
"github.com/github/gh-ost/go/mysql" "github.com/github/gh-ost/go/mysql"
"github.com/github/gh-ost/go/sql" "github.com/github/gh-ost/go/sql"
@ -82,8 +82,6 @@ type MigrationContext struct {
AlterStatement string AlterStatement string
AlterStatementOptions string // anything following the 'ALTER TABLE [schema.]table' from AlterStatement AlterStatementOptions string // anything following the 'ALTER TABLE [schema.]table' from AlterStatement
countMutex sync.Mutex
countTableRowsCancelFunc func()
CountTableRows bool CountTableRows bool
ConcurrentCountTableRows bool ConcurrentCountTableRows bool
AllowedRunningOnMaster bool AllowedRunningOnMaster bool
@ -92,7 +90,6 @@ type MigrationContext struct {
AssumeRBR bool AssumeRBR bool
SkipForeignKeyChecks bool SkipForeignKeyChecks bool
SkipStrictMode bool SkipStrictMode bool
AllowZeroInDate bool
NullableUniqueKeyAllowed bool NullableUniqueKeyAllowed bool
ApproveRenamedColumns bool ApproveRenamedColumns bool
SkipRenamedColumns bool SkipRenamedColumns bool
@ -101,7 +98,6 @@ type MigrationContext struct {
AliyunRDS bool AliyunRDS bool
GoogleCloudPlatform bool GoogleCloudPlatform bool
AzureMySQL bool AzureMySQL bool
AttemptInstantDDL bool
config ContextConfig config ContextConfig
configMutex *sync.Mutex configMutex *sync.Mutex
@ -144,7 +140,6 @@ type MigrationContext struct {
HooksHintMessage string HooksHintMessage string
HooksHintOwner string HooksHintOwner string
HooksHintToken string HooksHintToken string
HooksStatusIntervalSec int64
DropServeSocket bool DropServeSocket bool
ServeSocketFile string ServeSocketFile string
@ -188,9 +183,7 @@ type MigrationContext struct {
CurrentLag int64 CurrentLag int64
currentProgress uint64 currentProgress uint64
etaNanoseonds int64 etaNanoseonds int64
ThrottleHTTPIntervalMillis int64
ThrottleHTTPStatusCode int64 ThrottleHTTPStatusCode int64
ThrottleHTTPTimeoutMillis int64
controlReplicasLagResult mysql.ReplicationLagResult controlReplicasLagResult mysql.ReplicationLagResult
TotalRowsCopied int64 TotalRowsCopied int64
TotalDMLEventsApplied int64 TotalDMLEventsApplied int64
@ -232,8 +225,6 @@ type MigrationContext struct {
recentBinlogCoordinates mysql.BinlogCoordinates recentBinlogCoordinates mysql.BinlogCoordinates
BinlogSyncerMaxReconnectAttempts int
Log Logger Log Logger
} }
@ -292,19 +283,6 @@ func NewMigrationContext() *MigrationContext {
} }
} }
func (this *MigrationContext) SetConnectionConfig(storageEngine string) error {
var transactionIsolation string
switch storageEngine {
case "rocksdb":
transactionIsolation = "READ-COMMITTED"
default:
transactionIsolation = "REPEATABLE-READ"
}
this.InspectorConnectionConfig.TransactionIsolation = transactionIsolation
this.ApplierConnectionConfig.TransactionIsolation = transactionIsolation
return nil
}
func getSafeTableName(baseName string, suffix string) string { func getSafeTableName(baseName string, suffix string) string {
name := fmt.Sprintf("_%s_%s", baseName, suffix) name := fmt.Sprintf("_%s_%s", baseName, suffix)
if len(name) <= mysql.MaxTableNameLength { if len(name) <= mysql.MaxTableNameLength {
@ -443,44 +421,10 @@ func (this *MigrationContext) IsTransactionalTable() bool {
{ {
return true return true
} }
case "rocksdb":
{
return true
}
} }
return false return false
} }
// SetCountTableRowsCancelFunc sets the cancel function for the CountTableRows query context
func (this *MigrationContext) SetCountTableRowsCancelFunc(f func()) {
this.countMutex.Lock()
defer this.countMutex.Unlock()
this.countTableRowsCancelFunc = f
}
// IsCountingTableRows returns true if the migration has a table count query running
func (this *MigrationContext) IsCountingTableRows() bool {
this.countMutex.Lock()
defer this.countMutex.Unlock()
return this.countTableRowsCancelFunc != nil
}
// CancelTableRowsCount cancels the CountTableRows query context. It is safe to
// call function even when IsCountingTableRows is false.
func (this *MigrationContext) CancelTableRowsCount() {
this.countMutex.Lock()
defer this.countMutex.Unlock()
if this.countTableRowsCancelFunc == nil {
return
}
this.countTableRowsCancelFunc()
this.countTableRowsCancelFunc = nil
}
// ElapsedTime returns time since very beginning of the process // ElapsedTime returns time since very beginning of the process
func (this *MigrationContext) ElapsedTime() time.Duration { func (this *MigrationContext) ElapsedTime() time.Duration {
return time.Since(this.StartTime) return time.Since(this.StartTime)
@ -607,8 +551,8 @@ func (this *MigrationContext) SetMaxLagMillisecondsThrottleThreshold(maxLagMilli
} }
func (this *MigrationContext) SetChunkSize(chunkSize int64) { func (this *MigrationContext) SetChunkSize(chunkSize int64) {
if chunkSize < 10 { if chunkSize < 100 {
chunkSize = 10 chunkSize = 100
} }
if chunkSize > 100000 { if chunkSize > 100000 {
chunkSize = 100000 chunkSize = 100000
@ -867,33 +811,33 @@ func (this *MigrationContext) ReadConfigFile() error {
return err return err
} }
if cfg.Section("client").HasKey("user") { if cfg.Section("client").Haskey("user") {
this.config.Client.User = cfg.Section("client").Key("user").String() this.config.Client.User = cfg.Section("client").Key("user").String()
} }
if cfg.Section("client").HasKey("password") { if cfg.Section("client").Haskey("password") {
this.config.Client.Password = cfg.Section("client").Key("password").String() this.config.Client.Password = cfg.Section("client").Key("password").String()
} }
if cfg.Section("osc").HasKey("chunk_size") { if cfg.Section("osc").Haskey("chunk_size") {
this.config.Osc.Chunk_Size, err = cfg.Section("osc").Key("chunk_size").Int64() this.config.Osc.Chunk_Size, err = cfg.Section("osc").Key("chunk_size").Int64()
if err != nil { if err != nil {
return fmt.Errorf("Unable to read osc chunk size: %w", err) return fmt.Errorf("Unable to read osc chunk size: %s", err.Error())
} }
} }
if cfg.Section("osc").HasKey("max_load") { if cfg.Section("osc").Haskey("max_load") {
this.config.Osc.Max_Load = cfg.Section("osc").Key("max_load").String() this.config.Osc.Max_Load = cfg.Section("osc").Key("max_load").String()
} }
if cfg.Section("osc").HasKey("replication_lag_query") { if cfg.Section("osc").Haskey("replication_lag_query") {
this.config.Osc.Replication_Lag_Query = cfg.Section("osc").Key("replication_lag_query").String() this.config.Osc.Replication_Lag_Query = cfg.Section("osc").Key("replication_lag_query").String()
} }
if cfg.Section("osc").HasKey("max_lag_millis") { if cfg.Section("osc").Haskey("max_lag_millis") {
this.config.Osc.Max_Lag_Millis, err = cfg.Section("osc").Key("max_lag_millis").Int64() this.config.Osc.Max_Lag_Millis, err = cfg.Section("osc").Key("max_lag_millis").Int64()
if err != nil { if err != nil {
return fmt.Errorf("Unable to read max lag millis: %w", err) return fmt.Errorf("Unable to read max lag millis: %s", err.Error())
} }
} }

View File

@ -1,11 +1,12 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2021 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
package base package base
import ( import (
"fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"testing" "testing"
@ -88,7 +89,7 @@ func TestReadConfigFile(t *testing.T) {
} }
defer os.Remove(f.Name()) defer os.Remove(f.Name())
f.Write([]byte("[client]\nuser=test\npassword=123456")) f.Write([]byte(fmt.Sprintf("[client]\nuser=test\npassword=123456")))
context := NewMigrationContext() context := NewMigrationContext()
context.ConfigFile = f.Name() context.ConfigFile = f.Name()
if err := context.ReadConfigFile(); err != nil { if err := context.ReadConfigFile(); err != nil {
@ -108,7 +109,7 @@ func TestReadConfigFile(t *testing.T) {
} }
defer os.Remove(f.Name()) defer os.Remove(f.Name())
f.Write([]byte("[osc]\nmax_load=10")) f.Write([]byte(fmt.Sprintf("[osc]\nmax_load=10")))
context := NewMigrationContext() context := NewMigrationContext()
context.ConfigFile = f.Name() context.ConfigFile = f.Name()
if err := context.ReadConfigFile(); err != nil { if err := context.ReadConfigFile(); err != nil {

View File

@ -1,8 +1,3 @@
/*
Copyright 2022 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE
*/
package base package base
import ( import (
@ -17,18 +12,22 @@ func NewDefaultLogger() *simpleLogger {
func (*simpleLogger) Debug(args ...interface{}) { func (*simpleLogger) Debug(args ...interface{}) {
log.Debug(args[0].(string), args[1:]) log.Debug(args[0].(string), args[1:])
return
} }
func (*simpleLogger) Debugf(format string, args ...interface{}) { func (*simpleLogger) Debugf(format string, args ...interface{}) {
log.Debugf(format, args...) log.Debugf(format, args...)
return
} }
func (*simpleLogger) Info(args ...interface{}) { func (*simpleLogger) Info(args ...interface{}) {
log.Info(args[0].(string), args[1:]) log.Info(args[0].(string), args[1:])
return
} }
func (*simpleLogger) Infof(format string, args ...interface{}) { func (*simpleLogger) Infof(format string, args ...interface{}) {
log.Infof(format, args...) log.Infof(format, args...)
return
} }
func (*simpleLogger) Warning(args ...interface{}) error { func (*simpleLogger) Warning(args ...interface{}) error {
@ -65,8 +64,10 @@ func (*simpleLogger) Fatale(err error) error {
func (*simpleLogger) SetLevel(level log.LogLevel) { func (*simpleLogger) SetLevel(level log.LogLevel) {
log.SetLevel(level) log.SetLevel(level)
return
} }
func (*simpleLogger) SetPrintStackTrace(printStackTraceFlag bool) { func (*simpleLogger) SetPrintStackTrace(printStackTraceFlag bool) {
log.SetPrintStackTrace(printStackTraceFlag) log.SetPrintStackTrace(printStackTraceFlag)
return
} }

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -25,7 +25,9 @@ func PrettifyDurationOutput(d time.Duration) string {
if d < time.Second { if d < time.Second {
return "0s" return "0s"
} }
return prettifyDurationRegexp.ReplaceAllString(d.String(), "") result := fmt.Sprintf("%s", d)
result = prettifyDurationRegexp.ReplaceAllString(result, "")
return result
} }
func FileExists(fileName string) bool { func FileExists(fileName string) bool {
@ -69,7 +71,7 @@ func ValidateConnection(db *gosql.DB, connectionConfig *mysql.ConnectionConfig,
return "", err return "", err
} }
extraPortQuery := `select @@global.extra_port` extraPortQuery := `select @@global.extra_port`
if err := db.QueryRow(extraPortQuery).Scan(&extraPort); err != nil { // nolint:staticcheck if err := db.QueryRow(extraPortQuery).Scan(&extraPort); err != nil {
// swallow this error. not all servers support extra_port // swallow this error. not all servers support extra_port
} }
// AliyunRDS set users port to "NULL", replace it by gh-ost param // AliyunRDS set users port to "NULL", replace it by gh-ost param

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2021 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -28,25 +28,31 @@ type GoMySQLReader struct {
LastAppliedRowsEventHint mysql.BinlogCoordinates LastAppliedRowsEventHint mysql.BinlogCoordinates
} }
func NewGoMySQLReader(migrationContext *base.MigrationContext) *GoMySQLReader { func NewGoMySQLReader(migrationContext *base.MigrationContext) (binlogReader *GoMySQLReader, err error) {
connectionConfig := migrationContext.InspectorConnectionConfig binlogReader = &GoMySQLReader{
return &GoMySQLReader{
migrationContext: migrationContext, migrationContext: migrationContext,
connectionConfig: connectionConfig, connectionConfig: migrationContext.InspectorConnectionConfig,
currentCoordinates: mysql.BinlogCoordinates{}, currentCoordinates: mysql.BinlogCoordinates{},
currentCoordinatesMutex: &sync.Mutex{}, currentCoordinatesMutex: &sync.Mutex{},
binlogSyncer: replication.NewBinlogSyncer(replication.BinlogSyncerConfig{ binlogSyncer: nil,
ServerID: uint32(migrationContext.ReplicaServerId), binlogStreamer: nil,
Flavor: gomysql.MySQLFlavor,
Host: connectionConfig.Key.Hostname,
Port: uint16(connectionConfig.Key.Port),
User: connectionConfig.User,
Password: connectionConfig.Password,
TLSConfig: connectionConfig.TLSConfig(),
UseDecimal: true,
MaxReconnectAttempts: migrationContext.BinlogSyncerMaxReconnectAttempts,
}),
} }
serverId := uint32(migrationContext.ReplicaServerId)
binlogSyncerConfig := replication.BinlogSyncerConfig{
ServerID: serverId,
Flavor: "mysql",
Host: binlogReader.connectionConfig.Key.Hostname,
Port: uint16(binlogReader.connectionConfig.Key.Port),
User: binlogReader.connectionConfig.User,
Password: binlogReader.connectionConfig.Password,
TLSConfig: binlogReader.connectionConfig.TLSConfig(),
UseDecimal: true,
}
binlogReader.binlogSyncer = replication.NewBinlogSyncer(binlogSyncerConfig)
return binlogReader, err
} }
// ConnectBinlogStreamer // ConnectBinlogStreamer
@ -58,10 +64,7 @@ func (this *GoMySQLReader) ConnectBinlogStreamer(coordinates mysql.BinlogCoordin
this.currentCoordinates = coordinates this.currentCoordinates = coordinates
this.migrationContext.Log.Infof("Connecting binlog streamer at %+v", this.currentCoordinates) this.migrationContext.Log.Infof("Connecting binlog streamer at %+v", this.currentCoordinates)
// Start sync with specified binlog file and position // Start sync with specified binlog file and position
this.binlogStreamer, err = this.binlogSyncer.StartSync(gomysql.Position{ this.binlogStreamer, err = this.binlogSyncer.StartSync(gomysql.Position{this.currentCoordinates.LogFile, uint32(this.currentCoordinates.LogPos)})
Name: this.currentCoordinates.LogFile,
Pos: uint32(this.currentCoordinates.LogPos),
})
return err return err
} }
@ -139,17 +142,15 @@ func (this *GoMySQLReader) StreamEvents(canStopStreaming func() bool, entriesCha
defer this.currentCoordinatesMutex.Unlock() defer this.currentCoordinatesMutex.Unlock()
this.currentCoordinates.LogPos = int64(ev.Header.LogPos) this.currentCoordinates.LogPos = int64(ev.Header.LogPos)
}() }()
if rotateEvent, ok := ev.Event.(*replication.RotateEvent); ok {
switch binlogEvent := ev.Event.(type) {
case *replication.RotateEvent:
func() { func() {
this.currentCoordinatesMutex.Lock() this.currentCoordinatesMutex.Lock()
defer this.currentCoordinatesMutex.Unlock() defer this.currentCoordinatesMutex.Unlock()
this.currentCoordinates.LogFile = string(binlogEvent.NextLogName) this.currentCoordinates.LogFile = string(rotateEvent.NextLogName)
}() }()
this.migrationContext.Log.Infof("rotate to next log from %s:%d to %s", this.currentCoordinates.LogFile, int64(ev.Header.LogPos), binlogEvent.NextLogName) this.migrationContext.Log.Infof("rotate to next log from %s:%d to %s", this.currentCoordinates.LogFile, int64(ev.Header.LogPos), rotateEvent.NextLogName)
case *replication.RowsEvent: } else if rowsEvent, ok := ev.Event.(*replication.RowsEvent); ok {
if err := this.handleRowsEvent(ev, binlogEvent, entriesChannel); err != nil { if err := this.handleRowsEvent(ev, rowsEvent, entriesChannel); err != nil {
return err return err
} }
} }

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -8,7 +8,6 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"net/url"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@ -19,7 +18,7 @@ import (
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
"github.com/openark/golib/log" "github.com/openark/golib/log"
"golang.org/x/term" "golang.org/x/crypto/ssh/terminal"
) )
var AppVersion string var AppVersion string
@ -67,9 +66,6 @@ func main() {
flag.StringVar(&migrationContext.DatabaseName, "database", "", "database name (mandatory)") flag.StringVar(&migrationContext.DatabaseName, "database", "", "database name (mandatory)")
flag.StringVar(&migrationContext.OriginalTableName, "table", "", "table name (mandatory)") flag.StringVar(&migrationContext.OriginalTableName, "table", "", "table name (mandatory)")
flag.StringVar(&migrationContext.AlterStatement, "alter", "", "alter statement (mandatory)") flag.StringVar(&migrationContext.AlterStatement, "alter", "", "alter statement (mandatory)")
flag.BoolVar(&migrationContext.AttemptInstantDDL, "attempt-instant-ddl", false, "Attempt to use instant DDL for this migration first")
storageEngine := flag.String("storage-engine", "innodb", "Specify table storage engine (default: 'innodb'). When 'rocksdb': the session transaction isolation level is changed from REPEATABLE_READ to READ_COMMITTED.")
flag.BoolVar(&migrationContext.CountTableRows, "exact-rowcount", false, "actually count table rows as opposed to estimate them (results in more accurate progress estimation)") flag.BoolVar(&migrationContext.CountTableRows, "exact-rowcount", false, "actually count table rows as opposed to estimate them (results in more accurate progress estimation)")
flag.BoolVar(&migrationContext.ConcurrentCountTableRows, "concurrent-rowcount", true, "(with --exact-rowcount), when true (default): count rows after row-copy begins, concurrently, and adjust row estimate later on; when false: first count rows, then start row copy") flag.BoolVar(&migrationContext.ConcurrentCountTableRows, "concurrent-rowcount", true, "(with --exact-rowcount), when true (default): count rows after row-copy begins, concurrently, and adjust row estimate later on; when false: first count rows, then start row copy")
flag.BoolVar(&migrationContext.AllowedRunningOnMaster, "allow-on-master", false, "allow this migration to run directly on master. Preferably it would run on a replica") flag.BoolVar(&migrationContext.AllowedRunningOnMaster, "allow-on-master", false, "allow this migration to run directly on master. Preferably it would run on a replica")
@ -81,7 +77,6 @@ func main() {
flag.BoolVar(&migrationContext.DiscardForeignKeys, "discard-foreign-keys", false, "DANGER! This flag will migrate a table that has foreign keys and will NOT create foreign keys on the ghost table, thus your altered table will have NO foreign keys. This is useful for intentional dropping of foreign keys") flag.BoolVar(&migrationContext.DiscardForeignKeys, "discard-foreign-keys", false, "DANGER! This flag will migrate a table that has foreign keys and will NOT create foreign keys on the ghost table, thus your altered table will have NO foreign keys. This is useful for intentional dropping of foreign keys")
flag.BoolVar(&migrationContext.SkipForeignKeyChecks, "skip-foreign-key-checks", false, "set to 'true' when you know for certain there are no foreign keys on your table, and wish to skip the time it takes for gh-ost to verify that") flag.BoolVar(&migrationContext.SkipForeignKeyChecks, "skip-foreign-key-checks", false, "set to 'true' when you know for certain there are no foreign keys on your table, and wish to skip the time it takes for gh-ost to verify that")
flag.BoolVar(&migrationContext.SkipStrictMode, "skip-strict-mode", false, "explicitly tell gh-ost binlog applier not to enforce strict sql mode") flag.BoolVar(&migrationContext.SkipStrictMode, "skip-strict-mode", false, "explicitly tell gh-ost binlog applier not to enforce strict sql mode")
flag.BoolVar(&migrationContext.AllowZeroInDate, "allow-zero-in-date", false, "explicitly tell gh-ost binlog applier to ignore NO_ZERO_IN_DATE,NO_ZERO_DATE in sql_mode")
flag.BoolVar(&migrationContext.AliyunRDS, "aliyun-rds", false, "set to 'true' when you execute on Aliyun RDS.") flag.BoolVar(&migrationContext.AliyunRDS, "aliyun-rds", false, "set to 'true' when you execute on Aliyun RDS.")
flag.BoolVar(&migrationContext.GoogleCloudPlatform, "gcp", false, "set to 'true' when you execute on a 1st generation Google Cloud Platform (GCP).") flag.BoolVar(&migrationContext.GoogleCloudPlatform, "gcp", false, "set to 'true' when you execute on a 1st generation Google Cloud Platform (GCP).")
flag.BoolVar(&migrationContext.AzureMySQL, "azure", false, "set to 'true' when you execute on Azure Database on MySQL.") flag.BoolVar(&migrationContext.AzureMySQL, "azure", false, "set to 'true' when you execute on Azure Database on MySQL.")
@ -103,7 +98,7 @@ func main() {
flag.BoolVar(&migrationContext.AssumeRBR, "assume-rbr", false, "set to 'true' when you know for certain your server uses 'ROW' binlog_format. gh-ost is unable to tell, event after reading binlog_format, whether the replication process does indeed use 'ROW', and restarts replication to be certain RBR setting is applied. Such operation requires SUPER privileges which you might not have. Setting this flag avoids restarting replication and you can proceed to use gh-ost without SUPER privileges") flag.BoolVar(&migrationContext.AssumeRBR, "assume-rbr", false, "set to 'true' when you know for certain your server uses 'ROW' binlog_format. gh-ost is unable to tell, event after reading binlog_format, whether the replication process does indeed use 'ROW', and restarts replication to be certain RBR setting is applied. Such operation requires SUPER privileges which you might not have. Setting this flag avoids restarting replication and you can proceed to use gh-ost without SUPER privileges")
flag.BoolVar(&migrationContext.CutOverExponentialBackoff, "cut-over-exponential-backoff", false, "Wait exponentially longer intervals between failed cut-over attempts. Wait intervals obey a maximum configurable with 'exponential-backoff-max-interval').") flag.BoolVar(&migrationContext.CutOverExponentialBackoff, "cut-over-exponential-backoff", false, "Wait exponentially longer intervals between failed cut-over attempts. Wait intervals obey a maximum configurable with 'exponential-backoff-max-interval').")
exponentialBackoffMaxInterval := flag.Int64("exponential-backoff-max-interval", 64, "Maximum number of seconds to wait between attempts when performing various operations with exponential backoff.") exponentialBackoffMaxInterval := flag.Int64("exponential-backoff-max-interval", 64, "Maximum number of seconds to wait between attempts when performing various operations with exponential backoff.")
chunkSize := flag.Int64("chunk-size", 1000, "amount of rows to handle in each iteration (allowed range: 10-100,000)") chunkSize := flag.Int64("chunk-size", 1000, "amount of rows to handle in each iteration (allowed range: 100-100,000)")
dmlBatchSize := flag.Int64("dml-batch-size", 10, "batch size for DML events to apply in a single transaction (range 1-100)") dmlBatchSize := flag.Int64("dml-batch-size", 10, "batch size for DML events to apply in a single transaction (range 1-100)")
defaultRetries := flag.Int64("default-retries", 60, "Default number of retries for various operations before panicking") defaultRetries := flag.Int64("default-retries", 60, "Default number of retries for various operations before panicking")
cutOverLockTimeoutSeconds := flag.Int64("cut-over-lock-timeout-seconds", 3, "Max number of seconds to hold locks on tables while attempting to cut-over (retry attempted when lock exceeds timeout)") cutOverLockTimeoutSeconds := flag.Int64("cut-over-lock-timeout-seconds", 3, "Max number of seconds to hold locks on tables while attempting to cut-over (retry attempted when lock exceeds timeout)")
@ -114,8 +109,6 @@ func main() {
throttleControlReplicas := flag.String("throttle-control-replicas", "", "List of replicas on which to check for lag; comma delimited. Example: myhost1.com:3306,myhost2.com,myhost3.com:3307") throttleControlReplicas := flag.String("throttle-control-replicas", "", "List of replicas on which to check for lag; comma delimited. Example: myhost1.com:3306,myhost2.com,myhost3.com:3307")
throttleQuery := flag.String("throttle-query", "", "when given, issued (every second) to check if operation should throttle. Expecting to return zero for no-throttle, >0 for throttle. Query is issued on the migrated server. Make sure this query is lightweight") throttleQuery := flag.String("throttle-query", "", "when given, issued (every second) to check if operation should throttle. Expecting to return zero for no-throttle, >0 for throttle. Query is issued on the migrated server. Make sure this query is lightweight")
throttleHTTP := flag.String("throttle-http", "", "when given, gh-ost checks given URL via HEAD request; any response code other than 200 (OK) causes throttling; make sure it has low latency response") throttleHTTP := flag.String("throttle-http", "", "when given, gh-ost checks given URL via HEAD request; any response code other than 200 (OK) causes throttling; make sure it has low latency response")
flag.Int64Var(&migrationContext.ThrottleHTTPIntervalMillis, "throttle-http-interval-millis", 100, "Number of milliseconds to wait before triggering another HTTP throttle check")
flag.Int64Var(&migrationContext.ThrottleHTTPTimeoutMillis, "throttle-http-timeout-millis", 1000, "Number of milliseconds to use as an HTTP throttle check timeout")
ignoreHTTPErrors := flag.Bool("ignore-http-errors", false, "ignore HTTP connection errors during throttle check") ignoreHTTPErrors := flag.Bool("ignore-http-errors", false, "ignore HTTP connection errors during throttle check")
heartbeatIntervalMillis := flag.Int64("heartbeat-interval-millis", 100, "how frequently would gh-ost inject a heartbeat value") heartbeatIntervalMillis := flag.Int64("heartbeat-interval-millis", 100, "how frequently would gh-ost inject a heartbeat value")
flag.StringVar(&migrationContext.ThrottleFlagFile, "throttle-flag-file", "", "operation pauses when this file exists; hint: use a file that is specific to the table being altered") flag.StringVar(&migrationContext.ThrottleFlagFile, "throttle-flag-file", "", "operation pauses when this file exists; hint: use a file that is specific to the table being altered")
@ -131,15 +124,13 @@ func main() {
flag.StringVar(&migrationContext.HooksHintMessage, "hooks-hint", "", "arbitrary message to be injected to hooks via GH_OST_HOOKS_HINT, for your convenience") flag.StringVar(&migrationContext.HooksHintMessage, "hooks-hint", "", "arbitrary message to be injected to hooks via GH_OST_HOOKS_HINT, for your convenience")
flag.StringVar(&migrationContext.HooksHintOwner, "hooks-hint-owner", "", "arbitrary name of owner to be injected to hooks via GH_OST_HOOKS_HINT_OWNER, for your convenience") flag.StringVar(&migrationContext.HooksHintOwner, "hooks-hint-owner", "", "arbitrary name of owner to be injected to hooks via GH_OST_HOOKS_HINT_OWNER, for your convenience")
flag.StringVar(&migrationContext.HooksHintToken, "hooks-hint-token", "", "arbitrary token to be injected to hooks via GH_OST_HOOKS_HINT_TOKEN, for your convenience") flag.StringVar(&migrationContext.HooksHintToken, "hooks-hint-token", "", "arbitrary token to be injected to hooks via GH_OST_HOOKS_HINT_TOKEN, for your convenience")
flag.Int64Var(&migrationContext.HooksStatusIntervalSec, "hooks-status-interval", 60, "how many seconds to wait between calling onStatus hook")
flag.UintVar(&migrationContext.ReplicaServerId, "replica-server-id", 99999, "server id used by gh-ost process. Default: 99999") flag.UintVar(&migrationContext.ReplicaServerId, "replica-server-id", 99999, "server id used by gh-ost process. Default: 99999")
flag.IntVar(&migrationContext.BinlogSyncerMaxReconnectAttempts, "binlogsyncer-max-reconnect-attempts", 0, "when master node fails, the maximum number of binlog synchronization attempts to reconnect. 0 is unlimited")
maxLoad := flag.String("max-load", "", "Comma delimited status-name=threshold. e.g: 'Threads_running=100,Threads_connected=500'. When status exceeds threshold, app throttles writes") maxLoad := flag.String("max-load", "", "Comma delimited status-name=threshold. e.g: 'Threads_running=100,Threads_connected=500'. When status exceeds threshold, app throttles writes")
criticalLoad := flag.String("critical-load", "", "Comma delimited status-name=threshold, same format as --max-load. When status exceeds threshold, app panics and quits") criticalLoad := flag.String("critical-load", "", "Comma delimited status-name=threshold, same format as --max-load. When status exceeds threshold, app panics and quits")
flag.Int64Var(&migrationContext.CriticalLoadIntervalMilliseconds, "critical-load-interval-millis", 0, "When 0, migration immediately bails out upon meeting critical-load. When non-zero, a second check is done after given interval, and migration only bails out if 2nd check still meets critical load") flag.Int64Var(&migrationContext.CriticalLoadIntervalMilliseconds, "critical-load-interval-millis", 0, "When 0, migration immediately bails out upon meeting critical-load. When non-zero, a second check is done after given interval, and migration only bails out if 2nd check still meets critical load")
flag.Int64Var(&migrationContext.CriticalLoadHibernateSeconds, "critical-load-hibernate-seconds", 0, "When non-zero, critical-load does not panic and bail out; instead, gh-ost goes into hibernation for the specified duration. It will not read/write anything from/to any server") flag.Int64Var(&migrationContext.CriticalLoadHibernateSeconds, "critical-load-hibernate-seconds", 0, "When nonzero, critical-load does not panic and bail out; instead, gh-ost goes into hibernate for the specified duration. It will not read/write anything to from/to any server")
quiet := flag.Bool("quiet", false, "quiet") quiet := flag.Bool("quiet", false, "quiet")
verbose := flag.Bool("verbose", false, "verbose") verbose := flag.Bool("verbose", false, "verbose")
debug := flag.Bool("debug", false, "debug mode (very verbose)") debug := flag.Bool("debug", false, "debug mode (very verbose)")
@ -184,12 +175,8 @@ func main() {
migrationContext.Log.SetLevel(log.ERROR) migrationContext.Log.SetLevel(log.ERROR)
} }
if err := migrationContext.SetConnectionConfig(*storageEngine); err != nil {
migrationContext.Log.Fatale(err)
}
if migrationContext.AlterStatement == "" { if migrationContext.AlterStatement == "" {
log.Fatal("--alter must be provided and statement must not be empty") log.Fatalf("--alter must be provided and statement must not be empty")
} }
parser := sql.NewParserFromAlterStatement(migrationContext.AlterStatement) parser := sql.NewParserFromAlterStatement(migrationContext.AlterStatement)
migrationContext.AlterStatementOptions = parser.GetAlterStatementOptions() migrationContext.AlterStatementOptions = parser.GetAlterStatementOptions()
@ -198,63 +185,55 @@ func main() {
if parser.HasExplicitSchema() { if parser.HasExplicitSchema() {
migrationContext.DatabaseName = parser.GetExplicitSchema() migrationContext.DatabaseName = parser.GetExplicitSchema()
} else { } else {
log.Fatal("--database must be provided and database name must not be empty, or --alter must specify database name") log.Fatalf("--database must be provided and database name must not be empty, or --alter must specify database name")
} }
} }
if err := flag.Set("database", url.QueryEscape(migrationContext.DatabaseName)); err != nil {
migrationContext.Log.Fatale(err)
}
if migrationContext.OriginalTableName == "" { if migrationContext.OriginalTableName == "" {
if parser.HasExplicitTable() { if parser.HasExplicitTable() {
migrationContext.OriginalTableName = parser.GetExplicitTable() migrationContext.OriginalTableName = parser.GetExplicitTable()
} else { } else {
log.Fatal("--table must be provided and table name must not be empty, or --alter must specify table name") log.Fatalf("--table must be provided and table name must not be empty, or --alter must specify table name")
} }
} }
migrationContext.Noop = !(*executeFlag) migrationContext.Noop = !(*executeFlag)
if migrationContext.AllowedRunningOnMaster && migrationContext.TestOnReplica { if migrationContext.AllowedRunningOnMaster && migrationContext.TestOnReplica {
migrationContext.Log.Fatal("--allow-on-master and --test-on-replica are mutually exclusive") migrationContext.Log.Fatalf("--allow-on-master and --test-on-replica are mutually exclusive")
} }
if migrationContext.AllowedRunningOnMaster && migrationContext.MigrateOnReplica { if migrationContext.AllowedRunningOnMaster && migrationContext.MigrateOnReplica {
migrationContext.Log.Fatal("--allow-on-master and --migrate-on-replica are mutually exclusive") migrationContext.Log.Fatalf("--allow-on-master and --migrate-on-replica are mutually exclusive")
} }
if migrationContext.MigrateOnReplica && migrationContext.TestOnReplica { if migrationContext.MigrateOnReplica && migrationContext.TestOnReplica {
migrationContext.Log.Fatal("--migrate-on-replica and --test-on-replica are mutually exclusive") migrationContext.Log.Fatalf("--migrate-on-replica and --test-on-replica are mutually exclusive")
} }
if migrationContext.SwitchToRowBinlogFormat && migrationContext.AssumeRBR { if migrationContext.SwitchToRowBinlogFormat && migrationContext.AssumeRBR {
migrationContext.Log.Fatal("--switch-to-rbr and --assume-rbr are mutually exclusive") migrationContext.Log.Fatalf("--switch-to-rbr and --assume-rbr are mutually exclusive")
} }
if migrationContext.TestOnReplicaSkipReplicaStop { if migrationContext.TestOnReplicaSkipReplicaStop {
if !migrationContext.TestOnReplica { if !migrationContext.TestOnReplica {
migrationContext.Log.Fatal("--test-on-replica-skip-replica-stop requires --test-on-replica to be enabled") migrationContext.Log.Fatalf("--test-on-replica-skip-replica-stop requires --test-on-replica to be enabled")
} }
migrationContext.Log.Warning("--test-on-replica-skip-replica-stop enabled. We will not stop replication before cut-over. Ensure you have a plugin that does this.") migrationContext.Log.Warning("--test-on-replica-skip-replica-stop enabled. We will not stop replication before cut-over. Ensure you have a plugin that does this.")
} }
if migrationContext.CliMasterUser != "" && migrationContext.AssumeMasterHostname == "" { if migrationContext.CliMasterUser != "" && migrationContext.AssumeMasterHostname == "" {
migrationContext.Log.Fatal("--master-user requires --assume-master-host") migrationContext.Log.Fatalf("--master-user requires --assume-master-host")
} }
if migrationContext.CliMasterPassword != "" && migrationContext.AssumeMasterHostname == "" { if migrationContext.CliMasterPassword != "" && migrationContext.AssumeMasterHostname == "" {
migrationContext.Log.Fatal("--master-password requires --assume-master-host") migrationContext.Log.Fatalf("--master-password requires --assume-master-host")
} }
if migrationContext.TLSCACertificate != "" && !migrationContext.UseTLS { if migrationContext.TLSCACertificate != "" && !migrationContext.UseTLS {
migrationContext.Log.Fatal("--ssl-ca requires --ssl") migrationContext.Log.Fatalf("--ssl-ca requires --ssl")
} }
if migrationContext.TLSCertificate != "" && !migrationContext.UseTLS { if migrationContext.TLSCertificate != "" && !migrationContext.UseTLS {
migrationContext.Log.Fatal("--ssl-cert requires --ssl") migrationContext.Log.Fatalf("--ssl-cert requires --ssl")
} }
if migrationContext.TLSKey != "" && !migrationContext.UseTLS { if migrationContext.TLSKey != "" && !migrationContext.UseTLS {
migrationContext.Log.Fatal("--ssl-key requires --ssl") migrationContext.Log.Fatalf("--ssl-key requires --ssl")
} }
if migrationContext.TLSAllowInsecure && !migrationContext.UseTLS { if migrationContext.TLSAllowInsecure && !migrationContext.UseTLS {
migrationContext.Log.Fatal("--ssl-allow-insecure requires --ssl") migrationContext.Log.Fatalf("--ssl-allow-insecure requires --ssl")
} }
if *replicationLagQuery != "" { if *replicationLagQuery != "" {
migrationContext.Log.Warning("--replication-lag-query is deprecated") migrationContext.Log.Warningf("--replication-lag-query is deprecated")
}
if *storageEngine == "rocksdb" {
migrationContext.Log.Warning("RocksDB storage engine support is experimental")
} }
switch *cutOver { switch *cutOver {
@ -282,7 +261,7 @@ func main() {
} }
if *askPass { if *askPass {
fmt.Println("Password:") fmt.Println("Password:")
bytePassword, err := term.ReadPassword(syscall.Stdin) bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
migrationContext.Log.Fatale(err) migrationContext.Log.Fatale(err)
} }
@ -311,10 +290,11 @@ func main() {
log.Infof("starting gh-ost %+v", AppVersion) log.Infof("starting gh-ost %+v", AppVersion)
acceptSignals(migrationContext) acceptSignals(migrationContext)
migrator := logic.NewMigrator(migrationContext, AppVersion) migrator := logic.NewMigrator(migrationContext)
if err := migrator.Migrate(); err != nil { err := migrator.Migrate()
if err != nil {
migrator.ExecOnFailureHook() migrator.ExecOnFailureHook()
migrationContext.Log.Fatale(err) migrationContext.Log.Fatale(err)
} }
fmt.Fprintln(os.Stdout, "# Done") fmt.Fprintf(os.Stdout, "# Done\n")
} }

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2021 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -8,7 +8,7 @@ package logic
import ( import (
gosql "database/sql" gosql "database/sql"
"fmt" "fmt"
"strings" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -22,8 +22,7 @@ import (
) )
const ( const (
GhostChangelogTableComment = "gh-ost changelog" atomicCutOverMagicHint = "ghost-cut-over-sentry"
atomicCutOverMagicHint = "ghost-cut-over-sentry"
) )
type dmlBuildResult struct { type dmlBuildResult struct {
@ -48,7 +47,7 @@ func newDmlBuildResultError(err error) *dmlBuildResult {
} }
} }
// Applier connects and writes the applier-server, which is the server where migration // Applier connects and writes the the applier-server, which is the server where migration
// happens. This is typically the master, but could be a replica when `--test-on-replica` or // happens. This is typically the master, but could be a replica when `--test-on-replica` or
// `--execute-on-replica` are given. // `--execute-on-replica` are given.
// Applier is the one to actually write row data and apply binlog events onto the ghost table. // Applier is the one to actually write row data and apply binlog events onto the ghost table.
@ -72,6 +71,7 @@ func NewApplier(migrationContext *base.MigrationContext) *Applier {
} }
func (this *Applier) InitDBConnections() (err error) { func (this *Applier) InitDBConnections() (err error) {
applierUri := this.connectionConfig.GetDBUri(this.migrationContext.DatabaseName) applierUri := this.connectionConfig.GetDBUri(this.migrationContext.DatabaseName)
if this.db, _, err = mysql.GetDB(this.migrationContext.Uuid, applierUri); err != nil { if this.db, _, err = mysql.GetDB(this.migrationContext.Uuid, applierUri); err != nil {
return err return err
@ -117,33 +117,6 @@ func (this *Applier) validateAndReadTimeZone() error {
return nil return nil
} }
// generateSqlModeQuery return a `sql_mode = ...` query, to be wrapped with a `set session` or `set global`,
// based on gh-ost configuration:
// - User may skip strict mode
// - User may allow zero dats or zero in dates
func (this *Applier) generateSqlModeQuery() string {
sqlModeAddendum := []string{`NO_AUTO_VALUE_ON_ZERO`}
if !this.migrationContext.SkipStrictMode {
sqlModeAddendum = append(sqlModeAddendum, `STRICT_ALL_TABLES`)
}
sqlModeQuery := fmt.Sprintf("CONCAT(@@session.sql_mode, ',%s')", strings.Join(sqlModeAddendum, ","))
if this.migrationContext.AllowZeroInDate {
sqlModeQuery = fmt.Sprintf("REPLACE(REPLACE(%s, 'NO_ZERO_IN_DATE', ''), 'NO_ZERO_DATE', '')", sqlModeQuery)
}
return fmt.Sprintf("sql_mode = %s", sqlModeQuery)
}
// generateInstantDDLQuery returns the SQL for this ALTER operation
// with an INSTANT assertion (requires MySQL 8.0+)
func (this *Applier) generateInstantDDLQuery() string {
return fmt.Sprintf(`ALTER /* gh-ost */ TABLE %s.%s %s, ALGORITHM=INSTANT`,
sql.EscapeName(this.migrationContext.DatabaseName),
sql.EscapeName(this.migrationContext.OriginalTableName),
this.migrationContext.AlterStatementOptions,
)
}
// readTableColumns reads table columns on applier // readTableColumns reads table columns on applier
func (this *Applier) readTableColumns() (err error) { func (this *Applier) readTableColumns() (err error) {
this.migrationContext.Log.Infof("Examining table structure on applier") this.migrationContext.Log.Infof("Examining table structure on applier")
@ -197,27 +170,6 @@ func (this *Applier) ValidateOrDropExistingTables() error {
return nil return nil
} }
// AttemptInstantDDL attempts to use instant DDL (from MySQL 8.0, and earlier in Aurora and some others).
// If successful, the operation is only a meta-data change so a lot of time is saved!
// The risk of attempting to instant DDL when not supported is that a metadata lock may be acquired.
// This is minor, since gh-ost will eventually require a metadata lock anyway, but at the cut-over stage.
// Instant operations include:
// - Adding a column
// - Dropping a column
// - Dropping an index
// - Extending a VARCHAR column
// - Adding a virtual generated column
// It is not reliable to parse the `alter` statement to determine if it is instant or not.
// This is because the table might be in an older row format, or have some other incompatibility
// that is difficult to identify.
func (this *Applier) AttemptInstantDDL() error {
query := this.generateInstantDDLQuery()
this.migrationContext.Log.Infof("INSTANT DDL query is: %s", query)
// We don't need a trx, because for instant DDL the SQL mode doesn't matter.
_, err := this.db.Exec(query)
return err
}
// CreateGhostTable creates the ghost table on the applier host // CreateGhostTable creates the ghost table on the applier host
func (this *Applier) CreateGhostTable() error { func (this *Applier) CreateGhostTable() error {
query := fmt.Sprintf(`create /* gh-ost */ table %s.%s like %s.%s`, query := fmt.Sprintf(`create /* gh-ost */ table %s.%s like %s.%s`,
@ -230,33 +182,11 @@ func (this *Applier) CreateGhostTable() error {
sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.DatabaseName),
sql.EscapeName(this.migrationContext.GetGhostTableName()), sql.EscapeName(this.migrationContext.GetGhostTableName()),
) )
if _, err := sqlutils.ExecNoPrepare(this.db, query); err != nil {
err := func() error { return err
tx, err := this.db.Begin() }
if err != nil { this.migrationContext.Log.Infof("Ghost table created")
return err return nil
}
defer tx.Rollback()
sessionQuery := fmt.Sprintf(`SET SESSION time_zone = '%s'`, this.migrationContext.ApplierTimeZone)
sessionQuery = fmt.Sprintf("%s, %s", sessionQuery, this.generateSqlModeQuery())
if _, err := tx.Exec(sessionQuery); err != nil {
return err
}
if _, err := tx.Exec(query); err != nil {
return err
}
this.migrationContext.Log.Infof("Ghost table created")
if err := tx.Commit(); err != nil {
// Neither SET SESSION nor ALTER are really transactional, so strictly speaking
// there's no need to commit; but let's do this the legit way anyway.
return err
}
return nil
}()
return err
} }
// AlterGhost applies `alter` statement on ghost table // AlterGhost applies `alter` statement on ghost table
@ -271,33 +201,11 @@ func (this *Applier) AlterGhost() error {
sql.EscapeName(this.migrationContext.GetGhostTableName()), sql.EscapeName(this.migrationContext.GetGhostTableName()),
) )
this.migrationContext.Log.Debugf("ALTER statement: %s", query) this.migrationContext.Log.Debugf("ALTER statement: %s", query)
if _, err := sqlutils.ExecNoPrepare(this.db, query); err != nil {
err := func() error { return err
tx, err := this.db.Begin() }
if err != nil { this.migrationContext.Log.Infof("Ghost table altered")
return err return nil
}
defer tx.Rollback()
sessionQuery := fmt.Sprintf(`SET SESSION time_zone = '%s'`, this.migrationContext.ApplierTimeZone)
sessionQuery = fmt.Sprintf("%s, %s", sessionQuery, this.generateSqlModeQuery())
if _, err := tx.Exec(sessionQuery); err != nil {
return err
}
if _, err := tx.Exec(query); err != nil {
return err
}
this.migrationContext.Log.Infof("Ghost table altered")
if err := tx.Commit(); err != nil {
// Neither SET SESSION nor ALTER are really transactional, so strictly speaking
// there's no need to commit; but let's do this the legit way anyway.
return err
}
return nil
}()
return err
} }
// AlterGhost applies `alter` statement on ghost table // AlterGhost applies `alter` statement on ghost table
@ -325,16 +233,16 @@ func (this *Applier) CreateChangelogTable() error {
return err return err
} }
query := fmt.Sprintf(`create /* gh-ost */ table %s.%s ( query := fmt.Sprintf(`create /* gh-ost */ table %s.%s (
id bigint unsigned auto_increment, id bigint auto_increment,
last_update timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, last_update timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
hint varchar(64) charset ascii not null, hint varchar(64) charset ascii not null,
value varchar(4096) charset ascii not null, value varchar(4096) charset ascii not null,
primary key(id), primary key(id),
unique key hint_uidx(hint) unique key hint_uidx(hint)
) auto_increment=256 comment='%s'`, ) auto_increment=256
`,
sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.DatabaseName),
sql.EscapeName(this.migrationContext.GetChangelogTableName()), sql.EscapeName(this.migrationContext.GetChangelogTableName()),
GhostChangelogTableComment,
) )
this.migrationContext.Log.Infof("Creating changelog table %s.%s", this.migrationContext.Log.Infof("Creating changelog table %s.%s",
sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.DatabaseName),
@ -436,9 +344,8 @@ func (this *Applier) InitiateHeartbeat() {
} }
injectHeartbeat() injectHeartbeat()
ticker := time.NewTicker(time.Duration(this.migrationContext.HeartbeatIntervalMilliseconds) * time.Millisecond) heartbeatTick := time.Tick(time.Duration(this.migrationContext.HeartbeatIntervalMilliseconds) * time.Millisecond)
defer ticker.Stop() for range heartbeatTick {
for range ticker.C {
if atomic.LoadInt64(&this.finishedMigrating) > 0 { if atomic.LoadInt64(&this.finishedMigrating) > 0 {
return return
} }
@ -468,20 +375,17 @@ func (this *Applier) ExecuteThrottleQuery() (int64, error) {
return result, nil return result, nil
} }
// readMigrationMinValues returns the minimum values to be iterated on rowcopy // ReadMigrationMinValues returns the minimum values to be iterated on rowcopy
func (this *Applier) readMigrationMinValues(tx *gosql.Tx, uniqueKey *sql.UniqueKey) error { func (this *Applier) ReadMigrationMinValues(uniqueKey *sql.UniqueKey) error {
this.migrationContext.Log.Debugf("Reading migration range according to key: %s", uniqueKey.Name) this.migrationContext.Log.Debugf("Reading migration range according to key: %s", uniqueKey.Name)
query, err := sql.BuildUniqueKeyMinValuesPreparedQuery(this.migrationContext.DatabaseName, this.migrationContext.OriginalTableName, &uniqueKey.Columns) query, err := sql.BuildUniqueKeyMinValuesPreparedQuery(this.migrationContext.DatabaseName, this.migrationContext.OriginalTableName, &uniqueKey.Columns)
if err != nil { if err != nil {
return err return err
} }
rows, err := this.db.Query(query)
rows, err := tx.Query(query)
if err != nil { if err != nil {
return err return err
} }
defer rows.Close()
for rows.Next() { for rows.Next() {
this.migrationContext.MigrationRangeMinValues = sql.NewColumnValues(uniqueKey.Len()) this.migrationContext.MigrationRangeMinValues = sql.NewColumnValues(uniqueKey.Len())
if err = rows.Scan(this.migrationContext.MigrationRangeMinValues.ValuesPointers...); err != nil { if err = rows.Scan(this.migrationContext.MigrationRangeMinValues.ValuesPointers...); err != nil {
@ -490,23 +394,21 @@ func (this *Applier) readMigrationMinValues(tx *gosql.Tx, uniqueKey *sql.UniqueK
} }
this.migrationContext.Log.Infof("Migration min values: [%s]", this.migrationContext.MigrationRangeMinValues) this.migrationContext.Log.Infof("Migration min values: [%s]", this.migrationContext.MigrationRangeMinValues)
return rows.Err() err = rows.Err()
return err
} }
// readMigrationMaxValues returns the maximum values to be iterated on rowcopy // ReadMigrationMaxValues returns the maximum values to be iterated on rowcopy
func (this *Applier) readMigrationMaxValues(tx *gosql.Tx, uniqueKey *sql.UniqueKey) error { func (this *Applier) ReadMigrationMaxValues(uniqueKey *sql.UniqueKey) error {
this.migrationContext.Log.Debugf("Reading migration range according to key: %s", uniqueKey.Name) this.migrationContext.Log.Debugf("Reading migration range according to key: %s", uniqueKey.Name)
query, err := sql.BuildUniqueKeyMaxValuesPreparedQuery(this.migrationContext.DatabaseName, this.migrationContext.OriginalTableName, &uniqueKey.Columns) query, err := sql.BuildUniqueKeyMaxValuesPreparedQuery(this.migrationContext.DatabaseName, this.migrationContext.OriginalTableName, &uniqueKey.Columns)
if err != nil { if err != nil {
return err return err
} }
rows, err := this.db.Query(query)
rows, err := tx.Query(query)
if err != nil { if err != nil {
return err return err
} }
defer rows.Close()
for rows.Next() { for rows.Next() {
this.migrationContext.MigrationRangeMaxValues = sql.NewColumnValues(uniqueKey.Len()) this.migrationContext.MigrationRangeMaxValues = sql.NewColumnValues(uniqueKey.Len())
if err = rows.Scan(this.migrationContext.MigrationRangeMaxValues.ValuesPointers...); err != nil { if err = rows.Scan(this.migrationContext.MigrationRangeMaxValues.ValuesPointers...); err != nil {
@ -515,45 +417,19 @@ func (this *Applier) readMigrationMaxValues(tx *gosql.Tx, uniqueKey *sql.UniqueK
} }
this.migrationContext.Log.Infof("Migration max values: [%s]", this.migrationContext.MigrationRangeMaxValues) this.migrationContext.Log.Infof("Migration max values: [%s]", this.migrationContext.MigrationRangeMaxValues)
return rows.Err() err = rows.Err()
return err
} }
// ReadMigrationRangeValues reads min/max values that will be used for rowcopy. // ReadMigrationRangeValues reads min/max values that will be used for rowcopy
// Before read min/max, write a changelog state into the ghc table to avoid lost data in mysql two-phase commit.
/*
Detail description of the lost data in mysql two-phase commit issue by @Fanduzi:
When using semi-sync and setting rpl_semi_sync_master_wait_point=AFTER_SYNC,
if an INSERT statement is being committed but blocks due to an unmet ack count,
the data inserted by the transaction is not visible to ReadMigrationRangeValues,
so the copy of the existing data in the table does not include the new row inserted by the transaction.
However, the binlog event for the transaction is already written to the binlog,
so the addDMLEventsListener only captures the binlog event after the transaction,
and thus the transaction's binlog event is not captured, resulting in data loss.
If write a changelog into ghc table before ReadMigrationRangeValues, and the transaction commit blocks
because the ack is not met, then the changelog will not be able to write, so the ReadMigrationRangeValues
will not be run. When the changelog writes successfully, the ReadMigrationRangeValues will read the
newly inserted data, thus Avoiding data loss due to the above problem.
*/
func (this *Applier) ReadMigrationRangeValues() error { func (this *Applier) ReadMigrationRangeValues() error {
if _, err := this.WriteChangelogState(string(ReadMigrationRangeValues)); err != nil { if err := this.ReadMigrationMinValues(this.migrationContext.UniqueKey); err != nil {
return err return err
} }
if err := this.ReadMigrationMaxValues(this.migrationContext.UniqueKey); err != nil {
tx, err := this.db.Begin()
if err != nil {
return err return err
} }
defer tx.Rollback() return nil
if err := this.readMigrationMinValues(tx, this.migrationContext.UniqueKey); err != nil {
return err
}
if err := this.readMigrationMaxValues(tx, this.migrationContext.UniqueKey); err != nil {
return err
}
return tx.Commit()
} }
// CalculateNextIterationRangeEndValues reads the next-iteration-range-end unique key values, // CalculateNextIterationRangeEndValues reads the next-iteration-range-end unique key values,
@ -583,13 +459,10 @@ func (this *Applier) CalculateNextIterationRangeEndValues() (hasFurtherRange boo
if err != nil { if err != nil {
return hasFurtherRange, err return hasFurtherRange, err
} }
rows, err := this.db.Query(query, explodedArgs...) rows, err := this.db.Query(query, explodedArgs...)
if err != nil { if err != nil {
return hasFurtherRange, err return hasFurtherRange, err
} }
defer rows.Close()
iterationRangeMaxValues := sql.NewColumnValues(this.migrationContext.UniqueKey.Len()) iterationRangeMaxValues := sql.NewColumnValues(this.migrationContext.UniqueKey.Len())
for rows.Next() { for rows.Next() {
if err = rows.Scan(iterationRangeMaxValues.ValuesPointers...); err != nil { if err = rows.Scan(iterationRangeMaxValues.ValuesPointers...); err != nil {
@ -638,9 +511,12 @@ func (this *Applier) ApplyIterationInsertQuery() (chunkSize int64, rowsAffected
return nil, err return nil, err
} }
defer tx.Rollback() defer tx.Rollback()
sessionQuery := fmt.Sprintf(`SET SESSION time_zone = '%s'`, this.migrationContext.ApplierTimeZone) sessionQuery := fmt.Sprintf(`SET SESSION time_zone = '%s'`, this.migrationContext.ApplierTimeZone)
sessionQuery = fmt.Sprintf("%s, %s", sessionQuery, this.generateSqlModeQuery()) sqlModeAddendum := `,NO_AUTO_VALUE_ON_ZERO`
if !this.migrationContext.SkipStrictMode {
sqlModeAddendum = fmt.Sprintf("%s,STRICT_ALL_TABLES", sqlModeAddendum)
}
sessionQuery = fmt.Sprintf("%s, sql_mode = CONCAT(@@session.sql_mode, ',%s')", sessionQuery, sqlModeAddendum)
if _, err := tx.Exec(sessionQuery); err != nil { if _, err := tx.Exec(sessionQuery); err != nil {
return nil, err return nil, err
@ -934,7 +810,7 @@ func (this *Applier) CreateAtomicCutOverSentryTable() error {
} }
// AtomicCutOverMagicLock // AtomicCutOverMagicLock
func (this *Applier) AtomicCutOverMagicLock(sessionIdChan chan int64, tableLocked chan<- error, okToUnlockTable <-chan bool, tableUnlocked chan<- error) error { func (this *Applier) AtomicCutOverMagicLock(sessionIdChan chan int64, tableLocked chan<- error, okToUnlockTable <-chan bool, tableUnlocked chan<- error, dropCutOverSentryTableOnce *sync.Once) error {
tx, err := this.db.Begin() tx, err := this.db.Begin()
if err != nil { if err != nil {
tableLocked <- err tableLocked <- err
@ -945,7 +821,6 @@ func (this *Applier) AtomicCutOverMagicLock(sessionIdChan chan int64, tableLocke
tableLocked <- fmt.Errorf("Unexpected error in AtomicCutOverMagicLock(), injected to release blocking channel reads") tableLocked <- fmt.Errorf("Unexpected error in AtomicCutOverMagicLock(), injected to release blocking channel reads")
tableUnlocked <- fmt.Errorf("Unexpected error in AtomicCutOverMagicLock(), injected to release blocking channel reads") tableUnlocked <- fmt.Errorf("Unexpected error in AtomicCutOverMagicLock(), injected to release blocking channel reads")
tx.Rollback() tx.Rollback()
this.DropAtomicCutOverSentryTableIfExists()
}() }()
var sessionId int64 var sessionId int64
@ -1014,10 +889,12 @@ func (this *Applier) AtomicCutOverMagicLock(sessionIdChan chan int64, tableLocke
sql.EscapeName(this.migrationContext.GetOldTableName()), sql.EscapeName(this.migrationContext.GetOldTableName()),
) )
if _, err := tx.Exec(query); err != nil { dropCutOverSentryTableOnce.Do(func() {
this.migrationContext.Log.Errore(err) if _, err := tx.Exec(query); err != nil {
// We DO NOT return here because we must `UNLOCK TABLES`! this.migrationContext.Log.Errore(err)
} // We DO NOT return here because we must `UNLOCK TABLES`!
}
})
// Tables still locked // Tables still locked
this.migrationContext.Log.Infof("Releasing lock from %s.%s, %s.%s", this.migrationContext.Log.Infof("Releasing lock from %s.%s, %s.%s",
@ -1137,6 +1014,7 @@ func (this *Applier) buildDMLEventQuery(dmlEvent *binlog.BinlogDMLEvent) (result
// ApplyDMLEventQueries applies multiple DML queries onto the _ghost_ table // ApplyDMLEventQueries applies multiple DML queries onto the _ghost_ table
func (this *Applier) ApplyDMLEventQueries(dmlEvents [](*binlog.BinlogDMLEvent)) error { func (this *Applier) ApplyDMLEventQueries(dmlEvents [](*binlog.BinlogDMLEvent)) error {
var totalDelta int64 var totalDelta int64
err := func() error { err := func() error {
@ -1151,7 +1029,12 @@ func (this *Applier) ApplyDMLEventQueries(dmlEvents [](*binlog.BinlogDMLEvent))
} }
sessionQuery := "SET SESSION time_zone = '+00:00'" sessionQuery := "SET SESSION time_zone = '+00:00'"
sessionQuery = fmt.Sprintf("%s, %s", sessionQuery, this.generateSqlModeQuery())
sqlModeAddendum := `,NO_AUTO_VALUE_ON_ZERO`
if !this.migrationContext.SkipStrictMode {
sqlModeAddendum = fmt.Sprintf("%s,STRICT_ALL_TABLES", sqlModeAddendum)
}
sessionQuery = fmt.Sprintf("%s, sql_mode = CONCAT(@@session.sql_mode, ',%s')", sessionQuery, sqlModeAddendum)
if _, err := tx.Exec(sessionQuery); err != nil { if _, err := tx.Exec(sessionQuery); err != nil {
return rollback(err) return rollback(err)
@ -1163,7 +1046,7 @@ func (this *Applier) ApplyDMLEventQueries(dmlEvents [](*binlog.BinlogDMLEvent))
} }
result, err := tx.Exec(buildResult.query, buildResult.args...) result, err := tx.Exec(buildResult.query, buildResult.args...)
if err != nil { if err != nil {
err = fmt.Errorf("%w; query=%s; args=%+v", err, buildResult.query, buildResult.args) err = fmt.Errorf("%s; query=%s; args=%+v", err.Error(), buildResult.query, buildResult.args)
return rollback(err) return rollback(err)
} }

View File

@ -1,185 +0,0 @@
/*
Copyright 2022 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE
*/
package logic
import (
"strings"
"testing"
test "github.com/openark/golib/tests"
"github.com/github/gh-ost/go/base"
"github.com/github/gh-ost/go/binlog"
"github.com/github/gh-ost/go/sql"
)
func TestApplierGenerateSqlModeQuery(t *testing.T) {
migrationContext := base.NewMigrationContext()
applier := NewApplier(migrationContext)
{
test.S(t).ExpectEquals(
applier.generateSqlModeQuery(),
`sql_mode = CONCAT(@@session.sql_mode, ',NO_AUTO_VALUE_ON_ZERO,STRICT_ALL_TABLES')`,
)
}
{
migrationContext.SkipStrictMode = true
migrationContext.AllowZeroInDate = false
test.S(t).ExpectEquals(
applier.generateSqlModeQuery(),
`sql_mode = CONCAT(@@session.sql_mode, ',NO_AUTO_VALUE_ON_ZERO')`,
)
}
{
migrationContext.SkipStrictMode = false
migrationContext.AllowZeroInDate = true
test.S(t).ExpectEquals(
applier.generateSqlModeQuery(),
`sql_mode = REPLACE(REPLACE(CONCAT(@@session.sql_mode, ',NO_AUTO_VALUE_ON_ZERO,STRICT_ALL_TABLES'), 'NO_ZERO_IN_DATE', ''), 'NO_ZERO_DATE', '')`,
)
}
{
migrationContext.SkipStrictMode = true
migrationContext.AllowZeroInDate = true
test.S(t).ExpectEquals(
applier.generateSqlModeQuery(),
`sql_mode = REPLACE(REPLACE(CONCAT(@@session.sql_mode, ',NO_AUTO_VALUE_ON_ZERO'), 'NO_ZERO_IN_DATE', ''), 'NO_ZERO_DATE', '')`,
)
}
}
func TestApplierUpdateModifiesUniqueKeyColumns(t *testing.T) {
columns := sql.NewColumnList([]string{"id", "item_id"})
columnValues := sql.ToColumnValues([]interface{}{123456, 42})
migrationContext := base.NewMigrationContext()
migrationContext.OriginalTableColumns = columns
migrationContext.UniqueKey = &sql.UniqueKey{
Name: t.Name(),
Columns: *columns,
}
applier := NewApplier(migrationContext)
t.Run("unmodified", func(t *testing.T) {
modifiedColumn, isModified := applier.updateModifiesUniqueKeyColumns(&binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.UpdateDML,
NewColumnValues: columnValues,
WhereColumnValues: columnValues,
})
test.S(t).ExpectEquals(modifiedColumn, "")
test.S(t).ExpectFalse(isModified)
})
t.Run("modified", func(t *testing.T) {
modifiedColumn, isModified := applier.updateModifiesUniqueKeyColumns(&binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.UpdateDML,
NewColumnValues: sql.ToColumnValues([]interface{}{123456, 24}),
WhereColumnValues: columnValues,
})
test.S(t).ExpectEquals(modifiedColumn, "item_id")
test.S(t).ExpectTrue(isModified)
})
}
func TestApplierBuildDMLEventQuery(t *testing.T) {
columns := sql.NewColumnList([]string{"id", "item_id"})
columnValues := sql.ToColumnValues([]interface{}{123456, 42})
migrationContext := base.NewMigrationContext()
migrationContext.OriginalTableName = "test"
migrationContext.OriginalTableColumns = columns
migrationContext.SharedColumns = columns
migrationContext.MappedSharedColumns = columns
migrationContext.UniqueKey = &sql.UniqueKey{
Name: t.Name(),
Columns: *columns,
}
applier := NewApplier(migrationContext)
t.Run("delete", func(t *testing.T) {
binlogEvent := &binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.DeleteDML,
WhereColumnValues: columnValues,
}
res := applier.buildDMLEventQuery(binlogEvent)
test.S(t).ExpectEquals(len(res), 1)
test.S(t).ExpectNil(res[0].err)
test.S(t).ExpectEquals(strings.TrimSpace(res[0].query),
`delete /* gh-ost `+"`test`.`_test_gho`"+` */
from
`+"`test`.`_test_gho`"+`
where
((`+"`id`"+` = ?) and (`+"`item_id`"+` = ?))`)
test.S(t).ExpectEquals(len(res[0].args), 2)
test.S(t).ExpectEquals(res[0].args[0], 123456)
test.S(t).ExpectEquals(res[0].args[1], 42)
})
t.Run("insert", func(t *testing.T) {
binlogEvent := &binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.InsertDML,
NewColumnValues: columnValues,
}
res := applier.buildDMLEventQuery(binlogEvent)
test.S(t).ExpectEquals(len(res), 1)
test.S(t).ExpectNil(res[0].err)
test.S(t).ExpectEquals(strings.TrimSpace(res[0].query),
`replace /* gh-ost `+"`test`.`_test_gho`"+` */ into
`+"`test`.`_test_gho`"+`
`+"(`id`, `item_id`)"+`
values
(?, ?)`)
test.S(t).ExpectEquals(len(res[0].args), 2)
test.S(t).ExpectEquals(res[0].args[0], 123456)
test.S(t).ExpectEquals(res[0].args[1], 42)
})
t.Run("update", func(t *testing.T) {
binlogEvent := &binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.UpdateDML,
NewColumnValues: columnValues,
WhereColumnValues: columnValues,
}
res := applier.buildDMLEventQuery(binlogEvent)
test.S(t).ExpectEquals(len(res), 1)
test.S(t).ExpectNil(res[0].err)
test.S(t).ExpectEquals(strings.TrimSpace(res[0].query),
`update /* gh-ost `+"`test`.`_test_gho`"+` */
`+"`test`.`_test_gho`"+`
set
`+"`id`"+`=?, `+"`item_id`"+`=?
where
((`+"`id`"+` = ?) and (`+"`item_id`"+` = ?))`)
test.S(t).ExpectEquals(len(res[0].args), 4)
test.S(t).ExpectEquals(res[0].args[0], 123456)
test.S(t).ExpectEquals(res[0].args[1], 42)
test.S(t).ExpectEquals(res[0].args[2], 123456)
test.S(t).ExpectEquals(res[0].args[3], 42)
})
}
func TestApplierInstantDDL(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrationContext.DatabaseName = "test"
migrationContext.OriginalTableName = "mytable"
migrationContext.AlterStatementOptions = "ADD INDEX (foo)"
applier := NewApplier(migrationContext)
t.Run("instantDDLstmt", func(t *testing.T) {
stmt := applier.generateInstantDDLQuery()
test.S(t).ExpectEquals(stmt, "ALTER /* gh-ost */ TABLE `test`.`mytable` ADD INDEX (foo), ALGORITHM=INSTANT")
})
}

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2022 GitHub Inc. /*
Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -7,7 +8,6 @@ package logic
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -35,16 +35,18 @@ const (
type HooksExecutor struct { type HooksExecutor struct {
migrationContext *base.MigrationContext migrationContext *base.MigrationContext
writer io.Writer
} }
func NewHooksExecutor(migrationContext *base.MigrationContext) *HooksExecutor { func NewHooksExecutor(migrationContext *base.MigrationContext) *HooksExecutor {
return &HooksExecutor{ return &HooksExecutor{
migrationContext: migrationContext, migrationContext: migrationContext,
writer: os.Stderr,
} }
} }
func (this *HooksExecutor) initHooks() error {
return nil
}
func (this *HooksExecutor) applyEnvironmentVariables(extraVariables ...string) []string { func (this *HooksExecutor) applyEnvironmentVariables(extraVariables ...string) []string {
env := os.Environ() env := os.Environ()
env = append(env, fmt.Sprintf("GH_OST_DATABASE_NAME=%s", this.migrationContext.DatabaseName)) env = append(env, fmt.Sprintf("GH_OST_DATABASE_NAME=%s", this.migrationContext.DatabaseName))
@ -70,18 +72,20 @@ func (this *HooksExecutor) applyEnvironmentVariables(extraVariables ...string) [
env = append(env, fmt.Sprintf("GH_OST_HOOKS_HINT_TOKEN=%s", this.migrationContext.HooksHintToken)) env = append(env, fmt.Sprintf("GH_OST_HOOKS_HINT_TOKEN=%s", this.migrationContext.HooksHintToken))
env = append(env, fmt.Sprintf("GH_OST_DRY_RUN=%t", this.migrationContext.Noop)) env = append(env, fmt.Sprintf("GH_OST_DRY_RUN=%t", this.migrationContext.Noop))
env = append(env, extraVariables...) for _, variable := range extraVariables {
env = append(env, variable)
}
return env return env
} }
// executeHook executes a command, and sets relevant environment variables // executeHook executes a command, and sets relevant environment variables
// combined output & error are printed to the configured writer. // combined output & error are printed to gh-ost's standard error.
func (this *HooksExecutor) executeHook(hook string, extraVariables ...string) error { func (this *HooksExecutor) executeHook(hook string, extraVariables ...string) error {
cmd := exec.Command(hook) cmd := exec.Command(hook)
cmd.Env = this.applyEnvironmentVariables(extraVariables...) cmd.Env = this.applyEnvironmentVariables(extraVariables...)
combinedOutput, err := cmd.CombinedOutput() combinedOutput, err := cmd.CombinedOutput()
fmt.Fprintln(this.writer, string(combinedOutput)) fmt.Fprintln(os.Stderr, string(combinedOutput))
return log.Errore(err) return log.Errore(err)
} }

View File

@ -1,113 +0,0 @@
/*
Copyright 2022 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE
*/
package logic
import (
"bufio"
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/openark/golib/tests"
"github.com/github/gh-ost/go/base"
)
func TestHooksExecutorExecuteHooks(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrationContext.AlterStatement = "ENGINE=InnoDB"
migrationContext.DatabaseName = "test"
migrationContext.Hostname = "test.example.com"
migrationContext.OriginalTableName = "tablename"
migrationContext.RowsDeltaEstimate = 1
migrationContext.RowsEstimate = 122
migrationContext.TotalRowsCopied = 123456
migrationContext.SetETADuration(time.Minute)
migrationContext.SetProgressPct(50)
hooksExecutor := NewHooksExecutor(migrationContext)
writeTmpHookFunc := func(testName, hookName, script string) (path string, err error) {
if path, err = os.MkdirTemp("", testName); err != nil {
return path, err
}
err = os.WriteFile(filepath.Join(path, hookName), []byte(script), 0777)
return path, err
}
t.Run("does-not-exist", func(t *testing.T) {
migrationContext.HooksPath = "/does/not/exist"
tests.S(t).ExpectNil(hooksExecutor.executeHooks("test-hook"))
})
t.Run("failed", func(t *testing.T) {
var err error
if migrationContext.HooksPath, err = writeTmpHookFunc(
"TestHooksExecutorExecuteHooks-failed",
"failed-hook",
"#!/bin/sh\nexit 1",
); err != nil {
panic(err)
}
defer os.RemoveAll(migrationContext.HooksPath)
tests.S(t).ExpectNotNil(hooksExecutor.executeHooks("failed-hook"))
})
t.Run("success", func(t *testing.T) {
var err error
if migrationContext.HooksPath, err = writeTmpHookFunc(
"TestHooksExecutorExecuteHooks-success",
"success-hook",
"#!/bin/sh\nenv",
); err != nil {
panic(err)
}
defer os.RemoveAll(migrationContext.HooksPath)
var buf bytes.Buffer
hooksExecutor.writer = &buf
tests.S(t).ExpectNil(hooksExecutor.executeHooks("success-hook", "TEST="+t.Name()))
scanner := bufio.NewScanner(&buf)
for scanner.Scan() {
split := strings.SplitN(scanner.Text(), "=", 2)
switch split[0] {
case "GH_OST_COPIED_ROWS":
copiedRows, _ := strconv.ParseInt(split[1], 10, 64)
tests.S(t).ExpectEquals(copiedRows, migrationContext.TotalRowsCopied)
case "GH_OST_DATABASE_NAME":
tests.S(t).ExpectEquals(split[1], migrationContext.DatabaseName)
case "GH_OST_DDL":
tests.S(t).ExpectEquals(split[1], migrationContext.AlterStatement)
case "GH_OST_DRY_RUN":
tests.S(t).ExpectEquals(split[1], "false")
case "GH_OST_ESTIMATED_ROWS":
estimatedRows, _ := strconv.ParseInt(split[1], 10, 64)
tests.S(t).ExpectEquals(estimatedRows, int64(123))
case "GH_OST_ETA_SECONDS":
etaSeconds, _ := strconv.ParseInt(split[1], 10, 64)
tests.S(t).ExpectEquals(etaSeconds, int64(60))
case "GH_OST_EXECUTING_HOST":
tests.S(t).ExpectEquals(split[1], migrationContext.Hostname)
case "GH_OST_GHOST_TABLE_NAME":
tests.S(t).ExpectEquals(split[1], fmt.Sprintf("_%s_gho", migrationContext.OriginalTableName))
case "GH_OST_OLD_TABLE_NAME":
tests.S(t).ExpectEquals(split[1], fmt.Sprintf("_%s_del", migrationContext.OriginalTableName))
case "GH_OST_PROGRESS":
progress, _ := strconv.ParseFloat(split[1], 64)
tests.S(t).ExpectEquals(progress, 50.0)
case "GH_OST_TABLE_NAME":
tests.S(t).ExpectEquals(split[1], migrationContext.OriginalTableName)
case "TEST":
tests.S(t).ExpectEquals(split[1], t.Name())
}
}
})
}

View File

@ -1,14 +1,12 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
package logic package logic
import ( import (
"context"
gosql "database/sql" gosql "database/sql"
"errors"
"fmt" "fmt"
"reflect" "reflect"
"strings" "strings"
@ -133,7 +131,10 @@ func (this *Inspector) inspectOriginalAndGhostTables() (err error) {
if err != nil { if err != nil {
return err return err
} }
sharedUniqueKeys := this.getSharedUniqueKeys(this.migrationContext.OriginalTableUniqueKeys, this.migrationContext.GhostTableUniqueKeys) sharedUniqueKeys, err := this.getSharedUniqueKeys(this.migrationContext.OriginalTableUniqueKeys, this.migrationContext.GhostTableUniqueKeys)
if err != nil {
return err
}
for i, sharedUniqueKey := range sharedUniqueKeys { for i, sharedUniqueKey := range sharedUniqueKeys {
this.applyColumnTypes(this.migrationContext.DatabaseName, this.migrationContext.OriginalTableName, &sharedUniqueKey.Columns) this.applyColumnTypes(this.migrationContext.DatabaseName, this.migrationContext.OriginalTableName, &sharedUniqueKey.Columns)
uniqueKeyIsValid := true uniqueKeyIsValid := true
@ -191,7 +192,7 @@ func (this *Inspector) inspectOriginalAndGhostTables() (err error) {
this.migrationContext.MappedSharedColumns.SetEnumValues(column.Name, column.EnumValues) this.migrationContext.MappedSharedColumns.SetEnumValues(column.Name, column.EnumValues)
} }
if column.Name == mappedColumn.Name && column.Charset != mappedColumn.Charset { if column.Name == mappedColumn.Name && column.Charset != mappedColumn.Charset {
this.migrationContext.SharedColumns.SetCharsetConversion(column.Name, column.Charset, mappedColumn.Charset) this.migrationContext.MappedSharedColumns.SetCharsetConversion(column.Name, column.Charset, mappedColumn.Charset)
} }
} }
@ -285,7 +286,7 @@ func (this *Inspector) validateGrants() error {
// It is entirely possible, for example, that the replication is using 'STATEMENT' // It is entirely possible, for example, that the replication is using 'STATEMENT'
// binlog format even as the variable says 'ROW' // binlog format even as the variable says 'ROW'
func (this *Inspector) restartReplication() error { func (this *Inspector) restartReplication() error {
this.migrationContext.Log.Infof("Restarting replication on %s to make sure binlog settings apply to replication thread", this.connectionConfig.Key.String()) this.migrationContext.Log.Infof("Restarting replication on %s:%d to make sure binlog settings apply to replication thread", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
masterKey, _ := mysql.GetMasterKeyFromSlaveStatus(this.connectionConfig) masterKey, _ := mysql.GetMasterKeyFromSlaveStatus(this.connectionConfig)
if masterKey == nil { if masterKey == nil {
@ -344,13 +345,13 @@ func (this *Inspector) validateBinlogs() error {
return err return err
} }
if !hasBinaryLogs { if !hasBinaryLogs {
return fmt.Errorf("%s must have binary logs enabled", this.connectionConfig.Key.String()) return fmt.Errorf("%s:%d must have binary logs enabled", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
} }
if this.migrationContext.RequiresBinlogFormatChange() { if this.migrationContext.RequiresBinlogFormatChange() {
if !this.migrationContext.SwitchToRowBinlogFormat { if !this.migrationContext.SwitchToRowBinlogFormat {
return fmt.Errorf("You must be using ROW binlog format. I can switch it for you, provided --switch-to-rbr and that %s doesn't have replicas", this.connectionConfig.Key.String()) return fmt.Errorf("You must be using ROW binlog format. I can switch it for you, provided --switch-to-rbr and that %s:%d doesn't have replicas", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
} }
query := `show /* gh-ost */ slave hosts` query := fmt.Sprintf(`show /* gh-ost */ slave hosts`)
countReplicas := 0 countReplicas := 0
err := sqlutils.QueryRowsMap(this.db, query, func(rowMap sqlutils.RowMap) error { err := sqlutils.QueryRowsMap(this.db, query, func(rowMap sqlutils.RowMap) error {
countReplicas++ countReplicas++
@ -360,20 +361,21 @@ func (this *Inspector) validateBinlogs() error {
return err return err
} }
if countReplicas > 0 { if countReplicas > 0 {
return fmt.Errorf("%s has %s binlog_format, but I'm too scared to change it to ROW because it has replicas. Bailing out", this.connectionConfig.Key.String(), this.migrationContext.OriginalBinlogFormat) return fmt.Errorf("%s:%d has %s binlog_format, but I'm too scared to change it to ROW because it has replicas. Bailing out", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port, this.migrationContext.OriginalBinlogFormat)
} }
this.migrationContext.Log.Infof("%s has %s binlog_format. I will change it to ROW, and will NOT change it back, even in the event of failure.", this.connectionConfig.Key.String(), this.migrationContext.OriginalBinlogFormat) this.migrationContext.Log.Infof("%s:%d has %s binlog_format. I will change it to ROW, and will NOT change it back, even in the event of failure.", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port, this.migrationContext.OriginalBinlogFormat)
} }
query = `select @@global.binlog_row_image` query = `select @@global.binlog_row_image`
if err := this.db.QueryRow(query).Scan(&this.migrationContext.OriginalBinlogRowImage); err != nil { if err := this.db.QueryRow(query).Scan(&this.migrationContext.OriginalBinlogRowImage); err != nil {
return err // Only as of 5.6. We wish to support 5.5 as well
this.migrationContext.OriginalBinlogRowImage = "FULL"
} }
this.migrationContext.OriginalBinlogRowImage = strings.ToUpper(this.migrationContext.OriginalBinlogRowImage) this.migrationContext.OriginalBinlogRowImage = strings.ToUpper(this.migrationContext.OriginalBinlogRowImage)
if this.migrationContext.OriginalBinlogRowImage != "FULL" { if this.migrationContext.OriginalBinlogRowImage != "FULL" {
return fmt.Errorf("%s has '%s' binlog_row_image, and only 'FULL' is supported. This operation cannot proceed. You may `set global binlog_row_image='full'` and try again", this.connectionConfig.Key.String(), this.migrationContext.OriginalBinlogRowImage) return fmt.Errorf("%s:%d has '%s' binlog_row_image, and only 'FULL' is supported. This operation cannot proceed. You may `set global binlog_row_image='full'` and try again", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port, this.migrationContext.OriginalBinlogRowImage)
} }
this.migrationContext.Log.Infof("binary logs validated on %s", this.connectionConfig.Key.String()) this.migrationContext.Log.Infof("binary logs validated on %s:%d", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
return nil return nil
} }
@ -386,25 +388,25 @@ func (this *Inspector) validateLogSlaveUpdates() error {
} }
if logSlaveUpdates { if logSlaveUpdates {
this.migrationContext.Log.Infof("log_slave_updates validated on %s", this.connectionConfig.Key.String()) this.migrationContext.Log.Infof("log_slave_updates validated on %s:%d", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
return nil return nil
} }
if this.migrationContext.IsTungsten { if this.migrationContext.IsTungsten {
this.migrationContext.Log.Warningf("log_slave_updates not found on %s, but --tungsten provided, so I'm proceeding", this.connectionConfig.Key.String()) this.migrationContext.Log.Warningf("log_slave_updates not found on %s:%d, but --tungsten provided, so I'm proceeding", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
return nil return nil
} }
if this.migrationContext.TestOnReplica || this.migrationContext.MigrateOnReplica { if this.migrationContext.TestOnReplica || this.migrationContext.MigrateOnReplica {
return fmt.Errorf("%s must have log_slave_updates enabled for testing/migrating on replica", this.connectionConfig.Key.String()) return fmt.Errorf("%s:%d must have log_slave_updates enabled for testing/migrating on replica", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
} }
if this.migrationContext.InspectorIsAlsoApplier() { if this.migrationContext.InspectorIsAlsoApplier() {
this.migrationContext.Log.Warningf("log_slave_updates not found on %s, but executing directly on master, so I'm proceeding", this.connectionConfig.Key.String()) this.migrationContext.Log.Warningf("log_slave_updates not found on %s:%d, but executing directly on master, so I'm proceeding", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
return nil return nil
} }
return fmt.Errorf("%s must have log_slave_updates enabled for executing migration", this.connectionConfig.Key.String()) return fmt.Errorf("%s:%d must have log_slave_updates enabled for executing migration", this.connectionConfig.Key.Hostname, this.connectionConfig.Key.Port)
} }
// validateTable makes sure the table we need to operate on actually exists // validateTable makes sure the table we need to operate on actually exists
@ -535,37 +537,17 @@ func (this *Inspector) estimateTableRowsViaExplain() error {
} }
// CountTableRows counts exact number of rows on the original table // CountTableRows counts exact number of rows on the original table
func (this *Inspector) CountTableRows(ctx context.Context) error { func (this *Inspector) CountTableRows() error {
atomic.StoreInt64(&this.migrationContext.CountingRowsFlag, 1) atomic.StoreInt64(&this.migrationContext.CountingRowsFlag, 1)
defer atomic.StoreInt64(&this.migrationContext.CountingRowsFlag, 0) defer atomic.StoreInt64(&this.migrationContext.CountingRowsFlag, 0)
this.migrationContext.Log.Infof("As instructed, I'm issuing a SELECT COUNT(*) on the table. This may take a while") this.migrationContext.Log.Infof("As instructed, I'm issuing a SELECT COUNT(*) on the table. This may take a while")
conn, err := this.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
var connectionID string
if err := conn.QueryRowContext(ctx, `SELECT /* gh-ost */ CONNECTION_ID()`).Scan(&connectionID); err != nil {
return err
}
query := fmt.Sprintf(`select /* gh-ost */ count(*) as count_rows from %s.%s`, sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName)) query := fmt.Sprintf(`select /* gh-ost */ count(*) as count_rows from %s.%s`, sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))
var rowsEstimate int64 var rowsEstimate int64
if err := conn.QueryRowContext(ctx, query).Scan(&rowsEstimate); err != nil { if err := this.db.QueryRow(query).Scan(&rowsEstimate); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
this.migrationContext.Log.Infof("exact row count cancelled (%s), likely because I'm about to cut over. I'm going to kill that query.", ctx.Err())
return mysql.Kill(this.db, connectionID)
}
return err return err
} }
// row count query finished. nil out the cancel func, so the main migration thread
// doesn't bother calling it after row copy is done.
this.migrationContext.SetCountTableRowsCancelFunc(nil)
atomic.StoreInt64(&this.migrationContext.RowsEstimate, rowsEstimate) atomic.StoreInt64(&this.migrationContext.RowsEstimate, rowsEstimate)
this.migrationContext.UsedRowsEstimateMethod = base.CountRowsEstimate this.migrationContext.UsedRowsEstimateMethod = base.CountRowsEstimate
@ -727,7 +709,7 @@ func (this *Inspector) getCandidateUniqueKeys(tableName string) (uniqueKeys [](*
// getSharedUniqueKeys returns the intersection of two given unique keys, // getSharedUniqueKeys returns the intersection of two given unique keys,
// testing by list of columns // testing by list of columns
func (this *Inspector) getSharedUniqueKeys(originalUniqueKeys, ghostUniqueKeys []*sql.UniqueKey) (uniqueKeys []*sql.UniqueKey) { func (this *Inspector) getSharedUniqueKeys(originalUniqueKeys, ghostUniqueKeys [](*sql.UniqueKey)) (uniqueKeys [](*sql.UniqueKey), err error) {
// We actually do NOT rely on key name, just on the set of columns. This is because maybe // We actually do NOT rely on key name, just on the set of columns. This is because maybe
// the ALTER is on the name itself... // the ALTER is on the name itself...
for _, originalUniqueKey := range originalUniqueKeys { for _, originalUniqueKey := range originalUniqueKeys {
@ -737,7 +719,7 @@ func (this *Inspector) getSharedUniqueKeys(originalUniqueKeys, ghostUniqueKeys [
} }
} }
} }
return uniqueKeys return uniqueKeys, nil
} }
// getSharedColumns returns the intersection of two lists of columns in same order as the first list // getSharedColumns returns the intersection of two lists of columns in same order as the first list
@ -826,4 +808,5 @@ func (this *Inspector) getReplicationLag() (replicationLag time.Duration, err er
func (this *Inspector) Teardown() { func (this *Inspector) Teardown() {
this.db.Close() this.db.Close()
this.informationSchemaDb.Close() this.informationSchemaDb.Close()
return
} }

View File

@ -1,31 +0,0 @@
/*
Copyright 2022 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE
*/
package logic
import (
"testing"
test "github.com/openark/golib/tests"
"github.com/github/gh-ost/go/sql"
)
func TestInspectGetSharedUniqueKeys(t *testing.T) {
origUniqKeys := []*sql.UniqueKey{
{Columns: *sql.NewColumnList([]string{"id", "item_id"})},
{Columns: *sql.NewColumnList([]string{"id", "org_id"})},
}
ghostUniqKeys := []*sql.UniqueKey{
{Columns: *sql.NewColumnList([]string{"id", "item_id"})},
{Columns: *sql.NewColumnList([]string{"id", "org_id"})},
{Columns: *sql.NewColumnList([]string{"item_id", "user_id"})},
}
inspector := &Inspector{}
sharedUniqKeys := inspector.getSharedUniqueKeys(origUniqKeys, ghostUniqKeys)
test.S(t).ExpectEquals(len(sharedUniqKeys), 2)
test.S(t).ExpectEquals(sharedUniqKeys[0].Columns.String(), "id,item_id")
test.S(t).ExpectEquals(sharedUniqKeys[1].Columns.String(), "id,org_id")
}

View File

@ -1,18 +1,17 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
package logic package logic
import ( import (
"context"
"errors"
"fmt" "fmt"
"io" "io"
"math" "math"
"os" "os"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -22,17 +21,11 @@ import (
"github.com/github/gh-ost/go/sql" "github.com/github/gh-ost/go/sql"
) )
var (
ErrMigratorUnsupportedRenameAlter = errors.New("ALTER statement seems to RENAME the table. This is not supported, and you should run your RENAME outside gh-ost.")
)
type ChangelogState string type ChangelogState string
const ( const (
AllEventsUpToLockProcessed ChangelogState = "AllEventsUpToLockProcessed"
GhostTableMigrated ChangelogState = "GhostTableMigrated" GhostTableMigrated ChangelogState = "GhostTableMigrated"
Migrated ChangelogState = "Migrated" AllEventsUpToLockProcessed = "AllEventsUpToLockProcessed"
ReadMigrationRangeValues ChangelogState = "ReadMigrationRangeValues"
) )
func ReadChangelogState(s string) ChangelogState { func ReadChangelogState(s string) ChangelogState {
@ -68,7 +61,6 @@ const (
// Migrator is the main schema migration flow manager. // Migrator is the main schema migration flow manager.
type Migrator struct { type Migrator struct {
appVersion string
parser *sql.AlterTableParser parser *sql.AlterTableParser
inspector *Inspector inspector *Inspector
applier *Applier applier *Applier
@ -94,10 +86,8 @@ type Migrator struct {
finishedMigrating int64 finishedMigrating int64
} }
func NewMigrator(context *base.MigrationContext, appVersion string) *Migrator { func NewMigrator(context *base.MigrationContext) *Migrator {
migrator := &Migrator{ migrator := &Migrator{
appVersion: appVersion,
hooksExecutor: NewHooksExecutor(context),
migrationContext: context, migrationContext: context,
parser: sql.NewAlterTableParser(), parser: sql.NewAlterTableParser(),
ghostTableMigrated: make(chan bool), ghostTableMigrated: make(chan bool),
@ -113,6 +103,15 @@ func NewMigrator(context *base.MigrationContext, appVersion string) *Migrator {
return migrator return migrator
} }
// initiateHooksExecutor
func (this *Migrator) initiateHooksExecutor() (err error) {
this.hooksExecutor = NewHooksExecutor(this.migrationContext)
if err := this.hooksExecutor.initHooks(); err != nil {
return err
}
return nil
}
// sleepWhileTrue sleeps indefinitely until the given function returns 'false' // sleepWhileTrue sleeps indefinitely until the given function returns 'false'
// (or fails with error) // (or fails with error)
func (this *Migrator) sleepWhileTrue(operation func() (bool, error)) error { func (this *Migrator) sleepWhileTrue(operation func() (bool, error)) error {
@ -177,6 +176,16 @@ func (this *Migrator) retryOperationWithExponentialBackoff(operation func() erro
return err return err
} }
// executeAndThrottleOnError executes a given function. If it errors, it
// throttles.
func (this *Migrator) executeAndThrottleOnError(operation func() error) (err error) {
if err := operation(); err != nil {
this.throttler.throttle(nil)
return err
}
return nil
}
// consumeRowCopyComplete blocks on the rowCopyComplete channel once, and then // consumeRowCopyComplete blocks on the rowCopyComplete channel once, and then
// consumes and drops any further incoming events that may be left hanging. // consumes and drops any further incoming events that may be left hanging.
func (this *Migrator) consumeRowCopyComplete() { func (this *Migrator) consumeRowCopyComplete() {
@ -216,25 +225,29 @@ func (this *Migrator) onChangelogStateEvent(dmlEvent *binlog.BinlogDMLEvent) (er
changelogState := ReadChangelogState(changelogStateString) changelogState := ReadChangelogState(changelogStateString)
this.migrationContext.Log.Infof("Intercepted changelog state %s", changelogState) this.migrationContext.Log.Infof("Intercepted changelog state %s", changelogState)
switch changelogState { switch changelogState {
case Migrated, ReadMigrationRangeValues:
// no-op event
case GhostTableMigrated: case GhostTableMigrated:
this.ghostTableMigrated <- true {
case AllEventsUpToLockProcessed: this.ghostTableMigrated <- true
var applyEventFunc tableWriteFunc = func() error { }
this.allEventsUpToLockProcessed <- changelogStateString case AllEventsUpToLockProcessed:
return nil {
var applyEventFunc tableWriteFunc = func() error {
this.allEventsUpToLockProcessed <- changelogStateString
return nil
}
// at this point we know all events up to lock have been read from the streamer,
// because the streamer works sequentially. So those events are either already handled,
// or have event functions in applyEventsQueue.
// So as not to create a potential deadlock, we write this func to applyEventsQueue
// asynchronously, understanding it doesn't really matter.
go func() {
this.applyEventsQueue <- newApplyEventStructByFunc(&applyEventFunc)
}()
} }
// at this point we know all events up to lock have been read from the streamer,
// because the streamer works sequentially. So those events are either already handled,
// or have event functions in applyEventsQueue.
// So as not to create a potential deadlock, we write this func to applyEventsQueue
// asynchronously, understanding it doesn't really matter.
go func() {
this.applyEventsQueue <- newApplyEventStructByFunc(&applyEventFunc)
}()
default: default:
return fmt.Errorf("Unknown changelog state: %+v", changelogState) {
return fmt.Errorf("Unknown changelog state: %+v", changelogState)
}
} }
this.migrationContext.Log.Infof("Handled changelog state %s", changelogState) this.migrationContext.Log.Infof("Handled changelog state %s", changelogState)
return nil return nil
@ -258,13 +271,13 @@ func (this *Migrator) listenOnPanicAbort() {
this.migrationContext.Log.Fatale(err) this.migrationContext.Log.Fatale(err)
} }
// validateAlterStatement validates the `alter` statement meets criteria. // validateStatement validates the `alter` statement meets criteria.
// At this time this means: // At this time this means:
// - column renames are approved // - column renames are approved
// - no table rename allowed // - no table rename allowed
func (this *Migrator) validateAlterStatement() (err error) { func (this *Migrator) validateStatement() (err error) {
if this.parser.IsRenameTable() { if this.parser.IsRenameTable() {
return ErrMigratorUnsupportedRenameAlter return fmt.Errorf("ALTER statement seems to RENAME the table. This is not supported, and you should run your RENAME outside gh-ost.")
} }
if this.parser.HasNonTrivialRenames() && !this.migrationContext.SkipRenamedColumns { if this.parser.HasNonTrivialRenames() && !this.migrationContext.SkipRenamedColumns {
this.migrationContext.ColumnRenameMap = this.parser.GetNonTrivialRenames() this.migrationContext.ColumnRenameMap = this.parser.GetNonTrivialRenames()
@ -287,8 +300,8 @@ func (this *Migrator) countTableRows() (err error) {
return nil return nil
} }
countRowsFunc := func(ctx context.Context) error { countRowsFunc := func() error {
if err := this.inspector.CountTableRows(ctx); err != nil { if err := this.inspector.CountTableRows(); err != nil {
return err return err
} }
if err := this.hooksExecutor.onRowCountComplete(); err != nil { if err := this.hooksExecutor.onRowCountComplete(); err != nil {
@ -298,17 +311,12 @@ func (this *Migrator) countTableRows() (err error) {
} }
if this.migrationContext.ConcurrentCountTableRows { if this.migrationContext.ConcurrentCountTableRows {
// store a cancel func so we can stop this query before a cut over
rowCountContext, rowCountCancel := context.WithCancel(context.Background())
this.migrationContext.SetCountTableRowsCancelFunc(rowCountCancel)
this.migrationContext.Log.Infof("As instructed, counting rows in the background; meanwhile I will use an estimated count, and will update it later on") this.migrationContext.Log.Infof("As instructed, counting rows in the background; meanwhile I will use an estimated count, and will update it later on")
go countRowsFunc(rowCountContext) go countRowsFunc()
// and we ignore errors, because this turns to be a background job // and we ignore errors, because this turns to be a background job
return nil return nil
} }
return countRowsFunc(context.Background()) return countRowsFunc()
} }
func (this *Migrator) createFlagFiles() (err error) { func (this *Migrator) createFlagFiles() (err error) {
@ -333,13 +341,16 @@ func (this *Migrator) Migrate() (err error) {
go this.listenOnPanicAbort() go this.listenOnPanicAbort()
if err := this.initiateHooksExecutor(); err != nil {
return err
}
if err := this.hooksExecutor.onStartup(); err != nil { if err := this.hooksExecutor.onStartup(); err != nil {
return err return err
} }
if err := this.parser.ParseAlterStatement(this.migrationContext.AlterStatement); err != nil { if err := this.parser.ParseAlterStatement(this.migrationContext.AlterStatement); err != nil {
return err return err
} }
if err := this.validateAlterStatement(); err != nil { if err := this.validateStatement(); err != nil {
return err return err
} }
@ -359,17 +370,6 @@ func (this *Migrator) Migrate() (err error) {
if err := this.createFlagFiles(); err != nil { if err := this.createFlagFiles(); err != nil {
return err return err
} }
// In MySQL 8.0 (and possibly earlier) some DDL statements can be applied instantly.
// Attempt to do this if AttemptInstantDDL is set.
if this.migrationContext.AttemptInstantDDL {
this.migrationContext.Log.Infof("Attempting to execute alter with ALGORITHM=INSTANT")
if err := this.applier.AttemptInstantDDL(); err == nil {
this.migrationContext.Log.Infof("Success! table %s.%s migrated instantly", sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))
return nil
} else {
this.migrationContext.Log.Infof("ALGORITHM=INSTANT not supported for this operation, proceeding with original algorithm: %s", err)
}
}
initialLag, _ := this.inspector.getReplicationLag() initialLag, _ := this.inspector.getReplicationLag()
this.migrationContext.Log.Infof("Waiting for ghost table to be migrated. Current lag is %+v", initialLag) this.migrationContext.Log.Infof("Waiting for ghost table to be migrated. Current lag is %+v", initialLag)
@ -401,9 +401,9 @@ func (this *Migrator) Migrate() (err error) {
if err := this.applier.ReadMigrationRangeValues(); err != nil { if err := this.applier.ReadMigrationRangeValues(); err != nil {
return err return err
} }
if err := this.initiateThrottler(); err != nil {
this.initiateThrottler() return err
}
if err := this.hooksExecutor.onBeforeRowCopy(); err != nil { if err := this.hooksExecutor.onBeforeRowCopy(); err != nil {
return err return err
} }
@ -420,10 +420,6 @@ func (this *Migrator) Migrate() (err error) {
} }
this.printStatus(ForcePrintStatusRule) this.printStatus(ForcePrintStatusRule)
if this.migrationContext.IsCountingTableRows() {
this.migrationContext.Log.Info("stopping query for exact row count, because that can accidentally lock out the cut over")
this.migrationContext.CancelTableRowsCount()
}
if err := this.hooksExecutor.onBeforeCutOver(); err != nil { if err := this.hooksExecutor.onBeforeCutOver(); err != nil {
return err return err
} }
@ -548,19 +544,19 @@ func (this *Migrator) cutOver() (err error) {
} }
} }
} }
if this.migrationContext.CutOverType == base.CutOverAtomic {
switch this.migrationContext.CutOverType {
case base.CutOverAtomic:
// Atomic solution: we use low timeout and multiple attempts. But for // Atomic solution: we use low timeout and multiple attempts. But for
// each failed attempt, we throttle until replication lag is back to normal // each failed attempt, we throttle until replication lag is back to normal
err = this.atomicCutOver() err := this.atomicCutOver()
case base.CutOverTwoStep: this.handleCutOverResult(err)
err = this.cutOverTwoStep() return err
default:
return this.migrationContext.Log.Fatalf("Unknown cut-over type: %d; should never get here!", this.migrationContext.CutOverType)
} }
this.handleCutOverResult(err) if this.migrationContext.CutOverType == base.CutOverTwoStep {
return err err := this.cutOverTwoStep()
this.handleCutOverResult(err)
return err
}
return this.migrationContext.Log.Fatalf("Unknown cut-over type: %d; should never get here!", this.migrationContext.CutOverType)
} }
// Inject the "AllEventsUpToLockProcessed" state hint, wait for it to appear in the binary logs, // Inject the "AllEventsUpToLockProcessed" state hint, wait for it to appear in the binary logs,
@ -638,8 +634,12 @@ func (this *Migrator) atomicCutOver() (err error) {
defer atomic.StoreInt64(&this.migrationContext.InCutOverCriticalSectionFlag, 0) defer atomic.StoreInt64(&this.migrationContext.InCutOverCriticalSectionFlag, 0)
okToUnlockTable := make(chan bool, 4) okToUnlockTable := make(chan bool, 4)
var dropCutOverSentryTableOnce sync.Once
defer func() { defer func() {
okToUnlockTable <- true okToUnlockTable <- true
dropCutOverSentryTableOnce.Do(func() {
this.applier.DropAtomicCutOverSentryTableIfExists()
})
}() }()
atomic.StoreInt64(&this.migrationContext.AllEventsUpToLockProcessedInjectedFlag, 0) atomic.StoreInt64(&this.migrationContext.AllEventsUpToLockProcessedInjectedFlag, 0)
@ -648,7 +648,7 @@ func (this *Migrator) atomicCutOver() (err error) {
tableLocked := make(chan error, 2) tableLocked := make(chan error, 2)
tableUnlocked := make(chan error, 2) tableUnlocked := make(chan error, 2)
go func() { go func() {
if err := this.applier.AtomicCutOverMagicLock(lockOriginalSessionIdChan, tableLocked, okToUnlockTable, tableUnlocked); err != nil { if err := this.applier.AtomicCutOverMagicLock(lockOriginalSessionIdChan, tableLocked, okToUnlockTable, tableUnlocked, &dropCutOverSentryTableOnce); err != nil {
this.migrationContext.Log.Errore(err) this.migrationContext.Log.Errore(err)
} }
}() }()
@ -804,16 +804,17 @@ func (this *Migrator) initiateInspector() (err error) {
} }
// initiateStatus sets and activates the printStatus() ticker // initiateStatus sets and activates the printStatus() ticker
func (this *Migrator) initiateStatus() { func (this *Migrator) initiateStatus() error {
this.printStatus(ForcePrintStatusAndHintRule) this.printStatus(ForcePrintStatusAndHintRule)
ticker := time.NewTicker(time.Second) statusTick := time.Tick(1 * time.Second)
defer ticker.Stop() for range statusTick {
for range ticker.C {
if atomic.LoadInt64(&this.finishedMigrating) > 0 { if atomic.LoadInt64(&this.finishedMigrating) > 0 {
return return nil
} }
go this.printStatus(HeuristicPrintStatusRule) go this.printStatus(HeuristicPrintStatusRule)
} }
return nil
} }
// printMigrationStatusHint prints a detailed configuration dump, that is useful // printMigrationStatusHint prints a detailed configuration dump, that is useful
@ -822,57 +823,57 @@ func (this *Migrator) initiateStatus() {
// migration, and as response to the "status" interactive command. // migration, and as response to the "status" interactive command.
func (this *Migrator) printMigrationStatusHint(writers ...io.Writer) { func (this *Migrator) printMigrationStatusHint(writers ...io.Writer) {
w := io.MultiWriter(writers...) w := io.MultiWriter(writers...)
fmt.Fprintf(w, "# Migrating %s.%s; Ghost table is %s.%s\n", fmt.Fprintln(w, fmt.Sprintf("# Migrating %s.%s; Ghost table is %s.%s",
sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.DatabaseName),
sql.EscapeName(this.migrationContext.OriginalTableName), sql.EscapeName(this.migrationContext.OriginalTableName),
sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.DatabaseName),
sql.EscapeName(this.migrationContext.GetGhostTableName()), sql.EscapeName(this.migrationContext.GetGhostTableName()),
) ))
fmt.Fprintf(w, "# Migrating %+v; inspecting %+v; executing on %+v\n", fmt.Fprintln(w, fmt.Sprintf("# Migrating %+v; inspecting %+v; executing on %+v",
*this.applier.connectionConfig.ImpliedKey, *this.applier.connectionConfig.ImpliedKey,
*this.inspector.connectionConfig.ImpliedKey, *this.inspector.connectionConfig.ImpliedKey,
this.migrationContext.Hostname, this.migrationContext.Hostname,
) ))
fmt.Fprintf(w, "# Migration started at %+v\n", fmt.Fprintln(w, fmt.Sprintf("# Migration started at %+v",
this.migrationContext.StartTime.Format(time.RubyDate), this.migrationContext.StartTime.Format(time.RubyDate),
) ))
maxLoad := this.migrationContext.GetMaxLoad() maxLoad := this.migrationContext.GetMaxLoad()
criticalLoad := this.migrationContext.GetCriticalLoad() criticalLoad := this.migrationContext.GetCriticalLoad()
fmt.Fprintf(w, "# chunk-size: %+v; max-lag-millis: %+vms; dml-batch-size: %+v; max-load: %s; critical-load: %s; nice-ratio: %f\n", fmt.Fprintln(w, fmt.Sprintf("# chunk-size: %+v; max-lag-millis: %+vms; dml-batch-size: %+v; max-load: %s; critical-load: %s; nice-ratio: %f",
atomic.LoadInt64(&this.migrationContext.ChunkSize), atomic.LoadInt64(&this.migrationContext.ChunkSize),
atomic.LoadInt64(&this.migrationContext.MaxLagMillisecondsThrottleThreshold), atomic.LoadInt64(&this.migrationContext.MaxLagMillisecondsThrottleThreshold),
atomic.LoadInt64(&this.migrationContext.DMLBatchSize), atomic.LoadInt64(&this.migrationContext.DMLBatchSize),
maxLoad.String(), maxLoad.String(),
criticalLoad.String(), criticalLoad.String(),
this.migrationContext.GetNiceRatio(), this.migrationContext.GetNiceRatio(),
) ))
if this.migrationContext.ThrottleFlagFile != "" { if this.migrationContext.ThrottleFlagFile != "" {
setIndicator := "" setIndicator := ""
if base.FileExists(this.migrationContext.ThrottleFlagFile) { if base.FileExists(this.migrationContext.ThrottleFlagFile) {
setIndicator = "[set]" setIndicator = "[set]"
} }
fmt.Fprintf(w, "# throttle-flag-file: %+v %+v\n", fmt.Fprintln(w, fmt.Sprintf("# throttle-flag-file: %+v %+v",
this.migrationContext.ThrottleFlagFile, setIndicator, this.migrationContext.ThrottleFlagFile, setIndicator,
) ))
} }
if this.migrationContext.ThrottleAdditionalFlagFile != "" { if this.migrationContext.ThrottleAdditionalFlagFile != "" {
setIndicator := "" setIndicator := ""
if base.FileExists(this.migrationContext.ThrottleAdditionalFlagFile) { if base.FileExists(this.migrationContext.ThrottleAdditionalFlagFile) {
setIndicator = "[set]" setIndicator = "[set]"
} }
fmt.Fprintf(w, "# throttle-additional-flag-file: %+v %+v\n", fmt.Fprintln(w, fmt.Sprintf("# throttle-additional-flag-file: %+v %+v",
this.migrationContext.ThrottleAdditionalFlagFile, setIndicator, this.migrationContext.ThrottleAdditionalFlagFile, setIndicator,
) ))
} }
if throttleQuery := this.migrationContext.GetThrottleQuery(); throttleQuery != "" { if throttleQuery := this.migrationContext.GetThrottleQuery(); throttleQuery != "" {
fmt.Fprintf(w, "# throttle-query: %+v\n", fmt.Fprintln(w, fmt.Sprintf("# throttle-query: %+v",
throttleQuery, throttleQuery,
) ))
} }
if throttleControlReplicaKeys := this.migrationContext.GetThrottleControlReplicaKeys(); throttleControlReplicaKeys.Len() > 0 { if throttleControlReplicaKeys := this.migrationContext.GetThrottleControlReplicaKeys(); throttleControlReplicaKeys.Len() > 0 {
fmt.Fprintf(w, "# throttle-control-replicas count: %+v\n", fmt.Fprintln(w, fmt.Sprintf("# throttle-control-replicas count: %+v",
throttleControlReplicaKeys.Len(), throttleControlReplicaKeys.Len(),
) ))
} }
if this.migrationContext.PostponeCutOverFlagFile != "" { if this.migrationContext.PostponeCutOverFlagFile != "" {
@ -880,111 +881,23 @@ func (this *Migrator) printMigrationStatusHint(writers ...io.Writer) {
if base.FileExists(this.migrationContext.PostponeCutOverFlagFile) { if base.FileExists(this.migrationContext.PostponeCutOverFlagFile) {
setIndicator = "[set]" setIndicator = "[set]"
} }
fmt.Fprintf(w, "# postpone-cut-over-flag-file: %+v %+v\n", fmt.Fprintln(w, fmt.Sprintf("# postpone-cut-over-flag-file: %+v %+v",
this.migrationContext.PostponeCutOverFlagFile, setIndicator, this.migrationContext.PostponeCutOverFlagFile, setIndicator,
) ))
} }
if this.migrationContext.PanicFlagFile != "" { if this.migrationContext.PanicFlagFile != "" {
fmt.Fprintf(w, "# panic-flag-file: %+v\n", fmt.Fprintln(w, fmt.Sprintf("# panic-flag-file: %+v",
this.migrationContext.PanicFlagFile, this.migrationContext.PanicFlagFile,
) ))
} }
fmt.Fprintf(w, "# Serving on unix socket: %+v\n", fmt.Fprintln(w, fmt.Sprintf("# Serving on unix socket: %+v",
this.migrationContext.ServeSocketFile, this.migrationContext.ServeSocketFile,
) ))
if this.migrationContext.ServeTCPPort != 0 { if this.migrationContext.ServeTCPPort != 0 {
fmt.Fprintf(w, "# Serving on TCP port: %+v\n", this.migrationContext.ServeTCPPort) fmt.Fprintln(w, fmt.Sprintf("# Serving on TCP port: %+v", this.migrationContext.ServeTCPPort))
} }
} }
// getProgressPercent returns an estimate of migration progess as a percent.
func (this *Migrator) getProgressPercent(rowsEstimate int64) (progressPct float64) {
progressPct = 100.0
if rowsEstimate > 0 {
progressPct *= float64(this.migrationContext.GetTotalRowsCopied()) / float64(rowsEstimate)
}
return progressPct
}
// getMigrationETA returns the estimated duration of the migration
func (this *Migrator) getMigrationETA(rowsEstimate int64) (eta string, duration time.Duration) {
duration = time.Duration(base.ETAUnknown)
progressPct := this.getProgressPercent(rowsEstimate)
if progressPct >= 100.0 {
duration = 0
} else if progressPct >= 0.1 {
totalRowsCopied := this.migrationContext.GetTotalRowsCopied()
elapsedRowCopySeconds := this.migrationContext.ElapsedRowCopyTime().Seconds()
totalExpectedSeconds := elapsedRowCopySeconds * float64(rowsEstimate) / float64(totalRowsCopied)
etaSeconds := totalExpectedSeconds - elapsedRowCopySeconds
if etaSeconds >= 0 {
duration = time.Duration(etaSeconds) * time.Second
} else {
duration = 0
}
}
switch duration {
case 0:
eta = "due"
case time.Duration(base.ETAUnknown):
eta = "N/A"
default:
eta = base.PrettifyDurationOutput(duration)
}
return eta, duration
}
// getMigrationStateAndETA returns the state and eta of the migration.
func (this *Migrator) getMigrationStateAndETA(rowsEstimate int64) (state, eta string, etaDuration time.Duration) {
eta, etaDuration = this.getMigrationETA(rowsEstimate)
state = "migrating"
if atomic.LoadInt64(&this.migrationContext.CountingRowsFlag) > 0 && !this.migrationContext.ConcurrentCountTableRows {
state = "counting rows"
} else if atomic.LoadInt64(&this.migrationContext.IsPostponingCutOver) > 0 {
eta = "due"
state = "postponing cut-over"
} else if isThrottled, throttleReason, _ := this.migrationContext.IsThrottled(); isThrottled {
state = fmt.Sprintf("throttled, %s", throttleReason)
}
return state, eta, etaDuration
}
// shouldPrintStatus returns true when the migrator is due to print status info.
func (this *Migrator) shouldPrintStatus(rule PrintStatusRule, elapsedSeconds int64, etaDuration time.Duration) (shouldPrint bool) {
if rule != HeuristicPrintStatusRule {
return true
}
etaSeconds := etaDuration.Seconds()
if elapsedSeconds <= 60 {
shouldPrint = true
} else if etaSeconds <= 60 {
shouldPrint = true
} else if etaSeconds <= 180 {
shouldPrint = (elapsedSeconds%5 == 0)
} else if elapsedSeconds <= 180 {
shouldPrint = (elapsedSeconds%5 == 0)
} else if this.migrationContext.TimeSincePointOfInterest().Seconds() <= 60 {
shouldPrint = (elapsedSeconds%5 == 0)
} else {
shouldPrint = (elapsedSeconds%30 == 0)
}
return shouldPrint
}
// shouldPrintMigrationStatus returns true when the migrator is due to print the migration status hint
func (this *Migrator) shouldPrintMigrationStatusHint(rule PrintStatusRule, elapsedSeconds int64) (shouldPrint bool) {
if elapsedSeconds%600 == 0 {
shouldPrint = true
} else if rule == ForcePrintStatusAndHintRule {
shouldPrint = true
}
return shouldPrint
}
// printStatus prints the progress status, and optionally additionally detailed // printStatus prints the progress status, and optionally additionally detailed
// dump of configuration. // dump of configuration.
// `rule` indicates the type of output expected. // `rule` indicates the type of output expected.
@ -1005,21 +918,81 @@ func (this *Migrator) printStatus(rule PrintStatusRule, writers ...io.Writer) {
// and there is no further need to keep updating the value. // and there is no further need to keep updating the value.
rowsEstimate = totalRowsCopied rowsEstimate = totalRowsCopied
} }
var progressPct float64
if rowsEstimate == 0 {
progressPct = 100.0
} else {
progressPct = 100.0 * float64(totalRowsCopied) / float64(rowsEstimate)
}
// we take the opportunity to update migration context with progressPct // we take the opportunity to update migration context with progressPct
progressPct := this.getProgressPercent(rowsEstimate)
this.migrationContext.SetProgressPct(progressPct) this.migrationContext.SetProgressPct(progressPct)
// Before status, let's see if we should print a nice reminder for what exactly we're doing here. // Before status, let's see if we should print a nice reminder for what exactly we're doing here.
if this.shouldPrintMigrationStatusHint(rule, elapsedSeconds) { shouldPrintMigrationStatusHint := (elapsedSeconds%600 == 0)
if rule == ForcePrintStatusAndHintRule {
shouldPrintMigrationStatusHint = true
}
if rule == ForcePrintStatusOnlyRule {
shouldPrintMigrationStatusHint = false
}
if shouldPrintMigrationStatusHint {
this.printMigrationStatusHint(writers...) this.printMigrationStatusHint(writers...)
} }
// Get state + ETA var etaSeconds float64 = math.MaxFloat64
state, eta, etaDuration := this.getMigrationStateAndETA(rowsEstimate) var etaDuration = time.Duration(base.ETAUnknown)
if progressPct >= 100.0 {
etaDuration = 0
} else if progressPct >= 0.1 {
elapsedRowCopySeconds := this.migrationContext.ElapsedRowCopyTime().Seconds()
totalExpectedSeconds := elapsedRowCopySeconds * float64(rowsEstimate) / float64(totalRowsCopied)
etaSeconds = totalExpectedSeconds - elapsedRowCopySeconds
if etaSeconds >= 0 {
etaDuration = time.Duration(etaSeconds) * time.Second
} else {
etaDuration = 0
}
}
this.migrationContext.SetETADuration(etaDuration) this.migrationContext.SetETADuration(etaDuration)
var eta string
switch etaDuration {
case 0:
eta = "due"
case time.Duration(base.ETAUnknown):
eta = "N/A"
default:
eta = base.PrettifyDurationOutput(etaDuration)
}
if !this.shouldPrintStatus(rule, elapsedSeconds, etaDuration) { state := "migrating"
if atomic.LoadInt64(&this.migrationContext.CountingRowsFlag) > 0 && !this.migrationContext.ConcurrentCountTableRows {
state = "counting rows"
} else if atomic.LoadInt64(&this.migrationContext.IsPostponingCutOver) > 0 {
eta = "due"
state = "postponing cut-over"
} else if isThrottled, throttleReason, _ := this.migrationContext.IsThrottled(); isThrottled {
state = fmt.Sprintf("throttled, %s", throttleReason)
}
shouldPrintStatus := false
if rule == HeuristicPrintStatusRule {
if elapsedSeconds <= 60 {
shouldPrintStatus = true
} else if etaSeconds <= 60 {
shouldPrintStatus = true
} else if etaSeconds <= 180 {
shouldPrintStatus = (elapsedSeconds%5 == 0)
} else if elapsedSeconds <= 180 {
shouldPrintStatus = (elapsedSeconds%5 == 0)
} else if this.migrationContext.TimeSincePointOfInterest().Seconds() <= 60 {
shouldPrintStatus = (elapsedSeconds%5 == 0)
} else {
shouldPrintStatus = (elapsedSeconds%30 == 0)
}
} else {
// Not heuristic
shouldPrintStatus = true
}
if !shouldPrintStatus {
return return
} }
@ -1038,14 +1011,12 @@ func (this *Migrator) printStatus(rule PrintStatusRule, writers ...io.Writer) {
) )
this.applier.WriteChangelog( this.applier.WriteChangelog(
fmt.Sprintf("copy iteration %d at %d", this.migrationContext.GetIteration(), time.Now().Unix()), fmt.Sprintf("copy iteration %d at %d", this.migrationContext.GetIteration(), time.Now().Unix()),
state, status,
) )
w := io.MultiWriter(writers...) w := io.MultiWriter(writers...)
fmt.Fprintln(w, status) fmt.Fprintln(w, status)
this.migrationContext.Log.Infof(status)
hooksStatusIntervalSec := this.migrationContext.HooksStatusIntervalSec if elapsedSeconds%60 == 0 {
if hooksStatusIntervalSec > 0 && elapsedSeconds%hooksStatusIntervalSec == 0 {
this.hooksExecutor.onStatus(status) this.hooksExecutor.onStatus(status)
} }
} }
@ -1075,9 +1046,8 @@ func (this *Migrator) initiateStreaming() error {
}() }()
go func() { go func() {
ticker := time.NewTicker(time.Second) ticker := time.Tick(1 * time.Second)
defer ticker.Stop() for range ticker {
for range ticker.C {
if atomic.LoadInt64(&this.finishedMigrating) > 0 { if atomic.LoadInt64(&this.finishedMigrating) > 0 {
return return
} }
@ -1103,8 +1073,8 @@ func (this *Migrator) addDMLEventsListener() error {
} }
// initiateThrottler kicks in the throttling collection and the throttling checks. // initiateThrottler kicks in the throttling collection and the throttling checks.
func (this *Migrator) initiateThrottler() { func (this *Migrator) initiateThrottler() error {
this.throttler = NewThrottler(this.migrationContext, this.applier, this.inspector, this.appVersion) this.throttler = NewThrottler(this.migrationContext, this.applier, this.inspector)
go this.throttler.initiateThrottlerCollection(this.firstThrottlingCollected) go this.throttler.initiateThrottlerCollection(this.firstThrottlingCollected)
this.migrationContext.Log.Infof("Waiting for first throttle metrics to be collected") this.migrationContext.Log.Infof("Waiting for first throttle metrics to be collected")
@ -1113,6 +1083,8 @@ func (this *Migrator) initiateThrottler() {
<-this.firstThrottlingCollected // other, general metrics <-this.firstThrottlingCollected // other, general metrics
this.migrationContext.Log.Infof("First throttle metrics collected") this.migrationContext.Log.Infof("First throttle metrics collected")
go this.throttler.initiateThrottlerChecks() go this.throttler.initiateThrottlerChecks()
return nil
} }
func (this *Migrator) initiateApplier() error { func (this *Migrator) initiateApplier() error {
@ -1223,6 +1195,7 @@ func (this *Migrator) iterateChunks() error {
// Enqueue copy operation; to be executed by executeWriteFuncs() // Enqueue copy operation; to be executed by executeWriteFuncs()
this.copyRowsQueue <- copyRowsFunc this.copyRowsQueue <- copyRowsFunc
} }
return nil
} }
func (this *Migrator) onApplyEventStruct(eventStruct *applyEventStruct) error { func (this *Migrator) onApplyEventStruct(eventStruct *applyEventStruct) error {
@ -1313,7 +1286,7 @@ func (this *Migrator) executeWriteFuncs() error {
if niceRatio := this.migrationContext.GetNiceRatio(); niceRatio > 0 { if niceRatio := this.migrationContext.GetNiceRatio(); niceRatio > 0 {
copyRowsDuration := time.Since(copyRowsStartTime) copyRowsDuration := time.Since(copyRowsStartTime)
sleepTimeNanosecondFloat64 := niceRatio * float64(copyRowsDuration.Nanoseconds()) sleepTimeNanosecondFloat64 := niceRatio * float64(copyRowsDuration.Nanoseconds())
sleepTime := time.Duration(int64(sleepTimeNanosecondFloat64)) * time.Nanosecond sleepTime := time.Duration(time.Duration(int64(sleepTimeNanosecondFloat64)) * time.Nanosecond)
time.Sleep(sleepTime) time.Sleep(sleepTime)
} }
} }
@ -1328,17 +1301,13 @@ func (this *Migrator) executeWriteFuncs() error {
} }
} }
} }
return nil
} }
// finalCleanup takes actions at very end of migration, dropping tables etc. // finalCleanup takes actions at very end of migration, dropping tables etc.
func (this *Migrator) finalCleanup() error { func (this *Migrator) finalCleanup() error {
atomic.StoreInt64(&this.migrationContext.CleanupImminentFlag, 1) atomic.StoreInt64(&this.migrationContext.CleanupImminentFlag, 1)
this.migrationContext.Log.Infof("Writing changelog state: %+v", Migrated)
if _, err := this.applier.WriteChangelogState(string(Migrated)); err != nil {
return err
}
if this.migrationContext.Noop { if this.migrationContext.Noop {
if createTableStatement, err := this.inspector.showCreateTable(this.migrationContext.GetGhostTableName()); err == nil { if createTableStatement, err := this.inspector.showCreateTable(this.migrationContext.GetGhostTableName()); err == nil {
this.migrationContext.Log.Infof("New table structure follows") this.migrationContext.Log.Infof("New table structure follows")

View File

@ -1,256 +0,0 @@
/*
Copyright 2022 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE
*/
package logic
import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/openark/golib/tests"
"github.com/github/gh-ost/go/base"
"github.com/github/gh-ost/go/binlog"
"github.com/github/gh-ost/go/sql"
)
func TestMigratorOnChangelogEvent(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
t.Run("heartbeat", func(t *testing.T) {
columnValues := sql.ToColumnValues([]interface{}{
123,
time.Now().Unix(),
"heartbeat",
"2022-08-16T00:45:10.52Z",
})
tests.S(t).ExpectNil(migrator.onChangelogEvent(&binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.InsertDML,
NewColumnValues: columnValues,
}))
})
t.Run("state-AllEventsUpToLockProcessed", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
go func(wg *sync.WaitGroup) {
defer wg.Done()
es := <-migrator.applyEventsQueue
tests.S(t).ExpectNotNil(es)
tests.S(t).ExpectNotNil(es.writeFunc)
}(&wg)
columnValues := sql.ToColumnValues([]interface{}{
123,
time.Now().Unix(),
"state",
AllEventsUpToLockProcessed,
})
tests.S(t).ExpectNil(migrator.onChangelogEvent(&binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.InsertDML,
NewColumnValues: columnValues,
}))
wg.Wait()
})
t.Run("state-GhostTableMigrated", func(t *testing.T) {
go func() {
tests.S(t).ExpectTrue(<-migrator.ghostTableMigrated)
}()
columnValues := sql.ToColumnValues([]interface{}{
123,
time.Now().Unix(),
"state",
GhostTableMigrated,
})
tests.S(t).ExpectNil(migrator.onChangelogEvent(&binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.InsertDML,
NewColumnValues: columnValues,
}))
})
t.Run("state-Migrated", func(t *testing.T) {
columnValues := sql.ToColumnValues([]interface{}{
123,
time.Now().Unix(),
"state",
Migrated,
})
tests.S(t).ExpectNil(migrator.onChangelogEvent(&binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.InsertDML,
NewColumnValues: columnValues,
}))
})
t.Run("state-ReadMigrationRangeValues", func(t *testing.T) {
columnValues := sql.ToColumnValues([]interface{}{
123,
time.Now().Unix(),
"state",
ReadMigrationRangeValues,
})
tests.S(t).ExpectNil(migrator.onChangelogEvent(&binlog.BinlogDMLEvent{
DatabaseName: "test",
DML: binlog.InsertDML,
NewColumnValues: columnValues,
}))
})
}
func TestMigratorValidateStatement(t *testing.T) {
t.Run("add-column", func(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
tests.S(t).ExpectNil(migrator.parser.ParseAlterStatement(`ALTER TABLE test ADD test_new VARCHAR(64) NOT NULL`))
tests.S(t).ExpectNil(migrator.validateAlterStatement())
tests.S(t).ExpectEquals(len(migrator.migrationContext.DroppedColumnsMap), 0)
})
t.Run("drop-column", func(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
tests.S(t).ExpectNil(migrator.parser.ParseAlterStatement(`ALTER TABLE test DROP abc`))
tests.S(t).ExpectNil(migrator.validateAlterStatement())
tests.S(t).ExpectEquals(len(migrator.migrationContext.DroppedColumnsMap), 1)
_, exists := migrator.migrationContext.DroppedColumnsMap["abc"]
tests.S(t).ExpectTrue(exists)
})
t.Run("rename-column", func(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
tests.S(t).ExpectNil(migrator.parser.ParseAlterStatement(`ALTER TABLE test CHANGE test123 test1234 bigint unsigned`))
err := migrator.validateAlterStatement()
tests.S(t).ExpectNotNil(err)
tests.S(t).ExpectTrue(strings.HasPrefix(err.Error(), "gh-ost believes the ALTER statement renames columns"))
tests.S(t).ExpectEquals(len(migrator.migrationContext.DroppedColumnsMap), 0)
})
t.Run("rename-column-approved", func(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
migrator.migrationContext.ApproveRenamedColumns = true
tests.S(t).ExpectNil(migrator.parser.ParseAlterStatement(`ALTER TABLE test CHANGE test123 test1234 bigint unsigned`))
tests.S(t).ExpectNil(migrator.validateAlterStatement())
tests.S(t).ExpectEquals(len(migrator.migrationContext.DroppedColumnsMap), 0)
})
t.Run("rename-table", func(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
tests.S(t).ExpectNil(migrator.parser.ParseAlterStatement(`ALTER TABLE test RENAME TO test_new`))
err := migrator.validateAlterStatement()
tests.S(t).ExpectNotNil(err)
tests.S(t).ExpectTrue(errors.Is(err, ErrMigratorUnsupportedRenameAlter))
tests.S(t).ExpectEquals(len(migrator.migrationContext.DroppedColumnsMap), 0)
})
}
func TestMigratorCreateFlagFiles(t *testing.T) {
tmpdir, err := os.MkdirTemp("", t.Name())
if err != nil {
panic(err)
}
defer os.RemoveAll(tmpdir)
migrationContext := base.NewMigrationContext()
migrationContext.PostponeCutOverFlagFile = filepath.Join(tmpdir, "cut-over.flag")
migrator := NewMigrator(migrationContext, "1.2.3")
tests.S(t).ExpectNil(migrator.createFlagFiles())
tests.S(t).ExpectNil(migrator.createFlagFiles()) // twice to test already-exists
_, err = os.Stat(migrationContext.PostponeCutOverFlagFile)
tests.S(t).ExpectNil(err)
}
func TestMigratorGetProgressPercent(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
{
tests.S(t).ExpectEquals(migrator.getProgressPercent(0), float64(100.0))
}
{
migrationContext.TotalRowsCopied = 250
tests.S(t).ExpectEquals(migrator.getProgressPercent(1000), float64(25.0))
}
}
func TestMigratorGetMigrationStateAndETA(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
now := time.Now()
migrationContext.RowCopyStartTime = now.Add(-time.Minute)
migrationContext.RowCopyEndTime = now
{
migrationContext.TotalRowsCopied = 456
state, eta, etaDuration := migrator.getMigrationStateAndETA(123456)
tests.S(t).ExpectEquals(state, "migrating")
tests.S(t).ExpectEquals(eta, "4h29m44s")
tests.S(t).ExpectEquals(etaDuration.String(), "4h29m44s")
}
{
migrationContext.TotalRowsCopied = 456
state, eta, etaDuration := migrator.getMigrationStateAndETA(456)
tests.S(t).ExpectEquals(state, "migrating")
tests.S(t).ExpectEquals(eta, "due")
tests.S(t).ExpectEquals(etaDuration.String(), "0s")
}
{
migrationContext.TotalRowsCopied = 123456
state, eta, etaDuration := migrator.getMigrationStateAndETA(456)
tests.S(t).ExpectEquals(state, "migrating")
tests.S(t).ExpectEquals(eta, "due")
tests.S(t).ExpectEquals(etaDuration.String(), "0s")
}
{
atomic.StoreInt64(&migrationContext.CountingRowsFlag, 1)
state, eta, etaDuration := migrator.getMigrationStateAndETA(123456)
tests.S(t).ExpectEquals(state, "counting rows")
tests.S(t).ExpectEquals(eta, "due")
tests.S(t).ExpectEquals(etaDuration.String(), "0s")
}
{
atomic.StoreInt64(&migrationContext.CountingRowsFlag, 0)
atomic.StoreInt64(&migrationContext.IsPostponingCutOver, 1)
state, eta, etaDuration := migrator.getMigrationStateAndETA(123456)
tests.S(t).ExpectEquals(state, "postponing cut-over")
tests.S(t).ExpectEquals(eta, "due")
tests.S(t).ExpectEquals(etaDuration.String(), "0s")
}
}
func TestMigratorShouldPrintStatus(t *testing.T) {
migrationContext := base.NewMigrationContext()
migrator := NewMigrator(migrationContext, "1.2.3")
tests.S(t).ExpectTrue(migrator.shouldPrintStatus(NoPrintStatusRule, 10, time.Second)) // test 'rule != HeuristicPrintStatusRule' return
tests.S(t).ExpectTrue(migrator.shouldPrintStatus(HeuristicPrintStatusRule, 10, time.Second)) // test 'etaDuration.Seconds() <= 60'
tests.S(t).ExpectTrue(migrator.shouldPrintStatus(HeuristicPrintStatusRule, 90, time.Second)) // test 'etaDuration.Seconds() <= 60' again
tests.S(t).ExpectTrue(migrator.shouldPrintStatus(HeuristicPrintStatusRule, 90, time.Minute)) // test 'etaDuration.Seconds() <= 180'
tests.S(t).ExpectTrue(migrator.shouldPrintStatus(HeuristicPrintStatusRule, 60, 90*time.Second)) // test 'elapsedSeconds <= 180'
tests.S(t).ExpectFalse(migrator.shouldPrintStatus(HeuristicPrintStatusRule, 61, 90*time.Second)) // test 'elapsedSeconds <= 180'
tests.S(t).ExpectFalse(migrator.shouldPrintStatus(HeuristicPrintStatusRule, 99, 210*time.Second)) // test 'elapsedSeconds <= 180'
tests.S(t).ExpectFalse(migrator.shouldPrintStatus(HeuristicPrintStatusRule, 12345, 86400*time.Second)) // test 'else'
tests.S(t).ExpectTrue(migrator.shouldPrintStatus(HeuristicPrintStatusRule, 30030, 86400*time.Second)) // test 'else' again
}

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2021 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -122,6 +122,8 @@ func (this *Server) onServerCommand(command string, writer *bufio.Writer) (err e
// applyServerCommand parses and executes commands by user // applyServerCommand parses and executes commands by user
func (this *Server) applyServerCommand(command string, writer *bufio.Writer) (printStatusRule PrintStatusRule, err error) { func (this *Server) applyServerCommand(command string, writer *bufio.Writer) (printStatusRule PrintStatusRule, err error) {
printStatusRule = NoPrintStatusRule
tokens := strings.SplitN(command, "=", 2) tokens := strings.SplitN(command, "=", 2)
command = strings.TrimSpace(tokens[0]) command = strings.TrimSpace(tokens[0])
arg := "" arg := ""
@ -132,7 +134,7 @@ func (this *Server) applyServerCommand(command string, writer *bufio.Writer) (pr
} }
} }
argIsQuestion := (arg == "?") argIsQuestion := (arg == "?")
throttleHint := "# Note: you may only throttle for as long as your binary logs are not purged" throttleHint := "# Note: you may only throttle for as long as your binary logs are not purged\n"
if err := this.hooksExecutor.onInteractiveCommand(command); err != nil { if err := this.hooksExecutor.onInteractiveCommand(command); err != nil {
return NoPrintStatusRule, err return NoPrintStatusRule, err
@ -280,7 +282,7 @@ help # This message
return NoPrintStatusRule, nil return NoPrintStatusRule, nil
} }
this.migrationContext.SetThrottleQuery(arg) this.migrationContext.SetThrottleQuery(arg)
fmt.Fprintln(writer, throttleHint) fmt.Fprintf(writer, throttleHint)
return ForcePrintStatusAndHintRule, nil return ForcePrintStatusAndHintRule, nil
} }
case "throttle-http": case "throttle-http":
@ -290,7 +292,7 @@ help # This message
return NoPrintStatusRule, nil return NoPrintStatusRule, nil
} }
this.migrationContext.SetThrottleHTTP(arg) this.migrationContext.SetThrottleHTTP(arg)
fmt.Fprintln(writer, throttleHint) fmt.Fprintf(writer, throttleHint)
return ForcePrintStatusAndHintRule, nil return ForcePrintStatusAndHintRule, nil
} }
case "throttle-control-replicas": case "throttle-control-replicas":
@ -313,7 +315,7 @@ help # This message
return NoPrintStatusRule, err return NoPrintStatusRule, err
} }
atomic.StoreInt64(&this.migrationContext.ThrottleCommandedByUser, 1) atomic.StoreInt64(&this.migrationContext.ThrottleCommandedByUser, 1)
fmt.Fprintln(writer, throttleHint) fmt.Fprintf(writer, throttleHint)
return ForcePrintStatusAndHintRule, nil return ForcePrintStatusAndHintRule, nil
} }
case "no-throttle", "unthrottle", "resume", "continue": case "no-throttle", "unthrottle", "resume", "continue":

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -59,6 +59,7 @@ func NewEventsStreamer(migrationContext *base.MigrationContext) *EventsStreamer
// AddListener registers a new listener for binlog events, on a per-table basis // AddListener registers a new listener for binlog events, on a per-table basis
func (this *EventsStreamer) AddListener( func (this *EventsStreamer) AddListener(
async bool, databaseName string, tableName string, onDmlEvent func(event *binlog.BinlogDMLEvent) error) (err error) { async bool, databaseName string, tableName string, onDmlEvent func(event *binlog.BinlogDMLEvent) error) (err error) {
this.listenersMutex.Lock() this.listenersMutex.Lock()
defer this.listenersMutex.Unlock() defer this.listenersMutex.Unlock()
@ -86,10 +87,10 @@ func (this *EventsStreamer) notifyListeners(binlogEvent *binlog.BinlogDMLEvent)
for _, listener := range this.listeners { for _, listener := range this.listeners {
listener := listener listener := listener
if !strings.EqualFold(listener.databaseName, binlogEvent.DatabaseName) { if strings.ToLower(listener.databaseName) != strings.ToLower(binlogEvent.DatabaseName) {
continue continue
} }
if !strings.EqualFold(listener.tableName, binlogEvent.TableName) { if strings.ToLower(listener.tableName) != strings.ToLower(binlogEvent.TableName) {
continue continue
} }
if listener.async { if listener.async {
@ -122,7 +123,10 @@ func (this *EventsStreamer) InitDBConnections() (err error) {
// initBinlogReader creates and connects the reader: we hook up to a MySQL server as a replica // initBinlogReader creates and connects the reader: we hook up to a MySQL server as a replica
func (this *EventsStreamer) initBinlogReader(binlogCoordinates *mysql.BinlogCoordinates) error { func (this *EventsStreamer) initBinlogReader(binlogCoordinates *mysql.BinlogCoordinates) error {
goMySQLReader := binlog.NewGoMySQLReader(this.migrationContext) goMySQLReader, err := binlog.NewGoMySQLReader(this.migrationContext)
if err != nil {
return err
}
if err := goMySQLReader.ConnectBinlogStreamer(*binlogCoordinates); err != nil { if err := goMySQLReader.ConnectBinlogStreamer(*binlogCoordinates); err != nil {
return err return err
} }
@ -216,4 +220,5 @@ func (this *EventsStreamer) Close() (err error) {
func (this *EventsStreamer) Teardown() { func (this *EventsStreamer) Teardown() {
this.db.Close() this.db.Close()
return
} }

View File

@ -1,12 +1,11 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
package logic package logic
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -43,22 +42,16 @@ const frenoMagicHint = "freno"
// Throttler collects metrics related to throttling and makes informed decision // Throttler collects metrics related to throttling and makes informed decision
// whether throttling should take place. // whether throttling should take place.
type Throttler struct { type Throttler struct {
appVersion string
migrationContext *base.MigrationContext migrationContext *base.MigrationContext
applier *Applier applier *Applier
httpClient *http.Client
httpClientTimeout time.Duration
inspector *Inspector inspector *Inspector
finishedMigrating int64 finishedMigrating int64
} }
func NewThrottler(migrationContext *base.MigrationContext, applier *Applier, inspector *Inspector, appVersion string) *Throttler { func NewThrottler(migrationContext *base.MigrationContext, applier *Applier, inspector *Inspector) *Throttler {
return &Throttler{ return &Throttler{
appVersion: appVersion,
migrationContext: migrationContext, migrationContext: migrationContext,
applier: applier, applier: applier,
httpClient: &http.Client{},
httpClientTimeout: time.Duration(migrationContext.ThrottleHTTPTimeoutMillis) * time.Millisecond,
inspector: inspector, inspector: inspector,
finishedMigrating: 0, finishedMigrating: 0,
} }
@ -168,9 +161,8 @@ func (this *Throttler) collectReplicationLag(firstThrottlingCollected chan<- boo
collectFunc() collectFunc()
firstThrottlingCollected <- true firstThrottlingCollected <- true
ticker := time.NewTicker(time.Duration(this.migrationContext.HeartbeatIntervalMilliseconds) * time.Millisecond) ticker := time.Tick(time.Duration(this.migrationContext.HeartbeatIntervalMilliseconds) * time.Millisecond)
defer ticker.Stop() for range ticker {
for range ticker.C {
if atomic.LoadInt64(&this.finishedMigrating) > 0 { if atomic.LoadInt64(&this.finishedMigrating) > 0 {
return return
} }
@ -180,6 +172,7 @@ func (this *Throttler) collectReplicationLag(firstThrottlingCollected chan<- boo
// collectControlReplicasLag polls all the control replicas to get maximum lag value // collectControlReplicasLag polls all the control replicas to get maximum lag value
func (this *Throttler) collectControlReplicasLag() { func (this *Throttler) collectControlReplicasLag() {
if atomic.LoadInt64(&this.migrationContext.HibernateUntil) > 0 { if atomic.LoadInt64(&this.migrationContext.HibernateUntil) > 0 {
return return
} }
@ -244,14 +237,12 @@ func (this *Throttler) collectControlReplicasLag() {
} }
this.migrationContext.SetControlReplicasLagResult(readControlReplicasLag()) this.migrationContext.SetControlReplicasLagResult(readControlReplicasLag())
} }
aggressiveTicker := time.Tick(100 * time.Millisecond)
relaxedFactor := 10 relaxedFactor := 10
counter := 0 counter := 0
shouldReadLagAggressively := false shouldReadLagAggressively := false
ticker := time.NewTicker(100 * time.Millisecond) for range aggressiveTicker {
defer ticker.Stop()
for range ticker.C {
if atomic.LoadInt64(&this.finishedMigrating) > 0 { if atomic.LoadInt64(&this.finishedMigrating) > 0 {
return return
} }
@ -294,22 +285,10 @@ func (this *Throttler) collectThrottleHTTPStatus(firstThrottlingCollected chan<-
if url == "" { if url == "" {
return true, nil return true, nil
} }
resp, err := http.Head(url)
ctx, cancel := context.WithTimeout(context.Background(), this.httpClientTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil { if err != nil {
return false, err return false, err
} }
req.Header.Set("User-Agent", fmt.Sprintf("gh-ost/%s", this.appVersion))
resp, err := this.httpClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
atomic.StoreInt64(&this.migrationContext.ThrottleHTTPStatusCode, int64(resp.StatusCode)) atomic.StoreInt64(&this.migrationContext.ThrottleHTTPStatusCode, int64(resp.StatusCode))
return false, nil return false, nil
} }
@ -324,10 +303,8 @@ func (this *Throttler) collectThrottleHTTPStatus(firstThrottlingCollected chan<-
firstThrottlingCollected <- true firstThrottlingCollected <- true
collectInterval := time.Duration(this.migrationContext.ThrottleHTTPIntervalMillis) * time.Millisecond ticker := time.Tick(100 * time.Millisecond)
ticker := time.NewTicker(collectInterval) for range ticker {
defer ticker.Stop()
for range ticker.C {
if atomic.LoadInt64(&this.finishedMigrating) > 0 { if atomic.LoadInt64(&this.finishedMigrating) > 0 {
return return
} }
@ -446,9 +423,8 @@ func (this *Throttler) initiateThrottlerCollection(firstThrottlingCollected chan
this.collectGeneralThrottleMetrics() this.collectGeneralThrottleMetrics()
firstThrottlingCollected <- true firstThrottlingCollected <- true
ticker := time.NewTicker(time.Second) throttlerMetricsTick := time.Tick(1 * time.Second)
defer ticker.Stop() for range throttlerMetricsTick {
for range ticker.C {
if atomic.LoadInt64(&this.finishedMigrating) > 0 { if atomic.LoadInt64(&this.finishedMigrating) > 0 {
return return
} }
@ -459,7 +435,9 @@ func (this *Throttler) initiateThrottlerCollection(firstThrottlingCollected chan
} }
// initiateThrottlerChecks initiates the throttle ticker and sets the basic behavior of throttling. // initiateThrottlerChecks initiates the throttle ticker and sets the basic behavior of throttling.
func (this *Throttler) initiateThrottlerChecks() { func (this *Throttler) initiateThrottlerChecks() error {
throttlerTick := time.Tick(100 * time.Millisecond)
throttlerFunction := func() { throttlerFunction := func() {
alreadyThrottling, currentReason, _ := this.migrationContext.IsThrottled() alreadyThrottling, currentReason, _ := this.migrationContext.IsThrottled()
shouldThrottle, throttleReason, throttleReasonHint := this.shouldThrottle() shouldThrottle, throttleReason, throttleReasonHint := this.shouldThrottle()
@ -476,15 +454,14 @@ func (this *Throttler) initiateThrottlerChecks() {
this.migrationContext.SetThrottled(shouldThrottle, throttleReason, throttleReasonHint) this.migrationContext.SetThrottled(shouldThrottle, throttleReason, throttleReasonHint)
} }
throttlerFunction() throttlerFunction()
for range throttlerTick {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
if atomic.LoadInt64(&this.finishedMigrating) > 0 { if atomic.LoadInt64(&this.finishedMigrating) > 0 {
return return nil
} }
throttlerFunction() throttlerFunction()
} }
return nil
} }
// throttle sees if throttling needs take place, and if so, continuously sleeps (blocks) // throttle sees if throttling needs take place, and if so, continuously sleeps (blocks)

View File

@ -1,21 +1,36 @@
/* /*
Copyright 2015 Shlomi Noach, courtesy Booking.com Copyright 2015 Shlomi Noach, courtesy Booking.com
Copyright 2022 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
package mysql package mysql
import ( import (
"errors"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
) )
var detachPattern *regexp.Regexp
func init() {
detachPattern, _ = regexp.Compile(`//([^/:]+):([\d]+)`) // e.g. `//binlog.01234:567890`
}
type BinlogType int
const (
BinaryLog BinlogType = iota
RelayLog
)
// BinlogCoordinates described binary log coordinates in the form of log file & log position. // BinlogCoordinates described binary log coordinates in the form of log file & log position.
type BinlogCoordinates struct { type BinlogCoordinates struct {
LogFile string LogFile string
LogPos int64 LogPos int64
Type BinlogType
} }
// ParseInstanceKey will parse an InstanceKey from a string representation such as 127.0.0.1:3306 // ParseInstanceKey will parse an InstanceKey from a string representation such as 127.0.0.1:3306
@ -47,7 +62,7 @@ func (this *BinlogCoordinates) Equals(other *BinlogCoordinates) bool {
if other == nil { if other == nil {
return false return false
} }
return this.LogFile == other.LogFile && this.LogPos == other.LogPos return this.LogFile == other.LogFile && this.LogPos == other.LogPos && this.Type == other.Type
} }
// IsEmpty returns true if the log file is empty, unnamed // IsEmpty returns true if the log file is empty, unnamed
@ -72,5 +87,76 @@ func (this *BinlogCoordinates) SmallerThanOrEquals(other *BinlogCoordinates) boo
if this.SmallerThan(other) { if this.SmallerThan(other) {
return true return true
} }
return this.LogFile == other.LogFile && this.LogPos == other.LogPos return this.LogFile == other.LogFile && this.LogPos == other.LogPos // No Type comparison
}
// FileSmallerThan returns true if this coordinate's file is strictly smaller than the other's.
func (this *BinlogCoordinates) FileSmallerThan(other *BinlogCoordinates) bool {
return this.LogFile < other.LogFile
}
// FileNumberDistance returns the numeric distance between this coordinate's file number and the other's.
// Effectively it means "how many rotates/FLUSHes would make these coordinates's file reach the other's"
func (this *BinlogCoordinates) FileNumberDistance(other *BinlogCoordinates) int {
thisNumber, _ := this.FileNumber()
otherNumber, _ := other.FileNumber()
return otherNumber - thisNumber
}
// FileNumber returns the numeric value of the file, and the length in characters representing the number in the filename.
// Example: FileNumber() of mysqld.log.000789 is (789, 6)
func (this *BinlogCoordinates) FileNumber() (int, int) {
tokens := strings.Split(this.LogFile, ".")
numPart := tokens[len(tokens)-1]
numLen := len(numPart)
fileNum, err := strconv.Atoi(numPart)
if err != nil {
return 0, 0
}
return fileNum, numLen
}
// PreviousFileCoordinatesBy guesses the filename of the previous binlog/relaylog, by given offset (number of files back)
func (this *BinlogCoordinates) PreviousFileCoordinatesBy(offset int) (BinlogCoordinates, error) {
result := BinlogCoordinates{LogPos: 0, Type: this.Type}
fileNum, numLen := this.FileNumber()
if fileNum == 0 {
return result, errors.New("Log file number is zero, cannot detect previous file")
}
newNumStr := fmt.Sprintf("%d", (fileNum - offset))
newNumStr = strings.Repeat("0", numLen-len(newNumStr)) + newNumStr
tokens := strings.Split(this.LogFile, ".")
tokens[len(tokens)-1] = newNumStr
result.LogFile = strings.Join(tokens, ".")
return result, nil
}
// PreviousFileCoordinates guesses the filename of the previous binlog/relaylog
func (this *BinlogCoordinates) PreviousFileCoordinates() (BinlogCoordinates, error) {
return this.PreviousFileCoordinatesBy(1)
}
// PreviousFileCoordinates guesses the filename of the previous binlog/relaylog
func (this *BinlogCoordinates) NextFileCoordinates() (BinlogCoordinates, error) {
result := BinlogCoordinates{LogPos: 0, Type: this.Type}
fileNum, numLen := this.FileNumber()
newNumStr := fmt.Sprintf("%d", (fileNum + 1))
newNumStr = strings.Repeat("0", numLen-len(newNumStr)) + newNumStr
tokens := strings.Split(this.LogFile, ".")
tokens[len(tokens)-1] = newNumStr
result.LogFile = strings.Join(tokens, ".")
return result, nil
}
// FileSmallerThan returns true if this coordinate's file is strictly smaller than the other's.
func (this *BinlogCoordinates) DetachedCoordinates() (isDetached bool, detachedLogFile string, detachedLogPos string) {
detachedCoordinatesSubmatch := detachPattern.FindStringSubmatch(this.LogFile)
if len(detachedCoordinatesSubmatch) == 0 {
return false, "", ""
}
return true, detachedCoordinatesSubmatch[1], detachedCoordinatesSubmatch[2]
} }

View File

@ -37,6 +37,57 @@ func TestBinlogCoordinates(t *testing.T) {
test.S(t).ExpectTrue(c1.SmallerThanOrEquals(&c3)) test.S(t).ExpectTrue(c1.SmallerThanOrEquals(&c3))
} }
func TestBinlogNext(t *testing.T) {
c1 := BinlogCoordinates{LogFile: "mysql-bin.00017", LogPos: 104}
cres, err := c1.NextFileCoordinates()
test.S(t).ExpectNil(err)
test.S(t).ExpectEquals(c1.Type, cres.Type)
test.S(t).ExpectEquals(cres.LogFile, "mysql-bin.00018")
c2 := BinlogCoordinates{LogFile: "mysql-bin.00099", LogPos: 104}
cres, err = c2.NextFileCoordinates()
test.S(t).ExpectNil(err)
test.S(t).ExpectEquals(c1.Type, cres.Type)
test.S(t).ExpectEquals(cres.LogFile, "mysql-bin.00100")
c3 := BinlogCoordinates{LogFile: "mysql.00.prod.com.00099", LogPos: 104}
cres, err = c3.NextFileCoordinates()
test.S(t).ExpectNil(err)
test.S(t).ExpectEquals(c1.Type, cres.Type)
test.S(t).ExpectEquals(cres.LogFile, "mysql.00.prod.com.00100")
}
func TestBinlogPrevious(t *testing.T) {
c1 := BinlogCoordinates{LogFile: "mysql-bin.00017", LogPos: 104}
cres, err := c1.PreviousFileCoordinates()
test.S(t).ExpectNil(err)
test.S(t).ExpectEquals(c1.Type, cres.Type)
test.S(t).ExpectEquals(cres.LogFile, "mysql-bin.00016")
c2 := BinlogCoordinates{LogFile: "mysql-bin.00100", LogPos: 104}
cres, err = c2.PreviousFileCoordinates()
test.S(t).ExpectNil(err)
test.S(t).ExpectEquals(c1.Type, cres.Type)
test.S(t).ExpectEquals(cres.LogFile, "mysql-bin.00099")
c3 := BinlogCoordinates{LogFile: "mysql.00.prod.com.00100", LogPos: 104}
cres, err = c3.PreviousFileCoordinates()
test.S(t).ExpectNil(err)
test.S(t).ExpectEquals(c1.Type, cres.Type)
test.S(t).ExpectEquals(cres.LogFile, "mysql.00.prod.com.00099")
c4 := BinlogCoordinates{LogFile: "mysql.00.prod.com.00000", LogPos: 104}
_, err = c4.PreviousFileCoordinates()
test.S(t).ExpectNotNil(err)
}
func TestBinlogCoordinatesAsKey(t *testing.T) { func TestBinlogCoordinatesAsKey(t *testing.T) {
m := make(map[BinlogCoordinates]bool) m := make(map[BinlogCoordinates]bool)
@ -52,3 +103,20 @@ func TestBinlogCoordinatesAsKey(t *testing.T) {
test.S(t).ExpectEquals(len(m), 3) test.S(t).ExpectEquals(len(m), 3)
} }
func TestBinlogFileNumber(t *testing.T) {
c1 := BinlogCoordinates{LogFile: "mysql-bin.00017", LogPos: 104}
c2 := BinlogCoordinates{LogFile: "mysql-bin.00022", LogPos: 104}
test.S(t).ExpectEquals(c1.FileNumberDistance(&c1), 0)
test.S(t).ExpectEquals(c1.FileNumberDistance(&c2), 5)
test.S(t).ExpectEquals(c2.FileNumberDistance(&c1), -5)
}
func TestBinlogFileNumberDistance(t *testing.T) {
c1 := BinlogCoordinates{LogFile: "mysql-bin.00017", LogPos: 104}
fileNum, numLen := c1.FileNumber()
test.S(t).ExpectEquals(fileNum, 17)
test.S(t).ExpectEquals(numLen, 5)
}

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -12,7 +12,6 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net" "net"
"strings"
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
) )
@ -23,13 +22,12 @@ const (
// ConnectionConfig is the minimal configuration required to connect to a MySQL server // ConnectionConfig is the minimal configuration required to connect to a MySQL server
type ConnectionConfig struct { type ConnectionConfig struct {
Key InstanceKey Key InstanceKey
User string User string
Password string Password string
ImpliedKey *InstanceKey ImpliedKey *InstanceKey
tlsConfig *tls.Config tlsConfig *tls.Config
Timeout float64 Timeout float64
TransactionIsolation string
} }
func NewConnectionConfig() *ConnectionConfig { func NewConnectionConfig() *ConnectionConfig {
@ -43,12 +41,11 @@ func NewConnectionConfig() *ConnectionConfig {
// DuplicateCredentials creates a new connection config with given key and with same credentials as this config // DuplicateCredentials creates a new connection config with given key and with same credentials as this config
func (this *ConnectionConfig) DuplicateCredentials(key InstanceKey) *ConnectionConfig { func (this *ConnectionConfig) DuplicateCredentials(key InstanceKey) *ConnectionConfig {
config := &ConnectionConfig{ config := &ConnectionConfig{
Key: key, Key: key,
User: this.User, User: this.User,
Password: this.Password, Password: this.Password,
tlsConfig: this.tlsConfig, tlsConfig: this.tlsConfig,
Timeout: this.Timeout, Timeout: this.Timeout,
TransactionIsolation: this.TransactionIsolation,
} }
config.ImpliedKey = &config.Key config.ImpliedKey = &config.Key
return config return config
@ -115,23 +112,12 @@ func (this *ConnectionConfig) GetDBUri(databaseName string) string {
// Wrap IPv6 literals in square brackets // Wrap IPv6 literals in square brackets
hostname = fmt.Sprintf("[%s]", hostname) hostname = fmt.Sprintf("[%s]", hostname)
} }
interpolateParams := true
// go-mysql-driver defaults to false if tls param is not provided; explicitly setting here to // go-mysql-driver defaults to false if tls param is not provided; explicitly setting here to
// simplify construction of the DSN below. // simplify construction of the DSN below.
tlsOption := "false" tlsOption := "false"
if this.tlsConfig != nil { if this.tlsConfig != nil {
tlsOption = TLS_CONFIG_KEY tlsOption = TLS_CONFIG_KEY
} }
connectionParams := []string{ return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?timeout=%fs&readTimeout=%fs&writeTimeout=%fs&interpolateParams=%t&autocommit=true&charset=utf8mb4,utf8,latin1&tls=%s", this.User, this.Password, hostname, this.Key.Port, databaseName, this.Timeout, this.Timeout, this.Timeout, interpolateParams, tlsOption)
"autocommit=true",
"charset=utf8mb4,utf8,latin1",
"interpolateParams=true",
fmt.Sprintf("tls=%s", tlsOption),
fmt.Sprintf("transaction_isolation=%q", this.TransactionIsolation),
fmt.Sprintf("timeout=%fs", this.Timeout),
fmt.Sprintf("readTimeout=%fs", this.Timeout),
fmt.Sprintf("writeTimeout=%fs", this.Timeout),
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", this.User, this.Password, hostname, this.Key.Port, databaseName, strings.Join(connectionParams, "&"))
} }

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -13,10 +13,6 @@ import (
test "github.com/openark/golib/tests" test "github.com/openark/golib/tests"
) )
const (
transactionIsolation = "REPEATABLE-READ"
)
func init() { func init() {
log.SetLevel(log.ERROR) log.SetLevel(log.ERROR)
} }
@ -29,7 +25,6 @@ func TestNewConnectionConfig(t *testing.T) {
test.S(t).ExpectEquals(c.ImpliedKey.Port, 0) test.S(t).ExpectEquals(c.ImpliedKey.Port, 0)
test.S(t).ExpectEquals(c.User, "") test.S(t).ExpectEquals(c.User, "")
test.S(t).ExpectEquals(c.Password, "") test.S(t).ExpectEquals(c.Password, "")
test.S(t).ExpectEquals(c.TransactionIsolation, "")
} }
func TestDuplicateCredentials(t *testing.T) { func TestDuplicateCredentials(t *testing.T) {
@ -41,7 +36,6 @@ func TestDuplicateCredentials(t *testing.T) {
InsecureSkipVerify: true, InsecureSkipVerify: true,
ServerName: "feathers", ServerName: "feathers",
} }
c.TransactionIsolation = transactionIsolation
dup := c.DuplicateCredentials(InstanceKey{Hostname: "otherhost", Port: 3310}) dup := c.DuplicateCredentials(InstanceKey{Hostname: "otherhost", Port: 3310})
test.S(t).ExpectEquals(dup.Key.Hostname, "otherhost") test.S(t).ExpectEquals(dup.Key.Hostname, "otherhost")
@ -51,7 +45,6 @@ func TestDuplicateCredentials(t *testing.T) {
test.S(t).ExpectEquals(dup.User, "gromit") test.S(t).ExpectEquals(dup.User, "gromit")
test.S(t).ExpectEquals(dup.Password, "penguin") test.S(t).ExpectEquals(dup.Password, "penguin")
test.S(t).ExpectEquals(dup.tlsConfig, c.tlsConfig) test.S(t).ExpectEquals(dup.tlsConfig, c.tlsConfig)
test.S(t).ExpectEquals(dup.TransactionIsolation, c.TransactionIsolation)
} }
func TestDuplicate(t *testing.T) { func TestDuplicate(t *testing.T) {
@ -59,7 +52,6 @@ func TestDuplicate(t *testing.T) {
c.Key = InstanceKey{Hostname: "myhost", Port: 3306} c.Key = InstanceKey{Hostname: "myhost", Port: 3306}
c.User = "gromit" c.User = "gromit"
c.Password = "penguin" c.Password = "penguin"
c.TransactionIsolation = transactionIsolation
dup := c.Duplicate() dup := c.Duplicate()
test.S(t).ExpectEquals(dup.Key.Hostname, "myhost") test.S(t).ExpectEquals(dup.Key.Hostname, "myhost")
@ -68,7 +60,6 @@ func TestDuplicate(t *testing.T) {
test.S(t).ExpectEquals(dup.ImpliedKey.Port, 3306) test.S(t).ExpectEquals(dup.ImpliedKey.Port, 3306)
test.S(t).ExpectEquals(dup.User, "gromit") test.S(t).ExpectEquals(dup.User, "gromit")
test.S(t).ExpectEquals(dup.Password, "penguin") test.S(t).ExpectEquals(dup.Password, "penguin")
test.S(t).ExpectEquals(dup.TransactionIsolation, transactionIsolation)
} }
func TestGetDBUri(t *testing.T) { func TestGetDBUri(t *testing.T) {
@ -76,11 +67,9 @@ func TestGetDBUri(t *testing.T) {
c.Key = InstanceKey{Hostname: "myhost", Port: 3306} c.Key = InstanceKey{Hostname: "myhost", Port: 3306}
c.User = "gromit" c.User = "gromit"
c.Password = "penguin" c.Password = "penguin"
c.Timeout = 1.2345
c.TransactionIsolation = transactionIsolation
uri := c.GetDBUri("test") uri := c.GetDBUri("test")
test.S(t).ExpectEquals(uri, `gromit:penguin@tcp(myhost:3306)/test?autocommit=true&charset=utf8mb4,utf8,latin1&interpolateParams=true&tls=false&transaction_isolation="REPEATABLE-READ"&timeout=1.234500s&readTimeout=1.234500s&writeTimeout=1.234500s`) test.S(t).ExpectEquals(uri, "gromit:penguin@tcp(myhost:3306)/test?timeout=0.000000s&readTimeout=0.000000s&writeTimeout=0.000000s&interpolateParams=true&autocommit=true&charset=utf8mb4,utf8,latin1&tls=false")
} }
func TestGetDBUriWithTLSSetup(t *testing.T) { func TestGetDBUriWithTLSSetup(t *testing.T) {
@ -88,10 +77,8 @@ func TestGetDBUriWithTLSSetup(t *testing.T) {
c.Key = InstanceKey{Hostname: "myhost", Port: 3306} c.Key = InstanceKey{Hostname: "myhost", Port: 3306}
c.User = "gromit" c.User = "gromit"
c.Password = "penguin" c.Password = "penguin"
c.Timeout = 1.2345
c.tlsConfig = &tls.Config{} c.tlsConfig = &tls.Config{}
c.TransactionIsolation = transactionIsolation
uri := c.GetDBUri("test") uri := c.GetDBUri("test")
test.S(t).ExpectEquals(uri, `gromit:penguin@tcp(myhost:3306)/test?autocommit=true&charset=utf8mb4,utf8,latin1&interpolateParams=true&tls=ghost&transaction_isolation="REPEATABLE-READ"&timeout=1.234500s&readTimeout=1.234500s&writeTimeout=1.234500s`) test.S(t).ExpectEquals(uri, "gromit:penguin@tcp(myhost:3306)/test?timeout=0.000000s&readTimeout=0.000000s&writeTimeout=0.000000s&interpolateParams=true&autocommit=true&charset=utf8mb4,utf8,latin1&tls=ghost")
} }

View File

@ -1,6 +1,5 @@
/* /*
Copyright 2015 Shlomi Noach, courtesy Booking.com Copyright 2015 Shlomi Noach, courtesy Booking.com
Copyright 2022 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -13,16 +12,15 @@ import (
"strings" "strings"
) )
const DefaultInstancePort = 3306 const (
DefaultInstancePort = 3306
)
var ( var (
ipv4HostPortRegexp = regexp.MustCompile("^([^:]+):([0-9]+)$") ipv4HostPortRegexp = regexp.MustCompile("^([^:]+):([0-9]+)$")
ipv4HostRegexp = regexp.MustCompile("^([^:]+)$") ipv4HostRegexp = regexp.MustCompile("^([^:]+)$")
ipv6HostPortRegexp = regexp.MustCompile("^\\[([:0-9a-fA-F]+)\\]:([0-9]+)$") // e.g. [2001:db8:1f70::999:de8:7648:6e8]:3308
// e.g. [2001:db8:1f70::999:de8:7648:6e8]:3308 ipv6HostRegexp = regexp.MustCompile("^([:0-9a-fA-F]+)$") // e.g. 2001:db8:1f70::999:de8:7648:6e8
ipv6HostPortRegexp = regexp.MustCompile("^\\[([:0-9a-fA-F]+)\\]:([0-9]+)$") //nolint:gosimple
// e.g. 2001:db8:1f70::999:de8:7648:6e8
ipv6HostRegexp = regexp.MustCompile("^([:0-9a-fA-F]+)$")
) )
// InstanceKey is an instance indicator, identified by hostname and port // InstanceKey is an instance indicator, identified by hostname and port
@ -35,7 +33,8 @@ const detachHint = "//"
// ParseInstanceKey will parse an InstanceKey from a string representation such as 127.0.0.1:3306 // ParseInstanceKey will parse an InstanceKey from a string representation such as 127.0.0.1:3306
func NewRawInstanceKey(hostPort string) (*InstanceKey, error) { func NewRawInstanceKey(hostPort string) (*InstanceKey, error) {
var hostname, port string hostname := ""
port := ""
if submatch := ipv4HostPortRegexp.FindStringSubmatch(hostPort); len(submatch) > 0 { if submatch := ipv4HostPortRegexp.FindStringSubmatch(hostPort); len(submatch) > 0 {
hostname = submatch[1] hostname = submatch[1]
port = submatch[2] port = submatch[2]

View File

@ -205,9 +205,3 @@ func GetTableColumns(db *gosql.DB, databaseName, tableName string) (*sql.ColumnL
} }
return sql.NewColumnList(columnNames), sql.NewColumnList(virtualColumnNames), nil return sql.NewColumnList(columnNames), sql.NewColumnList(virtualColumnNames), nil
} }
// Kill executes a KILL QUERY by connection id
func Kill(db *gosql.DB, connectionID string) error {
_, err := db.Exec(`KILL QUERY %s`, connectionID)
return err
}

65
go/os/process.go Normal file
View File

@ -0,0 +1,65 @@
/*
Copyright 2014 Outbrain Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package os
import (
"github.com/openark/golib/log"
"io/ioutil"
"os"
"os/exec"
)
func execCmd(commandText string, arguments ...string) (*exec.Cmd, string, error) {
commandBytes := []byte(commandText)
tmpFile, err := ioutil.TempFile("", "gh-ost-process-cmd-")
if err != nil {
return nil, "", log.Errore(err)
}
ioutil.WriteFile(tmpFile.Name(), commandBytes, 0644)
log.Debugf("execCmd: %s", commandText)
shellArguments := append([]string{}, tmpFile.Name())
shellArguments = append(shellArguments, arguments...)
log.Debugf("%+v", shellArguments)
return exec.Command("bash", shellArguments...), tmpFile.Name(), nil
}
// CommandRun executes a command
func CommandRun(commandText string, arguments ...string) error {
cmd, tmpFileName, err := execCmd(commandText, arguments...)
defer os.Remove(tmpFileName)
if err != nil {
return log.Errore(err)
}
err = cmd.Run()
return log.Errore(err)
}
// RunCommandWithOutput executes a command and return output bytes
func RunCommandWithOutput(commandText string) ([]byte, error) {
cmd, tmpFileName, err := execCmd(commandText)
defer os.Remove(tmpFileName)
if err != nil {
return nil, log.Errore(err)
}
outputBytes, err := cmd.Output()
if err != nil {
return nil, log.Errore(err)
}
return outputBytes, nil
}

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -33,7 +33,7 @@ func EscapeName(name string) string {
} }
func buildColumnsPreparedValues(columns *ColumnList) []string { func buildColumnsPreparedValues(columns *ColumnList) []string {
values := make([]string, columns.Len()) values := make([]string, columns.Len(), columns.Len())
for i, column := range columns.Columns() { for i, column := range columns.Columns() {
var token string var token string
if column.timezoneConversion != nil { if column.timezoneConversion != nil {
@ -42,6 +42,8 @@ func buildColumnsPreparedValues(columns *ColumnList) []string {
token = fmt.Sprintf("ELT(?, %s)", column.EnumValues) token = fmt.Sprintf("ELT(?, %s)", column.EnumValues)
} else if column.Type == JSONColumnType { } else if column.Type == JSONColumnType {
token = "convert(? using utf8mb4)" token = "convert(? using utf8mb4)"
} else if column.charsetConversion != nil {
token = fmt.Sprintf("convert(convert(? using %s) using %s)", column.charsetConversion.FromCharset, column.charsetConversion.ToCharset)
} else { } else {
token = "?" token = "?"
} }
@ -51,7 +53,7 @@ func buildColumnsPreparedValues(columns *ColumnList) []string {
} }
func buildPreparedValues(length int) []string { func buildPreparedValues(length int) []string {
values := make([]string, length) values := make([]string, length, length)
for i := 0; i < length; i++ { for i := 0; i < length; i++ {
values[i] = "?" values[i] = "?"
} }
@ -59,7 +61,7 @@ func buildPreparedValues(length int) []string {
} }
func duplicateNames(names []string) []string { func duplicateNames(names []string) []string {
duplicate := make([]string, len(names)) duplicate := make([]string, len(names), len(names))
copy(duplicate, names) copy(duplicate, names)
return duplicate return duplicate
} }
@ -114,6 +116,8 @@ func BuildSetPreparedClause(columns *ColumnList) (result string, err error) {
setToken = fmt.Sprintf("%s=ELT(?, %s)", EscapeName(column.Name), column.EnumValues) setToken = fmt.Sprintf("%s=ELT(?, %s)", EscapeName(column.Name), column.EnumValues)
} else if column.Type == JSONColumnType { } else if column.Type == JSONColumnType {
setToken = fmt.Sprintf("%s=convert(? using utf8mb4)", EscapeName(column.Name)) setToken = fmt.Sprintf("%s=convert(? using utf8mb4)", EscapeName(column.Name))
} else if column.charsetConversion != nil {
setToken = fmt.Sprintf("%s=convert(convert(? using %s) using %s)", EscapeName(column.Name), column.charsetConversion.FromCharset, column.charsetConversion.ToCharset)
} else { } else {
setToken = fmt.Sprintf("%s=?", EscapeName(column.Name)) setToken = fmt.Sprintf("%s=?", EscapeName(column.Name))
} }
@ -167,7 +171,7 @@ func BuildRangeComparison(columns []string, values []string, args []interface{},
if includeEquals { if includeEquals {
comparison, err := BuildEqualsComparison(columns, values) comparison, err := BuildEqualsComparison(columns, values)
if err != nil { if err != nil {
return "", explodedArgs, err return "", explodedArgs, nil
} }
comparisons = append(comparisons, comparison) comparisons = append(comparisons, comparison)
explodedArgs = append(explodedArgs, args...) explodedArgs = append(explodedArgs, args...)
@ -261,8 +265,8 @@ func BuildUniqueKeyRangeEndPreparedQueryViaOffset(databaseName, tableName string
explodedArgs = append(explodedArgs, rangeExplodedArgs...) explodedArgs = append(explodedArgs, rangeExplodedArgs...)
uniqueKeyColumnNames := duplicateNames(uniqueKeyColumns.Names()) uniqueKeyColumnNames := duplicateNames(uniqueKeyColumns.Names())
uniqueKeyColumnAscending := make([]string, len(uniqueKeyColumnNames)) uniqueKeyColumnAscending := make([]string, len(uniqueKeyColumnNames), len(uniqueKeyColumnNames))
uniqueKeyColumnDescending := make([]string, len(uniqueKeyColumnNames)) uniqueKeyColumnDescending := make([]string, len(uniqueKeyColumnNames), len(uniqueKeyColumnNames))
for i, column := range uniqueKeyColumns.Columns() { for i, column := range uniqueKeyColumns.Columns() {
uniqueKeyColumnNames[i] = EscapeName(uniqueKeyColumnNames[i]) uniqueKeyColumnNames[i] = EscapeName(uniqueKeyColumnNames[i])
if column.Type == EnumColumnType { if column.Type == EnumColumnType {
@ -316,8 +320,8 @@ func BuildUniqueKeyRangeEndPreparedQueryViaTemptable(databaseName, tableName str
explodedArgs = append(explodedArgs, rangeExplodedArgs...) explodedArgs = append(explodedArgs, rangeExplodedArgs...)
uniqueKeyColumnNames := duplicateNames(uniqueKeyColumns.Names()) uniqueKeyColumnNames := duplicateNames(uniqueKeyColumns.Names())
uniqueKeyColumnAscending := make([]string, len(uniqueKeyColumnNames)) uniqueKeyColumnAscending := make([]string, len(uniqueKeyColumnNames), len(uniqueKeyColumnNames))
uniqueKeyColumnDescending := make([]string, len(uniqueKeyColumnNames)) uniqueKeyColumnDescending := make([]string, len(uniqueKeyColumnNames), len(uniqueKeyColumnNames))
for i, column := range uniqueKeyColumns.Columns() { for i, column := range uniqueKeyColumns.Columns() {
uniqueKeyColumnNames[i] = EscapeName(uniqueKeyColumnNames[i]) uniqueKeyColumnNames[i] = EscapeName(uniqueKeyColumnNames[i])
if column.Type == EnumColumnType { if column.Type == EnumColumnType {
@ -368,7 +372,7 @@ func buildUniqueKeyMinMaxValuesPreparedQuery(databaseName, tableName string, uni
tableName = EscapeName(tableName) tableName = EscapeName(tableName)
uniqueKeyColumnNames := duplicateNames(uniqueKeyColumns.Names()) uniqueKeyColumnNames := duplicateNames(uniqueKeyColumns.Names())
uniqueKeyColumnOrder := make([]string, len(uniqueKeyColumnNames)) uniqueKeyColumnOrder := make([]string, len(uniqueKeyColumnNames), len(uniqueKeyColumnNames))
for i, column := range uniqueKeyColumns.Columns() { for i, column := range uniqueKeyColumns.Columns() {
uniqueKeyColumnNames[i] = EscapeName(uniqueKeyColumnNames[i]) uniqueKeyColumnNames[i] = EscapeName(uniqueKeyColumnNames[i])
if column.Type == EnumColumnType { if column.Type == EnumColumnType {
@ -501,17 +505,14 @@ func BuildDMLUpdateQuery(databaseName, tableName string, tableColumns, sharedCol
} }
equalsComparison, err := BuildEqualsPreparedComparison(uniqueKeyColumns.Names()) equalsComparison, err := BuildEqualsPreparedComparison(uniqueKeyColumns.Names())
if err != nil {
return "", sharedArgs, uniqueKeyArgs, err
}
result = fmt.Sprintf(` result = fmt.Sprintf(`
update /* gh-ost %s.%s */ update /* gh-ost %s.%s */
%s.%s %s.%s
set set
%s %s
where where
%s %s
`, databaseName, tableName, `, databaseName, tableName,
databaseName, tableName, databaseName, tableName,
setClause, setClause,
equalsComparison, equalsComparison,

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -62,7 +62,7 @@ func NewParserFromAlterStatement(alterStatement string) *AlterTableParser {
return parser return parser
} }
func (this *AlterTableParser) tokenizeAlterStatement(alterStatement string) (tokens []string) { func (this *AlterTableParser) tokenizeAlterStatement(alterStatement string) (tokens []string, err error) {
terminatingQuote := rune(0) terminatingQuote := rune(0)
f := func(c rune) bool { f := func(c rune) bool {
switch { switch {
@ -86,7 +86,7 @@ func (this *AlterTableParser) tokenizeAlterStatement(alterStatement string) (tok
for i := range tokens { for i := range tokens {
tokens[i] = strings.TrimSpace(tokens[i]) tokens[i] = strings.TrimSpace(tokens[i])
} }
return tokens return tokens, nil
} }
func (this *AlterTableParser) sanitizeQuotesFromAlterStatement(alterStatement string) (strippedStatement string) { func (this *AlterTableParser) sanitizeQuotesFromAlterStatement(alterStatement string) (strippedStatement string) {
@ -95,7 +95,7 @@ func (this *AlterTableParser) sanitizeQuotesFromAlterStatement(alterStatement st
return strippedStatement return strippedStatement
} }
func (this *AlterTableParser) parseAlterToken(alterToken string) { func (this *AlterTableParser) parseAlterToken(alterToken string) (err error) {
{ {
// rename // rename
allStringSubmatch := renameColumnRegexp.FindAllStringSubmatch(alterToken, -1) allStringSubmatch := renameColumnRegexp.FindAllStringSubmatch(alterToken, -1)
@ -131,9 +131,11 @@ func (this *AlterTableParser) parseAlterToken(alterToken string) {
this.isAutoIncrementDefined = true this.isAutoIncrementDefined = true
} }
} }
return nil
} }
func (this *AlterTableParser) ParseAlterStatement(alterStatement string) (err error) { func (this *AlterTableParser) ParseAlterStatement(alterStatement string) (err error) {
this.alterStatementOptions = alterStatement this.alterStatementOptions = alterStatement
for _, alterTableRegexp := range alterTableExplicitSchemaTableRegexps { for _, alterTableRegexp := range alterTableExplicitSchemaTableRegexps {
if submatch := alterTableRegexp.FindStringSubmatch(this.alterStatementOptions); len(submatch) > 0 { if submatch := alterTableRegexp.FindStringSubmatch(this.alterStatementOptions); len(submatch) > 0 {
@ -150,7 +152,8 @@ func (this *AlterTableParser) ParseAlterStatement(alterStatement string) (err er
break break
} }
} }
for _, alterToken := range this.tokenizeAlterStatement(this.alterStatementOptions) { alterTokens, _ := this.tokenizeAlterStatement(this.alterStatementOptions)
for _, alterToken := range alterTokens {
alterToken = this.sanitizeQuotesFromAlterStatement(alterToken) alterToken = this.sanitizeQuotesFromAlterStatement(alterToken)
this.parseAlterToken(alterToken) this.parseAlterToken(alterToken)
this.alterTokens = append(this.alterTokens, alterToken) this.alterTokens = append(this.alterTokens, alterToken)

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 GitHub Inc. Copyright 2016 GitHub Inc.
See https://github.com/github/gh-ost/blob/master/LICENSE See https://github.com/github/gh-ost/blob/master/LICENSE
*/ */
@ -40,6 +40,7 @@ func TestParseAlterStatementTrivialRename(t *testing.T) {
} }
func TestParseAlterStatementWithAutoIncrement(t *testing.T) { func TestParseAlterStatementWithAutoIncrement(t *testing.T) {
statements := []string{ statements := []string{
"auto_increment=7", "auto_increment=7",
"auto_increment = 7", "auto_increment = 7",
@ -99,37 +100,37 @@ func TestTokenizeAlterStatement(t *testing.T) {
parser := NewAlterTableParser() parser := NewAlterTableParser()
{ {
alterStatement := "add column t int" alterStatement := "add column t int"
tokens := parser.tokenizeAlterStatement(alterStatement) tokens, _ := parser.tokenizeAlterStatement(alterStatement)
test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int"})) test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int"}))
} }
{ {
alterStatement := "add column t int, change column i int" alterStatement := "add column t int, change column i int"
tokens := parser.tokenizeAlterStatement(alterStatement) tokens, _ := parser.tokenizeAlterStatement(alterStatement)
test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "change column i int"})) test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "change column i int"}))
} }
{ {
alterStatement := "add column t int, change column i int 'some comment'" alterStatement := "add column t int, change column i int 'some comment'"
tokens := parser.tokenizeAlterStatement(alterStatement) tokens, _ := parser.tokenizeAlterStatement(alterStatement)
test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "change column i int 'some comment'"})) test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "change column i int 'some comment'"}))
} }
{ {
alterStatement := "add column t int, change column i int 'some comment, with comma'" alterStatement := "add column t int, change column i int 'some comment, with comma'"
tokens := parser.tokenizeAlterStatement(alterStatement) tokens, _ := parser.tokenizeAlterStatement(alterStatement)
test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "change column i int 'some comment, with comma'"})) test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "change column i int 'some comment, with comma'"}))
} }
{ {
alterStatement := "add column t int, add column d decimal(10,2)" alterStatement := "add column t int, add column d decimal(10,2)"
tokens := parser.tokenizeAlterStatement(alterStatement) tokens, _ := parser.tokenizeAlterStatement(alterStatement)
test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "add column d decimal(10,2)"})) test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "add column d decimal(10,2)"}))
} }
{ {
alterStatement := "add column t int, add column e enum('a','b','c')" alterStatement := "add column t int, add column e enum('a','b','c')"
tokens := parser.tokenizeAlterStatement(alterStatement) tokens, _ := parser.tokenizeAlterStatement(alterStatement)
test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "add column e enum('a','b','c')"})) test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int", "add column e enum('a','b','c')"}))
} }
{ {
alterStatement := "add column t int(11), add column e enum('a','b','c')" alterStatement := "add column t int(11), add column e enum('a','b','c')"
tokens := parser.tokenizeAlterStatement(alterStatement) tokens, _ := parser.tokenizeAlterStatement(alterStatement)
test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int(11)", "add column e enum('a','b','c')"})) test.S(t).ExpectTrue(reflect.DeepEqual(tokens, []string{"add column t int(11)", "add column e enum('a','b','c')"}))
} }
} }
@ -149,6 +150,7 @@ func TestSanitizeQuotesFromAlterStatement(t *testing.T) {
} }
func TestParseAlterStatementDroppedColumns(t *testing.T) { func TestParseAlterStatementDroppedColumns(t *testing.T) {
{ {
parser := NewAlterTableParser() parser := NewAlterTableParser()
statement := "drop column b" statement := "drop column b"
@ -188,6 +190,7 @@ func TestParseAlterStatementDroppedColumns(t *testing.T) {
} }
func TestParseAlterStatementRenameTable(t *testing.T) { func TestParseAlterStatementRenameTable(t *testing.T) {
{ {
parser := NewAlterTableParser() parser := NewAlterTableParser()
statement := "drop column b" statement := "drop column b"
@ -227,6 +230,7 @@ func TestParseAlterStatementRenameTable(t *testing.T) {
} }
func TestParseAlterStatementExplicitTable(t *testing.T) { func TestParseAlterStatementExplicitTable(t *testing.T) {
{ {
parser := NewAlterTableParser() parser := NewAlterTableParser()
statement := "drop column b" statement := "drop column b"

View File

@ -32,7 +32,7 @@ type TimezoneConversion struct {
ToTimezone string ToTimezone string
} }
type CharacterSetConversion struct { type CharsetConversion struct {
ToCharset string ToCharset string
FromCharset string FromCharset string
} }
@ -45,25 +45,21 @@ type Column struct {
EnumValues string EnumValues string
timezoneConversion *TimezoneConversion timezoneConversion *TimezoneConversion
enumToTextConversion bool enumToTextConversion bool
charsetConversion *CharsetConversion
// add Octet length for binary type, fix bytes with suffix "00" get clipped in mysql binlog. // add Octet length for binary type, fix bytes with suffix "00" get clipped in mysql binlog.
// https://github.com/github/gh-ost/issues/909 // https://github.com/github/gh-ost/issues/909
BinaryOctetLength uint BinaryOctetLength uint
charsetConversion *CharacterSetConversion
} }
func (this *Column) convertArg(arg interface{}, isUniqueKeyColumn bool) interface{} { func (this *Column) convertArg(arg interface{}, isUniqueKeyColumn bool) interface{} {
if s, ok := arg.(string); ok { if s, ok := arg.(string); ok {
arg2Bytes := []byte(s) // string, charset conversion
// convert to bytes if character string without charsetConversion. if encoding, ok := charsetEncodingMap[this.Charset]; ok {
if this.Charset != "" && this.charsetConversion == nil { arg, _ = encoding.NewDecoder().String(s)
arg = arg2Bytes
} else {
if encoding, ok := charsetEncodingMap[this.Charset]; ok {
arg, _ = encoding.NewDecoder().String(s)
}
} }
if this.Type == BinaryColumnType && isUniqueKeyColumn { if this.Type == BinaryColumnType && isUniqueKeyColumn {
arg2Bytes := []byte(arg.(string))
size := len(arg2Bytes) size := len(arg2Bytes)
if uint(size) < this.BinaryOctetLength { if uint(size) < this.BinaryOctetLength {
buf := bytes.NewBuffer(arg2Bytes) buf := bytes.NewBuffer(arg2Bytes)
@ -221,6 +217,14 @@ func (this *ColumnList) SetEnumValues(columnName string, enumValues string) {
this.GetColumn(columnName).EnumValues = enumValues this.GetColumn(columnName).EnumValues = enumValues
} }
func (this *ColumnList) SetCharsetConversion(columnName string, fromCharset string, toCharset string) {
this.GetColumn(columnName).charsetConversion = &CharsetConversion{FromCharset: fromCharset, ToCharset: toCharset}
}
func (this *ColumnList) IsCharsetConversion(columnName string) bool {
return this.GetColumn(columnName).charsetConversion != nil
}
func (this *ColumnList) String() string { func (this *ColumnList) String() string {
return strings.Join(this.Names(), ",") return strings.Join(this.Names(), ",")
} }
@ -248,10 +252,6 @@ func (this *ColumnList) Len() int {
return len(this.columns) return len(this.columns)
} }
func (this *ColumnList) SetCharsetConversion(columnName string, fromCharset string, toCharset string) {
this.GetColumn(columnName).charsetConversion = &CharacterSetConversion{FromCharset: fromCharset, ToCharset: toCharset}
}
// UniqueKey is the combination of a key's name and columns // UniqueKey is the combination of a key's name and columns
type UniqueKey struct { type UniqueKey struct {
Name string Name string

View File

@ -1,13 +0,0 @@
drop table if exists gh_ost_test;
create table gh_ost_test (
id int auto_increment,
i int not null,
color varchar(32),
primary key(id)
) auto_increment=1;
drop event if exists gh_ost_test;
insert into gh_ost_test values (null, 11, 'red');
insert into gh_ost_test values (null, 13, 'green');
insert into gh_ost_test values (null, 17, 'blue');

View File

@ -1 +0,0 @@
--attempt-instant-ddl

View File

@ -1,40 +0,0 @@
drop table if exists gh_ost_test;
create table gh_ost_test (
id int auto_increment,
i int not null,
ts0 timestamp(6) default current_timestamp(6),
updated tinyint unsigned default 0,
primary key(id, ts0)
) auto_increment=1;
drop event if exists gh_ost_test;
delimiter ;;
create event gh_ost_test
on schedule every 1 second
starts current_timestamp
ends current_timestamp + interval 60 second
on completion not preserve
enable
do
begin
insert into gh_ost_test values (null, 11, sysdate(6), 0);
update gh_ost_test set updated = 1 where i = 11 order by id desc limit 1;
insert into gh_ost_test values (null, 13, sysdate(6), 0);
update gh_ost_test set updated = 1 where i = 13 order by id desc limit 1;
insert into gh_ost_test values (null, 17, sysdate(6), 0);
update gh_ost_test set updated = 1 where i = 17 order by id desc limit 1;
insert into gh_ost_test values (null, 19, sysdate(6), 0);
update gh_ost_test set updated = 1 where i = 19 order by id desc limit 1;
insert into gh_ost_test values (null, 23, sysdate(6), 0);
update gh_ost_test set updated = 1 where i = 23 order by id desc limit 1;
insert into gh_ost_test values (null, 29, sysdate(6), 0);
insert into gh_ost_test values (null, 31, sysdate(6), 0);
insert into gh_ost_test values (null, 37, sysdate(6), 0);
insert into gh_ost_test values (null, 41, sysdate(6), 0);
delete from gh_ost_test where i = 31 order by id desc limit 1;
end ;;

View File

@ -1,40 +0,0 @@
drop table if exists gh_ost_test;
create table gh_ost_test (
id int auto_increment,
i int not null,
v varchar(128),
updated tinyint unsigned default 0,
primary key(id, v)
) auto_increment=1;
drop event if exists gh_ost_test;
delimiter ;;
create event gh_ost_test
on schedule every 1 second
starts current_timestamp
ends current_timestamp + interval 60 second
on completion not preserve
enable
do
begin
insert into gh_ost_test values (null, 11, 'eleven', 0);
update gh_ost_test set updated = 1 where i = 11 order by id desc limit 1;
insert into gh_ost_test values (null, 13, 'thirteen', 0);
update gh_ost_test set updated = 1 where i = 13 order by id desc limit 1;
insert into gh_ost_test values (null, 17, 'seventeen', 0);
update gh_ost_test set updated = 1 where i = 17 order by id desc limit 1;
insert into gh_ost_test values (null, 19, 'nineteen', 0);
update gh_ost_test set updated = 1 where i = 19 order by id desc limit 1;
insert into gh_ost_test values (null, 23, 'twenty three', 0);
update gh_ost_test set updated = 1 where i = 23 order by id desc limit 1;
insert into gh_ost_test values (null, 29, 'twenty nine', 0);
insert into gh_ost_test values (null, 31, 'thirty one', 0);
insert into gh_ost_test values (null, 37, 'thirty seven', 0);
insert into gh_ost_test values (null, 41, 'forty one', 0);
delete from gh_ost_test where i = 31 order by id desc limit 1;
end ;;

View File

@ -7,6 +7,9 @@ create table gh_ost_test (
primary key(id) primary key(id)
) auto_increment=1; ) auto_increment=1;
insert into gh_ost_test values (null, 'átesting', '', '');
insert into gh_ost_test values (null, 'Hello world, Καλημέρα κόσμε, コンニチハ', 'átesting0', 'initial'); insert into gh_ost_test values (null, 'Hello world, Καλημέρα κόσμε, コンニチハ', 'átesting0', 'initial');
drop event if exists gh_ost_test; drop event if exists gh_ost_test;

View File

@ -0,0 +1 @@
(5.5)

View File

@ -0,0 +1 @@
(5.5)

View File

@ -0,0 +1 @@
(5.5)

View File

@ -1,20 +0,0 @@
drop table if exists gh_ost_test;
create table gh_ost_test (
id int unsigned auto_increment,
i int not null,
dt datetime,
primary key(id)
) auto_increment=1;
drop event if exists gh_ost_test;
delimiter ;;
create event gh_ost_test
on schedule every 1 second
starts current_timestamp
ends current_timestamp + interval 60 second
on completion not preserve
enable
do
begin
insert into gh_ost_test values (null, 7, '2010-10-20 10:20:30');
end ;;

View File

@ -1 +0,0 @@
--allow-zero-in-date --alter="change column dt dt datetime not null default '1970-00-00 00:00:00'"

View File

@ -0,0 +1 @@
(5.5)

View File

@ -1 +0,0 @@
Percona

View File

@ -1,21 +0,0 @@
set session sql_mode='';
drop table if exists gh_ost_test;
create table gh_ost_test (
id int unsigned auto_increment,
i int not null,
dt datetime not null default '1970-00-00 00:00:00',
primary key(id)
) auto_increment=1;
drop event if exists gh_ost_test;
delimiter ;;
create event gh_ost_test
on schedule every 1 second
starts current_timestamp
ends current_timestamp + interval 60 second
on completion not preserve
enable
do
begin
insert into gh_ost_test values (null, 7, '2010-10-20 10:20:30');
end ;;

View File

@ -1 +0,0 @@
--allow-zero-in-date --alter="engine=innodb"

View File

@ -1,20 +0,0 @@
drop table if exists gh_ost_test;
create table gh_ost_test (
id int unsigned auto_increment,
i int not null,
dt datetime,
primary key(id)
) auto_increment=1;
drop event if exists gh_ost_test;
delimiter ;;
create event gh_ost_test
on schedule every 1 second
starts current_timestamp
ends current_timestamp + interval 60 second
on completion not preserve
enable
do
begin
insert into gh_ost_test values (null, 7, '2010-10-20 10:20:30');
end ;;

View File

@ -1 +0,0 @@
Invalid default value for 'dt'

View File

@ -1 +0,0 @@
--alter="change column dt dt datetime not null default '1970-00-00 00:00:00'"

View File

@ -1,21 +0,0 @@
set session sql_mode='';
drop table if exists gh_ost_test;
create table gh_ost_test (
id int unsigned auto_increment,
i int not null,
dt datetime not null default '1970-00-00 00:00:00',
primary key(id)
) auto_increment=1;
drop event if exists gh_ost_test;
delimiter ;;
create event gh_ost_test
on schedule every 1 second
starts current_timestamp
ends current_timestamp + interval 60 second
on completion not preserve
enable
do
begin
insert into gh_ost_test values (null, 7, '2010-10-20 10:20:30');
end ;;

View File

@ -1 +0,0 @@
Invalid default value for 'dt'

View File

@ -1 +0,0 @@
--alter="engine=innodb"

View File

@ -1 +0,0 @@
Percona

View File

@ -1 +0,0 @@
Percona

View File

@ -1 +0,0 @@
Percona

View File

@ -0,0 +1 @@
(5.5|5.6)

View File

@ -1 +0,0 @@
Percona

View File

@ -0,0 +1 @@
(5.5|5.6)

View File

@ -1 +0,0 @@
Percona

View File

@ -1 +0,0 @@
Percona

View File

@ -0,0 +1 @@
(5.5|5.6)

View File

@ -0,0 +1 @@
(5.5|5.6)

View File

@ -1 +0,0 @@
Percona

View File

@ -0,0 +1 @@
(5.5|5.6)

View File

@ -0,0 +1 @@
(5.5|5.6)

View File

@ -0,0 +1 @@
(5.5|5.6)

View File

@ -0,0 +1,24 @@
drop table if exists gh_ost_test;
create table gh_ost_test (
id int auto_increment,
t text charset latin1 collate latin1_swedish_ci,
primary key(id)
) auto_increment=1 charset latin1 collate latin1_swedish_ci;
insert into gh_ost_test values (null, char(189));
drop event if exists gh_ost_test;
delimiter ;;
create event gh_ost_test
on schedule every 1 second
starts current_timestamp
ends current_timestamp + interval 60 second
on completion not preserve
enable
do
begin
insert into gh_ost_test values (null, md5(rand()));
insert into gh_ost_test values (null, char(189));
update gh_ost_test set t=char(190) order by id desc limit 1;
delete from gh_ost_test where t=char(190);
end ;;

View File

@ -0,0 +1 @@
--alter "convert to character set utf8mb4 collate utf8mb4_unicode_ci"

View File

@ -0,0 +1 @@
(5.5)

View File

@ -1 +0,0 @@
Percona

View File

@ -0,0 +1 @@
(5.5|5.6)

View File

@ -0,0 +1 @@
(5.5)

View File

@ -0,0 +1 @@
(5.5)

Some files were not shown because too many files have changed in this diff Show More