Skip to content

Commit

Permalink
pg: enforce settings for determinism and repl timeout
Browse files Browse the repository at this point in the history
doc and test setting compose functions
  • Loading branch information
jchappelow committed Jun 7, 2024
1 parent 9c6ed34 commit 61cc13c
Show file tree
Hide file tree
Showing 2 changed files with 214 additions and 8 deletions.
98 changes: 90 additions & 8 deletions internal/sql/pg/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,20 @@ func pgVersion(ctx context.Context, conn *pgx.Conn) (ver string, verNum uint32,

type settingValidFn func(val string) error

func wantMinIntFn(wantMin int64) settingValidFn {
func wantIntFn(want int64) settingValidFn { //nolint:unused
return func(val string) error {
num, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return err
}
if num != want {
return fmt.Errorf("require %d, but setting is %d", want, num)
}
return nil
}
}

func wantMinIntFn(wantMin int64) settingValidFn { //nolint:unused
return func(val string) error {
num, err := strconv.ParseInt(val, 10, 64)
if err != nil {
Expand All @@ -80,7 +93,20 @@ func wantMinIntFn(wantMin int64) settingValidFn {
}
}

func wantStringFn(want string) settingValidFn {
func wantMaxIntFn(wantMax int64) settingValidFn { //nolint:unused
return func(val string) error {
num, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return err
}
if num > wantMax {
return fmt.Errorf("require at most %d, but setting is %d", wantMax, num)
}
return nil
}
}

func wantStringFn(want string) settingValidFn { //nolint:unused
want = strings.TrimSpace(want)
if want == "" {
panic("empty want string is invalid")
Expand All @@ -93,15 +119,52 @@ func wantStringFn(want string) settingValidFn {
}
}

func wantOnFn(on bool) settingValidFn {
func wantOnFn(on bool) settingValidFn { //nolint:unused
if on {
return wantStringFn("on")
}
return wantStringFn("off")
}

// orValidFn creates a settings validation function that passes if *any* of the
// conditions pass. This is useful for instance if there are two acceptable
// string values, or if an integer value of either exactly 0 or >50 are
// acceptable as no single min/max condition captures that criteria.
func orValidFn(fns ...settingValidFn) settingValidFn { //nolint:unused
return func(val string) error {
var err error
for i, fn := range fns {
erri := fn(val)
if erri == nil {
return nil
}
err = errors.Join(err, fmt.Errorf("condition % 2d: %w", i, erri))
}
return errors.Join(errors.New("no condition is satisfied"), err)
}
}

// andValidFn creates a settings validation function that passes only if *all*
// of the conditions pass. This can be used to define a range, or enumerate a
// list of unacceptable values.
func andValidFn(fns ...settingValidFn) settingValidFn { //nolint:unused
return func(val string) error {
var err error
for _, fn := range fns {
erri := fn(val)
if erri != nil {
return errors.Join(err, erri)
}
}
return nil
}
}

var settingValidations = map[string]settingValidFn{
"wal_level": wantStringFn("logical"),
"synchronous_commit": wantOnFn(true),
"fsync": wantOnFn(true),
"max_connections": wantMinIntFn(50),
"wal_level": wantStringFn("logical"),

// There is one instance of the DB type that requires the a replication
// slot to precommit: the one used by TxApp for processing blockchain
Expand All @@ -110,10 +173,29 @@ var settingValidations = map[string]settingValidFn{
"max_wal_senders": wantMinIntFn(10),
"max_replication_slots": wantMinIntFn(10),
"max_prepared_transactions": wantMinIntFn(2),

"synchronous_commit": wantOnFn(true),
"fsync": wantOnFn(true),
"max_connections": wantMinIntFn(50),
"wal_sender_timeout": orValidFn(wantIntFn(0), wantMinIntFn(3_600_000)), // ms units, 0 for no limit or 1 hr min

// We shouldn't have idle abandoned transactions, but we need to investigate
// the effect of allowing this kind of clean up.
"idle_in_transaction_timeout": wantIntFn(0), // disable disable idle transaction timeout for now

// Behavior related settings that must be set properly for determinism
// https://www.postgresql.org/docs/16/runtime-config-compatible.html
// The following are the documented defaults, which we enforce.
"array_nulls": wantOnFn(true), // recognize NULL in array parser, false is for pre-8.2 compat
"standard_conforming_strings": wantOnFn(true), // backslashes (\) in string literals are treated literally -- only escape syntax (E'...') is still usable if needed
"transform_null_equals": wantOnFn(false), // do not treat "expr=NULL" as "expr IS NULL"
"backslash_quote": wantStringFn("safe_encoding"), // reject escaped single quotes like \', require standard ''
"lo_compat_privileges": wantOnFn(false), // access-controlled large object storage

// server_encoding is a read-only setting that allows postgres to report the
// character encoding of the connected database. The default for new
// databases is set by `initdb` when creating the cluster:
// The database cluster will be initialized with locale "en_US.utf8".
// The default database encoding has accordingly been set to "UTF8".
// Or it can be set for a new data base like
// CREATE DATABASE ... WITH ENCODING 'UTF8'
"server_encoding": wantStringFn("UTF8"),
}

func verifySettings(ctx context.Context, conn *pgx.Conn) error {
Expand Down
124 changes: 124 additions & 0 deletions internal/sql/pg/system_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package pg

import (
"testing"

"github.com/stretchr/testify/require"
)

func Test_validateVersion(t *testing.T) {
Expand Down Expand Up @@ -220,3 +222,125 @@ func Test_wantMinIntFn(t *testing.T) {
})
}
}

func TestSettingValidFnAND(t *testing.T) {
tests := []struct {
name string
fns []settingValidFn
val string
wantErr bool
}{
{
name: "no conditions",
fns: []settingValidFn{},
val: "any",
wantErr: false,
},
{
name: "one condition satisfied",
fns: []settingValidFn{wantStringFn("abc")},
val: "abc",
wantErr: false,
},
{
name: "one condition not satisfied",
fns: []settingValidFn{wantStringFn("abc")},
val: "xyz",
wantErr: true,
},
{
name: "multiple conditions, one satisfied",
fns: []settingValidFn{wantStringFn("abc"), wantStringFn("xyz")},
val: "xyz",
wantErr: true,
},
{
name: "multiple conditions, none satisfied",
fns: []settingValidFn{wantStringFn("invalid"), wantOnFn(false)},
val: "on",
wantErr: true,
},
{
name: "multiple int conditions, all satisfied",
fns: []settingValidFn{wantMinIntFn(20), wantMaxIntFn(30)},
val: "25",
wantErr: false,
},
{
name: "multiple int conditions, first satisfied",
fns: []settingValidFn{wantMinIntFn(20), wantMaxIntFn(30)},
val: "31",
wantErr: true,
},
{
name: "multiple int conditions, last satisfied",
fns: []settingValidFn{wantMinIntFn(20), wantMaxIntFn(30)},
val: "19",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fn := andValidFn(tt.fns...)
err := fn(tt.val)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

func TestSettingValidFnOR(t *testing.T) {
tests := []struct {
name string
fns []settingValidFn
val string
wantErr bool
}{
{
name: "no conditions",
fns: []settingValidFn{},
val: "any",
wantErr: true,
},
{
name: "one condition satisfied",
fns: []settingValidFn{wantStringFn("abc")},
val: "abc",
wantErr: false,
},
{
name: "one condition not satisfied",
fns: []settingValidFn{wantStringFn("abc")},
val: "xyz",
wantErr: true,
},
{
name: "multiple conditions, one satisfied",
fns: []settingValidFn{wantStringFn("abc"), wantStringFn("xyz")},
val: "xyz",
wantErr: false,
},
{
name: "multiple conditions, none satisfied",
fns: []settingValidFn{wantStringFn("invalid"), wantOnFn(false)},
val: "on",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fn := orValidFn(tt.fns...)
err := fn(tt.val)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

0 comments on commit 61cc13c

Please sign in to comment.