Skip to content

Commit 3e9420b

Browse files
committed
Tidy the pgBackRest restore command using slices
Having these lines broken into string slices allows for Go comments that explain them without presenting those comments in YAML at runtime. This also: - Uses the postgres.ParameterSet type to accumulate Postgres settings. A new String method renders those values safely for use in postgresql.conf. - Disables localization using LC_ALL=C in calls to pg_controldata before we parse its output. - Removes commands to change permissions on tablespace directories; pgBackRest handles this for us now. - Passes command line parameters to Postgres using "-c" rather than "--" long flags. Both work on Linux, but the former works on all systems. - Explains why we need a large timeout for "pg_ctl --wait" and configures it once using the PGCTLTIMEOUT environment variable.
1 parent 44b1847 commit 3e9420b

File tree

3 files changed

+134
-88
lines changed

3 files changed

+134
-88
lines changed

internal/pgbackrest/config.go

Lines changed: 110 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"strconv"
1111
"strings"
12+
"time"
1213

1314
corev1 "k8s.io/api/core/v1"
1415
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -171,100 +172,121 @@ func MakePGBackrestLogDir(template *corev1.PodTemplateSpec,
171172
// - Renames the data directory as needed to bootstrap the cluster using the restored database.
172173
// This ensures compatibility with the "existing" bootstrap method that is included in the
173174
// Patroni config when bootstrapping a cluster using an existing data directory.
174-
func RestoreCommand(pgdata, hugePagesSetting, fetchKeyCommand string, tablespaceVolumes []*corev1.PersistentVolumeClaim, args ...string) []string {
175-
176-
// After pgBackRest restores files, PostgreSQL starts in recovery to finish
177-
// replaying WAL files. "hot_standby" is "on" so we can detect
178-
// when recovery has finished. In that mode, some parameters cannot be
179-
// smaller than they were when PostgreSQL was backed up. Configure them to
180-
// match the values reported by "pg_controldata". Those parameters are also
181-
// written to WAL files and may change during recovery. When they increase,
182-
// PostgreSQL exits and we reconfigure and restart it.
183-
// For PG14, when some parameters from WAL require a restart, the behavior is
184-
// to pause unless a restart is requested. For this edge case, we run a CASE
185-
// query to check
186-
// (a) if the instance is in recovery;
187-
// (b) if so, if the WAL replay is paused;
188-
// (c) if so, to unpause WAL replay, allowing our expected behavior to resume.
189-
// A note on the PostgreSQL code: we cast `pg_catalog.pg_wal_replay_resume()` as text
190-
// because that method returns a void (which is a non-NULL but empty result). When
191-
// that void is cast as a string, it is an ''
192-
// - https://www.postgresql.org/docs/current/hot-standby.html
193-
// - https://www.postgresql.org/docs/current/app-pgcontroldata.html
175+
func RestoreCommand(pgdata, hugePagesSetting, fetchKeyCommand string, _ []*corev1.PersistentVolumeClaim, args ...string) []string {
176+
ps := postgres.NewParameterSet()
177+
ps.Add("data_directory", pgdata)
178+
ps.Add("huge_pages", hugePagesSetting)
194179

195-
// The postmaster.pid file is removed, if it exists, before attempting a restore.
196-
// This allows the restore to be tried more than once without the causing an
197-
// error due to the presence of the file in subsequent attempts.
180+
// Keep history and WAL files until the cluster starts with its normal
181+
// archiving enabled.
182+
ps.Add("archive_command", "false -- store WAL files locally for now")
183+
ps.Add("archive_mode", "on")
198184

199-
// The 'pg_ctl' timeout is set to a very large value (1 year) to ensure there
200-
// are no timeouts when starting or stopping Postgres.
201-
202-
tablespaceCmd := ""
203-
for _, tablespaceVolume := range tablespaceVolumes {
204-
tablespaceCmd = tablespaceCmd + fmt.Sprintf(
205-
"\ninstall --directory --mode=0700 '/tablespaces/%s/data'",
206-
tablespaceVolume.Labels[naming.LabelData])
207-
}
185+
// Enable "hot_standby" so we can connect to Postgres and observe its
186+
// progress during recovery.
187+
ps.Add("hot_standby", "on")
208188

209-
// If the fetch key command is not empty, save the GUC variable and value
210-
// to a new string.
211-
var ekc string
212189
if fetchKeyCommand != "" {
213-
ekc = `
214-
encryption_key_command = '` + fetchKeyCommand + `'`
190+
ps.Add("encryption_key_command", fetchKeyCommand)
215191
}
216192

217-
restoreScript := `declare -r pgdata="$1" opts="$2"
218-
install --directory --mode=0700 "${pgdata}"` + tablespaceCmd + `
219-
rm -f "${pgdata}/postmaster.pid"
220-
bash -xc "pgbackrest restore ${opts}"
221-
rm -f "${pgdata}/patroni.dynamic.json"
222-
export PGDATA="${pgdata}" PGHOST='/tmp'
223-
224-
until [[ "${recovery=}" == 'f' ]]; do
225-
if [[ -z "${recovery}" ]]; then
226-
control=$(pg_controldata)
227-
read -r max_conn <<< "${control##*max_connections setting:}"
228-
read -r max_lock <<< "${control##*max_locks_per_xact setting:}"
229-
read -r max_ptxn <<< "${control##*max_prepared_xacts setting:}"
230-
read -r max_work <<< "${control##*max_worker_processes setting:}"
231-
echo > /tmp/pg_hba.restore.conf 'local all "postgres" peer'
232-
cat > /tmp/postgres.restore.conf <<EOF
233-
archive_command = 'false'
234-
archive_mode = 'on'
235-
hba_file = '/tmp/pg_hba.restore.conf'
236-
hot_standby = 'on'
237-
max_connections = '${max_conn}'
238-
max_locks_per_transaction = '${max_lock}'
239-
max_prepared_transactions = '${max_ptxn}'
240-
max_worker_processes = '${max_work}'
241-
unix_socket_directories = '/tmp'` +
242-
// Add the encryption key command setting, if provided.
243-
ekc + `
244-
huge_pages = ` + hugePagesSetting + `
245-
EOF
246-
if [[ "$(< "${pgdata}/PG_VERSION")" -ge 12 ]]; then
247-
read -r max_wals <<< "${control##*max_wal_senders setting:}"
248-
echo >> /tmp/postgres.restore.conf "max_wal_senders = '${max_wals}'"
249-
fi
250-
251-
read -r stopped <<< "${control##*recovery ending location:}"
252-
pg_ctl start --silent --timeout=31536000 --wait --options='--config-file=/tmp/postgres.restore.conf' || failed=$?
253-
[[ "${started-}" == "${stopped}" && -n "${failed-}" ]] && exit "${failed}"
254-
started="${stopped}" && [[ -n "${failed-}" ]] && failed= && continue
255-
fi
256-
257-
recovery=$(psql -Atc "SELECT CASE
258-
WHEN NOT pg_catalog.pg_is_in_recovery() THEN false
259-
WHEN NOT pg_catalog.pg_is_wal_replay_paused() THEN true
260-
ELSE pg_catalog.pg_wal_replay_resume()::text = ''
261-
END recovery" && sleep 1) ||:
262-
done
263-
264-
pg_ctl stop --silent --wait --timeout=31536000
265-
mv "${pgdata}" "${pgdata}_bootstrap"`
266-
267-
return append([]string{"bash", "-ceu", "--", restoreScript, "-", pgdata}, args...)
193+
configure := strings.Join([]string{
194+
// With "hot_standby" on, some parameters cannot be smaller than they were
195+
// when Postgres was backed up. Configure these to match values reported by
196+
// "pg_controldata" before starting Postgres. These parameters are also
197+
// written to WAL files and may change during recovery. When they increase,
198+
// Postgres exits and we reconfigure it here.
199+
// - https://www.postgresql.org/docs/current/app-pgcontroldata.html
200+
`control=$(LC_ALL=C pg_controldata)`,
201+
`read -r max_conn <<< "${control##*max_connections setting:}"`,
202+
`read -r max_lock <<< "${control##*max_locks_per_xact setting:}"`,
203+
`read -r max_ptxn <<< "${control##*max_prepared_xacts setting:}"`,
204+
`read -r max_work <<< "${control##*max_worker_processes setting:}"`,
205+
206+
// During recovery, only allow connections over the the domain socket.
207+
`echo > /tmp/pg_hba.restore.conf 'local all "postgres" peer'`,
208+
209+
// Combine parameters from Go with those detected in Bash.
210+
`cat > /tmp/postgres.restore.conf <<'EOF'`, ps.String(), `EOF`,
211+
`cat >> /tmp/postgres.restore.conf <<EOF`,
212+
`hba_file = '/tmp/pg_hba.restore.conf'`,
213+
`max_connections = '${max_conn}'`,
214+
`max_locks_per_transaction = '${max_lock}'`,
215+
`max_prepared_transactions = '${max_ptxn}'`,
216+
`max_worker_processes = '${max_work}'`,
217+
`EOF`,
218+
219+
`version=$(< "${PGDATA}/PG_VERSION")`,
220+
221+
// PostgreSQL v12 introduced the "max_wal_senders" parameter.
222+
`if [[ "${version}" -ge 12 ]]; then`,
223+
`read -r max_wals <<< "${control##*max_wal_senders setting:}"`,
224+
`echo >> /tmp/postgres.restore.conf "max_wal_senders = '${max_wals}'"`,
225+
`fi`,
226+
227+
// TODO(sockets): PostgreSQL v14 is able to connect over abstract sockets in the network namespace.
228+
`PGHOST=$([[ "${version}" -ge 14 ]] && echo '/tmp' || echo '/tmp')`,
229+
`echo >> /tmp/postgres.restore.conf "unix_socket_directories = '${PGHOST}'"`,
230+
}, "\n")
231+
232+
script := strings.Join([]string{
233+
`declare -r PGDATA="$1" opts="$2"; export PGDATA PGHOST`,
234+
235+
// Remove any "postmaster.pid" file leftover from a prior failure.
236+
`rm -f "${PGDATA}/postmaster.pid"`,
237+
238+
// Run the restore and print its arguments.
239+
`bash -xc "pgbackrest restore ${opts}"`,
240+
241+
// Ignore any Patroni settings present in the backup.
242+
`rm -f "${PGDATA}/patroni.dynamic.json"`,
243+
244+
// By default, pg_ctl waits 60 seconds for Postgres to stop or start.
245+
// We want to be certain when Postgres is running or not, so we use
246+
// a very large timeout (365 days) to effectively wait forever. With
247+
// this, the result of "pg_ctl --wait" indicates the state of Postgres.
248+
// - https://www.postgresql.org/docs/current/app-pg-ctl.html
249+
fmt.Sprintf(`export PGCTLTIMEOUT=%d`, 365*24*time.Hour/time.Second),
250+
251+
// Configure and start Postgres until we can see that it has finished
252+
// replaying WAL.
253+
//
254+
// PostgreSQL v13 and earlier exit when they need reconfiguration with
255+
// "hot_standby" on. This can cause pg_ctl to fail, so we compare the
256+
// LSN from before and after calling it. If the LSN changed, Postgres
257+
// ran and was able to replay WAL before exiting. In that case, configure
258+
// Postgres and start it again to see if it can make more progress.
259+
//
260+
// If Postgres exits after pg_ctl succeeds, psql returns nothing which
261+
// resets the "recovering" variable. Configure Postgres and start it again.
262+
`until [[ "${recovering=}" == 'f' ]]; do`,
263+
` if [[ -z "${recovering}" ]]; then`, configure,
264+
` read -r stopped <<< "${control##*recovery ending location:}"`,
265+
` pg_ctl start --silent --wait --options='-c config_file=/tmp/postgres.restore.conf' || failed=$?`,
266+
` [[ "${started-}" == "${stopped}" && -n "${failed-}" ]] && exit "${failed}"`,
267+
` started="${stopped}" && [[ -n "${failed-}" ]] && failed= && continue`,
268+
` fi`,
269+
// Ask Postgres if it is still recovering. PostgreSQL v14 pauses when it
270+
// needs reconfiguration with "hot_standby" on, and resuming replay causes
271+
// it to exit like prior versions.
272+
// - https://www.postgresql.org/docs/current/hot-standby.html
273+
//
274+
// NOTE: "pg_wal_replay_resume()" returns void which cannot be compared to
275+
// null. Instead, cast it to text and compare that for a boolean result.
276+
` recovering=$(psql -Atc "SELECT CASE`,
277+
` WHEN NOT pg_catalog.pg_is_in_recovery() THEN false`,
278+
` WHEN NOT pg_catalog.pg_is_wal_replay_paused() THEN true`,
279+
` ELSE pg_catalog.pg_wal_replay_resume()::text = ''`,
280+
` END" && sleep 1) ||:`,
281+
`done`,
282+
283+
// Replay is done. Stop Postgres gracefully and move the data directory
284+
// into position for our Patroni bootstrap method.
285+
`pg_ctl stop --silent --wait`,
286+
`mv "${PGDATA}" "${PGDATA}_bootstrap"`,
287+
}, "\n")
288+
289+
return append([]string{"bash", "-ceu", "--", script, "-", pgdata}, args...)
268290
}
269291

270292
// DedicatedSnapshotVolumeRestoreCommand returns the command for performing a pgBackRest delta restore

internal/postgres/parameters.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package postgres
66

77
import (
8+
"fmt"
9+
"slices"
810
"strings"
911
)
1012

@@ -124,3 +126,21 @@ func (ps *ParameterSet) Value(name string) string {
124126
value, _ := ps.Get(name)
125127
return value
126128
}
129+
130+
func (ps *ParameterSet) String() string {
131+
keys := make([]string, 0, len(ps.values))
132+
for k := range ps.values {
133+
keys = append(keys, k)
134+
}
135+
136+
slices.Sort(keys)
137+
138+
var b strings.Builder
139+
for _, k := range keys {
140+
_, _ = fmt.Fprintf(&b, "%s = '%s'\n", k, escapeParameterQuotes(ps.values[k]))
141+
}
142+
return b.String()
143+
}
144+
145+
// escapeParameterQuotes is used by [ParameterSet.String].
146+
var escapeParameterQuotes = strings.NewReplacer(`'`, `''`).Replace

internal/postgres/parameters_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ func TestParameterSet(t *testing.T) {
5656

5757
ps2.Add("x", "n")
5858
assert.Assert(t, ps2.Value("x") != ps.Value("x"))
59+
60+
assert.DeepEqual(t, ps.String(), ``+
61+
`abc = 'j''l'`+"\n"+
62+
`x = 'z'`+"\n")
5963
}
6064

6165
func TestParameterSetAppendToList(t *testing.T) {

0 commit comments

Comments
 (0)