Skip to content

Commit 74d603a

Browse files
committed
add gcs.parallel_upload for parallel composite uploads of large files
Files bigger than gcs.parallel_upload_min_size (default 1GB) upload to GCS as multiple parallel parts via a dedicated gRPC client and compose into the final object, using the experimental EnableParallelUpload writer surface from cloud.google.com/go/storage v1.62+. Single-stream uploads of 100GB+ parts were the bottleneck for large-table backups (12h+), see #1028. - parallel_upload_part_size (0 = SDK default 16MiB, min 5MiB) and parallel_upload_max_concurrency (0 = SDK default min(4+CPU/2, 16)) - rejected together with endpoint, force_http and encryption_key: PCU works only via gRPC to storage.googleapis.com and Compose requires the same CSEK for source parts and destination - retry policy set client-level: part uploads create fresh ObjectHandles, per-object Retryer does not cover them - UploadPath now passes the real file size to PutFile so the min_size threshold works for uncompressed uploads - TestGCSParallelUpload runs against the real bucket with GCS_ENCRYPTION_KEY unset per command (run.sh exports it globally for CSEK coverage in TestGCS)
1 parent 75493eb commit 74d603a

9 files changed

Lines changed: 192 additions & 7 deletions

File tree

ChangeLog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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)
34
- 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)
45

56
# v2.7.4

ReadMe.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,13 @@ 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
371+
# requires direct gRPC connection to storage.googleapis.com, not compatible with `endpoint`, `force_http` and `encryption_key`
372+
# 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
370377
# GCS_OBJECT_LABELS, allow setup metadata for each object during upload, use {macro_name} from system.macros and {backupName} for current backup name
371378
# The format for this env variable is "key1:value1,key2:value2". For YAML please continue using map syntax
372379
object_labels: {}

pkg/config/config.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ 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.
181+
// 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"`
179189
}
180190

181191
// AzureBlobConfig - Azure Blob settings section
@@ -608,6 +618,19 @@ func ValidateConfig(cfg *Config) error {
608618
}
609619
}
610620
}
621+
if cfg.General.RemoteStorage == "gcs" && cfg.GCS.ParallelUpload {
622+
// parallel composite upload works only via gRPC client to storage.googleapis.com,
623+
// and Compose requires the same CSEK for source parts and destination object
624+
if cfg.GCS.Endpoint != "" {
625+
return errors.New("gcs `parallel_upload` requires gRPC client and is not compatible with `endpoint`")
626+
}
627+
if cfg.GCS.ForceHttp {
628+
return errors.New("gcs `parallel_upload` requires gRPC client and is not compatible with `force_http`")
629+
}
630+
if cfg.GCS.EncryptionKey != "" {
631+
return errors.New("gcs `parallel_upload` is not compatible with `encryption_key`")
632+
}
633+
}
611634
if cfg.GetCompressionFormat() == "unknown" {
612635
return errors.Errorf("'%s' is unknown remote storage", cfg.General.RemoteStorage)
613636
}
@@ -884,9 +907,10 @@ func DefaultConfig() *Config {
884907
StorageClass: "STANDARD",
885908
ClientPoolSize: int(max(uploadConcurrency*3, downloadConcurrency*3, objectDiskServerSideCopyConcurrency)),
886909
// 16Mb default chunk size, fix https://github.com/Altinity/clickhouse-backup/issues/1292
887-
ChunkSize: 16 * 1024 * 1024,
888-
DeleteConcurrency: 50,
889-
UploadBufferSize: 128 * 1024,
910+
ChunkSize: 16 * 1024 * 1024,
911+
DeleteConcurrency: 50,
912+
UploadBufferSize: 128 * 1024,
913+
ParallelUploadMinSize: 1024 * 1024 * 1024,
890914
},
891915
COS: COSConfig{
892916
RowURL: "",

pkg/config/config_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,25 @@ 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) {
391+
cfg.General.RemoteStorage = "gcs"
392+
cfg.GCS.ParallelUpload = true
393+
}, ""},
394+
{"gcs parallel_upload incompatible with endpoint", func(cfg *Config) {
395+
cfg.General.RemoteStorage = "gcs"
396+
cfg.GCS.ParallelUpload = true
397+
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) {
400+
cfg.General.RemoteStorage = "gcs"
401+
cfg.GCS.ParallelUpload = true
402+
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) {
405+
cfg.General.RemoteStorage = "gcs"
406+
cfg.GCS.ParallelUpload = true
407+
cfg.GCS.EncryptionKey = "dGVzdC1rZXktdGVzdC1rZXktdGVzdC1rZXktdGVzdA=="
408+
}, "gcs `parallel_upload` is not compatible with `encryption_key`"},
390409
{"unknown remote storage", func(cfg *Config) {
391410
cfg.General.RemoteStorage = "banana"
392411
}, "'banana' is unknown remote storage"},

