mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 20:26:45 +00:00
* s3 lifecycle: bound the daily-replay subscription at the pass boundary A pass opens one meta-log subscription and 16 shard drains, then waits on all of them. Nothing told the subscription where the pass ends, so the only exit was the fan-out spotting an event past runNow — i.e. some unrelated write landing under /buckets after the pass started. On a cluster that goes quiet the reader parks in Recv, every shard drain starves on an empty channel, and Run never returns. The job sits at stage "starting" with the executor slot held and no log line, so expiry stops cluster-wide until someone restarts the worker. The pass covers (globalStartTsNs, runNow], so say that: UntilNs on the subscribe request makes the filer end the stream once it has shipped that range. The reader then closes the event channel on the way out, which is what unblocks the fan-out and the drains when the stream finishes on its own rather than by cancellation. Same fix retires the other silent hang: a reader that failed early (subscribe error, stream error) also left every drain waiting forever. * s3 lifecycle: keep a halted shard from starving the shared fan-out A drain that halts mid-stream (BLOCKED / RETRY_LATER / an RPC error on dispatch) returns while the fan-out is still routing that shard's events. After 256 of them the per-shard buffer is full and the fan-out blocks on the send, so no other shard sees another event. Run's WaitGroup never drains, and the teardown that would cancel the reader sits behind that wait — the pass wedges exactly like an idle subscription did, with one S3 hiccup as the trigger. Keep discarding the channel after runShard returns. The events are past this shard's saved cursor and get re-scanned next pass anyway. * s3 lifecycle: assert the starved shard actually made progress The fan-out test only checked that Run returned, which a version that quietly dropped the second shard's events would also satisfy. Assert the dispatch landed and the cursor moved. recordingClient gains a per-object outcome map: the two shards dispatch from separate goroutines, so pinning BLOCKED by call index was a race waiting to pick the wrong shard. * s3 lifecycle: fail the pass when the shared subscription dies Closing the event channel on reader exit is what unblocks the shard drains, but it also means a subscribe that never opened, or a stream that broke mid-pass, now ends every drain cleanly. Run logged that at V(2) and returned the shard result — so a filer failure produced a green lifecycle job that had processed nothing. Surface it as the pass error. Cursors still hold what was processed and tomorrow resumes there; what changes is that the job stops claiming success. Cancellation has to stay a non-error — the shell driver's -runtime cap is a truncated pass, not a failed one — and a canceled gRPC stream arrives as a status code, not a wrapped context.Canceled, so isCanceled checks both forms the way the rest of the tree does. * s3 lifecycle: decide reader cancellation by intent, not status code A stream we cancel and a stream the filer cancels both arrive as codes.Canceled, so classifying the reader's exit by its error let a truncated pass report success whenever the failure happened to carry a cancellation status. Intent is knowable exactly, so read that instead: the pass stops on purpose only when the caller's context ended (the shell driver's -runtime cap) or the fan-out hit the pass boundary itself. Everything else is a broken subscription and fails the pass. TestRun_ServerSideCancelFailsThePass and TestRun_CappedPassIsNotAFailure are the same codes.Canceled from the reader with opposite verdicts — the pair only passes because the decision no longer looks at the error. * s3 lifecycle: time out a subscription that stops delivering UntilNs ends a healthy stream and gRPC keepalive catches a dead connection, but neither reaches a filer that keeps answering pings while its handler has stopped producing. The pass would wait on that forever, since s3_lifecycle is the one job type with no execution timeout. Bound the wait for each response at 20 minutes, and opt into the filer's idle heartbeats so a caught-up stream proves liveness instead of looking stalled. The default sits above the filer's 15-minute metadata-gap recovery budget, so a subscriber legitimately parked on a gap is never mistaken for a stalled one. Recv is only interruptible by killing the RPC, so it moves to its own goroutine behind a per-response deadline. The timer covers only the wait on the filer — dispatch to Events happens outside it, so a slow consumer can't trip the watchdog. Approach and the 20-minute figure are from #10577 by way of comparing the two fixes; the wiring differs because the reader here ends the pass by closing its event channel rather than cancelling the fan-out. * s3 lifecycle: trim the comments added by this branch Keep the non-obvious why, drop the prose restating what the code says. * s3 lifecycle: snapshot reader intent where the reader stops Sampling ctx.Err() during teardown reads it after the drains and cursor saves have run. A reader that failed while the deadline was still live, on a pass whose teardown then outlives that deadline, was classified as an intentional stop and reported success. Sampling earlier in Run is not the fix either: before the shard wait, a legitimately capped pass has not reached its deadline yet and would be misclassified the other way. Intent belongs where the reader actually stops, so the reader goroutine records it next to the error it returns. Reported by greptile on #10578. * s3 lifecycle: cover the worker-dispatched pass with nothing due The e2e suite drives the shell command in 14 of 15 files; the one test on the real admin->worker path backdates an object, so its own delete pushes a meta-log event past the pass boundary and ends the pass. The branch where a pass has nothing to dispatch was never exercised through the worker. Cover it, asserting the pass returns on its own: no admin cancellation, and the executor slot free for the next one. This is not a regression test for the wedge. A pass used to end when any write landed past its boundary, and on a shared test cluster something usually does — the whole suite passes on the unfixed build, verified. The deterministic guards stay the dailyrun unit tests; this one would catch a pass that hangs unconditionally.
339 lines
10 KiB
Go
339 lines
10 KiB
Go
package reader
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
)
|
|
|
|
// Event is one in-shard meta-log event delivered to the router.
|
|
//
|
|
// BootstrapVersion is set only by the bucket bootstrapper when it
|
|
// expands a .versions/ directory; the meta-log path leaves it nil.
|
|
// Carries pre-computed sibling state so the router can fire
|
|
// NoncurrentDays / NewerNoncurrent without listing again.
|
|
type Event struct {
|
|
TsNs int64
|
|
Bucket string
|
|
Key string
|
|
ShardID int
|
|
OldEntry *filer_pb.Entry
|
|
NewEntry *filer_pb.Entry
|
|
NewParent string
|
|
BootstrapVersion *BootstrapVersion
|
|
}
|
|
|
|
// BootstrapVersion is the per-version state computed once per
|
|
// .versions/<key>/ directory at bootstrap time. Key fields shape
|
|
// EvaluateAction: IsLatest gates current vs. noncurrent rules,
|
|
// NoncurrentIndex gates NewerNoncurrentVersions retention,
|
|
// SuccessorModTime sets the noncurrent clock (when this version was
|
|
// replaced).
|
|
type BootstrapVersion struct {
|
|
LogicalKey string
|
|
VersionID string
|
|
IsLatest bool
|
|
IsDeleteMarker bool
|
|
NumVersions int
|
|
NoncurrentIndex int // 0 = newest noncurrent
|
|
SuccessorModTime time.Time
|
|
}
|
|
|
|
// IsDelete reports whether this event removes an entry.
|
|
func (e *Event) IsDelete() bool {
|
|
return e.NewEntry == nil && e.OldEntry != nil
|
|
}
|
|
|
|
// IsCreate reports whether this event creates an entry.
|
|
func (e *Event) IsCreate() bool {
|
|
return e.OldEntry == nil && e.NewEntry != nil
|
|
}
|
|
|
|
// Reader subscribes to the filer meta-log and emits in-range Events to a
|
|
// channel. One subscription handles a contiguous span (or arbitrary set)
|
|
// of shards via ShardPredicate; the downstream router/dispatcher consume
|
|
// events and ack-advance the per-shard cursor for matched ActionKeys
|
|
// when their actions complete.
|
|
type Reader struct {
|
|
// ShardID and ShardPredicate are alternatives — set at most one.
|
|
// ShardPredicate wins if both are populated.
|
|
ShardID int // [0, s3lifecycle.ShardCount); used when ShardPredicate is nil
|
|
ShardPredicate func(int) bool // accepts an event when true; nil falls back to ShardID equality
|
|
|
|
BucketsPath string // e.g. "/buckets"
|
|
// Cursor is the single-shard cursor used for SinceNs when StartTsNs is 0.
|
|
// Range callers pass StartTsNs directly and leave Cursor nil; SinceNs=0
|
|
// then means "subscribe from the start of the meta-log".
|
|
Cursor *Cursor
|
|
StartTsNs int64
|
|
Events chan<- *Event
|
|
|
|
// UntilTsNs ends the stream once the filer has delivered through this
|
|
// timestamp. Zero follows forever, which on an idle cluster parks a
|
|
// bounded caller in Recv until something unrelated is written.
|
|
UntilTsNs int64
|
|
|
|
// EventBudget caps how many events Run processes before returning nil.
|
|
// Zero = unbounded; the run continues until ctx cancellation or stream
|
|
// error. Used by the worker scheduler to bound a single READ task.
|
|
EventBudget int
|
|
|
|
// ReceiveTimeout bounds the wait for each response, covering a filer
|
|
// that stops producing while the transport still answers keepalives.
|
|
// Also opts into idle heartbeats so a caught-up stream stays alive.
|
|
// Keep above the filer's 15m maxGapStall — a subscriber parked on a
|
|
// gap sends nothing and is not stuck. Zero disables it.
|
|
ReceiveTimeout time.Duration
|
|
|
|
// bucketsPathSlash is BucketsPath with a guaranteed trailing slash,
|
|
// computed once on Run and reused per event to avoid recomputing the
|
|
// normalized prefix in extractBucketKey.
|
|
bucketsPathSlash string
|
|
}
|
|
|
|
// ErrReceiveTimeout: stream still open, but no events and no heartbeats.
|
|
var ErrReceiveTimeout = errors.New("reader: metadata receive timeout")
|
|
|
|
type receiveResult struct {
|
|
resp *filer_pb.SubscribeMetadataResponse
|
|
err error
|
|
}
|
|
|
|
// awaitResponse waits for the next response under ReceiveTimeout. Started
|
|
// per call, so it times the filer only — a slow Events consumer blocks in
|
|
// dispatchOne, outside this window, and can't trip the watchdog.
|
|
func (r *Reader) awaitResponse(ctx context.Context, received <-chan receiveResult) (*filer_pb.SubscribeMetadataResponse, error, bool) {
|
|
var timeout <-chan time.Time
|
|
if r.ReceiveTimeout > 0 {
|
|
timer := time.NewTimer(r.ReceiveTimeout)
|
|
defer timer.Stop()
|
|
timeout = timer.C
|
|
}
|
|
select {
|
|
case result := <-received:
|
|
return result.resp, result.err, false
|
|
case <-timeout:
|
|
return nil, nil, true
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err(), false
|
|
}
|
|
}
|
|
|
|
// Run subscribes via SubscribeMetadata starting at the configured position,
|
|
// filters to the configured shard set, and emits Events. Returns on
|
|
// ctx.Done(), io.EOF, or stream error. Caller is responsible for closing
|
|
// Events if it owns it.
|
|
func (r *Reader) Run(ctx context.Context, client filer_pb.SeaweedFilerClient, clientName string, clientID int32) error {
|
|
if r.ShardPredicate == nil {
|
|
if r.ShardID < 0 || r.ShardID >= s3lifecycle.ShardCount {
|
|
return fmt.Errorf("reader: shard_id %d out of range and no ShardPredicate", r.ShardID)
|
|
}
|
|
}
|
|
if r.Events == nil {
|
|
return errors.New("reader: nil Events channel")
|
|
}
|
|
if r.BucketsPath == "" {
|
|
return errors.New("reader: empty BucketsPath")
|
|
}
|
|
if r.ReceiveTimeout < 0 {
|
|
return fmt.Errorf("reader: negative ReceiveTimeout %v", r.ReceiveTimeout)
|
|
}
|
|
r.bucketsPathSlash = r.BucketsPath
|
|
if !strings.HasSuffix(r.bucketsPathSlash, "/") {
|
|
r.bucketsPathSlash += "/"
|
|
}
|
|
|
|
sinceNs := r.StartTsNs
|
|
if sinceNs == 0 && r.Cursor != nil {
|
|
sinceNs = r.Cursor.MinTsNs()
|
|
}
|
|
// Own context: aborting the RPC is the only way to unblock Recv.
|
|
streamCtx, cancelStream := context.WithCancel(ctx)
|
|
defer cancelStream()
|
|
stream, err := client.SubscribeMetadata(streamCtx, &filer_pb.SubscribeMetadataRequest{
|
|
ClientName: clientName,
|
|
PathPrefix: r.BucketsPath,
|
|
SinceNs: sinceNs,
|
|
UntilNs: r.UntilTsNs,
|
|
ClientId: clientID,
|
|
ClientSupportsBatching: true,
|
|
// dispatchOne drops these, but arriving at all is the point.
|
|
ClientSupportsIdleHeartbeat: r.ReceiveTimeout > 0,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("subscribe: %w", err)
|
|
}
|
|
|
|
// Buffered so a response landing as the watchdog fires doesn't strand
|
|
// this goroutine.
|
|
received := make(chan receiveResult, 1)
|
|
go func() {
|
|
for {
|
|
resp, recvErr := stream.Recv()
|
|
select {
|
|
case received <- receiveResult{resp: resp, err: recvErr}:
|
|
case <-streamCtx.Done():
|
|
return
|
|
}
|
|
if recvErr != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
processed := 0
|
|
for {
|
|
resp, recvErr, timedOut := r.awaitResponse(streamCtx, received)
|
|
if timedOut {
|
|
cancelStream() // unwind the goroutine blocked in Recv
|
|
return fmt.Errorf("%w after %s", ErrReceiveTimeout, r.ReceiveTimeout)
|
|
}
|
|
if recvErr == io.EOF {
|
|
return nil
|
|
}
|
|
if recvErr != nil {
|
|
return recvErr
|
|
}
|
|
|
|
// First event in resp is the primary; resp.Events is the batched tail.
|
|
if err := r.dispatchOne(ctx, resp, &processed); err != nil {
|
|
return err
|
|
}
|
|
for _, ev := range resp.Events {
|
|
if err := r.dispatchOne(ctx, ev, &processed); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if r.EventBudget > 0 && processed >= r.EventBudget {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Reader) dispatchOne(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, processed *int) error {
|
|
if resp == nil || resp.EventNotification == nil {
|
|
return nil
|
|
}
|
|
bucket, key, ok := r.extractBucketKey(resp)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
shardID := s3lifecycle.ShardID(bucket, key)
|
|
if r.ShardPredicate != nil {
|
|
if !r.ShardPredicate(shardID) {
|
|
return nil
|
|
}
|
|
} else if shardID != r.ShardID {
|
|
return nil
|
|
}
|
|
|
|
ev := &Event{
|
|
TsNs: resp.TsNs,
|
|
Bucket: bucket,
|
|
Key: key,
|
|
ShardID: shardID,
|
|
OldEntry: resp.EventNotification.OldEntry,
|
|
NewEntry: resp.EventNotification.NewEntry,
|
|
NewParent: resp.EventNotification.NewParentPath,
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case r.Events <- ev:
|
|
*processed++
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// extractBucketKey turns a meta-log event's path into (bucket, key) when the
|
|
// event lies under BucketsPath. Returns ok=false for events outside that
|
|
// subtree (cluster admin entries, system files, etc.) so the reader can skip
|
|
// them without engaging the routing index.
|
|
//
|
|
// The path is reconstructed as DirectoryPath/Name, where DirectoryPath comes
|
|
// from the entry context and Name from old_entry/new_entry. We prefer
|
|
// new_entry on creates/updates and old_entry on deletes; both carry the same
|
|
// Name on renames where new_parent_path differs.
|
|
func (r *Reader) extractBucketKey(resp *filer_pb.SubscribeMetadataResponse) (string, string, bool) {
|
|
notif := resp.EventNotification
|
|
dir := notif.NewParentPath
|
|
if dir == "" {
|
|
// On deletes, NewParentPath may be empty; the directory is encoded
|
|
// in resp.Directory.
|
|
dir = resp.Directory
|
|
}
|
|
var name string
|
|
switch {
|
|
case notif.NewEntry != nil:
|
|
name = notif.NewEntry.Name
|
|
case notif.OldEntry != nil:
|
|
name = notif.OldEntry.Name
|
|
default:
|
|
return "", "", false
|
|
}
|
|
|
|
// Pre-normalized prefix (BucketsPath with trailing slash) is computed
|
|
// once in Run; bucket-root events arrive as either "/buckets" or
|
|
// "/buckets/", so accept both. The fallback path mirrors Run's
|
|
// normalization for tests that call extractBucketKey directly.
|
|
prefix := r.bucketsPathSlash
|
|
if prefix == "" {
|
|
prefix = r.BucketsPath
|
|
if !strings.HasSuffix(prefix, "/") {
|
|
prefix += "/"
|
|
}
|
|
}
|
|
bare := strings.TrimSuffix(prefix, "/")
|
|
var rest string
|
|
switch {
|
|
case dir == bare || dir == prefix:
|
|
// Bucket create/delete at /buckets root: bucket name is the entry name.
|
|
if name == "" {
|
|
return "", "", false
|
|
}
|
|
return name, "", true
|
|
case strings.HasPrefix(dir, prefix):
|
|
rest = dir[len(prefix):]
|
|
default:
|
|
return "", "", false
|
|
}
|
|
// rest = "<bucket>" or "<bucket>/<sub>/<sub>..."
|
|
slash := strings.IndexByte(rest, '/')
|
|
var bucket, parentInBucket string
|
|
if slash < 0 {
|
|
bucket = rest
|
|
} else {
|
|
bucket = rest[:slash]
|
|
parentInBucket = rest[slash+1:]
|
|
}
|
|
if bucket == "" {
|
|
return "", "", false
|
|
}
|
|
if parentInBucket != "" {
|
|
return bucket, parentInBucket + "/" + name, true
|
|
}
|
|
return bucket, name, true
|
|
}
|
|
|
|
// LogStartup is a small helper for callers that want a one-line readable
|
|
// description of where the reader is starting.
|
|
func (r *Reader) LogStartup() {
|
|
sinceNs := r.StartTsNs
|
|
if sinceNs == 0 && r.Cursor != nil {
|
|
sinceNs = r.Cursor.MinTsNs()
|
|
}
|
|
if r.ShardPredicate != nil {
|
|
glog.V(1).Infof("lifecycle reader: shard=range sinceNs=%d budget=%d", sinceNs, r.EventBudget)
|
|
return
|
|
}
|
|
glog.V(1).Infof("lifecycle reader: shard=%d sinceNs=%d budget=%d",
|
|
r.ShardID, sinceNs, r.EventBudget)
|
|
}
|