Skip to content

Commit 76120eb

Browse files
authored
feat: add support for RESTORE DATABASE (#310) (#325)
1 parent 377eadb commit 76120eb

File tree

6 files changed

+179
-30
lines changed

6 files changed

+179
-30
lines changed

.github/workflows/backup-restore.yml

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
branches:
66
- main
7-
- compatibility
7+
- noy/feat-restore-database
88
- test
99
pull_request:
1010
branches: [ "main" ]
@@ -147,7 +147,7 @@ jobs:
147147
psql "postgres://postgres:password@localhost:15432/testdb" \
148148
-c "INSERT INTO test_table VALUES (4, 'offline data 4');"
149149
150-
- name: Restore MyDuck
150+
- name: Restore MyDuck at Startup
151151
run: |
152152
# Restart MyDuck
153153
./myduckserver \
@@ -165,6 +165,29 @@ jobs:
165165
166166
# Kill MyDuck
167167
pkill myduckserver
168+
rm -f ./myduck.db
169+
170+
- name: Restore MyDuck at Runtime
171+
run: |
172+
# Start MyDuck
173+
./myduckserver &
174+
sleep 5
175+
176+
psql "postgres://postgres:@127.0.0.1:5432" <<-EOSQL
177+
RESTORE DATABASE testdb2 FROM 's3c://myduck-backup/myduck/myduck.bak'
178+
ENDPOINT = '127.0.0.1:9001'
179+
ACCESS_KEY_ID = 'minioadmin'
180+
SECRET_ACCESS_KEY = 'minioadmin';
181+
EOSQL
182+
sleep 10
183+
184+
- name: Test Replication
185+
run: |
186+
# Verify replication catches up
187+
psql -h 127.0.0.1 -p 5432 -U postgres -d testdb2 -c "SELECT 1 FROM test_table WHERE id = 4 AND name = 'offline data 4';" | grep -q 1
188+
189+
# Kill MyDuck
190+
pkill myduckserver
168191
169192
- name: Cleanup
170193
if: always()

catalog/provider.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,16 +189,33 @@ func (prov *DatabaseProvider) attachCatalogs() error {
189189
return fmt.Errorf("failed to read data directory: %w", err)
190190
}
191191
for _, file := range files {
192-
if file.IsDir() {
193-
continue
192+
err := prov.AttachCatalog(file, true)
193+
if err != nil {
194+
logrus.Error(err)
194195
}
195-
if !strings.HasSuffix(file.Name(), ".db") {
196-
continue
196+
}
197+
return nil
198+
}
199+
200+
func (prov *DatabaseProvider) AttachCatalog(file interface {
201+
IsDir() bool
202+
Name() string
203+
}, ignoreNonDB bool) error {
204+
if file.IsDir() {
205+
if ignoreNonDB {
206+
return nil
197207
}
198-
name := strings.TrimSuffix(file.Name(), ".db")
199-
if _, err := prov.storage.ExecContext(context.Background(), "ATTACH IF NOT EXISTS '"+filepath.Join(prov.dataDir, file.Name())+"' AS "+name); err != nil {
200-
logrus.WithError(err).Errorf("Failed to attach database %s", name)
208+
return fmt.Errorf("file %s is a directory", file.Name())
209+
}
210+
if !strings.HasSuffix(file.Name(), ".db") {
211+
if ignoreNonDB {
212+
return nil
201213
}
214+
return fmt.Errorf("file %s is not a database file", file.Name())
215+
}
216+
name := strings.TrimSuffix(file.Name(), ".db")
217+
if _, err := prov.storage.ExecContext(context.Background(), "ATTACH IF NOT EXISTS '"+filepath.Join(prov.dataDir, file.Name())+"' AS "+name); err != nil {
218+
return fmt.Errorf("failed to attach database %s: %w", name, err)
202219
}
203220
return nil
204221
}

docs/tutorial/backup-restore.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,14 @@ BACKUP DATABASE my_database TO 's3://my_bucket/my_database/'
3838

3939
## Restore
4040

41-
### Current Limitation
42-
43-
Currently, MyDuck Server supports only a single database (catalog). Therefore, restore operations are performed only at startup. Future updates will enable support for multiple databases, allowing restore operations via SQL commands.
44-
4541
### Restore Process
4642

47-
To restore a database, download the backup file (`mysql.db`) and attach it during the startup of MyDuck Server.
43+
To restore a database, download the backup file (`mysql.db`) and attach it.
4844

4945
**Note:** Backup files created by either MyDuck Server or DuckDB can be used to restore MyDuck Server.
5046

47+
### Restore at Startup
48+
5149
#### Docker Usage
5250

5351
Use the following Docker command to run MyDuck Server with restore parameters:
@@ -103,4 +101,22 @@ Run MyDuck Server with the following command-line arguments to perform a restore
103101
--restore-endpoint=s3.ap-northwest-1.amazonaws.com \
104102
--restore-access-key-id=xxxxxxxxxxxxxx \
105103
--restore-secret-access-key=xxxxxxxxxxxxxx
106-
```
104+
```
105+
106+
### Restore Syntax (Restore at Runtime)
107+
108+
```sql
109+
RESTORE DATABASE my_database FROM '<uri>'
110+
ENDPOINT = '<endpoint>'
111+
ACCESS_KEY_ID = '<access_key>'
112+
SECRET_ACCESS_KEY = '<secret_key>'
113+
```
114+
115+
**Example**
116+
117+
```sql
118+
RESTORE DATABASE my_database FROM 's3://my_bucket/my_database/'
119+
ENDPOINT = 's3.cn-northwest-1.amazonaws.com.cn'
120+
ACCESS_KEY_ID = 'xxxxxxxxxxxxx'
121+
SECRET_ACCESS_KEY = 'xxxxxxxxxxxx'
122+
```

pgserver/connection_data.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type ConvertedStatement struct {
5959
PgParsable bool
6060
SubscriptionConfig *SubscriptionConfig
6161
BackupConfig *BackupConfig
62+
RestoreConfig *RestoreConfig
6263
}
6364

6465
// copyFromStdinState tracks the metadata for an import of data into a table using a COPY FROM STDIN statement. When

pgserver/connection_handler.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,14 @@ func (h *ConnectionHandler) run(statement ConvertedStatement) error {
10041004
return h.send(&pgproto3.ErrorResponse{
10051005
Message: msg,
10061006
})
1007+
} else if statement.RestoreConfig != nil {
1008+
msg, err := h.executeRestore(statement.RestoreConfig)
1009+
if err != nil {
1010+
return err
1011+
}
1012+
return h.send(&pgproto3.ErrorResponse{
1013+
Message: msg,
1014+
})
10071015
}
10081016

10091017
callback := h.spoolRowsCallback(statement.Tag, &rowsAffected, false)
@@ -1221,7 +1229,7 @@ func (h *ConnectionHandler) convertQuery(query string, modifiers ...QueryModifie
12211229
}}, nil
12221230
}
12231231

1224-
// Check if the query is a backup query, and if so, parse it as a backup query.
1232+
// Check if the query is a backup/restore query, and if so, parse it as a backup/restore query.
12251233
backupConfig, err := parseBackupSQL(query)
12261234
if backupConfig != nil && err == nil {
12271235
return []ConvertedStatement{{
@@ -1230,6 +1238,14 @@ func (h *ConnectionHandler) convertQuery(query string, modifiers ...QueryModifie
12301238
BackupConfig: backupConfig,
12311239
}}, nil
12321240
}
1241+
restoreConfig, err := parseRestoreSQL(query)
1242+
if restoreConfig != nil && err == nil {
1243+
return []ConvertedStatement{{
1244+
String: query,
1245+
PgParsable: true,
1246+
RestoreConfig: restoreConfig,
1247+
}}, nil
1248+
}
12331249

12341250
stmts, err := parser.Parse(query)
12351251
if err != nil {

pgserver/restore_handler.go

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,118 @@ package pgserver
33
import (
44
"fmt"
55
"github.com/apecloud/myduckserver/storage"
6+
"os"
7+
"path/filepath"
8+
"regexp"
69
"strings"
710
)
811

9-
// Since MyDuck Server currently supports only a single database (catalog),
10-
// restore operations are performed only at startup. Once multiple databases
11-
// are supported, we will implement restore as a SQL command.
12+
// This file implements the logic for handling RESTORE SQL statements.
13+
//
14+
// Syntax:
15+
// RESTORE DATABASE my_database FROM '<uri>'
16+
// ENDPOINT = '<endpoint>'
17+
// ACCESS_KEY_ID = '<access_key>'
18+
// SECRET_ACCESS_KEY = '<secret_key>'
19+
//
20+
// Example Usage:
21+
// RESTORE DATABASE my_database FROM 's3://my_bucket/my_database/'
22+
// ENDPOINT = 's3.cn-northwest-1.amazonaws.com.cn'
23+
// ACCESS_KEY_ID = 'xxxxxxxxxxxxx'
24+
// SECRET_ACCESS_KEY = 'xxxxxxxxxxxx'
1225

1326
type RestoreConfig struct {
1427
DbName string
1528
RemoteFile string
1629
StorageConfig *storage.ObjectStorageConfig
1730
}
1831

19-
func NewRestoreConfig(dbName, remoteUri, endpoint, accessKeyId, secretAccessKey string) (*RestoreConfig, error) {
32+
var restoreRegex = regexp.MustCompile(
33+
`(?i)RESTORE\s+DATABASE\s+(\S+)\s+FROM\s+'(s3c?://[^']+)'` +
34+
`(?:\s+ENDPOINT\s*=\s*'([^']+)')?` +
35+
`(?:\s+ACCESS_KEY_ID\s*=\s*'([^']+)')?` +
36+
`(?:\s+SECRET_ACCESS_KEY\s*=\s*'([^']+)')?`)
37+
38+
func NewRestoreConfig(dbName, remotePath string, storageConfig *storage.ObjectStorageConfig) *RestoreConfig {
39+
return &RestoreConfig{
40+
DbName: dbName,
41+
RemoteFile: remotePath,
42+
StorageConfig: storageConfig,
43+
}
44+
}
45+
46+
func parseRestoreSQL(sql string) (*RestoreConfig, error) {
47+
matches := restoreRegex.FindStringSubmatch(sql)
48+
if matches == nil {
49+
// No match means the SQL doesn't follow the expected pattern
50+
return nil, nil
51+
}
52+
53+
// matches:
54+
// [1] DbName
55+
// [2] RemoteUri
56+
// [3] Endpoint
57+
// [4] AccessKeyId
58+
// [5] SecretAccessKey
59+
dbName := strings.TrimSpace(matches[1])
60+
remoteUri := strings.TrimSpace(matches[2])
61+
endpoint := strings.TrimSpace(matches[3])
62+
accessKeyId := strings.TrimSpace(matches[4])
63+
secretAccessKey := strings.TrimSpace(matches[5])
64+
65+
if dbName == "" {
66+
return nil, fmt.Errorf("missing required restore configuration: DATABASE")
67+
}
68+
if remoteUri == "" {
69+
return nil, fmt.Errorf("missing required restore configuration: TO '<URI>'")
70+
}
71+
if endpoint == "" {
72+
return nil, fmt.Errorf("missing required restore configuration: ENDPOINT")
73+
}
74+
if accessKeyId == "" {
75+
return nil, fmt.Errorf("missing required restore configuration: ACCESS_KEY_ID")
76+
}
77+
if secretAccessKey == "" {
78+
return nil, fmt.Errorf("missing required restore configuration: SECRET_ACCESS_KEY")
79+
}
80+
2081
storageConfig, remotePath, err := storage.ConstructStorageConfig(remoteUri, endpoint, accessKeyId, secretAccessKey)
2182
if err != nil {
2283
return nil, fmt.Errorf("failed to construct storage configuration for restore: %w", err)
2384
}
2485

25-
if strings.HasSuffix(remotePath, "/") {
26-
return nil, fmt.Errorf("remote path must be a file, not a directory")
27-
}
86+
return NewRestoreConfig(dbName, remotePath, storageConfig), nil
87+
}
2888

29-
return &RestoreConfig{
30-
DbName: dbName,
31-
RemoteFile: remotePath,
32-
StorageConfig: storageConfig,
33-
}, nil
89+
func (h *ConnectionHandler) executeRestore(restoreConfig *RestoreConfig) (string, error) {
90+
provider := h.server.Provider
91+
msg, err := restoreConfig.StorageConfig.DownloadFile(restoreConfig.RemoteFile, provider.DataDir(), restoreConfig.DbName+".db")
92+
if err != nil {
93+
return "", fmt.Errorf("failed to download file: %w", err)
94+
}
95+
dbFile := filepath.Join(provider.DataDir(), restoreConfig.DbName+".db")
96+
// load dbFile as DirEntry
97+
file, err := os.Stat(dbFile)
98+
if err != nil {
99+
return "", fmt.Errorf("failed to stat file: %w", err)
100+
}
101+
err = provider.AttachCatalog(file, false)
102+
if err != nil {
103+
return "", fmt.Errorf("failed to attach catalog: %w", err)
104+
}
105+
return msg, nil
34106
}
35107

108+
// ExecuteRestore downloads the specified file from the remote storage and restores it to the specified local directory.
109+
// Note that this should only be called at startup, as this function does not attach the restored database to the catalog.
36110
func ExecuteRestore(dbName, localDir, localFile, remoteUri, endpoint, accessKeyId, secretAccessKey string) (string, error) {
37-
config, err := NewRestoreConfig(dbName, remoteUri, endpoint, accessKeyId, secretAccessKey)
111+
storageConfig, remotePath, err := storage.ConstructStorageConfig(remoteUri, endpoint, accessKeyId, secretAccessKey)
38112
if err != nil {
39-
return "", fmt.Errorf("failed to create restore configuration: %w", err)
113+
return "", fmt.Errorf("failed to construct storage configuration for restore: %w", err)
40114
}
41115

116+
config := NewRestoreConfig(dbName, remotePath, storageConfig)
117+
42118
msg, err := config.StorageConfig.DownloadFile(config.RemoteFile, localDir, localFile)
43119
if err != nil {
44120
return "", fmt.Errorf("failed to download file: %w", err)

0 commit comments

Comments
 (0)