Skip to content

--resume and --revert silently lose changes read before the DML listener is registered #1793

Open

Description

@CharlieW-22

Summary

When gh-ost resumes a migration (--resume) or reverts one (--revert), it starts reading the binlog from the saved checkpoint a moment before it registers the listener for the original table. Changes to that table that are read in that moment are skipped: they never reach the ghost table (or, for --revert, the old table). gh-ost then cuts over and exits 0, and the new table is missing those changes. Nothing is logged.

It affects v1.1.8 (when resume and revert were added, in #1595 and #1607) through v1.1.11, and current master (dd44453). I found it while testing resume support in our own migration tooling, then reproduced it with only the official v1.1.11 binary and a stock MySQL container.

What goes wrong

In Migrator.Migrate(), the resume path runs these steps in order:

  1. Line 594: initiateStreaming() connects the binlog reader at the checkpoint. go-mysql starts receiving and parsing events straight away, in a background goroutine.
  2. Line 600: hooksExecutor.OnValidated() runs the gh-ost-on-validated hook.
  3. Lines 604 and 609: initiateServer() and countTableRows().
  4. Line 612: addDMLEventsListener(). Only now is there a listener for the original table.

Until step 4, shouldDecodeRowsEvent returns false for the original table, so go-mysql doesn't decode the row data of its events, and notifyListeners has no listener to call. The reader's position still moves past those events, so they are never read again.

Migrator.Revert() has the same order: initiateStreaming() from the cut-over checkpoint, then the hook and initiateServer(), and only then addDMLEventsListener().

Why fresh migrations are fine: the reader starts at the current position before the row copy starts, so a change skipped in that gap is to a row the copy will read later, change included.

Why resume and revert aren't: the reader starts at a checkpoint, behind changes to rows that are already in the target table. On resume, the row copy carries on from the checkpoint, so rows copied before it are never read again (and INSERT IGNORE skips any row copied twice). On revert, there's no row copy at all. Either way, a skipped change is lost for good.

How likely it is

It's a race between the main goroutine (steps 1 to 4) and the reader working through the backlog since the checkpoint. With several CPUs and no hooks, the listener usually wins by a millisecond or two, which is probably why nobody has noticed. Three ordinary setups make it lose:

  • A gh-ost-on-validated hook. It runs inside the gap. A hook that only runs sleep 1 made every resume lose data.
  • --exact-rowcount --concurrent-rowcount=false. gh-ost then counts every row of the table inside the gap (step 3). With no hooks and 11 CPUs, on a 1,000,000-row table, 3 of 3 resumes lost data.
  • One CPU (GOMAXPROCS=1), for example on a 1-vCPU host. With no hooks at all, 5 of 6 resumes lost data. (A container limited to one CPU still gets GOMAXPROCS=2 from Go, and lost nothing in 3 runs.)

Workaround

None that's reliable. Without a gh-ost-on-validated hook, with --concurrent-rowcount left on and more than one CPU, gh-ost usually wins the race. What's safe instead: starting the migration over rather than resuming it, and running the reverse ALTER as a new migration rather than using --revert.

Steps to reproduce

repro.sh (at the end) needs only Docker and curl. It uses the official v1.1.11 release binary (checksum-verified) and the official mysql:8.0.45 image, and:

  1. creates repro.t with 100,000 rows (600,000 with KILL_PHASE=copy):
    CREATE TABLE t (
      id  INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
      v   INT NOT NULL DEFAULT 0,
      pad VARCHAR(200) NOT NULL DEFAULT ''
    );
  2. starts an "app" that keeps committing UPDATE t SET v = v + 1 WHERE id = <random> together with INSERT INTO audit (tid) VALUES (<same id>), in one transaction, about 50 times a second. audit is an independent record of every committed update.
  3. runs gh-ost with a gh-ost-on-validated hook that runs sleep 1:
    gh-ost --host=... --user=... --password=... --database=repro --table=t \
      --alter="ADD COLUMN note VARCHAR(20) NULL" --allow-on-master \
      --checkpoint --checkpoint-seconds=10 \
      --postpone-cut-over-flag-file=/work/postpone-cut-over.flag \
      --hooks-path=/work/hooks --chunk-size=1000 --verbose --execute
    
  4. once the cut-over is postponed, waits for a checkpoint and SIGKILLs gh-ost 1 second later (like a crashed host). With KILL_PHASE=copy, it does this during the row copy instead, and adds --nice-ratio=5 so the copy outlasts the first checkpoint. The app keeps writing.
  5. 3 seconds later, starts gh-ost again with the same flags plus --resume.
  6. stops the app, removes the flag file, lets gh-ost cut over, and compares every row of the new t, and of the old table gh-ost leaves behind (_t_del), with audit.
./repro.sh                                       # 1 s gh-ost-on-validated hook
KILL_PHASE=copy ./repro.sh                       # same, but killed during the row copy
HOOK_DELAY=0 GHOST_ENV=GOMAXPROCS=1 ./repro.sh   # no hooks, one CPU
RESUME=0 ./repro.sh                              # control: same hook, no interruption

# no hooks and all CPUs, but gh-ost counts every row before it starts (1,000,000 rows):
HOOK_DELAY=0 ROWS=1000000 EXTRA_ARGS="--exact-rowcount --concurrent-rowcount=false" ./repro.sh

Each run ends with a RESULT: line. The script exits 1 when it reproduces the bug, 0 when no update was lost, and 2 if the result is inconclusive.

Expected: the new t matches audit, like _t_del does.

Actual: gh-ost exits 0 with Done migrating, and _t_del matches audit exactly. But the new t is missing about 1 in 5 of the ~1,100 committed updates, with no warning or error in the log. For example, from the first run:

id updates in audit v in new t v in old _t_del
850 1 0 1
1132 1 0 1
1192 1 0 1
Interruption Hooks / CPUs v1.1.11 release: runs that lost updates Updates lost per failing run
killed while the cut-over was postponed 1 s gh-ost-on-validated hook 3 of 3 229 to 240
killed during the row copy (KILL_PHASE=copy) 1 s hook 3 of 3 67 to 79
killed while the cut-over was postponed no hooks, GOMAXPROCS=1 5 of 6 167 to 187
killed while the cut-over was postponed no hooks, 11 CPUs, --exact-rowcount --concurrent-rowcount=false, 1,000,000 rows 3 of 3 179 to 198
killed while the cut-over was postponed no hooks, 11 CPUs 0 of 5
none (control) 1 s hook 0 of 2

In the runs killed during the row copy, only rows copied before the kill lost updates. Rows copied after the resume were read fresh from the original table and were correct.

The first run's logs show the gap (times in UTC). Every lost update was committed between shortly before the kill and the end of run 2's hook. None was committed later:

run 1: checkpoint taken at binlog.000003:43202972              18:30:20.88
run 1: SIGKILLed                                               18:30:23.36
run 2 (--resume) started                                       18:30:26.46
run 2: "Connecting binlog streamer at binlog.000003:43202972"  18:30:26
run 2: gh-ost-on-validated hook                                18:30:26.675 to 18:30:27.678
lost updates were committed between                            18:30:22.54 and 18:30:27.67

The changes between the checkpoint and 18:30:22.54 were skipped too, but run 1 had already applied them before it was killed. Run 2's first status line also shows the skip: Applied: 478 is still the checkpoint's own count, while streamer: binlog.000003:43540424 is already about 337 KB past the checkpoint.

--revert: I reproduced it with gh-ost's own test suite rather than the script: the setup of TestRevert, then some updates after the cut-over, then a revert with a gh-ost-on-validated hook that runs sleep 1. On master, all 34 updated rows keep their old values in the reverted table. The test, TestRevertAppliesDMLWrittenAfterCutOver, is in #1794. With Docker running (the suite starts a MySQL container), this runs it:

go test ./go/logic/ -run 'TestMigrator$' -testify.m 'TestRevertAppliesDMLWrittenAfterCutOver' -v

Suggested fix

When resuming or reverting, register the listener before the reader starts. In initiateStreaming(), before InitDBConnections():

if mgtr.migrationContext.Resume || mgtr.migrationContext.Revert {
	if err := mgtr.addDMLEventsListener(); err != nil {
		return err
	}
}

The later addDMLEventsListener() call is then skipped when resuming (in Migrate()) and removed from Revert().

  • Fresh migrations keep the current order on purpose. There, the streamer has to deliver the GhostTableMigrated changelog event before anything drains applyEventsQueue, so an early DML listener could fill the queue and hold that event back.
  • On the resume and revert paths, nothing waits on the streamer before the applier starts. A backlog bigger than the 1,000-event queue just pauses the reader until executeWriteFuncs() (or executeDMLWriteFuncs() for revert) drains it. I checked this locally with 3,000 changes waiting: the queue filled up during the hook, then both the resume and the revert finished with every change applied.

With that change, the same script lost nothing in any run, including with GOMAXPROCS=1 and the exact row count. #1794 has the change and two regression tests for MigratorTestSuite, one for resume and one for revert. Both fail on master and pass with the fix. I'm happy to change the approach if you'd prefer another one.

Why the existing tests don't catch it

localtests/gtid-resume resumes a table that nothing writes to during the test, and TestRevert has no hook, so the listener wins the race. A write while gh-ost is stopped (or after the cut-over), plus a gh-ost-on-validated hook that sleeps, catches it every time.

Related

Environment

  • gh-ost v1.1.11, official gh-ost-linux-arm64.tar.gz (SHA-256 matches SHA256SUMS). In the test suite: master at dd44453. go/logic/migrator.go, go/logic/streamer.go and go/binlog/ haven't changed since v1.1.11.
  • MySQL 8.0.45 (official image), one server, --allow-on-master, file/position coordinates. The test-suite reproductions use gh-ost's MySQL 8.0.42 test container with GTID coordinates, so both coordinate modes are affected.
  • Docker Desktop on an arm64 Mac with 11 CPUs.
repro.sh
#!/usr/bin/env bash
# Standalone reproduction: gh-ost --resume silently loses updates.
#
# Needs only docker, curl and sha256sum/shasum. Uses the official gh-ost release
# binary (checksum-verified) and the official mysql image. Nothing else.
#
# What it does:
#   1. Starts one MySQL server and creates repro.t (ROWS rows, a counter column v).
#   2. Starts an "app" that keeps running, in one transaction each:
#        UPDATE t SET v = v + 1 WHERE id = <random>;  INSERT INTO audit (tid) VALUES (<same id>);
#      so audit holds an independent, committed record of every update.
#   3. Runs gh-ost (--checkpoint, --postpone-cut-over-flag-file) to add a column.
#   4. SIGKILLs gh-ost just after a checkpoint (during the copy or during the postponed cut-over),
#      waits DOWNTIME seconds, then starts it again with the same flags plus --resume.
#   5. Stops the app, lets gh-ost cut over, and compares every row of the new table, and of the
#      old table gh-ost leaves behind (_t_del), with the audit table.
#
# Exit status: 1 = bug reproduced (gh-ost exited 0 but the new table lost committed updates),
#              0 = no data loss, 2 = inconclusive or setup error.
#
# Settings (environment variables):
#   HOOK_DELAY   seconds a gh-ost-on-validated hook sleeps; 0 = no hooks at all   (default 1)
#   KILL_PHASE   postponed | copy                                                (default postponed)
#   RESUME       1 = kill and resume; 0 = control: one uninterrupted run          (default 1)
#   DOWNTIME     seconds gh-ost stays down before --resume                        (default 3)
#   ROWS         rows in repro.t                        (default 100000; 600000 with KILL_PHASE=copy)
#   NICE_RATIO   gh-ost --nice-ratio, slows the row copy        (default 0; 5 with KILL_PHASE=copy)
#   GHOST_CPUS   if set, docker --cpus for the gh-ost container                   (default unset)
#   GHOST_ENV    extra "-e NAME=value" for the gh-ost container, e.g. GOMAXPROCS=1 (default unset)
#   EXTRA_ARGS   extra gh-ost flags, e.g. "--exact-rowcount"                       (default unset)
#   GHOST_BIN    path to a linux gh-ost binary to use instead of the official release
#   GHOST_VERSION, MYSQL_IMAGE, PREFIX, KEEP=1 (keep containers afterwards)
set -euo pipefail

GHOST_VERSION=${GHOST_VERSION:-v1.1.11}
MYSQL_IMAGE=${MYSQL_IMAGE:-mysql:8.0.45}
HOOK_DELAY=${HOOK_DELAY:-1}
KILL_PHASE=${KILL_PHASE:-postponed}
# KILL_PHASE=copy needs the row copy to outlast the first checkpoint (--checkpoint-seconds can't go below 10).
if [ "$KILL_PHASE" = copy ]; then
  ROWS=${ROWS:-600000}
  NICE_RATIO=${NICE_RATIO:-5}
else
  ROWS=${ROWS:-100000}
  NICE_RATIO=${NICE_RATIO:-0}
fi
RESUME=${RESUME:-1}
DOWNTIME=${DOWNTIME:-3}
GHOST_CPUS=${GHOST_CPUS:-}
GHOST_ENV=${GHOST_ENV:-}
EXTRA_ARGS=${EXTRA_ARGS:-}
GHOST_BIN=${GHOST_BIN:-}
P=${PREFIX:-ghrepro}
PW=reprorootpw

HERE=$(cd "$(dirname "$0")" && pwd)
WORK=$HERE/work-$P
RUN_ID=$(date +%Y%m%d-%H%M%S)-${LABEL:-run}-hook${HOOK_DELAY}-${KILL_PHASE}-resume${RESUME}
OUT=$HERE/results/$RUN_ID
mkdir -p "$OUT"

log() { printf '%s  %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$OUT/summary.txt"; }

cleanup() {
  [ -n "${WRITER_PID:-}" ] && kill "$WRITER_PID" 2>/dev/null || true
  for c in $P-ghost-1 $P-ghost-2; do docker logs "$c" >"$OUT/$c.log" 2>&1 || true; done
  if [ "${KEEP:-0}" != 1 ]; then
    docker rm -fv $P-mysql $P-ghost-1 $P-ghost-2 >/dev/null 2>&1 || true
    docker network rm $P-net >/dev/null 2>&1 || true
  fi
}
trap cleanup EXIT

die() { log "ERROR: $*"; exit 2; }

sql() { docker exec -i -e MYSQL_PWD=$PW $P-mysql mysql -h127.0.0.1 -uroot -N -B "$@"; }
sqlt() { docker exec -i -e MYSQL_PWD=$PW $P-mysql mysql -h127.0.0.1 -uroot -t "$@"; }
mark() { sql repro -e "INSERT INTO marks (name) VALUES ('$1')" </dev/null; }

# --- gh-ost binary: official release, checksum-verified --------------------------------------
case "$(docker info --format '{{.Architecture}}')" in
  aarch64|arm64) A=arm64 ;;
  x86_64|amd64)  A=amd64 ;;
  *) die "unsupported docker architecture" ;;
