Context cancellation during reading range reading large files (#7093)

* context cancellation during reading range reading large files

* address comments

* cancellation for fuse read

* fix cancellation

* pass in context for each function to avoid racing condition

* Update reader_at_test.go

* remove dead code

* Update weed/filer/reader_at.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update weed/filer/filechunk_group.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update weed/filer/filechunk_group.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* address comments

* Update weed/mount/weedfs_file_read.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update weed/mount/weedfs_file_lseek.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update weed/mount/weedfs_file_read.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update weed/filer/reader_at.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update weed/mount/weedfs_file_lseek.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* test cancellation

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Lu
2025-08-06 10:09:26 -07:00
committed by GitHub
parent e446234e9c
commit 4af182f880
14 changed files with 231 additions and 49 deletions

View File

@@ -1,8 +1,11 @@
package filer
import (
"context"
"errors"
"io"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
@@ -25,7 +28,7 @@ func TestChunkGroup_ReadDataAt_ErrorHandling(t *testing.T) {
offset := int64(0)
// With an empty ChunkGroup, we should get no error
n, tsNs, err := group.ReadDataAt(fileSize, buff, offset)
n, tsNs, err := group.ReadDataAt(context.Background(), fileSize, buff, offset)
// Should return 100 (length of buffer) and no error since there are no sections
// and missing sections are filled with zeros
@@ -44,7 +47,7 @@ func TestChunkGroup_ReadDataAt_ErrorHandling(t *testing.T) {
fileSize := int64(50) // File smaller than buffer
offset := int64(0)
n, tsNs, err := group.ReadDataAt(fileSize, buff, offset)
n, tsNs, err := group.ReadDataAt(context.Background(), fileSize, buff, offset)
// Should return 50 (file size) and no error
assert.Equal(t, 50, n)
@@ -57,7 +60,7 @@ func TestChunkGroup_ReadDataAt_ErrorHandling(t *testing.T) {
fileSize := int64(50)
offset := int64(100) // Offset beyond file size
n, tsNs, err := group.ReadDataAt(fileSize, buff, offset)
n, tsNs, err := group.ReadDataAt(context.Background(), fileSize, buff, offset)
assert.Equal(t, 0, n)
assert.Equal(t, int64(0), tsNs)
@@ -80,19 +83,19 @@ func TestChunkGroup_ReadDataAt_ErrorHandling(t *testing.T) {
fileSize := int64(1000)
// Test 1: Normal operation with no sections (filled with zeros)
n, tsNs, err := group.ReadDataAt(fileSize, buff, int64(0))
n, tsNs, err := group.ReadDataAt(context.Background(), fileSize, buff, int64(0))
assert.Equal(t, 100, n, "should read full buffer")
assert.Equal(t, int64(0), tsNs, "timestamp should be zero for missing sections")
assert.NoError(t, err, "should not error for missing sections")
// Test 2: Reading beyond file size should return io.EOF immediately
n, tsNs, err = group.ReadDataAt(fileSize, buff, fileSize+1)
n, tsNs, err = group.ReadDataAt(context.Background(), fileSize, buff, fileSize+1)
assert.Equal(t, 0, n, "should not read any bytes when beyond file size")
assert.Equal(t, int64(0), tsNs, "timestamp should be zero")
assert.Equal(t, io.EOF, err, "should return io.EOF when reading beyond file size")
// Test 3: Reading at exact file boundary
n, tsNs, err = group.ReadDataAt(fileSize, buff, fileSize)
n, tsNs, err = group.ReadDataAt(context.Background(), fileSize, buff, fileSize)
assert.Equal(t, 0, n, "should not read any bytes at exact file size boundary")
assert.Equal(t, int64(0), tsNs, "timestamp should be zero")
assert.Equal(t, io.EOF, err, "should return io.EOF at file boundary")
@@ -102,6 +105,130 @@ func TestChunkGroup_ReadDataAt_ErrorHandling(t *testing.T) {
// This prevents later sections from masking earlier errors, especially
// preventing io.EOF from masking network errors or other real failures.
})
t.Run("Context Cancellation", func(t *testing.T) {
// Test 4: Context cancellation should be properly propagated through ReadDataAt
// This test verifies that the context parameter is properly threaded through
// the call chain and that cancellation checks are in place at the right points
// Test with a pre-cancelled context to ensure the cancellation is detected
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
group := &ChunkGroup{
sections: make(map[SectionIndex]*FileChunkSection),
}
buff := make([]byte, 100)
fileSize := int64(1000)
// Call ReadDataAt with the already cancelled context
n, tsNs, err := group.ReadDataAt(ctx, fileSize, buff, int64(0))
// For an empty ChunkGroup (no sections), the operation will complete successfully
// since it just fills the buffer with zeros. However, the important thing is that
// the context is properly threaded through the call chain.
// The actual cancellation would be more evident with real chunk sections that
// perform network operations.
if err != nil {
// If an error is returned, it should be a context cancellation error
assert.True(t,
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
"Expected context.Canceled or context.DeadlineExceeded, got: %v", err)
} else {
// If no error (operation completed before cancellation check),
// verify normal behavior for empty ChunkGroup
assert.Equal(t, 100, n, "should read full buffer size when no sections exist")
assert.Equal(t, int64(0), tsNs, "timestamp should be zero")
t.Log("Operation completed before context cancellation was checked - this is expected for empty ChunkGroup")
}
})
t.Run("Context Cancellation with Timeout", func(t *testing.T) {
// Test 5: Context with timeout should be respected
group := &ChunkGroup{
sections: make(map[SectionIndex]*FileChunkSection),
}
// Create a context with a very short timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
buff := make([]byte, 100)
fileSize := int64(1000)
// This should fail due to timeout
n, tsNs, err := group.ReadDataAt(ctx, fileSize, buff, int64(0))
// For this simple case with no sections, it might complete before timeout
// But if it does timeout, we should handle it properly
if err != nil {
assert.True(t,
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
"Expected context.Canceled or context.DeadlineExceeded when context times out, got: %v", err)
} else {
// If no error, verify normal behavior
assert.Equal(t, 100, n, "should read full buffer size when no sections exist")
assert.Equal(t, int64(0), tsNs, "timestamp should be zero")
}
})
}
func TestChunkGroup_SearchChunks_Cancellation(t *testing.T) {
t.Run("Context Cancellation in SearchChunks", func(t *testing.T) {
// Test that SearchChunks properly handles context cancellation
group := &ChunkGroup{
sections: make(map[SectionIndex]*FileChunkSection),
}
// Test with a pre-cancelled context
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
fileSize := int64(1000)
offset := int64(0)
whence := uint32(3) // SEEK_DATA
// Call SearchChunks with cancelled context
found, resultOffset := group.SearchChunks(ctx, offset, fileSize, whence)
// For an empty ChunkGroup, SearchChunks should complete quickly
// The main goal is to verify the context parameter is properly threaded through
// In real scenarios with actual chunk sections, context cancellation would be more meaningful
// Verify the function completes and returns reasonable values
assert.False(t, found, "should not find data in empty chunk group")
assert.Equal(t, int64(0), resultOffset, "should return 0 offset when no data found")
t.Log("SearchChunks completed with cancelled context - context threading verified")
})
t.Run("Context with Timeout in SearchChunks", func(t *testing.T) {
// Test SearchChunks with a timeout context
group := &ChunkGroup{
sections: make(map[SectionIndex]*FileChunkSection),
}
// Create a context with very short timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
fileSize := int64(1000)
offset := int64(0)
whence := uint32(3) // SEEK_DATA
// Call SearchChunks - should complete quickly for empty group
found, resultOffset := group.SearchChunks(ctx, offset, fileSize, whence)
// Verify reasonable behavior
assert.False(t, found, "should not find data in empty chunk group")
assert.Equal(t, int64(0), resultOffset, "should return 0 offset when no data found")
})
}
func TestChunkGroup_doSearchChunks(t *testing.T) {
@@ -127,7 +254,7 @@ func TestChunkGroup_doSearchChunks(t *testing.T) {
group := &ChunkGroup{
sections: tt.fields.sections,
}
gotFound, gotOut := group.doSearchChunks(tt.args.offset, tt.args.fileSize, tt.args.whence)
gotFound, gotOut := group.doSearchChunks(context.Background(), tt.args.offset, tt.args.fileSize, tt.args.whence)
assert.Equalf(t, tt.wantFound, gotFound, "doSearchChunks(%v, %v, %v)", tt.args.offset, tt.args.fileSize, tt.args.whence)
assert.Equalf(t, tt.wantOut, gotOut, "doSearchChunks(%v, %v, %v)", tt.args.offset, tt.args.fileSize, tt.args.whence)
})