Skip to content

K8SPS-866: stop pt-heartbeat from starting during a clone - #1518

Draft
hors wants to merge 5 commits into
mainfrom
heartbeat-sidecar-fix
Draft

K8SPS-866: stop pt-heartbeat from starting during a clone#1518
hors wants to merge 5 commits into
mainfrom
heartbeat-sidecar-fix

Conversation

@hors

@hors hors commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

When a new async replica joins, MySQL copies the data with a clone. Two
problems around that clone are fixed here.

  1. pt-heartbeat crashed during the clone

While the clone runs, the sys_operator database does not exist yet. The
pt-heartbeat sidecar started too early and kept crashing with
"Unknown database 'sys_operator'". It decided the data was ready by
looking at the clone.lock file, but a native InnoDB clone does not
reliably create that file, so the check was wrong.

Now the sidecar asks the database directly and starts pt-heartbeat only
after the clone is no longer "In Progress" and sys_operator is present.
It waits for as long as that takes - a large clone can run for hours and
waiting costs nothing; a genuinely stuck replica is already shown by the
mysql container's own readiness. There is no wait timeout to tune.

A clone also finishes with a mandatory mysqld restart to finalize the
data. The datadir looks ready for a few seconds before that restart, so
pt-heartbeat could start just in time to hit it and exit once with
"Server shutdown in progress". pt-heartbeat now runs under a small retry
loop: if it exits shortly after starting, the script waits for MySQL to
come back and starts it again in place, so a fresh replica no longer
shows a container restart. A pt-heartbeat that ran for a while and then
exits is treated as a real failure and the container is allowed to
restart.

  1. the clone itself now has no fixed timeout

A fixed clone timeout is always a guess: too short aborts a real clone
of a large/slow dataset, too long delays noticing a hung one. Instead of
a fixed timeout, the bootstrap now watches the clone's progress: it polls
how many bytes the clone has transferred (performance_schema.clone_progress)
and aborts the clone only if it makes no progress for a while
(BOOTSTRAP_CLONE_STALL_TIMEOUT, default 900s / 15 min). A clone that keeps
moving runs as long as it needs; a frozen one is aborted, which fails the
startup probe and lets kubelet restart the container and retry. This all
happens inside the pod, so it works with the operator down.

Set BOOTSTRAP_CLONE_STALL_TIMEOUT to 0 (via spec.mysql.env) to turn the
watchdog off and let the clone run with no progress check at all. The
startup probe timeout (which caps how long the clone may run) is raised
to 7 days so a long but progressing clone is not killed by kubelet.

All of the new clone behaviour is enabled only from crVersion 1.3.0.
Older clusters keep their previous behaviour, so upgrading just the
operator does not change their pod template or restart their pods.

CHECKLIST

Jira

  • Is the Jira ticket created and referenced properly?
  • Does the Jira ticket have the proper statuses for documentation (Needs Doc) and QA (Needs QA)?
  • Does the Jira ticket link to the proper milestone (Fix Version field)?

Tests

  • Is an E2E test/test case added for the new feature/change?
  • Are unit tests added where appropriate?

Config/Logging/Testability

  • Are all needed new/changed options added to default YAML files?
  • Are all needed new/changed options added to the Helm Chart?
  • Did we add proper logging messages for operator actions?
  • Did we ensure compatibility with the previous version or cluster upgrade process?
  • Does the change support oldest and newest supported PS version?
  • Does the change support oldest and newest supported Kubernetes version?

When a new replica joins, MySQL copies the data with a clone. While the
clone is running, the sys_operator database does not exist yet. The
pt-heartbeat sidecar used to start too early and kept crashing with
"Unknown database 'sys_operator'".

Now the sidecar asks the database directly. It starts pt-heartbeat only
after:
  - the clone is no longer "In Progress", and
  - the sys_operator database is present.