esac
BIN=$HERE/gh-ost-$GHOST_VERSION-linux-$A
if [ ! -x "$BIN/gh-ost" ]; then
  mkdir -p "$BIN"
  base=https://github.com/github/gh-ost/releases/download/$GHOST_VERSION
  curl -fsSL -o "$BIN/gh-ost-linux-$A.tar.gz" "$base/gh-ost-linux-$A.tar.gz"
  curl -fsSL -o "$BIN/SHA256SUMS" "$base/SHA256SUMS"
  ( cd "$BIN" && grep " gh-ost-linux-$A.tar.gz\$" SHA256SUMS > want.sum &&
    { sha256sum -c want.sum 2>/dev/null || shasum -a 256 -c want.sum; } &&
    tar -xzf "gh-ost-linux-$A.tar.gz" gh-ost )
fi

# --- fresh MySQL and work dir ----------------------------------------------------------------
docker rm -fv $P-mysql $P-ghost-1 $P-ghost-2 >/dev/null 2>&1 || true
docker network rm $P-net >/dev/null 2>&1 || true
rm -rf "$WORK" && mkdir -p "$WORK/hooks"
cp "${GHOST_BIN:-$BIN/gh-ost}" "$WORK/gh-ost"

docker network create $P-net >/dev/null
docker run -d --name $P-mysql --network $P-net -e MYSQL_ROOT_PASSWORD=$PW "$MYSQL_IMAGE" \
  --server-id=1 --log-bin=binlog --binlog-format=ROW --binlog-row-image=FULL >/dev/null