pkg/storage/gcs.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
// GCS - presents methods for manipulate data on GCS
3232
type GCS struct {
3333
client *storage.Client
34+
grpcClient *storage.Client // used only for parallel composite uploads, see https://github.com/Altinity/clickhouse-backup/issues/1028
3435
Config *config.GCSConfig
3536
clientPool *pool.ObjectPool
3637
encryptionKey []byte // Customer-Supplied Encryption Key (CSEK)
@@ -239,6 +240,20 @@ func (gcs *GCS) Connect(ctx context.Context) error {
239240
return errors.Wrap(err, "GCS Connect storage.NewClient")
240241
}
241242

243+
if gcs.Config.ParallelUpload {
244+
grpcOptions := []option.ClientOption{option.WithTelemetryDisabled()}
245+
if credOption != nil {
246+
grpcOptions = append(grpcOptions, credOption)
247+
}
248+
gcs.grpcClient, err = storage.NewGRPCClient(ctx, grpcOptions...)
249+
if err != nil {
250+
return errors.Wrap(err, "GCS Connect storage.NewGRPCClient")
251+
}
252+
// client-level retry policy, part uploads inside parallel upload create
253+
// fresh ObjectHandle's from the client, so per-object Retryer doesn't cover them
254+
gcs.grpcClient.SetRetry(storage.WithPolicy(storage.RetryAlways))
255+
}
256+
242257
// Validate and decode the encryption key if provided
243258
if gcs.Config.EncryptionKey != "" {
244259
key, err := base64.StdEncoding.DecodeString(gcs.Config.EncryptionKey)
@@ -257,6 +272,11 @@ func (gcs *GCS) Connect(ctx context.Context) error {
257272

258273
func (gcs *GCS) Close(ctx context.Context) error {
259274
gcs.clientPool.Close(ctx)
275+
if gcs.grpcClient != nil {
276+
if err := gcs.grpcClient.Close(); err != nil {
277+
return errors.Wrap(err, "GCS Close grpcClient")
278+
}
279+
}
260280
if err := gcs.client.Close(); err != nil {
261281
return errors.Wrap(err, "GCS Close")
262282
}
@@ -381,7 +401,10 @@ func (gcs *GCS) PutFile(ctx context.Context, key string, r io.ReadCloser, localS
381401
return gcs.PutFileAbsolute(ctx, path.Join(gcs.Config.Path, key), r, localSize)
382402
}
383403

384-
func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser, _ int64) error {
404+
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)
407+
}
385408
pClientObj, err := gcs.clientPool.BorrowObject(ctx)
386409
if err != nil {
387410
log.Error().Msgf("gcs.PutFile: gcs.clientPool.BorrowObject error: %+v", err)
@@ -425,6 +448,39 @@ func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser
425448
return nil
426449
}
427450

451+
// putFileParallel uploads a large file with the experimental parallel composite upload,
452+
// temporary parts go to the `gcs-go-sdk-pu-tmp/` prefix in the bucket root and are composed
453+
// 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)
457+
obj := gcs.grpcClient.Bucket(gcs.Config.Bucket).Object(key)
458+
writer := obj.NewWriter(ctx)
459+
writer.EnableParallelUpload = true
460+
writer.ParallelUploadConfig = storage.ParallelUploadConfig{
461+
PartSize: gcs.Config.ParallelUploadPartSize,
462+
MaxConcurrency: gcs.Config.ParallelUploadMaxConcurrency,
463+
}
464+
writer.StorageClass = gcs.Config.StorageClass
465+
if len(gcs.Config.ObjectLabels) > 0 {
466+
writer.Metadata = gcs.Config.ObjectLabels
467+
}
468+
uploadBufferSize := gcs.Config.UploadBufferSize
469+
if uploadBufferSize <= 0 {
470+
uploadBufferSize = 128 * 1024
471+
}
472+
buffer := make([]byte, uploadBufferSize)
473+
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")
476+
}
477+
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")
480+
}
481+
return nil
482+
}
483+
428484
func (gcs *GCS) StatFile(ctx context.Context, key string) (RemoteFile, error) {
429485
return gcs.StatFileAbsolute(ctx, path.Join(gcs.Config.Path, key))
430486
}

pkg/storage/general.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,7 @@ func (bd *BackupDestination) UploadPath(ctx context.Context, baseLocalPath strin
785785
}
786786
retry := retrier.New(retrier.ExponentialBackoff(RetriesOnFailure, common.AddRandomJitter(RetriesDuration, RetriesJitter)), RertierClassifier)
787787
err = retry.RunCtx(ctx, func(ctx context.Context) error {
788-
return bd.PutFile(ctx, path.Join(remotePath, filename), bwlimit.ReadCloser(ctx, f, limiter), 0)
788+
return bd.PutFile(ctx, path.Join(remotePath, filename), bwlimit.ReadCloser(ctx, f, limiter), fInfo.Size())
789789
})
790790
if err != nil {
791791
closeFile()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
general:
2+
remote_storage: gcs
3+
upload_concurrency: 4
4+
download_concurrency: 4
5+
restore_schema_on_cluster: "{cluster}"
6+
allow_object_disk_streaming: true
7+
s3:
8+
disable_ssl: false
9+
disable_cert_verification: true
10+
clickhouse:
11+
host: clickhouse
12+
port: 9000
13+
restart_command: bash -c 'echo "FAKE RESTART"'
14+
timeout: 10m
15+
gcs:
16+
bucket: altinity-qa-test
17+
path: backup/{cluster}/{shard}/{version}
18+
object_disk_path: object_disks/{cluster}/{shard}/{version}
19+
credentials_file: /etc/clickhouse-backup/credentials.json
20+
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

test/integration/containers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,7 @@ func (tc *TestContainers) clickHouseBinds(curDir, configsDir string) []string {
10351035
"config-custom-kopia.yml", "config-custom-restic.yml", "config-custom-rsync.yml",
10361036
"config-database-mapping.yml",
10371037
"config-ftp.yaml", "config-ftp-old.yaml",
1038-
"config-gcs.yml", "config-gcs-custom-endpoint.yml",
1038+
"config-gcs.yml", "config-gcs-custom-endpoint.yml", "config-gcs-parallel.yml",
10391039
"config-s3.yml", "config-s3-embedded.yml", "config-s3-embedded-url.yml",
10401040
"config-s3-embedded-local.yml", "config-s3-nodelete.yml", "config-s3-plain-embedded.yml",
10411041
"config-sftp-auth-key.yaml", "config-sftp-auth-password.yaml",

test/integration/gcs_test.go

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
package main
44

5-
import "testing"
5+
import (
6+
"fmt"
7+
"math/rand"
8+
"testing"
9+
"time"
10+
)
611

712
func TestGCS(t *testing.T) {
813
if isTestShouldSkip("GCS_TESTS") {
@@ -13,3 +18,51 @@ func TestGCS(t *testing.T) {
1318
defer env.Cleanup(t, r)
1419
env.runMainIntegrationScenario(t, "GCS", "config-gcs.yml")
1520
}
21+
22+
// TestGCSParallelUpload exercises the experimental parallel composite upload path
23+
// (gcs.parallel_upload), see https://github.com/Altinity/clickhouse-backup/issues/1028.
24+
// run.sh exports GCS_ENCRYPTION_KEY for all containers, but CSEK is not compatible with
25+
// parallel_upload, so every command unsets it explicitly.
26+
func TestGCSParallelUpload(t *testing.T) {
27+
if isTestShouldSkip("GCS_TESTS") {
28+
t.Skip("Skipping GCS integration tests...")
29+
return
30+
}
31+
env, r := NewTestEnvironment(t)
32+
defer env.Cleanup(t, r)
33+
env.connectWithWait(t, r, 500*time.Millisecond, 1500*time.Millisecond, 3*time.Minute)
34+
35+
backupName := fmt.Sprintf("%s_%d", t.Name(), rand.Int())
36+
cfgPath := "/etc/clickhouse-backup/config-gcs-parallel.yml"
37+
dbName := "test_gcs_parallel_upload"
38+
39+
execCmd := func(cmd string) string {
40+
out, err := env.DockerExecOut("clickhouse-backup", "bash", "-ce", "GCS_ENCRYPTION_KEY= LOG_LEVEL=debug "+cmd)
41+
r.NoError(err, "%s\n%s", cmd, out)
42+
return out
43+
}
44+
45+
env.queryWithNoError(t, r, "CREATE DATABASE IF NOT EXISTS "+dbName)
46+
env.queryWithNoError(t, r, "CREATE TABLE IF NOT EXISTS "+dbName+".big (key UInt64, value String) ENGINE=MergeTree() ORDER BY key")
47+
// ~30MB of incompressible data, so the tar stream exceeds parallel_upload_min_size
48+
// and splits into multiple parallel_upload_part_size parts
49+
env.queryWithNoError(t, r, "INSERT INTO "+dbName+".big SELECT number, randomString(1000) FROM numbers(30000)")
50+
51+
execCmd(fmt.Sprintf("clickhouse-backup -c %s create --tables=%s.* %s", cfgPath, dbName, backupName))
52+
out := execCmd(fmt.Sprintf("clickhouse-backup -c %s upload %s", cfgPath, backupName))
53+
r.Contains(out, "putFileParallel", "upload must go through the parallel composite upload path")
54+
55+
execCmd(fmt.Sprintf("clickhouse-backup -c %s delete local %s", cfgPath, backupName))
56+
env.queryWithNoError(t, r, "DROP DATABASE IF EXISTS "+dbName)
57+
58+
execCmd(fmt.Sprintf("clickhouse-backup -c %s download %s", cfgPath, backupName))
59+
execCmd(fmt.Sprintf("clickhouse-backup -c %s restore %s", cfgPath, backupName))
60+
61+
var count uint64
62+
r.NoError(env.ch.SelectSingleRowNoCtx(&count, "SELECT count() FROM "+dbName+".big"))
63+
r.Equal(uint64(30000), count)
64+
65+
execCmd(fmt.Sprintf("clickhouse-backup -c %s delete local %s", cfgPath, backupName))
66+
execCmd(fmt.Sprintf("clickhouse-backup -c %s delete remote %s", cfgPath, backupName))
67+
env.queryWithNoError(t, r, "DROP DATABASE IF EXISTS "+dbName)
68+
}

0 commit comments

Comments
 (0)