From 6b1b8614780652da253777fe69d2cdfeef2112ce Mon Sep 17 00:00:00 2001 From: Henrique Kraemer <822026+henriquekraemer@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:41:57 -0300 Subject: [PATCH] feat(module-postgres): configurable snapshot socket timeout Adds a snapshot_socket_timeout connection option (seconds) for the idle timeout on snapshot connection sockets, defaulting to the previous fixed 30 seconds. When the storage cannot keep up with the snapshot, a storage flush can stall the snapshot loop for longer than the timeout, and the idle timeout kills the source connection mid-snapshot. The snapshot resumes, but each kill costs a retry delay plus re-reading the current chunk. This follows the same pattern as the replication_socket_timeout option proposed in powersync-ja/powersync-service#715, for the snapshot connection. --- .changeset/snapshot-socket-timeout.md | 5 +++ .../src/replication/PgManager.ts | 4 +- modules/module-postgres/src/types/types.ts | 17 +++++++-- .../module-postgres/test/src/config.test.ts | 37 +++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 .changeset/snapshot-socket-timeout.md create mode 100644 modules/module-postgres/test/src/config.test.ts diff --git a/.changeset/snapshot-socket-timeout.md b/.changeset/snapshot-socket-timeout.md new file mode 100644 index 000000000..6f12fb956 --- /dev/null +++ b/.changeset/snapshot-socket-timeout.md @@ -0,0 +1,5 @@ +--- +'@powersync/service-module-postgres': minor +--- + +Add a `snapshot_socket_timeout` connection option for the idle timeout of snapshot connection sockets. Defaults to the previous fixed 30 seconds. When storage flushes stall the snapshot loop for longer than the timeout (for example when replicating while an active sync rules instance is streaming on the same storage), the source connection is killed mid-snapshot; raising the timeout avoids the reconnect cycle. diff --git a/modules/module-postgres/src/replication/PgManager.ts b/modules/module-postgres/src/replication/PgManager.ts index 612d631ac..fb0682636 100644 --- a/modules/module-postgres/src/replication/PgManager.ts +++ b/modules/module-postgres/src/replication/PgManager.ts @@ -11,6 +11,8 @@ export interface PgManagerOptions extends pgwire.PgPoolOptions {} /** * Shorter timeout for snapshot connections than for replication connections. + * + * Default, can be overridden with the `snapshot_socket_timeout` connection option. */ const SNAPSHOT_SOCKET_TIMEOUT = 30_000; @@ -74,7 +76,7 @@ export class PgManager extends BaseObserver { // Since we are constantly using the connection, we don't need any // custom keepalives. - (connection as any)._socket.setTimeout(SNAPSHOT_SOCKET_TIMEOUT); + (connection as any)._socket.setTimeout(this.options.snapshot_socket_timeout_ms ?? SNAPSHOT_SOCKET_TIMEOUT); // Disable statement timeout for snapshot queries. // On Supabase, the default is 2 minutes. diff --git a/modules/module-postgres/src/types/types.ts b/modules/module-postgres/src/types/types.ts index 4de2ac0ed..b76260382 100644 --- a/modules/module-postgres/src/types/types.ts +++ b/modules/module-postgres/src/types/types.ts @@ -5,14 +5,24 @@ import * as t from 'ts-codec'; // Maintain backwards compatibility by exporting these export const validatePort = lib_postgres.validatePort; export const baseUri = lib_postgres.baseUri; -export type NormalizedPostgresConnectionConfig = lib_postgres.NormalizedBasePostgresConnectionConfig; +export interface NormalizedPostgresConnectionConfig extends lib_postgres.NormalizedBasePostgresConnectionConfig { + snapshot_socket_timeout_ms?: number | undefined; +} export const POSTGRES_CONNECTION_TYPE = lib_postgres.POSTGRES_CONNECTION_TYPE; export const PostgresConnectionConfig = service_types.configFile.DataSourceConfig.and( lib_postgres.BasePostgresConnectionConfig ).and( t.object({ - // Add any replication connection specific config here in future + /** + * Idle timeout in seconds for snapshot connection sockets. + * + * Defaults to 30 seconds. When the storage cannot keep up with the snapshot, + * a storage flush can stall the snapshot loop for longer than this, killing + * the source connection mid-snapshot. Raising the timeout gives the source + * connection more slack under storage backpressure. + */ + snapshot_socket_timeout: t.number.optional() }) ); @@ -39,6 +49,7 @@ export function isPostgresConfig( */ export function normalizeConnectionConfig(options: PostgresConnectionConfig) { return { - ...lib_postgres.normalizeConnectionConfig(options) + ...lib_postgres.normalizeConnectionConfig(options), + snapshot_socket_timeout_ms: lib_postgres.parseConnectTimeout(options.snapshot_socket_timeout, undefined) } satisfies NormalizedPostgresConnectionConfig; } diff --git a/modules/module-postgres/test/src/config.test.ts b/modules/module-postgres/test/src/config.test.ts new file mode 100644 index 000000000..b938ddda2 --- /dev/null +++ b/modules/module-postgres/test/src/config.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest'; +import { normalizeConnectionConfig } from '../../src/types/types.js'; + +describe('config', () => { + describe('snapshot_socket_timeout', () => { + test('normalizes snapshot socket timeout from seconds to milliseconds', () => { + const normalized = normalizeConnectionConfig({ + type: 'postgresql', + uri: 'postgresql://postgres:postgres@localhost:4321/powersync_test', + snapshot_socket_timeout: 90 + }); + + expect(normalized.snapshot_socket_timeout_ms).equals(90_000); + }); + + test('leaves snapshot socket timeout unset by default', () => { + const normalized = normalizeConnectionConfig({ + type: 'postgresql', + uri: 'postgresql://postgres:postgres@localhost:4321/powersync_test' + }); + + expect(normalized.snapshot_socket_timeout_ms).toBeUndefined(); + }); + + test('ignores invalid snapshot socket timeout values', () => { + for (const invalid of [0, -5, NaN, Infinity]) { + const normalized = normalizeConnectionConfig({ + type: 'postgresql', + uri: 'postgresql://postgres:postgres@localhost:4321/powersync_test', + snapshot_socket_timeout: invalid + }); + + expect(normalized.snapshot_socket_timeout_ms, `value ${invalid}`).toBeUndefined(); + } + }); + }); +});