for _ in $(seq 120); do sql -e 'SELECT 1' </dev/null >/dev/null 2>&1 && break; sleep 1; done
sql -e 'SELECT 1' </dev/null >/dev/null || die "mysql did not start"

log "gh-ost $("$WORK/gh-ost" --version 2>/dev/null || docker run --rm -v "$WORK:/work" --entrypoint /work/gh-ost "$MYSQL_IMAGE" --version) ($A), $MYSQL_IMAGE ($(sql -e 'SELECT @@version' </dev/null)), ROWS=$ROWS HOOK_DELAY=$HOOK_DELAY KILL_PHASE=$KILL_PHASE RESUME=$RESUME DOWNTIME=$DOWNTIME NICE_RATIO=$NICE_RATIO GHOST_CPUS=${GHOST_CPUS:-none} GHOST_ENV=${GHOST_ENV:-none} EXTRA_ARGS=${EXTRA_ARGS:-none}"

sql <<SQL
CREATE DATABASE repro;
CREATE TABLE repro.t (
  id  INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  v   INT NOT NULL DEFAULT 0,
  pad VARCHAR(200) NOT NULL DEFAULT ''
);
CREATE TABLE repro.audit (
  seq BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  tid INT NOT NULL,
  ts  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  KEY (tid)
);
CREATE TABLE repro.marks (
  name VARCHAR(40) NOT NULL PRIMARY KEY,
  ts   DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
);
SET SESSION cte_max_recursion_depth = 100000000;
INSERT INTO repro.t (pad)
  WITH RECURSIVE s (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < $ROWS)
  SELECT REPEAT('x', 200) FROM s;
