Skip to content

Commit 1b0c236

Browse files
committed
Backup feature for DuckDB databases
1 parent 5f2feb9 commit 1b0c236

9 files changed

Lines changed: 201 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
src/ws4sql
22
bin/
3+
env/
34
.vscode/
45
.idea/
56
/notes.md

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ upd-libraries:
1010
cd src; go mod tidy
1111

1212
test:
13-
cd src; go test -v -timeout 6m
13+
cd src; go test -v -timeout 8m
1414

1515
build-prepare:
1616
make cleanup

ROAD_TO_WS4SQL.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
# Road to `ws4sql`
2+
13
The next version of `ws4sqlite` will be called `ws4sql`, because it will support more RDBMs than sqlite.
24

35
The version in this branch is a work in progress to add features and (unfortunately) breaking changes; here is a review of what changed compared to `ws4sqlite` "stable" + a migration path.
46

5-
# Changes
7+
## Changes
68

79
- SQLite is embedded via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) and CGO. Should be way faster.
810
- Support for DuckDB (see below).
@@ -14,7 +16,7 @@ The version in this branch is a work in progress to add features and (unfortunat
1416
- Docker images are now based on `distroless/static-debian12`.
1517
- Docker images are now hosted on Github's Container Registry.
1618

17-
# Migration
19+
## Migration
1820

1921
- For any `--db` and `--mem-db` switch that was used, an explicit YAML config file must be created. The format is the same, but there is a new section at the beginning:
2022

@@ -28,13 +30,15 @@ database:
2830
readOnly: false # Same as before, but moved here.
2931
```
3032
31-
# Specific to DuckDB
33+
## Specific to DuckDB
3234
3335
- `noFail` is not supported.
3436
- Placeholders for named parameters are `$VAL`, not `:VAL` as in SQLite.
3537
- As DuckDB does not support read-only transactions, when `readOnly` is specified the requests won't be processed in a transaction.
38+
- Duckdb exports backups in a folder. A backup is performed by exporting to a temp folder and zipping it. CSV format is used (`EXPORT DATABASE '...' (FORMAT CSV)`).
39+
- At least for now, when instructed, a `VACUUM` is performed, not a `VACUUM ANALYZE`.
3640

37-
# Roadmap
41+
## Roadmap
3842

3943
1. Support mariadb/mysql
4044
1. Support postgresql

src/engines/duckdb.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
package engines
1818

1919
import (
20+
"context"
2021
"database/sql"
2122
"fmt"
23+
"os"
2224
"path/filepath"
2325
"strings"
2426

@@ -152,3 +154,24 @@ func (s *duckdbEngine) SanitizeResponseField(fldVal interface{}) (interface{}, e
152154
return fldVal, nil
153155
}
154156
}
157+
158+
// Saves a backup to a temporary folder and zips it
159+
func (s *duckdbEngine) DoBackup(task structs.ScheduledTask, fname string, now string) error {
160+
tempDir, err := os.MkdirTemp("", fmt.Sprintf("duckdb_backup_%s_%s", *task.Db.DatabaseDef.Id, now))
161+
if err != nil {
162+
return fmt.Errorf("sched. task (backup prep): %w", err)
163+
}
164+
defer os.RemoveAll(tempDir) // Clean up after function returns
165+
166+
backupPath := strings.ReplaceAll(filepath.Join(tempDir, "backup"), "'", "''")
167+
_, err = task.Db.DbConn.ExecContext(context.Background(), fmt.Sprintf("EXPORT DATABASE '%s' (FORMAT CSV)", backupPath))
168+
if err != nil {
169+
return fmt.Errorf("sched. task (backup): %w", err)
170+
}
171+
172+
if err := utils.ZipFolder(backupPath, fname); err != nil {
173+
return fmt.Errorf("sched. task (backup zip): %w", err)
174+
}
175+
176+
return nil
177+
}

src/engines/interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type Engine interface {
3131
CheckConfig(dbConfig structs.Db) structs.Db
3232
CheckRequest(body structs.Request) *structs.WsError
3333
SanitizeResponseField(fldVal interface{}) (interface{}, error)
34+
DoBackup(task structs.ScheduledTask, fname string, now string) error
3435
}
3536

3637
const ID_SQLITE = "SQLITE"

src/engines/sqlite.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
package engines
1818

1919
import (
20+
"context"
2021
"database/sql"
22+
"fmt"
2123
"path/filepath"
2224
"strings"
2325

@@ -108,3 +110,15 @@ func (s *sqliteEngine) CheckConfig(dbConfig structs.Db) structs.Db {
108110
func (s *sqliteEngine) SanitizeResponseField(fldVal interface{}) (interface{}, error) {
109111
return fldVal, nil
110112
}
113+
114+
func (s *sqliteEngine) DoBackup(task structs.ScheduledTask, fname string, _ string) error {
115+
stat, err := task.Db.DbConn.PrepareContext(context.Background(), "VACUUM INTO ?")
116+
if err != nil {
117+
return fmt.Errorf("sched. task (backup prep): %s", err.Error())
118+
}
119+
defer stat.Close()
120+
if _, err := stat.Exec(fname); err != nil {
121+
return fmt.Errorf("sched. task (backup): %s", err.Error())
122+
}
123+
return nil
124+
}

src/sched_tasks.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828

2929
"github.com/mitchellh/go-homedir"
3030
mllog "github.com/proofrock/go-mylittlelogger"
31+
"github.com/proofrock/ws4sql/engines"
3132
"github.com/proofrock/ws4sql/structs"
3233

3334
cronDesc "github.com/lnquy/cron"
@@ -37,7 +38,7 @@ import (
3738
const bkpTimeFormat = "060102-1504"
3839

3940
// Used when deleting older backup files, the date/time is substituted with '?'
40-
var bkpTimeGlob = strings.Repeat("?", len(bkpTimeFormat))
41+
const bkpTimeGlob = "??????-????"
4142

4243
// Parses a backup plan, checks that it is well-formed and returns a function that
4344
// will be called by cron and executes the plan.
@@ -81,7 +82,7 @@ func doTask(task structs.ScheduledTask) func() {
8182
task.Db.Mutex.Lock()
8283
defer task.Db.Mutex.Unlock()
8384

84-
if task.DoVacuum {
85+
if task.DoVacuum { // SQLite only
8586
if _, err := task.Db.DbConn.ExecContext(context.Background(), "VACUUM"); err != nil {
8687
mllog.Error("sched. task (vacuum): ", err.Error())
8788
return
@@ -91,14 +92,9 @@ func doTask(task structs.ScheduledTask) func() {
9192
if task.DoBackup {
9293
now := time.Now().Format(bkpTimeFormat)
9394
fname := fmt.Sprintf(filepath.Join(bkpDir, bkpFile), now)
94-
stat, err := task.Db.DbConn.PrepareContext(context.Background(), "VACUUM INTO ?")
95+
err := engines.GetFlavorForDb(*task.Db).DoBackup(task, fname, now)
9596
if err != nil {
96-
mllog.Error("sched. task (backup prep): ", err.Error())
97-
return
98-
}
99-
defer stat.Close()
100-
if _, err := stat.Exec(fname); err != nil {
101-
mllog.Error("sched. task (backup): ", err.Error())
97+
mllog.Error(err.Error())
10298
return
10399
}
104100
// delete the backup files, except for the last n

src/sched_tasks_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,110 @@ func TestSchedTasks(t *testing.T) {
156156
time.Sleep(time.Second)
157157
}
158158

159+
// For DuckDB. Two minutes too.
160+
func TestDDBSchedTasks(t *testing.T) {
161+
defer os.Remove("../test/test1.zip")
162+
defer os.Remove("../test/test2.zip")
163+
defer Shutdown()
164+
165+
sched := "* * * * *"
166+
167+
cfg := structs.Config{
168+
Bindhost: "0.0.0.0",
169+
Port: 12321,
170+
Databases: []structs.Db{
171+
{
172+
DatabaseDef: structs.DatabaseDef{
173+
Type: utils.Ptr("DUCKDB"),
174+
Id: utils.Ptr("test1"),
175+
Path: utils.Ptr("../test/test1.db"),
176+
},
177+
Maintenance: &structs.ScheduledTask{
178+
Schedule: &sched,
179+
DoVacuum: false,
180+
DoBackup: true,
181+
BackupTemplate: "../test/test1_%s.zip",
182+
NumFiles: 1,
183+
},
184+
}, {
185+
DatabaseDef: structs.DatabaseDef{
186+
Type: utils.Ptr("DUCKDB"),
187+
Id: utils.Ptr("test2"),
188+
Path: utils.Ptr("../test/test2.db"),
189+
},
190+
Maintenance: &structs.ScheduledTask{
191+
Schedule: &sched,
192+
DoVacuum: false,
193+
DoBackup: true,
194+
BackupTemplate: "../test/test2_%s.zip",
195+
NumFiles: 1,
196+
},
197+
},
198+
},
199+
}
200+
201+
cleanSchedTasksFiles(cfg)
202+
defer cleanSchedTasksFiles(cfg)
203+
204+
go launch(cfg, true)
205+
206+
time.Sleep(time.Second)
207+
208+
if !utils.FileExists(*cfg.Databases[0].DatabaseDef.Path) || !utils.FileExists(*cfg.Databases[1].DatabaseDef.Path) {
209+
t.Error("db file not created")
210+
return
211+
}
212+
213+
req := structs.Request{
214+
Transaction: []structs.RequestItem{
215+
{
216+
Statement: "CREATE TABLE T1 (ID INT PRIMARY KEY, VAL TEXT NOT NULL)",
217+
},
218+
},
219+
}
220+
code, _, _ := call("test1", req, t)
221+
if code != 200 {
222+
t.Error("did not succeed")
223+
return
224+
}
225+
226+
time.Sleep(time.Minute)
227+
228+
now := time.Now().Format(bkpTimeFormat)
229+
bk1 := fmt.Sprintf(cfg.Databases[0].Maintenance.BackupTemplate, now)
230+
bk2 := fmt.Sprintf(cfg.Databases[1].Maintenance.BackupTemplate, now)
231+
232+
if !utils.FileExists(bk1) || !utils.FileExists(bk2) {
233+
t.Error("backup file not created")
234+
return
235+
}
236+
237+
stat1, _ := os.Stat(bk1)
238+
stat2, _ := os.Stat(bk2)
239+
240+
if stat2.Size() >= stat1.Size() {
241+
t.Error("backup files sizes are inconsistent")
242+
}
243+
244+
time.Sleep(time.Minute)
245+
246+
now = time.Now().Format(bkpTimeFormat)
247+
bk3 := fmt.Sprintf(cfg.Databases[0].Maintenance.BackupTemplate, now)
248+
bk4 := fmt.Sprintf(cfg.Databases[1].Maintenance.BackupTemplate, now)
249+
250+
if !utils.FileExists(bk3) || !utils.FileExists(bk4) {
251+
t.Error("backup file not created, the second time")
252+
return
253+
}
254+
255+
if utils.FileExists(bk1) || utils.FileExists(bk2) {
256+
t.Error("backup file not rotated")
257+
return
258+
}
259+
260+
time.Sleep(time.Second)
261+
}
262+
159263
// Takes one minute
160264
func TestSchedTasksWithReadOnly(t *testing.T) {
161265
defer os.Remove("../test/test.db")

src/utils/utils.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717
package utils
1818

1919
import (
20+
"archive/zip"
2021
"database/sql"
2122
"encoding/json"
2223
"errors"
24+
"io"
2325
"os"
26+
"path/filepath"
2427
"slices"
2528
"strings"
2629

@@ -122,3 +125,44 @@ func GetDefault[T any](m orderedmap.OrderedMap, key string) T {
122125

123126
return value.(T)
124127
}
128+
129+
// Zips a folder to a path
130+
func ZipFolder(srcDir, destZip string) error {
131+
zipFile, err := os.Create(destZip)
132+
if err != nil {
133+
return err
134+
}
135+
defer zipFile.Close()
136+
137+
zipWriter := zip.NewWriter(zipFile)
138+
defer zipWriter.Close()
139+
140+
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
141+
if err != nil {
142+
return err
143+
}
144+
145+
relPath, err := filepath.Rel(srcDir, path)
146+
if err != nil {
147+
return err
148+
}
149+
150+
if info.IsDir() {
151+
return nil
152+
}
153+
154+
file, err := os.Open(path)
155+
if err != nil {
156+
return err
157+
}
158+
defer file.Close()
159+
160+
zipEntry, err := zipWriter.Create(relPath)
161+
if err != nil {
162+
return err
163+
}
164+
165+
_, err = io.Copy(zipEntry, file)
166+
return err
167+
})
168+
}

0 commit comments

Comments
 (0)