Skip to content

Commit b37fa20

Browse files
fix: filter out set transaction timeout
1 parent a9b50b5 commit b37fa20

3 files changed

Lines changed: 167 additions & 24 deletions

File tree

flow/activities/flowable.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2196,11 +2196,17 @@ func (a *FlowableActivity) RunPgDumpSchema(
21962196

21972197
logger.Info("running pg_dump schema migration from source to destination",
21982198
slog.String("source", input.SourceName), slog.String("destination", input.DestinationName))
2199+
a.Alerter.LogFlowInfo(ctx, input.FlowName,
2200+
fmt.Sprintf("starting pg_dump schema migration from %s to %s", input.SourceName, input.DestinationName))
21992201

2202+
start := time.Now()
22002203
if err := connpostgres.RunPgDumpSchema(ctx, srcPgConfig.PostgresConfig, dstPgConfig.PostgresConfig); err != nil {
22012204
return false, a.Alerter.LogFlowError(ctx, input.FlowName, fmt.Errorf("pg_dump schema migration failed: %w", err))
22022205
}
22032206

2204-
logger.Info("pg_dump schema migration completed successfully")
2207+
elapsed := time.Since(start).Round(time.Millisecond)
2208+
logger.Info("pg_dump schema migration completed successfully", slog.Duration("elapsed", elapsed))
2209+
a.Alerter.LogFlowInfo(ctx, input.FlowName,
2210+
fmt.Sprintf("pg_dump schema migration completed successfully in %s", elapsed))
22052211
return true, nil
22062212
}

flow/connectors/postgres/pgdump_schema.go

Lines changed: 122 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
package connpostgres
22

33
import (
4+
"bufio"
45
"bytes"
56
"context"
67
"fmt"
8+
"io"
79
"log/slog"
810
"os"
911
"os/exec"
12+
"regexp"
1013
"strconv"
1114

1215
"github.com/PeerDB-io/peerdb/flow/generated/protos"
1316
)
1417