SQL
log "created repro.t with $(sql repro -e 'SELECT COUNT(*) FROM t' </dev/null) rows"

# --- the app: random single-row updates, each recorded in audit in the same transaction -------
writer() {
  while [ ! -f "$WORK/stop-writer" ]; do
    id=$(( (RANDOM * 32768 + RANDOM) % ROWS + 1 ))
    echo "BEGIN; UPDATE t SET v = v + 1 WHERE id = $id; INSERT INTO audit (tid) VALUES ($id); COMMIT;"
    sleep 0.01
  done
}
writer | docker exec -i -e MYSQL_PWD=$PW $P-mysql mysql -h127.0.0.1 -uroot repro &
WRITER_PID=$!
log "app writer started"

# --- gh-ost ---------------------------------------------------------------------------------
if [ "$HOOK_DELAY" != 0 ]; then
  cat >"$WORK/hooks/gh-ost-on-validated" <<HOOK
#!/bin/sh
echo "\$(date -u +%H:%M:%S.%N) on-validated hook start (sleeping $HOOK_DELAY s)" >> /work/hooks.log
sleep $HOOK_DELAY
echo "\$(date -u +%H:%M:%S.%N) on-validated hook end" >> /work/hooks.log
HOOK
  chmod +x "$WORK/hooks/gh-ost-on-validated"
  HOOK_ARGS=(--hooks-path=/work/hooks)
