6565# Capture psql's stderr for visibility on partial failures.
6666PSQL_STDERR=$(mktemp)
6767
68+ # TCP keepalive options on the connection URI. Without keepalives a
69+ # silently-dropped pod-to-pod TCP connection (network policy change,
70+ # node disruption, etc.) leaves both ends waiting indefinitely — pg_dump
71+ # blocked on read, postgres marked "idle in transaction" with
72+ # wait_event=ClientRead. Observed in production: a migration sat
73+ # stuck for 39 hours after the connection died, with no detection.
74+ # 60s idle + 10s × 3 probes = dead connection killed within ~90s.
75+ KEEPALIVES="keepalives=1&keepalives_idle=60&keepalives_interval=10&keepalives_count=3"
76+ APPNAME="application_name=pgro-schema-migration"
77+ SOURCE_URI="postgresql://${SOURCE_USER}@${SOURCE_HOST}:5432/${SOURCE_DB}?${KEEPALIVES}&${APPNAME}"
78+ TARGET_URI="postgresql://${TARGET_USER}@${TARGET_HOST}:5432/${TARGET_DB}?${KEEPALIVES}&${APPNAME}"
79+
6880# ON_ERROR_STOP is deliberately NOT set: persistent_schemas like dbt
6981# contain views derived from upstream tables, and across upstream schema
7082# changes (renamed columns, dropped tables) some view DDL in the old
@@ -74,13 +86,13 @@ PSQL_STDERR=$(mktemp)
7486# for replica availability — clients can regenerate the broken views
7587# afterward, but the replica must be reachable.
7688PGPASSWORD="$SOURCE_PASSWORD" pg_dump \
77- -h "$SOURCE_HOST" -p 5432 -U "$SOURCE_USER" - d "$SOURCE_DB " \
89+ -d "$SOURCE_URI " \
7890 "${SCHEMA_ARGS[@]}" \
7991 --no-owner --no-privileges \
8092 --no-publications --no-subscriptions \
8193 --verbose \
8294| PGPASSWORD="$TARGET_PASSWORD" psql \
83- -h "$TARGET_HOST" -p 5432 -U "$TARGET_USER" - d "$TARGET_DB " \
95+ -d "$TARGET_URI " \
8496 --quiet 2> >(tee "$PSQL_STDERR" >&2)
8597
8698PSQL_EXIT=$?
@@ -208,6 +220,16 @@ pub fn build_schema_migration_job(
208220 env_literal( "MIGRATION_CALLBACK_URL" , callback_url) ,
209221 ] ) ,
210222 resources: Some ( ResourceRequirements {
223+ // pg_dump and psql each buffer some state per
224+ // large object / row, and for non-trivial
225+ // persistent schemas (dbt with many tables and
226+ // indexes) the streaming pipe peaks well past
227+ // the original 512Mi limit. Observed in
228+ // production: the migration container was being
229+ // OOMKilled dozens of times in succession.
230+ // Bump to a generous limit so the migration
231+ // completes; the Job is short-lived and only
232+ // runs during switchover.
211233 requests: Some ( BTreeMap :: from( [
212234 (
213235 "cpu" . to_string( ) ,
@@ -218,21 +240,21 @@ pub fn build_schema_migration_job(
218240 (
219241 "memory" . to_string( ) ,
220242 k8s_openapi:: apimachinery:: pkg:: api:: resource:: Quantity (
221- "128Mi " . to_string( ) ,
243+ "256Mi " . to_string( ) ,
222244 ) ,
223245 ) ,
224246 ] ) ) ,
225247 limits: Some ( BTreeMap :: from( [
226248 (
227249 "cpu" . to_string( ) ,
228250 k8s_openapi:: apimachinery:: pkg:: api:: resource:: Quantity (
229- "1 " . to_string( ) ,
251+ "2 " . to_string( ) ,
230252 ) ,
231253 ) ,
232254 (
233255 "memory" . to_string( ) ,
234256 k8s_openapi:: apimachinery:: pkg:: api:: resource:: Quantity (
235- "512Mi " . to_string( ) ,
257+ "4Gi " . to_string( ) ,
236258 ) ,
237259 ) ,
238260 ] ) ) ,
@@ -313,6 +335,80 @@ mod tests {
313335 ) ;
314336 }
315337
338+ #[ test]
339+ fn migration_script_uses_tcp_keepalives ( ) {
340+ // Without TCP keepalives a silently-dropped pod-to-pod connection
341+ // leaves pg_dump and psql blocked indefinitely on a dead socket,
342+ // while postgres marks the session "idle in transaction" with
343+ // wait_event=ClientRead. Observed in production: a migration sat
344+ // stuck for 39 hours.
345+ assert ! (
346+ MIGRATION_SCRIPT . contains( "keepalives=1" ) ,
347+ "migration script must enable libpq TCP keepalives"
348+ ) ;
349+ assert ! (
350+ MIGRATION_SCRIPT . contains( "keepalives_idle=" ) ,
351+ "migration script must set keepalives_idle"
352+ ) ;
353+ assert ! (
354+ MIGRATION_SCRIPT . contains( "keepalives_interval=" ) ,
355+ "migration script must set keepalives_interval"
356+ ) ;
357+ assert ! (
358+ MIGRATION_SCRIPT . contains( "keepalives_count=" ) ,
359+ "migration script must set keepalives_count"
360+ ) ;
361+ // application_name shows up in pg_stat_activity, making the
362+ // migration session identifiable for diagnosis.
363+ assert ! (
364+ MIGRATION_SCRIPT . contains( "application_name=pgro-schema-migration" ) ,
365+ "migration script must set application_name for visibility"
366+ ) ;
367+ }
368+
369+ #[ test]
370+ fn migration_job_has_enough_memory_for_dbt_scale_schemas ( ) {
371+ // Default limits must be high enough for realistic
372+ // persistent_schemas like dbt with many tables and indexes.
373+ // pg_dump + psql peak well past the historic 512Mi default and
374+ // OOMKill in production. Memory limit must be at least 2Gi.
375+ let replica = make_replica ( vec ! [ "dbt" ] ) ;
376+ let job = build_schema_migration_job (
377+ & replica,
378+ "test-ns" ,
379+ "old" ,
380+ "new" ,
381+ "db" ,
382+ "db" ,
383+ & [ "dbt" . to_string ( ) ] ,
384+ "reader" ,
385+ "super" ,
386+ "http://op" ,
387+ 18 ,
388+ ) ;
389+ let resources = job. spec . unwrap ( ) . template . spec . unwrap ( ) . containers [ 0 ]
390+ . resources
391+ . clone ( )
392+ . expect ( "migration container must declare resources" ) ;
393+ let limits = resources
394+ . limits
395+ . expect ( "migration container must declare limits" ) ;
396+ let mem_limit = & limits. get ( "memory" ) . expect ( "memory limit set" ) . 0 ;
397+ // Accept anything ending with Gi where N >= 2, or Mi where N >= 2048.
398+ let mem_ok = mem_limit
399+ . strip_suffix ( "Gi" )
400+ . and_then ( |n| n. parse :: < u64 > ( ) . ok ( ) )
401+ . is_some_and ( |n| n >= 2 )
402+ || mem_limit
403+ . strip_suffix ( "Mi" )
404+ . and_then ( |n| n. parse :: < u64 > ( ) . ok ( ) )
405+ . is_some_and ( |n| n >= 2048 ) ;
406+ assert ! (
407+ mem_ok,
408+ "migration memory limit must be at least 2Gi (got {mem_limit})"
409+ ) ;
410+ }
411+
316412 #[ test]
317413 fn migration_job_name_format ( ) {
318414 assert_eq ! (
0 commit comments