Skip to content

Commit 77d15c0

Browse files
committed
add MultiPart download, test refactoring, fix #1028
1 parent 74d603a commit 77d15c0

7 files changed

Lines changed: 150 additions & 62 deletions

File tree

ChangeLog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# v2.8.0
22
NEW FEATURES
3-
- add `gcs.parallel_upload` (env `GCS_PARALLEL_UPLOAD`, default `false`, experimental) with `gcs.parallel_upload_part_size`, `gcs.parallel_upload_max_concurrency` and `gcs.parallel_upload_min_size` (default 1GB) — files bigger than `parallel_upload_min_size` upload to GCS as multiple parallel parts via gRPC client and compose into the final object; not compatible with `endpoint`, `force_http`, `encryption_key`, fix [#1028](https://github.com/Altinity/clickhouse-backup/issues/1028)
3+
- add `gcs.allow_multipart_upload` (env `GCS_ALLOW_MULTIPART_UPLOAD`, default `false`, experimental) with `gcs.upload_concurrency` and `gcs.multipart_upload_min_size` (default 1GB) — files bigger than `multipart_upload_min_size` upload to GCS as multiple parallel parts (part size is `chunk_size`) via gRPC client and compose into the final object; not compatible with `endpoint`, `force_http`, `encryption_key`, fix [#1028](https://github.com/Altinity/clickhouse-backup/issues/1028)
4+
- add `gcs.allow_multipart_download` (env `GCS_ALLOW_MULTIPART_DOWNLOAD`, default `false`) with `gcs.download_concurrency` — download each file as parallel range reads (part size is `chunk_size`) into a temporary file, mirrors `s3.allow_multipart_download`, fix [#1028](https://github.com/Altinity/clickhouse-backup/issues/1028)
45
- add `general.disable_environment_override` (settable ONLY in the config file, no environment variable name on purpose, default `false`) — when `true`, config values come only from the config file: all environment variables and the `--env` CLI flag are ignored during config loading; protects against accidental overrides such as Kubernetes service-discovery variables, fix [#1079](https://github.com/Altinity/clickhouse-backup/issues/1079)
56

67
# v2.7.4

ReadMe.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -367,13 +367,15 @@ gcs:
367367
client_pool_size: 500 # GCS_CLIENT_POOL_SIZE, default max(upload_concurrency, download concurrency) * 3, should be at least 3 times bigger than `UPLOAD_CONCURRENCY` or `DOWNLOAD_CONCURRENCY` in each upload and download case to avoid stuck
368368
delete_concurrency: 50 # GCS_DELETE_CONCURRENCY, how many objects delete in parallel during clean/delete operations
369369
upload_buffer_size: 131072 # GCS_UPLOAD_BUFFER_SIZE, io.CopyBuffer size in bytes feeding the GCS object writer, default 128KB; raise (e.g. 1048576 = 1MB) on high-bandwidth networks, see https://github.com/Altinity/clickhouse-backup/issues/1376
370-
# GCS_PARALLEL_UPLOAD, experimental: upload files bigger than `parallel_upload_min_size` as multiple parts in parallel and compose them into the final object, see https://github.com/Altinity/clickhouse-backup/issues/1028
370+
# GCS_ALLOW_MULTIPART_UPLOAD, experimental: upload files bigger than `multipart_upload_min_size` as multiple parts (part size is `chunk_size`, minimum 5MiB) in parallel and compose them into the final object, see https://github.com/Altinity/clickhouse-backup/issues/1028
371371
# requires direct gRPC connection to storage.googleapis.com, not compatible with `endpoint`, `force_http` and `encryption_key`
372372
# temporary parts are written to the `gcs-go-sdk-pu-tmp/` prefix in the bucket root and deleted after compose; setup a bucket lifecycle rule for this prefix to clean up leftovers after crashes
373-
parallel_upload: false
374-
parallel_upload_part_size: 0 # GCS_PARALLEL_UPLOAD_PART_SIZE, size in bytes of each part uploaded in parallel, default 16MiB, minimum 5MiB
375-
parallel_upload_max_concurrency: 0 # GCS_PARALLEL_UPLOAD_MAX_CONCURRENCY, how many parts of one file upload in parallel, default min(4 + CPU/2, 16); total concurrent streams = upload_concurrency * parallel_upload_max_concurrency
376-
parallel_upload_min_size: 1073741824 # GCS_PARALLEL_UPLOAD_MIN_SIZE, files smaller than this size in bytes use the regular single-stream upload, default 1GB
373+
allow_multipart_upload: false
374+
upload_concurrency: 0 # GCS_UPLOAD_CONCURRENCY, how many parts of one file upload in parallel when `allow_multipart_upload` enabled, default min(4 + CPU/2, 16)
375+
multipart_upload_min_size: 1073741824 # GCS_MULTIPART_UPLOAD_MIN_SIZE, files smaller than this size in bytes use the regular single-stream upload, default 1GB
376+
# GCS_ALLOW_MULTIPART_DOWNLOAD, download each file as parallel range reads (part size is `chunk_size`) into a temporary file, requires additional disk space, see https://github.com/Altinity/clickhouse-backup/issues/1028
377+
allow_multipart_download: false
378+
download_concurrency: 1 # GCS_DOWNLOAD_CONCURRENCY, how many parts of one file download in parallel when `allow_multipart_download` enabled, default `max(CPU/2, 1) + 1`
377379
# GCS_OBJECT_LABELS, allow setup metadata for each object during upload, use {macro_name} from system.macros and {backupName} for current backup name
378380
# The format for this env variable is "key1:value1,key2:value2". For YAML please continue using map syntax
379381
object_labels: {}

pkg/config/config.go

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,21 @@ type GCSConfig struct {
176176
EncryptionKey string `yaml:"encryption_key" envconfig:"GCS_ENCRYPTION_KEY"`
177177
// UploadBufferSize - io.CopyBuffer size feeding the GCS object writer, see https://github.com/Altinity/clickhouse-backup/issues/1376
178178
UploadBufferSize int `yaml:"upload_buffer_size" envconfig:"GCS_UPLOAD_BUFFER_SIZE"`
179-
// ParallelUpload enables experimental parallel composite uploads via a gRPC client for files
180-
// bigger than ParallelUploadMinSize, see https://github.com/Altinity/clickhouse-backup/issues/1028.
179+
// AllowMultipartUpload enables experimental parallel composite uploads via a gRPC client for files
180+
// bigger than MultipartUploadMinSize, part size is chunk_size, see https://github.com/Altinity/clickhouse-backup/issues/1028.
181181
// Not compatible with `endpoint`, `force_http` and `encryption_key`.
182-
ParallelUpload bool `yaml:"parallel_upload" envconfig:"GCS_PARALLEL_UPLOAD"`
183-
// ParallelUploadPartSize - size of each part uploaded in parallel, 0 means SDK default 16MiB, minimum 5MiB
184-
ParallelUploadPartSize int `yaml:"parallel_upload_part_size" envconfig:"GCS_PARALLEL_UPLOAD_PART_SIZE"`
185-
// ParallelUploadMaxConcurrency - how many parts of one file upload in parallel, 0 means SDK default min(4 + NumCPU/2, 16)
186-
ParallelUploadMaxConcurrency int `yaml:"parallel_upload_max_concurrency" envconfig:"GCS_PARALLEL_UPLOAD_MAX_CONCURRENCY"`
187-
// ParallelUploadMinSize - files smaller than this size use the regular single-stream upload
188-
ParallelUploadMinSize int64 `yaml:"parallel_upload_min_size" envconfig:"GCS_PARALLEL_UPLOAD_MIN_SIZE"`
182+
AllowMultipartUpload bool `yaml:"allow_multipart_upload" envconfig:"GCS_ALLOW_MULTIPART_UPLOAD"`
183+
// UploadConcurrency - how many parts of one file upload in parallel when allow_multipart_upload enabled,
184+
// 0 means SDK default min(4 + NumCPU/2, 16)
185+
UploadConcurrency int `yaml:"upload_concurrency" envconfig:"GCS_UPLOAD_CONCURRENCY"`
186+
// MultipartUploadMinSize - files smaller than this size use the regular single-stream upload
187+
MultipartUploadMinSize int64 `yaml:"multipart_upload_min_size" envconfig:"GCS_MULTIPART_UPLOAD_MIN_SIZE"`
188+
// AllowMultipartDownload - download each file as parallel range reads into a temporary file
189+
// (requires additional disk space), part size is chunk_size, mirrors s3.allow_multipart_download,
190+
// see https://github.com/Altinity/clickhouse-backup/issues/1028
191+
AllowMultipartDownload bool `yaml:"allow_multipart_download" envconfig:"GCS_ALLOW_MULTIPART_DOWNLOAD"`
192+
// DownloadConcurrency - how many parts of one file download in parallel when allow_multipart_download enabled
193+
DownloadConcurrency int `yaml:"download_concurrency" envconfig:"GCS_DOWNLOAD_CONCURRENCY"`
189194
}
190195

191196
// AzureBlobConfig - Azure Blob settings section
@@ -618,19 +623,25 @@ func ValidateConfig(cfg *Config) error {
618623
}
619624
}
620625
}
621-
if cfg.General.RemoteStorage == "gcs" && cfg.GCS.ParallelUpload {
626+
if cfg.General.RemoteStorage == "gcs" && cfg.GCS.AllowMultipartUpload {
622627
// parallel composite upload works only via gRPC client to storage.googleapis.com,
623628
// and Compose requires the same CSEK for source parts and destination object
624629
if cfg.GCS.Endpoint != "" {
625-
return errors.New("gcs `parallel_upload` requires gRPC client and is not compatible with `endpoint`")
630+
return errors.New("gcs `allow_multipart_upload` requires gRPC client and is not compatible with `endpoint`")
626631
}
627632
if cfg.GCS.ForceHttp {
628-
return errors.New("gcs `parallel_upload` requires gRPC client and is not compatible with `force_http`")
633+
return errors.New("gcs `allow_multipart_upload` requires gRPC client and is not compatible with `force_http`")
629634
}
630635
if cfg.GCS.EncryptionKey != "" {
631-
return errors.New("gcs `parallel_upload` is not compatible with `encryption_key`")
636+
return errors.New("gcs `allow_multipart_upload` is not compatible with `encryption_key`")
632637
}
633638
}
639+
if cfg.General.RemoteStorage == "gcs" && cfg.GCS.AllowMultipartDownload && cfg.GCS.DownloadConcurrency <= 1 {
640+
return errors.Errorf(
641+
"`allow_multipart_download` require `download_concurrency` in `gcs` section more than 1 (3-4 recommends) current value: %d",
642+
cfg.GCS.DownloadConcurrency,
643+
)
644+
}
634645
if cfg.GetCompressionFormat() == "unknown" {
635646
return errors.Errorf("'%s' is unknown remote storage", cfg.General.RemoteStorage)
636647
}
@@ -907,10 +918,11 @@ func DefaultConfig() *Config {
907918
StorageClass: "STANDARD",
908919
ClientPoolSize: int(max(uploadConcurrency*3, downloadConcurrency*3, objectDiskServerSideCopyConcurrency)),
909920
// 16Mb default chunk size, fix https://github.com/Altinity/clickhouse-backup/issues/1292
910-
ChunkSize: 16 * 1024 * 1024,
911-
DeleteConcurrency: 50,
912-
UploadBufferSize: 128 * 1024,
913-
ParallelUploadMinSize: 1024 * 1024 * 1024,
921+
ChunkSize: 16 * 1024 * 1024,
922+
DeleteConcurrency: 50,
923+
UploadBufferSize: 128 * 1024,
924+
MultipartUploadMinSize: 1024 * 1024 * 1024,
925+
DownloadConcurrency: int(downloadConcurrency + 1),
914926
},
915927
COS: COSConfig{
916928
RowURL: "",

pkg/config/config_test.go

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -387,25 +387,34 @@ func TestValidateConfigErrors(t *testing.T) {
387387
cfg.General.RemoteStorage = "s3"
388388
cfg.S3.HTTPIdleConnTimeout = "1parsec"
389389
}, "invalid s3 http_idle_conn_timeout"},
390-
{"gcs parallel_upload with default config", func(cfg *Config) {
390+
{"gcs allow_multipart_upload with default config", func(cfg *Config) {
391391
cfg.General.RemoteStorage = "gcs"
392-
cfg.GCS.ParallelUpload = true
392+
cfg.GCS.AllowMultipartUpload = true
393393
}, ""},
394-
{"gcs parallel_upload incompatible with endpoint", func(cfg *Config) {
394+
{"gcs allow_multipart_upload incompatible with endpoint", func(cfg *Config) {
395395
cfg.General.RemoteStorage = "gcs"
396-
cfg.GCS.ParallelUpload = true
396+
cfg.GCS.AllowMultipartUpload = true
397397
cfg.GCS.Endpoint = "http://gcs:8080/storage/v1/"
398-
}, "gcs `parallel_upload` requires gRPC client and is not compatible with `endpoint`"},
399-
{"gcs parallel_upload incompatible with force_http", func(cfg *Config) {
398+
}, "gcs `allow_multipart_upload` requires gRPC client and is not compatible with `endpoint`"},
399+
{"gcs allow_multipart_upload incompatible with force_http", func(cfg *Config) {
400400
cfg.General.RemoteStorage = "gcs"
401-
cfg.GCS.ParallelUpload = true
401+
cfg.GCS.AllowMultipartUpload = true
402402
cfg.GCS.ForceHttp = true
403-
}, "gcs `parallel_upload` requires gRPC client and is not compatible with `force_http`"},
404-
{"gcs parallel_upload incompatible with encryption_key", func(cfg *Config) {
403+
}, "gcs `allow_multipart_upload` requires gRPC client and is not compatible with `force_http`"},
404+
{"gcs allow_multipart_upload incompatible with encryption_key", func(cfg *Config) {
405405
cfg.General.RemoteStorage = "gcs"
406-
cfg.GCS.ParallelUpload = true
406+
cfg.GCS.AllowMultipartUpload = true
407407
cfg.GCS.EncryptionKey = "dGVzdC1rZXktdGVzdC1rZXktdGVzdC1rZXktdGVzdA=="
408-
}, "gcs `parallel_upload` is not compatible with `encryption_key`"},
408+
}, "gcs `allow_multipart_upload` is not compatible with `encryption_key`"},
409+
{"gcs allow_multipart_download with default config", func(cfg *Config) {
410+
cfg.General.RemoteStorage = "gcs"
411+
cfg.GCS.AllowMultipartDownload = true
412+
}, ""},
413+
{"gcs allow_multipart_download requires download_concurrency > 1", func(cfg *Config) {
414+
cfg.General.RemoteStorage = "gcs"
415+
cfg.GCS.AllowMultipartDownload = true
416+
cfg.GCS.DownloadConcurrency = 1
417+
}, "`allow_multipart_download` require `download_concurrency` in `gcs` section more than 1"},
409418
{"unknown remote storage", func(cfg *Config) {
410419
cfg.General.RemoteStorage = "banana"
411420
}, "'banana' is unknown remote storage"},

pkg/storage/gcs.go

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/pkg/errors"
2121

2222
"cloud.google.com/go/storage"
23+
"cloud.google.com/go/storage/transfermanager"
2324
"github.com/rs/zerolog/log"
2425
"golang.org/x/sync/errgroup"
2526
"google.golang.org/api/impersonate"
@@ -240,7 +241,7 @@ func (gcs *GCS) Connect(ctx context.Context) error {
240241
return errors.Wrap(err, "GCS Connect storage.NewClient")
241242
}
242243

243-
if gcs.Config.ParallelUpload {
244+
if gcs.Config.AllowMultipartUpload {
244245
grpcOptions := []option.ClientOption{option.WithTelemetryDisabled()}
245246
if credOption != nil {
246247
grpcOptions = append(grpcOptions, credOption)
@@ -393,17 +394,75 @@ func (gcs *GCS) GetFileReaderAbsolute(ctx context.Context, key string) (io.ReadC
393394
return reader, nil
394395
}
395396

396-
func (gcs *GCS) GetFileReaderWithLocalPath(ctx context.Context, key, _ string, _ int64) (io.ReadCloser, error) {
397+
func (gcs *GCS) GetFileReaderWithLocalPath(ctx context.Context, key, localPath string, remoteSize int64) (io.ReadCloser, error) {
398+
/* unfortunately, multipart download require allocate additional disk space
399+
and don't allow us to decompress data directly from stream */
400+
if gcs.Config.AllowMultipartDownload {
401+
log.Debug().Msgf("GCS->GetFileReaderWithLocalPath: multipart download %s, size=%d", key, remoteSize)
402+
writer, err := os.CreateTemp(localPath, strings.ReplaceAll(key, "/", "_"))
403+
if err != nil {
404+
return nil, errors.Wrap(err, "GCS GetFileReaderWithLocalPath CreateTemp")
405+
}
406+
if err = gcs.downloadMultipart(ctx, key, writer); err != nil {
407+
_ = writer.Close()
408+
_ = os.Remove(writer.Name())
409+
return nil, err
410+
}
411+
return writer, nil
412+
}
397413
return gcs.GetFileReader(ctx, key)
398414
}
399415

416+
// downloadMultipart downloads the object as parallel range reads via transfermanager.Downloader,
417+
// see https://github.com/Altinity/clickhouse-backup/issues/1028
418+
func (gcs *GCS) downloadMultipart(ctx context.Context, key string, writer io.WriterAt) error {
419+
fullKey := path.Join(gcs.Config.Path, key)
420+
partSize := int64(gcs.Config.ChunkSize)
421+
if partSize <= 0 {
422+
partSize = 16 * 1024 * 1024
423+
}
424+
attempt := func(encryptionKey []byte) error {
425+
downloader, err := transfermanager.NewDownloader(gcs.client,
426+
transfermanager.WithWorkers(gcs.Config.DownloadConcurrency),
427+
transfermanager.WithPartSize(partSize),
428+
)
429+
if err != nil {
430+
return errors.Wrap(err, "GCS downloadMultipart NewDownloader")
431+
}
432+
if err = downloader.DownloadObject(ctx, &transfermanager.DownloadObjectInput{
433+
Bucket: gcs.Config.Bucket,
434+
Object: fullKey,
435+
Destination: writer,
436+
EncryptionKey: encryptionKey,
437+
}); err != nil {
438+
return errors.Wrap(err, "GCS downloadMultipart DownloadObject")
439+
}
440+
_, err = downloader.WaitAndClose()
441+
return err
442+
}
443+
// Do NOT apply encryption for object_disks files - they are not encrypted
444+
// because ClickHouse needs to read them directly without encryption key
445+
encryptionKey := gcs.encryptionKey
446+
if gcs.Config.ObjectDiskPath != "" && strings.HasPrefix(fullKey, gcs.Config.ObjectDiskPath) {
447+
encryptionKey = nil
448+
}
449+
err := attempt(encryptionKey)
450+
if err != nil && encryptionKey != nil && gcs.isNotEncryptedError(err) {
451+
// If the object is not encrypted but we tried to read it with encryption key,
452+
// retry without encryption (for backward compatibility with old backups)
453+
log.Warn().Msgf("gcs.downloadMultipart: object %s not encrypted, retrying without encryption key", fullKey)
454+
err = attempt(nil)
455+
}
456+
return errors.Wrap(err, "GCS downloadMultipart WaitAndClose")
457+
}
458+
400459
func (gcs *GCS) PutFile(ctx context.Context, key string, r io.ReadCloser, localSize int64) error {
401460
return gcs.PutFileAbsolute(ctx, path.Join(gcs.Config.Path, key), r, localSize)
402461
}
403462

404463
func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser, localSize int64) error {
405-
if gcs.grpcClient != nil && localSize >= gcs.Config.ParallelUploadMinSize {
406-
return gcs.putFileParallel(ctx, key, r, localSize)
464+
if gcs.grpcClient != nil && localSize >= gcs.Config.MultipartUploadMinSize {
465+
return gcs.putFileMultipart(ctx, key, r, localSize)
407466
}
408467
pClientObj, err := gcs.clientPool.BorrowObject(ctx)
409468
if err != nil {
@@ -448,18 +507,19 @@ func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser
448507
return nil
449508
}
450509

451-
// putFileParallel uploads a large file with the experimental parallel composite upload,
510+
// putFileMultipart uploads a large file with the experimental parallel composite upload,
452511
// temporary parts go to the `gcs-go-sdk-pu-tmp/` prefix in the bucket root and are composed
453512
// into the final object, see https://github.com/Altinity/clickhouse-backup/issues/1028.
454-
// encryption_key is rejected together with parallel_upload in config validation, so no CSEK handling here.
455-
func (gcs *GCS) putFileParallel(ctx context.Context, key string, r io.ReadCloser, localSize int64) error {
456-
log.Debug().Msgf("GCS->putFileParallel %s, size=%d", key, localSize)
513+
// encryption_key is rejected together with allow_multipart_upload in config validation, so no CSEK handling here.
514+
func (gcs *GCS) putFileMultipart(ctx context.Context, key string, r io.ReadCloser, localSize int64) error {
515+
log.Debug().Msgf("GCS->putFileMultipart %s, size=%d", key, localSize)
457516
obj := gcs.grpcClient.Bucket(gcs.Config.Bucket).Object(key)
458517
writer := obj.NewWriter(ctx)
459518
writer.EnableParallelUpload = true
519+
// PartSize <= 0 falls back to the SDK default 16MiB, values below 5MiB are bumped to 5MiB by the SDK
460520
writer.ParallelUploadConfig = storage.ParallelUploadConfig{
461-
PartSize: gcs.Config.ParallelUploadPartSize,
462-
MaxConcurrency: gcs.Config.ParallelUploadMaxConcurrency,
521+
PartSize: gcs.Config.ChunkSize,
522+
MaxConcurrency: gcs.Config.UploadConcurrency,
463523
}
464524
writer.StorageClass = gcs.Config.StorageClass
465525
if len(gcs.Config.ObjectLabels) > 0 {
@@ -471,12 +531,12 @@ func (gcs *GCS) putFileParallel(ctx context.Context, key string, r io.ReadCloser
471531
}
472532
buffer := make([]byte, uploadBufferSize)
473533
if _, err := io.CopyBuffer(writer, r, buffer); err != nil {
474-
log.Warn().Msgf("gcs.putFileParallel: can't copy buffer: %+v", err)
475-
return errors.Wrap(err, "GCS putFileParallel CopyBuffer")
534+
log.Warn().Msgf("gcs.putFileMultipart: can't copy buffer: %+v", err)
535+
return errors.Wrap(err, "GCS putFileMultipart CopyBuffer")
476536
}
477537
if err := writer.Close(); err != nil {
478-
log.Warn().Msgf("gcs.putFileParallel: can't close writer: %+v", err)
479-
return errors.Wrap(err, "GCS putFileParallel writer.Close")
538+
log.Warn().Msgf("gcs.putFileMultipart: can't close writer: %+v", err)
539+
return errors.Wrap(err, "GCS putFileMultipart writer.Close")
480540
}
481541
return nil
482542
}

test/integration/configs/config-gcs-parallel.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ gcs:
1818
object_disk_path: object_disks/{cluster}/{shard}/{version}
1919
credentials_file: /etc/clickhouse-backup/credentials.json
2020
compression_format: tar
21-
# experimental parallel composite upload, see https://github.com/Altinity/clickhouse-backup/issues/1028
22-
# low thresholds so the ~30MB test archive splits into multiple parallel parts
23-
parallel_upload: true
24-
parallel_upload_min_size: 1048576
25-
parallel_upload_part_size: 5242880
21+
# experimental parallel composite upload and multipart download, see https://github.com/Altinity/clickhouse-backup/issues/1028
22+
# low thresholds and 5MiB chunk_size so the ~30MB test archive splits into multiple parallel parts
23+
allow_multipart_upload: true
24+
multipart_upload_min_size: 1048576
25+
allow_multipart_download: true
26+
download_concurrency: 4
27+
chunk_size: 5242880

0 commit comments

Comments
 (0)