else
  HOOK_ARGS=()
fi
touch "$WORK/postpone-cut-over.flag"

GHOST_ARGS=(
  --host=$P-mysql --port=3306 --user=root --password=$PW
  --database=repro --table=t
  --alter="ADD COLUMN note VARCHAR(20) NULL"
  --allow-on-master
  --checkpoint --checkpoint-seconds=10
  --postpone-cut-over-flag-file=/work/postpone-cut-over.flag
  --chunk-size=1000
  --verbose
  --execute
)
if [ "$NICE_RATIO" != 0 ]; then GHOST_ARGS+=(--nice-ratio="$NICE_RATIO"); fi
# shellcheck disable=SC2206
GHOST_ARGS+=(${HOOK_ARGS[@]+"${HOOK_ARGS[@]}"} $EXTRA_ARGS)

start_ghost() { # name, extra args...
  local name=$1; shift
  local opts=(-d --name "$name" --network $P-net -v "$WORK:/work" --entrypoint /work/gh-ost)
  [ -n "$GHOST_CPUS" ] && opts+=(--cpus "$GHOST_CPUS")
  [ -n "$GHOST_ENV" ] && opts+=(-e "$GHOST_ENV")
  docker run "${opts[@]}" "$MYSQL_IMAGE" "${GHOST_ARGS[@]}" "$@" >/dev/null
}

