mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-07 12:37:41 +01:00
* filer: fail the request, not the process, when chunks cannot be resolved CompactFileChunks and ViewFromChunks discarded the error from NonOverlappingVisibleIntervals and iterated the nil interval list it returns on failure. A CreateEntry or UpdateEntry whose context was cancelled therefore panicked in SeparateGarbageChunks and took the whole filer down, losing the metadata log it had not yet flushed. Both functions now return the error. cleanupChunks fails the request with it, the mount flush paths return it, the replication sinks and the S3 SSE range reader return it, and a ChunkStreamReader that cannot build its view fails every read with it and reports it as its SourceError instead of reading as an empty file. Fixes #11682 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * filer: propagate chunk resolution failures in scan paths NonOverlappingVisibleIntervals can fail without a cancelled context; the query engine and log-store callers discarded the error and dereferenced nil intervals. * query: propagate chunk resolution errors instead of skipping files When a manifest cannot be resolved, ReadParquetStatistics, countLiveLogRowsExcludingParquetSources, and computeLiveLogMinMax logged a warning and skipped the failed file, so queries returned understated counts and wrong min/max values. Surface the errors instead: the fast-path aggregation collector now returns a DataSourceError so the engine falls back to a full scan, and the row-count helpers return the error rather than a partial total. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
297 lines
11 KiB
Go
297 lines
11 KiB
Go
package azuresink
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/appendblob"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/remote_storage/azure"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/repl_util"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/sink"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/source"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
type AzureSink struct {
|
|
client *azblob.Client
|
|
accountName string
|
|
endpoint string
|
|
container string
|
|
dir string
|
|
filerSource *source.FilerSource
|
|
isIncremental bool
|
|
}
|
|
|
|
func init() {
|
|
sink.Sinks = append(sink.Sinks, &AzureSink{})
|
|
}
|
|
|
|
func (g *AzureSink) GetName() string {
|
|
return "azure"
|
|
}
|
|
|
|
func (g *AzureSink) GetSinkToDirectory() string {
|
|
return g.dir
|
|
}
|
|
|
|
func (g *AzureSink) GetDestinationIdentity() string {
|
|
return g.accountName + "\x00" + g.endpoint + "\x00" + g.container + "\x00" + g.dir
|
|
}
|
|
|
|
func (g *AzureSink) IsIncremental() bool {
|
|
return g.isIncremental
|
|
}
|
|
|
|
func (g *AzureSink) Initialize(configuration util.Configuration, prefix string) error {
|
|
g.isIncremental = configuration.GetBool(prefix + "is_incremental")
|
|
return g.initialize(
|
|
configuration.GetString(prefix+"account_name"),
|
|
configuration.GetString(prefix+"account_key"),
|
|
configuration.GetString(prefix+"client_id"),
|
|
configuration.GetString(prefix+"endpoint"),
|
|
configuration.GetString(prefix+"container"),
|
|
configuration.GetString(prefix+"directory"),
|
|
)
|
|
}
|
|
|
|
func (g *AzureSink) SetSourceFiler(s *source.FilerSource) {
|
|
g.filerSource = s
|
|
}
|
|
|
|
func (g *AzureSink) initialize(accountName, accountKey, clientID, endpoint, container, dir string) error {
|
|
g.accountName = accountName
|
|
g.endpoint = endpoint
|
|
g.container = container
|
|
g.dir = dir
|
|
|
|
client, err := azure.NewAzBlobClient(accountName, accountKey, clientID, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
g.client = client
|
|
|
|
// Validate that the container exists early to catch configuration errors
|
|
containerClient := client.ServiceClient().NewContainerClient(container)
|
|
ctxValidate, cancelValidate := context.WithTimeout(context.Background(), azure.DefaultAzureOpTimeout)
|
|
defer cancelValidate()
|
|
_, err = containerClient.GetProperties(ctxValidate, nil)
|
|
if err != nil {
|
|
if bloberror.HasCode(err, bloberror.ContainerNotFound) {
|
|
return fmt.Errorf("Azure container '%s' does not exist. Please create it first", container)
|
|
}
|
|
return fmt.Errorf("failed to validate Azure container '%s': %w", container, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *AzureSink) DeleteEntry(key string, isDirectory, deleteIncludeChunks bool, signatures []int32) error {
|
|
|
|
key = cleanKey(key)
|
|
|
|
if isDirectory {
|
|
key = key + "/"
|
|
}
|
|
|
|
blobClient := g.client.ServiceClient().NewContainerClient(g.container).NewBlobClient(key)
|
|
ctxDelete, cancelDelete := context.WithTimeout(context.Background(), azure.DefaultAzureOpTimeout)
|
|
defer cancelDelete()
|
|
_, err := blobClient.Delete(ctxDelete, &blob.DeleteOptions{
|
|
DeleteSnapshots: to.Ptr(blob.DeleteSnapshotsOptionTypeInclude),
|
|
})
|
|
if err != nil {
|
|
// Make delete idempotent - don't return error if blob doesn't exist
|
|
if bloberror.HasCode(err, bloberror.BlobNotFound) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("azure delete %s/%s: %w", g.container, key, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *AzureSink) CreateEntry(key string, entry *filer_pb.Entry, signatures []int32) error {
|
|
|
|
key = cleanKey(key)
|
|
|
|
if entry.IsDirectory {
|
|
return nil
|
|
}
|
|
|
|
totalSize := filer.FileSize(entry)
|
|
chunkViews, err := filer.ViewFromChunks(context.Background(), g.filerSource.LookupFileId, entry.GetChunks(), 0, int64(totalSize))
|
|
if err != nil {
|
|
return fmt.Errorf("resolve chunks of %s: %w", key, err)
|
|
}
|
|
|
|
// Create append blob client
|
|
appendBlobClient := g.client.ServiceClient().NewContainerClient(g.container).NewAppendBlobClient(key)
|
|
|
|
// Try to create the blob first (without access conditions for initial creation)
|
|
ctxCreate, cancelCreate := context.WithTimeout(context.Background(), azure.DefaultAzureOpTimeout)
|
|
defer cancelCreate()
|
|
_, err = appendBlobClient.Create(ctxCreate, nil)
|
|
|
|
needsWrite := true
|
|
freshlyCreated := false
|
|
if err != nil {
|
|
if bloberror.HasCode(err, bloberror.BlobAlreadyExists) {
|
|
// Handle existing blob - check if overwrite is needed and perform it if necessary
|
|
var handleErr error
|
|
needsWrite, handleErr = g.handleExistingBlob(appendBlobClient, key, entry, totalSize)
|
|
if handleErr != nil {
|
|
return handleErr
|
|
}
|
|
// handleExistingBlob recreates the blob when needsWrite is true
|
|
freshlyCreated = needsWrite
|
|
} else {
|
|
return fmt.Errorf("azure create append blob %s/%s: %w", g.container, key, err)
|
|
}
|
|
} else {
|
|
freshlyCreated = true
|
|
}
|
|
|
|
// If we don't need to write (blob is up-to-date), return early
|
|
if !needsWrite {
|
|
return nil
|
|
}
|
|
|
|
// cleanupOnError deletes a freshly created blob when content write fails,
|
|
// preventing empty blobs from being left behind.
|
|
cleanupOnError := func(writeErr error) error {
|
|
if !freshlyCreated {
|
|
return writeErr
|
|
}
|
|
glog.Warningf("azure sink: cleaning up empty blob %s/%s after write failure: %v", g.container, key, writeErr)
|
|
ctxCleanup, cancelCleanup := context.WithTimeout(context.Background(), azure.DefaultAzureOpTimeout)
|
|
defer cancelCleanup()
|
|
if _, delErr := appendBlobClient.Delete(ctxCleanup, nil); delErr != nil {
|
|
if !bloberror.HasCode(delErr, bloberror.BlobNotFound) {
|
|
glog.Warningf("azure sink: failed to clean up blob %s/%s: %v", g.container, key, delErr)
|
|
}
|
|
}
|
|
return writeErr
|
|
}
|
|
|
|
writeFunc := func(data []byte) error {
|
|
ctxWrite, cancelWrite := context.WithTimeout(context.Background(), azure.DefaultAzureOpTimeout)
|
|
defer cancelWrite()
|
|
_, writeErr := appendBlobClient.AppendBlock(ctxWrite, streaming.NopCloser(bytes.NewReader(data)), &appendblob.AppendBlockOptions{})
|
|
return writeErr
|
|
}
|
|
|
|
if len(entry.Content) > 0 {
|
|
content, err := repl_util.MaybeDecryptContent(entry.Content, entry)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("decrypt inline SSE content: %w", err))
|
|
}
|
|
if err := writeFunc(content); err != nil {
|
|
return cleanupOnError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := repl_util.CopyFromChunkViews(chunkViews, g.filerSource, writeFunc, entry); err != nil {
|
|
return cleanupOnError(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleExistingBlob determines whether an existing blob needs to be overwritten and performs the overwrite if necessary.
|
|
// It returns:
|
|
// - needsWrite: true if the caller should write data to the blob, false if the blob is already up-to-date
|
|
// - error: any error encountered during the operation
|
|
func (g *AzureSink) handleExistingBlob(appendBlobClient *appendblob.Client, key string, entry *filer_pb.Entry, totalSize uint64) (needsWrite bool, err error) {
|
|
// Get the blob's properties to decide whether to overwrite.
|
|
// Use a timeout to fail fast on network issues.
|
|
ctxProps, cancelProps := context.WithTimeout(context.Background(), azure.DefaultAzureOpTimeout)
|
|
defer cancelProps()
|
|
props, propErr := appendBlobClient.GetProperties(ctxProps, nil)
|
|
|
|
// Fail fast if we cannot fetch properties - we should not proceed to delete without knowing the blob state.
|
|
if propErr != nil {
|
|
return false, fmt.Errorf("azure get properties %s/%s: %w", g.container, key, propErr)
|
|
}
|
|
|
|
// Check if we can skip writing based on modification time and size.
|
|
if entry.Attributes != nil && entry.Attributes.Mtime > 0 && props.LastModified != nil && props.ContentLength != nil {
|
|
const clockSkewTolerance = int64(2) // seconds - allow small clock differences
|
|
remoteMtime := props.LastModified.Unix()
|
|
localMtime := entry.Attributes.Mtime
|
|
// Skip if remote is newer/same (within skew tolerance) and has the SAME size.
|
|
// This prevents skipping partial/corrupted files that may have a newer mtime.
|
|
if remoteMtime >= localMtime-clockSkewTolerance && *props.ContentLength == int64(totalSize) {
|
|
glog.V(2).Infof("skip overwriting %s/%s: remote is up-to-date (remote mtime: %d >= local mtime: %d, size: %d)",
|
|
g.container, key, remoteMtime, localMtime, *props.ContentLength)
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// Blob is empty or outdated - we need to delete and recreate it.
|
|
// REQUIRE ETag for conditional delete to avoid race conditions and data loss.
|
|
if props.ETag == nil {
|
|
return false, fmt.Errorf("azure blob %s/%s: missing ETag; refusing to delete without conditional", g.container, key)
|
|
}
|
|
|
|
deleteOpts := &blob.DeleteOptions{
|
|
DeleteSnapshots: to.Ptr(blob.DeleteSnapshotsOptionTypeInclude),
|
|
AccessConditions: &blob.AccessConditions{
|
|
ModifiedAccessConditions: &blob.ModifiedAccessConditions{
|
|
IfMatch: props.ETag,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Delete existing blob with conditional delete and timeout.
|
|
ctxDel, cancelDel := context.WithTimeout(context.Background(), azure.DefaultAzureOpTimeout)
|
|
defer cancelDel()
|
|
_, delErr := appendBlobClient.Delete(ctxDel, deleteOpts)
|
|
|
|
if delErr != nil {
|
|
// If the precondition fails, the blob was modified by another process after we checked it.
|
|
// Failing here is safe; replication will retry.
|
|
if bloberror.HasCode(delErr, bloberror.ConditionNotMet) {
|
|
return false, fmt.Errorf("azure blob %s/%s was modified concurrently, preventing overwrite: %w", g.container, key, delErr)
|
|
}
|
|
// Ignore BlobNotFound, as the goal is to delete it anyway.
|
|
if !bloberror.HasCode(delErr, bloberror.BlobNotFound) {
|
|
return false, fmt.Errorf("azure delete existing blob %s/%s: %w", g.container, key, delErr)
|
|
}
|
|
}
|
|
|
|
// Recreate the blob with timeout.
|
|
ctxRecreate, cancelRecreate := context.WithTimeout(context.Background(), azure.DefaultAzureOpTimeout)
|
|
defer cancelRecreate()
|
|
_, createErr := appendBlobClient.Create(ctxRecreate, nil)
|
|
|
|
if createErr != nil {
|
|
// It's possible another process recreated it after our delete.
|
|
// Failing is safe, as a retry of the whole function will handle it.
|
|
return false, fmt.Errorf("azure recreate append blob %s/%s: %w", g.container, key, createErr)
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (g *AzureSink) UpdateEntry(key string, oldEntry *filer_pb.Entry, newParentPath string, newEntry *filer_pb.Entry, deleteIncludeChunks bool, signatures []int32) (foundExistingEntry bool, err error) {
|
|
key = cleanKey(key)
|
|
return true, g.CreateEntry(key, newEntry, signatures)
|
|
}
|
|
|
|
func cleanKey(key string) string {
|
|
// Remove all leading slashes (TrimLeft handles multiple slashes, unlike TrimPrefix)
|
|
return strings.TrimLeft(key, "/")
|
|
}
|