Files
seaweedfs/weed/replication/sink/gcssink/gcs_sink.go
T
Chris Lu b3be2f5449 filer.backup, filer.sync: stop sharing resume checkpoints across destinations (#10934)
* filer.backup: key the checkpoint by source path and sink destination

The checkpoint id hashed only sink name + directory, so two backups to
different buckets or endpoints sharing a directory layout advanced one
checkpoint: whichever job was running pushed the shared offset forward,
and a stopped or failing job later resumed from the other's position,
silently skipping changes. Backups of different source paths to the same
destination shared a checkpoint the same way.

Each sink now reports a destination identity (endpoint or account,
bucket or container, directory) and the checkpoint is keyed by the
source path plus that identity. Reads fall back to the historical
name+directory key when the new key has no value, so existing backups
resume where they left off; writes go only to the new key.

* filer.sync: include the target path in the offset key

The offset stored on the target filer was keyed by source path and
source filer signature only, so two syncs from the same source cluster
and path to different directories on the same target cluster advanced
one shared checkpoint, and the slower one could resume past events it
never applied. The target path now participates in the key; "/" keeps
the historical form, and a sync with a non-root target path falls back
to the historical key once when its own key has no value yet.

* join checkpoint key fields with NUL so they cannot alias

A path or configuration value spelling out the separator could
concatenate two different field tuples to the same checkpoint key.
NUL cannot appear in a CLI path argument or any sane configuration
value, making the encoding injective.
2026-08-24 19:30:20 -07:00

159 lines
4.3 KiB
Go

package gcssink
import (
"context"
"fmt"
"os"
"strings"
"cloud.google.com/go/storage"
"github.com/seaweedfs/seaweedfs/weed/replication/repl_util"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/replication/sink"
"github.com/seaweedfs/seaweedfs/weed/replication/source"
"github.com/seaweedfs/seaweedfs/weed/util"
)
type GcsSink struct {
client *storage.Client
bucket string
dir string
filerSource *source.FilerSource
isIncremental bool
}
func init() {
sink.Sinks = append(sink.Sinks, &GcsSink{})
}
func (g *GcsSink) GetName() string {
return "google_cloud_storage"
}
func (g *GcsSink) GetSinkToDirectory() string {
return g.dir
}
func (g *GcsSink) GetDestinationIdentity() string {
return g.bucket + "\x00" + g.dir
}
func (g *GcsSink) IsIncremental() bool {
return g.isIncremental
}
func (g *GcsSink) Initialize(configuration util.Configuration, prefix string) error {
g.isIncremental = configuration.GetBool(prefix + "is_incremental")
return g.initialize(
configuration.GetString(prefix+"google_application_credentials"),
configuration.GetString(prefix+"bucket"),
configuration.GetString(prefix+"directory"),
)
}
func (g *GcsSink) SetSourceFiler(s *source.FilerSource) {
g.filerSource = s
}
func (g *GcsSink) initialize(google_application_credentials, bucketName, dir string) error {
g.bucket = bucketName
g.dir = dir
// Creates a client.
var clientOpts []option.ClientOption
if google_application_credentials != "" {
var data []byte
var err error
if strings.HasPrefix(google_application_credentials, "{") {
data = []byte(google_application_credentials)
} else {
googleCredentialsPath := util.ResolvePath(google_application_credentials)
data, err = os.ReadFile(googleCredentialsPath)
if err != nil {
return fmt.Errorf("failed to read credentials file %s: %v", googleCredentialsPath, err)
}
}
creds, err := google.CredentialsFromJSON(context.Background(), data, storage.ScopeFullControl)
if err != nil {
return fmt.Errorf("failed to parse credentials: %v", err)
}
httpClient := oauth2.NewClient(context.Background(), creds.TokenSource)
clientOpts = append(clientOpts, option.WithHTTPClient(httpClient), option.WithoutAuthentication())
}
client, err := storage.NewClient(context.Background(), clientOpts...)
if err != nil {
return fmt.Errorf("failed to create client with credentials \"%s\" env \"%s\": %v",
google_application_credentials, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), err)
}
g.client = client
return nil
}
func (g *GcsSink) DeleteEntry(key string, isDirectory, deleteIncludeChunks bool, signatures []int32) error {
if isDirectory {
key = key + "/"
}
if err := g.client.Bucket(g.bucket).Object(key).Delete(context.Background()); err != nil {
return fmt.Errorf("gcs delete %s/%s: %v", g.bucket, key, err)
}
return nil
}
func (g *GcsSink) CreateEntry(key string, entry *filer_pb.Entry, signatures []int32) error {
if entry.IsDirectory {
return nil
}
totalSize := filer.FileSize(entry)
chunkViews := filer.ViewFromChunks(context.Background(), g.filerSource.LookupFileId, entry.GetChunks(), 0, int64(totalSize))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wc := g.client.Bucket(g.bucket).Object(key).NewWriter(ctx)
writeFunc := func(data []byte) error {
_, writeErr := wc.Write(data)
return writeErr
}
var writeErr error
if len(entry.Content) > 0 {
content, decErr := repl_util.MaybeDecryptContent(entry.Content, entry)
if decErr != nil {
writeErr = fmt.Errorf("decrypt inline SSE content: %w", decErr)
} else {
writeErr = writeFunc(content)
}
} else {
writeErr = repl_util.CopyFromChunkViews(chunkViews, g.filerSource, writeFunc, entry)
}
if writeErr != nil {
// Cancel the context to abort the GCS upload without touching
// any existing object at this key.
cancel()
wc.Close()
return writeErr
}
return wc.Close()
}
func (g *GcsSink) UpdateEntry(key string, oldEntry *filer_pb.Entry, newParentPath string, newEntry *filer_pb.Entry, deleteIncludeChunks bool, signatures []int32) (foundExistingEntry bool, err error) {
return true, g.CreateEntry(key, newEntry, signatures)
}