count_log() { docker logs "$1" 2>&1 | grep -c -- "$2" || true; }

wait_log() { # container, pattern, [min count], [timeout seconds]
  local c=$1 pat=$2 n=${3:-1} t=${4:-600} start=$SECONDS
  while [ "$(count_log "$c" "$pat")" -lt "$n" ]; do
    [ "$(docker inspect -f '{{.State.Running}}' "$c")" = true ] || { docker logs --tail 20 "$c"; die "$c exited while waiting for '$pat'"; }
    [ $((SECONDS - start)) -lt "$t" ] || die "timed out waiting for '$pat' in $c"
    sleep 0.2
  done
}

mark run1_start
start_ghost $P-ghost-1
log "gh-ost run 1 started"
FINAL=$P-ghost-1

if [ "$RESUME" = 1 ]; then
  if [ "$KILL_PHASE" = postponed ]; then
    wait_log $P-ghost-1 'Row copy complete'
    log "run 1: row copy complete, cut-over postponed"
  fi
  n=$(count_log $P-ghost-1 'checkpoint success')
  wait_log $P-ghost-1 'checkpoint success' $((n + 1))
  log "run 1: $(docker logs $P-ghost-1 2>&1 | grep 'checkpoint success' | tail -1 | sed 's/.*checkpoint success/checkpoint success/')"
  if [ "$KILL_PHASE" = copy ] && [ "$(count_log $P-ghost-1 'Row copy complete')" -gt 0 ]; then
    die "row copy finished before the first checkpoint; raise ROWS or NICE_RATIO for KILL_PHASE=copy"
  fi
  sleep 1
  docker kill $P-ghost-1 >/dev/null   # SIGKILL, like a crashed host or an evicted pod
  mark killed
  log "run 1: SIGKILLed 1 s after that checkpoint; app keeps writing; down for $DOWNTIME s"
  sleep "$DOWNTIME"

  mark run2_start
  start_ghost $P-ghost-2 --resume
  log "gh-ost run 2 (--resume) started"
  wait_log $P-ghost-2 'Row copy complete'
  wait_log $P-ghost-2 'postponing cut-over'
  log "run 2: $(docker logs $P-ghost-2 2>&1 | grep -m1 'Resuming from checkpoint' | sed 's/.*Resuming/Resuming/')"
  log "run 2: row copy complete, cut-over postponed"
  FINAL=$P-ghost-2
else
  wait_log $P-ghost-1 'Row copy complete'
  wait_log $P-ghost-1 'postponing cut-over'
  log "run 1: row copy complete, cut-over postponed (control: no interruption)"
fi

sleep 3
touch "$WORK/stop-writer"
wait "$WRITER_PID" || die "app writer failed"
WRITER_PID=
mark writer_stopped
log "app writer stopped after $(sql repro -e 'SELECT COUNT(*) FROM audit' </dev/null) committed updates"