18+
// pg_dump from newer Postgres versions emits statements that older
19+
// destinations don't recognize:
20+
// - SET transaction_timeout = 0; (PG17+ session GUC)
21+
// - \restrict / \unrestrict <token> (pg_dump 17.6+ psql meta-commands
22+
// that gate replay against an unrelated psql session; older psql treats
23+
// them as unknown backslash commands and aborts under ON_ERROR_STOP)
24+
//
25+
// These are session/replay housekeeping and safe to drop on the wire so we
26+
// keep ON_ERROR_STOP=1 for genuine DDL failures while remaining cross-version.
27+
var incompatibleLineRE = regexp.MustCompile(`^(SET\s+transaction_timeout\s*=|\\(?:un)?restrict(\s|$))`)
28+
1529
// RunPgDumpSchema streams a schema-only pg_dump from source directly into psql
1630
// on the destination, piping stdout into stdin without intermediate files.
1731
func RunPgDumpSchema(ctx context.Context, srcConfig *protos.PostgresConfig, dstConfig *protos.PostgresConfig) error {
@@ -43,39 +57,123 @@ func pipeCommand(
4357
appendTLSEnv(ctx, srcCmd, srcConfig)
4458
appendTLSEnv(ctx, psqlCmd, dstConfig)
4559

46-
return runPipeline(srcCmd, psqlCmd, srcBinary, "psql")
60+
return runPipeline(ctx, srcCmd, psqlCmd, srcBinary, "psql", filterIncompatibleLines)
61+
}
62+
63+
// filterIncompatibleLines copies r->w line by line, dropping statements that
64+
// are valid in newer pg_dump output but rejected by older psql/destinations.
65+
func filterIncompatibleLines(ctx context.Context, r io.Reader, w io.Writer) error {
66+
br := bufio.NewReaderSize(r, 64*1024)
67+
for {
68+
line, err := br.ReadBytes('\n')
69+
if len(line) > 0 {
70+
if !incompatibleLineRE.Match(line) {
71+
if _, werr := w.Write(line); werr != nil {
72+
return werr
73+
}
74+
} else {
75+
slog.DebugContext(ctx, "dropping incompatible line from pg_dump stream",
76+
slog.String("line", string(bytes.TrimRight(line, "\n"))))
77+
}
78+
}
79+
if err != nil {
80+
if err == io.EOF {
81+
return nil
82+
}
83+
return err
84+
}
85+
}
4786
}
4887

49-
// runPipeline wires srcCmd's stdout into dstCmd's stdin and waits for both.
50-
func runPipeline(srcCmd, dstCmd *exec.Cmd, srcName, dstName string) error {
51-
pr, pw, err := os.Pipe()
88+
// runPipeline wires srcCmd's stdout into dstCmd's stdin (optionally through a
89+
// filter goroutine) and waits for both processes.
90+
//
91+
// Pipe topology:
92+
//
93+
// without filter: src.stdout -> srcW |--pipe--| srcR -> dst.stdin
94+
// with filter: src.stdout -> srcW |--pipe--| srcR -> filter -> dstW |--pipe--| dstR -> dst.stdin
95+
//
96+
// File descriptor ownership matters here -- if the parent keeps a write end
97+
// open after the child consumer dies, the producer can hang forever on a
98+
// blocked write. We close each fd as soon as the child or filter goroutine
99+
// owns it.
100+
func runPipeline(
101+
ctx context.Context,
102+
srcCmd, dstCmd *exec.Cmd,
103+
srcName, dstName string,
104+
filter func(context.Context, io.Reader, io.Writer) error,
105+
) error {
106+
srcR, srcW, err := os.Pipe()
52107
if err != nil {
53-
return fmt.Errorf("create pipe: %w", err)
108+
return fmt.Errorf("create src pipe: %w", err)
109+
}
110+
srcCmd.Stdout = srcW
111+
112+
var (
113+
dstR, dstW *os.File
114+
filterDone chan error
115+
)
116+
if filter == nil {
117+
dstCmd.Stdin = srcR
118+
} else {
119+
dstR, dstW, err = os.Pipe()
120+
if err != nil {
121+
srcR.Close()
122+
srcW.Close()
123+
return fmt.Errorf("create dst pipe: %w", err)
124+
}
125+
dstCmd.Stdin = dstR
126+
filterDone = make(chan error, 1)
54127
}
55-
srcCmd.Stdout = pw
56-
dstCmd.Stdin = pr
57128

58129
var srcStderr, dstStderr bytes.Buffer
59130
srcCmd.Stderr = &srcStderr
60131
dstCmd.Stderr = &dstStderr
61132

62133
// Start dst first so it's ready to read.
63134
if err := dstCmd.Start(); err != nil {
64-
pr.Close()
65-
pw.Close()
135+
srcR.Close()
136+
srcW.Close()
137+
if dstW != nil {
138+
dstR.Close()
139+
dstW.Close()
140+
}
66141
return fmt.Errorf("start %s: %w", dstName, err)
67142
}
68-
// dst now owns the read end in its child process.
69-
pr.Close()
143+
// dst owns its stdin fd in its child; close our copy.
144+
if filter == nil {
145+
srcR.Close()
146+
} else {
147+
dstR.Close()
148+
}
70149

71150
if err := srcCmd.Start(); err != nil {
72-
pw.Close()
151+
srcW.Close()
152+
if dstW != nil {
153+
// filter never started; close its writer so dst sees EOF.
154+
dstW.Close()
155+
// and the read side we still hold if filter==nil path wasn't taken.
156+
if filter != nil {
157+
srcR.Close()
158+
}
159+
}
73160
_ = dstCmd.Process.Kill()
74161
_ = dstCmd.Wait()
75162
return fmt.Errorf("start %s: %w", srcName, err)
76163
}
77-
// src now owns the write end in its child process.
78-
pw.Close()
164+
// src owns its stdout fd in its child; close our copy.
165+
srcW.Close()
166+
167+
// Run the filter goroutine if configured. It bridges srcR -> dstW.
168+
if filter != nil {
169+
go func() {
170+
err := filter(ctx, srcR, dstW)
171+
// Always close both ends so the producer/consumer unblock.
172+
srcR.Close()
173+
dstW.Close()
174+
filterDone <- err
175+
}()
176+
}
79177

80178
srcDone := make(chan error, 1)
81179
dstDone := make(chan error, 1)
@@ -105,13 +203,23 @@ func runPipeline(srcCmd, dstCmd *exec.Cmd, srcName, dstName string) error {
105203
}
106204
}
107205

206+
// Wait for the filter to finish so we surface any I/O error and so the
207+
// goroutine doesn't outlive this function.
208+
var filterErr error
209+
if filterDone != nil {
210+
filterErr = <-filterDone
211+
}
212+
108213
// Report the original cause, not the side we killed in response.
109214
if dstErr != nil && !dstKilled {
110215
return fmt.Errorf("%s failed: %w\nstderr:\n%s", dstName, dstErr, dstStderr.String())
111216
}
112217
if srcErr != nil && !srcKilled {
113218
return fmt.Errorf("%s failed: %w\nstderr:\n%s", srcName, srcErr, srcStderr.String())
114219
}
220+
if filterErr != nil {
221+
return fmt.Errorf("filter failed: %w", filterErr)
222+
}
115223
// Fallback: both sides killed (e.g. ctx cancel) — surface whichever error we have.
116224
if srcErr != nil {
117225
return fmt.Errorf("%s failed: %w\nstderr:\n%s", srcName, srcErr, srcStderr.String())

flow/connectors/postgres/pgdump_schema_test.go

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestRunPipeline_HappyPath(t *testing.T) {
2828
dst := exec.CommandContext(ctx, "cat")
2929
dst.Stdout = &dstOut
3030

31-
if err := runPipeline(src, dst, "src", "dst"); err != nil {
31+
if err := runPipeline(ctx, src, dst, "src", "dst", nil); err != nil {
3232
t.Fatalf("unexpected error: %v", err)
3333
}
3434
if got := dstOut.String(); got != "hello world" {
@@ -42,7 +42,7 @@ func TestRunPipeline_SrcStartFails(t *testing.T) {
4242
src := exec.CommandContext(ctx, "/nonexistent/peerdb-test-binary")
4343
dst := exec.CommandContext(ctx, "cat")
4444

45-
err := runPipeline(src, dst, "src", "dst")
45+
err := runPipeline(ctx, src, dst, "src", "dst", nil)
4646
if err == nil {
4747
t.Fatal("expected error, got nil")
4848
}
@@ -61,7 +61,7 @@ func TestRunPipeline_DstStartFails(t *testing.T) {
6161
src := exec.CommandContext(ctx, "echo", "hi")
6262
dst := exec.CommandContext(ctx, "/nonexistent/peerdb-test-binary")
6363

64-
err := runPipeline(src, dst, "src", "dst")
64+
err := runPipeline(ctx, src, dst, "src", "dst", nil)
6565
if err == nil {
6666
t.Fatal("expected error, got nil")
6767
}
@@ -83,7 +83,7 @@ func TestRunPipeline_SrcExitsNonZero(t *testing.T) {
8383
dst := exec.CommandContext(ctx, "cat")
8484
dst.Stdout = &bytes.Buffer{}
8585

86-
err := runPipeline(src, dst, "src", "dst")
86+
err := runPipeline(ctx, src, dst, "src", "dst", nil)
8787
if err == nil {
8888
t.Fatal("expected error, got nil")
8989
}
@@ -100,7 +100,7 @@ func TestRunPipeline_DstExitsNonZero(t *testing.T) {
100100
// exit 3 immediately, ignoring stdin
101101
dst := exec.CommandContext(ctx, "sh", "-c", "exit 3")
102102

103-
err := runPipeline(src, dst, "src", "dst")
103+
err := runPipeline(ctx, src, dst, "src", "dst", nil)
104104
if err == nil {
105105
t.Fatal("expected error, got nil")
106106
}
@@ -129,7 +129,7 @@ func TestRunPipeline_SrcFailsWhileDstSlow(t *testing.T) {
129129

130130
start := time.Now()
131131
done := make(chan error, 1)
132-
go func() { done <- runPipeline(src, dst, "src", "dst") }()
132+
go func() { done <- runPipeline(ctx, src, dst, "src", "dst", nil) }()
133133

134134
select {
135135
case err := <-done:
@@ -162,7 +162,7 @@ func TestRunPipeline_DstExitsWhileSrcWriting(t *testing.T) {
162162

163163
start := time.Now()
164164
done := make(chan error, 1)
165-
go func() { done <- runPipeline(src, dst, "src", "dst") }()
165+
go func() { done <- runPipeline(ctx, src, dst, "src", "dst", nil) }()
166166

167167
select {
168168
case err := <-done:
@@ -194,7 +194,7 @@ func TestRunPipeline_LargeStream(t *testing.T) {
194194
dst := exec.CommandContext(ctx, "cat")
195195
dst.Stdout = &out
196196

197-
if err := runPipeline(src, dst, "src", "dst"); err != nil {
197+
if err := runPipeline(ctx, src, dst, "src", "dst", nil); err != nil {
198198
t.Fatalf("unexpected error: %v", err)
199199
}
200200
if out.Len() != size {
@@ -217,7 +217,7 @@ func TestRunPipeline_ContextCancel(t *testing.T) {
217217
dst.Stdout = &bytes.Buffer{}
218218

219219
done := make(chan error, 1)
220-
go func() { done <- runPipeline(src, dst, "src", "dst") }()
220+
go func() { done <- runPipeline(ctx, src, dst, "src", "dst", nil) }()
221221

222222
// give them a moment to start
223223
time.Sleep(100 * time.Millisecond)
@@ -239,3 +239,32 @@ func TestRunPipeline_ContextCancel(t *testing.T) {
239239
t.Fatal("runPipeline did not return after context cancel")
240240
}
241241
}
242+
243+
// TestRunPipeline_FilterStripsLines verifies the filter goroutine drops
244+
// matching lines and forwards the rest. Covers SET transaction_timeout (PG17+)
245+
// and \restrict / \unrestrict psql meta-commands (pg_dump 17.6+).
246+
func TestRunPipeline_FilterStripsLines(t *testing.T) {
247+
requireUnix(t)
248+
ctx := t.Context()
249+
250+
input := "SELECT 1;\n" +
251+
"SET transaction_timeout = 0;\n" +
252+
"\\restrict abc123\n" +
253+
"CREATE TABLE t(id int);\n" +
254+
"\\unrestrict abc123\n" +
255+
"SELECT 2;\n"
256+
src := exec.CommandContext(ctx, "printf", "%s", input)
257+
var out bytes.Buffer
258+
dst := exec.CommandContext(ctx, "cat")
259+
dst.Stdout = &out
260+
261+
if err := runPipeline(ctx, src, dst, "src", "dst", filterIncompatibleLines); err != nil {
262+
t.Fatalf("unexpected error: %v", err)
263+
}
264+
265+
got := out.String()
266+
want := "SELECT 1;\nCREATE TABLE t(id int);\nSELECT 2;\n"
267+
if got != want {
268+
t.Fatalf("filtered output = %q, want %q", got, want)
269+
}
270+
}

0 commit comments

Comments
 (0)