If the data is still not ready when the timeout is reached, the sidecar
exits instead of running against an incomplete database.

How long to wait is now a real setting: spec.mysql.cloneTimeoutSeconds.
It defaults to 21600 (6 hours), which is generous for very large
(multi-TiB) datasets. The same value is passed to both the bootstrap
clone (BOOTSTRAP_CLONE_TIMEOUT) and the sidecar wait
(CLONE_TIMEOUT_SECONDS), so there is one setting to change instead of
two hidden ones.

api changes: add spec.mysql.cloneTimeoutSeconds
@pull-request-size pull-request-size Bot added the size/L 100-499 lines label Aug 28, 2026
When a new replica joins, MySQL copies the data with a clone. While the
clone is running, the sys_operator database does not exist yet. The
pt-heartbeat sidecar used to start too early and kept crashing with
"Unknown database 'sys_operator'".

Now the sidecar asks the database directly and starts pt-heartbeat only
after the clone is no longer "In Progress" and the sys_operator database
is present. It waits for as long as that takes - a clone of a large
dataset can run for hours, and waiting costs nothing. There is no wait
timeout: a genuinely stuck replica is already surfaced by the mysql
container's own readiness, so a timeout only added noise (a slow clone
looked like a crash loop) and a value nobody could tune for multi-TiB
datasets. The sidecar stops only on shutdown.

A clone also finishes with a mandatory mysqld restart to finalize the
data. The datadir looks ready a few seconds before that restart, so
pt-heartbeat can start just in time to hit it and exit once with "Server
shutdown in progress". To avoid a container restart, pt-heartbeat now
runs under a small retry loop: if it exits shortly after starting, the
script waits for MySQL to come back and starts it again in place. A
pt-heartbeat that ran for a while before exiting, or that keeps exiting,
is treated as a real failure and the container is allowed to restart.

The clone operation itself still has a timeout (BOOTSTRAP_CLONE_TIMEOUT)
as a safety net so a hung clone can abort and retry instead of hanging
forever. Its default is generous (6h) and it stays configurable through
spec.mysql.env for the rare very large dataset.
@github-actions github-actions Bot removed the tests label Aug 28, 2026
@hors hors changed the title K8SPS-XXX: stop pt-heartbeat from starting during a clone K8SPS-866: stop pt-heartbeat from starting during a clone Aug 28, 2026
@hors
hors requested a lite review from Copilot August 29, 2026 19:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The updated heartbeat entrypoint’s mysql output parsing/quoting can cause incorrect state detection (including infinite waiting) and the new 6h clone timeout can still be undercut by the existing 1h driver read timeout during CLONE INSTANCE.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adjusts the pt-heartbeat sidecar startup behavior during replica bootstrap/cloning so it no longer crash-loops while the clone is still “In Progress” and required schemas (e.g., sys_operator) are not yet available, and it makes bootstrap clone operations less likely to fail on large datasets by increasing the default clone timeout.

Changes:

  • Increase the default bootstrap clone timeout (BOOTSTRAP_CLONE_TIMEOUT) from 1h to 6h.
  • Gate pt-heartbeat startup on MySQL clone state + sys_operator existence, and add a bounded retry loop to avoid a container restart during the post-clone mysqld restart.
  • Gate emission of legacy CLONE_TIMEOUT_SECONDS env var to CR versions < 1.3.0, and add a unit test to ensure the gating behavior.
File summaries
File Description
pkg/mysql/mysql.go Introduces a 6h default clone timeout constant and gates legacy heartbeat env injection by CR version.
pkg/mysql/mysql_test.go Adds coverage verifying CLONE_TIMEOUT_SECONDS env is present pre-1.3.0 and dropped from 1.3.0+.
cmd/internal/db/db.go Updates DB default clone timeout to 6h for bootstrap clone operations.
cmd/internal/db/db_test.go Updates expected default clone timeout values to 6h.
build/heartbeat-entrypoint.sh Reworks heartbeat startup gating to query clone state + schema presence and adds in-process retry logic around pt-heartbeat to avoid restart churn.
Review details