sleep 2
rm -f "$WORK/postpone-cut-over.flag"
log "postpone flag removed; waiting for cut-over"
rc=$(docker wait $FINAL)
log "gh-ost exit code: $rc; last line: $(docker logs $FINAL 2>&1 | grep -E 'Done migrating|FATAL|ERROR' | tail -1)"

# --- verify ---------------------------------------------------------------------------------
sqlt repro >>"$OUT/summary.txt" <<'SQL'
SELECT name, ts FROM marks ORDER BY ts;
SQL
RESULT=$(sql repro <<'SQL'
WITH expected AS (SELECT tid, COUNT(*) AS n FROM audit GROUP BY tid)
SELECT
  (SELECT COUNT(*) FROM t),
  (SELECT COUNT(*) FROM _t_del),
  (SELECT COUNT(*) FROM expected e JOIN t     x ON x.id = e.tid WHERE x.v <> e.n),
  (SELECT COALESCE(SUM(e.n - x.v), 0) FROM expected e JOIN t x ON x.id = e.tid WHERE x.v <> e.n),
  (SELECT COUNT(*) FROM expected e JOIN _t_del x ON x.id = e.tid WHERE x.v <> e.n),
  (SELECT COUNT(*) FROM t x LEFT JOIN expected e ON e.tid = x.id WHERE e.tid IS NULL AND x.v <> 0);
SQL
)
read -r new_rows old_rows new_wrong new_lost old_wrong new_extra <<<"$RESULT"
log "rows: new t=$new_rows, old _t_del=$old_rows"
log "old table _t_del vs audit: $old_wrong rows wrong (sanity check, must be 0)"
log "new table t      vs audit: $new_wrong rows wrong, $new_lost updates missing, $new_extra rows with unexpected updates"
if [ "$new_wrong" -gt 0 ]; then
  sqlt repro >>"$OUT/summary.txt" <<'SQL'
-- Lost updates: rows where the new table holds none of the row's committed updates.
SELECT COUNT(*) AS lost_updates_on_untouched_rows, MIN(a.ts) AS first_committed, MAX(a.ts) AS last_committed
FROM audit a JOIN t x ON x.id = a.tid WHERE x.v = 0;
-- Which rows: ids of rows whose value in the new table is wrong.
WITH expected AS (SELECT tid, COUNT(*) AS n FROM audit GROUP BY tid)
SELECT MIN(x.id) AS lowest_wrong_id, MAX(x.id) AS highest_wrong_id
FROM expected e JOIN t x ON x.id = e.tid WHERE x.v <> e.n;
-- A few examples: committed updates per audit vs the value in the new and old tables.
WITH expected AS (SELECT tid, COUNT(*) AS n FROM audit GROUP BY tid)
SELECT e.tid AS id, e.n AS committed_updates, x.v AS v_in_new_t, o.v AS v_in_old_t_del
FROM expected e JOIN t x ON x.id = e.tid JOIN _t_del o ON o.id = e.tid
WHERE x.v <> e.n ORDER BY e.tid LIMIT 5;
SQL
fi
[ -f "$WORK/hooks.log" ] && cp "$WORK/hooks.log" "$OUT/hooks.log"

if [ "$rc" = 0 ] && [ "$new_wrong" -gt 0 ] && [ "$old_wrong" = 0 ]; then
  log "RESULT: BUG REPRODUCED: gh-ost exited 0 but the new table lost $new_lost committed updates ($new_wrong rows)"
  exit 1
elif [ "$rc" = 0 ] && [ "$new_wrong" = 0 ] && [ "$old_wrong" = 0 ] && [ "$new_extra" = 0 ]; then
  log "RESULT: no data loss"
  exit 0
else
  log "RESULT: inconclusive (gh-ost rc=$rc, new_wrong=$new_wrong, old_wrong=$old_wrong, extra=$new_extra)"
  exit 2
fi

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions