Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 60 additions & 10 deletions pkg/objstore/azblob.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import (
"github.com/pingcap/tidb/pkg/objstore/storeapi"
"github.com/spf13/pflag"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)

const (
Expand Down Expand Up @@ -717,7 +718,7 @@ func (s *AzureBlobStorage) URI() string {
const azblobChunkSize = 64 * 1024 * 1024

// Create implements the StorageWriter interface.
func (s *AzureBlobStorage) Create(_ context.Context, name string, _ *storeapi.WriterOption) (objectio.Writer, error) {
func (s *AzureBlobStorage) Create(ctx context.Context, name string, wo *storeapi.WriterOption) (objectio.Writer, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Minor] Storage.Create interface doc now contradicts Azure's new WriterOption support

Impact
The storeapi.Storage.Create doc comment states "Currently only s3 implemented WriterOption," but this diff makes AzureBlobStorage.Create honor wo.Concurrency and wo.PartSize for real, so the comment is now factually wrong.
A maintainer deciding whether to pass concurrency/part-size options for the Azure backend, or evaluating parity when adding the same feature to GCS, will be misled into thinking Azure ignores these fields.

Scope

  • pkg/objstore/storeapi/storage.go:168Storage.Create
  • pkg/objstore/azblob.go:722AzureBlobStorage.Create

Evidence
storeapi/storage.go:166-168 documents the shared Create contract with "Currently only s3 implemented WriterOption." This diff changes AzureBlobStorage.Create from func (s *AzureBlobStorage) Create(_ context.Context, name string, _ *storeapi.WriterOption) to actually reading wo.Concurrency/wo.PartSize and driving concurrent block staging, which the interface comment no longer reflects.

Change request
Update the Create doc comment on storeapi.Storage to list Azure alongside s3 (and any other backends that already implement it, e.g. s3store.KS3Storage), or drop the backend-enumeration claim and instead point readers to backend-specific docs.

client := s.containerClient.NewBlockBlobClient(s.withPrefix(name))
uploader := &azblobUploader{
blobClient: client,
Expand All @@ -730,7 +731,18 @@ func (s *AzureBlobStorage) Create(_ context.Context, name string, _ *storeapi.Wr
cpkInfo: s.cpkInfo,
}

uploaderWriter := objectio.NewBufferedWriter(uploader, azblobChunkSize, compressedio.NoCompression, nil)
chunkSize := azblobChunkSize
if wo != nil && wo.PartSize > 0 {
chunkSize = int(wo.PartSize)
}
if wo != nil && wo.Concurrency > 1 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [Major] WriterOption.PartSize is silently dropped unless Concurrency > 1, unlike the s3like backend

Impact
A caller who sets WriterOption.PartSize without also requesting Concurrency > 1 gets the default 64MiB azblobChunkSize instead of the requested part size, since chunkSize is only overridden inside the wo.Concurrency > 1 branch.
This silently diverges from s3like.Storage.Create, where option.PartSize is honored whenever it is set, independent of Concurrency, so the same storeapi.WriterOption field carries different meaning depending on which backend is used, with nothing documenting the difference.

Scope

  • pkg/objstore/azblob.go:736AzureBlobStorage.Create
  • pkg/objstore/s3like/store.go:639Storage.Create
  • pkg/objstore/storeapi/storage.go:113WriterOption

Evidence
In azblob.go, chunkSize = int(wo.PartSize) only runs inside if wo != nil && wo.Concurrency > 1. In s3like/store.go, bufSize = int(option.PartSize) runs whenever option.PartSize > 0, regardless of Concurrency. WriterOption (storage.go:112-115) has no field comments describing this backend-specific coupling.

Change request
Either apply wo.PartSize to chunkSize unconditionally to match the s3like backend's handling of the same field, or, if the Azure backend genuinely requires Concurrency > 1 to honor PartSize, document that constraint on the PartSize/Concurrency fields in WriterOption so callers relying on the shared option struct aren't misled.

eg, egCtx := errgroup.WithContext(ctx)
eg.SetLimit(wo.Concurrency)
uploader.eg = eg
uploader.egCtx = egCtx
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

uploaderWriter := objectio.NewBufferedWriter(uploader, chunkSize, compressedio.NoCompression, nil)
return uploaderWriter, nil
}

Expand Down Expand Up @@ -889,28 +901,66 @@ type azblobUploader struct {

cpkScope *blob.CPKScopeInfo
cpkInfo *blob.CPKInfo

// eg/egCtx drive concurrent upload; nil means Write stages blocks
// synchronously. err holds the synchronous path's stage failure; the
// concurrent path's error comes from eg.Wait().
eg *errgroup.Group
egCtx context.Context
err error
}

func (u *azblobUploader) stageBlock(ctx context.Context, blockID string, data []byte) error {
_, err := u.blobClient.StageBlock(ctx, blockID, newNopCloser(bytes.NewReader(data)), &blockblob.StageBlockOptions{
CPKScopeInfo: u.cpkScope,
CPKInfo: u.cpkInfo,
})
if err != nil {
return errors.Annotate(err, "Failed to upload block to azure blob")
}
return nil
}

func (u *azblobUploader) Write(ctx context.Context, data []byte) (int, error) {
if u.eg != nil {
if err := u.egCtx.Err(); err != nil {
return 0, err
}
}
generatedUUID, err := uuid.NewUUID()
if err != nil {
return 0, errors.Annotate(err, "Fail to generate uuid")
}
blockID := base64.StdEncoding.EncodeToString([]byte(generatedUUID.String()))
u.blockIDList = append(u.blockIDList, blockID)

_, err = u.blobClient.StageBlock(ctx, blockID, newNopCloser(bytes.NewReader(data)), &blockblob.StageBlockOptions{
CPKScopeInfo: u.cpkScope,
CPKInfo: u.cpkInfo,
})
if err != nil {
return 0, errors.Annotate(err, "Failed to upload block to azure blob")
if u.eg != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 findings on this line:

🟡 [Minor] Async Write path's defensive copy and block-order guarantee have no rationale comment

Impact
Write copies data into a fresh buf only on the concurrent path and appends blockID to blockIDList before the goroutine that stages it runs, but neither behavior is explained anywhere nearby.
A future maintainer could "simplify" by dropping the copy (reintroducing a data race with the caller reusing/overwriting its buffer) or by moving the blockIDList append into the goroutine (silently reordering committed blocks), since nothing documents these as load-bearing invariants.

Scope

  • pkg/objstore/azblob.go:932azblobUploader.Write

Evidence
In azblobUploader.Write, u.blockIDList = append(u.blockIDList, blockID) runs synchronously before u.eg.Go(...) dispatches the async stageBlock call, and buf := make([]byte, len(data)); copy(buf, data) exists only for that async branch. The struct comment on eg/egCtx/err explains blocking vs. async dispatch but says nothing about why the copy is required or why block order stays correct under concurrency.

Change request
Add a short comment at the copy noting it guards against the caller mutating/reusing data after Write returns, and a comment at the blockIDList append noting block commit order depends on this append happening before the block is dispatched to the errgroup.


🟡 [Minor] Changed concurrent-upload semantics have no deterministic regression coverage

Impact
The new concurrent branch changes real semantics: block staging becomes async, Write returns success before the stage completes, first-failure is recorded in u.err, and Close now short-circuits CommitBlockList when u.err is set. None of this runs in normal CI, because the only exercising test skips whenever azurite is not listening on 127.0.0.1:10000, and even then it asserts only the success path. A regression that swallowed the staging error, committed a partial block list, or reordered blocks would pass CI silently.

Scope

  • pkg/objstore/azblob.go:934azblobUploader.Write
  • pkg/objstore/azblob.go:953azblobUploader.Close
  • pkg/objstore/azblob_test.go:520TestAzblobConcurrentUpload

Evidence
TestAzblobConcurrentUpload calls createContainer and does t.Skip("azurite is not running, skip test") on failure, matching every other test in this file, so without a live azurite the concurrent path gets zero coverage. When it does run, it only writes 20MB and asserts require.Equal(t, data, got); nothing forces a stageBlock failure to exercise the u.err CompareAndSwap and the Close early-return that skips CommitBlockList.

Change request
Add a deterministic test that does not depend on a running azurite emulator (stub or fake blobClient / failpoint on StageBlock) covering both: the concurrent success path (verifying committed block order matches write order) and a forced single-block failure asserting Close returns the annotated error and does not call CommitBlockList.


🟡 [Minor] Concurrent upload keeps allocating/copying the rest of the file after a block already failed

Impact
In the concurrent branch, once one staged block fails, errgroup cancels egCtx and every subsequent block fails instantly, yet Write still returns (len(data), nil) for all remaining chunks.
The driving BufferedWriter therefore reads the entire remaining source and performs a full make([]byte, len(data)) copy per chunk for throwaway work, and the failure is only observed at Close.
On a multi-GB backup this wastes CPU, allocation, and source I/O and delays the error by the length of the file.

Scope

  • pkg/objstore/azblob.go:934azblobUploader.Write
  • pkg/objstore/azblob.go:949azblobUploader.Close

Evidence
The if u.eg != nil branch copies data into a fresh buffer, calls u.eg.Go(...), and returns len(data), nil without ever checking u.err. After the first stageBlock failure records u.err via CompareAndSwap and cancels egCtx, nothing short-circuits the producer until Close runs u.err.Load().

Change request
At the start of the concurrent branch in Write, check if err := u.err.Load(); err != nil { return 0, err } so an already-failed upload stops consuming the source and copying buffers promptly instead of after the full file is written.

buf := make([]byte, len(data))
copy(buf, data)
u.eg.Go(func() error {
return u.stageBlock(u.egCtx, blockID, buf)
})
return len(data), nil
}
u.blockIDList = append(u.blockIDList, blockID)

if err := u.stageBlock(ctx, blockID, data); err != nil {
u.err = err
return 0, err
}
return len(data), nil
}

func (u *azblobUploader) Close(ctx context.Context) error {
err := u.err
if u.eg != nil {
err = u.eg.Wait()
}
if err != nil {
// Uncommitted blocks are garbage-collected by Azure after a week, so
// skip CommitBlockList and surface the failure instead.
return err
}

// the encryption scope and the access tier can not be both in the HTTP headers
options := &blockblob.CommitBlockListOptions{
CPKScopeInfo: u.cpkScope,
Expand All @@ -920,6 +970,6 @@ func (u *azblobUploader) Close(ctx context.Context) error {
if len(u.accessTier) > 0 {
options.Tier = &u.accessTier
}
_, err := u.blobClient.CommitBlockList(ctx, u.blockIDList, options)
_, err = u.blobClient.CommitBlockList(ctx, u.blockIDList, options)
return errors.Trace(err)
}
30 changes: 30 additions & 0 deletions pkg/objstore/azblob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,3 +513,33 @@ func TestCopyObject(t *testing.T) {
require.NoError(t, err)
require.Equal(t, srcReader, reader)
}

func TestAzblobConcurrentUpload(t *testing.T) {
ctx := context.Background()
options := &backuppb.AzureBlobStorage{Bucket: "test", Prefix: "concurrent/"}
builder := &sharedKeyAzuriteClientBuilder{}
skip, err := createContainer(ctx, builder, options.Bucket)
if skip || err != nil {
t.Skip("azurite is not running, skip test")
}

azblobStorage, err := newAzureBlobStorageWithClientBuilder(ctx, options, builder)
require.NoError(t, err)

data := make([]byte, 20*1024*1024)
_, err = rand.Read(data)
require.NoError(t, err)

w, err := azblobStorage.Create(ctx, "concurrent.bin", &storeapi.WriterOption{
Concurrency: 4,
PartSize: 4 * 1024 * 1024,
})
require.NoError(t, err)
_, err = w.Write(ctx, data)
require.NoError(t, err)
require.NoError(t, w.Close(ctx))

got, err := azblobStorage.ReadFile(ctx, "concurrent.bin")
require.NoError(t, err)
require.Equal(t, data, got)
}
4 changes: 2 additions & 2 deletions pkg/objstore/storeapi/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ type Storage interface {
URI() string

// Create opens a file writer by path. path is relative path to storage base
// path. The old file under same path will be overwritten. Currently only s3
// implemented WriterOption.
// path. The old file under same path will be overwritten. Not all backends
// honor WriterOption; see each backend's Create implementation.
Create(ctx context.Context, path string, option *WriterOption) (objectio.Writer, error)
// Rename file name from oldFileName to newFileName
Rename(ctx context.Context, oldFileName, newFileName string) error
Expand Down
Loading