Suppressed comments (1)

build/heartbeat-entrypoint.sh:136

  • The MySQL wait loop uses an unquoted environment assignment (MYSQL_PWD=${MYSQL_PASSWORD}) before invoking mysql. If the password contains spaces or shell-special characters, this can break the retry check and cause an unnecessary restart.
	until [ "$shutdown_requested" -eq 1 ] || MYSQL_PWD=${MYSQL_PASSWORD} $MYSQL_CMDLINE -P$MYSQL_ADMIN_PORT -e 'SELECT 1;' >/dev/null 2>&1; do
		sleep 2
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 52 to +55
CLONE_STATUS=$(MYSQL_PWD=${MYSQL_PASSWORD} $MYSQL_CMDLINE -P$MYSQL_ADMIN_PORT -e 'SELECT STATE FROM performance_schema.clone_status;' | sed -n -e '2p' | tr -d '\n')
if [[ $CLONE_STATUS == "Completed" || -z $CLONE_IN_PROGRESS ]]; then
echo '[INFO] Clone completed, starting pt-heartbeat'
break
if [[ $CLONE_STATUS != "In Progress" ]]; then
HAS_SYS_OPERATOR=$(MYSQL_PWD=${MYSQL_PASSWORD} $MYSQL_CMDLINE -P$MYSQL_ADMIN_PORT -e "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='sys_operator';" | sed -n -e '2p' | tr -d '\n')
if [[ $HAS_SYS_OPERATOR == "sys_operator" ]]; then
Comment thread cmd/internal/db/db.go
Comment on lines 60 to 62
if p.CloneTimeoutSeconds == 0 {
p.CloneTimeoutSeconds = defs.DefaultCloneTimeoutSecondsSeconds // 1 hour for clone operations (large databases can take time)
p.CloneTimeoutSeconds = defs.DefaultCloneTimeoutSeconds // generous default; large databases can take hours to clone
}
Comment thread pkg/mysql/mysql.go
Comment on lines +49 to +53
// DefaultCloneTimeoutSeconds is the default for the bootstrap clone timeout
// (BOOTSTRAP_CLONE_TIMEOUT) when it is not set via spec.mysql.env. It is
// generous (6h) because multi-TiB clones can run for hours; it is a safety
// net that lets a genuinely hung clone abort and retry, not a tuning knob.
DefaultCloneTimeoutSeconds = 21600

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need this safety net? we'll increase this to 6 hours and someone will definitely complain how 6 hours is not enough for their dataset size.

i think we need to disable timeout for clone operations and if we are worried that it might hung, find a better way to detect it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, a fixed clone timeout is always a guess if too short aborts a real clone
of a large/slow dataset, too long delays noticing a hung one. Instead of
a fixed timeout, let's allow bootstrap watches to monitor the clone's progress. WDYT?

@github-actions github-actions Bot added the tests label Sep 3, 2026
@JNKPercona

