Skip to content

Commit de41b40

Browse files
authored
Merge pull request #1609 from Altinity/issue_1607_transfer_throughput
Fix SFTP and GCS transfer throughput under RTT, rate limits and HTTP/1.1
2 parents 4b757d7 + 18f2c72 commit de41b40

13 files changed

Lines changed: 343 additions & 39 deletions

File tree

‎ChangeLog.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ BUG FIXES
77
- `list` and `tables` text output is now written to stdout as a single write, so a log line on stderr can no longer land in the middle of a backup row when both streams are merged (`docker exec`, `2>&1`), the same fix `json` / `yaml` / `csv` / `tsv` formats got in v2.8.1
88
- `restore` / `restore_remote` with `--restore-database-mapping` / `--restore-table-mapping` of tables on object disks (`s3`, `azure_blob_storage`, including `cache` and `encrypted` wrappers) no longer leaves the restored copy sharing object storage keys with the source table, so `DROP TABLE|DATABASE ... SYNC` of the copy no longer deletes the objects of the source (`Code: 499 ... The specified key does not exist (S3_ERROR)` on the source afterwards): the key rewrite was skipped when the disk endpoint key prefix contained the backup name (e.g. backup `regression` on the disk `https://s3/bucket/regression-test/live/`), and the metadata files were linked into `detached` / the table directory before the keys were rewritten, so an interrupted restore left the copy pointing to the source keys; the rewritten key now also carries a hash of the destination table (`<dir>_<backup_name>_<crc32(db.table)>/<object>`), so several copies of one backup restored next to each other never share objects, the source key of the copy is always derived from the backup, so the same local backup can be restored several times, and metadata files are written via temp file + rename, so a repeated restore never modifies the parts of an already restored table through their shared hardlink, fix [#1568](https://github.com/Altinity/clickhouse-backup/issues/1568)
99
- `upload --resume` no longer writes an incomplete `manifest.bolt.gz`: files of parts, archives and table metadata which the resumable state marks as already processed were skipped without being recorded in the manifest, so the manifest of a resumed upload listed only the files of the last attempt, overwrote the fuller one and `download` / `restore_remote` fell back to a `Walk` (ListObjectsV2) per skipped part. Skipped files are now recorded from the local backup without any extra remote listing; with `--delete-source` (`create_remote`) the local part files are already removed, so parts skipped on resume stay out of the manifest and still use the `Walk` fallback, fix [#1569](https://github.com/Altinity/clickhouse-backup/issues/1569)
10+
- SFTP `upload` of compressed archives (`compression_format` other than `none`) now keeps `sftp.concurrency` writes in flight per file, previously the archive stream from the compressor has no known size, so every 32KiB write waited for the previous one (about 8MB/s per file at 4ms RTT), fix [#1607](https://github.com/Altinity/clickhouse-backup/issues/1607)
11+
- SFTP `download` with `download_max_bytes_per_second` now keeps concurrent reads per file, the rate limiter wrapper hid `WriteTo` of the SFTP file, so every 32KiB read waited for the previous one and the download stayed at about 75% of the limit, fix [#1607](https://github.com/Altinity/clickhouse-backup/issues/1607)
12+
- GCS ranged download (`gcs.ranged_download_concurrency`) with `download_max_bytes_per_second` now reaches the limit instead of about 80% of it, the limit is applied to each range request on the wire instead of the in-order consumer, which waited idle until the first chunks arrived and never made up the lost time, fix [#1607](https://github.com/Altinity/clickhouse-backup/issues/1607)
13+
- change default `gcs.ranged_download_min_size` from 256MiB to 64MiB, with `gcs.ranged_download_concurrency` enabled archives of 64-256MiB, typical for big parts, were downloaded by a single stream and stayed the tail of the download, fix [#1607](https://github.com/Altinity/clickhouse-backup/issues/1607)
14+
- GCS with `force_http: true` or `disable_http2: true` no longer opens a new TCP connection for most requests, the HTTP/1.1 transport kept only 1 (64 with `disable_http2`) idle connection, so a download with many concurrent transfers left closed connections in TIME_WAIT, exhausted the ephemeral port range and failed with `connect: cannot assign requested address`, fix [#1607](https://github.com/Altinity/clickhouse-backup/issues/1607)
1015

1116
# v2.8.1
1217
NEW FEATURES

‎ReadMe.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ gcs:
449449
# GCS_RANGED_DOWNLOAD_CONCURRENCY, stream one large archive as N concurrent byte-range requests straight into decompression, without a temporary file and compatible with `download_max_bytes_per_second`,
450450
# one GCS read stream has a fixed ceiling, so a single big part is often the whole tail of a download, 0 or 1 disables, takes precedence over `allow_multipart_download` for archives >= `ranged_download_min_size`, see https://github.com/Altinity/clickhouse-backup/issues/1592
451451
ranged_download_concurrency: 0
452-
ranged_download_min_size: 268435456 # GCS_RANGED_DOWNLOAD_MIN_SIZE, archives smaller than this size in bytes keep the single-stream reader, at least 2 * `ranged_download_chunk_size`
452+
ranged_download_min_size: 67108864 # GCS_RANGED_DOWNLOAD_MIN_SIZE, archives smaller than this size in bytes keep the single-stream reader, at least 2 * `ranged_download_chunk_size`
453453
ranged_download_chunk_size: 33554432 # GCS_RANGED_DOWNLOAD_CHUNK_SIZE, bytes per range request, one chunk is one in-memory buffer
454454
ranged_download_max_buffers: 64 # GCS_RANGED_DOWNLOAD_MAX_BUFFERS, process-wide limit of chunk buffers in flight for all ranged readers, so extra memory is at most `ranged_download_max_buffers` * `ranged_download_chunk_size` (2GiB by default) however many archives are in flight
455455
# GCS_OBJECT_LABELS, allow setup metadata for each object during upload, use {macro_name} from system.macros and {backupName} for current backup name

‎pkg/config/config.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1048,7 +1048,7 @@ func DefaultConfig() *Config {
10481048
DownloadConcurrency: int(downloadConcurrency + 1),
10491049
// ranged streaming download is opt-in: ranged_download_concurrency: 8 enables it
10501050
RangedDownloadConcurrency: 0,
1051-
RangedDownloadMinSize: 256 * 1024 * 1024,
1051+
RangedDownloadMinSize: 64 * 1024 * 1024,
10521052
RangedDownloadChunkSize: 32 * 1024 * 1024,
10531053
RangedDownloadMaxBuffers: 64,
10541054
},

‎pkg/storage/bwlimit/bwlimit.go‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,43 @@ func ReadCloser(ctx context.Context, reader io.ReadCloser, limiter *Limiter) io.
167167
if limiter == nil {
168168
return reader
169169
}
170-
return &rateLimitedReadCloser{
170+
rc := &rateLimitedReadCloser{
171171
Reader: Reader(ctx, reader, limiter),
172172
closer: reader,
173173
}
174+
// keep WriteTo of the wrapped reader, sftp.File issues concurrent reads only from WriteTo,
175+
// with Read it sends one request per round trip, see https://github.com/Altinity/clickhouse-backup/issues/1607
176+
if wt, ok := reader.(io.WriterTo); ok {
177+
return &rateLimitedWriterToReadCloser{rateLimitedReadCloser: rc, writerTo: wt, ctx: ctx, limiter: limiter}
178+
}
179+
return rc
174180
}
175181

176182
func (r *rateLimitedReadCloser) Close() error {
177183
return r.closer.Close()
178184
}
185+
186+
type rateLimitedWriterToReadCloser struct {
187+
*rateLimitedReadCloser
188+
writerTo io.WriterTo
189+
ctx context.Context
190+
limiter *Limiter
191+
}
192+
193+
// WriteTo paces the writes into w, the wrapped reader stops reading when its writes block
194+
func (r *rateLimitedWriterToReadCloser) WriteTo(w io.Writer) (int64, error) {
195+
return r.writerTo.WriteTo(&rateLimitedWriter{ctx: r.ctx, writer: w, limiter: r.limiter})
196+
}
197+
198+
type rateLimitedWriter struct {
199+
ctx context.Context
200+
writer io.Writer
201+
limiter *Limiter
202+
}
203+
204+
func (w *rateLimitedWriter) Write(p []byte) (int, error) {
205+
if err := w.limiter.Wait(w.ctx, len(p)); err != nil {
206+
return 0, err
207+
}
208+
return w.writer.Write(p)
209+
}

‎pkg/storage/bwlimit/bwlimit_test.go‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,61 @@ func TestLimiterIsSharedAcrossReaders(t *testing.T) {
9898
t.Fatalf("shared limiter finished too quickly: %s", elapsed)
9999
}
100100
}
101+
102+
// writerToSource records whether io.Copy went through its WriteTo, like sftp.File,
103+
// which issues concurrent reads only from WriteTo
104+
type writerToSource struct {
105+
*bytes.Reader
106+
writeToCalled bool
107+
}
108+
109+
func (s *writerToSource) WriteTo(w io.Writer) (int64, error) {
110+
s.writeToCalled = true
111+
return s.Reader.WriteTo(w)
112+
}
113+
114+
func (s *writerToSource) Close() error { return nil }
115+
116+
func TestReadCloserKeepsWriterToAndLimits(t *testing.T) {
117+
src := &writerToSource{Reader: bytes.NewReader(bytes.Repeat([]byte("a"), 200))}
118+
wrapped := ReadCloser(context.Background(), src, New(100))
119+
120+
if _, ok := wrapped.(io.WriterTo); !ok {
121+
t.Fatal("ReadCloser() should expose io.WriterTo when the wrapped reader implements it")
122+
}
123+
var dst bytes.Buffer
124+
start := time.Now()
125+
n, err := io.Copy(&dst, wrapped)
126+
if err != nil || n != 200 {
127+
t.Fatalf("io.Copy() = (%d, %v), want (200, nil)", n, err)
128+
}
129+
if !src.writeToCalled {
130+
t.Fatal("io.Copy() should use WriteTo of the wrapped reader")
131+
}
132+
// 100 bytes initial burst, the other 100 bytes need one second
133+
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
134+
t.Fatalf("WriteTo is not rate limited, finished in %s", elapsed)
135+
}
136+
}
137+
138+
func TestReadCloserWithoutWriterTo(t *testing.T) {
139+
reader := &closeRecorder{Reader: bytes.NewReader([]byte("abc"))}
140+
// io.Reader without WriteTo, an io.CopyBuffer buffer must still be used
141+
wrapped := ReadCloser(context.Background(), struct {
142+
io.Reader
143+
io.Closer
144+
}{reader, reader}, New(1024))
145+
if _, ok := wrapped.(io.WriterTo); ok {
146+
t.Fatal("ReadCloser() should not expose io.WriterTo when the wrapped reader does not implement it")
147+
}
148+
}
149+
150+
func TestWriteToReturnsContextError(t *testing.T) {
151+
ctx, cancel := context.WithCancel(context.Background())
152+
cancel()
153+
src := &writerToSource{Reader: bytes.NewReader(bytes.Repeat([]byte("a"), 10))}
154+
wrapped := ReadCloser(ctx, src, New(1)).(io.WriterTo)
155+
if _, err := wrapped.WriteTo(io.Discard); err == nil {
156+
t.Fatal("WriteTo() should return the context error")
157+
}
158+
}

‎pkg/storage/gcs.go‎

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/json"
88
"fmt"
99
"io"
10+
"math"
1011
"net"
1112
"net/http"
1213
"os"
@@ -16,6 +17,7 @@ import (
1617
"time"
1718

1819
"github.com/Altinity/clickhouse-backup/v2/pkg/config"
20+
"github.com/Altinity/clickhouse-backup/v2/pkg/storage/bwlimit"
1921
pool "github.com/jolestar/go-commons-pool/v2"
2022
"github.com/pkg/errors"
2123

@@ -168,31 +170,7 @@ func (gcs *GCS) Connect(ctx context.Context) error {
168170

169171
var httpClient *http.Client
170172
if gcs.Config.ForceHttp || gcs.Config.DisableHttp2 {
171-
customTransport := &http.Transport{
172-
WriteBufferSize: 128 * 1024,
173-
Proxy: http.ProxyFromEnvironment,
174-
DialContext: (&net.Dialer{
175-
Timeout: 30 * time.Second,
176-
KeepAlive: 30 * time.Second,
177-
}).DialContext,
178-
MaxIdleConns: 1,
179-
MaxIdleConnsPerHost: 1,
180-
IdleConnTimeout: 90 * time.Second,
181-
TLSHandshakeTimeout: 10 * time.Second,
182-
ExpectContinueTimeout: 1 * time.Second,
183-
}
184-
if gcs.Config.DisableHttp2 {
185-
// DisableHttp2 is designed for high-concurrency downloads — raise
186-
// connection limits so each parallel part gets its own TCP stream.
187-
customTransport.MaxIdleConns = 0
188-
customTransport.MaxIdleConnsPerHost = 64
189-
}
190-
// must set ForceAttemptHTTP2 to false so that when a custom TLSClientConfig
191-
// is provided Golang does not setup HTTP/2 transport
192-
customTransport.ForceAttemptHTTP2 = false
193-
customTransport.TLSClientConfig = &tls.Config{
194-
NextProtos: []string{"http/1.1"},
195-
}
173+
customTransport := newGCSHTTP1Transport()
196174
// ForceHttp downgrades the request scheme to cleartext http:// via
197175
// rewriteTransport (needed only for internal caches like varnish).
198176
// DisableHttp2 keeps the original https:// scheme so TLS and
@@ -484,11 +462,38 @@ func (gcs *GCS) rangedDownloadSettings() (workers int, minSize, chunk int64, ok
484462
return workers, minSize, chunk, true
485463
}
486464

465+
// newGCSHTTP1Transport builds the HTTP/1.1 transport for force_http and disable_http2.
466+
// Idle connections are not limited: every concurrent request above MaxIdleConnsPerHost closed its connection
467+
// after use, a busy download left them in TIME_WAIT and exhausted the ephemeral ports with
468+
// `connect: cannot assign requested address`. There are never more idle connections than concurrent requests,
469+
// which the transfer budgets already bound, and IdleConnTimeout closes them, see https://github.com/Altinity/clickhouse-backup/issues/1607
470+
func newGCSHTTP1Transport() *http.Transport {
471+
return &http.Transport{
472+
WriteBufferSize: 128 * 1024,
473+
Proxy: http.ProxyFromEnvironment,
474+
DialContext: (&net.Dialer{
475+
Timeout: 30 * time.Second,
476+
KeepAlive: 30 * time.Second,
477+
}).DialContext,
478+
MaxIdleConns: 0,
479+
MaxIdleConnsPerHost: math.MaxInt32,
480+
IdleConnTimeout: 90 * time.Second,
481+
TLSHandshakeTimeout: 10 * time.Second,
482+
ExpectContinueTimeout: 1 * time.Second,
483+
// must set ForceAttemptHTTP2 to false so that when a custom TLSClientConfig
484+
// is provided Golang does not setup HTTP/2 transport
485+
ForceAttemptHTTP2: false,
486+
TLSClientConfig: &tls.Config{
487+
NextProtos: []string{"http/1.1"},
488+
},
489+
}
490+
}
491+
487492
// GetFileRangedReader streams a large object as concurrent byte-range GETs (see rangedReader).
488493
// ok=false means the caller should use its normal single-stream reader (feature disabled or object too small).
489494
// Every range borrows its own pooled client for the duration of the request, so with HTTP/2 the ranges
490495
// do not end up multiplexed on one connection.
491-
func (gcs *GCS) GetFileRangedReader(ctx context.Context, key string, size int64) (io.ReadCloser, bool, error) {
496+
func (gcs *GCS) GetFileRangedReader(ctx context.Context, key string, size int64, limiter *bwlimit.Limiter) (io.ReadCloser, bool, error) {
492497
workers, minSize, chunk, enabled := gcs.rangedDownloadSettings()
493498
if !enabled || size < minSize || gcs.rangedBudget == nil {
494499
return nil, false, nil
@@ -529,7 +534,7 @@ func (gcs *GCS) GetFileRangedReader(ctx context.Context, key string, size int64)
529534
giveBack(true)
530535
return nil, errors.Wrapf(err, "GCS NewRangeReader %s", absKey)
531536
}
532-
return &releasingReadCloser{ReadCloser: rc, onClose: func() { giveBack(false) }}, nil
537+
return bwlimit.ReadCloser(ctx, &releasingReadCloser{ReadCloser: rc, onClose: func() { giveBack(false) }}, limiter), nil
533538
}
534539
log.Debug().Msgf("GCS ranged read %s size=%d chunk=%d workers=%d", absKey, size, chunk, workers)
535540
return newRangedReader(ctx, fetch, size, chunk, workers, 2, gcs.rangedBudget), true, nil

‎pkg/storage/gcs_test.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ package storage
33
import (
44
"encoding/base64"
55
"fmt"
6+
"io"
7+
"net"
68
"net/http"
9+
"net/http/httptest"
710
"net/url"
11+
"sync"
12+
"sync/atomic"
813
"testing"
14+
"time"
915

1016
"github.com/Altinity/clickhouse-backup/v2/pkg/config"
1117
"github.com/stretchr/testify/assert"
@@ -222,3 +228,42 @@ func (gcs *GCS) validateAndDecodeEncryptionKey() error {
222228
gcs.encryptionKey = key
223229
return nil
224230
}
231+
232+
// TestGCSHTTP1TransportReusesConnections every concurrent request above MaxIdleConnsPerHost closes its
233+
// connection after use, on a busy download that used up the whole ephemeral port range with
234+
// `connect: cannot assign requested address`, see https://github.com/Altinity/clickhouse-backup/issues/1607
235+
func TestGCSHTTP1TransportReusesConnections(t *testing.T) {
236+
const workers, requestsPerWorker = 16, 20
237+
var newConns atomic.Int64
238+
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
239+
time.Sleep(time.Millisecond)
240+
_, _ = w.Write([]byte("ok"))
241+
}))
242+
srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
243+
if state == http.StateNew {
244+
newConns.Add(1)
245+
}
246+
}
247+
srv.Start()
248+
defer srv.Close()
249+
250+
client := &http.Client{Transport: newGCSHTTP1Transport()}
251+
var wg sync.WaitGroup
252+
for range workers {
253+
wg.Add(1)
254+
go func() {
255+
defer wg.Done()
256+
for range requestsPerWorker {
257+
resp, err := client.Get(srv.URL)
258+
if !assert.NoError(t, err) {
259+
return
260+
}
261+
_, _ = io.Copy(io.Discard, resp.Body)
262+
_ = resp.Body.Close()
263+
}
264+
}()
265+
}
266+
wg.Wait()
267+
t.Logf("%d requests opened %d connections", workers*requestsPerWorker, newConns.Load())
268+
assert.LessOrEqual(t, newConns.Load(), int64(2*workers), "concurrent requests should reuse idle connections")
269+
}

‎pkg/storage/general.go‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -516,10 +516,13 @@ func (bd *BackupDestination) DownloadCompressedStream(ctx context.Context, remot
516516
var getReaderErr error
517517
rangedUsed := false
518518
// Ranged streaming keeps the object in memory chunks consumed in order (no temp file), so unlike the
519-
// multipart path below it stays compatible with the rate limiter: the limiter paces the consumer and the
520-
// prefetch window is bounded. Use it whenever the storage supports it and the object is large enough.
519+
// multipart path below it stays compatible with the rate limiter: the limiter paces the range requests on
520+
// the wire, not the in-order consumer. Pacing the consumer instead left it idle until the first chunks
521+
// completed and the lost time was never made up (~80% of the cap), see https://github.com/Altinity/clickhouse-backup/issues/1607
522+
// Use it whenever the storage supports it and the object is large enough.
523+
limiter := bd.DownloadLimiter(maxSpeed)
521524
if rp, ok := bd.RemoteStorage.(RangedReaderProvider); ok {
522-
reader, rangedUsed, getReaderErr = rp.GetFileRangedReader(ctx, remotePath, remoteFileInfo.Size())
525+
reader, rangedUsed, getReaderErr = rp.GetFileRangedReader(ctx, remotePath, remoteFileInfo.Size(), limiter)
523526
}
524527
if rangedUsed || getReaderErr != nil {
525528
// reader (or error) already set by the ranged path
@@ -537,7 +540,9 @@ func (bd *BackupDestination) DownloadCompressedStream(ctx context.Context, remot
537540
return 0, errors.Wrap(getReaderErr, "DownloadCompressedStream GetFileReader")
538541
}
539542
rawReader := reader
540-
reader = bwlimit.ReadCloser(ctx, reader, bd.DownloadLimiter(maxSpeed))
543+
if !rangedUsed {
544+
reader = bwlimit.ReadCloser(ctx, reader, limiter)
545+
}
541546
var closeReaderOnce sync.Once
542547
closeReader := func() {
543548
closeReaderOnce.Do(func() {

‎pkg/storage/ranged_reader_test.go‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"testing"
1515
"time"
1616

17+
"github.com/Altinity/clickhouse-backup/v2/pkg/storage/bwlimit"
18+
1719
"github.com/stretchr/testify/require"
1820
"golang.org/x/sync/semaphore"
1921
)
@@ -192,16 +194,25 @@ type rangedRemote struct {
192194
archive []byte
193195
used atomic.Bool
194196
budget *semaphore.Weighted
197+
limiter *bwlimit.Limiter
195198
}
196199

197200
func (m *rangedRemote) StatFile(_ context.Context, _ string) (RemoteFile, error) {
198201
return fakeRemoteFile{size: int64(len(m.archive))}, nil
199202
}
200203

201-
func (m *rangedRemote) GetFileRangedReader(ctx context.Context, _ string, size int64) (io.ReadCloser, bool, error) {
204+
func (m *rangedRemote) GetFileRangedReader(ctx context.Context, _ string, size int64, limiter *bwlimit.Limiter) (io.ReadCloser, bool, error) {
202205
m.used.Store(true)
206+
m.limiter = limiter
203207
f := &memFetcher{data: m.archive, failChunkAt: -1}
204-
return newRangedReader(ctx, f.fetch, size, 64*1024, 4, 0, m.budget), true, nil
208+
fetch := func(ctx context.Context, offset, length int64) (io.ReadCloser, error) {
209+
rc, err := f.fetch(ctx, offset, length)
210+
if err != nil {
211+
return nil, err
212+
}
213+
return bwlimit.ReadCloser(ctx, rc, limiter), nil
214+
}
215+
return newRangedReader(ctx, fetch, size, 64*1024, 4, 0, m.budget), true, nil
205216
}
206217

207218
func (m *rangedRemote) GetFileReader(_ context.Context, _ string) (io.ReadCloser, error) {
@@ -232,6 +243,7 @@ func TestDownloadCompressedStreamUsesRangedReaderWithRateLimit(t *testing.T) {
232243
_, err = bd.DownloadCompressedStream(context.Background(), "shadow/db/tbl/part.tar", out, 1<<40)
233244
require.NoError(t, err)
234245
require.True(t, remote.used.Load(), "ranged reader was not used")
246+
require.NotNil(t, remote.limiter, "the rate limit must be applied to the range requests, see https://github.com/Altinity/clickhouse-backup/issues/1607")
235247
got, err := os.ReadFile(filepath.Join(out, "data.bin"))
236248
require.NoError(t, err)
237249
require.True(t, bytes.Equal(payload, got))

‎pkg/storage/sftp.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,14 @@ func (sftp *SFTP) PutFileAbsolute(_ context.Context, key string, r io.ReadCloser
251251
log.Warn().Msgf("can't close %s err=%v", key, err)
252252
}
253253
}()
254-
if _, err = remoteFile.ReadFrom(r); err != nil {
254+
// ReadFrom keeps several writes in flight only when it can learn the size of r,
255+
// an io.Pipe from the compressor has none and gets one write per round trip, see https://github.com/Altinity/clickhouse-backup/issues/1607
256+
if sftp.Config.Concurrency > 0 {
257+
_, err = remoteFile.ReadFromWithConcurrency(r, sftp.Config.Concurrency)
258+
} else {
259+
_, err = remoteFile.ReadFrom(r)
260+
}
261+
if err != nil {
255262
return errors.Wrap(err, "SFTP PutFileAbsolute ReadFrom")
256263
}
257264
return nil

0 commit comments

Comments
 (0)