Skip to content

Commit fba4890

Browse files
authored
Various upload improvements (#1012)
## Summary This PR contains various improvements to the multipart upload flow. I realize that it's a lot in the same PR, sorry about that. * always use multipart upload * retry chunks when they fail. I expect that this will increase stability compared to the previous flow. * use the new endpoints, since there was a breaking change (there's an upload_id that's now exposed) * refresh token between each chunk and before `/finalize`. This was necessary because the new flow can include several requests over more than an hour. * Show a spinner with the DB size during validation. The first thing we do when we start an upload is that we check that the DB is valid. For large uploads, this could make it look like the CLI is hanging. * Add debug logging for multipart upload, using the `TURSO_DEBUG_UPLOAD=1` environment variable. I wasn't able to use the existing `--debug` flag, because that one logs the bodies of the PUT requests, and they can contain control characters that will wreck your terminal. * Update the progress tracker at least every 2 seconds. Previously, it was updated every 1% of an upload. For very large uploads, this made it look like the CLI was hanging. ## Testing I uploaded a very small databases (few KB), one that was ~100MB, and one that was 1GB. There are also some automated tests in the PR. ## Failing CI test The integration tests are failing in the CI. It's because multipart upload isn't deployed to us-east-1 yet. I changed the integration tests to use `eu-north-1`. You can see a passing run in that PR (tursodatabase/tursotest#93). ## AI use The code was mostly written by Claude, but I iterated a lot with it.
2 parents 61c45fc + 3d6ab5b commit fba4890

10 files changed

Lines changed: 721 additions & 351 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
module github.com/tursodatabase/turso-cli
22

3-
go 1.22
3+
go 1.25
44

5-
toolchain go1.22.0
5+
toolchain go1.25.0
66

77
require (
88
github.com/Clever/csvlint v0.3.0

internal/cmd/db_create.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func CreateDatabase(name string) error {
113113
return err
114114
}
115115

116-
seed, err := parseDBSeedFlags(client, isAWS, remoteEncryptionCipherFlag, multipartFlag)
116+
seed, err := parseDBSeedFlags(client, isAWS, remoteEncryptionCipherFlag)
117117
if err != nil {
118118
return err
119119
}

internal/cmd/db_import.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,11 @@ import (
88
"github.com/spf13/cobra"
99
)
1010

11-
var multipartFlag bool
12-
1311
func init() {
1412
dbCmd.AddCommand(importCmd)
1513
addGroupFlag(importCmd)
1614
addRemoteEncryptionKeyFlag(importCmd)
1715
addRemoteEncryptionCipherFlag(importCmd)
18-
importCmd.Flags().BoolVar(&multipartFlag, "multipart", false, "force multipart upload")
1916
}
2017

2118
var importCmd = &cobra.Command{

internal/cmd/db_shell.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ var shellCmd = &cobra.Command{
219219
}
220220

221221
if db != nil && db.Sleeping {
222-
return fmt.Errorf("Your DB might be archived. Please run `turso group unarchive " + db.Group + "` to unarchive it")
222+
return fmt.Errorf("your DB might be archived, please run `turso group unarchive %s` to unarchive it", db.Group)
223223
}
224224
connectionInfo := getConnectionInfo(urlString, db)
225225
schemaDb := false

internal/cmd/group_flag.go

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func parseTimestampFlag() (*time.Time, error) {
6565
return &timestamp, nil
6666
}
6767

68-
func parseDBSeedFlags(client *turso.Client, isAWS bool, cipher string, multipart bool) (*turso.DBSeed, error) {
68+
func parseDBSeedFlags(client *turso.Client, isAWS bool, cipher string) (*turso.DBSeed, error) {
6969
if countFlags(fromDBFlag, fromDumpFlag, fromFileFlag, fromDumpURLFlag, fromCSVFlag) > 1 {
7070
return nil, errors.New("only one of --from prefixed flags can be used at a time")
7171
}
@@ -86,7 +86,7 @@ func parseDBSeedFlags(client *turso.Client, isAWS bool, cipher string, multipart
8686
}
8787

8888
if fromFileFlag != "" {
89-
return handleDBFile(client, fromFileFlag, isAWS, cipher, multipart)
89+
return handleDBFile(client, fromFileFlag, isAWS, cipher)
9090
}
9191

9292
if fromDumpFlag != "" {
@@ -98,7 +98,7 @@ func parseDBSeedFlags(client *turso.Client, isAWS bool, cipher string, multipart
9898
if err != nil {
9999
return nil, err
100100
}
101-
return handleCSVFile(client, fromCSVFlag, csvTableNameFlag, csvSeparator, cipher, multipart)
101+
return handleCSVFile(client, fromCSVFlag, csvTableNameFlag, csvSeparator, cipher)
102102
}
103103
if fromDumpURLFlag != "" {
104104
return handleDumpURL(fromDumpURLFlag)
@@ -191,6 +191,20 @@ func countFlags(flags ...string) (count int) {
191191
}
192192

193193
const MaxAWSDBSizeBytes = 1024 * 1024 * 1024 * 20 // 20 GB
194+
195+
func humanReadableSize(bytes int64) string {
196+
const unit = 1024
197+
if bytes < unit {
198+
return fmt.Sprintf("%d B", bytes)
199+
}
200+
div, exp := int64(unit), 0
201+
for n := bytes / unit; n >= unit; n /= unit {
202+
div *= unit
203+
exp++
204+
}
205+
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
206+
}
207+
194208
func checkIfDump(filename string) (bool, error) {
195209
file, err := os.Open(filename)
196210
if err != nil {
@@ -307,9 +321,11 @@ func sqliteFileIntegrityChecks(file string, cipher string) error {
307321
if flags.Debug() {
308322
log.Printf("Running integrity check...")
309323
}
310-
_, err = exec.Command("sqlite3", file, "pragma quick_check;").CombinedOutput()
324+
spinner := prompt.Spinner(fmt.Sprintf("Validating database file (%s)...", humanReadableSize(fileInfo.Size())))
325+
err = runQuickCheck(file)
326+
spinner.Stop()
311327
if err != nil {
312-
return fmt.Errorf("integrity check on database failed: %w", err)
328+
return err
313329
}
314330

315331
// validate reserved bytes if encryption cipher is specified
@@ -323,21 +339,28 @@ func sqliteFileIntegrityChecks(file string, cipher string) error {
323339
return nil
324340
}
325341

326-
func handleDBFileAWS(file string, cipher string, multipart bool) (*turso.DBSeed, error) {
342+
func runQuickCheck(file string) error {
343+
cmd := exec.Command("sqlite3", file, "pragma quick_check;")
344+
if err := cmd.Run(); err != nil {
345+
return fmt.Errorf("integrity check failed: %w", err)
346+
}
347+
return nil
348+
}
349+
350+
func handleDBFileAWS(file string, cipher string) (*turso.DBSeed, error) {
327351
if err := sqliteFileIntegrityChecks(file, cipher); err != nil {
328352
return nil, err
329353
}
330354

331355
seed := &turso.DBSeed{
332-
Type: "database_upload",
333-
Filepath: file,
334-
Multipart: multipart,
356+
Type: "database_upload",
357+
Filepath: file,
335358
}
336359

337360
return seed, nil
338361
}
339362

340-
func handleDBFile(client *turso.Client, file string, isAWS bool, cipher string, multipart bool) (*turso.DBSeed, error) {
363+
func handleDBFile(client *turso.Client, file string, isAWS bool, cipher string) (*turso.DBSeed, error) {
341364
if err := checkFileExists(file); err != nil {
342365
return nil, err
343366
}
@@ -346,7 +369,7 @@ func handleDBFile(client *turso.Client, file string, isAWS bool, cipher string,
346369
}
347370

348371
if isAWS {
349-
return handleDBFileAWS(file, cipher, multipart)
372+
return handleDBFileAWS(file, cipher)
350373
}
351374

352375
if err := checkSQLiteFile(file); err != nil {
@@ -416,7 +439,7 @@ func dumpSQLiteDatabase(database string, dump *os.File) error {
416439
return nil
417440
}
418441

419-
func handleCSVFile(client *turso.Client, file, csvTableName string, separator rune, cipher string, multipart bool) (*turso.DBSeed, error) {
442+
func handleCSVFile(client *turso.Client, file, csvTableName string, separator rune, cipher string) (*turso.DBSeed, error) {
420443
if err := checkFileExists(file); err != nil {
421444
return nil, err
422445
}
@@ -446,7 +469,7 @@ func handleCSVFile(client *turso.Client, file, csvTableName string, separator ru
446469
return nil, err
447470
}
448471

449-
seed, err := handleDBFile(client, tempDB.Name(), false, cipher, multipart)
472+
seed, err := handleDBFile(client, tempDB.Name(), false, cipher)
450473
if err != nil {
451474
return nil, err
452475
}

internal/cmd/group_flag_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// createTestDatabase creates a valid WAL-mode test database with approximately the given size
14+
func createTestDatabase(t *testing.T, sizeBytes int) string {
15+
t.Helper()
16+
17+
if _, err := exec.LookPath("sqlite3"); err != nil {
18+
t.Skip("sqlite3 not available, skipping test")
19+
}
20+
21+
tmpDir := t.TempDir()
22+
dbPath := filepath.Join(tmpDir, "test.db")
23+
24+
// Create database with correct settings for Turso
25+
cmd := exec.Command("sqlite3", dbPath,
26+
"PRAGMA page_size=4096;",
27+
"PRAGMA journal_mode=WAL;",
28+
"CREATE TABLE data (id INTEGER PRIMARY KEY, blob BLOB);")
29+
require.NoError(t, cmd.Run(), "failed to create test database")
30+
31+
// Fill with data to reach target size
32+
if sizeBytes > 0 {
33+
rowSize := 1000 // ~1KB per row
34+
numRows := sizeBytes / rowSize
35+
if numRows < 1 {
36+
numRows = 1
37+
}
38+
for i := 0; i < numRows; i++ {
39+
cmd = exec.Command("sqlite3", dbPath,
40+
fmt.Sprintf("INSERT INTO data (blob) VALUES (randomblob(%d));", rowSize))
41+
require.NoError(t, cmd.Run())
42+
}
43+
}
44+
45+
return dbPath
46+
}
47+
48+
func TestRunQuickCheck(t *testing.T) {
49+
if _, err := exec.LookPath("sqlite3"); err != nil {
50+
t.Skip("sqlite3 not available, skipping test")
51+
}
52+
53+
t.Run("valid database succeeds", func(t *testing.T) {
54+
dbPath := createTestDatabase(t, 10*1024) // 10KB
55+
err := runQuickCheck(dbPath)
56+
require.NoError(t, err)
57+
})
58+
59+
t.Run("corrupted database returns error", func(t *testing.T) {
60+
tmpDir := t.TempDir()
61+
dbPath := filepath.Join(tmpDir, "corrupt.db")
62+
63+
// Create a file with garbage data
64+
err := os.WriteFile(dbPath, []byte("not a valid sqlite database content here"), 0644)
65+
require.NoError(t, err)
66+
67+
err = runQuickCheck(dbPath)
68+
require.Error(t, err)
69+
require.Contains(t, err.Error(), "integrity check failed")
70+
})
71+
72+
t.Run("nonexistent file returns error", func(t *testing.T) {
73+
err := runQuickCheck("/nonexistent/path/db.sqlite")
74+
require.Error(t, err)
75+
})
76+
}

internal/turso/databases.go

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import (
1515
"github.com/tursodatabase/turso-cli/internal/prompt"
1616
)
1717

18-
const multipartUploadThresholdBytes = 100 * 1024 * 1024 // 100MB
19-
2018
type Database struct {
2119
ID string `json:"dbId" mapstructure:"dbId"`
2220
Name string
@@ -133,8 +131,7 @@ type DBSeed struct {
133131
Timestamp *time.Time `json:"timestamp,omitempty"`
134132
// This is only used locally when uploading a database file and
135133
// never passed to the control plane as JSON.
136-
Filepath string `json:"-"`
137-
Multipart bool `json:"-"`
134+
Filepath string `json:"-"`
138135
}
139136

140137
type RemoteEncryption struct {
@@ -158,17 +155,14 @@ type CreateDatabaseBody struct {
158155
func (d *DatabasesClient) Create(name, location, image, extensions, group string, schema string, isSchema bool, seed *DBSeed, sizeLimit, remoteEncryptionCipher, remoteEncryptionKey string, spinner *prompt.SpinnerT) (*CreateDatabaseResponse, error) {
159156
isTursoServerUpload := seed != nil && seed.Type == "database_upload" && seed.Filepath != ""
160157
var uploadFilepath string
161-
var useMultipart bool
162158
var params CreateDatabaseBody
163159
if isTursoServerUpload {
164160
uploadFilepath = seed.Filepath
165-
useMultipart = seed.Multipart
166161
// Clear the unused seed parameters, only Type=database_upload is used.
167162
seed.Filepath = ""
168163
seed.Name = ""
169164
seed.URL = ""
170165
seed.Timestamp = nil
171-
seed.Multipart = false
172166
params = CreateDatabaseBody{
173167
Name: name,
174168
Location: location,
@@ -216,7 +210,7 @@ func (d *DatabasesClient) Create(name, location, image, extensions, group string
216210
}
217211

218212
if isTursoServerUpload {
219-
if _, err = d.UploadDatabaseAWS(data, group, uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey, useMultipart, spinner); err != nil {
213+
if _, err = d.UploadDatabaseAWS(data, group, uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey, spinner); err != nil {
220214
// Clean up the database if the upload fails
221215
if deleteErr := d.Delete(data.Database.Name); deleteErr != nil {
222216
fmt.Printf("%v", deleteErr)
@@ -237,36 +231,26 @@ func (d *DatabasesClient) Create(name, location, image, extensions, group string
237231
// This call happens in DatabasesClient.Create() above, after which it calls this function.
238232
// 2. This function creates a DB token for the newly-created DB, and then calls turso-server to upload the database file.
239233
// turso-server will perform validations on the file and 'activate' the db if everything is ok.
240-
func (d *DatabasesClient) UploadDatabaseAWS(resp *CreateDatabaseResponse, group, uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey string, useMultipart bool, spinner *prompt.SpinnerT) (*CreateDatabaseResponse, error) {
241-
// Create a short-lived DB token for the newly created database to facilitate the upload
242-
token, err := d.Token(resp.Database.Name, "1h", false, nil, nil)
243-
if err != nil {
244-
return nil, fmt.Errorf("could not create database token: %w", err)
234+
func (d *DatabasesClient) UploadDatabaseAWS(resp *CreateDatabaseResponse, group, uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey string, spinner *prompt.SpinnerT) (*CreateDatabaseResponse, error) {
235+
dbName := resp.Database.Name
236+
tokenTTL := 5 * time.Minute
237+
tokenProvider := func() (string, error) {
238+
return d.Token(dbName, "5m", false, nil, nil)
245239
}
246240

247241
baseURL, err := url.Parse(fmt.Sprintf("https://%s", resp.Database.Hostname))
248242
if err != nil {
249243
return nil, fmt.Errorf("unable to create TursoServerClient: %v", err)
250244
}
251-
tursoServerClient, err := NewTursoServerClient(baseURL, token, d.client.cliVersion, d.client.Org)
245+
tursoServerClient, err := NewTursoServerClient(baseURL, tokenProvider, tokenTTL, d.client.cliVersion, d.client.Org)
252246
if err != nil {
253247
return nil, fmt.Errorf("could not create Turso server client: %w", err)
254248
}
255249

256250
// Upload the database file
257251
spinner.Text(fmt.Sprintf("Uploading database %s in group %s, this may take a while...", internal.Emph(resp.Database.Name), internal.Emph(group)))
258252

259-
stat, err := os.Stat(uploadFilepath)
260-
if err != nil {
261-
return nil, fmt.Errorf("failed to fetch file size %s: %w", uploadFilepath, err)
262-
}
263-
264-
uploadFunc := tursoServerClient.UploadFileSinglePart
265-
if useMultipart || stat.Size() > multipartUploadThresholdBytes {
266-
uploadFunc = tursoServerClient.UploadFileMultipart
267-
}
268-
269-
err = uploadFunc(uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey, func(progressPct int, uploadedBytes int64, totalBytes int64, elapsedTime time.Duration, done bool) {
253+
err = tursoServerClient.UploadFileMultipart(uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey, func(progressPct int, uploadedBytes int64, totalBytes int64, elapsedTime time.Duration, done bool) {
270254
totalSeconds := int(elapsedTime.Seconds())
271255
minutes := totalSeconds / 60
272256
seconds := totalSeconds % 60
@@ -304,15 +288,14 @@ func (d *DatabasesClient) Export(dbName, dbUrl, outputFile string, withMetadata
304288
return fmt.Errorf("file %s already exists, use `--overwrite` flag to overwrite it", outputFile)
305289
}
306290
}
307-
token, err := d.Token(dbName, "1h", false, nil, nil)
308-
if err != nil {
309-
return fmt.Errorf("could not create database token: %w", err)
291+
tokenProvider := func() (string, error) {
292+
return d.Token(dbName, "1h", false, nil, nil)
310293
}
311294
baseURL, err := url.Parse(dbUrl)
312295
if err != nil {
313296
return fmt.Errorf("could not parse database URL: %w", err)
314297
}
315-
tursoServerClient, err := NewTursoServerClient(baseURL, token, d.client.cliVersion, d.client.Org)
298+
tursoServerClient, err := NewTursoServerClient(baseURL, tokenProvider, time.Hour, d.client.cliVersion, d.client.Org)
316299
if err != nil {
317300
return fmt.Errorf("could not create Turso server client: %w", err)
318301
}

internal/turso/turso.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ func New(base *url.URL, token string, cliVersion string, org string) *Client {
6363
return c
6464
}
6565

66+
func (t *Client) SetToken(token string) {
67+
t.token = token
68+
}
69+
6670
func (t *Client) newRequest(method, urlPath string, body io.Reader, extraHeaders map[string]string) (*http.Request, error) {
6771
if _, exists := extraHeaders["Content-Type"]; !exists {
6872
return nil, errors.New("content type is required")

0 commit comments

Comments
 (0)