Skip to content

Commit ed8f7e6

Browse files
committed
Merge branch 'feature/restore-configs-projection' into 'master'
feat: expose logicalRestore configs in config projection and UI Closes #701 See merge request postgres-ai/database-lab!1130
2 parents 0a4d58d + 662c262 commit ed8f7e6

10 files changed

Lines changed: 168 additions & 5 deletions

File tree

engine/pkg/models/configuration.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type ConfigProjection struct {
2424
DBList map[string]interface{} `proj:"retrieval.spec.logicalDump.options.databases,createKey"`
2525
DumpParallelJobs *int64 `proj:"retrieval.spec.logicalDump.options.parallelJobs"`
2626
RestoreParallelJobs *int64 `proj:"retrieval.spec.logicalRestore.options.parallelJobs"`
27+
RestoreConfigs map[string]interface{} `proj:"retrieval.spec.logicalRestore.options.configs,createKey"`
2728
DumpCustomOptions []interface{} `proj:"retrieval.spec.logicalDump.options.customOptions"`
2829
RestoreCustomOptions []interface{} `proj:"retrieval.spec.logicalRestore.options.customOptions"`
2930
IgnoreDumpErrors *bool `proj:"retrieval.spec.logicalDump.options.ignoreErrors"`

engine/pkg/util/projection/load_yaml_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
"github.com/stretchr/testify/require"
7+
"gopkg.in/yaml.v3"
78
)
89

910
func TestLoadYaml(t *testing.T) {
@@ -28,3 +29,33 @@ func TestLoadYamlNull(t *testing.T) {
2829

2930
requireEmpty(t, s)
3031
}
32+
33+
func TestLoadYaml_MergeKey(t *testing.T) {
34+
type mergeStruct struct {
35+
Configs map[string]interface{} `proj:"parent.options.configs"`
36+
ParallelJobs *int64 `proj:"parent.options.parallelJobs"`
37+
}
38+
39+
const yamlData = `
40+
defaults: &defaults
41+
configs:
42+
shared_buffers: 1GB
43+
work_mem: 100MB
44+
45+
parent:
46+
options:
47+
<<: *defaults
48+
parallelJobs: 4
49+
`
50+
51+
node := &yaml.Node{}
52+
err := yaml.Unmarshal([]byte(yamlData), node)
53+
require.NoError(t, err)
54+
55+
s := &mergeStruct{}
56+
err = LoadYaml(s, node, LoadOptions{})
57+
require.NoError(t, err)
58+
59+
require.Equal(t, map[string]interface{}{"shared_buffers": "1GB", "work_mem": "100MB"}, s.Configs)
60+
require.Equal(t, int64(4), *s.ParallelJobs)
61+
}

engine/pkg/util/projection/store_yaml_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,51 @@ nested:
184184
require.Equal(t, []interface{}{"--no-privileges", "--no-owner"}, itemsVal)
185185
}
186186

187+
func TestStoreYaml_MergeKeyCreatesDirect(t *testing.T) {
188+
type mergeStruct struct {
189+
Configs map[string]interface{} `proj:"parent.options.configs,createKey"`
190+
ParallelJobs *int64 `proj:"parent.options.parallelJobs"`
191+
}
192+
193+
const yamlData = `
194+
defaults: &defaults
195+
configs:
196+
shared_buffers: 1GB
197+
work_mem: 100MB
198+
199+
parent:
200+
options:
201+
<<: *defaults
202+
parallelJobs: 4
203+
`
204+
205+
node := &yaml.Node{}
206+
err := yaml.Unmarshal([]byte(yamlData), node)
207+
require.NoError(t, err)
208+
209+
pj := int64(8)
210+
s := &mergeStruct{Configs: map[string]interface{}{"fsync": "off", "maintenance_work_mem": "8GB"}, ParallelJobs: &pj}
211+
212+
err = StoreYaml(s, node, StoreOptions{})
213+
require.NoError(t, err)
214+
215+
soft, err := NewSoftYaml(node)
216+
require.NoError(t, err)
217+
218+
configVal, err := soft.Get(FieldGet{Path: []string{"parent", "options", "configs"}, Type: ptypes.Map})
219+
require.NoError(t, err)
220+
require.Equal(t, map[string]interface{}{"fsync": "off", "maintenance_work_mem": "8GB"}, configVal)
221+
222+
pjVal, err := soft.Get(FieldGet{Path: []string{"parent", "options", "parallelJobs"}, Type: ptypes.Int64})
223+
require.NoError(t, err)
224+
require.Equal(t, int64(8), pjVal)
225+
226+
// verify anchor source was not modified
227+
defaultConfigs, err := soft.Get(FieldGet{Path: []string{"defaults", "configs"}, Type: ptypes.Map})
228+
require.NoError(t, err)
229+
require.Equal(t, map[string]interface{}{"shared_buffers": "1GB", "work_mem": "100MB"}, defaultConfigs)
230+
}
231+
187232
func TestStoreYaml_NilMapPreservesExisting(t *testing.T) {
188233
type mapStruct struct {
189234
Name *string `proj:"nested.name"`

engine/pkg/util/projection/yaml.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,21 @@ func (y *yamlSoft) Set(set FieldSet) error {
4242
return fmt.Errorf("node is not a mapping node")
4343
}
4444

45-
child, hasChild := findNodeForKey(node, key)
45+
isLastSegment := i == len(set.Path)-1
46+
47+
var (
48+
child *yaml.Node
49+
hasChild bool
50+
)
51+
52+
if isLastSegment && set.CreateKey {
53+
child, hasChild = findDirectNodeForKey(node, key)
54+
} else {
55+
child, hasChild = findNodeForKey(node, key)
56+
}
57+
4658
if !hasChild {
47-
if set.CreateKey && i == len(set.Path)-1 {
59+
if set.CreateKey && isLastSegment {
4860
child = &yaml.Node{
4961
Kind: yaml.ScalarNode,
5062
Tag: "!!map",
@@ -129,7 +141,8 @@ func (y *yamlSoft) Get(get FieldGet) (interface{}, error) {
129141
return typed, nil
130142
}
131143

132-
func findNodeForKey(node *yaml.Node, key string) (*yaml.Node, bool) {
144+
// findDirectNodeForKey looks up a key in a mapping node's direct children only.
145+
func findDirectNodeForKey(node *yaml.Node, key string) (*yaml.Node, bool) {
133146
for i := 0; i < len(node.Content); i += 2 {
134147
if node.Content[i].Value == key {
135148
return node.Content[i+1], true
@@ -139,6 +152,34 @@ func findNodeForKey(node *yaml.Node, key string) (*yaml.Node, bool) {
139152
return nil, false
140153
}
141154

155+
// findNodeForKey looks up a key in a mapping node, resolving YAML merge keys (<<: *alias).
156+
func findNodeForKey(node *yaml.Node, key string) (*yaml.Node, bool) {
157+
if child, ok := findDirectNodeForKey(node, key); ok {
158+
return child, true
159+
}
160+
161+
for i := 0; i < len(node.Content); i += 2 {
162+
if node.Content[i].Tag != "!!merge" {
163+
continue
164+
}
165+
166+
merged := node.Content[i+1]
167+
if merged.Kind == yaml.AliasNode && merged.Alias != nil {
168+
merged = merged.Alias
169+
}
170+
171+
if merged.Kind != yaml.MappingNode {
172+
continue
173+
}
174+
175+
if child, ok := findNodeForKey(merged, key); ok {
176+
return child, true
177+
}
178+
}
179+
180+
return nil, false
181+
}
182+
142183
func convertMap(node *yaml.Node) (map[string]interface{}, error) {
143184
convertedMap := make(map[string]interface{}, 0)
144185

ui/packages/ce/src/api/configs/updateConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
postUniqueCustomOptions,
33
postUniqueDatabases,
44
} from '@postgres.ai/shared/pages/Instance/Configuration/utils'
5+
import { formatTuningParamsToObj } from '@postgres.ai/shared/types/api/endpoints/testDbSource'
56
import { Config } from '@postgres.ai/shared/types/api/entities/config'
67
import { request } from 'helpers/request'
78

@@ -51,6 +52,7 @@ export const updateConfig = async (req: Config) => {
5152
),
5253
parallelJobs: req.restoreParallelJobs,
5354
ignoreErrors: req.restoreIgnoreErrors,
55+
configs: formatTuningParamsToObj(req.restoreConfigs),
5456
},
5557
},
5658
},

ui/packages/shared/pages/Instance/Configuration/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,16 @@ export const Configuration = observer(
11201120
)
11211121
}
11221122
/>
1123+
<InputWithTooltip
1124+
type="textarea"
1125+
label="Restore PostgreSQL configs"
1126+
value={formik.values.restoreConfigs}
1127+
tooltipText={tooltipText.restoreConfigs}
1128+
disabled={isConfigurationDisabled}
1129+
onChange={(e) =>
1130+
formik.setFieldValue('restoreConfigs', e.target.value)
1131+
}
1132+
/>
11231133
<FormControlLabel
11241134
style={{ maxWidth: 'max-content' }}
11251135
control={

ui/packages/shared/pages/Instance/Configuration/tooltipText.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,26 @@ export const tooltipText = {
138138
hyphen, underscore, equal sign, and double quotes.
139139
</div>
140140
),
141+
restoreConfigs: () => (
142+
<div>
143+
PostgreSQL configuration parameters applied during logical restore (one{' '}
144+
<span className={styles.firaCodeFont}>parameter=value</span> per line).
145+
These settings are written to{' '}
146+
<span className={styles.firaCodeFont}>postgresql.conf</span> before
147+
restore starts and do not affect clones. Useful for tuning restore
148+
performance, for example:
149+
<br />
150+
<span className={styles.firaCodeFont}>maintenance_work_mem=8GB</span>
151+
<br />
152+
<span className={styles.firaCodeFont}>
153+
max_parallel_maintenance_workers=7
154+
</span>
155+
<br />
156+
<span className={styles.firaCodeFont}>shared_preload_libraries=</span>
157+
<br />
158+
<span className={styles.firaCodeFont}>fsync=off</span>
159+
</div>
160+
),
141161
timetable: () => (
142162
<div>
143163
Schedule for full data refreshes, in{' '}

ui/packages/shared/pages/Instance/Configuration/useForm.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type FormValues = {
2828
dumpIgnoreErrors: boolean
2929
restoreParallelJobs: string
3030
restoreIgnoreErrors: boolean
31+
restoreConfigs: string
3132
pgDumpCustomOptions: string
3233
pgRestoreCustomOptions: string
3334
}
@@ -60,6 +61,7 @@ export const useForm = (onSubmit: (values: FormValues) => void) => {
6061
databases: '',
6162
dumpParallelJobs: '',
6263
restoreParallelJobs: '',
64+
restoreConfigs: '',
6365
pgDumpCustomOptions: '',
6466
pgRestoreCustomOptions: '',
6567
dumpIgnoreErrors: false,

ui/packages/shared/types/api/endpoints/testDbSource.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ export const formatTuningParamsToObj = (tuningParams: string | undefined) => {
3939
if (tuningParams) {
4040
const tuningParamsArr = tuningParams.split('\n')
4141
tuningParamsArr.forEach((param) => {
42-
const paramArr = param.split('=')
43-
formattedTuningParams[paramArr[0]] = paramArr[1]
42+
const eqIndex = param.indexOf('=')
43+
if (eqIndex !== -1) {
44+
formattedTuningParams[param.substring(0, eqIndex)] = param.substring(eqIndex + 1)
45+
}
4446
})
4547
}
4648

ui/packages/shared/types/api/entities/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export type configTypes = {
5151
customOptions?: string[]
5252
parallelJobs?: string | number
5353
ignoreErrors?: boolean
54+
configs?: { [key: string]: string }
5455
}
5556
}
5657
}
@@ -103,6 +104,14 @@ export const formatConfig = (config: configTypes) => {
103104
config.retrieval?.spec?.logicalRestore?.options?.parallelJobs,
104105
restoreIgnoreErrors:
105106
config.retrieval?.spec?.logicalRestore?.options?.ignoreErrors,
107+
restoreConfigs: (() => {
108+
const configs =
109+
config.retrieval?.spec?.logicalRestore?.options?.configs
110+
if (!configs || Object.keys(configs).length === 0) return ''
111+
return Object.entries(configs)
112+
.map(([k, v]) => `${k}=${v}`)
113+
.join('\n')
114+
})(),
106115
pgDumpCustomOptions: formatDumpCustomOptions(
107116
(config.retrieval?.spec?.logicalDump?.options
108117
?.customOptions as string[] | undefined) ?? null,

0 commit comments

Comments
 (0)