Copy link
Copy Markdown
Collaborator
Test Name Result Time
async-ignore-annotations-8-4 passed 00:06:55
async-global-metadata-8-4 passed 00:13:50
async-upgrade-8-0 passed 00:12:36
async-upgrade-8-4 passed 00:12:40
auto-config-8-4 passed 00:18:24
config-8-4 failure 00:20:18
config-router-8-0 passed 00:07:53
config-router-8-4 passed 00:07:41
custom-users-8-4 passed 00:05:39
demand-backup-8-0 passed 00:20:10
demand-backup-8-4 passed 00:20:08
gr-pitr-minio-8-4 passed 00:28:19
gr-pitr-encrypted-minio-8-4 passed 00:16:25
gr-pitr-one-pod-8-4 passed 00:11:56
async-pitr-minio-8-4 passed 00:24:42
demand-backup-cloud-8-4 passed 00:21:41
demand-backup-retry-8-4 passed 00:17:31
demand-backup-incremental-8-0 passed 00:33:51
demand-backup-incremental-8-4 passed 00:31:46
async-data-at-rest-encryption-8-0 passed 00:12:58
async-data-at-rest-encryption-8-4 passed 00:14:08
gr-cross-cluster-8-0 passed 00:29:48
gr-cross-cluster-8-4 passed 00:33:03
gr-cross-cluster-backup-8-0 passed 00:26:16
gr-cross-cluster-backup-8-4 passed 00:27:16
gr-global-metadata-8-4 passed 00:14:38
gr-data-at-rest-encryption-8-0 passed 00:16:27
gr-data-at-rest-encryption-8-4 passed 00:15:12
gr-demand-backup-8-4 passed 00:13:28
gr-demand-backup-cloud-8-4 passed 00:23:59
gr-demand-backup-haproxy-8-4 passed 00:10:30
gr-demand-backup-incremental-8-0 passed 00:25:04
gr-demand-backup-incremental-8-4 passed 00:24:00
gr-demand-backup-incremental-compressed-8-0 passed 00:11:59
gr-demand-backup-incremental-compressed-8-4 passed 00:11:27
gr-demand-backup-incremental-encrypted-8-0 passed 00:18:18
gr-demand-backup-incremental-encrypted-8-4 passed 00:17:45
gr-finalizer-8-4 passed 00:05:53
gr-haproxy-8-0 passed 00:04:31
gr-haproxy-8-4 passed 00:04:16
gr-ignore-annotations-8-4 passed 00:04:50
gr-init-deploy-8-0 passed 00:09:38
gr-init-deploy-8-4 passed 00:09:17
gr-one-pod-8-4 passed 00:06:50
gr-recreate-8-4 passed 00:17:03
gr-scaling-8-4 passed 00:08:17
gr-scheduled-backup-8-4 passed 00:26:16
gr-scheduled-backup-incremental-8-4 passed 00:37:53
gr-security-context-8-4 passed 00:10:03
gr-self-healing-8-4 passed 00:22:03
gr-tls-cert-manager-8-4 passed 00:10:53
gr-users-8-4 passed 00:07:32
gr-upgrade-8-0 passed 00:10:24
gr-upgrade-8-4 passed 00:09:58
haproxy-8-0 passed 00:08:21
haproxy-8-4 passed 00:08:20
init-deploy-8-0 passed 00:06:54
init-deploy-8-4 passed 00:06:01
limits-8-4 passed 00:04:14
monitoring-8-4 passed 00:18:31
one-pod-8-0 passed 00:06:10
one-pod-8-4 passed 00:06:00
operator-self-healing-8-4 passed 00:11:36
pvc-auto-resize-8-4 passed 00:05:07
pvc-resize-8-4 passed 00:14:32
recreate-8-4 passed 00:13:05
scaling-8-4 passed 00:10:15
scheduled-backup-8-0 passed 00:24:45
scheduled-backup-8-4 failure 00:26:35
scheduled-backup-incremental-8-0 passed 00:32:41
scheduled-backup-incremental-8-4 passed 00:33:27
service-per-pod-8-4 passed 00:06:17
sidecars-8-4 passed 00:05:05
smart-update-8-4 passed 00:09:52
storage-8-4 passed 00:04:09
switch-cluster-type-8-4 passed 00:11:22
telemetry-8-4 passed 00:06:06
tls-cert-manager-8-4 passed 00:12:17
users-8-0 passed 00:07:47
users-8-4 passed 00:08:11
version-service-8-4 passed 00:20:31
Summary Value
Tests Run 81/81
Job Duration 03:23:45
Total Test Time 20:13:09

commit: 8e2edad
image: perconalab/percona-server-mysql-operator:PR-1518-8e2edad1

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants