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
4 changes: 2 additions & 2 deletions lightning/tests/lightning_write_timeout/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ for i in {1..200000}; do
done
set -x

export GO_FAILPOINTS="github.com/pingcap/tidb/pkg/lightning/backend/local/shortWaitNTimeout=100*return(1)"
export GO_FAILPOINTS="github.com/pingcap/tidb/pkg/lightning/backend/local/shortWaitNTimeout=5*return(1000)"

run_lightning --backend local -d "$TEST_DIR/data" --config "$CUR/config.toml"
check_lightning_log_contains 'Experiencing a wait timeout while writing to tikv'
check_lightning_log_contains 'experiencing a wait timeout while writing to TiKV'
31 changes: 30 additions & 1 deletion pkg/lightning/backend/local/job_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package local

import (
"context"
goerrors "errors"
"io"
"strings"
"sync"
Expand Down Expand Up @@ -197,7 +198,7 @@ func (w *regionJobBaseWorker) runJob(ctx context.Context, job *regionJob) error
job.convertStageTo(needRescan)
return nil
}
res, err := w.writeFn(ctx, job)
res, err := w.writeWithTimeout(ctx, job)
err = injectfailpoint.DXFRandomErrorWithOnePercentWrapper(err)
if err != nil {
if !w.isRetryableImportTiKVError(err) {
Expand Down Expand Up @@ -266,6 +267,34 @@ func (w *regionJobBaseWorker) runJob(ctx context.Context, job *regionJob) error
}
}

func (w *regionJobBaseWorker) writeWithTimeout(
Comment thread
D3Hunter marked this conversation as resolved.
ctx context.Context,
job *regionJob,
) (ret *tikvWriteResult, err error) {
// set a timeout for the write operation, if it takes too long, we will
// return with common.ErrWriteTooSlow and let caller retry the whole job
// instead of being stuck forever.
timeout := 15 * time.Minute
failpoint.Inject("shortWaitNTimeout", func(val failpoint.Value) {
ms := val.(int)
timeout = time.Duration(ms) * time.Millisecond
})
ctx, cancel := context.WithTimeoutCause(ctx, timeout, common.ErrWriteTooSlow)
defer cancel()

ret, err = w.writeFn(ctx, job)
if err == nil {
return ret, nil
}
if errors.Cause(err) == context.DeadlineExceeded {
if cause := context.Cause(ctx); goerrors.Is(cause, common.ErrWriteTooSlow) {
Comment thread
D3Hunter marked this conversation as resolved.
Comment on lines +289 to +290

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how writeFn implementations propagate context.DeadlineExceeded.
rg -nP -C3 '\bfunc\s+\([^)]*\)\s+(doWrite|write)\s*\(' pkg/lightning/backend/local
rg -nP -C2 'DeadlineExceeded|status\.FromContextError|context\.Cause' pkg/lightning/backend/local pkg/ingestor

Repository: pingcap/tidb

Length of output: 3167


🏁 Script executed:

# First, let's examine the doWrite function implementation
sed -n '331,450p' pkg/lightning/backend/local/region_job.go | head -100

Repository: pingcap/tidb

Length of output: 3077


🏁 Script executed:

# Examine the write function implementation
sed -n '355,420p' pkg/lightning/backend/local/job_worker.go

Repository: pingcap/tidb

Length of output: 1741


🏁 Script executed:

# Check how errors are imported and used in these files
head -50 pkg/lightning/backend/local/job_worker.go | grep -E 'import|errors|goerrors'

Repository: pingcap/tidb

Length of output: 164


🏁 Script executed:

# Look for gRPC status error handling in the codebase
rg -n 'status\.FromContextError|status\.FromError' pkg/lightning/backend/local pkg/ingestor

Repository: pingcap/tidb

Length of output: 141


🏁 Script executed:

# Get full context around job_worker.go:289-290
sed -n '270,310p' pkg/lightning/backend/local/job_worker.go

Repository: pingcap/tidb

Length of output: 1425


🏁 Script executed:

# Check if errors are wrapped with %w anywhere in writeFn implementations
rg '%w' pkg/lightning/backend/local/job_worker.go pkg/lightning/backend/local/region_job.go

Repository: pingcap/tidb

Length of output: 38


🏁 Script executed:

# Look for where gRPC stream errors might be handled before reaching line 289
sed -n '270,350p' pkg/lightning/backend/local/job_worker.go | grep -A30 'func (w \*localRegionJobWorker)'

Repository: pingcap/tidb

Length of output: 38


🏁 Script executed:

# Check how writeWithTimeout works and what writeFn is
sed -n '250,290p' pkg/lightning/backend/local/job_worker.go

Repository: pingcap/tidb

Length of output: 1392


🏁 Script executed:

# Check if pingcap/errors types implement Unwrap() for Is() compatibility
rg -n 'func.*Unwrap|func.*Cause' pkg/vendor/github.com/pingcap/errors 2>/dev/null || \
  rg -n 'pingcap/errors' go.mod

Repository: pingcap/tidb

Length of output: 125


🏁 Script executed:

# Look for any %w wrapping in region_job.go doWrite implementation
sed -n '331,550p' pkg/lightning/backend/local/region_job.go | grep -n '%w\|Wrap'

Repository: pingcap/tidb

Length of output: 38


🏁 Script executed:

# Check what Annotate and Trace functions do
rg -A5 'func.*Annotate|func.*Trace' pkg/lightning/backend/local/job_worker.go | head -20

Repository: pingcap/tidb

Length of output: 38


🏁 Script executed:

# Look at the complete doWrite to see all error returns
sed -n '331,600p' pkg/lightning/backend/local/region_job.go | grep -n 'return.*error\|errors\.\|err :='

Repository: pingcap/tidb

Length of output: 1077


🏁 Script executed:

# Check if there are any gRPC status error wrappings in doWrite
sed -n '331,600p' pkg/lightning/backend/local/region_job.go | grep -n 'status\|Status'

Repository: pingcap/tidb

Length of output: 38


🏁 Script executed:

# Look at the test to understand expected behavior
sed -n '310,330p' pkg/lightning/backend/local/job_worker_test.go

Repository: pingcap/tidb

Length of output: 945


🏁 Script executed:

# Check if pingcap/errors is implemented with Unwrap support by looking at actual usage patterns in tests
rg -B5 -A5 'goerrors\.Is.*DeadlineExceeded' pkg/lightning/backend/local/

Repository: pingcap/tidb

Length of output: 38


🏁 Script executed:

# Let's check the actual implementation path for how errors flow through WriteClient
rg -n 'WriteClient' pkg/lightning/backend/local/job_worker.go | head -5

Repository: pingcap/tidb

Length of output: 131


🏁 Script executed:

# Look at ingestCli to see what WriteClient returns
find pkg -name '*ingest*' -type f | head -10

Repository: pingcap/tidb

Length of output: 173


🏁 Script executed:

# Check how errors.Trace wraps errors - does it implement Unwrap?
rg -B3 -A10 'type.*Error|func.*Unwrap' pkg/vendor/github.com/pingcap/errors/errors.go 2>/dev/null || \
  python3 << 'EOF'
# Try to understand if pingcap/errors preserves error chains
import subprocess
result = subprocess.run(['find', 'pkg', '-name', 'errors.go', '-path', '*/pingcap/errors/*'], capture_output=True, text=True)
print("Found error files:")
print(result.stdout)
EOF

Repository: pingcap/tidb

Length of output: 76


🏁 Script executed:

# Let's check the ingestcli WriteClient implementation to see what errors it returns
sed -n '1,50p' pkg/ingestor/ingestcli/ingest_err.go

Repository: pingcap/tidb

Length of output: 1539


🏁 Script executed:

# Check what WriteClient interface/method looks like
rg -B5 -A10 'WriteClient.*func' pkg/ingestor/

Repository: pingcap/tidb

Length of output: 38


🏁 Script executed:

# Most importantly, let's verify if pingcap/errors implements Unwrap for goerrors.Is compatibility
python3 << 'EOF'
# Check if pingcap/errors in go.mod and look for its implementation
import subprocess
import os

# Find pingcap/errors package in vendor
result = subprocess.run(
    ['find', 'pkg/vendor/github.com/pingcap/errors', '-name', '*.go'],
    capture_output=True, text=True
)

if result.stdout:
    files = result.stdout.strip().split('\n')[:3]
    for f in files:
        print(f"\n=== {f} ===")
        with open(f) as fp:
            content = fp.read()
            # Look for Unwrap method
            if 'Unwrap' in content:
                lines = content.split('\n')
                for i, line in enumerate(lines):
                    if 'Unwrap' in line:
                        start = max(0, i-2)
                        end = min(len(lines), i+8)
                        print('\n'.join(lines[start:end]))
                        print('---')
EOF

Repository: pingcap/tidb

Length of output: 38


🏁 Script executed:

# Final check: verify the fix would work by checking if errors.Trace returns an error that satisfies goerrors.Is
rg -n 'func.*Trace.*error' pkg/ | grep pingcap || \
  grep -r 'func.*Trace' pkg/vendor/github.com/pingcap/errors/ 2>/dev/null | head -5

Repository: pingcap/tidb

Length of output: 38


Use goerrors.Is for consistent and robust deadline checking.

Line 289 uses errors.Cause(err) == context.DeadlineExceeded while line 290 already uses goerrors.Is. The current code relies on pingcap/errors to properly unwrap context.DeadlineExceeded, but future code could wrap errors using Go 1.13's %w operator or other patterns that bypass errors.Cause(). Using goerrors.Is(err, context.DeadlineExceeded) handles both current and future wrapping patterns correctly, matching the approach already used on line 290.

Suggested change
-	if errors.Cause(err) == context.DeadlineExceeded {
+	if goerrors.Is(err, context.DeadlineExceeded) {
 		if cause := context.Cause(ctx); goerrors.Is(cause, common.ErrWriteTooSlow) {
 			tidblogutil.Logger(ctx).Info("experiencing a wait timeout while writing to TiKV")
 			err = errors.Trace(cause)
 		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if errors.Cause(err) == context.DeadlineExceeded {
if cause := context.Cause(ctx); goerrors.Is(cause, common.ErrWriteTooSlow) {
if goerrors.Is(err, context.DeadlineExceeded) {
if cause := context.Cause(ctx); goerrors.Is(cause, common.ErrWriteTooSlow) {
tidblogutil.Logger(ctx).Info("experiencing a wait timeout while writing to TiKV")
err = errors.Trace(cause)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/lightning/backend/local/job_worker.go` around lines 289 - 290, Replace
the direct comparison errors.Cause(err) == context.DeadlineExceeded with a call
to goerrors.Is to consistently detect deadline errors even when wrapped;
specifically, in job_worker.go where the code checks errors.Cause(err) ==
context.DeadlineExceeded (near the block that then inspects context.Cause via
goerrors.Is), change that condition to goerrors.Is(err,
context.DeadlineExceeded) so the deadline check uses the same robust unwrapping
semantics as the subsequent goerrors.Is usage.

tidblogutil.Logger(ctx).Info("experiencing a wait timeout while writing to TiKV")
err = errors.Trace(cause)
}
}
return ret, err
}

func (*regionJobBaseWorker) isRetryableImportTiKVError(err error) bool {
err = errors.Cause(err)
// io.EOF is not retryable in normal case
Expand Down
20 changes: 20 additions & 0 deletions pkg/lightning/backend/local/job_worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/pingcap/tidb/pkg/ingestor/errdef"
"github.com/pingcap/tidb/pkg/ingestor/ingestcli"
ingestclimock "github.com/pingcap/tidb/pkg/ingestor/ingestcli/mock"
"github.com/pingcap/tidb/pkg/lightning/common"
"github.com/pingcap/tidb/pkg/resourcemanager/pool/workerpool"
rcmgrutil "github.com/pingcap/tidb/pkg/resourcemanager/util"
"github.com/pingcap/tidb/pkg/testkit/testfailpoint"
Expand Down Expand Up @@ -305,6 +306,25 @@ func TestCloudRegionJobWorker(t *testing.T) {
require.True(t, ctrl.Satisfied())
})

t.Run("timeout while creating ingest client should be handled by runJob wrapper", func(t *testing.T) {
job := &regionJob{
keyRange: engineapi.Range{Start: []byte("a"), End: []byte("z")},
stage: regionScanned,
ingestData: mockIngestData{{[]byte("a"), []byte("a")}},
region: &split.RegionInfo{Region: &metapb.Region{
Id: 1, Peers: []*metapb.Peer{{StoreId: 1}},
}, Leader: &metapb.Peer{StoreId: 1}},
}
mockIngestCli.EXPECT().WriteClient(gomock.Any(), gomock.Any()).Return(nil, context.DeadlineExceeded)
ctx, cancel := context.WithTimeoutCause(context.Background(), 0, common.ErrWriteTooSlow)
defer cancel()
err := cloudW.runJob(ctx, job)
require.NoError(t, err)
require.Equal(t, needRescan, job.stage)
require.ErrorIs(t, job.lastRetryableErr, common.ErrWriteTooSlow)
require.True(t, ctrl.Satisfied())
})

t.Run("failed to write data", func(t *testing.T) {
job := &regionJob{
keyRange: engineapi.Range{Start: []byte("a"), End: []byte("z")},
Expand Down
58 changes: 8 additions & 50 deletions pkg/lightning/backend/local/region_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,34 +339,6 @@ func (local *Backend) doWrite(ctx context.Context, j *regionJob) (ret *tikvWrite
failpoint.Return(front.write.result, err)
})

var cancel context.CancelFunc
// set a timeout for the write operation, if it takes too long, we will return with common.ErrWriteTooSlow and let caller retry the whole job instead of being stuck forever.
timeout := 15 * time.Minute
ctx, cancel = context.WithTimeoutCause(ctx, timeout, common.ErrWriteTooSlow)
defer cancel()

// A defer function to handle all DeadlineExceeded errors that may occur
// during the write operation using this context with 15 minutes timeout.
// When the error is "context deadline exceeded", we will check if the cause
// is common.ErrWriteTooSlow and return the common.ErrWriteTooSlow instead so
// our caller would be able to retry this doWrite operation. By doing this
// defer we are hoping to handle all DeadlineExceeded error during this
// write, either from gRPC stream or write limiter WaitN operation.
wctx := ctx
defer func() {
if err == nil {
return
}
if errors.Cause(err) == context.DeadlineExceeded {
if cause := context.Cause(wctx); goerrors.Is(cause, common.ErrWriteTooSlow) {
tidblogutil.Logger(ctx).Info("Experiencing a wait timeout while writing to tikv",
zap.Int("store-write-bwlimit", local.BackendConfig.StoreWriteBWLimit),
zap.Int("limit-size", local.writeLimiter.Limit()))
err = errors.Trace(cause) // return the common.ErrWriteTooSlow instead to let caller retry it
}
}
}()

apiVersion := local.tikvCodec.GetAPIVersion()
clientFactory := local.importClientFactory
kvBatchSize := local.KVWriteBatchSize
Expand Down Expand Up @@ -485,19 +457,6 @@ func (local *Backend) doWrite(ctx context.Context, j *regionJob) (ret *tikvWrite
regionMaxSize = j.regionSplitSize * 4 / 3
}

// preparation work for the write timeout fault injection, only enabled if the following failpoint is enabled
wcancel := func() {}
failpoint.Inject("shortWaitNTimeout", func(val failpoint.Value) {
var innerTimeout time.Duration
// GO_FAILPOINTS action supplies the duration in
ms, _ := val.(int)
innerTimeout = time.Duration(ms) * time.Millisecond
tidblogutil.Logger(ctx).Info("Injecting a timeout to write context.")
wctx, wcancel = context.WithTimeoutCause(
ctx, innerTimeout, common.ErrWriteTooSlow)
})
defer wcancel()

flushKVs := func() error {
req.Chunk.(*sst.WriteRequest_Batch).Batch.Pairs = pairs[:count]
preparedMsg := &grpc.PreparedMsg{}
Expand All @@ -506,11 +465,15 @@ func (local *Backend) doWrite(ctx context.Context, j *regionJob) (ret *tikvWrite
if err := preparedMsg.Encode(clients[0], req); err != nil {
return err
}

failpoint.Inject("shortWaitNTimeout", func(val failpoint.Value) {
// add 20 to make sure the ctx is timed out.
ms := val.(int) + 20
time.Sleep(time.Duration(ms) * time.Millisecond)
})
for i := range clients {
// original ctx would be used when failpoint is not enabled
// that new context would be used when failpoint is enabled
err := writeLimiter.WaitN(wctx, allPeers[i].StoreId, int(size))
err := writeLimiter.WaitN(ctx, allPeers[i].StoreId, int(size))
if err != nil {
// We expect to encounter two types of errors here:
// 1. context.DeadlineExceeded — occurs when the calculated delay is
Expand All @@ -520,13 +483,8 @@ func (local *Backend) doWrite(ctx context.Context, j *regionJob) (ret *tikvWrite
// path triggered when the delay already exceeds the remaining
// time for context before sleeping.
//
// Unfortunately, we cannot precisely control when the context will
// expire, so both scenarios are valid and expected.
// Fortunately, the "rate: Wait" error is already treated as
// retryable, so we only need to explicitly handle
// context.DeadlineExceeded here.
// We rely on the defer function at the top of doWrite to handle it
// for us in general.
// "rate: Wait" error is already treated as retryable,
// writeWithTimeout will handle the context.DeadlineExceeded.
return errors.Trace(err)
}
if err := clients[i].SendMsg(preparedMsg); err != nil {
Expand Down
Loading