Skip to content

[MySQL] Fix replication stalls caused by silently dropped idle connections - #760

Open
bean1352 wants to merge 7 commits into
mainfrom
fix/mysql-replication-freezes
Open

[MySQL] Fix replication stalls caused by silently dropped idle connections#760
bean1352 wants to merge 7 commits into
mainfrom
fix/mysql-replication-freezes

Conversation

@bean1352

@bean1352 bean1352 commented Aug 20, 2026

Copy link
Copy Markdown

Summary

The PowerSync Service keeps two connections open to MySQL: one that streams changes, and a helper connection used for occasional lookups. The helper can sit idle for hours. Some firewalls quietly close connections that have been idle for an hour. The next time the PowerSync Service tries to use that closed connection, it waits ~15 minutes for the operating system to give up. During that wait replication is completely frozen, nothing is logged, and the PowerSync Service still reports itself as healthy. Shutdown has the same problem, because it sends one last query over that same connection and waits for the answer with no time limit. A self-hosted customer hit this issue five times in two days and traced it to their firewall's one hour idle timeout.

Fix

The MySQL connections now send a keepalive signal every 40 seconds. That is enough for a firewall to see the connection as active, so it never gets closed in the first place. Shutdown also gets a 5 second limit: if the final query gets no answer in that time, the service closes the connection itself instead of waiting.

AI disclaimer

I developed this change using Claude Opus 4.8 for the investigation and Claude Fable 5 for the implementation. I reviewed and tested the changes myself.

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2a2e209

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@powersync/service-module-mysql Patch
@powersync/service-schema Patch
@powersync/service-image Patch
@powersync/service-core Patch
@powersync/service-module-convex Patch
@powersync/service-module-core Patch
@powersync/service-module-mongodb-storage Patch
@powersync/service-module-mongodb Patch
@powersync/service-module-mssql Patch
@powersync/service-module-postgres-storage Patch
@powersync/service-module-postgres Patch
test-client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@rkistner rkistner 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.

To clarify, this specifically enables TCP keepalive, not a mysql-specific keepalive?

Is there an option on MySQL / Zongji to additionally do a mysql-level keepalive? If there is no built-in functionality for that, maybe something along the lines of a SELECT 1 query every minute?

Your work here is definitely an improvement, but the firewall silently dropping the connection due to being idle is not the only case this can happen, and TCP keepalives typically take very long to detect the issue on their own. A protocol-level keepalive with a socketTimeout or equivalent can be a much stronger check.

@bean1352

bean1352 commented Aug 20, 2026

Copy link
Copy Markdown
Author

To clarify, this specifically enables TCP keepalive, not a mysql-specific keepalive?

Is there an option on MySQL / Zongji to additionally do a mysql-level keepalive? If there is no built-in functionality for that, maybe something along the lines of a SELECT 1 query every minute?

Your work here is definitely an improvement, but the firewall silently dropping the connection due to being idle is not the only case this can happen, and TCP keepalives typically take very long to detect the issue on their own. A protocol-level keepalive with a socketTimeout or equivalent can be a much stronger check.

Yes the change was TCP keepalive only. There doesn't seem to be a built-in mysql-level keepalive in zongji or @vlasky/mysql, so I added one along the lines you suggested.

The service now creates the control connection itself and passes it into zongji, then runs SELECT 1 on it once a minute with a 5 second timeout. If the probe gets no answer, the listener destroys the connection and stops with an error, which goes through the normal restart path.

I'll also open a small types PR to powersync-mysql-zongji. PR: powersync-ja/powersync-mysql-zongji#19

@bean1352
bean1352 requested a review from Rentacookie August 20, 2026 13:54
@bean1352
bean1352 marked this pull request as ready for review August 24, 2026 07:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3edb4060bb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +326 to +328
const responded = await new Promise<boolean>((resolve) => {
controlConnection.query('SELECT 1', (error) => resolve(!error));
timers.setTimeout(timeout, undefined, { ref: false }).then(() => resolve(false));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not time queued probes as dead connections

When Zongji is already running a table-metadata query on this control connection, the MySQL client queues this SELECT 1 behind that query. If the metadata query legitimately takes longer than five seconds—for example on an overloaded server—the timer wins even though the connection is healthy, and the code stops replication with an error. Since this repeats every minute, a slow source can enter a restart loop; the probe should use a dedicated connection or avoid applying its response timeout while another control query is in progress.

Useful? React with 👍 / 👎.

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.

Using a separate dedicated connection would defeat the purpose of the probe. But apart from that this makes sense - need to take into account the case where the connection is already waiting on another query.

Comment on lines +307 to +318
private async keepAliveControlConnectionUntilStopped(): Promise<void> {
const interval = this.options.ctrlConnectionKeepAliveIntervalMs ?? CTRL_CONNECTION_KEEPALIVE_INTERVAL;
let idleTime = 0;
while (!this.isStopped) {
await timers.setTimeout(1_000);
idleTime += 1_000;
if (idleTime >= interval && !(this.isStopped || this.isStopping)) {
idleTime = 0;
await this.probeControlConnection();
}
}
}

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.

Instead of having a separate mechanism here, can this use the async keepAlive() trigger in BinLogReplicationJob?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants